// 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; });