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
+6 -1
View File
@@ -1,9 +1,14 @@
node_modules/
dist/
# 必须锚定到仓库根:不加斜杠会连 icons/dist/ 一起忽略,
# 而构建脚本和单测都依赖 icons/dist/ 里的图标产物
/dist/
*.log
.DS_Store
Thumbs.db
# 图标工具生成的对照图,仅供肉眼检查,不参与构建
icons/dist/preview*.png
# 调试探测产生的临时快照
probe*.json
probe-*.js
+15 -2
View File
@@ -70,13 +70,26 @@ npx electron src/_test/electron/<name>.integration.js # Electron 集成
## 构建
```bash
npm run build # 输出 dist/PeopleLib-windows-x64/
npm run build # Windows输出 dist/PeopleLib-windows-x64/
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
```
Windows
- 输出目录固定,不带版本号。改名会导致 `data/` 被遗留在旧目录。
- 构建保留 `data/`,但会清空其余内容。**构建前必须退出该目录下运行中的 `PeopleLib.exe`**,否则清理到一半失败,目录处于不完整状态。
- 涉及 `data/` 的操作前后做逐文件哈希比对,确认书库、笔记、批注未被改动。
macOS`build-mac.js`,易踩坑):
- **不能交叉构建**。DMG 需要 `hdiutil`,且 Apple Silicon 内核直接拒绝执行未签名二进制;改名与改 plist 会让 Electron 原始签名失效,必须用 macOS 的 `codesign` 重签(ad-hoc `--sign -` 即可)。
- 解压官方 zip 必须用 `ditto``Electron Framework.framework` 内有符号链接,用 Node 或 `unzip` 解压会展开成副本,签名随即失效。
- 重打包后 `Info.plist` 里的 `ElectronAsarIntegrity` 必须删除,否则启动即报完整性错误。
- helper 的 plist **没有** `CFBundleExecutable`,靠 bundle 名推断可执行文件名。重命名 helper 后必须显式补上该字段,否则渲染进程起不来,界面一片空白。
- 签名严格由内向外:嵌套可执行文件 → helper → framework → 外层 `.app`。带版本的 framework 签 `Versions/A``Squirrel.framework``Resources/ShipIt` 是独立可执行文件,要单独签。
- 数据目录走 `~/Library/Application Support/PeopleLib`**不要**沿用 Windows 的便携布局:`.app` 在 DMG 里只读,且升级覆盖会删掉用户书库。
- `.icns``npm run icons:icns` 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 `iconutil`。窗口图标在非 Windows 平台用 PNG`.ico` 只有 Windows 认。
## 仓库
两个远端,用途不同:
@@ -86,6 +99,6 @@ npm run build # 输出 dist/PeopleLib-windows-x64/
其他:
- `dist/` 已 gitignore。诊断产物(`*-diagnostic.png``probe*.json`)也已忽略,不要提交。
- 根目录 `dist/` 已 gitignore,规则写作 `/dist/`**必须保留前导斜杠**:不加会连 `icons/dist/` 一起忽略,新克隆的仓库缺图标,构建直接失败。诊断产物(`*-diagnostic.png``probe*.json`)也已忽略,不要提交。
- 未跟踪文件视为用户资产,不要删除或覆盖。清理前先看 `git status --porcelain`
- 提交前 `git diff --cached` 检查是否混入密钥。
+30 -2
View File
@@ -21,6 +21,30 @@ npm run portable
发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
### macOSApple Silicon
**只能在 macOS 上执行**,且需要 Xcode 命令行工具(`xcode-select --install`):
```bash
npm install
npm run build:mac
```
产出 `dist/PeopleLib-macos-arm64/PeopleLib.app``dist/PeopleLib-macos-arm64.dmg`
不能在 Windows 或 Linux 上交叉构建,原因有两条,都无法绕开:
- DMG 由 `hdiutil` 生成,该工具只存在于 macOS。
- Apple Silicon 内核会拒绝执行未签名的二进制。打包过程要重命名可执行文件、修改 `Info.plist`,Electron 的原始签名必然失效,必须用 macOS 的 `codesign` 重新签名。
脚本使用 ad-hoc 签名(`codesign --sign -`),可以在本机及自行放行的机器上运行,但未经 Apple 公证。首次打开需右键点按图标选择「打开」,或执行:
```bash
xattr -dr com.apple.quarantine /Applications/PeopleLib.app
```
图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`
## 配置
### 代理
@@ -39,8 +63,12 @@ npm run portable
| 模式 | 路径 |
|---|---|
| 开发运行 | `%APPDATA%/PeopleLib`Windows |
| 打包运行 | 可执行文件同级的 `data/` 目录 |
| 开发运行Windows | `%APPDATA%/PeopleLib` |
| 开发运行(macOS | `~/Library/Application Support/PeopleLib` |
| 打包运行(Windows 便携版) | 可执行文件同级的 `data/` 目录 |
| 打包运行(macOS | `~/Library/Application Support/PeopleLib` |
macOS 不采用便携布局:`.app` 内部在 DMG 挂载时只读,且覆盖升级会连同用户书库一并删除。
该目录包含:
+322
View File
@@ -0,0 +1,322 @@
// 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'));
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;
});
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+62
View File
@@ -0,0 +1,62 @@
// 从 icons/dist/<theme>/icon-*.png 生成 macOS 的 .icns。
// 用途:Windows 上没有 iconutil,但 icns 自 10.7 起支持直接内嵌 PNG,
// 因此容器可以手工拼出来,不必依赖 macOS 工具链。
const fs = require('fs');
const path = require('path');
const DIST = path.join(__dirname, '..', 'dist');
// OSType -> 该槽位要求的像素尺寸。ic11/ic12/ic13/ic14 是 @2x 变体,
// 像素数等于逻辑尺寸的两倍,复用同一批 PNG 即可。
const SLOTS = [
['icp4', 16],
['icp5', 32],
['ic11', 32],
['ic12', 64],
['ic07', 128],
['ic13', 256],
['ic08', 256],
['ic14', 512],
['ic09', 512],
['ic10', 1024]
];
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function pngSize(buf) {
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是 PNG 文件');
if (buf.readUInt32BE(8) !== 13 || buf.toString('latin1', 12, 16) !== 'IHDR') {
throw new Error('PNG 缺少 IHDR');
}
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
function build(theme) {
const chunks = [];
for (const [osType, size] of SLOTS) {
const file = path.join(DIST, theme, `icon-${size}.png`);
const png = fs.readFileSync(file);
const dim = pngSize(png);
if (dim.width !== size || dim.height !== size) {
throw new Error(`${file} 期望 ${size}x${size},实际 ${dim.width}x${dim.height}`);
}
const header = Buffer.alloc(8);
header.write(osType, 0, 4, 'latin1');
header.writeUInt32BE(png.length + 8, 4);
chunks.push(header, png);
}
const body = Buffer.concat(chunks);
const header = Buffer.alloc(8);
header.write('icns', 0, 4, 'latin1');
header.writeUInt32BE(body.length + 8, 4);
const out = path.join(DIST, `book-ai-${theme}.icns`);
fs.writeFileSync(out, Buffer.concat([header, body]));
return out;
}
for (const theme of ['dark', 'light']) {
const out = build(theme);
console.log(`${path.basename(out)} ${(fs.statSync(out).size / 1024).toFixed(1)} KB`);
}
+13 -4
View File
@@ -80,12 +80,21 @@ app.setAppUserModelId('com.peoplelib.client');
const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist');
function iconForTheme(theme) {
return path.join(APP_ICON_DIR, theme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico');
const name = theme === 'light' ? 'light' : 'dark';
// .ico 只有 Windows 认;macOS/Linux 用 PNG,窗口图标不需要 icns
return process.platform === 'win32'
? path.join(APP_ICON_DIR, `book-ai-${name}.ico`)
: path.join(APP_ICON_DIR, name, 'icon-256.png');
}
const userDataDir = app.isPackaged
? path.join(path.dirname(app.getPath('exe')), 'data')
: path.join(app.getPath('appData'), 'PeopleLib');
// macOS 的 .app 内部不可写(DMG 只读,且升级覆盖会连用户数据一起删),
// 只有 Windows 便携版才把 data/ 放在可执行文件旁边。
function resolveUserDataDir() {
if (!app.isPackaged) return path.join(app.getPath('appData'), 'PeopleLib');
if (process.platform === 'win32') return path.join(path.dirname(app.getPath('exe')), 'data');
return path.join(app.getPath('appData'), 'PeopleLib');
}
const userDataDir = resolveUserDataDir();
app.setPath('userData', userDataDir);
const sources = require('./src/sources');
+3 -1
View File
@@ -12,7 +12,9 @@
"start": "electron .",
"test": "node --test \"src/_test/*.test.js\"",
"build": "node build-portable.js",
"portable": "node build-portable.js"
"portable": "node build-portable.js",
"build:mac": "node build-mac.js",
"icons:icns": "node icons/tools/make-icns.js"
},
"dependencies": {
"foliate-js": "1.0.1",
+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\)/);