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/, 导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
@@ -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
|
||||
|
||||
@@ -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` 检查是否混入密钥。
|
||||
|
||||
@@ -21,6 +21,30 @@ npm run portable
|
||||
|
||||
发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
|
||||
|
||||
### macOS(Apple 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 挂载时只读,且覆盖升级会连同用户书库一并删除。
|
||||
|
||||
该目录包含:
|
||||
|
||||
|
||||
@@ -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 图标用 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;
|
||||
});
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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`);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||