import { app, Menu, net, protocol, session } from "electron"; import { access, mkdir } 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 { parseThemeResourceUrl } from "./application-contract.js"; import { DesktopApplicationController } from "./desktop-application-controller.js"; import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js"; import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js"; import { findMarkdownFileArgument, isMarkdownFilePath } from "./markdown-file.js"; const APP_SCHEME = "mdpdf"; const APP_HOST = "bundle"; const THEME_HOST = "theme"; const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); let applicationController: DesktopApplicationController | undefined; let pendingExternalMarkdownPath = findMarkdownFileArgument( process.argv, process.cwd() ); 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 dispatchExternalMarkdown(filePath: string) { if (!applicationController) { pendingExternalMarkdownPath = filePath; return; } void applicationController.openExternalMarkdown(filePath).catch( (error: unknown) => { console.warn("无法打开系统传入的 Markdown 文件", error); } ); } function getDesktopThemeDirectory() { return app.isPackaged ? path.join(app.getPath("userData"), "themes") : path.resolve(currentDirectory, "../../..", ".local", "themes"); } function createDesktopApplicationService() { const projectRoot = path.resolve(currentDirectory, "../../.."); return createApplicationService({ bundledRoot: app.isPackaged ? path.join(process.resourcesPath, "themes") : path.join(projectRoot, "themes"), localRoot: getDesktopThemeDirectory(), 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), session .fromPartition(DESKTOP_DOCX_PARTITION) .protocol.handle(APP_SCHEME, handleRequest) ]); } const hasSingleInstanceLock = app.requestSingleInstanceLock(); let windowStateReadyToQuit = false; if (!hasSingleInstanceLock) { app.quit(); } else { app.on("second-instance", (_event, arguments_, workingDirectory) => { const filePath = findMarkdownFileArgument( arguments_, workingDirectory ); if (filePath) { dispatchExternalMarkdown(filePath); } else if (!applicationController?.focusMostRecentWindow()) { void applicationController?.createWindow(); } }); app.on("open-file", (event, filePath) => { event.preventDefault(); if (isMarkdownFilePath(filePath)) { dispatchExternalMarkdown(filePath); } }); app.on("activate", () => { if (!applicationController?.focusMostRecentWindow()) { void applicationController?.createWindow(); } }); app.on("before-quit", (event) => { if (windowStateReadyToQuit || !applicationController) { return; } event.preventDefault(); void Promise.all([ applicationController.flushWindowState(), applicationController.closeResources() ]).finally(() => { windowStateReadyToQuit = true; app.quit(); setTimeout(() => { windowStateReadyToQuit = false; }, 1_000); }); }); app .whenReady() .then(async () => { Menu.setApplicationMenu(null); if (process.platform === "win32") { app.setAppUserModelId("com.md-to-pdf.desktop"); } const themeDirectory = getDesktopThemeDirectory(); await mkdir(themeDirectory, { recursive: true }); const applicationService = createDesktopApplicationService(); await registerApplicationProtocol(applicationService); const developmentUrl = getCommandLineValue("web-url"); const applicationUrl = developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`; applicationController = new DesktopApplicationController({ applicationService, applicationUrl, renderUrl: new URL( "/preview-frame.html?target=pdf", applicationUrl ).href, preloadPath: path.join( currentDirectory, "app-preload.cjs" ), pdfPreloadPath: path.join( currentDirectory, "pdf-preload.cjs" ), docxRenderUrl: new URL( "/preview-frame.html?target=continuous", applicationUrl ).href, desktopResourcesPath: process.resourcesPath, iconPath: getApplicationIcon(), themeDirectory, windowStatePath: path.join( app.getPath("userData"), "window-state.json" ) }); await applicationController.initialize(); const startupMarkdownPath = pendingExternalMarkdownPath; pendingExternalMarkdownPath = undefined; await applicationController.createWindow(startupMarkdownPath); }) .catch((error: unknown) => { console.error("桌面应用启动失败", error); app.quit(); }); } app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } });