feat: 发布 v0.4.0 桌面端

This commit is contained in:
SkyJourney
2026-07-27 20:56:39 +08:00
parent 92a5bfd016
commit 4d60741f5a
90 changed files with 10431 additions and 223 deletions
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
getThemeCss: (themeId: string) =>
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
});
+53
View File
@@ -0,0 +1,53 @@
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function parseMarkdownRenderRequest(
value: unknown
): MarkdownRenderRequest {
if (!isRecord(value)) {
return {};
}
return {
markdown: value.markdown,
language: value.language
};
}
export function parseThemeId(value: unknown) {
if (
typeof value !== "string" ||
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
) {
throw new Error("主题 ID 无效");
}
return value;
}
export function parseThemeResourceUrl(requestUrl: string) {
const url = new URL(requestUrl);
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
return undefined;
}
let segments: string[];
try {
segments = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
throw new Error("主题资源地址无效");
}
const [unsafeThemeId, ...assetSegments] = segments;
if (!unsafeThemeId || assetSegments.length === 0) {
throw new Error("主题资源地址无效");
}
return {
themeId: parseThemeId(unsafeThemeId),
assetPath: assetSegments.join("/")
};
}
+18
View File
@@ -0,0 +1,18 @@
export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS =
"md-to-pdf:desktop:get-theme-css";
export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF =
"md-to-pdf:desktop:save-pdf";
export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER =
"md-to-pdf:desktop-pdf:render";
export const PDF_RUNTIME_COMPLETE =
"md-to-pdf:desktop-pdf:complete";
export const PDF_RUNTIME_FAIL =
"md-to-pdf:desktop-pdf:fail";
+342
View File
@@ -0,0 +1,342 @@
import {
BrowserWindow,
ipcMain,
type IpcMainEvent
} from "electron";
import { performance } from "node:perf_hooks";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl
} from "./pdf-contract.js";
export const DESKTOP_PDF_PARTITION =
"md-to-pdf-desktop-pdf";
export interface ElectronPdfGenerationResult {
pdf: Buffer;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: {
documentRenderMs: number;
pdfPrintMs: number;
totalMs: number;
};
}
export interface ElectronPdfGeneratorOptions {
renderUrl: string;
preloadPath: string;
timeoutMs?: number;
}
interface PendingRender {
requestId: number;
resolve: (result: PagedDocumentRenderResult) => void;
reject: (reason: Error) => void;
}
async function waitWithTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(message)),
timeoutMs
);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class ElectronPdfGenerator {
private readonly renderUrl: string;
private readonly preloadPath: string;
private readonly timeoutMs: number;
private windowPromise: Promise<BrowserWindow> | undefined;
private renderWindow: BrowserWindow | undefined;
private pendingRender: PendingRender | undefined;
private nextRequestId = 1;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronPdfGeneratorOptions) {
this.renderUrl = options.renderUrl;
this.preloadPath = options.preloadPath;
this.timeoutMs = options.timeoutMs ?? 60_000;
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
}
generate(payload: PagedDocumentPayload) {
if (this.closed) {
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
}
const operation = this.queue.then(() => this.generateNow(payload));
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
ipcMain.removeListener(
PDF_RUNTIME_COMPLETE,
this.handleRenderComplete
);
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
this.pendingRender = undefined;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
this.renderWindow = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async generateNow(payload: PagedDocumentPayload) {
const startedAt = performance.now();
const window = await this.getWindow();
const documentRenderStartedAt = performance.now();
const renderResult = await this.requestRender(window, payload);
const documentRenderMs =
performance.now() - documentRenderStartedAt;
await window.webContents.executeJavaScript(
"new Promise((resolve) => setTimeout(resolve, 0))"
);
const pdfPrintStartedAt = performance.now();
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 50));
let pdf: Buffer;
try {
pdf = await waitWithTimeout(
window.webContents.printToPDF(
createElectronPdfPrintOptions(payload, renderResult)
),
this.timeoutMs,
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
);
} catch (error) {
if (!window.isDestroyed()) {
window.destroy();
}
throw error;
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
return {
pdf,
pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
timings: {
documentRenderMs,
pdfPrintMs,
totalMs: performance.now() - startedAt
}
} satisfies ElectronPdfGenerationResult;
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_PDF_PARTITION,
preload: this.preloadPath
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
if (this.windowPromise) {
this.windowPromise = undefined;
}
this.renderWindow = undefined;
this.pendingRender?.reject(
new Error("桌面 PDF 渲染窗口已关闭")
);
this.pendingRender = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("桌面 PDF 分页运行时启动超时"));
}, this.timeoutMs);
const handleReady = (event: IpcMainEvent) => {
if (event.sender !== window.webContents) {
return;
}
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
};
ipcMain.on(PDF_RUNTIME_READY, handleReady);
});
try {
await Promise.all([window.loadURL(this.renderUrl), ready]);
window.webContents.setZoomFactor(1);
this.renderWindow = window;
return window;
} catch (error) {
window.destroy();
throw error;
}
}
private requestRender(
window: BrowserWindow,
payload: PagedDocumentPayload
) {
if (this.pendingRender) {
throw new Error("桌面 PDF 渲染器状态异常");
}
const requestId = this.nextRequestId++;
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRender?.requestId === requestId) {
this.pendingRender = undefined;
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
if (!window.isDestroyed()) {
window.destroy();
}
}
}, this.timeoutMs);
this.pendingRender = {
requestId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
};
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
});
}
private readonly handleRenderComplete = (
event: IpcMainEvent,
requestId: number,
result: PagedDocumentRenderResult
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.resolve(result);
};
private readonly handleRenderFailure = (
event: IpcMainEvent,
requestId: number,
message: string
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.reject(
new Error(
typeof message === "string" ? message : "桌面 PDF 分页失败"
)
);
};
}
+349
View File
@@ -0,0 +1,349 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
net,
protocol,
session,
type IpcMainInvokeEvent
} from "electron";
import { access, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
type ApplicationService
} from "@md-to-pdf/application";
import {
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl
} from "./application-contract.js";
import {
DESKTOP_PDF_PARTITION,
ElectronPdfGenerator
} from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
codeCache: true,
corsEnabled: true
}
}
]);
function getCommandLineValue(name: string) {
const prefix = `--${name}=`;
return process.argv
.find((argument) => argument.startsWith(prefix))
?.slice(prefix.length);
}
function getWebRoot() {
return app.isPackaged
? path.join(process.resourcesPath, "dist")
: path.resolve(currentDirectory, "../../web/dist");
}
function getApplicationIcon() {
if (app.isPackaged) {
return path.join(
process.resourcesPath,
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
const projectRoot = path.resolve(currentDirectory, "../../..");
return path.join(
projectRoot,
"logos",
"desktop",
process.platform === "darwin" ? "macos" : "windows",
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
function encodeThemeAssetPath(assetPath: string) {
return assetPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
: path.join(projectRoot, "themes"),
localRoot: app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.join(projectRoot, ".local", "themes"),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
onWarning: (message) => console.warn(message)
});
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function registerApplicationProtocol(
applicationService: ApplicationService
) {
const webRoot = getWebRoot();
const handleRequest = async (request: Request) => {
const url = new URL(request.url);
if (url.host === THEME_HOST) {
try {
const resource = parseThemeResourceUrl(request.url);
if (!resource) {
return new Response("Not found", { status: 404 });
}
const asset = await applicationService.getThemeAsset(
resource.themeId,
resource.assetPath
);
if (!asset) {
return new Response("Not found", { status: 404 });
}
return new Response(asset.content, {
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=300",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
}
});
} catch {
return new Response("Not found", { status: 404 });
}
}
if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 });
}
let relativePath: string;
try {
relativePath =
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
"index.html";
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(target).href);
};
await Promise.all([
protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest)
]);
}
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== allowedOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
async function createApplication(
applicationService: ApplicationService
) {
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
const renderUrl = new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href;
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
}
});
lockDownWindow(window, new URL(applicationUrl).origin);
const pdfGenerator = new ElectronPdfGenerator({
renderUrl,
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
});
const validateSender = (event: IpcMainInvokeEvent) =>
event.sender === window.webContents &&
event.senderFrame !== null &&
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest)
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
return applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const css = await applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 请求");
}
const payload = parsePagedDocumentPayload(unsafePayload);
const result = await pdfGenerator.generate(payload);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 保存请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(window, {
title: "导出 PDF",
defaultPath: path.join(app.getPath("documents"), suggestedName),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
window.once("ready-to-show", () => window.show());
window.on("closed", () => {
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
void pdfGenerator.close();
});
await window.loadURL(applicationUrl);
}
app
.whenReady()
.then(async () => {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
await createApplication(applicationService);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
+124
View File
@@ -0,0 +1,124 @@
import {
exportConfigSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
} from "@md-to-pdf/core";
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(
record: Record<string, unknown>,
key: string,
maximum: number
) {
const value = record[key];
if (typeof value !== "string" || value.length > maximum) {
throw new Error(`${key} 无效`);
}
return value;
}
function readStringArray(value: unknown, key: string, maximum: number) {
if (
!Array.isArray(value) ||
value.length > maximum ||
value.some((item) => typeof item !== "string")
) {
throw new Error(`${key} 无效`);
}
return value as string[];
}
export function parsePagedDocumentPayload(
value: unknown
): PagedDocumentPayload {
if (!isRecord(value) || !isRecord(value.metadata)) {
throw new Error("PDF 分页载荷无效");
}
const metadata = value.metadata;
const features = readStringArray(value.features, "features", 20);
const parsedFeatures = features.map((feature) =>
themeFeatureSchema.parse(feature)
);
return {
articleHtml: readString(
value,
"articleHtml",
MAX_ARTICLE_HTML_LENGTH
),
fileName: readString(value, "fileName", 500),
metadata: {
title: readString(metadata, "title", 500),
author: readString(metadata, "author", 500),
subject: readString(metadata, "subject", 1_000),
keywords: readStringArray(
metadata.keywords,
"metadata.keywords",
100
),
language: readString(metadata, "language", 50)
},
features: parsedFeatures,
themeCss: readString(
value,
"themeCss",
MAX_THEME_CSS_LENGTH
),
exportConfig: exportConfigSchema.parse(value.exportConfig)
};
}
export interface ElectronPdfPrintOptions {
displayHeaderFooter: false;
margins: {
top: number;
right: number;
bottom: number;
left: number;
};
pageRanges: string;
preferCSSPageSize: true;
printBackground: boolean;
scale: number;
}
export function createElectronPdfPrintOptions(
payload: PagedDocumentPayload,
renderResult: PagedDocumentRenderResult
): ElectronPdfPrintOptions {
return {
displayHeaderFooter: false,
margins: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
preferCSSPageSize: true,
printBackground: payload.exportConfig.print.background,
scale: payload.exportConfig.print.scale
};
}
export function isAllowedPdfRuntimeUrl(
requestUrl: string,
renderUrl: string
) {
const requested = new URL(requestUrl);
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true;
}
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
return true;
}
return requested.origin === new URL(renderUrl).origin;
}
+41
View File
@@ -0,0 +1,41 @@
import {
contextBridge,
ipcRenderer,
type IpcRendererEvent
} from "electron";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
type RenderListener = (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void;
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
onRender: (listener: RenderListener) => {
const handler = (
_event: IpcRendererEvent,
requestId: number,
payload: PagedDocumentPayload
) => {
void listener(requestId, payload);
};
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
},
complete: (
requestId: number,
result: PagedDocumentRenderResult
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
fail: (requestId: number, message: string) =>
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
});