Files
MorphDoc/apps/desktop/src/desktop-application-controller.ts
T

1127 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
app,
BrowserWindow,
dialog,
ipcMain,
screen,
shell,
type IpcMainInvokeEvent,
type Rectangle
} from "electron";
import {
mkdir,
realpath,
stat,
writeFile
} from "node:fs/promises";
import path from "node:path";
import {
DocxExportService,
DocxThemeTokenService,
MAXIMUM_MARKDOWN_LENGTH,
readDocxExportRuntimeLimits,
type ApplicationService
} from "@md-to-pdf/application";
import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GENERATE_PDF,
DESKTOP_GET_DOCX_CAPABILITY,
DESKTOP_GET_THEME_CSS,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SAVE_PDF,
DESKTOP_EXPORT_DOCX,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED
} from "./channels.js";
import {
toDesktopDocxExportFailure,
type DesktopDocxExportOutcome
} from "./docx-contract.js";
import { saveDesktopDocx } from "./desktop-docx-save.js";
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
import {
parseMarkdownRenderRequest,
parseThemeId
} from "./application-contract.js";
import { ElectronPdfGenerator } from "./electron-pdf-generator.js";
import { resolveDesktopDocumentLinkAction } from "./desktop-document-link.js";
import {
isMarkdownFilePath,
createMarkdownDocumentKey,
readMarkdownFileSnapshot,
type MarkdownFileSnapshot
} from "./markdown-file.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
import {
loadWindowState,
resolveWindowBounds,
saveWindowState,
type WindowState
} from "./window-state.js";
import { getWindowCloseDecision } from "./window-close-policy.js";
import { SaveLocationStore } from "./save-location-state.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
const CASCADE_OFFSET = 32;
const FOCUS_CHECK_SUPPRESSION_MS = 800;
type MarkdownOpenReason = "open" | "reload" | "replace";
interface PendingOpenedMarkdown {
snapshot: MarkdownFileSnapshot;
reason: MarkdownOpenReason;
}
interface WindowSession {
window: BrowserWindow;
dirty: boolean;
allowClose: boolean;
closeRequestPending: boolean;
currentFilePath: string | undefined;
documentKey: string | undefined;
documentRoot: string | undefined;
baselineHash: string | undefined;
pendingOpenedMarkdown: PendingOpenedMarkdown | undefined;
focusCheckRunning: boolean;
suppressFocusCheckUntil: number;
}
export interface DesktopApplicationControllerOptions {
applicationService: ApplicationService;
applicationUrl: string;
renderUrl: string;
preloadPath: string;
pdfPreloadPath: string;
docxRenderUrl: string;
desktopResourcesPath: string;
iconPath: string;
themeDirectory: string;
windowStatePath: string;
saveLocationStatePath: string;
documentsDirectory: string;
}
function focusWindow(window: BrowserWindow) {
if (window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
export class DesktopApplicationController {
readonly #applicationService: ApplicationService;
readonly #applicationUrl: string;
readonly #applicationOrigin: string;
readonly #preloadPath: string;
readonly #iconPath: string;
readonly #themeDirectory: string;
readonly #windowStatePath: string;
readonly #saveLocationStore: SaveLocationStore;
readonly #pdfGenerator: ElectronPdfGenerator;
readonly #docxExportService: DocxExportService;
readonly #docxMediaEngine: ElectronDocxMediaEngine;
readonly #sessions = new Map<number, WindowSession>();
readonly #documentSessions = new Map<string, WindowSession>();
#primarySession: WindowSession | undefined;
#savedWindowState: WindowState | undefined;
#saveWindowStateTimer: NodeJS.Timeout | undefined;
#pendingWindowStateWrite = Promise.resolve();
#latestWindowState: WindowState | undefined;
constructor(options: DesktopApplicationControllerOptions) {
this.#applicationService = options.applicationService;
this.#applicationUrl = options.applicationUrl;
this.#applicationOrigin = new URL(options.applicationUrl).origin;
this.#preloadPath = options.preloadPath;
this.#iconPath = options.iconPath;
this.#themeDirectory = options.themeDirectory;
this.#windowStatePath = options.windowStatePath;
this.#saveLocationStore = new SaveLocationStore(
options.saveLocationStatePath,
options.documentsDirectory
);
this.#pdfGenerator = new ElectronPdfGenerator({
renderUrl: options.renderUrl,
preloadPath: options.pdfPreloadPath
});
const pandocRuntime = new PandocRuntime({
desktopResourcesPath: options.desktopResourcesPath
});
this.#docxMediaEngine = new ElectronDocxMediaEngine({
renderUrl: options.docxRenderUrl
});
this.#docxExportService = new DocxExportService({
application: this.#applicationService,
runtime: pandocRuntime,
converter: new PandocDocxConverter({
runtime: pandocRuntime,
luaFilterUrl: new URL(
"./docx-media-filter.lua",
import.meta.url
)
}),
themeTokens: new DocxThemeTokenService(
this.#docxMediaEngine
),
limits: readDocxExportRuntimeLimits()
});
}
async initialize() {
const [savedWindowState] = await Promise.all([
loadWindowState(this.#windowStatePath),
this.#saveLocationStore.initialize()
]);
this.#savedWindowState = savedWindowState;
this.#registerIpcHandlers();
}
async #recordSuccessfulFile(filePath: string) {
await this.#saveLocationStore
.recordSuccessfulFile(filePath)
.catch((error: unknown) => {
console.warn("无法持久化最近保存目录", error);
});
}
async createWindow(filePath?: string) {
let pendingSnapshot: MarkdownFileSnapshot | undefined;
if (filePath) {
pendingSnapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(
pendingSnapshot.documentKey
);
if (existing) {
focusWindow(existing.window);
return existing.window;
}
}
return this.#createWindow(pendingSnapshot);
}
async #createWindow(
pendingSnapshot?: MarkdownFileSnapshot,
startBlank = false
) {
const isPrimary = !this.#primarySession;
const restoredWindowState = isPrimary
? this.#latestWindowState ?? this.#savedWindowState
: undefined;
const initialBounds = this.#resolveInitialBounds(isPrimary);
const window = new BrowserWindow({
title: `${__APP_WINDOW_TITLE__} v${app.getVersion()}`,
icon: this.#iconPath,
...initialBounds,
minWidth: Math.min(
MINIMUM_WINDOW_SIZE.width,
initialBounds.width
),
minHeight: Math.min(
MINIMUM_WINDOW_SIZE.height,
initialBounds.height
),
autoHideMenuBar: true,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.#preloadPath
}
});
const session: WindowSession = {
window,
dirty: false,
allowClose: false,
closeRequestPending: false,
currentFilePath: undefined,
documentKey: undefined,
documentRoot: undefined,
baselineHash: undefined,
pendingOpenedMarkdown: pendingSnapshot
? { snapshot: pendingSnapshot, reason: "open" }
: undefined,
focusCheckRunning: false,
suppressFocusCheckUntil: 0
};
this.#sessions.set(window.webContents.id, session);
if (isPrimary) {
this.#primarySession = session;
this.#captureWindowState();
this.#bindPrimaryWindowState(session);
}
this.#lockDownWindow(window);
this.#bindWindowLifecycle(session);
window.once("ready-to-show", () => {
if (
isPrimary &&
restoredWindowState?.maximized &&
!window.isDestroyed()
) {
window.maximize();
}
window.show();
});
const targetUrl = new URL(this.#applicationUrl);
if (startBlank) {
targetUrl.searchParams.set("new", "1");
}
await window.loadURL(targetUrl.href);
return window;
}
async openExternalMarkdown(filePath: string) {
const snapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(snapshot.documentKey);
if (existing) {
focusWindow(existing.window);
return;
}
await this.#createWindow(snapshot);
}
focusMostRecentWindow() {
const focused = BrowserWindow.getFocusedWindow();
const focusedSession = focused
? [...this.#sessions.values()].find(
(session) => session.window === focused
)
: undefined;
if (focusedSession) {
focusWindow(focusedSession.window);
return true;
}
const session = [...this.#sessions.values()].at(-1);
if (!session) {
return false;
}
focusWindow(session.window);
return true;
}
async flushWindowState() {
if (this.#saveWindowStateTimer) {
clearTimeout(this.#saveWindowStateTimer);
this.#saveWindowStateTimer = undefined;
}
this.#captureWindowState();
await this.#persistWindowState();
}
async closeResources() {
await Promise.all([
this.#pdfGenerator.close(),
this.#docxExportService.close()
]);
await this.#docxMediaEngine.close();
}
#resolveInitialBounds(isPrimary: boolean): Rectangle {
const displays = screen
.getAllDisplays()
.map((display) => display.workArea);
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
if (isPrimary) {
return resolveWindowBounds(
(this.#latestWindowState ?? this.#savedWindowState)?.bounds,
displays,
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
}
const reference =
BrowserWindow.getFocusedWindow() ??
[...this.#sessions.values()].at(-1)?.window;
const referenceBounds =
reference && !reference.isDestroyed()
? reference.getNormalBounds()
: primaryWorkArea;
return resolveWindowBounds(
{
x: referenceBounds.x + CASCADE_OFFSET,
y: referenceBounds.y + CASCADE_OFFSET,
width: referenceBounds.width,
height: referenceBounds.height
},
displays,
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
}
#lockDownWindow(window: BrowserWindow) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== this.#applicationOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
#bindWindowLifecycle(session: WindowSession) {
const { window } = session;
const webContentsId = window.webContents.id;
window.on("focus", () => {
void this.#checkForExternalChange(session);
});
window.on("close", (event) => {
const decision = getWindowCloseDecision(session);
if (decision === "allow") {
return;
}
event.preventDefault();
if (decision === "request") {
session.closeRequestPending = true;
session.window.webContents.send(
DESKTOP_WINDOW_CLOSE_REQUESTED
);
}
});
window.on("closed", () => {
const wasPrimary = this.#primarySession === session;
this.#sessions.delete(webContentsId);
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
if (wasPrimary) {
this.#promotePrimarySession();
}
});
}
#promotePrimarySession() {
const nextPrimary = [...this.#sessions.values()]
.filter((session) => !session.window.isDestroyed())
.at(-1);
this.#primarySession = nextPrimary;
if (!nextPrimary) {
return;
}
this.#bindPrimaryWindowState(nextPrimary);
this.#captureWindowState();
}
async #checkForExternalChange(session: WindowSession) {
if (
session.focusCheckRunning ||
session.window.isDestroyed() ||
!session.currentFilePath ||
!session.baselineHash ||
Date.now() < session.suppressFocusCheckUntil
) {
return;
}
session.focusCheckRunning = true;
try {
let snapshot: MarkdownFileSnapshot;
try {
snapshot = await readMarkdownFileSnapshot(
session.currentFilePath,
MAXIMUM_MARKDOWN_LENGTH
);
} catch (error) {
await dialog.showMessageBox(session.window, {
type: "error",
title: "无法检查文件",
message: "当前 Markdown 文件已被删除或无法读取。",
detail:
error instanceof Error ? error.message : "未知文件错误",
buttons: ["确定"],
defaultId: 0,
noLink: true
});
return;
}
if (snapshot.contentHash === session.baselineHash) {
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "warning",
title: "文件已在外部修改",
message: session.dirty
? "文件已在外部修改,重新载入会丢弃当前未保存内容。"
: "文件已在外部修改,是否重新载入?",
detail: snapshot.filePath,
buttons: ["暂不载入", "重新载入"],
defaultId: 1,
cancelId: 0,
noLink: true
});
if (result.response === 1) {
session.baselineHash = snapshot.contentHash;
session.pendingOpenedMarkdown = {
snapshot,
reason: "reload"
};
session.window.webContents.send(
DESKTOP_MARKDOWN_OPENED,
"reload"
);
}
} finally {
session.focusCheckRunning = false;
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
}
}
#bindPrimaryWindowState(session: WindowSession) {
const schedule = () => {
this.#captureWindowState();
if (this.#saveWindowStateTimer) {
clearTimeout(this.#saveWindowStateTimer);
}
this.#saveWindowStateTimer = setTimeout(() => {
this.#saveWindowStateTimer = undefined;
void this.#persistWindowState();
}, 2_000);
};
session.window.on("move", schedule);
session.window.on("resize", schedule);
session.window.on("maximize", schedule);
session.window.on("unmaximize", schedule);
}
#captureWindowState() {
const window = this.#primarySession?.window;
if (!window || window.isDestroyed()) {
return;
}
this.#latestWindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
}
#persistWindowState() {
if (!this.#latestWindowState) {
return this.#pendingWindowStateWrite;
}
const state = structuredClone(this.#latestWindowState);
this.#pendingWindowStateWrite = this.#pendingWindowStateWrite
.catch(() => undefined)
.then(() => saveWindowState(this.#windowStatePath, state))
.catch((error: unknown) => {
console.warn("无法保存窗口状态", error);
});
return this.#pendingWindowStateWrite;
}
#findDocumentSession(documentKey: string) {
const committed = this.#documentSessions.get(documentKey);
if (committed) {
return committed;
}
return [...this.#sessions.values()].find(
(session) =>
session.pendingOpenedMarkdown?.snapshot.documentKey ===
documentKey
);
}
#getSession(event: IpcMainInvokeEvent) {
const session = this.#sessions.get(event.sender.id);
let origin: string | undefined;
try {
origin = event.senderFrame
? new URL(event.senderFrame.url).origin
: undefined;
} catch {
origin = undefined;
}
if (
!session ||
event.sender !== session.window.webContents ||
origin !== this.#applicationOrigin
) {
throw new Error("拒绝未授权的桌面请求");
}
return session;
}
#commitSnapshot(
session: WindowSession,
snapshot: MarkdownFileSnapshot
) {
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
session.currentFilePath = snapshot.filePath;
session.documentKey = snapshot.documentKey;
session.documentRoot = path.dirname(snapshot.filePath);
session.baselineHash = snapshot.contentHash;
session.dirty = false;
this.#documentSessions.set(snapshot.documentKey, session);
}
#clearDocument(session: WindowSession) {
if (
session.documentKey &&
this.#documentSessions.get(session.documentKey) === session
) {
this.#documentSessions.delete(session.documentKey);
}
session.currentFilePath = undefined;
session.documentKey = undefined;
session.documentRoot = undefined;
session.baselineHash = undefined;
session.pendingOpenedMarkdown = undefined;
session.dirty = false;
}
#registerIpcHandlers() {
ipcMain.handle(
DESKTOP_OPEN_DOCUMENT_LINK,
async (event, unsafeHref: unknown) => {
const session = this.#getSession(event);
if (
typeof unsafeHref !== "string" ||
unsafeHref.length === 0 ||
unsafeHref.length > 8_192
) {
throw new Error("文档链接无效");
}
try {
await this.#openDocumentLink(session, unsafeHref);
} catch (error) {
await dialog.showMessageBox(session.window, {
type: "error",
title: "无法打开链接",
message:
error instanceof Error
? error.message
: "无法打开文档链接",
detail: unsafeHref,
buttons: ["确定"],
defaultId: 0,
noLink: true
});
}
}
);
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
const session = this.#getSession(event);
const selection = await dialog.showOpenDialog(session.window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const snapshot = await readMarkdownFileSnapshot(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(snapshot.documentKey);
if (existing && existing !== session) {
focusWindow(existing.window);
return undefined;
}
this.#commitSnapshot(session, snapshot);
return snapshot.document;
});
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
const session = this.#getSession(event);
const pending = session.pendingOpenedMarkdown;
session.pendingOpenedMarkdown = undefined;
if (!pending) {
return undefined;
}
this.#commitSnapshot(session, pending.snapshot);
return pending.snapshot.document;
});
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
const session = this.#getSession(event);
session.pendingOpenedMarkdown = undefined;
});
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
this.#clearDocument(this.#getSession(event));
});
ipcMain.handle(DESKTOP_CREATE_NEW_WINDOW, async (event) => {
this.#getSession(event);
await this.#createWindow(undefined, true);
});
ipcMain.handle(
DESKTOP_RESOLVE_WINDOW_CLOSE,
async (event, unsafeClose: unknown) => {
if (typeof unsafeClose !== "boolean") {
throw new Error("窗口关闭决策无效");
}
const session = this.#getSession(event);
session.closeRequestPending = false;
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
if (unsafeClose && !session.window.isDestroyed()) {
session.allowClose = true;
session.window.close();
}
}
);
ipcMain.handle(
DESKTOP_SET_DOCUMENT_DIRTY,
async (event, unsafeDirty: unknown) => {
if (typeof unsafeDirty !== "boolean") {
throw new Error("文档修改状态无效");
}
this.#getSession(event).dirty = unsafeDirty;
}
);
const saveMarkdown = async (
event: IpcMainInvokeEvent,
unsafeFileName: unknown,
unsafeMarkdown: unknown,
forceSaveAs: boolean
) => {
const session = this.#getSession(event);
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
typeof unsafeMarkdown !== "string" ||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
) {
throw new Error("Markdown 保存参数无效");
}
let targetPath = session.currentFilePath;
if (forceSaveAs || !targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(session.window, {
title: forceSaveAs
? "另存为 Markdown"
: "保存 Markdown",
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [
{
name: "Markdown 文件",
extensions: ["md", "markdown"]
}
]
});
if (selection.canceled || !selection.filePath) {
return undefined;
}
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
? selection.filePath
: `${selection.filePath}.md`;
}
let targetKey = createMarkdownDocumentKey(
path.resolve(targetPath)
);
try {
targetKey = createMarkdownDocumentKey(
await realpath(targetPath)
);
} catch (error) {
if (
!(
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
)
) {
throw error;
}
}
const existing = this.#findDocumentSession(targetKey);
if (existing && existing !== session) {
focusWindow(existing.window);
throw new Error("该 Markdown 已在另一个窗口中打开");
}
await writeFile(targetPath, unsafeMarkdown, "utf8");
const snapshot = await readMarkdownFileSnapshot(
targetPath,
MAXIMUM_MARKDOWN_LENGTH
);
const canonicalExisting = this.#findDocumentSession(
snapshot.documentKey
);
if (canonicalExisting && canonicalExisting !== session) {
focusWindow(canonicalExisting.window);
throw new Error("该 Markdown 已在另一个窗口中打开");
}
this.#commitSnapshot(session, snapshot);
await this.#recordSuccessfulFile(targetPath);
return { fileName: snapshot.document.fileName };
};
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
false
)
);
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN_AS,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
true
)
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
this.#getSession(event);
await mkdir(this.#themeDirectory, { recursive: true });
const errorMessage = await shell.openPath(this.#themeDirectory);
if (errorMessage) {
throw new Error(
`无法打开自定义主题目录:${errorMessage}`
);
}
return this.#themeDirectory;
});
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
this.#getSession(event);
this.#applicationService.invalidateThemes();
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
const session = this.#getSession(event);
return this.#applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
session.documentRoot
? {
localRoot: session.documentRoot,
allowUnrestrictedLocalFiles: true
}
: {}
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
this.#getSession(event);
return this.#applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
this.#getSession(event);
const css = await this.#applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
this.#getSession(event);
const result = await this.#pdfGenerator.generate(
parsePagedDocumentPayload(unsafePayload)
);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
const session = this.#getSession(event);
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(session.window, {
title: "导出 PDF",
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
await this.#recordSuccessfulFile(selection.filePath);
return true;
}
);
ipcMain.handle(
DESKTOP_GET_DOCX_CAPABILITY,
async (event) => {
this.#getSession(event);
return this.#docxExportService.getCapability();
}
);
ipcMain.handle(
DESKTOP_EXPORT_DOCX,
async (
event,
unsafeRequest: unknown
): Promise<DesktopDocxExportOutcome> => {
const session = this.#getSession(event);
const abortController = new AbortController();
const abort = () =>
abortController.abort(
new Error("桌面应用窗口已关闭")
);
event.sender.once("destroyed", abort);
try {
const generated =
await this.#docxExportService.generate(
unsafeRequest,
{
mediaAdapter: this.#docxMediaEngine,
imageContext: session.documentRoot
? {
localRoot: session.documentRoot,
allowUnrestrictedLocalFiles: true
}
: {},
signal: abortController.signal
}
);
if (
session.window.isDestroyed() ||
event.sender.isDestroyed()
) {
return {
ok: false,
error: "DOCX_RENDER_TIMEOUT",
message: "DOCX 导出已取消",
retryable: false
};
}
const selection = await dialog.showSaveDialog(
session.window,
{
title: "导出 DOCX",
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
generated.fileName
),
filters: [
{
name: "Word 文档",
extensions: ["docx"]
}
]
}
);
if (selection.canceled || !selection.filePath) {
return {
ok: true,
saved: false,
fileName: generated.fileName,
diagnostics: generated.diagnostics,
timings: generated.timings
};
}
const saved = await saveDesktopDocx(
selection.filePath,
generated.docx
);
await this.#recordSuccessfulFile(saved.targetPath);
return {
ok: true,
saved: true,
fileName: saved.fileName,
diagnostics: generated.diagnostics,
timings: generated.timings
};
} catch (error) {
return toDesktopDocxExportFailure(error);
} finally {
event.sender.removeListener("destroyed", abort);
}
}
);
}
async #openDocumentLink(
session: WindowSession,
href: string
) {
const action = resolveDesktopDocumentLinkAction(
href,
session.currentFilePath
);
if (action.type === "ignore" || action.type === "anchor") {
return;
}
if (action.type === "external") {
await shell.openExternal(action.href);
return;
}
let targetStats;
try {
targetStats = await stat(action.filePath);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
) {
await dialog.showMessageBox(session.window, {
type: "warning",
title: "找不到文件",
message: "链接指向的本地文件不存在。",
detail: action.filePath,
buttons: ["确定"],
defaultId: 0,
noLink: true
});
return;
}
throw error;
}
if (
targetStats.isFile() &&
isMarkdownFilePath(action.filePath)
) {
const snapshot = await readMarkdownFileSnapshot(
action.filePath,
MAXIMUM_MARKDOWN_LENGTH
);
const existing = this.#findDocumentSession(
snapshot.documentKey
);
if (existing) {
focusWindow(existing.window);
return;
}
const result = await dialog.showMessageBox(session.window, {
type: "question",
title: "打开 Markdown",
message: `如何打开 ${snapshot.document.fileName}`,
detail: session.dirty
? "当前窗口有未保存修改;选择当前窗口会放弃这些修改。"
: snapshot.filePath,
buttons: ["取消", "当前窗口打开", "新窗口打开"],
defaultId: 2,
cancelId: 0,
noLink: true
});
if (result.response === 1) {
session.pendingOpenedMarkdown = {
snapshot,
reason: "replace"
};
session.window.webContents.send(
DESKTOP_MARKDOWN_OPENED,
"replace"
);
} else if (result.response === 2) {
await this.#createWindow(snapshot);
}
return;
}
const errorMessage = await shell.openPath(action.filePath);
if (errorMessage) {
throw new Error(`系统无法打开该路径:${errorMessage}`);
}
}
}