diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9dd784..54c336e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' || '' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ce23fa..3ebc090 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/package.json b/package.json index b95f185..937e319 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/audit-aria2-options.js b/scripts/audit-aria2-options.js new file mode 100644 index 0000000..25d0080 --- /dev/null +++ b/scripts/audit-aria2-options.js @@ -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)); diff --git a/scripts/smoke-aria2-transfers.js b/scripts/smoke-aria2-transfers.js new file mode 100644 index 0000000..ba2fd91 --- /dev/null +++ b/scripts/smoke-aria2-transfers.js @@ -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'); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 339e745..c6d5f12 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -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)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f376150..ed48337 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3073,7 +3073,7 @@ fn push_unique_path(paths: &mut Vec, 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>, last_stderr: Mutex, config_path: Mutex>, + 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::(); + if let Some(state) = app_handle.try_state::() { + 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![ + 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::(); let _ = state.extension_server_shutdown.send(true); + let guard = app_handle.state::(); + 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::().allow_exit(); + app.exit(code.unwrap_or(0)); + }); + } + } } _ => {} }); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 2cf85b6..be25e52 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -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 { + 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, String> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { @@ -761,6 +771,9 @@ pub struct SpawnPayload { pub mirrors: Option, pub user_agent: Option, pub max_tries: Option, + pub minimum_normal_download_speed_kib: u32, + pub retry_not_found_errors: bool, + pub adaptive_mirror_selection: bool, pub proxy: Option, /// 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 { - 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, + 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 { + 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, Strin async fn probe_bounded_range_support( uri: &str, payload: &SpawnPayload, -) -> Result { + credential_origin: &reqwest::Url, +) -> Result { 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, + 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, 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, pub user_agent: Option, pub max_tries: Option, + #[serde(default)] + #[ts(optional)] + pub minimum_normal_download_speed_kib: Option, + #[serde(default)] + #[ts(optional)] + pub retry_not_found_errors: Option, + #[serde(default)] + #[ts(optional)] + pub adaptive_mirror_selection: Option, pub proxy: Option, pub format_selector: Option, pub cookie_source: Option, @@ -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."; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 98845b6..595c3fb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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] diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index a8f7ba8..4c82bde 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -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 { + 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::>(); + ["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 { @@ -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() { diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 1b04d86..9841a0a 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -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, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, 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, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, 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, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index f8b1dd9..838cd48 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, 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, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9eb15e4..cb42a0c 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -963,6 +963,43 @@ runEngineChecks(false); className="app-control w-24 text-center" /> +
+
+ {t($ => $.settings.downloads.minimumNormalDownloadSpeed)} + {t($ => $.settings.downloads.minimumNormalDownloadSpeedDescription)} +
+ settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))} + className="app-control w-24 text-center" + aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)} + /> +
+ +
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 0c63bf5..c15d959 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 5fd93eb..689e861 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -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: 'پخش صدای تکمیل درون برنامه‌ای', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 7ec1c4b..720e7a5 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -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: 'השמע צליל סיום בתוך האפליקציה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 1abff6f..8d773ad 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -843,6 +843,12 @@ const ru = { parallelDownloadsDescription: 'Максимальное число одновременно активных файлов', automaticRetries: 'Автоматические повторы:', automaticRetriesDescription: 'При сбое соединения', + minimumNormalDownloadSpeed: 'Минимальная скорость обычной загрузки (КиБ/с):', + minimumNormalDownloadSpeedDescription: 'Повторять HTTP-, FTP- и SFTP-загрузки, если скорость остаётся ниже указанной. Значение 0 отключает эту функцию.', + retryNotFoundErrors: 'Повторять временные ошибки «не найдено»', + retryNotFoundErrorsDescription: 'Считать ответы HTTP/FTP «ресурс не найден» повторяемыми в пределах лимита автоматических попыток. По умолчанию выключено.', + adaptiveMirrorSelection: 'Адаптивный выбор зеркала', + adaptiveMirrorSelectionDescription: 'Выбирать из нескольких зеркал по недавней скорости. Статистика серверов хранится конфиденциально на этом устройстве.', systemNotification: 'Показывать системное уведомление по завершении загрузки', systemNotificationDescription: 'Использует настройки уведомлений вашей операционной системы', completionChime: 'Воспроизводить звуковой сигнал завершения в приложении', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index ad6c1fe..2f7f122 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -843,6 +843,12 @@ const uk = { parallelDownloadsDescription: 'Макс. одночасних активних файлів', automaticRetries: 'Автоматичні повторні спроби:', automaticRetriesDescription: 'Якщо з\'єднання перерветься', + minimumNormalDownloadSpeed: 'Мінімальна швидкість звичайного завантаження (КіБ/с):', + minimumNormalDownloadSpeedDescription: 'Повторювати HTTP-, FTP- і SFTP-завантаження, якщо швидкість залишається нижчою за вказану. Значення 0 вимикає цю функцію.', + retryNotFoundErrors: 'Повторювати тимчасові помилки «не знайдено»', + retryNotFoundErrorsDescription: 'Вважати відповіді HTTP/FTP «ресурс не знайдено» придатними до повтору в межах ліміту автоматичних спроб. Типово вимкнено.', + adaptiveMirrorSelection: 'Адаптивний вибір дзеркала', + adaptiveMirrorSelectionDescription: 'Вибирати з кількох дзеркал за нещодавньою швидкістю. Статистика серверів приватно зберігається на цьому пристрої.', systemNotification: 'Показувати системне сповіщення по завершенні завантаження', systemNotificationDescription: 'Використовує налаштування сповіщень вашої операційної системи', completionChime: 'Відтворювати звуковий сигнал по завершенні в програмі', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index ed68123..3378713 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -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: '播放应用内完成提示音', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 332f8e1..8812665 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -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); + 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: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 5779ebf..6a398ea 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -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((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, diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 51521fb..1795620 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -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(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 8b97149..27426c4 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -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()( // 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()( 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()( 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()( 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,