// 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`), collectFile( path.join(ROOT, 'manga-online-preload.js'), `${app}/manga-online-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; }); }