Files
peoplelib/build-mac.js
T

327 lines
12 KiB
JavaScript
Raw 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'));
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 图标用 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;
});