Files
peoplelib/build-linux.js
T
lofyer 7ca023023e
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
feat: 增加 Linux 打包与 GitHub 构建发布链
新增 build-linux.js,直接从官方 zip 转写 tar 保住可执行位,
Windows 上也能构建 Linux 包。新增 build-release.js 作为发布件
唯一出口,排除便携版 data/、回读产物校验内容、生成校验和。
GitHub Actions 分测试、四目标打包、标签发布三段。
2026-08-05 19:07:11 +08:00

333 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Linux 打包:产出 dist/PeopleLib-linux-<arch>.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 图标用 PNG256 供窗口,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;
});
}