diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 0d7c533..08c82c7 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -1,3 +1,67 @@ +!ifdef FONT_PACK_COMPANION_FILE +!ifndef BUILD_UNINSTALLER + !include "FileFunc.nsh" + !include "nsDialogs.nsh" + + Var fontPackCompanionAvailable + Var fontPackCompanionCheckbox + Var fontPackCompanionExitCode + Var fontPackCompanionHash + Var fontPackCompanionPath + Var fontPackCompanionSelected + + !macro customInit + StrCpy $fontPackCompanionAvailable "0" + StrCpy $fontPackCompanionSelected "0" + StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}" + IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done + ${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath" + StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done + StrCpy $fontPackCompanionAvailable "1" + IfSilent font_pack_silent font_pack_interactive + font_pack_interactive: + StrCpy $fontPackCompanionSelected "1" + Goto font_pack_init_done + font_pack_silent: + ${GetParameters} $R0 + ${GetOptions} $R0 "/WITHFONTPACK=" $R1 + StrCmp $R1 "1" 0 font_pack_init_done + StrCpy $fontPackCompanionSelected "1" + font_pack_init_done: + !macroend + + Function FontPackCompanionPage + StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip + nsDialogs::Create 1018 + Pop $R0 + StrCmp $R0 "error" 0 +2 + Abort + ${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。" + Pop $R0 + ${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}" + Pop $fontPackCompanionCheckbox + ${NSD_Check} $fontPackCompanionCheckbox + nsDialogs::Show + Return + font_pack_page_skip: + Abort + FunctionEnd + + Function FontPackCompanionPageLeave + ${NSD_GetState} $fontPackCompanionCheckbox $R0 + StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked + StrCpy $fontPackCompanionSelected "1" + Return + font_pack_page_unchecked: + StrCpy $fontPackCompanionSelected "0" + FunctionEnd + + !macro customPageAfterChangeDir + Page custom FontPackCompanionPage FontPackCompanionPageLeave + !macroend +!endif +!endif + !macro customInstall WriteRegStr SHCTX \ "Software\Classes\MarkdownPdfExporter.Markdown" \ @@ -43,6 +107,24 @@ "" \ "$INSTDIR\md-to-pdf.exe" StrCpy $launchLink "$INSTDIR\md-to-pdf.exe" + !ifdef FONT_PACK_COMPANION_FILE + StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done + StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done + ${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath" + StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed + DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}" + ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode + StrCmp $fontPackCompanionExitCode "0" font_pack_install_done + DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode" + IfSilent font_pack_install_done 0 + MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。" + Goto font_pack_install_done + font_pack_integrity_failed: + DetailPrint "可选字体包完整性复核失败,已跳过安装。" + IfSilent font_pack_install_done 0 + MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。" + font_pack_install_done: + !endif System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' !macroend diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 60d9bad..d59018b 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -5,6 +5,8 @@ const windowsIcon = path.resolve( __dirname, "../../logos/desktop/windows/app.ico" ); +const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() || + "build/installer.nsh"; module.exports = { appId: "com.md-to-pdf.desktop", @@ -71,7 +73,7 @@ module.exports = { installerIcon: windowsIcon, uninstallerIcon: windowsIcon, installerLanguages: ["zh_CN"], - include: "build/installer.nsh", + include: nsisInclude, artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}` } }; diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts index 01b2d81..7574b1d 100644 --- a/apps/desktop/tests/release-build.test.ts +++ b/apps/desktop/tests/release-build.test.ts @@ -58,6 +58,28 @@ describe("桌面发行构建链", () => { expect(builderConfig).toContain('to: "samples"'); }); + it("仅在组合构建时接入经过哈希校验的同目录字体包", () => { + const builderConfig = readFileSync( + `${desktopRoot}/electron-builder.config.cjs`, + "utf8" + ); + const installerInclude = readFileSync( + `${desktopRoot}/build/installer.nsh`, + "utf8" + ); + const companionBuild = readFileSync( + `${projectRoot}/scripts/build-desktop-with-font-pack.mjs`, + "utf8" + ); + + expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE"); + expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256"); + expect(installerInclude).toContain("StdUtils.HashFile"); + expect(installerInclude).toContain("/WITHFONTPACK="); + expect(companionBuild).toContain("createDesktopCompanionInclude"); + expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE"); + }); + it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => { const buildScript = readFileSync( `${desktopRoot}/scripts/build-main.mjs`, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 46703c4..dfae03e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -323,6 +323,29 @@ FP3 只处理独立 NSIS 字体包、Docker 可选挂载及其安装/升级/卸 本阶段未修改 NSIS 或 Docker;下一步 FP3-B 实现独立用户级字体包安装、 升级、卸载和主安装器同目录可选入口。 +阶段 12D-FP3-B 已实现 Windows 独立字体包安装与 Desktop 同目录可选入口。 +字体包构建器复用 electron-builder `26.15.3` 锁定并校验的 NSIS 3.0.4.1, +不要求系统安装 NSIS,也不引入第二套 Electron。独立安装器以当前用户权限 +将完整 `mdtp-serif-sc` ID 安装到 +`%LOCALAPPDATA%/md-to-pdf/font-packs/`,不注册系统字体;安装使用暂存、 +备份、原子提升和失败回滚,同版本重装幂等,独立卸载只清理该 ID。隔离 +根目录验收中,首次静默安装、同版本重装和卸载退出码均为 0,注册器复核 +的字体包指纹仍为 +`67adac137fa74ca54b4ab07dd584ae87657a1aec413b7f125141c5351fa5b89d`。 + +主安装器通过生成的 NSIS include 绑定同目录字体包安装器的精确文件名与 +SHA-256;交互模式仅在文件存在且哈希正确时显示默认勾选项,并在执行前 +再次复核哈希。静默主安装默认跳过,只有 `/WITHFONTPACK=1` 才安装;字体 +包失败不会回滚主程序。统一组合命令为 +`npm run desktop:make:with-font-pack`,普通 Desktop 构建不启用伴随逻辑。 +真实组合产物中,主安装器为 115,328,133 字节,独立字体包安装器为 +24,489,062 字节;桌面解包目录未包含字体二进制或字体包安装器。Windows +实机安装向导确认可选页位于安装目录页之后,说明文字完整且字体包默认 +勾选;未执行主程序重装。全仓 465 项测试、类型检查、生产构建和组合发行 +构建通过。下一步 FP3-C 实现 Docker 可选字体包挂载与部署门禁。 +本阶段按版本策略未提前修改应用的 `0.5.1` 版本号;正式 `v0.6.0` 发布 +提交升级版本后,字体包才满足其 `minimumAppVersion=0.6.0` 运行时门禁。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1279,8 +1302,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的 同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证; - 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、 - 同版本不可变门禁和真实资产构建;下一步 FP3-B 实现独立 NSIS 字体包, - FP3-C 再实现 Docker 可选挂载,最终由 R4 完成 14 套主题深度视觉回归; + 同版本不可变门禁和真实资产构建; +- 阶段 12D-FP3-B:已完成独立用户级 NSIS 字体包、原子安装/升级/卸载、 + 主安装器同目录哈希校验和显式静默开关;下一步 FP3-C 实现 Docker 可选 + 挂载,最终由 R4 完成 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/font-packs/README.md b/font-packs/README.md index 8d212db..164f20c 100644 --- a/font-packs/README.md +++ b/font-packs/README.md @@ -45,3 +45,52 @@ root///font-pack.json 当前 `mdtp-serif-sc@1.0.0` 只替换 `FandolSong` 与 `Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免 错误替换仿宋、黑体、楷体或普通外部主题字体。 + +## Windows 独立安装器 + +发布人员可以使用 electron-builder 锁定并校验的 NSIS 工具链构建独立 +字体包安装器,不需要额外安装系统 NSIS: + +```powershell +npm run build:font-pack:windows +``` + +默认产物为: + +```text +output/font-packs/windows/ +└── md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe +``` + +安装器使用当前用户权限,将整个 `mdtp-serif-sc` ID 安装到: + +```text +%LOCALAPPDATA%/md-to-pdf/font-packs/mdtp-serif-sc/1.0.0/ +``` + +它不向 Windows 系统字体目录注册字体。同一版本重装保持幂等,升级时先把 +该 ID 的现有目录移入备份,再原子提升新版本;失败会回滚。独立卸载项只 +删除安装器拥有的 `mdtp-serif-sc` ID,不影响其他字体包 ID。安装器支持 +`/S` 静默安装和卸载。 + +## Desktop 伴随发行 + +组合构建命令为: + +```powershell +npm run desktop:make:with-font-pack +``` + +该命令把主安装器和字体包安装器作为两个同目录文件发行。主安装器只编译 +字体包文件名与 SHA-256,不嵌入字体二进制;仅当同目录文件存在且哈希完全 +一致时,交互安装向导才显示默认勾选的字体包选项。字体包失败不回滚已安装 +的主程序。 + +主安装器静默运行时默认不安装伴随字体包;只有显式传入以下参数才会安装: + +```powershell +md-to-pdf--x86_64-Setup.exe /S /WITHFONTPACK=1 +``` + +普通 `npm run make -w @md-to-pdf/desktop` 不启用伴随逻辑,避免本地残留的 +生成文件影响常规桌面构建。 diff --git a/font-packs/windows/font-pack-installer.nsi b/font-packs/windows/font-pack-installer.nsi new file mode 100644 index 0000000..d657d2d --- /dev/null +++ b/font-packs/windows/font-pack-installer.nsi @@ -0,0 +1,132 @@ +Unicode true +RequestExecutionLevel user +SetCompressor /SOLID lzma +SetCompressorDictSize 64 + +!include "MUI2.nsh" +!include "LogicLib.nsh" + +!ifndef PACK_DIRECTORY + !error "PACK_DIRECTORY is required" +!endif +!ifndef PACK_ID + !error "PACK_ID is required" +!endif +!ifndef PACK_VERSION + !error "PACK_VERSION is required" +!endif +!ifndef PACK_NAME + !error "PACK_NAME is required" +!endif +!ifndef PACK_LICENSE_FILE + !error "PACK_LICENSE_FILE is required" +!endif +!ifndef OUTPUT_FILE + !error "OUTPUT_FILE is required" +!endif +!ifndef FONT_PACK_ROOT + !define FONT_PACK_ROOT "$LOCALAPPDATA\md-to-pdf\font-packs" +!endif +!ifndef MANAGER_ROOT + !define MANAGER_ROOT "$LOCALAPPDATA\md-to-pdf\font-pack-installers" +!endif + +Name "Markdown PDF 导出器字体包 - ${PACK_NAME}" +OutFile "${OUTPUT_FILE}" +InstallDir "${MANAGER_ROOT}\${PACK_ID}" +ShowInstDetails show +ShowUninstDetails show +BrandingText "Markdown PDF 导出器" + +!ifdef ICON_FILE + Icon "${ICON_FILE}" + UninstallIcon "${ICON_FILE}" + !define MUI_ICON "${ICON_FILE}" + !define MUI_UNICON "${ICON_FILE}" +!endif + +!define MUI_ABORTWARNING +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "${PACK_LICENSE_FILE}" +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "SimpChinese" + +VIProductVersion "1.0.0.0" +VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "ProductName" "Markdown PDF 导出器字体包" +VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "FileDescription" "${PACK_NAME}" +VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "FileVersion" "${PACK_VERSION}" +VIAddVersionKey /LANG=${LANG_SIMPCHINESE} "LegalCopyright" "Copyright © YIXIONG Tech.ltd" + +Section "安装字体包" SEC_FONT_PACK + SetShellVarContext current + StrCpy $R0 "${FONT_PACK_ROOT}\.installing-${PACK_ID}" + StrCpy $R1 "${FONT_PACK_ROOT}\${PACK_ID}" + StrCpy $R2 "${FONT_PACK_ROOT}\.backup-${PACK_ID}" + + CreateDirectory "${FONT_PACK_ROOT}" + RMDir /r "$R0" + RMDir /r "$R2" + CreateDirectory "$R0\${PACK_ID}\${PACK_VERSION}" + SetOutPath "$R0\${PACK_ID}\${PACK_VERSION}" + File /r "${PACK_DIRECTORY}\*.*" + SetOutPath "$TEMP" + IfFileExists "$R0\${PACK_ID}\${PACK_VERSION}\font-pack.json" +3 0 + RMDir /r "$R0" + Abort "字体包暂存校验失败:缺少 font-pack.json" + + ClearErrors + IfFileExists "$R1\*.*" 0 promote_pack + Rename "$R1" "$R2" + IfErrors install_failed + + promote_pack: + ClearErrors + Rename "$R0\${PACK_ID}" "$R1" + IfErrors rollback_pack + RMDir /r "$R0" + RMDir /r "$R2" + + SetOutPath "$INSTDIR" + WriteUninstaller "$INSTDIR\Uninstall.exe" + FileOpen $R3 "$INSTDIR\installed-version.txt" w + FileWrite $R3 "${PACK_VERSION}$\r$\n" + FileClose $R3 + + !ifdef REGISTER_UNINSTALL + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "DisplayName" "Markdown PDF 导出器字体包 - ${PACK_NAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "DisplayVersion" "${PACK_VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "Publisher" "YIXIONG Tech.ltd" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "UninstallString" '"$INSTDIR\Uninstall.exe"' + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" "NoRepair" 1 + !endif + Goto install_done + + rollback_pack: + RMDir /r "$R1" + IfFileExists "$R2\*.*" 0 install_failed + Rename "$R2" "$R1" + + install_failed: + RMDir /r "$R0" + Abort "字体包安装失败,原版本已保留。" + + install_done: +SectionEnd + +Section "Uninstall" + SetShellVarContext current + RMDir /r "${FONT_PACK_ROOT}\${PACK_ID}" + RMDir /r "${FONT_PACK_ROOT}\.installing-${PACK_ID}" + RMDir /r "${FONT_PACK_ROOT}\.backup-${PACK_ID}" + !ifdef REGISTER_UNINSTALL + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\md-to-pdf-font-pack-${PACK_ID}" + !endif + Delete "$INSTDIR\installed-version.txt" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR" +SectionEnd diff --git a/package-lock.json b/package-lock.json index b075d06..159148c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5631,7 +5631,6 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", @@ -10037,6 +10036,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", + "app-builder-lib": "26.15.3", "vitest": "^4.1.10" } }, diff --git a/package.json b/package.json index 472ce72..bce46d3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "desktop:package": "npm run package -w @md-to-pdf/desktop", "build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build", "verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify", + "build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer", + "desktop:make:with-font-pack": "npm run build:font-pack:windows && node scripts/build-desktop-with-font-pack.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", diff --git a/packages/font-pack-builder/package.json b/packages/font-pack-builder/package.json index 49febdb..2b60114 100644 --- a/packages/font-pack-builder/package.json +++ b/packages/font-pack-builder/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", + "app-builder-lib": "26.15.3", "vitest": "^4.1.10" } } diff --git a/packages/font-pack-builder/src/cli.ts b/packages/font-pack-builder/src/cli.ts index c759051..4bbfdc2 100644 --- a/packages/font-pack-builder/src/cli.ts +++ b/packages/font-pack-builder/src/cli.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { buildFontPack, verifyFontPack } from "./builder.js"; +import { buildWindowsFontPackInstaller } from "./windows-installer.js"; function argumentsMap(values: string[]) { const result = new Map(); @@ -61,7 +62,31 @@ async function main() { packVersion: option(values, "pack-version", "1.0.0") }); } - throw new Error("命令必须是 build 或 verify"); + if (command === "windows-installer") { + return buildWindowsFontPackInstaller({ + packRoot: resolve(option(values, "root", "output/font-packs/root")), + appVersion: option(values, "app-version", "0.6.0"), + packId: option(values, "pack-id", "mdtp-serif-sc"), + packVersion: option(values, "pack-version", "1.0.0"), + scriptPath: resolve( + option(values, "script", "font-packs/windows/font-pack-installer.nsi") + ), + outputDirectory: resolve( + option(values, "output", "output/font-packs/windows") + ), + iconPath: resolve( + option(values, "icon", "logos/desktop/windows/app.ico") + ), + reportPath: resolve( + option( + values, + "report", + "output/font-packs/windows-installer-report.json" + ) + ) + }); + } + throw new Error("命令必须是 build、verify 或 windows-installer"); } main() diff --git a/packages/font-pack-builder/src/index.ts b/packages/font-pack-builder/src/index.ts index 7e69f6a..31f0c40 100644 --- a/packages/font-pack-builder/src/index.ts +++ b/packages/font-pack-builder/src/index.ts @@ -1,2 +1,3 @@ export * from "./builder.js"; export * from "./recipe.js"; +export * from "./windows-installer.js"; diff --git a/packages/font-pack-builder/src/windows-installer.ts b/packages/font-pack-builder/src/windows-installer.ts new file mode 100644 index 0000000..218b30e --- /dev/null +++ b/packages/font-pack-builder/src/windows-installer.ts @@ -0,0 +1,219 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { getMakeNsisPath } from "app-builder-lib/out/toolsets/windows.js"; +import { fontPackManifestSchema } from "@md-to-pdf/font-pack-registry"; +import { verifyFontPack } from "./builder.js"; + +export const WINDOWS_FONT_PACK_ARCHITECTURE = "x86_64"; + +export interface BuildWindowsFontPackInstallerOptions { + packRoot: string; + appVersion: string; + packId: string; + packVersion: string; + scriptPath: string; + outputDirectory: string; + iconPath?: string; + reportPath?: string; + fontPackRoot?: string; + managerRoot?: string; + registerUninstall?: boolean; +} + +export interface WindowsFontPackInstallerReport { + packId: string; + packVersion: string; + artifactPath: string; + artifactName: string; + bytes: number; + sha256: string; + nsisPath: string; +} + +export interface CreateDesktopCompanionIncludeOptions { + installerPath: string; + baseIncludePath: string; + outputPath: string; + displayName: string; +} + +function sha256(content: Uint8Array) { + return createHash("sha256").update(content).digest("hex").toUpperCase(); +} + +function assertDefineValue(value: string, label: string) { + if (value.includes('"') || value.includes("\r") || value.includes("\n")) { + throw new Error(`${label} 包含 NSIS 定义不支持的字符`); + } + return value; +} + +function define(name: string, value: string) { + return `/D${name}=${assertDefineValue(value, name)}`; +} + +export function windowsFontPackInstallerName(packId: string, version: string) { + return `md-to-pdf-font-pack-${packId}-${version}-${WINDOWS_FONT_PACK_ARCHITECTURE}-Setup.exe`; +} + +export function createWindowsFontPackInstallerArguments(options: { + packDirectory: string; + packId: string; + packVersion: string; + packName: string; + licensePath: string; + outputPath: string; + scriptPath: string; + iconPath?: string; + fontPackRoot?: string; + managerRoot?: string; + registerUninstall?: boolean; +}) { + const argumentsList = [ + "/V2", + "/INPUTCHARSET", + "UTF8", + define("PACK_DIRECTORY", resolve(options.packDirectory)), + define("PACK_ID", options.packId), + define("PACK_VERSION", options.packVersion), + define("PACK_NAME", options.packName), + define("PACK_LICENSE_FILE", resolve(options.licensePath)), + define("OUTPUT_FILE", resolve(options.outputPath)) + ]; + if (options.iconPath) { + argumentsList.push(define("ICON_FILE", resolve(options.iconPath))); + } + if (options.fontPackRoot) { + argumentsList.push(define("FONT_PACK_ROOT", resolve(options.fontPackRoot))); + } + if (options.managerRoot) { + argumentsList.push(define("MANAGER_ROOT", resolve(options.managerRoot))); + } + if (options.registerUninstall !== false) { + argumentsList.push("/DREGISTER_UNINSTALL"); + } + argumentsList.push(resolve(options.scriptPath)); + return argumentsList; +} + +async function runProcess( + executable: string, + argumentsList: string[], + environment?: NodeJS.ProcessEnv +) { + await new Promise((resolvePromise, reject) => { + const child = spawn(executable, argumentsList, { + env: environment, + stdio: "inherit", + windowsHide: true + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + } else { + reject( + new Error( + `NSIS 编译失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}` + ) + ); + } + }); + }); +} + +export async function buildWindowsFontPackInstaller( + options: BuildWindowsFontPackInstallerOptions +): Promise { + if (process.platform !== "win32") { + throw new Error("字体包 NSIS 安装器只能在 Windows 上构建"); + } + const verified = await verifyFontPack({ + root: options.packRoot, + appVersion: options.appVersion, + packId: options.packId, + packVersion: options.packVersion + }); + const packDirectory = resolve(verified.outputDirectory); + const manifestPath = join(packDirectory, "font-pack.json"); + const manifest = fontPackManifestSchema.parse( + JSON.parse(await readFile(manifestPath, "utf8")) as unknown + ); + const scriptPath = resolve(options.scriptPath); + const scriptStatus = await lstat(scriptPath); + if (!scriptStatus.isFile() || scriptStatus.isSymbolicLink()) { + throw new Error("字体包 NSIS 脚本不是受信任的普通文件"); + } + const outputDirectory = resolve(options.outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + const artifactName = windowsFontPackInstallerName( + options.packId, + options.packVersion + ); + const artifactPath = join(outputDirectory, artifactName); + const nsis = await getMakeNsisPath(null); + const argumentsList = createWindowsFontPackInstallerArguments({ + packDirectory, + packId: manifest.id, + packVersion: manifest.version, + packName: manifest.name, + licensePath: join(packDirectory, manifest.licenseFile), + outputPath: artifactPath, + scriptPath, + ...(options.iconPath ? { iconPath: options.iconPath } : {}), + ...(options.fontPackRoot ? { fontPackRoot: options.fontPackRoot } : {}), + ...(options.managerRoot ? { managerRoot: options.managerRoot } : {}), + ...(options.registerUninstall !== undefined + ? { registerUninstall: options.registerUninstall } + : {}) + }); + await runProcess(nsis.path, argumentsList, { + ...process.env, + ...nsis.env + }); + const artifact = await readFile(artifactPath); + const artifactStatus = await stat(artifactPath); + const report: WindowsFontPackInstallerReport = { + packId: manifest.id, + packVersion: manifest.version, + artifactPath, + artifactName, + bytes: artifactStatus.size, + sha256: sha256(artifact), + nsisPath: nsis.path + }; + if (options.reportPath) { + const reportPath = resolve(options.reportPath); + await mkdir(dirname(reportPath), { recursive: true }); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + return report; +} + +export async function createDesktopCompanionInclude( + options: CreateDesktopCompanionIncludeOptions +) { + const installerPath = resolve(options.installerPath); + const status = await lstat(installerPath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error("字体包伴随安装器不是受信任的普通文件"); + } + const installerHash = sha256(await readFile(installerPath)); + const include = [ + `!define FONT_PACK_COMPANION_FILE "${assertDefineValue(basename(installerPath), "伴随安装器文件名")}"`, + `!define FONT_PACK_COMPANION_SHA256 "${installerHash}"`, + `!define FONT_PACK_COMPANION_NAME "${assertDefineValue(options.displayName, "伴随安装器名称")}"`, + `!include "${assertDefineValue(resolve(options.baseIncludePath), "基础安装脚本路径")}"`, + "" + ].join("\n"); + const outputPath = resolve(options.outputPath); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, include, "utf8"); + return { + outputPath, + installerPath, + installerHash + }; +} diff --git a/packages/font-pack-builder/tests/windows-installer.test.ts b/packages/font-pack-builder/tests/windows-installer.test.ts new file mode 100644 index 0000000..3006373 --- /dev/null +++ b/packages/font-pack-builder/tests/windows-installer.test.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createDesktopCompanionInclude, + createWindowsFontPackInstallerArguments, + windowsFontPackInstallerName +} from "../src/index.js"; + +const temporaryDirectories: string[] = []; +const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ); +}); + +describe("Windows 字体包安装器构建", () => { + it("保持用户级原子安装和精确卸载边界", async () => { + const script = await readFile( + join(repositoryRoot, "font-packs", "windows", "font-pack-installer.nsi"), + "utf8" + ); + expect(script).toContain( + '$LOCALAPPDATA\\md-to-pdf\\font-packs' + ); + expect(script).toContain(".installing-${PACK_ID}"); + expect(script).toContain(".backup-${PACK_ID}"); + expect(script).toContain('Rename "$R0\\${PACK_ID}" "$R1"'); + expect(script).toContain( + 'RMDir /r "${FONT_PACK_ROOT}\\${PACK_ID}"' + ); + expect(script).not.toContain("Windows\\Fonts"); + }); + + it("生成稳定发行文件名和完整 NSIS 定义", () => { + expect(windowsFontPackInstallerName("mdtp-serif-sc", "1.0.0")).toBe( + "md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe" + ); + const args = createWindowsFontPackInstallerArguments({ + packDirectory: "C:/pack", + packId: "mdtp-serif-sc", + packVersion: "1.0.0", + packName: "测试字体包", + licensePath: "C:/pack/LICENSE.txt", + outputPath: "C:/out/setup.exe", + scriptPath: "C:/repo/font-pack-installer.nsi", + registerUninstall: false + }); + expect(args).toContain("/DPACK_ID=mdtp-serif-sc"); + expect(args).toContain("/DPACK_VERSION=1.0.0"); + expect(args.slice(0, 3)).toEqual(["/V2", "/INPUTCHARSET", "UTF8"]); + expect(args).not.toContain("/DREGISTER_UNINSTALL"); + expect(args.at(-1)).toMatch(/font-pack-installer\.nsi$/u); + }); + + it("生成绑定同目录文件名与 SHA-256 的桌面 include", async () => { + const root = await mkdtemp(join(tmpdir(), "mdtp-font-companion-")); + temporaryDirectories.push(root); + const installer = join(root, "font-setup.exe"); + const baseInclude = join(root, "installer.nsh"); + const output = join(root, "generated", "companion.nsh"); + await Promise.all([ + writeFile(installer, "installer", "utf8"), + writeFile(baseInclude, "!macro customInstall\n!macroend\n", "utf8") + ]); + const result = await createDesktopCompanionInclude({ + installerPath: installer, + baseIncludePath: baseInclude, + outputPath: output, + displayName: "测试字体包" + }); + const expected = createHash("sha256") + .update("installer") + .digest("hex") + .toUpperCase(); + const content = await readFile(output, "utf8"); + expect(result.installerHash).toBe(expected); + expect(content).toContain('FONT_PACK_COMPANION_FILE "font-setup.exe"'); + expect(content).toContain(`FONT_PACK_COMPANION_SHA256 "${expected}"`); + expect(content).toContain(baseInclude); + }); +}); diff --git a/scripts/build-desktop-with-font-pack.mjs b/scripts/build-desktop-with-font-pack.mjs new file mode 100644 index 0000000..1ca3185 --- /dev/null +++ b/scripts/build-desktop-with-font-pack.mjs @@ -0,0 +1,71 @@ +import { copyFile, mkdir, readFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDesktopCompanionInclude } from "../packages/font-pack-builder/dist/index.js"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const desktopRoot = join(repositoryRoot, "apps", "desktop"); +const desktopManifest = JSON.parse( + await readFile(join(desktopRoot, "package.json"), "utf8") +); +const fontInstallerName = + "md-to-pdf-font-pack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"; +const fontInstallerPath = join( + repositoryRoot, + "output", + "font-packs", + "windows", + fontInstallerName +); +const generatedInclude = join( + repositoryRoot, + "output", + "font-packs", + "desktop-companion.nsh" +); + +await createDesktopCompanionInclude({ + installerPath: fontInstallerPath, + baseIncludePath: join(desktopRoot, "build", "installer.nsh"), + outputPath: generatedInclude, + displayName: "MdTP 中文宋体兼容包 1.0.0" +}); + +const npmCli = + process.env.npm_execpath || + join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); +await new Promise((resolvePromise, reject) => { + const child = spawn( + process.execPath, + [npmCli, "run", "make", "-w", "@md-to-pdf/desktop"], + { + cwd: repositoryRoot, + env: { + ...process.env, + MD_TO_PDF_NSIS_INCLUDE: generatedInclude + }, + stdio: "inherit", + windowsHide: true + } + ); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + } else { + reject( + new Error( + `桌面安装器构建失败:${signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`}` + ) + ); + } + }); +}); + +const desktopOutput = join(desktopRoot, "out", `v${desktopManifest.version}`); +await mkdir(desktopOutput, { recursive: true }); +await copyFile(fontInstallerPath, join(desktopOutput, fontInstallerName)); +process.stdout.write( + `主安装器与字体包伴随安装器已生成:${desktopOutput}\n` +);