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