feat: 实现 Windows 可选字体包安装链
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -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()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./builder.js";
|
||||
export * from "./recipe.js";
|
||||
export * from "./windows-installer.js";
|
||||
|
||||
@@ -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<void>((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<WindowsFontPackInstallerReport> {
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user