mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 02:40:21 +00:00
feat(downloads): add adaptive mirror reliability
This commit is contained in:
@@ -82,3 +82,5 @@ jobs:
|
||||
run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths
|
||||
- name: Run Aria2 resolver smoke
|
||||
run: node scripts/smoke-aria2-resolver.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
- name: Run Aria2 normal-transfer smoke
|
||||
run: node scripts/smoke-aria2-transfers.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||
|
||||
@@ -118,6 +118,9 @@ jobs:
|
||||
test -n "$DMG"
|
||||
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
|
||||
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
|
||||
ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||
test -n "$ARIA2"
|
||||
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
|
||||
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
|
||||
- name: Verify Windows installer payload and launch
|
||||
if: runner.os == 'Windows'
|
||||
@@ -130,6 +133,12 @@ jobs:
|
||||
& 7z x $installer.FullName "-o$extractRoot" -y
|
||||
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
|
||||
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
|
||||
$aria2 = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ieq "aria2c-${{ matrix.target }}.exe" } |
|
||||
Sort-Object FullName |
|
||||
Select-Object -First 1
|
||||
if (-not $aria2) { throw "Packaged Aria2 executable was not found in the installer payload." }
|
||||
node scripts/smoke-aria2-transfers.js --binary $aria2.FullName
|
||||
|
||||
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
|
||||
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
|
||||
@@ -195,6 +204,9 @@ jobs:
|
||||
chmod +x "$APPIMAGE"
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
|
||||
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
|
||||
ARIA2="$(find "$RUNNER_TEMP/squashfs-root" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
|
||||
test -n "$ARIA2"
|
||||
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
|
||||
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"smoke:torrent": "node scripts/smoke-torrent.js",
|
||||
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
|
||||
"smoke:aria2:resolver": "node scripts/smoke-aria2-resolver.js",
|
||||
"smoke:aria2:transfers": "node scripts/smoke-aria2-transfers.js",
|
||||
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
|
||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const sourceArgument = process.argv.indexOf('--source');
|
||||
const sourceLocation = sourceArgument >= 0
|
||||
? process.argv[sourceArgument + 1]
|
||||
: 'https://raw.githubusercontent.com/aria2/aria2/release-1.37.0/doc/manual-src/en/aria2c.rst';
|
||||
|
||||
if (!sourceLocation) throw new Error('--source requires a path or URL');
|
||||
|
||||
const source = /^https:\/\//.test(sourceLocation)
|
||||
? await fetch(sourceLocation, { signal: AbortSignal.timeout(15000) }).then(response => {
|
||||
if (!response.ok) throw new Error(`Aria2 manual fetch failed with HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
: fs.readFileSync(sourceLocation, 'utf8');
|
||||
|
||||
function section(start, end) {
|
||||
const startIndex = source.indexOf(`${start}\n`);
|
||||
const endIndex = source.indexOf(`${end}\n`, startIndex + start.length);
|
||||
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find manual section ${start} -> ${end}`);
|
||||
return source.slice(startIndex, endIndex);
|
||||
}
|
||||
|
||||
function options(text) {
|
||||
const names = [...text.matchAll(/^\.\. option:: .*?(--[a-z0-9-]+)/gm)]
|
||||
.map(match => match[1].slice(2));
|
||||
return [...new Set(names)].sort();
|
||||
}
|
||||
|
||||
const normal = options(section('HTTP/FTP/SFTP Options', 'BitTorrent/Metalink Options'));
|
||||
const torrent = options(
|
||||
`${section('BitTorrent/Metalink Options', 'BitTorrent Specific Options')}\n${section('BitTorrent Specific Options', 'Metalink Specific Options')}`,
|
||||
);
|
||||
|
||||
if (torrent.length !== 46) {
|
||||
throw new Error(`Expected 46 unique Aria2 1.37.0 Torrent options, found ${torrent.length}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
source: sourceLocation,
|
||||
aria2Version: '1.37.0',
|
||||
normal: { count: normal.length, options: normal },
|
||||
torrent: { count: torrent.length, options: torrent },
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/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 listen(server) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Could not allocate local fixture port');
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
const port = await listen(server);
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
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(5000),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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'}`);
|
||||
}
|
||||
|
||||
async function waitForTerminal(port, secret, gid, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, [
|
||||
'status', 'errorCode', 'errorMessage', 'completedLength', 'totalLength',
|
||||
]]);
|
||||
if (['complete', 'error', 'removed'].includes(latest.status)) return latest;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} did not become terminal: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
async function waitForProgress(port, secret, gid, timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, ['status', 'completedLength']]);
|
||||
if (latest.status === 'active' && Number(latest.completedLength) > 0) return latest;
|
||||
await wait(25);
|
||||
}
|
||||
throw new Error(`Aria2 gid ${gid} made no observable progress: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
function serveBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
if (!match) {
|
||||
response.writeHead(200, { 'content-length': String(buffer.length), 'accept-ranges': 'bytes' });
|
||||
response.end(buffer);
|
||||
return;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const requestedEnd = match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(206, {
|
||||
'content-length': String(body.length),
|
||||
'content-range': `bytes ${start}-${end}/${buffer.length}`,
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function serveThrottledBuffer(request, response, buffer) {
|
||||
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
const requestedEnd = match && match[2] ? Number(match[2]) : buffer.length - 1;
|
||||
const end = Math.min(requestedEnd, buffer.length - 1);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
|
||||
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const body = buffer.subarray(start, end + 1);
|
||||
response.writeHead(match ? 206 : 200, {
|
||||
'content-length': String(body.length),
|
||||
...(match ? { 'content-range': `bytes ${start}-${end}/${buffer.length}` } : {}),
|
||||
'accept-ranges': 'bytes',
|
||||
});
|
||||
let offset = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || offset >= body.length) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(offset + 32 * 1024, body.length);
|
||||
response.write(body.subarray(offset, next));
|
||||
offset = next;
|
||||
}, 20);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
}
|
||||
|
||||
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;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// It may already be stopping.
|
||||
}
|
||||
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 {
|
||||
// It 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 cleanup`);
|
||||
}
|
||||
|
||||
async function removeTempRoot(tempRoot) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100 * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
|
||||
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
|
||||
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
|
||||
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
|
||||
let finalRequests = 0;
|
||||
let finalCredentials = [];
|
||||
let redirectSourceCredentials;
|
||||
let smokePassed = false;
|
||||
let smokeFailure;
|
||||
|
||||
const targetServer = http.createServer((request, response) => {
|
||||
finalRequests += 1;
|
||||
finalCredentials.push({
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
});
|
||||
serveBuffer(request, response, payload);
|
||||
});
|
||||
const targetPort = await listen(targetServer);
|
||||
const fixtureServer = http.createServer((request, response) => {
|
||||
switch (new URL(request.url, 'http://fixture.invalid').pathname) {
|
||||
case '/range':
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
case '/no-range':
|
||||
response.writeHead(200, { 'content-length': String(payload.length) });
|
||||
response.end(payload);
|
||||
break;
|
||||
case '/throttled':
|
||||
serveThrottledBuffer(request, response, payload);
|
||||
break;
|
||||
case '/authenticated': {
|
||||
const expectedAuthorization = `Basic ${Buffer.from('fixture-user:fixture-password').toString('base64')}`;
|
||||
if (request.headers.authorization !== expectedAuthorization
|
||||
|| request.headers.cookie !== 'fixture-cookie=present'
|
||||
|| request.headers['x-firelink-auth'] !== 'present') {
|
||||
response.writeHead(401, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
}
|
||||
serveBuffer(request, response, payload);
|
||||
break;
|
||||
}
|
||||
case '/redirect':
|
||||
redirectSourceCredentials = {
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
custom: request.headers['x-firelink-secret'],
|
||||
};
|
||||
response.writeHead(302, { location: `http://127.0.0.1:${targetPort}/final` });
|
||||
response.end();
|
||||
break;
|
||||
case '/missing':
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
break;
|
||||
case '/malformed':
|
||||
response.writeHead(200, { 'content-length': String(payload.length * 2) });
|
||||
response.write(payload.subarray(0, 1024));
|
||||
response.destroy();
|
||||
break;
|
||||
case '/slow': {
|
||||
const slowLength = 64 * 1024;
|
||||
let written = 0;
|
||||
response.writeHead(200, { 'content-length': String(slowLength) });
|
||||
const timer = setInterval(() => {
|
||||
if (response.destroyed || written >= slowLength) {
|
||||
clearInterval(timer);
|
||||
if (!response.destroyed) response.end();
|
||||
return;
|
||||
}
|
||||
response.write(Buffer.alloc(1024, 0x73));
|
||||
written += 1024;
|
||||
}, 500);
|
||||
response.once('close', () => clearInterval(timer));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
response.writeHead(404, { 'content-length': '0' });
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
const fixturePort = await listen(fixtureServer);
|
||||
const rpcPort = await availablePort();
|
||||
const unavailableProxyPort = await availablePort();
|
||||
const secret = `firelink-transfers-${crypto.randomUUID()}`;
|
||||
const configPath = path.join(tempRoot, 'aria2.conf');
|
||||
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
|
||||
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',
|
||||
`--conf-path=${configPath}`,
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
`--server-stat-if=${serverStatPath}`,
|
||||
`--server-stat-of=${serverStatPath}`,
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke`);
|
||||
|
||||
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
|
||||
}]);
|
||||
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
|
||||
if (rangeStatus.status !== 'complete' || !fs.readFileSync(path.join(tempRoot, 'range.bin')).equals(payload)) {
|
||||
throw new Error(`bounded-range transfer failed: ${JSON.stringify(rangeStatus)}`);
|
||||
}
|
||||
|
||||
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
|
||||
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
|
||||
throw new Error('single-connection no-range transfer did not complete');
|
||||
}
|
||||
|
||||
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
|
||||
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
|
||||
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, authenticatedGid)).status !== 'complete') {
|
||||
throw new Error('authenticated cookie/header transfer did not complete');
|
||||
}
|
||||
|
||||
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'resume.bin', split: '1', continue: 'true',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, resumeGid);
|
||||
await rpc(rpcPort, secret, 'aria2.pause', [resumeGid]);
|
||||
const pausedStatus = await rpc(rpcPort, secret, 'aria2.tellStatus', [resumeGid, ['status', 'completedLength']]);
|
||||
if (pausedStatus.status !== 'paused' || Number(pausedStatus.completedLength) <= 0) {
|
||||
throw new Error(`normal transfer did not pause with resumable progress: ${JSON.stringify(pausedStatus)}`);
|
||||
}
|
||||
await rpc(rpcPort, secret, 'aria2.unpause', [resumeGid]);
|
||||
if ((await waitForTerminal(rpcPort, secret, resumeGid)).status !== 'complete') {
|
||||
throw new Error('paused normal transfer did not resume to completion');
|
||||
}
|
||||
|
||||
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
|
||||
out: 'cancel.bin', split: '1',
|
||||
}]);
|
||||
await waitForProgress(rpcPort, secret, cancelGid);
|
||||
await rpc(rpcPort, secret, 'aria2.remove', [cancelGid]);
|
||||
const cancelStatus = await waitForTerminal(rpcPort, secret, cancelGid);
|
||||
if (cancelStatus.status !== 'removed') {
|
||||
throw new Error(`normal transfer cancellation did not reach removed: ${JSON.stringify(cancelStatus)}`);
|
||||
}
|
||||
|
||||
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
|
||||
`http://127.0.0.1:${fixturePort}/missing`,
|
||||
`http://127.0.0.1:${fixturePort}/range`,
|
||||
], { out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
|
||||
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
|
||||
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
|
||||
|
||||
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
|
||||
throw new Error('valid checksum transfer did not complete');
|
||||
}
|
||||
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
|
||||
}]);
|
||||
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
|
||||
if (mismatchStatus.status !== 'error') throw new Error(`checksum mismatch was not rejected: ${JSON.stringify(mismatchStatus)}`);
|
||||
|
||||
// Model Firelink's manual preflight: credentials reach the original origin,
|
||||
// the redirect is not followed by the HTTP client, and Aria2 receives only
|
||||
// the resolved cross-origin URL without credential options.
|
||||
const redirectProbe = await fetch(`http://127.0.0.1:${fixturePort}/redirect`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-0',
|
||||
Authorization: 'Bearer fixture-secret',
|
||||
Cookie: 'fixture=secret',
|
||||
'X-Firelink-Secret': 'fixture',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (redirectProbe.status !== 302) throw new Error(`redirect preflight returned HTTP ${redirectProbe.status}`);
|
||||
if (!redirectSourceCredentials || Object.values(redirectSourceCredentials).some(value => !value)) {
|
||||
throw new Error(`redirect source did not receive its scoped credentials: ${JSON.stringify(redirectSourceCredentials)}`);
|
||||
}
|
||||
const redirectLocation = redirectProbe.headers.get('location');
|
||||
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
|
||||
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
|
||||
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
|
||||
out: 'redirect.bin',
|
||||
}]);
|
||||
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
|
||||
if (redirectStatus.status !== 'complete' || finalRequests === 0 || finalCredentials.some(headers => Object.values(headers).some(Boolean))) {
|
||||
throw new Error(`redirect credential boundary failed: ${JSON.stringify({ redirectStatus, finalRequests, finalCredentials })}`);
|
||||
}
|
||||
|
||||
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
|
||||
out: 'missing.bin', 'max-tries': '1',
|
||||
}]);
|
||||
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
|
||||
if (missingStatus.status !== 'error' || !['3', '4'].includes(missingStatus.errorCode)) {
|
||||
throw new Error(`not-found error classification changed: ${JSON.stringify(missingStatus)}`);
|
||||
}
|
||||
|
||||
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
|
||||
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
|
||||
}]);
|
||||
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
|
||||
if (lowSpeedStatus.status !== 'error' || lowSpeedStatus.errorCode !== '5') {
|
||||
throw new Error(`low-speed error classification changed: ${JSON.stringify(lowSpeedStatus)}`);
|
||||
}
|
||||
|
||||
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
|
||||
out: 'malformed.bin', 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
|
||||
throw new Error('malformed response unexpectedly completed');
|
||||
}
|
||||
|
||||
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
|
||||
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
|
||||
}]);
|
||||
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
|
||||
throw new Error('unavailable proxy unexpectedly completed');
|
||||
}
|
||||
|
||||
smokePassed = true;
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
if (smokePassed) {
|
||||
const stat = fs.readFileSync(serverStatPath, 'utf8');
|
||||
if (!stat.includes('host=127.0.0.1')) {
|
||||
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([
|
||||
new Promise(resolve => fixtureServer.close(resolve)),
|
||||
new Promise(resolve => targetServer.close(resolve)),
|
||||
]);
|
||||
await removeTempRoot(tempRoot);
|
||||
} catch (error) {
|
||||
if (!smokeFailure) smokeFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (smokeFailure) throw smokeFailure;
|
||||
console.log('[PASS] Aria2 normal transfers, auth/cookies, resume/cancel, mirrors, integrity, redirects, low-speed/not-found classification, malformed responses, proxy failures, and server statistics');
|
||||
@@ -54,6 +54,10 @@ fn default_aria2_disk_cache() -> String {
|
||||
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
||||
}
|
||||
|
||||
fn default_adaptive_mirror_selection() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -686,6 +690,12 @@ pub struct PersistedSettings {
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
#[serde(default)]
|
||||
pub minimum_normal_download_speed_ki_b: u32,
|
||||
#[serde(default)]
|
||||
pub retry_not_found_errors: bool,
|
||||
#[serde(default = "default_adaptive_mirror_selection")]
|
||||
pub adaptive_mirror_selection: bool,
|
||||
pub show_notifications: bool,
|
||||
pub play_completion_sound: bool,
|
||||
#[serde(default)]
|
||||
|
||||
+137
-2
@@ -3073,7 +3073,7 @@ fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBu
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
struct Aria2DaemonGuard {
|
||||
@@ -3081,6 +3081,7 @@ struct Aria2DaemonGuard {
|
||||
startup_error: Mutex<Option<String>>,
|
||||
last_stderr: Mutex<String>,
|
||||
config_path: Mutex<Option<tempfile::TempPath>>,
|
||||
shutdown_state: AtomicU8,
|
||||
}
|
||||
|
||||
impl Aria2DaemonGuard {
|
||||
@@ -3090,8 +3091,63 @@ impl Aria2DaemonGuard {
|
||||
startup_error: Mutex::new(None),
|
||||
last_stderr: Mutex::new(String::new()),
|
||||
config_path: Mutex::new(None),
|
||||
shutdown_state: AtomicU8::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_allowed(&self) -> bool {
|
||||
self.shutdown_state.load(Ordering::SeqCst) == 2
|
||||
}
|
||||
|
||||
fn begin_shutdown(&self) -> bool {
|
||||
self.shutdown_state
|
||||
.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn allow_exit(&self) {
|
||||
self.shutdown_state.store(2, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) {
|
||||
let guard = app_handle.state::<Aria2DaemonGuard>();
|
||||
if let Some(state) = app_handle.try_state::<AppState>() {
|
||||
let port = state.aria2_port.load(Ordering::Relaxed);
|
||||
if port != 0 {
|
||||
let shutdown = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
rpc_call(port, &state.aria2_secret, "aria2.shutdown", serde_json::json!([])),
|
||||
)
|
||||
.await;
|
||||
match shutdown {
|
||||
Ok(Ok(_)) => log::info!("aria2 graceful shutdown requested"),
|
||||
Ok(Err(error)) => log::warn!("aria2 graceful shutdown failed: {error}"),
|
||||
Err(_) => log::warn!("aria2 graceful shutdown timed out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let child = guard.child.lock().ok().and_then(|mut child| child.take());
|
||||
if let Some(mut child) = child {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Aria2DaemonGuard {
|
||||
@@ -8762,6 +8818,9 @@ async fn verify_torrent_data(
|
||||
mirrors: None,
|
||||
user_agent: None,
|
||||
max_tries: Some(0),
|
||||
minimum_normal_download_speed_kib: None,
|
||||
retry_not_found_errors: None,
|
||||
adaptive_mirror_selection: None,
|
||||
proxy: None,
|
||||
format_selector: None,
|
||||
cookie_source: None,
|
||||
@@ -9240,6 +9299,19 @@ fn apply_aria2_torrent_dht_options(
|
||||
command.arg(format!("--dht-message-timeout={timeout}"));
|
||||
}
|
||||
|
||||
fn apply_aria2_server_stat_options(
|
||||
command: &mut std::process::Command,
|
||||
path: Option<&std::path::Path>,
|
||||
) {
|
||||
let Some(path) = path else {
|
||||
return;
|
||||
};
|
||||
command
|
||||
.arg(format!("--server-stat-if={}", path.display()))
|
||||
.arg(format!("--server-stat-of={}", path.display()))
|
||||
.arg("--server-stat-timeout=86400");
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_peer_identity_options(
|
||||
command: &mut std::process::Command,
|
||||
peer_id_prefix: &str,
|
||||
@@ -10741,6 +10813,7 @@ mod tests {
|
||||
apply_aria2_torrent_peer_discovery_options,
|
||||
apply_aria2_torrent_dht_paths,
|
||||
apply_aria2_torrent_dht_options,
|
||||
apply_aria2_server_stat_options,
|
||||
aria2_rpc_port_is_occupied,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
collect_opened_torrent_paths,
|
||||
@@ -10762,6 +10835,7 @@ mod tests {
|
||||
retained_torrent_id_from_persisted_record,
|
||||
retained_torrent_info_hash_from_persisted_record,
|
||||
merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair,
|
||||
Aria2DaemonGuard,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::should_apply_dock_badge_update;
|
||||
@@ -11013,6 +11087,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("server-stat.txt");
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
apply_aria2_server_stat_options(&mut command, Some(&path));
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
format!("--server-stat-if={}", path.display()),
|
||||
format!("--server-stat-of={}", path.display()),
|
||||
"--server-stat-timeout=86400".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let mut disabled = std::process::Command::new("aria2c");
|
||||
apply_aria2_server_stat_options(&mut disabled, None);
|
||||
assert_eq!(disabled.get_args().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_shutdown_blocks_repeated_exit_requests_until_cleanup_finishes() {
|
||||
let guard = Aria2DaemonGuard::new();
|
||||
assert!(!guard.exit_allowed());
|
||||
assert!(guard.begin_shutdown());
|
||||
assert!(!guard.begin_shutdown());
|
||||
assert!(!guard.exit_allowed());
|
||||
|
||||
guard.allow_exit();
|
||||
assert!(guard.exit_allowed());
|
||||
assert!(!guard.begin_shutdown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
@@ -13765,6 +13875,15 @@ pub fn run() {
|
||||
// conflict must fail startup; silently allowing Aria2 to fall back
|
||||
// to a user-global dht.dat would escape the storage boundary.
|
||||
let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?;
|
||||
let aria2_server_stat_path = match storage_layout.prepare_aria2_server_stat_path() {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"adaptive mirror history is disabled for this session: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||
}
|
||||
@@ -14045,6 +14164,10 @@ pub fn run() {
|
||||
&mut cmd,
|
||||
torrent_startup_settings.dht_message_timeout,
|
||||
);
|
||||
apply_aria2_server_stat_options(
|
||||
&mut cmd,
|
||||
aria2_server_stat_path.as_deref(),
|
||||
);
|
||||
|
||||
apply_aria2_torrent_peer_discovery_options(
|
||||
&mut cmd,
|
||||
@@ -15008,9 +15131,21 @@ pub fn run() {
|
||||
restore_main_window(app_handle);
|
||||
}
|
||||
}
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
tauri::RunEvent::ExitRequested { code, api, .. } => {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let _ = state.extension_server_shutdown.send(true);
|
||||
let guard = app_handle.state::<Aria2DaemonGuard>();
|
||||
if !guard.exit_allowed() {
|
||||
api.prevent_exit();
|
||||
if guard.begin_shutdown() {
|
||||
let app = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
shutdown_aria2_daemon(app.clone()).await;
|
||||
app.state::<Aria2DaemonGuard>().allow_exit();
|
||||
app.exit(code.unwrap_or(0));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
+339
-71
@@ -50,6 +50,16 @@ pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
|
||||
pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
|
||||
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
|
||||
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
|
||||
pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576;
|
||||
|
||||
pub fn normalize_minimum_normal_download_speed_kib(value: u32) -> Result<u32, String> {
|
||||
if value > MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB {
|
||||
return Err(format!(
|
||||
"minimum normal download speed must be between 0 and {MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB} KiB/s"
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn normalize_sftp_host_key_md(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
@@ -761,6 +771,9 @@ pub struct SpawnPayload {
|
||||
pub mirrors: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub max_tries: Option<i32>,
|
||||
pub minimum_normal_download_speed_kib: u32,
|
||||
pub retry_not_found_errors: bool,
|
||||
pub adaptive_mirror_selection: bool,
|
||||
pub proxy: Option<String>,
|
||||
/// Runtime-only resolver selection. This is never part of an enqueue or
|
||||
/// persisted download payload.
|
||||
@@ -4932,6 +4945,27 @@ fn is_retryable_aria2_error(error: &str) -> bool {
|
||||
is_transient_network_error(error) || is_aria2_range_mode_error(error)
|
||||
}
|
||||
|
||||
fn is_aria2_not_found_error(error: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
lower.contains("aria2 error code 3") || lower.contains("aria2 error code 4")
|
||||
}
|
||||
|
||||
fn is_aria2_low_speed_error(error: &str) -> bool {
|
||||
error
|
||||
.to_ascii_lowercase()
|
||||
.contains("aria2 error code 5")
|
||||
}
|
||||
|
||||
fn is_retryable_aria2_error_for_payload(payload: &SpawnPayload, error: &str) -> bool {
|
||||
is_retryable_aria2_error(error)
|
||||
|| (!payload.is_torrent
|
||||
&& payload.retry_not_found_errors
|
||||
&& is_aria2_not_found_error(error))
|
||||
|| (!payload.is_torrent
|
||||
&& payload.minimum_normal_download_speed_kib > 0
|
||||
&& is_aria2_low_speed_error(error))
|
||||
}
|
||||
|
||||
fn should_use_aria2_system_resolver_fallback(
|
||||
payload: &SpawnPayload,
|
||||
error: &str,
|
||||
@@ -4958,7 +4992,9 @@ fn aria2_retry_action(
|
||||
if should_use_aria2_system_resolver_fallback(payload, error, async_dns_supported) {
|
||||
return Aria2RetryAction::SystemResolverFallback;
|
||||
}
|
||||
if is_retryable_aria2_error(error) && strike < automatic_retry_limit(payload.max_tries) {
|
||||
if is_retryable_aria2_error_for_payload(payload, error)
|
||||
&& strike < automatic_retry_limit(payload.max_tries)
|
||||
{
|
||||
Aria2RetryAction::OrdinaryRetry
|
||||
} else {
|
||||
Aria2RetryAction::Terminal
|
||||
@@ -5003,53 +5039,111 @@ enum BoundedRangeSupport {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> Result<i32, String> {
|
||||
let requested = clamp_download_connections(
|
||||
payload
|
||||
.connections
|
||||
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
|
||||
);
|
||||
if requested <= 1 {
|
||||
return Ok(requested);
|
||||
}
|
||||
struct HttpTransferProbe {
|
||||
final_uri: String,
|
||||
range_support: BoundedRangeSupport,
|
||||
credentials_allowed: bool,
|
||||
}
|
||||
|
||||
for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) {
|
||||
struct PreparedNormalTransfer {
|
||||
uris: Vec<String>,
|
||||
connections: i32,
|
||||
credentials_allowed: bool,
|
||||
}
|
||||
|
||||
fn payload_has_credential_material(payload: &SpawnPayload) -> bool {
|
||||
[
|
||||
payload.username.as_deref(),
|
||||
payload.password.as_deref(),
|
||||
payload.cookies.as_deref(),
|
||||
payload.headers.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
async fn prepare_normal_transfer(
|
||||
id: &str,
|
||||
payload: &SpawnPayload,
|
||||
) -> Result<PreparedNormalTransfer, String> {
|
||||
let credential_origin = reqwest::Url::parse(&payload.url)
|
||||
.map_err(|_| "normal download has an invalid primary URL".to_string())?;
|
||||
let requested =
|
||||
clamp_download_connections(payload.connections.unwrap_or(DOWNLOAD_CONNECTIONS_MIN));
|
||||
let mut connections = requested;
|
||||
let mut uris = Vec::new();
|
||||
let mut credentials_allowed = true;
|
||||
for (index, uri) in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if !is_http_uri(&uri) {
|
||||
crate::resolve_and_validate_url_host(
|
||||
&reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?,
|
||||
)
|
||||
.await?;
|
||||
uris.push(uri);
|
||||
continue;
|
||||
}
|
||||
|
||||
match probe_bounded_range_support(&uri, payload).await {
|
||||
Ok(BoundedRangeSupport::Unsupported) => {
|
||||
log::warn!(
|
||||
"aria2 range probe [{}]: {} does not honor bounded byte ranges; using one connection",
|
||||
id,
|
||||
uri_host_for_log(&uri)
|
||||
);
|
||||
return Ok(1);
|
||||
match probe_bounded_range_support(&uri, payload, &credential_origin).await {
|
||||
Ok(probe) => {
|
||||
if index > 0
|
||||
&& payload_has_credential_material(payload)
|
||||
&& !probe.credentials_allowed
|
||||
{
|
||||
return Err(
|
||||
"credentialed mirrors must use the same origin as the primary URL"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
credentials_allowed &= probe.credentials_allowed;
|
||||
uris.push(probe.final_uri);
|
||||
match probe.range_support {
|
||||
BoundedRangeSupport::Unsupported if requested > 1 => {
|
||||
log::warn!(
|
||||
"aria2 range probe [{}]: {} does not honor bounded byte ranges; using one connection",
|
||||
id,
|
||||
uri_host_for_log(&uri)
|
||||
);
|
||||
connections = 1;
|
||||
}
|
||||
BoundedRangeSupport::Unknown if requested > 1 => {
|
||||
log::debug!(
|
||||
"aria2 range probe [{}]: {} range support unknown; keeping {} connections",
|
||||
id,
|
||||
uri_host_for_log(&uri),
|
||||
requested
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(BoundedRangeSupport::Supported) => {}
|
||||
Ok(BoundedRangeSupport::Unknown) => {
|
||||
log::debug!(
|
||||
"aria2 range probe [{}]: {} range support unknown; keeping {} connections",
|
||||
id,
|
||||
uri_host_for_log(&uri),
|
||||
requested
|
||||
);
|
||||
}
|
||||
Err(error) if error.starts_with("SSRF blocked:") => return Err(error),
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"aria2 range probe [{}]: {} probe failed: {}; keeping {} connections",
|
||||
id,
|
||||
return Err(format!(
|
||||
"normal transfer preflight failed for {}: {}",
|
||||
uri_host_for_log(&uri),
|
||||
error,
|
||||
requested
|
||||
);
|
||||
crate::redact_sensitive_text(&error)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(requested)
|
||||
uris.dedup();
|
||||
if uris.is_empty() {
|
||||
return Err("normal download has no usable URI".to_string());
|
||||
}
|
||||
if payload_has_credential_material(payload) && !credentials_allowed {
|
||||
log::warn!(
|
||||
"aria2 redirect policy [{}]: stripping credentials after a cross-origin redirect",
|
||||
id
|
||||
);
|
||||
}
|
||||
Ok(PreparedNormalTransfer {
|
||||
uris,
|
||||
connections,
|
||||
credentials_allowed,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_http_uri(uri: &str) -> bool {
|
||||
@@ -5090,12 +5184,12 @@ pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, Strin
|
||||
async fn probe_bounded_range_support(
|
||||
uri: &str,
|
||||
payload: &SpawnPayload,
|
||||
) -> Result<BoundedRangeSupport, String> {
|
||||
credential_origin: &reqwest::Url,
|
||||
) -> Result<HttpTransferProbe, String> {
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
|
||||
let original = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
|
||||
let mut current = original.clone();
|
||||
let mut credentials_allowed = true;
|
||||
let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
|
||||
let mut credentials_allowed = can_forward_payload_credentials(credential_origin, ¤t);
|
||||
for redirect_count in 0..=5 {
|
||||
let (host, address) = crate::resolve_and_validate_url_host(¤t).await?;
|
||||
let mut builder = reqwest::Client::builder()
|
||||
@@ -5112,7 +5206,8 @@ async fn probe_bounded_range_support(
|
||||
if proxy.eq_ignore_ascii_case("none") {
|
||||
builder = builder.no_proxy();
|
||||
} else {
|
||||
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
|
||||
builder =
|
||||
builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5121,9 +5216,7 @@ async fn probe_bounded_range_support(
|
||||
.get(current.clone())
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.header(reqwest::header::ACCEPT_ENCODING, "identity");
|
||||
let include_credentials = credentials_allowed
|
||||
&& can_forward_payload_credentials(&original, ¤t);
|
||||
let response = apply_payload_headers(request, payload, include_credentials)
|
||||
let response = apply_payload_headers(request, payload, credentials_allowed)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -5143,8 +5236,8 @@ async fn probe_bounded_range_support(
|
||||
if !matches!(next.scheme(), "http" | "https") {
|
||||
return Err("range probe redirect uses an unsupported scheme".to_string());
|
||||
}
|
||||
credentials_allowed = credentials_allowed
|
||||
&& can_forward_payload_credentials(&original, &next);
|
||||
credentials_allowed =
|
||||
credentials_allowed && can_forward_payload_credentials(credential_origin, &next);
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
@@ -5153,19 +5246,17 @@ async fn probe_bounded_range_support(
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
return Ok(classify_bounded_range_response(
|
||||
response.status(),
|
||||
content_range,
|
||||
));
|
||||
return Ok(HttpTransferProbe {
|
||||
final_uri: current.to_string(),
|
||||
range_support: classify_bounded_range_response(response.status(), content_range),
|
||||
credentials_allowed,
|
||||
});
|
||||
}
|
||||
|
||||
Err("range probe redirect loop exhausted".to_string())
|
||||
}
|
||||
|
||||
fn can_forward_payload_credentials(
|
||||
original: &reqwest::Url,
|
||||
current: &reqwest::Url,
|
||||
) -> bool {
|
||||
fn can_forward_payload_credentials(original: &reqwest::Url, current: &reqwest::Url) -> bool {
|
||||
original.host() == current.host()
|
||||
&& (original.port_or_known_default() == current.port_or_known_default()
|
||||
|| (original.scheme() == "http"
|
||||
@@ -5370,6 +5461,31 @@ fn apply_aria2_connection_options(
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_aria2_normal_reliability_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
payload: &SpawnPayload,
|
||||
uri_count: usize,
|
||||
) -> Result<(), String> {
|
||||
if payload.is_torrent {
|
||||
return Ok(());
|
||||
}
|
||||
let minimum_speed =
|
||||
normalize_minimum_normal_download_speed_kib(payload.minimum_normal_download_speed_kib)?;
|
||||
if minimum_speed > 0 {
|
||||
options.insert(
|
||||
"lowest-speed-limit".to_string(),
|
||||
serde_json::json!(format!("{minimum_speed}K")),
|
||||
);
|
||||
}
|
||||
if payload.adaptive_mirror_selection && uri_count > 1 {
|
||||
options.insert(
|
||||
"uri-selector".to_string(),
|
||||
serde_json::json!("adaptive"),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_aria2_resolver_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
mode: Aria2ResolverMode,
|
||||
@@ -6378,18 +6494,22 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
if !payload.is_torrent {
|
||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||
}
|
||||
let transfer_uris = if payload.is_torrent {
|
||||
Vec::new()
|
||||
let (transfer_uris, transfer_connections, credentials_allowed) = if payload.is_torrent {
|
||||
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, true)
|
||||
} else {
|
||||
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
|
||||
let requested = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
||||
validate_aria2_transfer_network_policy(&requested).await?;
|
||||
let prepared = prepare_normal_transfer(id, payload).await?;
|
||||
(
|
||||
prepared.uris,
|
||||
prepared.connections,
|
||||
prepared.credentials_allowed,
|
||||
)
|
||||
};
|
||||
if !payload.is_torrent {
|
||||
validate_aria2_transfer_network_policy(&transfer_uris).await?;
|
||||
}
|
||||
if should_apply_aria2_connection_options(payload) {
|
||||
let conn = effective_aria2_connections(id, payload).await?;
|
||||
apply_aria2_connection_options(&mut options, conn);
|
||||
apply_aria2_connection_options(&mut options, transfer_connections);
|
||||
}
|
||||
apply_aria2_normal_reliability_options(&mut options, payload, transfer_uris.len())?;
|
||||
apply_aria2_follow_options(&mut options, payload);
|
||||
apply_aria2_torrent_options(&mut options, payload)?;
|
||||
let mt = aria2_attempt_limit(payload.max_tries);
|
||||
@@ -6408,20 +6528,24 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
||||
}
|
||||
if !payload.is_torrent {
|
||||
apply_protocol_auth_options(&mut options, payload, &transfer_uris);
|
||||
if credentials_allowed {
|
||||
apply_protocol_auth_options(&mut options, payload, &transfer_uris);
|
||||
}
|
||||
apply_checksum_options(&mut options, payload.checksum.as_deref());
|
||||
}
|
||||
if let Some(ua) = &payload.user_agent {
|
||||
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
||||
}
|
||||
let mut header_list = Vec::new();
|
||||
if let Some(cook) = &payload.cookies {
|
||||
header_list.push(format!("Cookie: {}", cook));
|
||||
}
|
||||
if let Some(hdrs) = &payload.headers {
|
||||
for line in hdrs.lines() {
|
||||
if !line.trim().is_empty() {
|
||||
header_list.push(line.trim().to_string());
|
||||
if payload.is_torrent || credentials_allowed {
|
||||
if let Some(cook) = &payload.cookies {
|
||||
header_list.push(format!("Cookie: {}", cook));
|
||||
}
|
||||
if let Some(hdrs) = &payload.headers {
|
||||
for line in hdrs.lines() {
|
||||
if !line.trim().is_empty() {
|
||||
header_list.push(line.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6941,6 +7065,15 @@ pub struct EnqueueItem {
|
||||
pub mirrors: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub max_tries: Option<i32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub minimum_normal_download_speed_kib: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub retry_not_found_errors: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub adaptive_mirror_selection: Option<bool>,
|
||||
pub proxy: Option<String>,
|
||||
pub format_selector: Option<String>,
|
||||
pub cookie_source: Option<String>,
|
||||
@@ -7055,6 +7188,11 @@ impl EnqueueItem {
|
||||
mirrors: self.mirrors,
|
||||
user_agent: self.user_agent,
|
||||
max_tries: self.max_tries,
|
||||
minimum_normal_download_speed_kib: self
|
||||
.minimum_normal_download_speed_kib
|
||||
.unwrap_or_default(),
|
||||
retry_not_found_errors: self.retry_not_found_errors.unwrap_or(false),
|
||||
adaptive_mirror_selection: self.adaptive_mirror_selection.unwrap_or(true),
|
||||
proxy: self.proxy,
|
||||
aria2_resolver_mode: Aria2ResolverMode::Automatic,
|
||||
format_selector: self.format_selector,
|
||||
@@ -7168,6 +7306,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_reliability_options_are_bounded_and_do_not_apply_to_torrents() {
|
||||
let mut options = serde_json::Map::new();
|
||||
let payload = SpawnPayload {
|
||||
minimum_normal_download_speed_kib: 64,
|
||||
adaptive_mirror_selection: true,
|
||||
..SpawnPayload::default()
|
||||
};
|
||||
apply_aria2_normal_reliability_options(&mut options, &payload, 2).unwrap();
|
||||
assert_eq!(
|
||||
options.get("lowest-speed-limit"),
|
||||
Some(&serde_json::json!("64K"))
|
||||
);
|
||||
assert_eq!(
|
||||
options.get("uri-selector"),
|
||||
Some(&serde_json::json!("adaptive"))
|
||||
);
|
||||
|
||||
options.clear();
|
||||
apply_aria2_normal_reliability_options(&mut options, &payload, 1).unwrap();
|
||||
assert!(!options.contains_key("uri-selector"));
|
||||
|
||||
options.clear();
|
||||
apply_aria2_normal_reliability_options(
|
||||
&mut options,
|
||||
&SpawnPayload {
|
||||
is_torrent: true,
|
||||
minimum_normal_download_speed_kib: 64,
|
||||
adaptive_mirror_selection: true,
|
||||
..SpawnPayload::default()
|
||||
},
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(options.is_empty());
|
||||
assert!(normalize_minimum_normal_download_speed_kib(
|
||||
MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB + 1
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_system_resolver_mode_is_per_transfer_and_preserves_automatic_default() {
|
||||
let mut automatic = serde_json::Map::new();
|
||||
@@ -8404,6 +8583,26 @@ mod tests {
|
||||
assert_eq!(payload.torrent_exclude_trackers.as_deref(), Some("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_item_carries_normal_reliability_policy_into_the_spawn_payload() {
|
||||
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
|
||||
"id": "normal-reliability",
|
||||
"queue_id": "main",
|
||||
"url": "https://example.test/file.bin",
|
||||
"destination": "/tmp/downloads",
|
||||
"filename": "file.bin",
|
||||
"minimum_normal_download_speed_kib": 64,
|
||||
"retry_not_found_errors": true,
|
||||
"adaptive_mirror_selection": false
|
||||
}))
|
||||
.expect("frontend enqueue payload should deserialize");
|
||||
|
||||
let payload = item.into_task().payload;
|
||||
assert_eq!(payload.minimum_normal_download_speed_kib, 64);
|
||||
assert!(payload.retry_not_found_errors);
|
||||
assert!(!payload.adaptive_mirror_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_item_carries_torrent_stop_timeout_into_the_spawn_payload() {
|
||||
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
|
||||
@@ -8821,6 +9020,75 @@ mod tests {
|
||||
assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_not_found_and_low_speed_retries_use_the_firelink_budget() {
|
||||
let not_found = "aria2 error code 3: Resource not found";
|
||||
let low_speed = "aria2 error code 5: Download speed is too slow";
|
||||
|
||||
assert_eq!(
|
||||
aria2_retry_action(&SpawnPayload::default(), not_found, 0, false),
|
||||
Aria2RetryAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
aria2_retry_action(
|
||||
&SpawnPayload {
|
||||
retry_not_found_errors: true,
|
||||
max_tries: Some(1),
|
||||
..SpawnPayload::default()
|
||||
},
|
||||
not_found,
|
||||
0,
|
||||
false,
|
||||
),
|
||||
Aria2RetryAction::OrdinaryRetry
|
||||
);
|
||||
assert_eq!(
|
||||
aria2_retry_action(
|
||||
&SpawnPayload {
|
||||
retry_not_found_errors: true,
|
||||
max_tries: Some(1),
|
||||
..SpawnPayload::default()
|
||||
},
|
||||
not_found,
|
||||
1,
|
||||
false,
|
||||
),
|
||||
Aria2RetryAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
aria2_retry_action(&SpawnPayload::default(), low_speed, 0, false),
|
||||
Aria2RetryAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
aria2_retry_action(
|
||||
&SpawnPayload {
|
||||
minimum_normal_download_speed_kib: 1,
|
||||
max_tries: Some(1),
|
||||
..SpawnPayload::default()
|
||||
},
|
||||
low_speed,
|
||||
0,
|
||||
false,
|
||||
),
|
||||
Aria2RetryAction::OrdinaryRetry
|
||||
);
|
||||
assert_eq!(
|
||||
aria2_retry_action(
|
||||
&SpawnPayload {
|
||||
is_torrent: true,
|
||||
minimum_normal_download_speed_kib: 1,
|
||||
retry_not_found_errors: true,
|
||||
max_tries: Some(1),
|
||||
..SpawnPayload::default()
|
||||
},
|
||||
low_speed,
|
||||
0,
|
||||
false,
|
||||
),
|
||||
Aria2RetryAction::Terminal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_name_resolution_error_is_retryable_for_resolver_recovery() {
|
||||
let error = "aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers.";
|
||||
|
||||
@@ -581,6 +581,11 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||
settings.minimum_normal_download_speed_ki_b =
|
||||
crate::queue::normalize_minimum_normal_download_speed_kib(
|
||||
settings.minimum_normal_download_speed_ki_b,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
|
||||
&settings.torrent_overall_upload_limit,
|
||||
)
|
||||
@@ -851,6 +856,9 @@ fn default_settings() -> PersistedSettings {
|
||||
last_custom_speed_limit_unit: "MB/s".to_string(),
|
||||
per_server_connections: 16,
|
||||
max_automatic_retries: 3,
|
||||
minimum_normal_download_speed_ki_b: 0,
|
||||
retry_not_found_errors: false,
|
||||
adaptive_mirror_selection: true,
|
||||
show_notifications: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
@@ -1167,7 +1175,8 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 99,
|
||||
"perServerConnections": -4,
|
||||
"maxAutomaticRetries": 99
|
||||
"maxAutomaticRetries": 99,
|
||||
"minimumNormalDownloadSpeedKiB": 2000000
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
@@ -1177,6 +1186,17 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||
assert_eq!(settings.per_server_connections, 1);
|
||||
assert_eq!(settings.max_automatic_retries, 10);
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_reliability_defaults_are_migration_safe() {
|
||||
let stored = json!({ "state": {}, "version": 5 });
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
assert!(!settings.retry_not_found_errors);
|
||||
assert!(settings.adaptive_mirror_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,8 @@ const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
const ARIA2_DATA_DIR: &str = "aria2";
|
||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||
const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt";
|
||||
const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
@@ -116,6 +118,12 @@ impl StorageLayout {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn aria2_server_stat_path(&self) -> PathBuf {
|
||||
self.data_dir
|
||||
.join(ARIA2_DATA_DIR)
|
||||
.join(ARIA2_SERVER_STAT_FILE)
|
||||
}
|
||||
|
||||
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||
/// the table contents; Firelink owns this exact location and must never
|
||||
/// fall back to a user-global default when it cannot establish it.
|
||||
@@ -160,6 +168,86 @@ impl StorageLayout {
|
||||
|
||||
Ok(self.aria2_dht_paths())
|
||||
}
|
||||
|
||||
/// Prepare the exact cache file used by Aria2's adaptive URI selector.
|
||||
/// The cache is non-authoritative: malformed or oversized contents are
|
||||
/// reset to empty, while symlinks and non-files disable the cache instead
|
||||
/// of allowing Aria2 to write outside Firelink's storage boundary.
|
||||
pub fn prepare_aria2_server_stat_path(&self) -> Result<PathBuf, String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err("Aria2 server-stat directory contains a symlink".to_string());
|
||||
}
|
||||
std::fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?;
|
||||
|
||||
let path = self.aria2_server_stat_path();
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err("Aria2 server-stat cache is a symlink".to_string());
|
||||
}
|
||||
Ok(metadata) if !metadata.is_file() => {
|
||||
return Err("Aria2 server-stat cache is not a regular file".to_string());
|
||||
}
|
||||
Ok(metadata) => {
|
||||
let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.is_some_and(|contents| aria2_server_stat_is_valid(&contents));
|
||||
if !valid {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to reset Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to create Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 server-stat cache: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn aria2_server_stat_is_valid(contents: &str) -> bool {
|
||||
contents.lines().all(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if line.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
let fields = line
|
||||
.split(',')
|
||||
.filter_map(|field| field.trim().split_once('='))
|
||||
.map(|(name, value)| (name.trim(), value.trim()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
["host", "protocol", "dl_speed", "last_updated", "status"]
|
||||
.iter()
|
||||
.all(|name| fields.get(name).is_some_and(|value| !value.is_empty()))
|
||||
})
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
@@ -278,6 +366,53 @@ mod tests {
|
||||
assert!(error.contains("not a directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
let path = layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(path, layout.aria2_server_stat_path());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
fs::write(&path, "not an aria2 server profile\n").unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
let valid =
|
||||
"host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n";
|
||||
fs::write(&path, valid).unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_rejects_symlink_output() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
symlink(
|
||||
target.path().join("outside"),
|
||||
layout.aria2_server_stat_path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(layout.prepare_aria2_server_stat_path().is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
|
||||
|
||||
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -963,6 +963,43 @@ runEngineChecks(false);
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.minimumNormalDownloadSpeed)}</span>
|
||||
<small>{t($ => $.settings.downloads.minimumNormalDownloadSpeedDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="0" max="1048576"
|
||||
value={settings.minimumNormalDownloadSpeedKiB}
|
||||
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
|
||||
/>
|
||||
</div>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.retryNotFoundErrors)}</span>
|
||||
<small>{t($ => $.settings.downloads.retryNotFoundErrorsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.retryNotFoundErrors}
|
||||
onChange={(event) => settings.setRetryNotFoundErrors(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.adaptiveMirrorSelection)}</span>
|
||||
<small>{t($ => $.settings.downloads.adaptiveMirrorSelectionDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.adaptiveMirrorSelection}
|
||||
onChange={(event) => settings.setAdaptiveMirrorSelection(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mac-settings-group">
|
||||
|
||||
@@ -843,6 +843,12 @@ const common = {
|
||||
parallelDownloadsDescription: 'Max simultaneous active files',
|
||||
automaticRetries: 'Automatic retries:',
|
||||
automaticRetriesDescription: 'If a connection fails',
|
||||
minimumNormalDownloadSpeed: 'Minimum normal-download speed (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'Retry HTTP, FTP, and SFTP transfers that remain below this speed. Use 0 to disable.',
|
||||
retryNotFoundErrors: 'Retry temporary not-found errors',
|
||||
retryNotFoundErrorsDescription: 'Treat HTTP/FTP resource-not-found responses as retryable within the automatic retry limit. Off by default.',
|
||||
adaptiveMirrorSelection: 'Adaptive mirror selection',
|
||||
adaptiveMirrorSelectionDescription: 'Use recent transfer performance to choose among multiple mirrors. Server statistics remain private on this device.',
|
||||
systemNotification: 'Show system notification when download completes',
|
||||
systemNotificationDescription: 'Uses your operating system notification settings',
|
||||
completionChime: 'Play in-app completion chime',
|
||||
|
||||
@@ -843,6 +843,12 @@ const fa = {
|
||||
parallelDownloadsDescription: 'حداکثر فایلهای فعال همزمان',
|
||||
automaticRetries: 'تلاشهای مجدد خودکار:',
|
||||
automaticRetriesDescription: 'اگر اتصالی ناموفق باشد',
|
||||
minimumNormalDownloadSpeed: 'حداقل سرعت دانلود عادی (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'دانلودهای HTTP، FTP و SFTP که سرعتشان پایینتر از این مقدار میماند دوباره تلاش میشوند. برای غیرفعالکردن ۰ را وارد کنید.',
|
||||
retryNotFoundErrors: 'تلاش دوباره برای خطای موقت «پیدا نشد»',
|
||||
retryNotFoundErrorsDescription: 'پاسخهای «منبع پیدا نشد» در HTTP/FTP را تا سقف تلاشهای خودکار دوباره امتحان میکند. بهطور پیشفرض خاموش است.',
|
||||
adaptiveMirrorSelection: 'انتخاب هوشمند میرور',
|
||||
adaptiveMirrorSelectionDescription: 'برای انتخاب بین چند میرور از عملکرد دانلودهای اخیر استفاده میکند. آمار سرورها فقط روی همین دستگاه نگهداری میشود.',
|
||||
systemNotification: 'نمایش اعلان سیستم هنگام تکمیل دانلود',
|
||||
systemNotificationDescription: 'از تنظیمات اعلان سیستمعامل شما استفاده میکند',
|
||||
completionChime: 'پخش صدای تکمیل درون برنامهای',
|
||||
|
||||
@@ -843,6 +843,12 @@ const he = {
|
||||
parallelDownloadsDescription: 'מקסימום קבצים פעילים בו-זמנית',
|
||||
automaticRetries: 'ניסיונות חוזרים אוטומטיים:',
|
||||
automaticRetriesDescription: 'אם חיבור נכשל',
|
||||
minimumNormalDownloadSpeed: 'מהירות מזערית להורדה רגילה (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'ניסיון חוזר להורדות HTTP, FTP ו-SFTP שנשארות מתחת למהירות זו. 0 משבית את האפשרות.',
|
||||
retryNotFoundErrors: 'ניסיון חוזר לשגיאות זמניות של משאב שלא נמצא',
|
||||
retryNotFoundErrorsDescription: 'מתייחס לתשובות HTTP/FTP של משאב שלא נמצא כניתנות לניסיון חוזר, עד למגבלת הניסיונות האוטומטיים. כבוי כברירת מחדל.',
|
||||
adaptiveMirrorSelection: 'בחירה מסתגלת של שרת מראה',
|
||||
adaptiveMirrorSelectionDescription: 'משתמש בביצועי העברות אחרונות כדי לבחור בין כמה שרתי מראה. סטטיסטיקת השרתים נשמרת באופן פרטי במכשיר זה.',
|
||||
systemNotification: 'הצג התראת מערכת כאשר ההורדה מסתיימת',
|
||||
systemNotificationDescription: 'משתמש בהגדרות ההתראות של מערכת ההפעלה שלך',
|
||||
completionChime: 'השמע צליל סיום בתוך האפליקציה',
|
||||
|
||||
@@ -843,6 +843,12 @@ const ru = {
|
||||
parallelDownloadsDescription: 'Максимальное число одновременно активных файлов',
|
||||
automaticRetries: 'Автоматические повторы:',
|
||||
automaticRetriesDescription: 'При сбое соединения',
|
||||
minimumNormalDownloadSpeed: 'Минимальная скорость обычной загрузки (КиБ/с):',
|
||||
minimumNormalDownloadSpeedDescription: 'Повторять HTTP-, FTP- и SFTP-загрузки, если скорость остаётся ниже указанной. Значение 0 отключает эту функцию.',
|
||||
retryNotFoundErrors: 'Повторять временные ошибки «не найдено»',
|
||||
retryNotFoundErrorsDescription: 'Считать ответы HTTP/FTP «ресурс не найден» повторяемыми в пределах лимита автоматических попыток. По умолчанию выключено.',
|
||||
adaptiveMirrorSelection: 'Адаптивный выбор зеркала',
|
||||
adaptiveMirrorSelectionDescription: 'Выбирать из нескольких зеркал по недавней скорости. Статистика серверов хранится конфиденциально на этом устройстве.',
|
||||
systemNotification: 'Показывать системное уведомление по завершении загрузки',
|
||||
systemNotificationDescription: 'Использует настройки уведомлений вашей операционной системы',
|
||||
completionChime: 'Воспроизводить звуковой сигнал завершения в приложении',
|
||||
|
||||
@@ -843,6 +843,12 @@ const uk = {
|
||||
parallelDownloadsDescription: 'Макс. одночасних активних файлів',
|
||||
automaticRetries: 'Автоматичні повторні спроби:',
|
||||
automaticRetriesDescription: 'Якщо з\'єднання перерветься',
|
||||
minimumNormalDownloadSpeed: 'Мінімальна швидкість звичайного завантаження (КіБ/с):',
|
||||
minimumNormalDownloadSpeedDescription: 'Повторювати HTTP-, FTP- і SFTP-завантаження, якщо швидкість залишається нижчою за вказану. Значення 0 вимикає цю функцію.',
|
||||
retryNotFoundErrors: 'Повторювати тимчасові помилки «не знайдено»',
|
||||
retryNotFoundErrorsDescription: 'Вважати відповіді HTTP/FTP «ресурс не знайдено» придатними до повтору в межах ліміту автоматичних спроб. Типово вимкнено.',
|
||||
adaptiveMirrorSelection: 'Адаптивний вибір дзеркала',
|
||||
adaptiveMirrorSelectionDescription: 'Вибирати з кількох дзеркал за нещодавньою швидкістю. Статистика серверів приватно зберігається на цьому пристрої.',
|
||||
systemNotification: 'Показувати системне сповіщення по завершенні завантаження',
|
||||
systemNotificationDescription: 'Використовує налаштування сповіщень вашої операційної системи',
|
||||
completionChime: 'Відтворювати звуковий сигнал по завершенні в програмі',
|
||||
|
||||
@@ -843,6 +843,12 @@ const zhCN = {
|
||||
parallelDownloadsDescription: '最大同时活动文件数',
|
||||
automaticRetries: '自动重试:',
|
||||
automaticRetriesDescription: '如果连接失败',
|
||||
minimumNormalDownloadSpeed: '普通下载最低速度(KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'HTTP、FTP 或 SFTP 下载持续低于此速度时重试。设为 0 可关闭。',
|
||||
retryNotFoundErrors: '重试临时“未找到”错误',
|
||||
retryNotFoundErrorsDescription: '在自动重试次数限制内,将 HTTP/FTP 的“资源未找到”响应视为可重试错误。默认关闭。',
|
||||
adaptiveMirrorSelection: '自适应镜像选择',
|
||||
adaptiveMirrorSelectionDescription: '根据近期传输性能在多个镜像之间选择。服务器统计信息仅私密保存在此设备上。',
|
||||
systemNotification: '下载完成时显示系统通知',
|
||||
systemNotificationDescription: '使用操作系统的通知设置',
|
||||
completionChime: '播放应用内完成提示音',
|
||||
|
||||
@@ -36,6 +36,9 @@ vi.mock('./useSettingsStore', () => ({
|
||||
perServerConnections: 16,
|
||||
customUserAgent: '',
|
||||
maxAutomaticRetries: 3,
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
mediaCookieSource: 'none',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
@@ -68,6 +71,9 @@ describe('useDownloadStore', () => {
|
||||
perServerConnections: 16,
|
||||
customUserAgent: '',
|
||||
maxAutomaticRetries: 3,
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
mediaCookieSource: 'none',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfoldersEnabled: true,
|
||||
@@ -1147,6 +1153,38 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches normal reliability and adaptive mirror settings to the native queue', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
minimumNormalDownloadSpeedKiB: 64,
|
||||
retryNotFoundErrors: true,
|
||||
adaptiveMirrorSelection: false,
|
||||
} as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'reliable', url: 'https://example.test/file', fileName: 'file.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') return { id: 'reliable', filename: 'file.bin' } as never;
|
||||
if (command === 'get_pending_order') return ['reliable'] as never;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().startQueue('MAIN');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
minimum_normal_download_speed_kib: 64,
|
||||
retry_not_found_errors: true,
|
||||
adaptive_mirror_selection: false,
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
|
||||
@@ -370,6 +370,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
mirrors: item.mirrors || null,
|
||||
user_agent: settings.customUserAgent.trim() || null,
|
||||
max_tries: settings.maxAutomaticRetries,
|
||||
minimum_normal_download_speed_kib: settings.minimumNormalDownloadSpeedKiB,
|
||||
retry_not_found_errors: settings.retryNotFoundErrors,
|
||||
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
||||
proxy,
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
@@ -2441,6 +2444,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
mirrors: item.mirrors || null,
|
||||
user_agent: settings.customUserAgent.trim() || null,
|
||||
max_tries: settings.maxAutomaticRetries,
|
||||
minimum_normal_download_speed_kib: settings.minimumNormalDownloadSpeedKiB,
|
||||
retry_not_found_errors: settings.retryNotFoundErrors,
|
||||
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
||||
proxy,
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
|
||||
@@ -25,6 +25,43 @@ describe('last used download directory preference', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normal download reliability preferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses migration-safe defaults and persists bounded changes', async () => {
|
||||
expect(useSettingsStore.getState()).toMatchObject({
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(64);
|
||||
useSettingsStore.getState().setRetryNotFoundErrors(true);
|
||||
useSettingsStore.getState().setAdaptiveMirrorSelection(false);
|
||||
await vi.waitFor(() => {
|
||||
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'db_save_settings')
|
||||
.slice(-1)[0];
|
||||
expect(save).toBeDefined();
|
||||
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
|
||||
minimumNormalDownloadSpeedKiB: 64,
|
||||
retryNotFoundErrors: true,
|
||||
adaptiveMirrorSelection: false,
|
||||
});
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(2_000_000);
|
||||
expect(useSettingsStore.getState().minimumNormalDownloadSpeedKiB).toBe(1_048_576);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent peer discovery preferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -234,6 +234,9 @@ export interface SettingsState {
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
maxAutomaticRetries: number;
|
||||
minimumNormalDownloadSpeedKiB: number;
|
||||
retryNotFoundErrors: boolean;
|
||||
adaptiveMirrorSelection: boolean;
|
||||
showNotifications: boolean;
|
||||
playCompletionSound: boolean;
|
||||
autoAddClipboardLinks: boolean;
|
||||
@@ -307,6 +310,9 @@ export interface SettingsState {
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setMinimumNormalDownloadSpeedKiB: (speed: number) => void;
|
||||
setRetryNotFoundErrors: (enabled: boolean) => void;
|
||||
setAdaptiveMirrorSelection: (enabled: boolean) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAutoAddClipboardLinks: (enabled: boolean) => void;
|
||||
@@ -403,6 +409,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
@@ -526,6 +535,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({
|
||||
maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3)
|
||||
}),
|
||||
setMinimumNormalDownloadSpeedKiB: (minimumNormalDownloadSpeedKiB) => set({
|
||||
minimumNormalDownloadSpeedKiB: clampSettingInteger(
|
||||
minimumNormalDownloadSpeedKiB,
|
||||
0,
|
||||
1_048_576,
|
||||
0
|
||||
)
|
||||
}),
|
||||
setRetryNotFoundErrors: (retryNotFoundErrors) => set({ retryNotFoundErrors }),
|
||||
setAdaptiveMirrorSelection: (adaptiveMirrorSelection) => set({ adaptiveMirrorSelection }),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||
@@ -762,6 +781,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
minimumNormalDownloadSpeedKiB: state.minimumNormalDownloadSpeedKiB,
|
||||
retryNotFoundErrors: state.retryNotFoundErrors,
|
||||
adaptiveMirrorSelection: state.adaptiveMirrorSelection,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||
@@ -961,6 +983,20 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
10,
|
||||
currentState.maxAutomaticRetries
|
||||
),
|
||||
minimumNormalDownloadSpeedKiB: clampSettingInteger(
|
||||
persisted.minimumNormalDownloadSpeedKiB,
|
||||
0,
|
||||
1_048_576,
|
||||
currentState.minimumNormalDownloadSpeedKiB
|
||||
),
|
||||
retryNotFoundErrors: persistedBoolean(
|
||||
persisted.retryNotFoundErrors,
|
||||
currentState.retryNotFoundErrors
|
||||
),
|
||||
adaptiveMirrorSelection: persistedBoolean(
|
||||
persisted.adaptiveMirrorSelection,
|
||||
currentState.adaptiveMirrorSelection
|
||||
),
|
||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||
? persisted.speedLimitPresetValues
|
||||
: currentState.speedLimitPresetValues,
|
||||
|
||||
Reference in New Issue
Block a user