diff --git a/.gitignore b/.gitignore index 389dfd9..b468c69 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ tmp/ playwright-report/ test-results/ output/ +/apps/desktop/out/ *.tsbuildinfo diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..af62c39 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,44 @@ +# Desktop 桌面端 + +`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成 +Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron +Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。 + +## 开发 + +从仓库根目录运行: + +```powershell +npm run desktop:dev +``` + +桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC +调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由 +Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP +适配器使用同一套应用服务。 + +## 构建与打包 + +```powershell +npm run build -w @md-to-pdf/desktop +npm run desktop:package +npm run make -w @md-to-pdf/desktop +``` + +版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到 +`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以 +`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名, +Windows 首次运行可能显示“未知发布者”提示。 + +当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。 +Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过 +`mdpdf://bundle/` 自定义协议加载。 + +## 安全边界 + +- 应用窗口和 PDF 窗口均关闭 Node Integration; +- 启用 Context Isolation 和 Chromium Sandbox; +- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口; +- 主进程校验 IPC 发送者和分页载荷; +- PDF 窗口使用独立内存 Session,并阻止外部网络资源; +- 应用页面使用自定义协议,不使用 `file://`。 diff --git a/apps/desktop/forge.config.cjs b/apps/desktop/forge.config.cjs new file mode 100644 index 0000000..4461a9f --- /dev/null +++ b/apps/desktop/forge.config.cjs @@ -0,0 +1,45 @@ +const path = require("node:path"); +const windowsIcon = path.resolve( + __dirname, + "../../logos/desktop/windows/app.ico" +); +const macosIcon = path.resolve( + __dirname, + "../../logos/desktop/macos/app.icns" +); +const { version } = require("../../package.json"); + +module.exports = { + buildIdentifier: `v${version}`, + packagerConfig: { + asar: true, + prune: false, + icon: process.platform === "darwin" ? macosIcon : windowsIcon, + ignore: [ + /^\/node_modules(?:\/|$)/, + /^\/out(?:\/|$)/, + /^\/src(?:\/|$)/, + /^\/tests(?:\/|$)/, + /^\/tsconfig\.json$/ + ], + extraResource: [ + path.resolve(__dirname, "../web/dist"), + path.resolve(__dirname, "../../themes"), + windowsIcon, + macosIcon + ] + }, + makers: [ + { + name: "@electron-forge/maker-squirrel", + config: { + name: "markdown_pdf_exporter", + setupIcon: windowsIcon + } + }, + { + name: "@electron-forge/maker-zip", + platforms: ["win32", "darwin"] + } + ] +}; diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..bd0e1a7 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,35 @@ +{ + "name": "@md-to-pdf/desktop", + "version": "0.4.0", + "private": true, + "productName": "Markdown PDF 导出器", + "description": "Markdown PDF 导出器桌面端", + "author": "md-to-pdf contributors", + "type": "module", + "main": "dist/main.js", + "dependencies": { + "@md-to-pdf/application": "0.4.0" + }, + "scripts": { + "build": "npm run clean && npm run build:main && npm run build:preload", + "build:main": "node scripts/build-main.mjs", + "build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs", + "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", + "dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173", + "package": "npm run build && electron-forge package", + "make": "npm run build && electron-forge make", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + }, + "devDependencies": { + "@electron-forge/cli": "7.11.2", + "@electron-forge/maker-squirrel": "7.11.2", + "@electron-forge/maker-zip": "7.11.2", + "@types/node": "^24.10.1", + "electron": "43.2.0", + "esbuild": "^0.25.0", + "typescript": "^5.9.3", + "vitest": "^4.1.10", + "wait-on": "^9.0.1" + } +} diff --git a/apps/desktop/scripts/build-main.mjs b/apps/desktop/scripts/build-main.mjs new file mode 100644 index 0000000..c8de872 --- /dev/null +++ b/apps/desktop/scripts/build-main.mjs @@ -0,0 +1,16 @@ +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/main.ts"], + bundle: true, + platform: "node", + format: "esm", + external: ["electron"], + outfile: "dist/main.js", + banner: { + js: [ + 'import { createRequire as __mdToPdfCreateRequire } from "node:module";', + "const require = __mdToPdfCreateRequire(import.meta.url);" + ].join("\n") + } +}); diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts new file mode 100644 index 0000000..2424839 --- /dev/null +++ b/apps/desktop/src/app-preload.ts @@ -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) +}); diff --git a/apps/desktop/src/application-contract.ts b/apps/desktop/src/application-contract.ts new file mode 100644 index 0000000..e8200f7 --- /dev/null +++ b/apps/desktop/src/application-contract.ts @@ -0,0 +1,53 @@ +import type { MarkdownRenderRequest } from "@md-to-pdf/application"; + +function isRecord(value: unknown): value is Record { + 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("/") + }; +} diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts new file mode 100644 index 0000000..e00c2dd --- /dev/null +++ b/apps/desktop/src/channels.ts @@ -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"; diff --git a/apps/desktop/src/electron-pdf-generator.ts b/apps/desktop/src/electron-pdf-generator.ts new file mode 100644 index 0000000..c96fb1c --- /dev/null +++ b/apps/desktop/src/electron-pdf-generator.ts @@ -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( + operation: Promise, + timeoutMs: number, + message: string +) { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_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 | 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((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((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 分页失败" + ) + ); + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 0000000..b2d5627 --- /dev/null +++ b/apps/desktop/src/main.ts @@ -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(); + } +}); diff --git a/apps/desktop/src/pdf-contract.ts b/apps/desktop/src/pdf-contract.ts new file mode 100644 index 0000000..7de4007 --- /dev/null +++ b/apps/desktop/src/pdf-contract.ts @@ -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 { + return typeof value === "object" && value !== null; +} + +function readString( + record: Record, + 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; +} diff --git a/apps/desktop/src/pdf-preload.ts b/apps/desktop/src/pdf-preload.ts new file mode 100644 index 0000000..356f670 --- /dev/null +++ b/apps/desktop/src/pdf-preload.ts @@ -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; + +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) +}); diff --git a/apps/desktop/tests/pdf-contract.test.ts b/apps/desktop/tests/pdf-contract.test.ts new file mode 100644 index 0000000..46aa2da --- /dev/null +++ b/apps/desktop/tests/pdf-contract.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + createPagedDocumentPayload, + defaultExportConfig +} from "@md-to-pdf/core"; +import { + createElectronPdfPrintOptions, + isAllowedPdfRuntimeUrl, + parsePagedDocumentPayload +} from "../src/pdf-contract.js"; +import { + parseMarkdownRenderRequest, + parseThemeId, + parseThemeResourceUrl +} from "../src/application-contract.js"; + +const payload = createPagedDocumentPayload({ + document: { + rendererVersion: 1, + articleHtml: '

