feat: 发布 v0.4.0 桌面端
This commit is contained in:
@@ -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)
|
||||
});
|
||||
@@ -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("/")
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 分页失败"
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
Reference in New Issue
Block a user