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:
@@ -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/A;Squirrel 里的 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\)/);
|
||||
|
||||
Reference in New Issue
Block a user