测试

', + bodyHtml: "

测试

", + metadata: { + title: "测试", + author: "", + subject: "", + keywords: [], + language: "zh-CN" + }, + features: ["mermaid"], + warnings: [] + }, + fileName: "测试.md", + themeCss: "#write { color: black; }", + exportConfig: defaultExportConfig +}); + +describe("桌面 PDF 载荷", () => { + it("校验并保留合法的共享分页载荷", () => { + expect(parsePagedDocumentPayload(payload)).toEqual(payload); + }); + + it("拒绝无效功能标识", () => { + expect(() => + parsePagedDocumentPayload({ + ...payload, + features: ["remote-script"] + }) + ).toThrow(); + }); + + it("映射为与 Playwright 对齐的 Electron 打印参数", () => { + expect( + createElectronPdfPrintOptions(payload, { + pageCount: 3, + echartsErrors: [], + mermaidErrors: [], + timings: { + setupMs: 0, + echartsMs: 0, + echartsFitMs: 0, + mermaidMs: 0, + mermaidFitMs: 0, + mermaidConversionMs: 0, + resourceWaitMs: 0, + paginationMs: 0, + finalizeMs: 0, + totalMs: 0 + } + }) + ).toEqual({ + displayHeaderFooter: false, + margins: { top: 0, right: 0, bottom: 0, left: 0 }, + pageRanges: "1-3", + preferCSSPageSize: true, + printBackground: true, + scale: 1 + }); + }); +}); + +describe("桌面 PDF 网络边界", () => { + const renderUrl = + "http://localhost:5173/preview-frame.html?target=pdf"; + + it("允许同源和内嵌资源", () => { + expect( + isAllowedPdfRuntimeUrl( + "http://localhost:5173/assets/runtime.js", + renderUrl + ) + ).toBe(true); + expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl)) + .toBe(true); + expect( + isAllowedPdfRuntimeUrl( + "mdpdf://theme/typora-like/font.woff2", + renderUrl + ) + ).toBe(true); + }); + + it("拒绝外部网络资源", () => { + expect( + isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl) + ).toBe(false); + }); +}); + +describe("桌面应用服务 IPC 载荷", () => { + it("只提取 Markdown 渲染所需字段", () => { + expect( + parseMarkdownRenderRequest({ + markdown: "# 测试", + language: "zh-CN", + ignored: true + }) + ).toEqual({ + markdown: "# 测试", + language: "zh-CN" + }); + }); + + it("校验主题 ID 与主题资源地址", () => { + expect(parseThemeId("typora-like")).toBe("typora-like"); + expect( + parseThemeResourceUrl( + "mdpdf://theme/typora-like/fonts/test.woff2" + ) + ).toEqual({ + themeId: "typora-like", + assetPath: "fonts/test.woff2" + }); + expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效"); + expect( + parseThemeResourceUrl("mdpdf://bundle/index.html") + ).toBeUndefined(); + }); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..5ae66da --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "types": [ + "node" + ], + "noEmit": true + }, + "include": [ + "src", + "tests" + ] +} diff --git a/apps/server/package.json b/apps/server/package.json index b4a94bf..1caf88a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { + "@md-to-pdf/application": "0.4.0", "@md-to-pdf/core": "0.1.0", "@md-to-pdf/renderer": "0.1.0", "fastify": "^5.6.2", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index fc6fb48..f16820d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -10,10 +10,11 @@ import { supportedPaperFormats } from "@md-to-pdf/core"; import { - MarkdownDocumentParseError, - RENDERER_VERSION, - renderMarkdown -} from "@md-to-pdf/renderer"; + ApplicationRequestError, + createApplicationService, + type ApplicationService +} from "@md-to-pdf/application"; +import { RENDERER_VERSION } from "@md-to-pdf/renderer"; import { createPdfGenerator, PdfEngineClosedError, @@ -21,7 +22,6 @@ import { PdfRenderTimeoutError, type PdfGenerator } from "./pdf-engine.js"; -import { createThemeRegistry } from "./theme-registry.js"; interface RenderRequestBody { markdown?: unknown; @@ -34,66 +34,6 @@ interface PdfRequestBody extends RenderRequestBody { } const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); -const maximumMarkdownLength = 1_500_000; - -function validateMarkdownRequest( - body: RenderRequestBody | undefined -): - | { markdown: string; language?: string } - | { statusCode: 400 | 413; error: string; message: string } { - const { markdown, language } = body ?? {}; - - if (typeof markdown !== "string") { - return { - statusCode: 400, - error: "INVALID_MARKDOWN", - message: "markdown 必须是字符串" - }; - } - if (markdown.length > maximumMarkdownLength) { - return { - statusCode: 413, - error: "MARKDOWN_TOO_LARGE", - message: "Markdown 内容不能超过 1.5 MB" - }; - } - if (language !== undefined && typeof language !== "string") { - return { - statusCode: 400, - error: "INVALID_LANGUAGE", - message: "language 必须是字符串" - }; - } - - return { - markdown, - ...(typeof language === "string" ? { language } : {}) - }; -} - -function renderMarkdownRequest( - markdown: string, - language: string | undefined -) { - try { - return { - success: true as const, - document: renderMarkdown(markdown, { - ...(language ? { language } : {}) - }) - }; - } catch (error) { - if (error instanceof MarkdownDocumentParseError) { - return { - success: false as const, - error: error.code, - message: error.message - }; - } - throw error; - } -} - function encodeRfc5987(value: string) { return encodeURIComponent(value).replace( /[!'()*]/g, @@ -127,6 +67,7 @@ export interface BuildAppOptions { logger?: boolean; pdfGenerator?: PdfGenerator; prewarmPdfBrowser?: boolean; + applicationService?: ApplicationService; } function milliseconds(value: number) { @@ -169,12 +110,14 @@ export function buildApp(options: BuildAppOptions = {}) { logger: options.logger ?? true, bodyLimit: 2 * 1024 * 1024 }); - const themes = createThemeRegistry({ - bundledRoot: resolve(projectRoot, "themes"), - localRoot: - options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), - onWarning: (message) => app.log.warn(message) - }); + const applicationService = + options.applicationService ?? + createApplicationService({ + bundledRoot: resolve(projectRoot, "themes"), + localRoot: + options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), + onWarning: (message) => app.log.warn(message) + }); const pdfGenerator = options.pdfGenerator ?? createPdfGenerator(); @@ -213,23 +156,16 @@ export function buildApp(options: BuildAppOptions = {}) { planned: [] })); - app.get("/api/themes", async () => ({ - themes: (await themes.list()).map(({ manifest, source }) => ({ - id: manifest.id, - name: manifest.name, - version: manifest.version, - description: manifest.description, - bundled: manifest.bundled, - source - })) - })); + app.get("/api/themes", async () => + applicationService.listThemes() + ); app.get<{ Params: { themeId: string } }>( "/api/themes/:themeId/css", async (request, reply) => { const { themeId } = request.params; - const css = await themes.getCss(themeId); + const css = await applicationService.getThemeCss(themeId); if (css === undefined) { return reply.code(404).send({ error: "THEME_NOT_FOUND", @@ -248,7 +184,7 @@ export function buildApp(options: BuildAppOptions = {}) { "/api/themes/:themeId/assets/*", async (request, reply) => { try { - const asset = await themes.getAsset( + const asset = await applicationService.getThemeAsset( request.params.themeId, request.params["*"] ); @@ -277,25 +213,17 @@ export function buildApp(options: BuildAppOptions = {}) { app.post<{ Body: RenderRequestBody }>( "/api/render", async (request, reply) => { - const validated = validateMarkdownRequest(request.body); - if ("statusCode" in validated) { - return reply.code(validated.statusCode).send({ - error: validated.error, - message: validated.message - }); + try { + return applicationService.render(request.body ?? {}); + } catch (error) { + if (error instanceof ApplicationRequestError) { + return reply.code(error.statusCode).send({ + error: error.code, + message: error.message + }); + } + throw error; } - - const rendered = renderMarkdownRequest( - validated.markdown, - validated.language - ); - if (!rendered.success) { - return reply.code(400).send({ - error: rendered.error, - message: rendered.message - }); - } - return rendered.document; } ); @@ -303,14 +231,6 @@ export function buildApp(options: BuildAppOptions = {}) { "/api/pdf", async (request, reply) => { const requestStartedAt = performance.now(); - const validated = validateMarkdownRequest(request.body); - if ("statusCode" in validated) { - return reply.code(validated.statusCode).send({ - error: validated.error, - message: validated.message - }); - } - const parsedConfig = exportConfigSchema.safeParse( request.body?.exportConfig ); @@ -333,7 +253,9 @@ export function buildApp(options: BuildAppOptions = {}) { } const themeStartedAt = performance.now(); - const themeCss = await themes.getCss(parsedConfig.data.themeId); + const themeCss = await applicationService.getThemeCss( + parsedConfig.data.themeId + ); const themeMs = performance.now() - themeStartedAt; if (themeCss === undefined) { return reply.code(404).send({ @@ -343,18 +265,19 @@ export function buildApp(options: BuildAppOptions = {}) { } const markdownStartedAt = performance.now(); - const renderedResult = renderMarkdownRequest( - validated.markdown, - validated.language - ); - const markdownMs = performance.now() - markdownStartedAt; - if (!renderedResult.success) { - return reply.code(400).send({ - error: renderedResult.error, - message: renderedResult.message - }); + let rendered; + try { + rendered = applicationService.render(request.body ?? {}); + } catch (error) { + if (error instanceof ApplicationRequestError) { + return reply.code(error.statusCode).send({ + error: error.code, + message: error.message + }); + } + throw error; } - const rendered = renderedResult.document; + const markdownMs = performance.now() - markdownStartedAt; try { const generated = await pdfGenerator.generate( diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 6631a15..f732c2d 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -8,7 +8,7 @@ import { createPdfContentDisposition, createPdfFileName } from "../src/app.js"; -import { createThemeRegistry } from "../src/theme-registry.js"; +import { createThemeRegistry } from "@md-to-pdf/application"; import { PdfEngineOverloadedError, type PdfGenerator diff --git a/apps/web/index.html b/apps/web/index.html index 29764bb..3992f2a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,6 +2,18 @@ + + + + +