release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。 完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。 统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。 发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。 验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
+21
-3
@@ -8,7 +8,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
|
||||
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||
双击文件或使用“打开方式”会复用已有应用窗口并加载文档。
|
||||
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
|
||||
一个窗口,其他文件可以同时使用多个窗口。
|
||||
|
||||
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||
重新加载,避免阅读或导出磁盘旧版本。
|
||||
|
||||
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
|
||||
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
|
||||
@@ -36,9 +40,13 @@ npm run desktop:package
|
||||
npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
|
||||
`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建
|
||||
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web,
|
||||
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
|
||||
|
||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
@@ -57,6 +65,16 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
|
||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||
`mdpdf://bundle/` 自定义协议加载。
|
||||
|
||||
## 链接与窗口行为
|
||||
|
||||
- 当前文档 `#anchor` 在当前预览中定位;
|
||||
- HTTP/HTTPS 链接交给系统默认浏览器;
|
||||
- `mailto:` 等系统协议交给操作系统处理;
|
||||
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
|
||||
- Markdown 文件会提示在当前窗口或新窗口打开;
|
||||
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
|
||||
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
|
||||
|
||||
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"productName": "Markdown PDF 导出器",
|
||||
"description": "Markdown PDF 导出器桌面端",
|
||||
@@ -9,12 +9,13 @@
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||
"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-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_MARKDOWN_OPENED,
|
||||
DESKTOP_OPEN_MARKDOWN,
|
||||
DESKTOP_OPEN_DOCUMENT_LINK,
|
||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||
DESKTOP_REFRESH_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
DESKTOP_START_NEW_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
@@ -23,14 +25,23 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
||||
discardPendingMarkdown: () =>
|
||||
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
||||
onMarkdownOpened: (listener: () => void) => {
|
||||
const handleOpened = () => listener();
|
||||
onMarkdownOpened: (
|
||||
listener: (reason: "open" | "reload" | "replace") => void
|
||||
) => {
|
||||
const handleOpened = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
reason: "open" | "reload" | "replace"
|
||||
) => listener(reason);
|
||||
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||
},
|
||||
startNewMarkdown: () =>
|
||||
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||
setDocumentDirty: (dirty: boolean) =>
|
||||
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
||||
openDocumentLink: (href: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
||||
saveMarkdown: (fileName: string, markdown: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||
openThemeDirectory: () =>
|
||||
|
||||
@@ -8,6 +8,10 @@ export const DESKTOP_DISCARD_PENDING_MARKDOWN =
|
||||
"md-to-pdf:desktop:discard-pending-markdown";
|
||||
export const DESKTOP_MARKDOWN_OPENED =
|
||||
"md-to-pdf:desktop:markdown-opened";
|
||||
export const DESKTOP_SET_DOCUMENT_DIRTY =
|
||||
"md-to-pdf:desktop:set-document-dirty";
|
||||
export const DESKTOP_OPEN_DOCUMENT_LINK =
|
||||
"md-to-pdf:desktop:open-document-link";
|
||||
export const DESKTOP_START_NEW_MARKDOWN =
|
||||
"md-to-pdf:desktop:start-new-markdown";
|
||||
export const DESKTOP_SAVE_MARKDOWN =
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
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 {
|
||||
MAXIMUM_MARKDOWN_LENGTH,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
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_SAVE_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF,
|
||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||
DESKTOP_START_NEW_MARKDOWN
|
||||
} from "./channels.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";
|
||||
|
||||
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;
|
||||
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;
|
||||
iconPath: string;
|
||||
themeDirectory: string;
|
||||
windowStatePath: 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 #pdfGenerator: ElectronPdfGenerator;
|
||||
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.#pdfGenerator = new ElectronPdfGenerator({
|
||||
renderUrl: options.renderUrl,
|
||||
preloadPath: options.pdfPreloadPath
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.#savedWindowState = await loadWindowState(
|
||||
this.#windowStatePath
|
||||
);
|
||||
this.#registerIpcHandlers();
|
||||
}
|
||||
|
||||
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) {
|
||||
const isPrimary = !this.#primarySession;
|
||||
const restoredWindowState = isPrimary
|
||||
? this.#latestWindowState ?? this.#savedWindowState
|
||||
: undefined;
|
||||
const initialBounds = this.#resolveInitialBounds(isPrimary);
|
||||
const window = new BrowserWindow({
|
||||
title: `Markdown PDF 导出器 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,
|
||||
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();
|
||||
});
|
||||
await window.loadURL(this.#applicationUrl);
|
||||
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 this.#pdfGenerator.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) => {
|
||||
if (!session.dirty || session.allowClose) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void this.#confirmDirtyWindowClose(session);
|
||||
});
|
||||
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 #confirmDirtyWindowClose(session: WindowSession) {
|
||||
if (session.window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const result = await dialog.showMessageBox(session.window, {
|
||||
type: "warning",
|
||||
title: "关闭未保存文档",
|
||||
message: "当前文档有未保存修改,确定要关闭窗口吗?",
|
||||
detail: "关闭后,尚未保存的修改将会丢失。",
|
||||
buttons: ["取消", "仍然关闭"],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true
|
||||
});
|
||||
session.suppressFocusCheckUntil =
|
||||
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
|
||||
if (result.response === 1 && !session.window.isDestroyed()) {
|
||||
session.allowClose = true;
|
||||
session.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
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_SET_DOCUMENT_DIRTY,
|
||||
async (event, unsafeDirty: unknown) => {
|
||||
if (typeof unsafeDirty !== "boolean") {
|
||||
throw new Error("文档修改状态无效");
|
||||
}
|
||||
this.#getSession(event).dirty = unsafeDirty;
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
async (
|
||||
event,
|
||||
unsafeFileName: unknown,
|
||||
unsafeMarkdown: unknown
|
||||
) => {
|
||||
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 (!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: "保存 Markdown",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
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);
|
||||
return { fileName: snapshot.document.fileName };
|
||||
}
|
||||
);
|
||||
|
||||
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: 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||
|
||||
export type DesktopDocumentLinkAction =
|
||||
| {
|
||||
type: "anchor";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "external";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "local";
|
||||
filePath: string;
|
||||
}
|
||||
| {
|
||||
type: "ignore";
|
||||
};
|
||||
|
||||
function decodeLocalPath(href: string) {
|
||||
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
|
||||
try {
|
||||
return decodeURIComponent(pathOnly);
|
||||
} catch {
|
||||
throw new Error("本地链接的 URL 编码无效");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDesktopDocumentLinkAction(
|
||||
href: string,
|
||||
markdownFilePath?: string
|
||||
): DesktopDocumentLinkAction {
|
||||
const link = classifyDocumentLink(href);
|
||||
if (link.kind === "anchor") {
|
||||
return { type: "anchor", href: link.normalizedHref };
|
||||
}
|
||||
if (link.kind === "network" || link.kind === "protocol") {
|
||||
return { type: "external", href: link.normalizedHref };
|
||||
}
|
||||
if (link.kind !== "local") {
|
||||
return { type: "ignore" };
|
||||
}
|
||||
|
||||
if (link.scheme === "file") {
|
||||
try {
|
||||
return {
|
||||
type: "local",
|
||||
filePath: path.normalize(fileURLToPath(new URL(link.href)))
|
||||
};
|
||||
} catch {
|
||||
throw new Error("file: 链接格式无效");
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = decodeLocalPath(link.href);
|
||||
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
|
||||
return { type: "local", filePath: path.normalize(decoded) };
|
||||
}
|
||||
if (!markdownFilePath) {
|
||||
throw new Error("当前文档尚未保存,无法解析相对本地链接");
|
||||
}
|
||||
return {
|
||||
type: "local",
|
||||
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
|
||||
};
|
||||
}
|
||||
+53
-451
@@ -1,79 +1,32 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
net,
|
||||
protocol,
|
||||
screen,
|
||||
session,
|
||||
shell,
|
||||
type IpcMainInvokeEvent
|
||||
session
|
||||
} from "electron";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import { access, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createApplicationService,
|
||||
MAXIMUM_MARKDOWN_LENGTH,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import { parseThemeResourceUrl } from "./application-contract.js";
|
||||
import {
|
||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_MARKDOWN_OPENED,
|
||||
DESKTOP_OPEN_MARKDOWN,
|
||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||
DESKTOP_REFRESH_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF,
|
||||
DESKTOP_START_NEW_MARKDOWN
|
||||
} 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";
|
||||
DesktopApplicationController
|
||||
} from "./desktop-application-controller.js";
|
||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||
import {
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath,
|
||||
readMarkdownDocument,
|
||||
type OpenedMarkdownDocument
|
||||
isMarkdownFilePath
|
||||
} from "./markdown-file.js";
|
||||
import {
|
||||
loadWindowState,
|
||||
resolveWindowBounds,
|
||||
saveWindowState,
|
||||
type WindowState
|
||||
} from "./window-state.js";
|
||||
|
||||
const APP_SCHEME = "mdpdf";
|
||||
const APP_HOST = "bundle";
|
||||
const THEME_HOST = "theme";
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||
let applicationController:
|
||||
| {
|
||||
window: BrowserWindow;
|
||||
openMarkdownFile(filePath: string): Promise<void>;
|
||||
flushWindowState(): Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
let applicationController: DesktopApplicationController | undefined;
|
||||
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||
process.argv,
|
||||
process.cwd()
|
||||
@@ -130,29 +83,16 @@ function encodeThemeAssetPath(assetPath: string) {
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function focusApplicationWindow() {
|
||||
const window = applicationController?.window;
|
||||
if (!window || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function dispatchExternalMarkdown(filePath: string) {
|
||||
if (!applicationController) {
|
||||
pendingExternalMarkdownPath = filePath;
|
||||
return;
|
||||
}
|
||||
void applicationController.openMarkdownFile(filePath).catch(
|
||||
void applicationController.openExternalMarkdown(filePath).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||
}
|
||||
);
|
||||
focusApplicationWindow();
|
||||
}
|
||||
|
||||
function getDesktopThemeDirectory() {
|
||||
@@ -226,12 +166,10 @@ async function registerApplicationProtocol(
|
||||
} 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 {
|
||||
@@ -248,376 +186,6 @@ async function registerApplicationProtocol(
|
||||
]);
|
||||
}
|
||||
|
||||
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 windowStatePath = path.join(
|
||||
app.getPath("userData"),
|
||||
"window-state.json"
|
||||
);
|
||||
const savedWindowState = await loadWindowState(windowStatePath);
|
||||
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
|
||||
const initialBounds = resolveWindowBounds(
|
||||
savedWindowState?.bounds,
|
||||
screen.getAllDisplays().map((display) => display.workArea),
|
||||
primaryWorkArea,
|
||||
DEFAULT_WINDOW_SIZE,
|
||||
MINIMUM_WINDOW_SIZE
|
||||
);
|
||||
|
||||
const window = new BrowserWindow({
|
||||
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||
icon: getApplicationIcon(),
|
||||
...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: path.join(currentDirectory, "app-preload.cjs")
|
||||
}
|
||||
});
|
||||
let saveWindowStateTimer: NodeJS.Timeout | undefined;
|
||||
let pendingWindowStateWrite = Promise.resolve();
|
||||
let latestWindowState: WindowState = {
|
||||
version: 1,
|
||||
bounds: window.getNormalBounds(),
|
||||
maximized: window.isMaximized()
|
||||
};
|
||||
const captureWindowState = () => {
|
||||
if (window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
latestWindowState = {
|
||||
version: 1,
|
||||
bounds: window.getNormalBounds(),
|
||||
maximized: window.isMaximized()
|
||||
};
|
||||
};
|
||||
const persistWindowState = () => {
|
||||
const state = structuredClone(latestWindowState);
|
||||
pendingWindowStateWrite = pendingWindowStateWrite
|
||||
.catch(() => undefined)
|
||||
.then(() => saveWindowState(windowStatePath, state))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("无法保存窗口状态", error);
|
||||
});
|
||||
return pendingWindowStateWrite;
|
||||
};
|
||||
const scheduleWindowStateSave = () => {
|
||||
captureWindowState();
|
||||
if (saveWindowStateTimer) {
|
||||
clearTimeout(saveWindowStateTimer);
|
||||
}
|
||||
saveWindowStateTimer = setTimeout(() => {
|
||||
saveWindowStateTimer = undefined;
|
||||
void persistWindowState();
|
||||
}, 2_000);
|
||||
};
|
||||
const flushWindowState = async () => {
|
||||
if (saveWindowStateTimer) {
|
||||
clearTimeout(saveWindowStateTimer);
|
||||
saveWindowStateTimer = undefined;
|
||||
}
|
||||
captureWindowState();
|
||||
await persistWindowState();
|
||||
};
|
||||
window.on("move", scheduleWindowStateSave);
|
||||
window.on("resize", scheduleWindowStateSave);
|
||||
window.on("maximize", scheduleWindowStateSave);
|
||||
window.on("unmaximize", scheduleWindowStateSave);
|
||||
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;
|
||||
let documentRoot: string | undefined;
|
||||
let currentMarkdownPath: string | undefined;
|
||||
let pendingOpenedMarkdown:
|
||||
| {
|
||||
document: OpenedMarkdownDocument;
|
||||
filePath: string;
|
||||
}
|
||||
| undefined;
|
||||
const queueOpenedMarkdown = async (filePath: string) => {
|
||||
const opened = await readMarkdownDocument(
|
||||
filePath,
|
||||
MAXIMUM_MARKDOWN_LENGTH
|
||||
);
|
||||
pendingOpenedMarkdown = {
|
||||
document: opened,
|
||||
filePath
|
||||
};
|
||||
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
|
||||
};
|
||||
|
||||
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
}
|
||||
const selection = await dialog.showOpenDialog(window, {
|
||||
title: "打开 Markdown",
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||
]
|
||||
});
|
||||
const filePath = selection.filePaths[0];
|
||||
if (selection.canceled || !filePath) {
|
||||
return undefined;
|
||||
}
|
||||
const opened = await readMarkdownDocument(
|
||||
filePath,
|
||||
MAXIMUM_MARKDOWN_LENGTH
|
||||
);
|
||||
documentRoot = path.dirname(filePath);
|
||||
currentMarkdownPath = filePath;
|
||||
return opened;
|
||||
});
|
||||
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
}
|
||||
const pending = pendingOpenedMarkdown;
|
||||
pendingOpenedMarkdown = undefined;
|
||||
if (!pending) {
|
||||
return undefined;
|
||||
}
|
||||
currentMarkdownPath = pending.filePath;
|
||||
documentRoot = path.dirname(pending.filePath);
|
||||
return pending.document;
|
||||
});
|
||||
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
}
|
||||
pendingOpenedMarkdown = undefined;
|
||||
});
|
||||
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
}
|
||||
currentMarkdownPath = undefined;
|
||||
documentRoot = undefined;
|
||||
pendingOpenedMarkdown = undefined;
|
||||
});
|
||||
ipcMain.handle(
|
||||
DESKTOP_SAVE_MARKDOWN,
|
||||
async (
|
||||
event,
|
||||
unsafeFileName: unknown,
|
||||
unsafeMarkdown: unknown
|
||||
) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
}
|
||||
if (
|
||||
typeof unsafeFileName !== "string" ||
|
||||
unsafeFileName.length === 0 ||
|
||||
unsafeFileName.length > 500 ||
|
||||
typeof unsafeMarkdown !== "string" ||
|
||||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
|
||||
) {
|
||||
throw new Error("Markdown 保存参数无效");
|
||||
}
|
||||
|
||||
let targetPath = currentMarkdownPath;
|
||||
if (!targetPath) {
|
||||
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||
path.basename(unsafeFileName)
|
||||
)
|
||||
? path.basename(unsafeFileName)
|
||||
: `${path.basename(unsafeFileName)}.md`;
|
||||
const selection = await dialog.showSaveDialog(window, {
|
||||
title: "保存 Markdown",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
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`;
|
||||
}
|
||||
|
||||
await writeFile(targetPath, unsafeMarkdown, "utf8");
|
||||
currentMarkdownPath = targetPath;
|
||||
documentRoot = path.dirname(targetPath);
|
||||
return { fileName: path.basename(targetPath) };
|
||||
}
|
||||
);
|
||||
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
const themeDirectory = getDesktopThemeDirectory();
|
||||
await mkdir(themeDirectory, { recursive: true });
|
||||
const errorMessage = await shell.openPath(themeDirectory);
|
||||
if (errorMessage) {
|
||||
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
|
||||
}
|
||||
return themeDirectory;
|
||||
});
|
||||
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
applicationService.invalidateThemes();
|
||||
});
|
||||
ipcMain.handle(
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
async (event, unsafeRequest: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面渲染请求");
|
||||
}
|
||||
return applicationService.render(
|
||||
parseMarkdownRenderRequest(unsafeRequest),
|
||||
documentRoot ? { localRoot: documentRoot } : {}
|
||||
);
|
||||
}
|
||||
);
|
||||
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", () => {
|
||||
if (savedWindowState?.maximized) {
|
||||
window.maximize();
|
||||
}
|
||||
window.show();
|
||||
});
|
||||
window.on("close", () => {
|
||||
void flushWindowState();
|
||||
});
|
||||
window.on("closed", () => {
|
||||
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
|
||||
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
|
||||
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);
|
||||
return {
|
||||
window,
|
||||
openMarkdownFile: queueOpenedMarkdown,
|
||||
flushWindowState
|
||||
};
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
let windowStateReadyToQuit = false;
|
||||
|
||||
@@ -631,8 +199,8 @@ if (!hasSingleInstanceLock) {
|
||||
);
|
||||
if (filePath) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
} else {
|
||||
focusApplicationWindow();
|
||||
} else if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
});
|
||||
app.on("open-file", (event, filePath) => {
|
||||
@@ -641,6 +209,11 @@ if (!hasSingleInstanceLock) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
}
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
});
|
||||
app.on("before-quit", (event) => {
|
||||
if (windowStateReadyToQuit || !applicationController) {
|
||||
return;
|
||||
@@ -649,8 +222,14 @@ if (!hasSingleInstanceLock) {
|
||||
void applicationController.flushWindowState().finally(() => {
|
||||
windowStateReadyToQuit = true;
|
||||
app.quit();
|
||||
setTimeout(() => {
|
||||
windowStateReadyToQuit = false;
|
||||
}, 1_000);
|
||||
});
|
||||
});
|
||||
app.on("will-quit", () => {
|
||||
void applicationController?.closeResources();
|
||||
});
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
@@ -658,18 +237,41 @@ if (!hasSingleInstanceLock) {
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
await mkdir(getDesktopThemeDirectory(), { recursive: true });
|
||||
const themeDirectory = getDesktopThemeDirectory();
|
||||
await mkdir(themeDirectory, { recursive: true });
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(applicationService);
|
||||
applicationController =
|
||||
await createApplication(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"
|
||||
),
|
||||
iconPath: getApplicationIcon(),
|
||||
themeDirectory,
|
||||
windowStatePath: path.join(
|
||||
app.getPath("userData"),
|
||||
"window-state.json"
|
||||
)
|
||||
});
|
||||
await applicationController.initialize();
|
||||
|
||||
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||
pendingExternalMarkdownPath = undefined;
|
||||
if (startupMarkdownPath) {
|
||||
await applicationController.openMarkdownFile(
|
||||
startupMarkdownPath
|
||||
);
|
||||
}
|
||||
await applicationController.createWindow(startupMarkdownPath);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||
@@ -8,6 +9,24 @@ export interface OpenedMarkdownDocument {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface MarkdownFileSnapshot {
|
||||
document: OpenedMarkdownDocument;
|
||||
filePath: string;
|
||||
documentKey: string;
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export function createMarkdownContentHash(markdown: string) {
|
||||
return createHash("sha256").update(markdown, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function createMarkdownDocumentKey(filePath: string) {
|
||||
const normalized = path.normalize(filePath);
|
||||
return process.platform === "win32"
|
||||
? normalized.toLocaleLowerCase("en-US")
|
||||
: normalized;
|
||||
}
|
||||
|
||||
export function isMarkdownFilePath(filePath: string) {
|
||||
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
@@ -58,3 +77,20 @@ export async function readMarkdownDocument(
|
||||
fileName: path.basename(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMarkdownFileSnapshot(
|
||||
filePath: string,
|
||||
maximumLength: number
|
||||
): Promise<MarkdownFileSnapshot> {
|
||||
const canonicalPath = await realpath(filePath);
|
||||
const document = await readMarkdownDocument(
|
||||
canonicalPath,
|
||||
maximumLength
|
||||
);
|
||||
return {
|
||||
document,
|
||||
filePath: canonicalPath,
|
||||
documentKey: createMarkdownDocumentKey(canonicalPath),
|
||||
contentHash: createMarkdownContentHash(document.markdown)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
|
||||
|
||||
describe("桌面文档链接动作", () => {
|
||||
const markdownPath = path.resolve("D:/文档/章节/当前.md");
|
||||
|
||||
it("保留锚点并把网络和自定义协议交给系统", () => {
|
||||
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
|
||||
type: "anchor",
|
||||
href: "#标题"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("//example.com/path")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "https://example.com/path"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("mailto:user@example.com")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "mailto:user@example.com"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "obsidian://open?vault=docs"
|
||||
});
|
||||
});
|
||||
|
||||
it("允许相对路径越过 Markdown 目录", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
"../../共享/说明.pdf#page=2",
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.resolve(
|
||||
path.dirname(markdownPath),
|
||||
"../../共享/说明.pdf"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
it("解析绝对路径、file URI 和 URL 编码", () => {
|
||||
const absolutePath = path.resolve("C:/资料/项目说明.md");
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.normalize(absolutePath)
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
pathToFileURL(absolutePath).href,
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.normalize(absolutePath)
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.resolve(
|
||||
path.dirname(markdownPath),
|
||||
"../共享/说明.pdf"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝危险链接和无文件基准的相对路径", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("javascript:alert(1)")
|
||||
).toEqual({ type: "ignore" });
|
||||
expect(() =>
|
||||
resolveDesktopDocumentLinkAction("../说明.pdf")
|
||||
).toThrow("尚未保存");
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMarkdownContentHash,
|
||||
createMarkdownDocumentKey,
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath,
|
||||
readMarkdownDocument
|
||||
readMarkdownDocument,
|
||||
readMarkdownFileSnapshot
|
||||
} from "../src/markdown-file.js";
|
||||
|
||||
describe("桌面 Markdown 文件参数", () => {
|
||||
@@ -72,4 +75,33 @@ describe("桌面 Markdown 文件参数", () => {
|
||||
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
||||
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
||||
});
|
||||
|
||||
it("使用真实路径和内容哈希创建文件快照", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
|
||||
);
|
||||
const filePath = path.join(directory, "快照.md");
|
||||
try {
|
||||
await writeFile(filePath, "# 第一版", "utf8");
|
||||
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||
|
||||
expect(snapshot.document).toEqual({
|
||||
markdown: "# 第一版",
|
||||
fileName: "快照.md"
|
||||
});
|
||||
expect(snapshot.filePath).toBe(filePath);
|
||||
expect(snapshot.documentKey).toBe(
|
||||
createMarkdownDocumentKey(filePath)
|
||||
);
|
||||
expect(snapshot.contentHash).toBe(
|
||||
createMarkdownContentHash("# 第一版")
|
||||
);
|
||||
|
||||
await writeFile(filePath, "# 第二版", "utf8");
|
||||
const changed = await readMarkdownFileSnapshot(filePath, 100);
|
||||
expect(changed.contentHash).not.toBe(snapshot.contentHash);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface PackageManifest {
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
||||
}
|
||||
|
||||
describe("桌面发行构建链", () => {
|
||||
it("打包前按依赖顺序完整重建内嵌 Web", () => {
|
||||
const rootManifest = readManifest(`${projectRoot}/package.json`);
|
||||
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
|
||||
const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? "";
|
||||
const expectedWorkspaces = [
|
||||
"@md-to-pdf/markdown-echarts",
|
||||
"@md-to-pdf/core",
|
||||
"@md-to-pdf/renderer",
|
||||
"@md-to-pdf/application",
|
||||
"@md-to-pdf/preview-engine",
|
||||
"@md-to-pdf/web"
|
||||
];
|
||||
|
||||
let previousIndex = -1;
|
||||
for (const workspace of expectedWorkspaces) {
|
||||
const workspaceIndex = webBuild.indexOf(workspace);
|
||||
expect(workspaceIndex).toBeGreaterThan(previousIndex);
|
||||
previousIndex = workspaceIndex;
|
||||
}
|
||||
|
||||
expect(desktopManifest.scripts?.["build:embedded-web"]).toBe(
|
||||
"npm --prefix ../.. run build:web-runtime"
|
||||
);
|
||||
expect(desktopManifest.scripts?.package).toMatch(
|
||||
/^npm run build:embedded-web && /
|
||||
);
|
||||
expect(desktopManifest.scripts?.make).toMatch(
|
||||
/^npm run build:embedded-web && /
|
||||
);
|
||||
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
||||
"npm run package -w @md-to-pdf/desktop"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user