diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..40ddbba --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,159 @@ +name: 构建与发布 + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +env: + # 仓库的 .npmrc 指向 npmmirror,GitHub runner 在境外,走官方源更稳 + npm_config_registry: https://registry.npmjs.org/ + ELECTRON_MIRROR: https://github.com/electron/electron/releases/download/ + +jobs: + validate: + name: 单测与集成测试 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: 校验发布标签与版本号一致 + if: github.ref_type == 'tag' + run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('标签应为 '+expected+',实际 '+process.env.GITHUB_REF_NAME)}" + + - name: 安装依赖 + run: npm ci + + - name: 单元测试 + run: npm test + + # Electron 集成测试要开真实窗口,无头环境靠 xvfb 提供 X server + - name: Electron 集成测试 + run: | + sudo apt-get update + sudo apt-get install -y xvfb libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libgbm1 libasound2t64 libgtk-3-0t64 + for suite in startup download cover annotation reader-features library-notes ai-scope; do + echo "::group::$suite" + xvfb-run -a npx electron "src/_test/electron/$suite.integration.js" + echo "::endgroup::" + done + + package: + name: 打包 ${{ matrix.platform }} ${{ matrix.arch }} + needs: validate + strategy: + fail-fast: false + matrix: + include: + - platform: windows + arch: x64 + runner: windows-2025 + - platform: macos + arch: arm64 + runner: macos-15 + - platform: linux + arch: x64 + runner: ubuntu-24.04 + - platform: linux + arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: 缓存 Electron 运行时 + uses: actions/cache@v4 + with: + path: node_modules/.cache + key: electron-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json') }} + + - name: 安装依赖 + run: npm ci + + - name: 构建并校验发布件 + run: npm run release -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} + + - name: 上传发布件 + uses: actions/upload-artifact@v4 + with: + name: peoplelib-${{ matrix.platform }}-${{ matrix.arch }} + path: dist/release/${{ matrix.platform }}-${{ matrix.arch }} + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + release: + name: 发布 GitHub Release + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: package + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: 核对标签指向当前提交 + shell: bash + run: | + set -euo pipefail + expected="v$(node -p "require('./package.json').version")" + test "$GITHUB_REF_NAME" = "$expected" + test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" + + - name: 下载各平台发布件 + uses: actions/download-artifact@v4 + with: + pattern: peoplelib-* + path: dist/release-downloads + + - name: 回验校验和并汇总 + run: npm run release -- --verify dist/release-downloads + + # 先建草稿再转正式,避免上传中途失败留下一个资产不全的 Release + - name: 创建 Release 并上传 + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="$GITHUB_REF_NAME" + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --draft --verify-tag + else + gh release create "$tag" --draft --verify-tag --generate-notes --title "PeopleLib $tag" + fi + gh release upload "$tag" dist/release-upload/* --clobber + gh release edit "$tag" --draft=false diff --git a/AGENTS.md b/AGENTS.md index 82c724e..f39c440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,8 +135,10 @@ npx electron src/_test/electron/.integration.js # Electron 集成 ## 构建 ```bash -npm run build # Windows,输出 dist/PeopleLib-windows-x64/ -npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑 +npm run build # Windows,输出 dist/PeopleLib-windows-x64/ +npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑 +npm run build:linux -- --arch x64 # Linux,输出 tar.gz,任意平台可构建 +npm run release -- --platform linux --arch x64 # 发布件 + 校验和 ``` Windows: @@ -155,12 +157,31 @@ macOS(`build-mac.js`,易踩坑): - 数据目录走 `~/Library/Application Support/PeopleLib`,**不要**沿用 Windows 的便携布局:`.app` 在 DMG 里只读,且升级覆盖会删掉用户书库。 - `.icns` 由 `npm run icons:icns` 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 `iconutil`。窗口图标在非 Windows 平台用 PNG,`.ico` 只有 Windows 认。 +Linux(`build-linux.js`): + +- 直接从官方 zip 的条目转写进 tar,**不落地中间目录**。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和 `chrome-sandbox` 都不可执行。因此这个脚本在 Windows 上也能构建。 +- tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的 `prefix` 只能在斜杠处切分,`undici` 与 vendor 里的深层路径切不出合法组合。 + +发布件(`build-release.js`): + +- 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的 `data/` 就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。 +- 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有 `data/` 与 `_test`。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。 + +## 持续集成 + +- `.github/workflows/build.yml`:`validate`(单测 + 全部集成套件,`xvfb-run` 起 X server)→ `package`(四目标矩阵)→ `release`(仅 `v*` 标签)。 +- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。 +- 新增集成套件后要同步加进工作流的套件列表,单测里有断言按 `src/_test/electron/` 的实际文件逐个核对,漏加会直接失败。 +- 标签名必须等于 `v` + `package.json` 的 `version`,且标签要指向被构建的那个提交。 + ## 仓库 两个远端,用途不同: -- `github`(公开):**只放 README 与截图**,不推源码、不推 `BUILD.md`。历史与本地无共同祖先,用独立的 orphan/docs 提交推送。 -- `origin`(私有):完整源码。 +- `origin`(私有):完整源码,日常开发推这里。 +- `github`(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围。 + +推公开仓前必须确认:没有本地配置、账号凭据、`data/` 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。 其他: diff --git a/BUILD.md b/BUILD.md index eccd844..4bd717d 100644 --- a/BUILD.md +++ b/BUILD.md @@ -19,7 +19,25 @@ npm run portable 输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。 -发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v2.0.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。 +发布件不要手工压缩目录上传,用下面的发布打包入口生成,它会排除 `data/` 并附带校验和。 + +### Linux(x64 / arm64) + +```bash +npm run build:linux -- --arch x64 +npm run build:linux -- --arch arm64 +``` + +产出 `dist/PeopleLib-linux-.tar.gz`,解压后运行其中的 `PeopleLib.sh`。 + +脚本直接把官方 Electron zip 里的条目转写进 tar,不落地中间目录,因此在 Windows 上也能构建出可用的 Linux 包。可执行位存在 zip 的 external attributes 里,先解压到 NTFS 再打包会把这些位全部丢掉,产物解压后 `PeopleLib` 与 `chrome-sandbox` 都不可执行。 + +多数发行版开启了非特权用户命名空间,无需额外配置。内核禁用该特性时(`kernel.unprivileged_userns_clone=0`),需给沙箱补 setuid: + +```bash +sudo chown root:root PeopleLib-linux-x64/chrome-sandbox +sudo chmod 4755 PeopleLib-linux-x64/chrome-sandbox +``` ### macOS(Apple Silicon) @@ -45,6 +63,46 @@ xattr -dr com.apple.quarantine /Applications/PeopleLib.app 图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`。 +## 发布打包 + +`build-release.js` 是发布件的唯一出口,负责调用平台构建脚本、校验产物内容、生成校验和: + +```bash +npm run release -- --platform windows --arch x64 +npm run release -- --platform macos --arch arm64 +npm run release -- --platform linux --arch x64 +npm run release -- --platform linux --arch arm64 +``` + +加 `--skip-build` 可复用已有的构建产物。输出落在 `dist/release/-/`,含发布件、`SHA256SUMS.txt` 与 `release-manifest.json`。 + +发布前的校验是硬要求,不要跳过直接压缩目录上传: + +- Windows 便携版把用户书库放在程序同级 `data/`,本机构建目录里通常有内容,手工压缩会把整个书库连同笔记打进公开发布件。脚本按前缀排除 `data/`,并在打包后回读压缩包确认。 +- Linux 产物必须回读 tar 确认 `PeopleLib`、`PeopleLib.sh`、`chrome-sandbox` 带可执行位,丢了就是解压后点不开。 +- 三个平台都会检查有没有混入 `_test`。 + +汇总多平台产物时用回验模式,它逐个比对哈希、体积与版本,再汇总到 `dist/release-upload/`: + +```bash +npm run release -- --verify dist/release-downloads +``` + +## 持续集成 + +`.github/workflows/build.yml` 在推送 `main`、提交 PR、打 `v*` 标签和手动触发时运行,分三个阶段: + +1. `validate`:`npm ci` 后跑单测与全部 Electron 集成套件。集成测试要开真实窗口,无头 runner 上用 `xvfb-run` 提供 X server。 +2. `package`:四个目标并行打包(`windows-2025`、`macos-15`、`ubuntu-24.04`、`ubuntu-24.04-arm`),各自调用 `npm run release`,产物作为 artifact 保留 30 天。 +3. `release`:仅在推送 `v*` 标签时执行,回验各平台校验和后创建 GitHub Release 并上传。 + +两个容易踩的点: + +- 仓库 `.npmrc` 指向 npmmirror,GitHub runner 在境外拉不动,工作流用 `npm_config_registry` 与 `ELECTRON_MIRROR` 覆盖回官方源。 +- 标签名必须与 `package.json` 的 `version` 一致(`v2.0.0` 对应 `2.0.0`),且标签要指向被构建的那个提交,两处校验不过直接中止发布。 + +Release 先建草稿、上传完再转正式,上传中途失败不会在页面上留下一个资产不全的版本。应用根据最新 Release 标签判断是否需要更新。 + ## 固定构建变体 PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围: diff --git a/build-linux.js b/build-linux.js new file mode 100644 index 0000000..f79f7bf --- /dev/null +++ b/build-linux.js @@ -0,0 +1,332 @@ +// Linux 打包:产出 dist/PeopleLib-linux-.tar.gz。 +// 直接把官方 zip 里的条目转写进 tar,不落地中间目录:Linux 的可执行位存在 +// zip 的 external attributes 里,先解到 NTFS 再打包会把这些位全部丢掉, +// 产物解压后 PeopleLib 与 chrome-sandbox 都不可执行。因此本脚本在任意平台 +// 都能构建 x64 与 arm64 包。 +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { spawnSync } = require('child_process'); + +const ROOT = __dirname; +const pkg = require('./package.json'); +const PRODUCT = pkg.productName || 'PeopleLib'; +const SUPPORTED_ARCHES = ['x64', 'arm64']; + +// 固定时间戳让同一份源码重复构建得到逐字节一致的产物,便于用哈希核对发布件。 +const MTIME = Number(process.env.SOURCE_DATE_EPOCH) || 1735689600; + +const KEEP_LOCALES = new Set(['zh-CN.pak', 'en-US.pak']); +// 无扩展名的可执行文件与 .so 之外,这几个也必须带执行位 +const FORCE_EXECUTABLE = new Set(['chrome-sandbox', 'chrome_crashpad_handler']); + +function parseArch(argv) { + const index = argv.indexOf('--arch'); + const value = index >= 0 ? argv[index + 1] : process.arch; + if (!SUPPORTED_ARCHES.includes(value)) { + throw new Error(`不支持的架构:${value || '未指定'}(可选 ${SUPPORTED_ARCHES.join(' / ')})`); + } + return value; +} + +function run(cmd, args) { + const result = spawnSync(cmd, args, { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`); +} + +// 官方 zip 只在需要时下载一次,缓存在 node_modules/.cache 下按版本和架构分目录 +function ensureRuntimeZip(arch) { + 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-linux-${arch}`, version); + const zip = path.join(cacheDir, 'electron.zip'); + if (fs.existsSync(zip) && fs.statSync(zip).size > 0) return zip; + + 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}-linux-${arch}.zip`; + + fs.mkdirSync(cacheDir, { recursive: true }); + const partial = `${zip}.download`; + fs.rmSync(partial, { force: true }); + console.log(`下载 Electron ${version} (linux-${arch})...`); + run('curl', ['-fSL', '--retry', '3', '-o', partial, url]); + fs.renameSync(partial, zip); + return zip; +} + +function isExecutableName(name) { + const base = path.posix.basename(name); + if (FORCE_EXECUTABLE.has(base)) return true; + if (/\.so(\.\d+)*$/.test(base)) return true; + return !base.includes('.'); +} + +// jszip 会把 external attributes 的高 16 位解析成 unixPermissions; +// 个别条目缺失时按文件名兜底,宁可多给执行位也不能让主程序起不来。 +function modeOf(entry, name) { + const raw = entry.unixPermissions; + const parsed = typeof raw === 'number' ? raw & 0o7777 : 0; + if (parsed) return parsed; + return isExecutableName(name) ? 0o755 : 0o644; +} + +async function readRuntimeEntries(zipPath, arch) { + const JSZip = require('jszip'); + const zip = await JSZip.loadAsync(fs.readFileSync(zipPath)); + const entries = []; + let sawElectron = false; + + for (const name of Object.keys(zip.files)) { + const entry = zip.files[name]; + if (entry.dir) continue; + if (name.startsWith('locales/') && !KEEP_LOCALES.has(path.posix.basename(name))) continue; + if (name === 'resources/default_app.asar') continue; + + // Electron Linux 包不该有符号链接;真出现了要显式失败,静默展开成副本 + // 会让产物体积翻倍且行为不可预期。 + const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0; + if ((unixMode & 0o170000) === 0o120000) { + throw new Error(`运行时包含未预期的符号链接:${name}`); + } + + const target = name === 'electron' ? PRODUCT : name; + if (name === 'electron') sawElectron = true; + entries.push({ + name: target, + mode: name === 'electron' ? 0o755 : modeOf(entry, name), + data: await entry.async('nodebuffer') + }); + } + + if (!sawElectron) throw new Error(`Electron 运行时缺少主可执行文件(linux-${arch})`); + return entries; +} + +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 collectDir(src, prefix, skip) { + const out = []; + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + if (skip && skip(e)) continue; + const from = path.join(src, e.name); + const to = path.posix.join(prefix, e.name); + if (e.isDirectory()) out.push(...collectDir(from, to, skip)); + else out.push({ name: to, mode: 0o644, data: fs.readFileSync(from) }); + } + return out; +} + +function collectFile(src, name, mode = 0o644) { + return { name, mode, data: fs.readFileSync(src) }; +} + +function appEntries() { + const app = 'resources/app'; + const entries = [ + collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`), + collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`), + ...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test')) + ]; + + for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) { + entries.push(collectFile(path.join(ROOT, 'icons', 'dist', name), `${app}/icons/dist/${name}`)); + } + for (const theme of ['dark', 'light']) { + // Linux 的 BrowserWindow 图标用 PNG:256 供窗口,32 供渲染层复用 + for (const size of [32, 256]) { + entries.push(collectFile( + path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`), + `${app}/icons/dist/${theme}/icon-${size}.png` + )); + } + } + + const undici = path.join(ROOT, 'node_modules', 'undici'); + for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) { + const file = path.join(undici, name); + if (fs.existsSync(file)) { + entries.push(collectFile(file, `${app}/node_modules/undici/${name}`)); + } + } + entries.push(...collectDir( + path.join(undici, 'lib'), `${app}/node_modules/undici/lib`, skipDevFiles + )); + + const foliate = path.join(ROOT, 'node_modules', 'foliate-js'); + for (const name of ['package.json', 'LICENSE', 'mobi.js']) { + entries.push(collectFile(path.join(foliate, name), `${app}/node_modules/foliate-js/${name}`)); + } + entries.push(collectFile( + path.join(foliate, 'vendor', 'fflate.js'), + `${app}/node_modules/foliate-js/vendor/fflate.js` + )); + + entries.push({ + name: `${app}/package.json`, + mode: 0o644, + data: Buffer.from(`${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)}\n`, 'utf8') + }); + + return entries; +} + +// 桌面集成留给用户自行安装,这里只给出可直接使用的启动脚本与 .desktop 模板。 +// 不加 --no-sandbox:多数发行版开启了非特权用户命名空间,Electron 能正常起沙箱; +// 内核禁用该特性时才需要按 BUILD.md 给 chrome-sandbox 补 setuid。 +function launcherEntries() { + const launcher = `#!/bin/sh +set -e +HERE="$(dirname "$(readlink -f "$0")")" +exec "$HERE/${PRODUCT}" "$@" +`; + const desktop = `[Desktop Entry] +Type=Application +Name=${PRODUCT} +Comment=${pkg.description} +Exec=${PRODUCT}.sh %U +Icon=${PRODUCT.toLowerCase()} +Terminal=false +Categories=Office;Viewer; +`; + return [ + { name: `${PRODUCT}.sh`, mode: 0o755, data: Buffer.from(launcher, 'utf8') }, + { name: `${PRODUCT}.desktop`, mode: 0o644, data: Buffer.from(desktop, 'utf8') }, + collectFile(path.join(ROOT, 'icons', 'dist', 'dark', 'icon-256.png'), `${PRODUCT.toLowerCase()}.png`) + ]; +} + +function octal(value, length) { + // tar 的数字字段是定长八进制串,末位留给 NUL + return Buffer.from(value.toString(8).padStart(length - 1, '0') + '\0', 'ascii'); +} + +function tarHeader({ name, mode, size, typeflag }) { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + octal(mode & 0o7777, 8).copy(header, 100); + octal(0, 8).copy(header, 108); // uid + octal(0, 8).copy(header, 116); // gid + octal(size, 12).copy(header, 124); + octal(MTIME, 12).copy(header, 136); + header.write(' ', 148, 8, 'ascii'); // 计算校验和时该字段视为空格 + header.write(typeflag, 156, 1, 'ascii'); + header.write('ustar\0', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + header.write('root', 265, 32, 'ascii'); + header.write('root', 297, 32, 'ascii'); + + let sum = 0; + for (const byte of header) sum += byte; + header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii'); + return header; +} + +function padding(size) { + const remainder = size % 512; + return remainder ? Buffer.alloc(512 - remainder) : Buffer.alloc(0); +} + +// 路径超过 100 字节时用 PAX 扩展头承载完整路径。ustar 的 prefix 字段只能在 +// 斜杠处切分,undici 与 vendor 里的深层路径切不出合法组合,必须走 PAX。 +function paxRecords(fullName) { + const record = (key, value) => { + const body = ` ${key}=${value}\n`; + let length = Buffer.byteLength(body) + 1; + while (Buffer.byteLength(`${length}${body}`) !== length) length += 1; + return Buffer.from(`${length}${body}`, 'utf8'); + }; + return record('path', fullName); +} + +function tarEntry(fullName, mode, data, typeflag = '0') { + const chunks = []; + const nameBytes = Buffer.byteLength(fullName, 'utf8'); + + if (nameBytes > 100) { + const records = paxRecords(fullName); + chunks.push(tarHeader({ + name: `PaxHeader/${path.posix.basename(fullName).slice(0, 80)}`, + mode: 0o644, + size: records.length, + typeflag: 'x' + })); + chunks.push(records, padding(records.length)); + } + + chunks.push(tarHeader({ + // 超长路径已由 PAX 头给出,这里的截断名只是给不支持 PAX 的工具看的回退值 + name: nameBytes > 100 ? fullName.slice(-100) : fullName, + mode, + size: data.length, + typeflag + })); + chunks.push(data, padding(data.length)); + return Buffer.concat(chunks); +} + +function writeTarGz(entries, root, outFile) { + const seen = new Set(); + const chunks = []; + const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + + for (const entry of sorted) { + const full = path.posix.join(root, entry.name); + if (seen.has(full)) throw new Error(`打包条目重复:${full}`); + seen.add(full); + chunks.push(tarEntry(full, entry.mode, entry.data)); + } + chunks.push(Buffer.alloc(1024)); // tar 以两个空块收尾 + + const gz = zlib.gzipSync(Buffer.concat(chunks), { level: 9, mtime: 0 }); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, gz); + return gz.length; +} + +async function build() { + const arch = parseArch(process.argv.slice(2)); + const target = `${PRODUCT}-linux-${arch}`; + const outFile = path.join(ROOT, 'dist', `${target}.tar.gz`); + + const zip = ensureRuntimeZip(arch); + console.log('读取 Electron 运行时(保留可执行位)...'); + const runtime = await readRuntimeEntries(zip, arch); + + console.log('组装 app 源码...'); + const entries = [...runtime, ...appEntries(), ...launcherEntries()]; + + console.log('生成 tar.gz...'); + const size = writeTarGz(entries, target, outFile); + + console.log('\n构建完成:'); + console.log(' 架构:', arch); + console.log(' 产物:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`); + console.log(' 解压后运行:', `./${target}/${PRODUCT}.sh`); +} + +module.exports = { parseArch, isExecutableName, modeOf, tarEntry, writeTarGz }; + +if (require.main === module) { + build().catch((error) => { + console.error('构建失败:', error && error.message ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/build-release.js b/build-release.js new file mode 100644 index 0000000..a28e71f --- /dev/null +++ b/build-release.js @@ -0,0 +1,281 @@ +// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。 +// +// node build-release.js --platform windows|macos|linux --arch x64|arm64 [--skip-build] +// node build-release.js --verify <目录> # 回验下载下来的各平台发布件 +// +// 产物统一落在 dist/release/-/,含发布件本体、SHA256SUMS.txt +// 与 release-manifest.json。发布流程只上传这个目录,避免把构建中间物或 +// 本地 data/ 误传到 Release。 +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); + +const ROOT = __dirname; +const DIST = path.join(ROOT, 'dist'); +const pkg = require('./package.json'); +const PRODUCT = pkg.productName || 'PeopleLib'; +const VERSION = pkg.version; + +const TARGETS = { + 'windows-x64': { + build: ['build-portable.js'], + source: path.join(DIST, `${PRODUCT}-windows-x64`), + asset: `${PRODUCT}-${VERSION}-windows-x64.zip`, + pack: packWindowsZip, + verify: verifyWindowsZip + }, + 'macos-arm64': { + build: ['build-mac.js'], + source: path.join(DIST, `${PRODUCT}-macos-arm64.dmg`), + asset: `${PRODUCT}-${VERSION}-macos-arm64.dmg`, + pack: copyAsset, + verify: verifyDmg + }, + 'linux-x64': { + build: ['build-linux.js', '--arch', 'x64'], + source: path.join(DIST, `${PRODUCT}-linux-x64.tar.gz`), + asset: `${PRODUCT}-${VERSION}-linux-x64.tar.gz`, + pack: copyAsset, + verify: verifyLinuxTarball + }, + 'linux-arm64': { + build: ['build-linux.js', '--arch', 'arm64'], + source: path.join(DIST, `${PRODUCT}-linux-arm64.tar.gz`), + asset: `${PRODUCT}-${VERSION}-linux-arm64.tar.gz`, + pack: copyAsset, + verify: verifyLinuxTarball + } +}; + +function parseArgs(argv) { + const args = { skipBuild: false }; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + if (key === '--skip-build') args.skipBuild = true; + else if (key === '--platform') args.platform = argv[++i]; + else if (key === '--arch') args.arch = argv[++i]; + else if (key === '--verify') args.verify = argv[++i]; + else throw new Error(`无法识别的参数:${key}`); + } + return args; +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); } + +function runBuild(script) { + const result = spawnSync(process.execPath, script, { cwd: ROOT, stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${script[0]} 失败(退出码 ${result.status})`); +} + +function walk(dir, prefix = '') { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(abs, rel)); + else out.push({ rel, abs }); + } + return out; +} + +// 便携版把用户书库放在程序同级 data/,本机构建目录里通常是有内容的。 +// 漏掉这条排除就会把整个书库连同笔记打进公开发布件。 +function packWindowsZip(source, outFile) { + const files = walk(source).filter((f) => !f.rel.startsWith('data/')); + if (!files.some((f) => f.rel === `${PRODUCT}.exe`)) { + throw new Error(`构建目录缺少 ${PRODUCT}.exe`); + } + const leaked = files.find((f) => /(^|\/)_test\//.test(f.rel)); + if (leaked) throw new Error(`构建目录混入测试文件:${leaked.rel}`); + + const JSZip = require('jszip'); + const zip = new JSZip(); + const root = `${PRODUCT}-${VERSION}-windows-x64`; + for (const file of files.sort((a, b) => (a.rel < b.rel ? -1 : 1))) { + zip.file(`${root}/${file.rel}`, fs.readFileSync(file.abs), { date: new Date(0) }); + } + return zip + .generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) + .then((buffer) => { fs.writeFileSync(outFile, buffer); }); +} + +async function copyAsset(source, outFile) { + fs.copyFileSync(source, outFile); +} + +async function verifyWindowsZip(file) { + const JSZip = require('jszip'); + const zip = await JSZip.loadAsync(fs.readFileSync(file)); + const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, '')); + requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']); + forbidEntries(names, [/^data\//, /(^|\/)_test\//]); +} + +async function verifyDmg(file) { + const magic = Buffer.alloc(2); + const fd = fs.openSync(file, 'r'); + try { fs.readSync(fd, magic, 0, 2, 0); } finally { fs.closeSync(fd); } + // UDZO 镜像是 zlib 压缩块,头两字节为 zlib 魔数;空壳或半截文件在这里就暴露 + if (magic[0] !== 0x78) throw new Error('DMG 内容无效'); + if (fs.statSync(file).size < 50 * 1024 * 1024) throw new Error('DMG 体积异常偏小'); +} + +// tar 里的可执行位是 Linux 产物能不能启动的唯一依据,必须解包回读确认。 +async function verifyLinuxTarball(file) { + const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file))); + const names = entries.map((e) => e.name.replace(/^[^/]+\//, '')); + requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']); + forbidEntries(names, [/^data\//, /(^|\/)_test\//]); + + for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) { + const entry = entries.find((e) => e.name.replace(/^[^/]+\//, '') === required); + if (!entry) throw new Error(`发布件缺少 ${required}`); + if (!(entry.mode & 0o111)) throw new Error(`${required} 缺少可执行位`); + } +} + +function requireEntries(names, required) { + for (const name of required) { + if (!names.includes(name)) throw new Error(`发布件缺少 ${name}`); + } +} + +function forbidEntries(names, patterns) { + for (const pattern of patterns) { + const hit = names.find((name) => pattern.test(name)); + if (hit) throw new Error(`发布件混入不该发布的内容:${hit}`); + } +} + +function readTarEntries(buffer) { + const entries = []; + let pending = ''; + for (let offset = 0; offset + 512 <= buffer.length;) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((b) => b === 0)) break; + + const readField = (start, length) => header + .toString('ascii', start, start + length).replace(/\0.*$/, '').trim(); + const size = parseInt(readField(124, 12) || '0', 8); + const typeflag = header.toString('ascii', 156, 157); + const body = offset + 512; + const advance = 512 + Math.ceil(size / 512) * 512; + + if (typeflag === 'x') { + const record = /(?:^|\n)\d+ path=([^\n]*)/.exec(buffer.toString('utf8', body, body + size)); + pending = record ? record[1] : ''; + } else { + entries.push({ + name: pending || readField(0, 100), + mode: parseInt(readField(100, 8) || '0', 8), + size + }); + pending = ''; + } + offset += advance; + } + return entries; +} + +async function packRelease(args) { + const arch = args.arch || (args.platform === 'macos' ? 'arm64' : 'x64'); + const key = `${args.platform}-${arch}`; + const target = TARGETS[key]; + if (!target) { + throw new Error(`不支持的目标:${key}(可选 ${Object.keys(TARGETS).join(' / ')})`); + } + + if (!args.skipBuild) runBuild(target.build); + if (!fs.existsSync(target.source)) { + throw new Error(`构建产物不存在:${target.source}`); + } + + const outDir = path.join(DIST, 'release', key); + rimraf(outDir); + fs.mkdirSync(outDir, { recursive: true }); + + const outFile = path.join(outDir, target.asset); + console.log(`打包发布件 ${target.asset} ...`); + await target.pack(target.source, outFile); + + console.log('校验发布件内容...'); + await target.verify(outFile); + + const digest = sha256(outFile); + const size = fs.statSync(outFile).size; + fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${digest} ${target.asset}\n`); + fs.writeFileSync(path.join(outDir, 'release-manifest.json'), `${JSON.stringify({ + product: PRODUCT, + version: VERSION, + platform: args.platform, + arch, + commit: process.env.GITHUB_SHA || null, + files: [{ name: target.asset, size, sha256: digest }] + }, null, 2)}\n`); + + console.log('\n发布件就绪:'); + console.log(' 目标:', key); + console.log(' 文件:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`); + console.log(' 校验:', digest); +} + +// 发布任务把各平台 artifact 下载到一处,这里逐个回验哈希再汇总, +// 防止把传输中损坏或版本不一致的产物挂到 Release 上。 +function verifyDownloads(inputDir) { + const root = path.resolve(inputDir); + if (!fs.existsSync(root)) throw new Error(`目录不存在:${root}`); + + const manifests = walk(root) + .filter((f) => path.basename(f.rel) === 'release-manifest.json') + .sort((a, b) => (a.rel < b.rel ? -1 : 1)); + if (!manifests.length) throw new Error('没有找到任何 release-manifest.json'); + + const outDir = path.join(DIST, 'release-upload'); + rimraf(outDir); + fs.mkdirSync(outDir, { recursive: true }); + + const rows = []; + for (const entry of manifests) { + const manifest = JSON.parse(fs.readFileSync(entry.abs, 'utf8')); + if (manifest.version !== VERSION) { + throw new Error(`${entry.rel} 的版本 ${manifest.version} 与 package.json 的 ${VERSION} 不一致`); + } + for (const file of manifest.files) { + const asset = path.join(path.dirname(entry.abs), file.name); + if (!fs.existsSync(asset)) throw new Error(`缺少发布件 ${file.name}`); + const digest = sha256(asset); + if (digest !== file.sha256) throw new Error(`${file.name} 校验和不匹配`); + if (fs.statSync(asset).size !== file.size) throw new Error(`${file.name} 体积不匹配`); + fs.copyFileSync(asset, path.join(outDir, file.name)); + rows.push(`${digest} ${file.name}`); + } + } + + rows.sort(); + fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${rows.join('\n')}\n`); + console.log(`已校验 ${rows.length} 个发布件,汇总到 ${outDir}`); + for (const row of rows) console.log(' ' + row); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.verify) verifyDownloads(args.verify); + else if (args.platform) await packRelease(args); + else throw new Error('缺少 --platform 或 --verify 参数'); +} + +module.exports = { TARGETS, parseArgs, readTarEntries }; + +if (require.main === module) { + main().catch((error) => { + console.error('发布打包失败:', error && error.message ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/package.json b/package.json index 575270b..d07b1b0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "build": "node build-portable.js", "portable": "node build-portable.js", "build:mac": "node build-mac.js", + "build:linux": "node build-linux.js", + "release": "node build-release.js", "icons:icns": "node icons/tools/make-icns.js" }, "dependencies": { diff --git a/src/_test/main.test.js b/src/_test/main.test.js index de11ea7..3642324 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -470,6 +470,112 @@ test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => { assert.match(build, /'--verify', '--deep', '--strict'/); }); +test('Linux 打包直接从官方 zip 转写 tar 并保住可执行位', () => { + const root = path.join(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const linux = require(path.join(root, 'build-linux.js')); + + assert.strictEqual(pkg.scripts['build:linux'], 'node build-linux.js'); + + // 主程序、沙箱与共享库丢了执行位就起不来;普通资源不该被误判成可执行 + for (const name of ['PeopleLib', 'chrome-sandbox', 'chrome_crashpad_handler', 'libffmpeg.so']) { + assert.ok(linux.isExecutableName(name), `${name} 应带可执行位`); + } + for (const name of ['resources/app/main.js', 'locales/zh-CN.pak', 'icudtl.dat']) { + assert.ok(!linux.isExecutableName(name), `${name} 不该带可执行位`); + } + + // jszip 给出的权限位优先,缺失时才按文件名兜底 + assert.strictEqual(linux.modeOf({ unixPermissions: 0o644 }, 'chrome-sandbox'), 0o644); + assert.strictEqual(linux.modeOf({}, 'chrome-sandbox'), 0o755); + assert.strictEqual(linux.modeOf({ unixPermissions: null }, 'resources/app/main.js'), 0o644); + + assert.strictEqual(linux.parseArch(['--arch', 'arm64']), 'arm64'); + assert.throws(() => linux.parseArch(['--arch', 'mips']), /不支持的架构/); +}); + +test('Linux tar 条目对超长路径用 PAX 头,权限位可被标准解析读回', () => { + const root = path.join(__dirname, '..', '..'); + const linux = require(path.join(root, 'build-linux.js')); + const { readTarEntries } = require(path.join(root, 'build-release.js')); + + // ustar 的 prefix 只能在斜杠处切分,undici 深层路径切不出合法组合 + const long = `PeopleLib-linux-x64/resources/app/node_modules/undici/lib/web/${'d'.repeat(60)}/x.js`; + assert.ok(Buffer.byteLength(long) > 100); + + const buffer = Buffer.concat([ + linux.tarEntry('PeopleLib-linux-x64/PeopleLib', 0o755, Buffer.from('bin')), + linux.tarEntry(long, 0o644, Buffer.from('src')), + Buffer.alloc(1024) + ]); + const entries = readTarEntries(buffer); + + assert.deepStrictEqual(entries.map((e) => e.name), ['PeopleLib-linux-x64/PeopleLib', long]); + assert.strictEqual(entries[0].mode & 0o111, 0o111); + assert.strictEqual(entries[1].mode & 0o111, 0); +}); + +test('发布打包排除便携版 data 目录并按校验和回验', () => { + const root = path.join(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8'); + const { TARGETS } = require(path.join(root, 'build-release.js')); + + assert.strictEqual(pkg.scripts.release, 'node build-release.js'); + assert.deepStrictEqual( + Object.keys(TARGETS).sort(), + ['linux-arm64', 'linux-x64', 'macos-arm64', 'windows-x64'] + ); + // 发布件名字带版本号,Release 页面上不同版本的资产才不会互相覆盖 + for (const [key, target] of Object.entries(TARGETS)) { + assert.ok(target.asset.includes(pkg.version), `${key} 发布件名缺少版本号`); + } + + // 便携版书库就在构建目录同级 data/,漏掉这条会把用户数据打进公开发布件 + assert.match(release, /!f\.rel\.startsWith\('data\/'\)/); + assert.match(release, /forbidEntries\(names, \[\/\^data\\\/\/, \/\(\^\|\\\/\)_test\\\/\/\]\)/); + // 跨平台产物汇总时逐个比对哈希与版本,防止挂上损坏或版本错配的资产 + assert.match(release, /if \(digest !== file\.sha256\) throw new Error/); + assert.match(release, /manifest\.version !== VERSION/); +}); + +test('CI 工作流覆盖三平台并在标签上核对版本后发布', () => { + const root = path.join(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'build.yml'), 'utf8'); + + for (const target of ['windows-2025', 'macos-15', 'ubuntu-24.04', 'ubuntu-24.04-arm']) { + assert.ok(workflow.includes(target), `构建矩阵缺少 ${target}`); + } + // 集成测试要开真实窗口,无头 runner 上没有 xvfb 会直接崩 + assert.match(workflow, /xvfb-run -a npx electron/); + for (const suite of ['startup', 'download', 'cover', 'annotation', 'reader-features', 'library-notes', 'ai-scope']) { + assert.ok(workflow.includes(suite), `CI 缺少集成套件 ${suite}`); + } + for (const suite of fs.readdirSync(path.join(root, 'src', '_test', 'electron'))) { + if (!suite.endsWith('.integration.js')) continue; + assert.ok( + workflow.includes(suite.replace('.integration.js', '')), + `CI 漏跑集成套件 ${suite}` + ); + } + + // 仓库 .npmrc 指向 npmmirror,GitHub runner 在境外必须切回官方源 + assert.match(fs.readFileSync(path.join(root, '.npmrc'), 'utf8'), /registry\.npmmirror\.com/); + assert.match(workflow, /npm_config_registry: https:\/\/registry\.npmjs\.org\//); + assert.match(workflow, /ELECTRON_MIRROR: https:\/\/github\.com\/electron\/electron\/releases\/download\//); + + // 打标签才发布,且发布前要求标签名与 package.json 版本一致 + assert.match(workflow, /if: github\.event_name == 'push' && github\.ref_type == 'tag'/); + assert.match(workflow, /标签应为/); + assert.ok(workflow.includes(`v'+p.version`)); + assert.match(workflow, /--verify dist\/release-downloads/); + // 先建草稿再转正式,上传中途失败不会留下资产不全的 Release + assert.match(workflow, /gh release edit "\$tag" --draft --verify-tag/); + assert.match(workflow, /gh release edit "\$tag" --draft=false/); + assert.ok(pkg.version && /^\d+\.\d+\.\d+$/.test(pkg.version)); +}); + test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => { assert.match(mainSrc, /await requestReaderPurge\(id\)/); assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);