mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
233 lines
7.6 KiB
JavaScript
233 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
|
const platform = {
|
|
darwin: 'apple-darwin',
|
|
linux: 'unknown-linux-gnu',
|
|
win32: 'pc-windows-msvc',
|
|
}[process.platform];
|
|
if (!arch || !platform) {
|
|
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
|
}
|
|
const targetTriple = `${arch}-${platform}`;
|
|
const argumentIndex = process.argv.indexOf('--binary');
|
|
const binaryPath = path.resolve(
|
|
argumentIndex >= 0
|
|
? process.argv[argumentIndex + 1]
|
|
: path.join(
|
|
repoRoot,
|
|
'src-tauri',
|
|
'binaries',
|
|
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
|
),
|
|
);
|
|
|
|
if (!fs.existsSync(binaryPath)) {
|
|
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
|
}
|
|
|
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
|
|
async function availablePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
|
});
|
|
const address = server.address();
|
|
const port = address && typeof address !== 'string' ? address.port : undefined;
|
|
await new Promise(resolve => server.close(resolve));
|
|
if (!port) throw new Error('Could not reserve a local port');
|
|
return port;
|
|
}
|
|
|
|
async function rpc(port, secret, method, params = []) {
|
|
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: crypto.randomUUID(),
|
|
method,
|
|
params: [`token:${secret}`, ...params],
|
|
}),
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
const body = await response.json();
|
|
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
|
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
|
return body.result;
|
|
}
|
|
|
|
function childExited(child) {
|
|
return child.exitCode !== null || child.signalCode !== null;
|
|
}
|
|
|
|
async function waitForChildExit(child, timeoutMs = 3000) {
|
|
if (childExited(child)) return true;
|
|
return new Promise(resolve => {
|
|
let settled = false;
|
|
let timer;
|
|
const finish = result => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
child.off('exit', onExit);
|
|
resolve(result);
|
|
};
|
|
const onExit = () => finish(true);
|
|
timer = setTimeout(() => finish(false), timeoutMs);
|
|
child.once('exit', onExit);
|
|
if (childExited(child)) finish(true);
|
|
});
|
|
}
|
|
|
|
async function waitForRpc(port, secret) {
|
|
const deadline = Date.now() + 10000;
|
|
let lastError;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
return await rpc(port, secret, 'aria2.getVersion');
|
|
} catch (error) {
|
|
lastError = error;
|
|
await wait(100);
|
|
}
|
|
}
|
|
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
|
}
|
|
|
|
function bencode(value) {
|
|
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
|
if (typeof value === 'string') return bencode(Buffer.from(value));
|
|
if (typeof value === 'number') return Buffer.from(`i${value}e`);
|
|
if (value && typeof value === 'object') {
|
|
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
return Buffer.concat([
|
|
Buffer.from('d'),
|
|
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
|
|
Buffer.from('e'),
|
|
]);
|
|
}
|
|
throw new Error(`Unsupported bencode value: ${typeof value}`);
|
|
}
|
|
|
|
async function stop(child, port, secret) {
|
|
if (!child || childExited(child)) return;
|
|
try {
|
|
await rpc(port, secret, 'aria2.shutdown');
|
|
} catch {
|
|
// The process may already have exited.
|
|
}
|
|
let exited = await waitForChildExit(child);
|
|
if (!exited) {
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
|
stdio: 'ignore',
|
|
timeout: 3000,
|
|
});
|
|
} catch {
|
|
// The process may have exited between the timeout and taskkill.
|
|
}
|
|
} else {
|
|
child.kill('SIGTERM');
|
|
}
|
|
exited = await waitForChildExit(child);
|
|
}
|
|
if (!exited && process.platform !== 'win32') {
|
|
child.kill('SIGKILL');
|
|
exited = await waitForChildExit(child);
|
|
}
|
|
if (!exited) {
|
|
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
|
|
}
|
|
}
|
|
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
|
|
const secret = `firelink-resolver-${crypto.randomUUID()}`;
|
|
const rpcPort = await availablePort();
|
|
const contentServer = http.createServer((_request, response) => {
|
|
response.writeHead(200, { 'content-length': '1' });
|
|
response.end('x');
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
contentServer.once('error', reject);
|
|
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
|
});
|
|
const contentPort = contentServer.address().port;
|
|
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
|
const environment = fs.existsSync(libraryPath)
|
|
? {
|
|
...process.env,
|
|
OPENSSL_MODULES: libraryPath,
|
|
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
|
}
|
|
: process.env;
|
|
const child = spawn(binaryPath, [
|
|
'--enable-rpc=true',
|
|
`--rpc-listen-port=${rpcPort}`,
|
|
'--rpc-listen-all=false',
|
|
`--rpc-secret=${secret}`,
|
|
`--dir=${tempRoot}`,
|
|
'--file-allocation=none',
|
|
'--enable-dht=false',
|
|
'--console-log-level=error',
|
|
'--quiet=true',
|
|
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let stderr = '';
|
|
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
|
|
|
try {
|
|
const version = await waitForRpc(rpcPort, secret);
|
|
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
|
|
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
|
|
|
|
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
|
'async-dns': 'false',
|
|
out: 'resolver-normal.bin',
|
|
}]);
|
|
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
|
if (uriOptions['async-dns'] !== 'false') {
|
|
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
|
|
}
|
|
|
|
const torrent = bencode({
|
|
info: {
|
|
length: 1,
|
|
name: 'resolver-torrent.bin',
|
|
pieces: Buffer.alloc(20),
|
|
'piece length': 16384,
|
|
},
|
|
}).toString('base64');
|
|
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
|
'async-dns': 'false',
|
|
dir: tempRoot,
|
|
}]);
|
|
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
|
if (torrentOptions['async-dns'] !== 'false') {
|
|
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
|
|
}
|
|
|
|
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
|
|
} catch (error) {
|
|
const detail = stderr.trim();
|
|
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
|
} finally {
|
|
try {
|
|
await stop(child, rpcPort, secret);
|
|
} finally {
|
|
await new Promise(resolve => contentServer.close(resolve));
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
}
|