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/,
导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
This commit is contained in:
lofyer
2026-08-03 16:18:47 +08:00
parent 2c5c7b1828
commit 381c07733a
28 changed files with 597 additions and 10 deletions
+146
View File
@@ -142,6 +142,152 @@ test('标准构建入口固定输出目录并保留便携数据', () => {
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
});
function loadPathResolvers(platform, isPackaged) {
return h.extractFns(
mainFile,
'const APP_ICON_DIR =',
'const userDataDir = resolveUserDataDir()',
['iconForTheme', 'resolveUserDataDir'],
`const path = require('path');
const __dirname = ${JSON.stringify(path.join('/opt', 'app'))};
const process = { platform: ${JSON.stringify(platform)} };
const app = {
isPackaged: ${isPackaged},
getPath: (key) => {
if (key === 'exe') return ${JSON.stringify(path.join('/opt', 'portable', 'PeopleLib.exe'))};
if (key === 'appData') return ${JSON.stringify(path.join('/home', 'u', 'AppData'))};
throw new Error('未预期的 getPath: ' + key);
}
};`
);
}
test('macOS 打包版把用户数据放到 appData,不写进只读的 .app 内部', () => {
const appData = path.join('/home', 'u', 'AppData', 'PeopleLib');
// macOS 上 exe 位于 PeopleLib.app/Contents/MacOS/DMG 挂载只读,
// 且覆盖升级会连同用户书库一起删掉,所以绝不能落在可执行文件旁边
assert.strictEqual(loadPathResolvers('darwin', true).resolveUserDataDir(), appData);
assert.strictEqual(loadPathResolvers('linux', true).resolveUserDataDir(), appData);
// Windows 便携版仍旧放在程序同级 data/
assert.strictEqual(
loadPathResolvers('win32', true).resolveUserDataDir(),
path.join('/opt', 'portable', 'data')
);
for (const platform of ['darwin', 'win32']) {
assert.strictEqual(loadPathResolvers(platform, false).resolveUserDataDir(), appData);
}
});
test('窗口图标按平台取用,macOS 不会拿到 .ico', () => {
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
for (const theme of ['dark', 'light']) {
const win = loadPathResolvers('win32', true).iconForTheme(theme);
assert.strictEqual(path.extname(win), '.ico');
assert.strictEqual(path.basename(win), `book-ai-${theme}.ico`);
const mac = loadPathResolvers('darwin', true).iconForTheme(theme);
assert.strictEqual(path.extname(mac), '.png');
// 打包脚本只复制 32 与 256 两个尺寸,取用的那个必须真实存在
assert.ok(
fs.existsSync(path.join(iconRoot, theme, path.basename(mac))),
`缺少 macOS 窗口图标 ${theme}/${path.basename(mac)}`
);
}
// 未知主题回落到 dark,不能拼出不存在的路径
assert.strictEqual(
path.basename(loadPathResolvers('darwin', true).iconForTheme('nope')),
'icon-256.png'
);
});
test('icns 图标容器结构合法且尺寸齐全', () => {
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (const theme of ['dark', 'light']) {
const buf = fs.readFileSync(path.join(iconRoot, `book-ai-${theme}.icns`));
assert.strictEqual(buf.toString('latin1', 0, 4), 'icns', `${theme} 魔数错误`);
// 长度字段写错时 Finder 会静默显示默认图标,必须逐字节核对
assert.strictEqual(buf.readUInt32BE(4), buf.length, `${theme} 长度字段与文件不符`);
const slots = new Map();
for (let off = 8; off < buf.length;) {
const type = buf.toString('latin1', off, off + 4);
const len = buf.readUInt32BE(off + 4);
assert.ok(len >= 8 && off + len <= buf.length, `${theme}${type} 长度非法`);
const payload = buf.subarray(off + 8, off + len);
assert.ok(payload.subarray(0, 8).equals(PNG), `${theme}${type} 不是 PNG`);
slots.set(type, payload.readUInt32BE(16));
off += len;
}
for (const [type, size] of [['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024]]) {
assert.strictEqual(slots.get(type), size, `${theme} 缺少 ${type}/${size}`);
}
}
});
test('图标产物不被 dist 规则忽略,构建脚本引用的文件都在仓库里', () => {
const root = path.join(__dirname, '..', '..');
const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
// 不加前导斜杠时 dist/ 会连 icons/dist/ 一起忽略,
// 新克隆的仓库缺少图标,构建直接失败
assert.match(ignore, /^\/dist\/$/m, '.gitignore 的 dist 规则必须锚定到仓库根');
assert.doesNotMatch(ignore, /^dist\/$/m);
const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
const required = [
'book-ai-dark.ico', 'book-ai-light.ico',
'book-ai-dark.icns',
path.join('dark', 'icon-32.png'), path.join('light', 'icon-32.png'),
path.join('dark', 'icon-256.png'), path.join('light', 'icon-256.png')
];
for (const rel of required) {
assert.ok(
fs.existsSync(path.join(root, 'icons', 'dist', rel)),
`构建需要的图标缺失: icons/dist/${rel}`
);
}
assert.ok(portable.includes('book-ai-dark.ico'));
assert.ok(mac.includes('book-ai-dark.icns'));
});
test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => {
const root = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const build = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
assert.strictEqual(pkg.scripts['build:mac'], 'node build-mac.js');
assert.match(build, /const TARGET = `\$\{PRODUCT\}-macos-\$\{ARCH\}`/);
assert.match(build, /const ARCH = 'arm64'/);
// 交叉打包做不出可用产物:hdiutil 与 codesign 都只有 macOS 才有
assert.match(build, /process\.platform !== 'darwin'/);
for (const tool of ['hdiutil', 'codesign', 'ditto']) assert.ok(build.includes(tool), `缺少 ${tool} 检查`);
// Electron Framework 带符号链接,用 Node 解压会展开成副本并让签名失效
assert.match(build, /run\('ditto', \['-x', '-k', zip, cacheDir\]\)/);
// 重打包后 asar 哈希对不上,留着该字段会启动即报完整性错误
assert.match(build, /ElectronAsarIntegrity/);
// 官方 helper plist 没有 CFBundleExecutable,改名后必须补上
assert.match(build, /CFBundleExecutable/);
// 带版本的 framework 要签 Versions/ASquirrel 里的 ShipIt 是独立可执行文件
assert.match(build, /Versions', 'A'/);
assert.match(build, /ShipIt/);
// 签名必须由内向外,外层 .app 最后签
assert.match(build, /targets\.push\(APP_BUNDLE\)/);
assert.match(build, /'--sign', '-'/);
assert.match(build, /'--verify', '--deep', '--strict'/);
});
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);