Files
peoplelib/build-mac.js
T
lofyer 381c07733a feat: 支持 macOS arm64 打包,修复图标未入库
新增 build-mac.js,产出 ad-hoc 签名的 .app 与 DMG。只能在 macOS 上
构建:DMG 依赖 hdiutil,且 Apple Silicon 拒绝执行未签名二进制,改名
与改 plist 后必须用 codesign 重签。

按官方 darwin-arm64 运行时的真实结构处理四处易错点:用 ditto 解压以
保留 framework 符号链接、删除重打包后失效的 ElectronAsarIntegrity、
为改名后的 helper 补上 CFBundleExecutable、签名由内向外且单独签
Squirrel 的 ShipIt。

打包版数据目录改为按平台决定:macOS 走 appData,避免写进 DMG 挂载后
只读的 .app 内部并在升级覆盖时丢失书库;Windows 便携版行为不变。
窗口图标在非 Windows 平台改用 PNG。

.gitignore 的 dist 规则补上前导斜杠。此前它同时匹配 icons/dist/,
导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
2026-08-03 16:18:47 +08:00

323 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// macOS arm64 打包:产出 PeopleLib.app 与 DMG。
// 必须在 macOS 上运行:DMG 由 hdiutil 生成,且 Apple Silicon 内核会拒绝执行
// 未签名的二进制,改名和塞文件都会让 Electron 原始签名失效,必须重新签。
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = __dirname;
const pkg = require('./package.json');
const PRODUCT = pkg.productName || 'PeopleLib';
const APP_ID = 'com.peoplelib.client';
const ARCH = 'arm64';
const TARGET = `${PRODUCT}-macos-${ARCH}`;
const OUT = path.join(ROOT, 'dist', TARGET);
const APP_BUNDLE = path.join(OUT, `${PRODUCT}.app`);
const CONTENTS = path.join(APP_BUNDLE, 'Contents');
const RESOURCES = path.join(CONTENTS, 'Resources');
const APP = path.join(RESOURCES, 'app');
const DMG = path.join(ROOT, 'dist', `${TARGET}.dmg`);
const KEEP_LOCALES = new Set(['zh_CN', 'en', 'en_GB', 'zh_TW']);
function run(cmd, args, options) {
const result = spawnSync(cmd, args, { stdio: 'inherit', ...options });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status}`);
}
function capture(cmd, args) {
const result = spawnSync(cmd, args, { encoding: 'utf8' });
if (result.error) throw result.error;
return { status: result.status, out: `${result.stdout || ''}${result.stderr || ''}` };
}
function requireMac() {
if (process.platform !== 'darwin') {
throw new Error(
'macOS 打包只能在 macOS 上执行:DMG 需要 hdiutil,且 Apple Silicon 要求重新签名(codesign)。'
);
}
for (const tool of ['hdiutil', 'codesign', 'ditto']) {
if (capture('which', [tool]).status !== 0) throw new Error(`缺少 ${tool},请先安装 Xcode 命令行工具`);
}
}
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
function copyDir(src, dst, skip) {
fs.mkdirSync(dst, { recursive: true });
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
if (skip && skip(e)) continue;
const s = path.join(src, e.name);
const d = path.join(dst, e.name);
if (e.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(s), d);
else if (e.isDirectory()) copyDir(s, d, skip);
else fs.copyFileSync(s, d);
}
}
function skipDevFiles(e) {
const name = e.name.toLowerCase();
if (e.isDirectory()) return false;
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
if (/\.(md|markdown)$/.test(name)) return true;
return false;
}
function copyUndici(dst) {
const src = path.join(ROOT, 'node_modules', 'undici');
fs.mkdirSync(dst, { recursive: true });
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
const file = path.join(src, name);
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dst, name));
}
copyDir(path.join(src, 'lib'), path.join(dst, 'lib'), skipDevFiles);
}
function copyFoliate(dst) {
const src = path.join(ROOT, 'node_modules', 'foliate-js');
fs.mkdirSync(path.join(dst, 'vendor'), { recursive: true });
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
fs.copyFileSync(path.join(src, name), path.join(dst, name));
}
fs.copyFileSync(path.join(src, 'vendor', 'fflate.js'), path.join(dst, 'vendor', 'fflate.js'));
}
// 官方 zip 里 Electron Framework 带符号链接,必须用 ditto 解压;
// unzip 或 Node 解压会把链接展开成副本,签名随即失效。
function ensureRuntime() {
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
if (!version) throw new Error('package.json 未锁定 electron 版本');
const cacheDir = path.join(ROOT, 'node_modules', '.cache', 'electron-darwin-arm64', version);
const source = path.join(cacheDir, 'Electron.app');
if (fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) return source;
const mirror = process.env.ELECTRON_MIRROR
|| process.env.npm_config_electron_mirror
|| 'https://npmmirror.com/mirrors/electron/';
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-darwin-${ARCH}.zip`;
const zip = path.join(cacheDir, 'electron.zip');
fs.mkdirSync(cacheDir, { recursive: true });
console.log(`下载 Electron ${version} (darwin-${ARCH})...`);
run('curl', ['-fSL', '--retry', '3', '-o', zip, url]);
console.log('解压运行时(ditto 保留符号链接与权限)...');
run('ditto', ['-x', '-k', zip, cacheDir]);
fs.rmSync(zip, { force: true });
if (!fs.existsSync(path.join(source, 'Contents', 'MacOS', 'Electron'))) {
throw new Error('Electron 运行时解压结果无效');
}
return source;
}
function pruneLproj(dir) {
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.lproj')) continue;
if (KEEP_LOCALES.has(name.slice(0, -6))) continue;
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
}
}
function plistString(key, value) {
return `\t<key>${key}</key>\n\t<string>${value}</string>`;
}
function writeInfoPlist() {
const file = path.join(CONTENTS, 'Info.plist');
let plist = fs.readFileSync(file, 'utf8');
const replacements = {
CFBundleDisplayName: PRODUCT,
CFBundleExecutable: PRODUCT,
CFBundleName: PRODUCT,
CFBundleIdentifier: APP_ID,
CFBundleIconFile: 'app.icns',
CFBundleShortVersionString: pkg.version,
CFBundleVersion: pkg.version,
LSApplicationCategoryType: 'public.app-category.productivity'
};
for (const [key, value] of Object.entries(replacements)) {
const re = new RegExp(`\\t<key>${key}</key>\\n\\t<string>[^<]*</string>`);
if (!re.test(plist)) throw new Error(`Info.plist 缺少 ${key}`);
plist = plist.replace(re, plistString(key, value));
}
// 重打包后 asar 哈希不再匹配,保留该字段会让 Electron 启动即报完整性错误。
plist = plist.replace(
/\t<key>ElectronAsarIntegrity<\/key>\n\t<dict>[\s\S]*?\n\t<\/dict>\n/,
''
);
fs.writeFileSync(file, plist);
}
function writeHelperPlists() {
const frameworks = path.join(CONTENTS, 'Frameworks');
for (const entry of fs.readdirSync(frameworks)) {
if (!entry.endsWith('.app')) continue;
const suffix = /\(([^)]+)\)/.exec(entry);
const label = suffix ? ` (${suffix[1]})` : '';
const oldName = entry.slice(0, -4);
const newName = `${PRODUCT} Helper${label}`;
const appDir = path.join(frameworks, entry);
const file = path.join(appDir, 'Contents', 'Info.plist');
let plist = fs.readFileSync(file, 'utf8');
plist = plist.replace(
/\t<key>CFBundleIdentifier<\/key>\n\t<string>[^<]*<\/string>/,
plistString('CFBundleIdentifier', `${APP_ID}.helper`)
);
plist = plist.replace(
/\t<key>CFBundleName<\/key>\n\t<string>[^<]*<\/string>/,
plistString('CFBundleName', newName)
);
// 官方 helper plist 没有 CFBundleExecutable,靠 bundle 名推断可执行文件名,
// 改名后必须显式写死,否则 helper 进程起不来,界面一片空白。
if (!/CFBundleExecutable/.test(plist)) {
plist = plist.replace(
/\t<key>CFBundleIdentifier<\/key>/,
`${plistString('CFBundleExecutable', newName)}\n\t<key>CFBundleIdentifier</key>`
);
}
fs.writeFileSync(file, plist);
fs.renameSync(
path.join(appDir, 'Contents', 'MacOS', oldName),
path.join(appDir, 'Contents', 'MacOS', newName)
);
fs.renameSync(appDir, path.join(frameworks, `${newName}.app`));
}
}
// 必须由内向外签:嵌套可执行文件 -> helper -> framework -> 外层 .app。
// 顺序反了会让外层签名立刻失效,Apple Silicon 上表现为应用被内核直接杀掉。
function signBundle() {
const frameworks = path.join(CONTENTS, 'Frameworks');
const targets = [];
for (const entry of fs.readdirSync(frameworks)) {
const full = path.join(frameworks, entry);
if (entry.endsWith('.app')) {
targets.push(path.join(full, 'Contents', 'MacOS', entry.slice(0, -4)));
targets.push(full);
continue;
}
if (!entry.endsWith('.framework')) continue;
// Squirrel 在 Resources 里藏了独立的 ShipIt 可执行文件,
// 不单独签会让 --deep --strict 校验失败。
const shipIt = path.join(full, 'Versions', 'A', 'Resources', 'ShipIt');
if (fs.existsSync(shipIt)) targets.push(shipIt);
// 带版本的 framework 要签 Versions/A,直接签 .framework 顶层可能被判定
// 为 bundle format unrecognized。
const versioned = path.join(full, 'Versions', 'A');
targets.push(fs.existsSync(versioned) ? versioned : full);
}
targets.push(APP_BUNDLE);
for (const target of targets) {
run('codesign', ['--force', '--sign', '-', '--timestamp=none', target]);
}
run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', APP_BUNDLE]);
}
function buildDmg() {
rimraf(DMG);
const staging = path.join(ROOT, 'dist', `${TARGET}-dmg`);
rimraf(staging);
fs.mkdirSync(staging, { recursive: true });
run('ditto', [APP_BUNDLE, path.join(staging, `${PRODUCT}.app`)]);
fs.symlinkSync('/Applications', path.join(staging, 'Applications'));
run('hdiutil', [
'create', '-volname', `${PRODUCT} ${pkg.version}`,
'-srcfolder', staging, '-ov', '-format', 'UDZO', '-fs', 'HFS+', DMG
]);
rimraf(staging);
}
async function build() {
requireMac();
const runtime = ensureRuntime();
console.log('清理输出目录...');
rimraf(OUT);
fs.mkdirSync(OUT, { recursive: true });
console.log('复制 Electron.app...');
run('ditto', [runtime, APP_BUNDLE]);
console.log('精简语言包...');
pruneLproj(RESOURCES);
pruneLproj(path.join(
CONTENTS, 'Frameworks', 'Electron Framework.framework', 'Versions', 'A', 'Resources'
));
console.log('应用图标与 Info.plist...');
fs.rmSync(path.join(RESOURCES, 'electron.icns'), { force: true });
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', 'book-ai-dark.icns'), path.join(RESOURCES, 'app.icns'));
writeInfoPlist();
writeHelperPlists();
console.log('重命名主可执行文件...');
fs.renameSync(path.join(CONTENTS, 'MacOS', 'Electron'), path.join(CONTENTS, 'MacOS', PRODUCT));
fs.rmSync(path.join(RESOURCES, 'default_app.asar'), { force: true });
console.log('组装 app 源码...');
fs.mkdirSync(APP, { recursive: true });
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
const iconDir = path.join(APP, 'icons', 'dist');
fs.mkdirSync(iconDir, { recursive: true });
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
fs.copyFileSync(path.join(ROOT, 'icons', 'dist', name), path.join(iconDir, name));
}
for (const theme of ['dark', 'light']) {
const themeDir = path.join(iconDir, theme);
fs.mkdirSync(themeDir, { recursive: true });
// macOS 的 BrowserWindow 图标用 PNG256 供窗口,32 供渲染层复用
for (const size of [32, 256]) {
fs.copyFileSync(
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
path.join(themeDir, `icon-${size}.png`)
);
}
}
copyUndici(path.join(APP, 'node_modules', 'undici'));
copyFoliate(path.join(APP, 'node_modules', 'foliate-js'));
fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({
name: pkg.name, version: pkg.version, description: pkg.description,
productName: PRODUCT,
main: 'main.js', author: pkg.author, license: pkg.license,
dependencies: {
undici: pkg.dependencies.undici,
'foliate-js': pkg.dependencies['foliate-js']
}
}, null, 2));
console.log('Ad-hoc 签名...');
signBundle();
console.log('生成 DMG...');
buildDmg();
console.log('\n构建完成:');
console.log(' 应用:', APP_BUNDLE);
console.log(' DMG :', DMG, `(${(fs.statSync(DMG).size / 1024 / 1024).toFixed(1)} MB)`);
console.log('\n首次打开:右键点按图标选“打开”,或执行');
console.log(` xattr -dr com.apple.quarantine "/Applications/${PRODUCT}.app"`);
}
build().catch((error) => {
console.error('构建失败:', error && error.message ? error.message : error);
process.exitCode = 1;
});