diff --git a/.gitignore b/.gitignore index f8ef636..c907fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 33a0ffe..b3fdd29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,13 +70,26 @@ npx electron src/_test/electron/.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` 检查是否混入密钥。 diff --git a/BUILD.md b/BUILD.md index fd2c344..89684a1 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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 挂载时只读,且覆盖升级会连同用户书库一并删除。 该目录包含: diff --git a/build-mac.js b/build-mac.js new file mode 100644 index 0000000..23ff41c --- /dev/null +++ b/build-mac.js @@ -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}\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')); + 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; +}); diff --git a/icons/dist/book-ai-dark.icns b/icons/dist/book-ai-dark.icns new file mode 100644 index 0000000..f832d90 Binary files /dev/null and b/icons/dist/book-ai-dark.icns differ diff --git a/icons/dist/book-ai-dark.ico b/icons/dist/book-ai-dark.ico new file mode 100644 index 0000000..a01ab1a Binary files /dev/null and b/icons/dist/book-ai-dark.ico differ diff --git a/icons/dist/book-ai-light.icns b/icons/dist/book-ai-light.icns new file mode 100644 index 0000000..34e432b Binary files /dev/null and b/icons/dist/book-ai-light.icns differ diff --git a/icons/dist/book-ai-light.ico b/icons/dist/book-ai-light.ico new file mode 100644 index 0000000..9307c46 Binary files /dev/null and b/icons/dist/book-ai-light.ico differ diff --git a/icons/dist/dark/icon-1024.png b/icons/dist/dark/icon-1024.png new file mode 100644 index 0000000..4dc4148 Binary files /dev/null and b/icons/dist/dark/icon-1024.png differ diff --git a/icons/dist/dark/icon-128.png b/icons/dist/dark/icon-128.png new file mode 100644 index 0000000..08a8ba7 Binary files /dev/null and b/icons/dist/dark/icon-128.png differ diff --git a/icons/dist/dark/icon-16.png b/icons/dist/dark/icon-16.png new file mode 100644 index 0000000..5826187 Binary files /dev/null and b/icons/dist/dark/icon-16.png differ diff --git a/icons/dist/dark/icon-256.png b/icons/dist/dark/icon-256.png new file mode 100644 index 0000000..b169074 Binary files /dev/null and b/icons/dist/dark/icon-256.png differ diff --git a/icons/dist/dark/icon-32.png b/icons/dist/dark/icon-32.png new file mode 100644 index 0000000..7ed43e8 Binary files /dev/null and b/icons/dist/dark/icon-32.png differ diff --git a/icons/dist/dark/icon-48.png b/icons/dist/dark/icon-48.png new file mode 100644 index 0000000..6ee38ba Binary files /dev/null and b/icons/dist/dark/icon-48.png differ diff --git a/icons/dist/dark/icon-512.png b/icons/dist/dark/icon-512.png new file mode 100644 index 0000000..856c528 Binary files /dev/null and b/icons/dist/dark/icon-512.png differ diff --git a/icons/dist/dark/icon-64.png b/icons/dist/dark/icon-64.png new file mode 100644 index 0000000..66ec814 Binary files /dev/null and b/icons/dist/dark/icon-64.png differ diff --git a/icons/dist/light/icon-1024.png b/icons/dist/light/icon-1024.png new file mode 100644 index 0000000..673a90d Binary files /dev/null and b/icons/dist/light/icon-1024.png differ diff --git a/icons/dist/light/icon-128.png b/icons/dist/light/icon-128.png new file mode 100644 index 0000000..f55807b Binary files /dev/null and b/icons/dist/light/icon-128.png differ diff --git a/icons/dist/light/icon-16.png b/icons/dist/light/icon-16.png new file mode 100644 index 0000000..e14166c Binary files /dev/null and b/icons/dist/light/icon-16.png differ diff --git a/icons/dist/light/icon-256.png b/icons/dist/light/icon-256.png new file mode 100644 index 0000000..c6c00fc Binary files /dev/null and b/icons/dist/light/icon-256.png differ diff --git a/icons/dist/light/icon-32.png b/icons/dist/light/icon-32.png new file mode 100644 index 0000000..674c831 Binary files /dev/null and b/icons/dist/light/icon-32.png differ diff --git a/icons/dist/light/icon-48.png b/icons/dist/light/icon-48.png new file mode 100644 index 0000000..003d000 Binary files /dev/null and b/icons/dist/light/icon-48.png differ diff --git a/icons/dist/light/icon-512.png b/icons/dist/light/icon-512.png new file mode 100644 index 0000000..dec1aef Binary files /dev/null and b/icons/dist/light/icon-512.png differ diff --git a/icons/dist/light/icon-64.png b/icons/dist/light/icon-64.png new file mode 100644 index 0000000..3a23dc5 Binary files /dev/null and b/icons/dist/light/icon-64.png differ diff --git a/icons/tools/make-icns.js b/icons/tools/make-icns.js new file mode 100644 index 0000000..2c17082 --- /dev/null +++ b/icons/tools/make-icns.js @@ -0,0 +1,62 @@ +// 从 icons/dist//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`); +} diff --git a/main.js b/main.js index 5be306e..1c2b1c6 100644 --- a/main.js +++ b/main.js @@ -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'); diff --git a/package.json b/package.json index b2b7384..f1d7ab9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/_test/main.test.js b/src/_test/main.test.js index eed0950..bea3603 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -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\)/);