mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
74275013dc
Update npm lockfile packages, compatible Rust lockfile entries, and GitHub Actions release workflow actions. Bundle the macOS aria2 OpenSSL legacy provider and set OPENSSL_MODULES for aria2 version checks and daemon startup so packaged builds do not depend on Homebrew OpenSSL provider paths. Keep the Rust Tauri crate family on the previously verified patch line and hold time at 0.3.49 because newer compatible lockfile candidates failed local cargo verification.
442 lines
13 KiB
JavaScript
442 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
function argValue(name) {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
|
const platformMap = {
|
|
darwin: 'apple-darwin',
|
|
win32: 'pc-windows-msvc',
|
|
linux: 'unknown-linux-gnu',
|
|
};
|
|
|
|
const currentArch = archMap[os.arch()];
|
|
const currentPlatform = platformMap[os.platform()];
|
|
|
|
if (!currentArch || !currentPlatform) {
|
|
console.error(`Unsupported architecture or platform: ${os.arch()} / ${os.platform()}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const targetTriple = argValue('--target')
|
|
|| process.env.FIRELINK_TARGET_TRIPLE
|
|
|| `${currentArch}-${currentPlatform}`;
|
|
const hostTriple = `${currentArch}-${currentPlatform}`;
|
|
const canExecuteTarget = targetTriple === hostTriple;
|
|
const isWindows = targetTriple.includes('windows');
|
|
const isMacOS = targetTriple.includes('apple-darwin');
|
|
const isLinux = targetTriple.includes('linux');
|
|
const ext = isWindows ? '.exe' : '';
|
|
const suffix = `-${targetTriple}${ext}`;
|
|
|
|
const scriptsDir = __dirname;
|
|
const searchRoot = argValue('--search-root');
|
|
function findEngineRoot(root) {
|
|
const expected = `yt-dlp-${targetTriple}${ext}`;
|
|
const matches = [];
|
|
const walk = directory => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const candidate = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
|
|
walk(candidate);
|
|
}
|
|
}
|
|
};
|
|
walk(path.resolve(root));
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
|
|
}
|
|
return matches[0];
|
|
}
|
|
|
|
const configuredRoot = argValue('--root')
|
|
|| (process.argv.includes('--staged')
|
|
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
|
|
: searchRoot
|
|
? findEngineRoot(searchRoot)
|
|
: null);
|
|
const binariesDir = configuredRoot
|
|
? path.resolve(configuredRoot)
|
|
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
|
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
|
|
|
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
|
const FORBIDDEN_STDERR = [
|
|
'Failed to load Python shared library',
|
|
'Library not loaded',
|
|
'image not found',
|
|
'Connection refused',
|
|
];
|
|
|
|
let exitCode = 0;
|
|
|
|
function fail(msg) {
|
|
console.error(`[FAIL] ${msg}`);
|
|
exitCode = 1;
|
|
}
|
|
|
|
function ok(msg) {
|
|
console.log(`[OK] ${msg}`);
|
|
}
|
|
|
|
function rejectSymlinks(root, label) {
|
|
if (!fs.existsSync(root)) {
|
|
return;
|
|
}
|
|
|
|
let symlinkCount = 0;
|
|
const walk = directory => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, entry.name);
|
|
const relative = path.relative(root, file).split(path.sep).join('/');
|
|
if (entry.isSymbolicLink()) {
|
|
fail(`Unsupported symlink in ${label}: ${relative}`);
|
|
symlinkCount += 1;
|
|
} else if (entry.isDirectory()) {
|
|
walk(file);
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(root);
|
|
if (symlinkCount === 0) {
|
|
ok(`${label} contains no symlinks`);
|
|
}
|
|
}
|
|
|
|
function binName(engine) {
|
|
return `${engine}${suffix}`;
|
|
}
|
|
|
|
function binPath(engine) {
|
|
return path.join(binariesDir, binName(engine));
|
|
}
|
|
|
|
function engineEnv(engine) {
|
|
if (engine !== 'aria2c') {
|
|
return process.env;
|
|
}
|
|
|
|
const modulesDir = path.join(binariesDir, 'aria2-libs');
|
|
if (!fs.existsSync(modulesDir)) {
|
|
return process.env;
|
|
}
|
|
|
|
return {
|
|
...process.env,
|
|
OPENSSL_MODULES: modulesDir,
|
|
};
|
|
}
|
|
|
|
// ───── Check 1: Sidecar existence ─────
|
|
console.log(`\n─── 1. Sidecar existence (${targetTriple}) ───`);
|
|
for (const eng of requiredEngines) {
|
|
const p = binPath(eng);
|
|
if (fs.existsSync(p)) {
|
|
ok(`Found ${binName(eng)}`);
|
|
} else {
|
|
fail(`Missing ${binName(eng)}`);
|
|
}
|
|
}
|
|
|
|
if (exitCode !== 0) {
|
|
console.error('\nAborting: missing required sidecars.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ───── Check 2: Executable permission ─────
|
|
console.log('\n─── 2. Executable permission ───');
|
|
for (const eng of requiredEngines) {
|
|
if (isWindows) {
|
|
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
|
|
continue;
|
|
}
|
|
try {
|
|
fs.accessSync(binPath(eng), fs.constants.X_OK);
|
|
ok(`Executable ${binName(eng)}`);
|
|
} catch {
|
|
fail(`Not executable ${binName(eng)}`);
|
|
}
|
|
}
|
|
|
|
// ───── Check 3: file(1) identification ─────
|
|
console.log('\n─── 3. file(1) identification ───');
|
|
for (const eng of requiredEngines) {
|
|
if (os.platform() === 'win32') {
|
|
try {
|
|
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
|
|
if (header === 'MZ') {
|
|
ok(`${binName(eng)}: Windows PE executable`);
|
|
} else {
|
|
fail(`${binName(eng)}: missing Windows PE MZ header`);
|
|
}
|
|
} catch (e) {
|
|
fail(`identify ${binName(eng)}: ${e.message}`);
|
|
}
|
|
continue;
|
|
}
|
|
try {
|
|
const out = execFileSync('file', ['--brief', binPath(eng)], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
}).trim();
|
|
ok(`${binName(eng)}: ${out}`);
|
|
} catch (e) {
|
|
fail(`file ${binName(eng)}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// ───── Check 4 & 5: otool -L linkage (macOS only) ─────
|
|
if (isMacOS) {
|
|
console.log('\n─── 4 & 5. otool -L linkage check ───');
|
|
for (const eng of requiredEngines) {
|
|
const p = binPath(eng);
|
|
try {
|
|
const out = execFileSync('otool', ['-L', p], {
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
const lines = out
|
|
.split('\n')
|
|
.slice(1)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
let hasBad = false;
|
|
for (const fp of FORBIDDEN_OTOOL_PATHS) {
|
|
for (const line of lines) {
|
|
if (line.includes(fp)) {
|
|
fail(`${binName(eng)} links to '${fp}': ${line}`);
|
|
hasBad = true;
|
|
}
|
|
}
|
|
}
|
|
if (!hasBad) {
|
|
ok(`${binName(eng)}: no local-only dylib paths`);
|
|
}
|
|
} catch (e) {
|
|
fail(`otool -L ${binName(eng)}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ───── Check 6: yt-dlp packaging ─────
|
|
console.log('\n─── 6. yt-dlp packaging ───');
|
|
{
|
|
const yt = binPath('yt-dlp');
|
|
if (!fs.existsSync(yt)) {
|
|
fail('yt-dlp binary not found, cannot verify packaging');
|
|
} else {
|
|
const internalDir = path.join(binariesDir, '_internal');
|
|
const hasInternal = fs.existsSync(internalDir) && fs.statSync(internalDir).isDirectory();
|
|
|
|
if (hasInternal) {
|
|
console.log(' Detected PyInstaller onedir layout (_internal/ present)');
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(internalDir);
|
|
ok(`_internal/ directory exists with ${entries.length} entries`);
|
|
} catch (e) {
|
|
fail(`Cannot read _internal/: ${e.message}`);
|
|
}
|
|
rejectSymlinks(internalDir, '_internal/');
|
|
|
|
const requiredRuntimeFiles = [
|
|
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
|
|
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
|
|
];
|
|
for (const required of requiredRuntimeFiles) {
|
|
if (fs.existsSync(required)) {
|
|
ok(`yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
|
|
} else {
|
|
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
|
|
}
|
|
}
|
|
const runtimeCandidates = isWindows
|
|
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
|
|
: isLinux
|
|
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
|
|
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
|
|
if (runtimeCandidates.length > 0) {
|
|
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
|
|
} else {
|
|
fail(`Missing embedded Python runtime in ${internalDir}`);
|
|
}
|
|
} else {
|
|
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
|
|
}
|
|
}
|
|
}
|
|
|
|
const aria2LibsDir = path.join(binariesDir, 'aria2-libs');
|
|
if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) {
|
|
rejectSymlinks(aria2LibsDir, 'aria2-libs/');
|
|
}
|
|
|
|
// ───── Check 7, 8 & 9: Engine version self-tests ─────
|
|
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
|
|
|
|
function runEngine(label, engine, args, timeout = 30000) {
|
|
const p = binPath(engine);
|
|
if (!fs.existsSync(p)) {
|
|
fail(`${label} binary not found at ${p}`);
|
|
return;
|
|
}
|
|
let stderr = '';
|
|
try {
|
|
const stdout = execFileSync(p, args, {
|
|
encoding: 'utf-8',
|
|
env: engineEnv(engine),
|
|
timeout,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const firstLine = stdout.trim().split('\n')[0];
|
|
ok(`${label} version: ${firstLine}`);
|
|
} catch (e) {
|
|
stderr = e.stderr || '';
|
|
const stdout = e.stdout || '';
|
|
const detail = stdout.trim().split('\n')[0] || e.message;
|
|
fail(`${label} execution failed: ${detail}`);
|
|
}
|
|
if (stderr) {
|
|
for (const pattern of FORBIDDEN_STDERR) {
|
|
if (stderr.includes(pattern)) {
|
|
fail(`${label} stderr contains '${pattern}'`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const coldStartTimeout = isMacOS ? 120000 : 30000;
|
|
if (canExecuteTarget) {
|
|
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
|
|
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
|
|
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
|
|
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
|
|
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
|
|
if (isMacOS) {
|
|
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
|
|
// Warm checks enforce engine startup performance after that OS validation.
|
|
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
|
|
runEngine('deno warm start', 'deno', ['--version'], 8000);
|
|
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
|
|
}
|
|
} else {
|
|
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
|
|
}
|
|
|
|
// ───── aria2 RPC smoke test (native target only) ─────
|
|
if (canExecuteTarget) {
|
|
console.log('\n─── aria2 RPC smoke test ───');
|
|
await (async function testAria2Rpc() {
|
|
const p = binPath('aria2c');
|
|
if (!fs.existsSync(p)) {
|
|
fail('aria2c binary not found, cannot run RPC test');
|
|
return;
|
|
}
|
|
|
|
const port = 16801 + (process.pid % 1000);
|
|
const proc = spawn(p, [
|
|
'--enable-rpc',
|
|
`--rpc-listen-port=${port}`,
|
|
'--rpc-max-request-size=1K',
|
|
'--quiet',
|
|
'--console-log-level=error',
|
|
'--rpc-listen-all=false',
|
|
], {
|
|
env: engineEnv('aria2c'),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 15000,
|
|
});
|
|
|
|
let rpcStderr = '';
|
|
proc.stderr.on('data', (d) => {
|
|
rpcStderr += d.toString();
|
|
});
|
|
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 'firelink-verify',
|
|
method: 'aria2.getVersion',
|
|
params: [],
|
|
});
|
|
|
|
const result = await new Promise((resolve) => {
|
|
const maxAttempts = 20;
|
|
let attempts = 0;
|
|
|
|
function tryFetch() {
|
|
attempts++;
|
|
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body,
|
|
})
|
|
.then(async (res) => {
|
|
resolve({ ok: true, data: await res.text() });
|
|
})
|
|
.catch(() => {
|
|
if (attempts >= maxAttempts) {
|
|
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
|
return;
|
|
}
|
|
setTimeout(tryFetch, 300);
|
|
});
|
|
}
|
|
|
|
tryFetch();
|
|
});
|
|
|
|
// Clean up
|
|
proc.kill('SIGTERM');
|
|
setTimeout(() => {
|
|
try {
|
|
proc.kill('SIGKILL');
|
|
} catch {}
|
|
}, 2000);
|
|
|
|
if (result.ok) {
|
|
try {
|
|
const resp = JSON.parse(result.data);
|
|
if (resp?.result?.version) {
|
|
ok(`aria2 RPC version: ${resp.result.version}`);
|
|
} else {
|
|
fail(`aria2 RPC unexpected response: ${result.data}`);
|
|
}
|
|
} catch (e) {
|
|
fail(`aria2 RPC parse error: ${e.message}`);
|
|
}
|
|
} else {
|
|
fail(result.error);
|
|
}
|
|
|
|
if (rpcStderr) {
|
|
for (const pattern of FORBIDDEN_STDERR) {
|
|
if (rpcStderr.includes(pattern)) {
|
|
fail(`aria2 RPC stderr contains '${pattern}'`);
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
// ───── Result ─────
|
|
console.log('');
|
|
if (exitCode !== 0) {
|
|
console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log('[PASS] All engine verification checks passed.');
|
|
process.exit(0);
|
|
}
|