新增能力:桌面端新增 macOS 打包支持,electron-builder 增加独立 mac 配置块, 产出 dmg,按 arm64、x64 分别单独构建、不产出 universal 包;Pandoc 运行时清单 新增 darwin/arm64、darwin/x64 两个官方发行项(含独立许可证文件来源),桌面 Pandoc 候选路径与 prepare-pandoc-runtime.mjs 均已泛化为按 --platform/--arch 参数选取目标,不再只认 Windows x64。AGENTS.md 不再硬编码 Windows 绝对路径与 强制 PowerShell 语法,改为按当前 Shell 与仓库实际位置自适应。 问题修复:修复解压内置 Pandoc 时未保留 Unix 可执行权限位的问题(unzipSync 不保留压缩包内权限,已补 chmod 0o755);修复根 package.json 中 build:web-runtime/dev/desktop:dev 三个脚本的构建顺序错误 (@md-to-pdf/application 被排在其依赖的 @md-to-pdf/preview-engine 之前,在 没有历史 dist 残留的全新环境中会导致类型解析失败);修正 packages/docx-engine/tests/pandoc-runtime.test.ts 与 apps/desktop/tests/markdown-file.test.ts 中依赖宿主 OS 或路径分隔符的测试 断言,避免这些用例只能在特定平台上通过。 验证结果:docx-engine 包 93 项测试、desktop 包 62 项测试(61 通过 + 1 项 Windows 专属用例按预期跳过)均通过;全项目类型检查、生产构建通过, git diff --check 无空白错误。跳过依赖 Git 忽略的真实客户文档语料(tmp/)的 test:docx-real-world-corpus 与 test:docx-release-gate-suites 后,其余全部 工作区测试均通过;这两步需要接入真实语料的机器上单独执行。本机 macOS (Apple Silicon)实测 npm run package:mac:arm64 全链路成功,内置 Pandoc 3.9.0.2 完成下载、哈希校验与真实 DOCX 冒烟转换;实际截图确认窗口、macOS 原生菜单栏、编辑器工具栏、中文字体与实时预览渲染正常。 兼容与部署:版本统一为 0.6.4。本版本仅完成开发基础设施与验证,未构建正式 发行文件:没有产出真实签名的 dmg、没有重新构建 Windows 安装包,也没有重新 构建 Docker 镜像;正式 macOS 发行产物与 Windows/Docker 同步验证留待 DOCX 发布门禁阶段完成后一并发布。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5N6pcbjV4jVfXrcH3NcLP
428 lines
13 KiB
JavaScript
428 lines
13 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { unzipSync } from "fflate";
|
|
|
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const desktopRoot = path.join(projectRoot, "apps", "desktop");
|
|
const manifestPath = path.join(
|
|
projectRoot,
|
|
"packages",
|
|
"docx-engine",
|
|
"src",
|
|
"pandoc-runtime.json"
|
|
);
|
|
const maximumArchiveBytes = 64 * 1024 * 1024;
|
|
const maximumExtractedBytes = 256 * 1024 * 1024;
|
|
const downloadTimeoutMs = 120_000;
|
|
|
|
function sha256(content) {
|
|
return createHash("sha256").update(content).digest("hex").toUpperCase();
|
|
}
|
|
|
|
function normalizedBaseName(fileName) {
|
|
return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1);
|
|
}
|
|
|
|
async function fileExists(filePath) {
|
|
try {
|
|
await access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function platformDirectoryName(platform, architecture) {
|
|
if (platform === "win32" && architecture === "x64") {
|
|
return "windows-x86_64";
|
|
}
|
|
if (platform === "darwin" && (architecture === "arm64" || architecture === "x64")) {
|
|
return `darwin-${architecture}`;
|
|
}
|
|
throw new Error(`不支持的 Pandoc 桌面内置目标:${platform}/${architecture}`);
|
|
}
|
|
|
|
/** 归档下载得到的、需要从压缩包内部提取的文件名(不含通过 licenseSource 单独下载的许可证文件)。 */
|
|
function archiveRequiredNames(artifact) {
|
|
const licenseSource = artifact.licenseSource ?? {};
|
|
return [
|
|
normalizedBaseName(artifact.executableRelativePath),
|
|
...artifact.licenseFiles
|
|
.filter((fileName) => !licenseSource[fileName])
|
|
.map(normalizedBaseName)
|
|
];
|
|
}
|
|
|
|
async function readRuntimeManifest({ platform, architecture }) {
|
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
const artifact = manifest.artifacts?.find(
|
|
(candidate) =>
|
|
candidate.platform === platform && candidate.architecture === architecture
|
|
);
|
|
if (!artifact) {
|
|
throw new Error(`Pandoc 清单缺少 ${platform}/${architecture} 发行项`);
|
|
}
|
|
if (artifact.archiveType !== "zip") {
|
|
throw new Error(`Pandoc 清单要求 ${platform}/${architecture} 为 ZIP 发行项`);
|
|
}
|
|
const requiredNames = [
|
|
normalizedBaseName(artifact.executableRelativePath),
|
|
...artifact.licenseFiles.map(normalizedBaseName)
|
|
];
|
|
if (
|
|
requiredNames.some(
|
|
(fileName) =>
|
|
!fileName ||
|
|
!Number.isInteger(artifact.files?.[fileName]?.bytes) ||
|
|
!/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "")
|
|
)
|
|
) {
|
|
throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度");
|
|
}
|
|
return { manifest, artifact };
|
|
}
|
|
|
|
async function downloadArchive(downloadUrl, destination) {
|
|
const response = await fetch(downloadUrl, {
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(downloadTimeoutMs)
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Pandoc 下载失败:HTTP ${response.status}`);
|
|
}
|
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
if (
|
|
Number.isFinite(declaredLength) &&
|
|
declaredLength > maximumArchiveBytes
|
|
) {
|
|
throw new Error("Pandoc 归档超过允许的下载大小");
|
|
}
|
|
const content = new Uint8Array(await response.arrayBuffer());
|
|
if (content.byteLength > maximumArchiveBytes) {
|
|
throw new Error("Pandoc 归档超过允许的下载大小");
|
|
}
|
|
await writeFile(destination, content);
|
|
return content;
|
|
}
|
|
|
|
async function downloadLicenseSourceFile(downloadUrl) {
|
|
const response = await fetch(downloadUrl, {
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(downloadTimeoutMs)
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Pandoc 许可证文件下载失败:HTTP ${response.status}`);
|
|
}
|
|
const content = new Uint8Array(await response.arrayBuffer());
|
|
if (content.byteLength > maximumExtractedBytes) {
|
|
throw new Error("Pandoc 许可证文件超过允许的下载大小");
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* 部分平台(如 macOS)的官方归档不随附许可证文件,需要按清单声明的
|
|
* 独立来源单独下载;返回的文件与归档内提取的文件合并后一并校验哈希。
|
|
*/
|
|
async function loadLicenseSourceFiles(artifact) {
|
|
const files = new Map();
|
|
for (const [fileName, source] of Object.entries(artifact.licenseSource ?? {})) {
|
|
files.set(fileName, await downloadLicenseSourceFile(source.downloadUrl));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function loadVerifiedArchive(artifact, archivePath) {
|
|
if (await fileExists(archivePath)) {
|
|
const cached = new Uint8Array(await readFile(archivePath));
|
|
if (sha256(cached) === artifact.sha256) {
|
|
return cached;
|
|
}
|
|
await rm(archivePath, { force: true });
|
|
}
|
|
|
|
const partialPath = `${archivePath}.${process.pid}.partial`;
|
|
await rm(partialPath, { force: true });
|
|
try {
|
|
const downloaded = await downloadArchive(
|
|
artifact.downloadUrl,
|
|
partialPath
|
|
);
|
|
const actualHash = sha256(downloaded);
|
|
if (actualHash !== artifact.sha256) {
|
|
throw new Error(
|
|
`Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}`
|
|
);
|
|
}
|
|
await rename(partialPath, archivePath);
|
|
return downloaded;
|
|
} finally {
|
|
await rm(partialPath, { force: true });
|
|
}
|
|
}
|
|
|
|
function extractRequiredFiles(archive, artifact) {
|
|
const requiredNames = new Set(archiveRequiredNames(artifact));
|
|
let selectedBytes = 0;
|
|
const entries = unzipSync(archive, {
|
|
filter(file) {
|
|
const selected = requiredNames.has(normalizedBaseName(file.name));
|
|
if (selected) {
|
|
selectedBytes += file.originalSize;
|
|
if (selectedBytes > maximumExtractedBytes) {
|
|
throw new Error("Pandoc 最小运行时超过允许的解压大小");
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
});
|
|
const files = new Map();
|
|
for (const [entryName, content] of Object.entries(entries)) {
|
|
const baseName = normalizedBaseName(entryName);
|
|
if (!baseName || !requiredNames.has(baseName)) {
|
|
continue;
|
|
}
|
|
if (files.has(baseName)) {
|
|
throw new Error(`Pandoc 归档包含重复文件:${baseName}`);
|
|
}
|
|
files.set(baseName, content);
|
|
}
|
|
for (const requiredName of requiredNames) {
|
|
if (!files.has(requiredName)) {
|
|
throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function verifyPandocVersion(executablePath, expectedVersion) {
|
|
const result = spawnSync(executablePath, ["--version"], {
|
|
encoding: "utf8",
|
|
maxBuffer: 64 * 1024,
|
|
windowsHide: true
|
|
});
|
|
if (result.error || result.status !== 0) {
|
|
throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`);
|
|
}
|
|
const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim();
|
|
if (firstLine !== `pandoc ${expectedVersion}`) {
|
|
throw new Error(
|
|
`内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function smokeTestPandoc(executablePath) {
|
|
const smokeRoot = path.join(
|
|
tmpdir(),
|
|
`morphdoc-pandoc-smoke-${process.pid}-${Date.now()}`
|
|
);
|
|
const dataDirectory = path.join(smokeRoot, "data");
|
|
const outputPath = path.join(smokeRoot, "smoke.docx");
|
|
await mkdir(dataDirectory, { recursive: true });
|
|
try {
|
|
const result = spawnSync(
|
|
executablePath,
|
|
[
|
|
"--from=gfm",
|
|
"--to=docx",
|
|
`--data-dir=${dataDirectory}`,
|
|
`--output=${outputPath}`
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n",
|
|
maxBuffer: 1024 * 1024,
|
|
windowsHide: true
|
|
}
|
|
);
|
|
if (result.error || result.status !== 0) {
|
|
throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`);
|
|
}
|
|
const output = await readFile(outputPath);
|
|
if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) {
|
|
throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包");
|
|
}
|
|
} finally {
|
|
await rm(smokeRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
|
|
const auditPath = path.join(targetDirectory, "runtime-manifest.json");
|
|
if (!(await fileExists(auditPath))) {
|
|
return false;
|
|
}
|
|
try {
|
|
const audit = JSON.parse(await readFile(auditPath, "utf8"));
|
|
if (
|
|
audit.version !== manifest.version ||
|
|
audit.archiveSha256 !== artifact.sha256 ||
|
|
audit.downloadUrl !== artifact.downloadUrl
|
|
) {
|
|
return false;
|
|
}
|
|
const expectedFiles = [
|
|
normalizedBaseName(artifact.executableRelativePath),
|
|
...artifact.licenseFiles.map(normalizedBaseName)
|
|
];
|
|
for (const fileName of expectedFiles) {
|
|
const content = await readFile(path.join(targetDirectory, fileName));
|
|
const expected = artifact.files[fileName];
|
|
if (
|
|
!expected ||
|
|
content.byteLength !== expected.bytes ||
|
|
sha256(content) !== expected.sha256 ||
|
|
audit.files?.[fileName]?.sha256 !== expected.sha256
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
const executablePath = path.join(
|
|
targetDirectory,
|
|
normalizedBaseName(artifact.executableRelativePath)
|
|
);
|
|
verifyPandocVersion(executablePath, manifest.version);
|
|
await smokeTestPandoc(executablePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function preparePandocRuntime({
|
|
checkOnly = false,
|
|
platform = process.platform,
|
|
architecture = process.arch
|
|
} = {}) {
|
|
const { manifest, artifact } = await readRuntimeManifest({ platform, architecture });
|
|
const platformDirectory = platformDirectoryName(platform, architecture);
|
|
const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
|
|
const targetDirectory = path.join(
|
|
runtimeRoot,
|
|
manifest.version,
|
|
platformDirectory
|
|
);
|
|
if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) {
|
|
console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`);
|
|
return targetDirectory;
|
|
}
|
|
if (checkOnly) {
|
|
throw new Error("Pandoc 最小运行时缺失或未通过完整性校验");
|
|
}
|
|
|
|
const downloadDirectory = path.join(runtimeRoot, "downloads");
|
|
await mkdir(downloadDirectory, { recursive: true });
|
|
const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1);
|
|
if (!archiveName) {
|
|
throw new Error("Pandoc 下载地址缺少归档文件名");
|
|
}
|
|
const archivePath = path.join(downloadDirectory, archiveName);
|
|
const archive = await loadVerifiedArchive(artifact, archivePath);
|
|
const files = new Map([
|
|
...extractRequiredFiles(archive, artifact),
|
|
...await loadLicenseSourceFiles(artifact)
|
|
]);
|
|
const temporaryDirectory = path.join(
|
|
runtimeRoot,
|
|
manifest.version,
|
|
`.${platformDirectory}-${process.pid}-${Date.now()}`
|
|
);
|
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
await mkdir(temporaryDirectory, { recursive: true });
|
|
try {
|
|
const auditFiles = {};
|
|
for (const [fileName, content] of files) {
|
|
const expected = artifact.files[fileName];
|
|
const actualHash = sha256(content);
|
|
if (
|
|
!expected ||
|
|
content.byteLength !== expected.bytes ||
|
|
actualHash !== expected.sha256
|
|
) {
|
|
throw new Error(`Pandoc 文件完整性校验失败:${fileName}`);
|
|
}
|
|
await writeFile(path.join(temporaryDirectory, fileName), content);
|
|
auditFiles[fileName] = {
|
|
bytes: content.byteLength,
|
|
sha256: actualHash
|
|
};
|
|
}
|
|
const executablePath = path.join(
|
|
temporaryDirectory,
|
|
normalizedBaseName(artifact.executableRelativePath)
|
|
);
|
|
if (platform !== "win32") {
|
|
// unzipSync 不保留压缩包内的可执行权限位,POSIX 平台需要手动补上。
|
|
await chmod(executablePath, 0o755);
|
|
}
|
|
verifyPandocVersion(executablePath, manifest.version);
|
|
await smokeTestPandoc(executablePath);
|
|
await writeFile(
|
|
path.join(temporaryDirectory, "runtime-manifest.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
version: manifest.version,
|
|
platform,
|
|
architecture,
|
|
license: manifest.license,
|
|
projectUrl: manifest.projectUrl,
|
|
sourceArchiveUrl: manifest.sourceArchiveUrl,
|
|
downloadUrl: artifact.downloadUrl,
|
|
archiveSha256: artifact.sha256,
|
|
files: auditFiles
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
"utf8"
|
|
);
|
|
await rm(targetDirectory, { recursive: true, force: true });
|
|
await rename(temporaryDirectory, targetDirectory);
|
|
} finally {
|
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`);
|
|
return targetDirectory;
|
|
}
|
|
|
|
function readCliFlag(name) {
|
|
const prefix = `--${name}=`;
|
|
const match = process.argv.find((argument) => argument.startsWith(prefix));
|
|
return match ? match.slice(prefix.length) : undefined;
|
|
}
|
|
|
|
const isDirectInvocation =
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (isDirectInvocation) {
|
|
preparePandocRuntime({
|
|
checkOnly: process.argv.includes("--check"),
|
|
platform: readCliFlag("platform") ?? process.platform,
|
|
architecture: readCliFlag("arch") ?? process.arch
|
|
}).catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
export {
|
|
extractRequiredFiles,
|
|
preparePandocRuntime,
|
|
sha256,
|
|
verifyPandocVersion
|
|
};
|