// 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}\n\t${value}`;
}
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}\\n\\t[^<]*`);
if (!re.test(plist)) throw new Error(`Info.plist 缺少 ${key}`);
plist = plist.replace(re, plistString(key, value));
}
// 重打包后 asar 哈希不再匹配,保留该字段会让 Electron 启动即报完整性错误。
plist = plist.replace(
/\tElectronAsarIntegrity<\/key>\n\t[\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(
/\tCFBundleIdentifier<\/key>\n\t[^<]*<\/string>/,
plistString('CFBundleIdentifier', `${APP_ID}.helper`)
);
plist = plist.replace(
/\tCFBundleName<\/key>\n\t[^<]*<\/string>/,
plistString('CFBundleName', newName)
);
// 官方 helper plist 没有 CFBundleExecutable,靠 bundle 名推断可执行文件名,
// 改名后必须显式写死,否则 helper 进程起不来,界面一片空白。
if (!/CFBundleExecutable/.test(plist)) {
plist = plist.replace(
/\tCFBundleIdentifier<\/key>/,
`${plistString('CFBundleExecutable', newName)}\n\tCFBundleIdentifier`
);
}
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'));
fs.copyFileSync(
path.join(ROOT, 'manga-online-preload.js'),
path.join(APP, 'manga-online-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 图标用 PNG;256 供窗口,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;
});