mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-01 15:13:19 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b51ca86e | |||
| 1bf8b675e4 | |||
| ebd11b26f6 | |||
| ae5199132d | |||
| 6336ff6f5d | |||
| a8bd80d557 | |||
| 186e189277 | |||
| 2c1bd9cdf1 | |||
| 292447d417 |
@@ -33,6 +33,8 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"check:i18n": "vitest run src/i18n/resources.test.ts",
|
"check:i18n": "vitest run src/i18n/resources.test.ts",
|
||||||
"check:updates": "node scripts/check-updates.js",
|
"check:updates": "node scripts/check-updates.js",
|
||||||
|
"smoke:torrent": "node scripts/smoke-torrent.js",
|
||||||
|
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
|
||||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|||||||
@@ -0,0 +1,756 @@
|
|||||||
|
#!/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 { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
function argumentValue(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--help')) {
|
||||||
|
console.log('Usage: node scripts/smoke-torrent.js [--binary PATH] [--failure-paths] [--keep-temp]');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepTemp = process.argv.includes('--keep-temp');
|
||||||
|
const runFailurePaths = process.argv.includes('--failure-paths');
|
||||||
|
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||||
|
const platform = {
|
||||||
|
darwin: 'apple-darwin',
|
||||||
|
linux: 'unknown-linux-gnu',
|
||||||
|
win32: 'pc-windows-msvc',
|
||||||
|
}[os.platform()];
|
||||||
|
if (!arch || !platform) {
|
||||||
|
throw new Error(`Unsupported host: ${os.arch()} / ${os.platform()}`);
|
||||||
|
}
|
||||||
|
const targetTriple = `${arch}-${platform}`;
|
||||||
|
const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`;
|
||||||
|
const binaryPath = path.resolve(
|
||||||
|
argumentValue('--binary') || path.join(repoRoot, 'src-tauri', 'binaries', executableName),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeAbortController = new AbortController();
|
||||||
|
const sleep = (milliseconds, signal = runtimeAbortController.signal) => new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new Error('Torrent runtime smoke was interrupted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timer;
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
reject(new Error('Torrent runtime smoke was interrupted'));
|
||||||
|
};
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve();
|
||||||
|
}, milliseconds);
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
const activeDaemons = new Set();
|
||||||
|
let runtimeTempRoot;
|
||||||
|
let runtimeTracker;
|
||||||
|
let cleanupPromise;
|
||||||
|
let signalTerminationRequested = false;
|
||||||
|
|
||||||
|
class DaemonExitedError extends Error {}
|
||||||
|
|
||||||
|
function childExited(child) {
|
||||||
|
return child.exitCode !== null || child.signalCode !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailablePort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
const port = address && typeof address !== 'string' ? address.port : null;
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
if (!port) throw new Error('Could not reserve a local port');
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bencode(value) {
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return bencode(Buffer.from(value));
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return Buffer.from(`i${value}e`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]);
|
||||||
|
}
|
||||||
|
if (value instanceof Map || (value && typeof value === 'object')) {
|
||||||
|
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
|
||||||
|
entries.sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from('d'),
|
||||||
|
...entries.flatMap(([key, child]) => [bencode(String(key)), bencode(child)]),
|
||||||
|
Buffer.from('e'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported bencode value: ${typeof value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBencode(bytes) {
|
||||||
|
let position = 0;
|
||||||
|
const parse = () => {
|
||||||
|
const marker = bytes[position];
|
||||||
|
if (marker === 0x69) {
|
||||||
|
position += 1;
|
||||||
|
const end = bytes.indexOf(0x65, position);
|
||||||
|
if (end < 0) throw new Error('Malformed integer in saved torrent');
|
||||||
|
const value = Number(bytes.subarray(position, end).toString('ascii'));
|
||||||
|
position = end + 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (marker === 0x6c) {
|
||||||
|
position += 1;
|
||||||
|
const values = [];
|
||||||
|
while (bytes[position] !== 0x65) values.push(parse());
|
||||||
|
position += 1;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
if (marker === 0x64) {
|
||||||
|
position += 1;
|
||||||
|
const values = new Map();
|
||||||
|
while (bytes[position] !== 0x65) {
|
||||||
|
const key = parse();
|
||||||
|
if (!Buffer.isBuffer(key)) throw new Error('Malformed dictionary key in saved torrent');
|
||||||
|
values.set(key.toString('utf8'), parse());
|
||||||
|
}
|
||||||
|
position += 1;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
if (marker >= 0x30 && marker <= 0x39) {
|
||||||
|
const separator = bytes.indexOf(0x3a, position);
|
||||||
|
if (separator < 0) throw new Error('Malformed byte string in saved torrent');
|
||||||
|
const length = Number(bytes.subarray(position, separator).toString('ascii'));
|
||||||
|
position = separator + 1;
|
||||||
|
const value = bytes.subarray(position, position + length);
|
||||||
|
if (value.length !== length) throw new Error('Truncated byte string in saved torrent');
|
||||||
|
position += length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected bencode marker ${String.fromCharCode(marker || 0)}`);
|
||||||
|
};
|
||||||
|
const value = parse();
|
||||||
|
if (position !== bytes.length) throw new Error('Saved torrent contains trailing data');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha1(value) {
|
||||||
|
return crypto.createHash('sha1').update(value).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeTorrent(trackerUrl) {
|
||||||
|
const name = 'firelink-torrent-runtime';
|
||||||
|
const pieceLength = 16 * 1024;
|
||||||
|
const files = [
|
||||||
|
{
|
||||||
|
path: 'selected.bin',
|
||||||
|
data: Buffer.alloc(256 * 1024, 0x53),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'skipped.bin',
|
||||||
|
data: Buffer.from('this file must not be selected\n'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const piecesSource = Buffer.concat(files.map(file => file.data));
|
||||||
|
const pieces = [];
|
||||||
|
for (let offset = 0; offset < piecesSource.length; offset += pieceLength) {
|
||||||
|
pieces.push(sha1(piecesSource.subarray(offset, offset + pieceLength)));
|
||||||
|
}
|
||||||
|
const info = new Map([
|
||||||
|
['files', files.map(file => new Map([
|
||||||
|
['length', file.data.length],
|
||||||
|
['path', [file.path]],
|
||||||
|
]))],
|
||||||
|
['name', name],
|
||||||
|
['piece length', pieceLength],
|
||||||
|
['pieces', Buffer.concat(pieces)],
|
||||||
|
]);
|
||||||
|
const torrent = new Map([
|
||||||
|
['announce', trackerUrl],
|
||||||
|
['info', info],
|
||||||
|
]);
|
||||||
|
const bytes = bencode(torrent);
|
||||||
|
const infoHash = sha1(bencode(info)).toString('hex');
|
||||||
|
return { bytes, infoHash, name, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTracker(seederPort) {
|
||||||
|
let activeSeederPort = seederPort;
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
let pathname;
|
||||||
|
try {
|
||||||
|
pathname = request.url ? new URL(request.url, 'http://127.0.0.1').pathname : undefined;
|
||||||
|
} catch {
|
||||||
|
response.writeHead(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname !== '/announce') {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peer = Buffer.alloc(6);
|
||||||
|
peer[0] = 127;
|
||||||
|
peer[1] = 0;
|
||||||
|
peer[2] = 0;
|
||||||
|
peer[3] = 1;
|
||||||
|
peer.writeUInt16BE(activeSeederPort, 4);
|
||||||
|
const body = bencode(new Map([
|
||||||
|
['complete', 1],
|
||||||
|
['incomplete', 0],
|
||||||
|
['interval', 2],
|
||||||
|
['min interval', 1],
|
||||||
|
['peers', peer],
|
||||||
|
]));
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Length': body.length,
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
setSeederPort(port) {
|
||||||
|
activeSeederPort = port;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 determine local server port');
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daemonEnvironment() {
|
||||||
|
const libraries = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||||
|
return fs.existsSync(libraries)
|
||||||
|
? { ...process.env, OPENSSL_MODULES: libraries }
|
||||||
|
: process.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc(port, secret, method, params = [], { signal = runtimeAbortController.signal } = {}) {
|
||||||
|
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: `firelink-torrent-${Date.now()}`,
|
||||||
|
method,
|
||||||
|
params: [`token:${secret}`, ...params],
|
||||||
|
}),
|
||||||
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(3000)]) : AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||||
|
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||||
|
return body.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(description, check, timeoutMs = 15000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
if (runtimeAbortController.signal.aborted) throw new Error('Torrent runtime smoke was interrupted');
|
||||||
|
const result = await check();
|
||||||
|
if (result) return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (runtimeAbortController.signal.aborted || error instanceof DaemonExitedError) throw error;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${description}${lastError ? `: ${lastError.message}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [] }) {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
const selectedRpcPort = attempt === 0 ? rpcPort : await findAvailablePort();
|
||||||
|
const selectedListenPort = listenPort === undefined
|
||||||
|
? undefined
|
||||||
|
: attempt === 0 ? listenPort : await findAvailablePort();
|
||||||
|
const secret = `firelink-${name}-${crypto.randomUUID()}`;
|
||||||
|
const stderr = [];
|
||||||
|
const child = spawn(binaryPath, [
|
||||||
|
'--enable-rpc=true',
|
||||||
|
`--rpc-listen-port=${selectedRpcPort}`,
|
||||||
|
'--rpc-listen-all=false',
|
||||||
|
`--rpc-secret=${secret}`,
|
||||||
|
`--dir=${directory}`,
|
||||||
|
'--file-allocation=none',
|
||||||
|
'--enable-dht=false',
|
||||||
|
'--enable-peer-exchange=false',
|
||||||
|
'--bt-enable-lpd=false',
|
||||||
|
'--console-log-level=error',
|
||||||
|
'--quiet=true',
|
||||||
|
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
|
||||||
|
...extraArgs,
|
||||||
|
], {
|
||||||
|
env: daemonEnvironment(),
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
|
||||||
|
let exit;
|
||||||
|
child.once('exit', (code, signal) => { exit = { code, signal }; });
|
||||||
|
child.once('error', error => { exit = { error }; });
|
||||||
|
const daemon = {
|
||||||
|
child,
|
||||||
|
secret,
|
||||||
|
stderr,
|
||||||
|
rpcPort: selectedRpcPort,
|
||||||
|
listenPort: selectedListenPort,
|
||||||
|
};
|
||||||
|
activeDaemons.add(daemon);
|
||||||
|
try {
|
||||||
|
await waitFor(`${name} Aria2 RPC`, async () => {
|
||||||
|
if (exit) throw new DaemonExitedError(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`);
|
||||||
|
try {
|
||||||
|
await rpc(selectedRpcPort, secret, 'aria2.getVersion');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
return daemon;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const detail = `${error.message}\n${stderr.join('')}`;
|
||||||
|
await stopDaemon(daemon);
|
||||||
|
if (!/address already in use|failed to bind|could not bind|listen failed/i.test(detail)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error(`Could not start ${name} Aria2 daemon`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemon(daemon) {
|
||||||
|
const child = daemon?.child;
|
||||||
|
if (!child) return;
|
||||||
|
try {
|
||||||
|
if (!childExited(child)) {
|
||||||
|
try {
|
||||||
|
await rpc(daemon.rpcPort, daemon.secret, 'aria2.shutdown', [], { signal: null });
|
||||||
|
} catch {
|
||||||
|
// The process may already have exited during cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!await waitForChildExit(child, 3000)) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
if (!await waitForChildExit(child, 3000) && process.platform !== 'win32') {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await waitForChildExit(child, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeDaemons.delete(daemon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer(server) {
|
||||||
|
if (!server || !server.listening) return;
|
||||||
|
await new Promise(resolve => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupRuntime() {
|
||||||
|
if (cleanupPromise) return cleanupPromise;
|
||||||
|
cleanupPromise = (async () => {
|
||||||
|
for (const daemon of [...activeDaemons].reverse()) {
|
||||||
|
try {
|
||||||
|
await stopDaemon(daemon);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WARN] failed to stop Aria2 daemon: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runtimeTracker) {
|
||||||
|
const tracker = runtimeTracker;
|
||||||
|
runtimeTracker = undefined;
|
||||||
|
await closeServer(tracker.server);
|
||||||
|
}
|
||||||
|
if (runtimeTempRoot) {
|
||||||
|
const tempRoot = runtimeTempRoot;
|
||||||
|
runtimeTempRoot = undefined;
|
||||||
|
if (keepTemp) console.log(`[INFO] retained smoke-test directory: ${tempRoot}`);
|
||||||
|
else fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return cleanupPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tellStatus(daemon, gid) {
|
||||||
|
return rpc(daemon.rpcPort, daemon.secret, 'aria2.tellStatus', [gid, [
|
||||||
|
'status',
|
||||||
|
'errorCode',
|
||||||
|
'errorMessage',
|
||||||
|
'completedLength',
|
||||||
|
'totalLength',
|
||||||
|
'files',
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDaemonRunning(daemon) {
|
||||||
|
if (childExited(daemon.child)) {
|
||||||
|
throw new DaemonExitedError(
|
||||||
|
`Aria2 daemon exited: ${daemon.child.exitCode ?? 'null'}/${daemon.child.signalCode ?? 'null'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTerminal(daemon, gid, timeoutMs = 30000) {
|
||||||
|
let lastStatus;
|
||||||
|
try {
|
||||||
|
return await waitFor(`Aria2 transfer ${gid} to complete`, async () => {
|
||||||
|
assertDaemonRunning(daemon);
|
||||||
|
lastStatus = await tellStatus(daemon, gid);
|
||||||
|
if (lastStatus.status === 'complete') return lastStatus;
|
||||||
|
if (lastStatus.status === 'error' || lastStatus.status === 'removed') {
|
||||||
|
throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, timeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
const stderr = daemon.stderr?.join('').trim();
|
||||||
|
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStatus(daemon, gid, expected, timeoutMs = 10000) {
|
||||||
|
return waitFor(`Aria2 transfer ${gid} to become ${expected}`, async () => {
|
||||||
|
assertDaemonRunning(daemon);
|
||||||
|
const status = await tellStatus(daemon, gid);
|
||||||
|
if (status.status === expected) return status;
|
||||||
|
if (status.status === 'error' || status.status === 'removed') {
|
||||||
|
throw new Error(`transfer ended ${status.status}: ${status.errorCode || ''} ${status.errorMessage || ''}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDataComplete(daemon, gid, timeoutMs = 30000) {
|
||||||
|
let lastStatus;
|
||||||
|
try {
|
||||||
|
return await waitFor(`Aria2 transfer ${gid} data to complete`, async () => {
|
||||||
|
assertDaemonRunning(daemon);
|
||||||
|
lastStatus = await tellStatus(daemon, gid);
|
||||||
|
if (lastStatus.status === 'error' || lastStatus.status === 'removed') {
|
||||||
|
throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`);
|
||||||
|
}
|
||||||
|
return Number(lastStatus.completedLength) === Number(lastStatus.totalLength) ? lastStatus : false;
|
||||||
|
}, timeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
const stderr = daemon.stderr?.join('').trim();
|
||||||
|
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRemoved(daemon, gid) {
|
||||||
|
let lastStatus;
|
||||||
|
try {
|
||||||
|
return await waitFor(`Aria2 transfer ${gid} to be removed`, async () => {
|
||||||
|
assertDaemonRunning(daemon);
|
||||||
|
try {
|
||||||
|
lastStatus = await tellStatus(daemon, gid);
|
||||||
|
return lastStatus.status === 'removed' ? lastStatus : false;
|
||||||
|
} catch (error) {
|
||||||
|
return /not found|no such download/i.test(error.message) ? true : false;
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceRemoveIfPresent(daemon, gid) {
|
||||||
|
try {
|
||||||
|
await rpc(daemon.rpcPort, daemon.secret, 'aria2.forceRemove', [gid]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (/not found|no such download|active download not found/i.test(error.message)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForChildExit(child, timeoutMs = 5000) {
|
||||||
|
if (childExited(child)) return true;
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
let timer;
|
||||||
|
let onExit;
|
||||||
|
const finish = result => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.off('exit', onExit);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
finish(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
onExit = () => finish(true);
|
||||||
|
child.once('exit', onExit);
|
||||||
|
if (childExited(child)) finish(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFailurePathChecks({ client, tempRoot }) {
|
||||||
|
const failureDir = path.join(tempRoot, 'failure');
|
||||||
|
fs.mkdirSync(failureDir, { recursive: true });
|
||||||
|
const unavailablePort = await findAvailablePort();
|
||||||
|
const unavailableInfoHash = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
|
||||||
|
const unavailableMagnet = `magnet:?xt=urn:btih:${unavailableInfoHash}&dn=unavailable-firelink-fixture&tr=${encodeURIComponent(`http://127.0.0.1:${unavailablePort}/announce`)}`;
|
||||||
|
const failureGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[unavailableMagnet], {
|
||||||
|
dir: failureDir,
|
||||||
|
'bt-metadata-only': 'true',
|
||||||
|
'bt-save-metadata': 'true',
|
||||||
|
'max-tries': '1',
|
||||||
|
'retry-wait': '1',
|
||||||
|
'connect-timeout': '1',
|
||||||
|
'bt-tracker-connect-timeout': '1',
|
||||||
|
timeout: '2',
|
||||||
|
'auto-file-renaming': 'false',
|
||||||
|
}]);
|
||||||
|
let lastStatus;
|
||||||
|
const deadline = Date.now() + 8000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
lastStatus = await tellStatus(client, failureGid);
|
||||||
|
if (lastStatus.status === 'error' || lastStatus.status === 'complete') break;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
const failureRemoved = await forceRemoveIfPresent(client, failureGid);
|
||||||
|
if (failureRemoved) await waitForRemoved(client, failureGid);
|
||||||
|
const savedFailureMetadata = fs.readdirSync(failureDir)
|
||||||
|
.filter(file => file.toLowerCase().endsWith('.torrent'));
|
||||||
|
assert(savedFailureMetadata.length === 0, 'unavailable tracker produced metadata unexpectedly');
|
||||||
|
assert(lastStatus?.status !== 'complete', 'unavailable tracker completed a metadata probe unexpectedly');
|
||||||
|
console.log(`[OK] unavailable tracker was cleaned up without metadata (${lastStatus?.status || 'gone'})`);
|
||||||
|
|
||||||
|
const exitDir = path.join(tempRoot, 'daemon-exit');
|
||||||
|
fs.mkdirSync(exitDir, { recursive: true });
|
||||||
|
const exitRpcPort = await findAvailablePort();
|
||||||
|
const exitDaemon = await startDaemon({
|
||||||
|
name: 'exit',
|
||||||
|
rpcPort: exitRpcPort,
|
||||||
|
listenPort: await findAvailablePort(),
|
||||||
|
directory: exitDir,
|
||||||
|
extraArgs: ['--seed-time=0'],
|
||||||
|
});
|
||||||
|
const exitGid = await rpc(exitDaemon.rpcPort, exitDaemon.secret, 'aria2.addUri', [[unavailableMagnet], {
|
||||||
|
dir: exitDir,
|
||||||
|
'bt-metadata-only': 'true',
|
||||||
|
'bt-save-metadata': 'true',
|
||||||
|
'max-tries': '1',
|
||||||
|
'connect-timeout': '1',
|
||||||
|
'bt-tracker-connect-timeout': '1',
|
||||||
|
timeout: '15',
|
||||||
|
}]);
|
||||||
|
assert(exitGid, 'daemon-exit probe did not return a GID');
|
||||||
|
exitDaemon.child.kill('SIGTERM');
|
||||||
|
assert(await waitForChildExit(exitDaemon.child), 'Aria2 daemon did not exit after termination');
|
||||||
|
assert(
|
||||||
|
!fs.readdirSync(exitDir).some(file => file.toLowerCase().endsWith('.torrent')),
|
||||||
|
'daemon exit left saved metadata in the probe directory',
|
||||||
|
);
|
||||||
|
console.log('[OK] daemon termination left no saved probe metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(binaryPath)) {
|
||||||
|
throw new Error(`aria2c binary not found: ${binaryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-torrent-smoke-'));
|
||||||
|
try {
|
||||||
|
const tempRoot = runtimeTempRoot;
|
||||||
|
const seedParent = path.join(tempRoot, 'seed');
|
||||||
|
const seedRoot = path.join(seedParent, 'firelink-torrent-runtime');
|
||||||
|
const probeDir = path.join(tempRoot, 'probe');
|
||||||
|
const finalDir = path.join(tempRoot, 'final');
|
||||||
|
const cancelDir = path.join(tempRoot, 'cancel');
|
||||||
|
for (const directory of [seedRoot, probeDir, finalDir, cancelDir]) fs.mkdirSync(directory, { recursive: true });
|
||||||
|
|
||||||
|
const seederListenPort = await findAvailablePort();
|
||||||
|
const clientListenPort = await findAvailablePort();
|
||||||
|
runtimeTracker = startTracker(seederListenPort);
|
||||||
|
const trackerPort = await listen(runtimeTracker.server);
|
||||||
|
const torrent = createRuntimeTorrent(`http://127.0.0.1:${trackerPort}/announce`);
|
||||||
|
for (const file of torrent.files) fs.writeFileSync(path.join(seedRoot, file.path), file.data);
|
||||||
|
const magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(torrent.name)}&tr=${encodeURIComponent(`http://127.0.0.1:${trackerPort}/announce`)}`;
|
||||||
|
const indexOut = [
|
||||||
|
`1=${torrent.name}/selected.bin`,
|
||||||
|
`2=${torrent.name}/skipped.bin`,
|
||||||
|
];
|
||||||
|
const seederRpcPort = await findAvailablePort();
|
||||||
|
const seeder = await startDaemon({
|
||||||
|
name: 'seeder',
|
||||||
|
rpcPort: seederRpcPort,
|
||||||
|
listenPort: seederListenPort,
|
||||||
|
directory: seedParent,
|
||||||
|
extraArgs: ['--seed-time=60', '--seed-ratio=0'],
|
||||||
|
});
|
||||||
|
runtimeTracker.setSeederPort(seeder.listenPort);
|
||||||
|
const seedGid = await rpc(seeder.rpcPort, seeder.secret, 'aria2.addTorrent', [
|
||||||
|
torrent.bytes.toString('base64'),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
dir: seedParent,
|
||||||
|
'seed-time': '60',
|
||||||
|
'seed-ratio': '0',
|
||||||
|
'allow-overwrite': 'true',
|
||||||
|
'check-integrity': 'true',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await waitForDataComplete(seeder, seedGid);
|
||||||
|
console.log(`[OK] local seeder completed ${seedGid}`);
|
||||||
|
|
||||||
|
const clientRpcPort = await findAvailablePort();
|
||||||
|
const client = await startDaemon({
|
||||||
|
name: 'client',
|
||||||
|
rpcPort: clientRpcPort,
|
||||||
|
listenPort: clientListenPort,
|
||||||
|
directory: probeDir,
|
||||||
|
extraArgs: ['--seed-time=0'],
|
||||||
|
});
|
||||||
|
const probeGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], {
|
||||||
|
dir: probeDir,
|
||||||
|
'bt-metadata-only': 'true',
|
||||||
|
'bt-save-metadata': 'true',
|
||||||
|
'max-tries': '3',
|
||||||
|
'retry-wait': '1',
|
||||||
|
'connect-timeout': '5',
|
||||||
|
timeout: '15',
|
||||||
|
'auto-file-renaming': 'false',
|
||||||
|
}]);
|
||||||
|
const probeStatus = await waitForTerminal(client, probeGid, 30000);
|
||||||
|
assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete');
|
||||||
|
const savedTorrentPaths = fs.readdirSync(probeDir)
|
||||||
|
.filter(file => file.toLowerCase().endsWith('.torrent'))
|
||||||
|
.map(file => path.join(probeDir, file));
|
||||||
|
assert(savedTorrentPaths.length === 1, `expected one saved metadata file, found ${savedTorrentPaths.length}`);
|
||||||
|
const expectedMetadataPath = path.join(probeDir, `${torrent.infoHash}.torrent`);
|
||||||
|
assert(savedTorrentPaths[0] === expectedMetadataPath, `Aria2 saved metadata at an unexpected path: ${savedTorrentPaths[0]}`);
|
||||||
|
const savedTorrentBytes = fs.readFileSync(savedTorrentPaths[0]);
|
||||||
|
const savedTorrent = decodeBencode(savedTorrentBytes);
|
||||||
|
const savedInfo = savedTorrent instanceof Map ? savedTorrent.get('info') : undefined;
|
||||||
|
assert(savedInfo instanceof Map, 'saved metadata has no info dictionary');
|
||||||
|
assert(sha1(bencode(savedInfo)).toString('hex') === torrent.infoHash, 'saved metadata hash differs from magnet');
|
||||||
|
console.log(`[OK] magnet metadata resolved and hash matched ${torrent.infoHash}`);
|
||||||
|
const probeRemoved = await forceRemoveIfPresent(client, probeGid);
|
||||||
|
if (probeRemoved) await waitForRemoved(client, probeGid);
|
||||||
|
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(probeDir, { recursive: true });
|
||||||
|
console.log('[OK] metadata probe was removed after resolution');
|
||||||
|
|
||||||
|
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
||||||
|
savedTorrentBytes.toString('base64'),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
dir: finalDir,
|
||||||
|
'select-file': '1',
|
||||||
|
'index-out': indexOut,
|
||||||
|
'max-download-limit': '32K',
|
||||||
|
'seed-time': '0',
|
||||||
|
'auto-file-renaming': 'false',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await waitForStatus(client, finalGid, 'active', 10000);
|
||||||
|
await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]);
|
||||||
|
await waitForStatus(client, finalGid, 'paused', 10000);
|
||||||
|
await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]);
|
||||||
|
const finalStatus = await waitForTerminal(client, finalGid, 30000);
|
||||||
|
const selectedPath = path.join(finalDir, torrent.name, 'selected.bin');
|
||||||
|
const skippedPath = path.join(finalDir, torrent.name, 'skipped.bin');
|
||||||
|
assert(fs.existsSync(selectedPath), `selected torrent output is missing: ${selectedPath}`);
|
||||||
|
assert(fs.readFileSync(selectedPath).equals(torrent.files[0].data), 'selected torrent output content differs');
|
||||||
|
assert(!fs.existsSync(skippedPath), 'unselected torrent output was written');
|
||||||
|
const reportedFiles = await rpc(client.rpcPort, client.secret, 'aria2.getFiles', [finalGid]);
|
||||||
|
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
|
||||||
|
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
|
||||||
|
assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output');
|
||||||
|
console.log('[OK] selected addTorrent output, pause/resume, and Aria2 file ownership passed');
|
||||||
|
|
||||||
|
const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
||||||
|
savedTorrentBytes.toString('base64'),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
dir: cancelDir,
|
||||||
|
'select-file': '1',
|
||||||
|
'index-out': indexOut,
|
||||||
|
'max-download-limit': '8K',
|
||||||
|
'seed-time': '0',
|
||||||
|
'auto-file-renaming': 'false',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await waitForStatus(client, cancelGid, 'active', 10000);
|
||||||
|
assert(await forceRemoveIfPresent(client, cancelGid), 'cancel/remove raced to a terminal state before forceRemove');
|
||||||
|
await waitForRemoved(client, cancelGid);
|
||||||
|
const canceledPath = path.join(cancelDir, torrent.name, 'selected.bin');
|
||||||
|
assert(
|
||||||
|
!fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length,
|
||||||
|
'canceled torrent produced a complete output',
|
||||||
|
);
|
||||||
|
console.log('[OK] cancel/remove stopped the second torrent before completion');
|
||||||
|
|
||||||
|
if (runFailurePaths) {
|
||||||
|
await runFailurePathChecks({ client, tempRoot });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await cleanupRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTerminationSignal(signal) {
|
||||||
|
if (signalTerminationRequested) return;
|
||||||
|
signalTerminationRequested = true;
|
||||||
|
runtimeAbortController.abort();
|
||||||
|
void cleanupRuntime().then(() => {
|
||||||
|
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||||
|
}, error => {
|
||||||
|
console.error(`[FAIL] Torrent runtime smoke cleanup: ${error.message}`);
|
||||||
|
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSigint = () => handleTerminationSignal('SIGINT');
|
||||||
|
const onSigterm = () => handleTerminationSignal('SIGTERM');
|
||||||
|
process.once('SIGINT', onSigint);
|
||||||
|
process.once('SIGTERM', onSigterm);
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(`[FAIL] Torrent runtime smoke test: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}).finally(() => {
|
||||||
|
process.off('SIGINT', onSigint);
|
||||||
|
process.off('SIGTERM', onSigterm);
|
||||||
|
});
|
||||||
Generated
+14
-1
@@ -1369,6 +1369,7 @@ dependencies = [
|
|||||||
"apple-native-keyring-store",
|
"apple-native-keyring-store",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
@@ -1383,6 +1384,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1 0.10.7",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"sysproxy",
|
"sysproxy",
|
||||||
@@ -4281,6 +4283,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -5632,7 +5645,7 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"sha1",
|
"sha1 0.11.0",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
|||||||
sysinfo = "0.39.3"
|
sysinfo = "0.39.3"
|
||||||
hmac = "0.13"
|
hmac = "0.13"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
sha1 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let primary = authorize_download_path(&app_handle, &path)?;
|
let primary = authorize_reveal_path(&app_handle, &path)?;
|
||||||
let path = existing_download_asset(&primary).ok_or_else(|| {
|
let path = existing_download_asset(&primary).ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"Downloaded file or partial file is missing: {}",
|
"Downloaded file or partial file is missing: {}",
|
||||||
@@ -56,18 +56,37 @@ fn authorize_download_path(
|
|||||||
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
|
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_reveal_path(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
requested: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
authorize_exact_path_with_directory(
|
||||||
|
Path::new(requested),
|
||||||
|
&known_download_paths(app_handle)?,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||||
crate::download_ownership::known_primary_paths(app_handle)
|
crate::download_ownership::known_primary_paths(app_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||||
|
authorize_exact_path_with_directory(requested, allowed_paths, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize_exact_path_with_directory(
|
||||||
|
requested: &Path,
|
||||||
|
allowed_paths: &[PathBuf],
|
||||||
|
allow_directory: bool,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
if crate::path_has_symlink_component(requested) {
|
if crate::path_has_symlink_component(requested) {
|
||||||
return Err("Download path may not contain symlink components".to_string());
|
return Err("Download path may not contain symlink components".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||||
if let Ok(metadata) = std::fs::metadata(&requested) {
|
if let Ok(metadata) = std::fs::metadata(&requested) {
|
||||||
if !metadata.is_file() {
|
if !metadata.is_file() && !(allow_directory && metadata.is_dir()) {
|
||||||
return Err("Download path is not a file".to_string());
|
return Err("Download path is not a file".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,8 +159,9 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
|
|||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|candidate| {
|
.find(|candidate| {
|
||||||
std::fs::symlink_metadata(candidate)
|
std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
|
||||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
(metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+191
-32
@@ -7,7 +7,7 @@ use std::sync::Mutex;
|
|||||||
const DATABASE_NAME: &str = "firelink.sqlite";
|
const DATABASE_NAME: &str = "firelink.sqlite";
|
||||||
const LEGACY_STORE_NAME: &str = "store.bin";
|
const LEGACY_STORE_NAME: &str = "store.bin";
|
||||||
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
||||||
const CURRENT_SCHEMA_VERSION: i64 = 1;
|
const CURRENT_SCHEMA_VERSION: i64 = 2;
|
||||||
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
||||||
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||||
// Development builds are a different executable identity from the packaged
|
// Development builds are a different executable identity from the packaged
|
||||||
@@ -127,6 +127,10 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
primary_path TEXT NOT NULL
|
primary_path TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS download_owned_paths (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paths TEXT NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS migration_events (
|
CREATE TABLE IF NOT EXISTS migration_events (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
consumed INTEGER NOT NULL DEFAULT 0
|
consumed INTEGER NOT NULL DEFAULT 0
|
||||||
@@ -176,6 +180,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from_version < 2 {
|
||||||
|
transaction
|
||||||
|
.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS download_owned_paths (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paths TEXT NOT NULL
|
||||||
|
);
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to migrate download ownership paths: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
transaction
|
transaction
|
||||||
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
||||||
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
||||||
@@ -937,20 +954,44 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
|
|||||||
if let Some(url) = object.get("url").and_then(Value::as_str) {
|
if let Some(url) = object.get("url").and_then(Value::as_str) {
|
||||||
if let Ok(mut parsed) = url::Url::parse(url) {
|
if let Ok(mut parsed) = url::Url::parse(url) {
|
||||||
let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
|
let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
|
||||||
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
|
if parsed.scheme() == "magnet" {
|
||||||
if had_userinfo || had_query_or_fragment {
|
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||||
|
let mut retained_info_hash = false;
|
||||||
|
let mut removed_query_context = false;
|
||||||
|
for (key, value) in parsed.query_pairs() {
|
||||||
|
if key == "xt" || key == "dn" {
|
||||||
|
retained_info_hash |= key == "xt";
|
||||||
|
serializer.append_pair(&key, &value);
|
||||||
|
} else {
|
||||||
|
removed_query_context = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let removed_fragment = parsed.fragment().is_some();
|
||||||
|
let safe_query = serializer.finish();
|
||||||
let _ = parsed.set_username("");
|
let _ = parsed.set_username("");
|
||||||
let _ = parsed.set_password(None);
|
let _ = parsed.set_password(None);
|
||||||
parsed.set_query(None);
|
parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str()));
|
||||||
parsed.set_fragment(None);
|
parsed.set_fragment(None);
|
||||||
object.insert("url".to_string(), Value::String(parsed.to_string()));
|
object.insert("url".to_string(), Value::String(parsed.to_string()));
|
||||||
|
if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash {
|
||||||
// A queued transfer whose URL depended on query/fragment
|
|
||||||
// credentials must not silently auto-resume with a truncated
|
|
||||||
// URL after a portable restart.
|
|
||||||
if had_userinfo || had_query_or_fragment {
|
|
||||||
mark_portable_download_unresumable(object);
|
mark_portable_download_unresumable(object);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
|
||||||
|
if had_userinfo || had_query_or_fragment {
|
||||||
|
let _ = parsed.set_username("");
|
||||||
|
let _ = parsed.set_password(None);
|
||||||
|
parsed.set_query(None);
|
||||||
|
parsed.set_fragment(None);
|
||||||
|
object.insert("url".to_string(), Value::String(parsed.to_string()));
|
||||||
|
|
||||||
|
// A queued transfer whose URL depended on query/fragment
|
||||||
|
// credentials must not silently auto-resume with a truncated
|
||||||
|
// URL after a portable restart.
|
||||||
|
if had_userinfo || had_query_or_fragment {
|
||||||
|
mark_portable_download_unresumable(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
object.insert("url".to_string(), Value::String(String::new()));
|
object.insert("url".to_string(), Value::String(String::new()));
|
||||||
@@ -1117,43 +1158,91 @@ fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
|
|||||||
.ok_or_else(|| format!("persisted item is missing '{key}'"))
|
.ok_or_else(|| format!("persisted item is missing '{key}'"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String)>, String> {
|
pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String, Vec<String>)>, String> {
|
||||||
let mut statement = connection
|
let mut statement = connection
|
||||||
.prepare("SELECT id, primary_path FROM download_ownership")
|
.prepare(
|
||||||
|
"SELECT ownership.id, ownership.primary_path, paths.paths
|
||||||
|
FROM download_ownership AS ownership
|
||||||
|
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id",
|
||||||
|
)
|
||||||
.map_err(|error| format!("failed to prepare ownership query: {error}"))?;
|
.map_err(|error| format!("failed to prepare ownership query: {error}"))?;
|
||||||
let rows = statement
|
let rows = statement
|
||||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
.query_map([], |row| {
|
||||||
|
let primary_path: String = row.get(1)?;
|
||||||
|
let owned_paths = row
|
||||||
|
.get::<_, Option<String>>(2)?
|
||||||
|
.and_then(|paths| serde_json::from_str::<Vec<String>>(&paths).ok())
|
||||||
|
.filter(|paths| !paths.is_empty())
|
||||||
|
.unwrap_or_else(|| vec![primary_path.clone()]);
|
||||||
|
Ok((row.get(0)?, primary_path, owned_paths))
|
||||||
|
})
|
||||||
.map_err(|error| format!("failed to query ownership data: {error}"))?;
|
.map_err(|error| format!("failed to query ownership data: {error}"))?;
|
||||||
rows.collect::<Result<Vec<_>, _>>()
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|error| format!("failed to read ownership data: {error}"))
|
.map_err(|error| format!("failed to read ownership data: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> {
|
pub fn set_ownership_paths(
|
||||||
let existing_owner = connection
|
connection: &Connection,
|
||||||
.query_row(
|
id: &str,
|
||||||
"SELECT id FROM download_ownership
|
primary_path: &str,
|
||||||
WHERE primary_path = ?1 AND id <> ?2
|
paths: &[String],
|
||||||
LIMIT 1",
|
) -> Result<(), String> {
|
||||||
params![path, id],
|
let mut statement = connection
|
||||||
|row| row.get::<_, String>(0),
|
.prepare(
|
||||||
|
"SELECT ownership.id, ownership.primary_path, paths.paths
|
||||||
|
FROM download_ownership AS ownership
|
||||||
|
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id
|
||||||
|
WHERE ownership.id <> ?1",
|
||||||
)
|
)
|
||||||
.optional()
|
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
|
||||||
.map_err(|error| format!("failed to check download ownership path: {error}"))?;
|
let existing = statement
|
||||||
if existing_owner.is_some() {
|
.query_map(params![id], |row| {
|
||||||
return Err("Download destination is already owned by another Firelink download".to_string());
|
let primary: String = row.get(1)?;
|
||||||
|
let owned = row
|
||||||
|
.get::<_, Option<String>>(2)?
|
||||||
|
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
|
||||||
|
.unwrap_or_else(|| vec![primary.clone()]);
|
||||||
|
Ok((primary, owned))
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
|
||||||
|
for row in existing {
|
||||||
|
let (existing_primary, owned) =
|
||||||
|
row.map_err(|error| format!("failed to read download ownership paths: {error}"))?;
|
||||||
|
let new_paths = std::iter::once(primary_path).chain(paths.iter().map(String::as_str));
|
||||||
|
let existing_paths = std::iter::once(existing_primary.as_str())
|
||||||
|
.chain(owned.iter().map(String::as_str));
|
||||||
|
if new_paths.clone().any(|new_path| {
|
||||||
|
existing_paths
|
||||||
|
.clone()
|
||||||
|
.any(|existing_path| crate::platform::paths_equal(Path::new(new_path), Path::new(existing_path)))
|
||||||
|
}) {
|
||||||
|
return Err("Download destination is already owned by another Firelink download".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connection
|
connection
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)
|
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)
|
||||||
ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path",
|
ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path",
|
||||||
params![id, path],
|
params![id, primary_path],
|
||||||
)
|
)
|
||||||
.map_err(|error| format!("failed to save ownership data: {error}"))?;
|
.map_err(|error| format!("failed to save ownership data: {error}"))?;
|
||||||
|
let encoded_paths = serde_json::to_string(paths)
|
||||||
|
.map_err(|error| format!("failed to encode download ownership paths: {error}"))?;
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET paths = excluded.paths",
|
||||||
|
params![id, encoded_paths],
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to save download ownership path list: {error}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> {
|
pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> {
|
||||||
|
connection
|
||||||
|
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
|
||||||
|
.map_err(|error| format!("failed to delete download ownership paths: {error}"))?;
|
||||||
connection
|
connection
|
||||||
.execute("DELETE FROM download_ownership WHERE id = ?1", params![id])
|
.execute("DELETE FROM download_ownership WHERE id = ?1", params![id])
|
||||||
.map_err(|error| format!("failed to delete ownership data: {error}"))?;
|
.map_err(|error| format!("failed to delete ownership data: {error}"))?;
|
||||||
@@ -1937,6 +2026,29 @@ mod tests {
|
|||||||
assert!(saved.get("headers").is_none());
|
assert!(saved.get("headers").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portable_magnet_persistence_keeps_identity_but_does_not_resume_after_tracker_removal() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let mut connection = state.lock().unwrap();
|
||||||
|
let data = json!([{
|
||||||
|
"id": "magnet-download",
|
||||||
|
"status": "queued",
|
||||||
|
"queueId": "main",
|
||||||
|
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret"
|
||||||
|
}])
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
replace_downloads(&mut connection, &data, true).unwrap();
|
||||||
|
|
||||||
|
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||||
|
assert_eq!(saved["url"], "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=Example+Torrent");
|
||||||
|
assert_eq!(saved["status"], "failed");
|
||||||
|
assert_eq!(saved["resumable"], false);
|
||||||
|
assert!(!saved.to_string().contains("tracker.invalid"));
|
||||||
|
assert!(!saved.to_string().contains("secret"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() {
|
fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -2142,15 +2254,62 @@ mod tests {
|
|||||||
let state = init_at_path(temp.path()).unwrap();
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
let connection = state.lock().unwrap();
|
let connection = state.lock().unwrap();
|
||||||
|
|
||||||
set_ownership(&connection, "first", "/downloads/file.bin").unwrap();
|
set_ownership_paths(
|
||||||
let error = set_ownership(&connection, "second", "/downloads/file.bin")
|
&connection,
|
||||||
|
"first",
|
||||||
|
"/downloads/file.bin",
|
||||||
|
&["/downloads/file.bin".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = set_ownership_paths(
|
||||||
|
&connection,
|
||||||
|
"second",
|
||||||
|
"/downloads/file.bin",
|
||||||
|
&["/downloads/file.bin".to_string()],
|
||||||
|
)
|
||||||
.expect_err("a primary path must have one live owner");
|
.expect_err("a primary path must have one live owner");
|
||||||
|
|
||||||
assert!(error.contains("already owned"));
|
assert!(error.contains("already owned"));
|
||||||
assert_eq!(load_ownership(&connection).unwrap(), vec![(
|
assert_eq!(
|
||||||
"first".to_string(),
|
load_ownership(&connection).unwrap(),
|
||||||
"/downloads/file.bin".to_string()
|
vec![(
|
||||||
)]);
|
"first".to_string(),
|
||||||
set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap();
|
"/downloads/file.bin".to_string(),
|
||||||
|
vec!["/downloads/file.bin".to_string()]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
set_ownership_paths(
|
||||||
|
&connection,
|
||||||
|
"first",
|
||||||
|
"/downloads/renamed.bin",
|
||||||
|
&["/downloads/renamed.bin".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_overlap_with_any_owned_torrent_path() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let state = init_at_path(temp.path()).unwrap();
|
||||||
|
let connection = state.lock().unwrap();
|
||||||
|
|
||||||
|
set_ownership_paths(
|
||||||
|
&connection,
|
||||||
|
"first",
|
||||||
|
"/downloads/root",
|
||||||
|
&[
|
||||||
|
"/downloads/root/a.bin".to_string(),
|
||||||
|
"/downloads/root/b.bin".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = set_ownership_paths(
|
||||||
|
&connection,
|
||||||
|
"second",
|
||||||
|
"/downloads/root",
|
||||||
|
&["/downloads/root/c.bin".to_string()],
|
||||||
|
)
|
||||||
|
.expect_err("a torrent root must not be reused");
|
||||||
|
assert!(error.contains("already owned"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tauri::Manager;
|
|||||||
struct DownloadOwnershipRecord {
|
struct DownloadOwnershipRecord {
|
||||||
id: String,
|
id: String,
|
||||||
primary_path: String,
|
primary_path: String,
|
||||||
|
owned_paths: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn canonical_download_filename(filename: &str) -> String {
|
pub fn canonical_download_filename(filename: &str) -> String {
|
||||||
@@ -124,6 +125,63 @@ pub fn set_primary_path(
|
|||||||
id: &str,
|
id: &str,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
set_owned_paths(app_handle, id, &[path.to_path_buf()])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_owned_paths<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
paths: &[PathBuf],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let primary = paths
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| "Download ownership requires at least one path".to_string())?;
|
||||||
|
set_owned_paths_with_primary(app_handle, id, primary, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
primary: &Path,
|
||||||
|
paths: &[PathBuf],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("Download ownership requires at least one path".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_primary = canonical_owned_path(app_handle, primary)?;
|
||||||
|
let mut canonical_paths = Vec::with_capacity(paths.len());
|
||||||
|
for path in paths {
|
||||||
|
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
|
||||||
|
return Err("Download ownership file path is a directory".to_string());
|
||||||
|
}
|
||||||
|
let canonical_path = canonical_owned_path(app_handle, path)?;
|
||||||
|
if !canonical_paths
|
||||||
|
.iter()
|
||||||
|
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
|
||||||
|
{
|
||||||
|
canonical_paths.push(canonical_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_strings = canonical_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let database = app_handle.state::<crate::db::DbState>();
|
||||||
|
let connection = database.lock()?;
|
||||||
|
crate::db::set_ownership_paths(
|
||||||
|
&connection,
|
||||||
|
id,
|
||||||
|
&canonical_primary.to_string_lossy(),
|
||||||
|
&path_strings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_owned_path<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err("Download ownership path must be absolute".to_string());
|
return Err("Download ownership path must be absolute".to_string());
|
||||||
}
|
}
|
||||||
@@ -140,10 +198,10 @@ pub fn set_primary_path(
|
|||||||
}
|
}
|
||||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||||
|
if !crate::is_safe_path(&canonical_path, app_handle) {
|
||||||
let database = app_handle.state::<crate::db::DbState>();
|
return Err("Download ownership path is outside an allowed download location".to_string());
|
||||||
let connection = database.lock()?;
|
}
|
||||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
Ok(canonical_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||||
@@ -162,10 +220,25 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
|
|||||||
.map(|record| PathBuf::from(record.primary_path)))
|
.map(|record| PathBuf::from(record.primary_path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn owned_paths_for_id<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<Vec<PathBuf>, String> {
|
||||||
|
Ok(load_records(app_handle)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|record| record.id == id)
|
||||||
|
.map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect())
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||||
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|record| PathBuf::from(record.primary_path))
|
.flat_map(|record| {
|
||||||
|
std::iter::once(PathBuf::from(record.primary_path)).chain(
|
||||||
|
record.owned_paths.into_iter().map(PathBuf::from),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// One-time compatibility for downloads created before the backend-owned
|
// One-time compatibility for downloads created before the backend-owned
|
||||||
@@ -185,7 +258,11 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
|
|||||||
crate::db::load_ownership(&connection).map(|records| {
|
crate::db::load_ownership(&connection).map(|records| {
|
||||||
records
|
records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path })
|
.map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
|
||||||
|
id,
|
||||||
|
primary_path,
|
||||||
|
owned_paths,
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,42 @@ pub struct DownloadItem {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub last_try: Option<String>,
|
pub last_try: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub is_torrent: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_file_indices: Option<Vec<u32>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_info_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct TorrentFile {
|
||||||
|
pub index: u32,
|
||||||
|
pub path: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct TorrentMetadata {
|
||||||
|
pub name: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub files: Vec<TorrentFile>,
|
||||||
|
pub info_hash: String,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
+543
-18
@@ -2918,6 +2918,8 @@ mod platform;
|
|||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod retry;
|
pub mod retry;
|
||||||
|
mod torrent_probe;
|
||||||
|
pub mod torrent;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod storage;
|
mod storage;
|
||||||
pub use error::AppError;
|
pub use error::AppError;
|
||||||
@@ -3156,7 +3158,7 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink {
|
|||||||
let Ok(url) = url::Url::parse(raw_url) else {
|
let Ok(url) = url::Url::parse(raw_url) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") {
|
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let url = url.to_string();
|
let url = url.to_string();
|
||||||
@@ -3274,6 +3276,22 @@ pub(crate) async fn rpc_call(
|
|||||||
.ok_or_else(|| "aria2 returned no result".to_string())
|
.ok_or_else(|| "aria2 returned no result".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Aria2RpcClient {
|
||||||
|
port: u16,
|
||||||
|
secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::torrent_probe::RpcClient for Aria2RpcClient {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
rpc_call(self.port, &self.secret, method, params).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn test_aria2c(
|
async fn test_aria2c(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -4883,19 +4901,37 @@ async fn remove_download(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("remove_download called for id: {}", id);
|
log::info!("remove_download called for id: {}", id);
|
||||||
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
||||||
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
|
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
|
||||||
|
|
||||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||||
let media_lifecycle_generation = state
|
let registered_lifecycle_generation = state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.registered_lifecycle_generation(&id)
|
.registered_lifecycle_generation(&id)
|
||||||
.await
|
.await;
|
||||||
.unwrap_or_default();
|
let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default();
|
||||||
state.queue_manager.remove_from_pending(&id).await;
|
if let Some(generation) = registered_lifecycle_generation {
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.remove_from_pending_for_generation(&id, generation)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
state.queue_manager.remove_from_pending(&id).await;
|
||||||
|
}
|
||||||
|
|
||||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||||
if let Some(gid) = gid.as_deref() {
|
if let Some(gid) = gid.as_deref() {
|
||||||
|
if let Err(error) = state
|
||||||
|
.queue_manager
|
||||||
|
.reconcile_aria2_torrent_ownership(&id, gid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"aria2 torrent ownership [{}]: could not resolve files before removal of gid {}: {}",
|
||||||
|
id,
|
||||||
|
gid,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
// Keep the current epoch and mapping alive until daemon removal is
|
// Keep the current epoch and mapping alive until daemon removal is
|
||||||
// confirmed. If removal fails, terminal events from this lifecycle
|
// confirmed. If removal fails, terminal events from this lifecycle
|
||||||
// must still be accepted so the permit and mapping can be cleaned up.
|
// must still be accepted so the permit and mapping can be cleaned up.
|
||||||
@@ -4928,7 +4964,7 @@ async fn remove_download(
|
|||||||
// There may be an addUri or retry handoff in flight with no mapped
|
// There may be an addUri or retry handoff in flight with no mapped
|
||||||
// GID yet. Invalidate that pending lifecycle before acknowledging the
|
// GID yet. Invalidate that pending lifecycle before acknowledging the
|
||||||
// removal so its late result cannot resurrect the download.
|
// removal so its late result cannot resurrect the download.
|
||||||
state.queue_manager.next_aria2_control_epoch(&id).await;
|
let removal_epoch = state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||||
state.queue_manager.cancel_aria2_retries(&id).await;
|
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||||
@@ -4947,11 +4983,79 @@ async fn remove_download(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The initial addUri call intentionally runs outside the control lock.
|
||||||
|
// Do not delete the guessed magnet path and ownership record until a
|
||||||
|
// late GID has been removed; otherwise the daemon can finish creating
|
||||||
|
// an output after cleanup and leave an untracked file behind.
|
||||||
|
drop(control_guard);
|
||||||
|
state.queue_manager.wait_for_aria2_dispatch(&id).await;
|
||||||
|
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
|
let current_generation = state
|
||||||
|
.queue_manager
|
||||||
|
.registered_lifecycle_generation(&id)
|
||||||
|
.await;
|
||||||
|
let lifecycle_changed = registered_lifecycle_generation.is_some()
|
||||||
|
&& current_generation != registered_lifecycle_generation
|
||||||
|
|| registered_lifecycle_generation.is_none() && current_generation.is_some();
|
||||||
|
if lifecycle_changed {
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.release_permit_for_generation(&id, media_lifecycle_generation)
|
||||||
|
.await;
|
||||||
|
return Err("download lifecycle changed while waiting for aria2 dispatch".to_string());
|
||||||
|
}
|
||||||
|
if let Some(late_gid) = state.queue_manager.aria2_gid_for_download(&id) {
|
||||||
|
if let Err(error) = state
|
||||||
|
.queue_manager
|
||||||
|
.reconcile_aria2_torrent_ownership(&id, &late_gid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"aria2 remove [{}]: could not resolve files for late gid {}: {}",
|
||||||
|
id,
|
||||||
|
late_gid,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let removal_result = async {
|
||||||
|
force_remove_aria2_gid(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
&late_gid,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_aria2_stopped(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
&late_gid,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = removal_result {
|
||||||
|
state.queue_manager.allow_aria2_retries(&id).await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||||
|
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||||
|
state.queue_manager.forget_aria2_gid(&id).await;
|
||||||
|
} else if !state
|
||||||
|
.queue_manager
|
||||||
|
.is_aria2_control_epoch_current(&id, removal_epoch)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// A same-generation pause/resume control action may advance the
|
||||||
|
// epoch while the late add is being retired. Removal still owns
|
||||||
|
// the registered row, so fence that action before cleaning it.
|
||||||
|
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||||
|
}
|
||||||
state.queue_manager.release_permit(&id).await;
|
state.queue_manager.release_permit(&id).await;
|
||||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||||
state.queue_manager.forget_aria2_gid(&id).await;
|
state.queue_manager.forget_aria2_gid(&id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?;
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
@@ -4959,16 +5063,15 @@ async fn remove_download(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let preserve_assets = preserve_resumable
|
let preserve_assets = preserve_resumable
|
||||||
&& primary_path
|
&& owned_paths.iter().any(|path| has_resumable_download_assets(path));
|
||||||
.as_deref()
|
|
||||||
.is_some_and(has_resumable_download_assets);
|
|
||||||
|
|
||||||
let cleanup_result = async {
|
let cleanup_result = async {
|
||||||
if delete_assets && !preserve_assets {
|
if delete_assets && !preserve_assets {
|
||||||
if let Some(path) = primary_path.as_deref() {
|
for path in &owned_paths {
|
||||||
remove_download_assets(path, &app_handle).await?;
|
remove_download_assets(path, &app_handle).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
|
||||||
crate::download_ownership::remove(&app_handle, &id)?;
|
crate::download_ownership::remove(&app_handle, &id)?;
|
||||||
Ok::<(), String>(())
|
Ok::<(), String>(())
|
||||||
}
|
}
|
||||||
@@ -4978,6 +5081,15 @@ async fn remove_download(
|
|||||||
cleanup_result
|
cleanup_result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_download_primary_path(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
crate::download_ownership::primary_path_for_id(&app_handle, &id)
|
||||||
|
.map(|path| path.map(|path| path.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn has_resumable_download_assets(primary: &std::path::Path) -> bool {
|
fn has_resumable_download_assets(primary: &std::path::Path) -> bool {
|
||||||
[".aria2", ".part", ".ytdl"].iter().any(|suffix| {
|
[".aria2", ".part", ".ytdl"].iter().any(|suffix| {
|
||||||
let mut candidate = primary.as_os_str().to_os_string();
|
let mut candidate = primary.as_os_str().to_os_string();
|
||||||
@@ -5510,15 +5622,283 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn validate_torrent_enqueue(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
item: &queue::EnqueueItem,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if item.is_media.unwrap_or(false) {
|
||||||
|
return Err("torrent transfer cannot be a media download".to_string());
|
||||||
|
}
|
||||||
|
validate_enqueue_uris("", item.mirrors.as_deref()).await?;
|
||||||
|
if let Some(path) = item.torrent_path.as_deref() {
|
||||||
|
let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?;
|
||||||
|
let bytes = std::fs::read(path)
|
||||||
|
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
||||||
|
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
crate::torrent::validate_info_hash(
|
||||||
|
item.torrent_info_hash.as_deref(),
|
||||||
|
&metadata.info_hash,
|
||||||
|
)?;
|
||||||
|
crate::torrent::validate_selected_indices(
|
||||||
|
item.torrent_file_indices.as_deref(),
|
||||||
|
metadata.files.len(),
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = url::Url::parse(&item.url).map_err(|_| "invalid magnet URI".to_string())?;
|
||||||
|
if parsed.scheme() != "magnet" {
|
||||||
|
return Err("torrent transfer has no magnet URI or cached metadata".to_string());
|
||||||
|
}
|
||||||
|
if item.torrent_file_indices.is_some() {
|
||||||
|
return Err("magnet file selection requires resolved torrent metadata".to_string());
|
||||||
|
}
|
||||||
|
let metadata = crate::torrent::inspect_source(&item.url)?;
|
||||||
|
crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_torrent_output_paths(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
item: &queue::EnqueueItem,
|
||||||
|
) -> Result<Option<Vec<std::path::PathBuf>>, String> {
|
||||||
|
if !item.is_torrent.unwrap_or(false) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(torrent_path) = item.torrent_path.as_deref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, torrent_path)?;
|
||||||
|
let bytes = std::fs::read(torrent_path)
|
||||||
|
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
||||||
|
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
let selected = crate::torrent::validate_selected_indices(
|
||||||
|
item.torrent_file_indices.as_deref(),
|
||||||
|
metadata.files.len(),
|
||||||
|
)?;
|
||||||
|
let destination = crate::resolve_path(&item.destination, app_handle);
|
||||||
|
if !crate::is_safe_path(&destination, app_handle) {
|
||||||
|
return Err("Path traversal blocked".to_string());
|
||||||
|
}
|
||||||
|
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
|
||||||
|
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for relative in crate::torrent::aria2_output_paths(&metadata, selected.as_deref()) {
|
||||||
|
let relative = std::path::PathBuf::from(relative);
|
||||||
|
if relative.is_absolute()
|
||||||
|
|| relative.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
std::path::Component::ParentDir | std::path::Component::CurDir
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("torrent output path is unsafe".to_string());
|
||||||
|
}
|
||||||
|
let path = destination.join(relative);
|
||||||
|
let canonical_path = crate::canonicalize_with_missing_components(&path)
|
||||||
|
.ok_or_else(|| "torrent output path could not be canonicalized".to_string())?;
|
||||||
|
if !crate::platform::path_is_within(&canonical_path, &canonical_destination) {
|
||||||
|
return Err("torrent output path is outside its destination".to_string());
|
||||||
|
}
|
||||||
|
paths.push(canonical_path);
|
||||||
|
}
|
||||||
|
Ok(Some(paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), String> {
|
||||||
|
match tokio::fs::remove_dir_all(path).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(format!(
|
||||||
|
"could not remove magnet metadata probe ({:?})",
|
||||||
|
error.kind()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_magnet_metadata(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
source: &str,
|
||||||
|
id: &str,
|
||||||
|
proxy: Option<&str>,
|
||||||
|
cache: bool,
|
||||||
|
) -> Result<crate::ipc::TorrentMetadata, String> {
|
||||||
|
let expected = crate::torrent::inspect_source(source)?;
|
||||||
|
let managed_path = crate::torrent::managed_torrent_path(app_handle, id)?;
|
||||||
|
let proxy_value = proxy
|
||||||
|
.map(crate::queue::aria2_all_proxy_value)
|
||||||
|
.transpose()?
|
||||||
|
.flatten();
|
||||||
|
let storage_root = managed_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "torrent storage has no parent directory".to_string())?;
|
||||||
|
let probe_dir = storage_root.join(format!(".probe-{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
tokio::fs::create_dir_all(&probe_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not create torrent metadata probe: {error}"))?;
|
||||||
|
|
||||||
|
let port = state
|
||||||
|
.aria2_port
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let secret = state.aria2_secret.clone();
|
||||||
|
let metadata_path = probe_dir.join(format!("{}.torrent", expected.info_hash));
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
options.insert(
|
||||||
|
"dir".to_string(),
|
||||||
|
serde_json::json!(probe_dir.to_string_lossy().to_string()),
|
||||||
|
);
|
||||||
|
options.insert("bt-metadata-only".to_string(), serde_json::json!("true"));
|
||||||
|
options.insert("bt-save-metadata".to_string(), serde_json::json!("true"));
|
||||||
|
options.insert("max-tries".to_string(), serde_json::json!("3"));
|
||||||
|
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
||||||
|
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
|
||||||
|
options.insert("timeout".to_string(), serde_json::json!("60"));
|
||||||
|
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
|
||||||
|
if let Some(proxy) = proxy_value {
|
||||||
|
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
|
||||||
|
let metadata_result = crate::torrent_probe::run_metadata_probe(
|
||||||
|
client,
|
||||||
|
source,
|
||||||
|
options,
|
||||||
|
&metadata_path,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
Duration::from_millis(250),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let bytes = match metadata_result {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => {
|
||||||
|
if let Err(remove_error) = remove_magnet_metadata_probe_dir(&probe_dir).await {
|
||||||
|
return Err(remove_error);
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Err(crate::torrent_probe::ProbeFailure::Cleanup(error)) => {
|
||||||
|
if aria2_daemon_process_exited(app_handle) {
|
||||||
|
if let Err(remove_error) = remove_magnet_metadata_probe_dir(&probe_dir).await {
|
||||||
|
return Err(format!(
|
||||||
|
"could not clean up magnet metadata probe: {error}; could not remove probe after aria2 exit: {remove_error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"could not clean up magnet metadata probe after aria2 exit: {error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!("could not clean up magnet metadata probe: {error}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = remove_magnet_metadata_probe_dir(&probe_dir).await {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let parsed = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
||||||
|
let torrent_path = if cache {
|
||||||
|
Some(crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(crate::torrent::to_metadata(parsed, torrent_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn inspect_torrent(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
source: String,
|
||||||
|
id: String,
|
||||||
|
cache: Option<bool>,
|
||||||
|
proxy: Option<String>,
|
||||||
|
) -> Result<crate::ipc::TorrentMetadata, AppError> {
|
||||||
|
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
||||||
|
return resolve_magnet_metadata(
|
||||||
|
&app_handle,
|
||||||
|
state.inner(),
|
||||||
|
&source,
|
||||||
|
&id,
|
||||||
|
proxy.as_deref(),
|
||||||
|
cache == Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal);
|
||||||
|
}
|
||||||
|
if cache == Some(false) {
|
||||||
|
return crate::torrent::inspect_source(&source)
|
||||||
|
.map(|parsed| crate::torrent::to_metadata(parsed, None))
|
||||||
|
.map_err(AppError::Internal);
|
||||||
|
}
|
||||||
|
let (parsed, path) = crate::torrent::prepare_local_torrent(&app_handle, &source, &id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
Ok(crate::torrent::to_metadata(parsed, Some(path)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn rekey_torrent_metadata(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
source_id: String,
|
||||||
|
target_id: String,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let source = crate::torrent::managed_torrent_path(&app_handle, &source_id)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let source = crate::torrent::validate_managed_torrent_path(
|
||||||
|
&app_handle,
|
||||||
|
&source_id,
|
||||||
|
&source.to_string_lossy(),
|
||||||
|
)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let bytes = tokio::fs::read(&source)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::Internal(format!("could not read cached torrent metadata: {error}"))
|
||||||
|
})?;
|
||||||
|
crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?;
|
||||||
|
let target = crate::torrent::cache_torrent_bytes(&app_handle, &target_id, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let target = crate::torrent::validate_managed_torrent_path(
|
||||||
|
&app_handle,
|
||||||
|
&target_id,
|
||||||
|
&target,
|
||||||
|
)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
if source != target {
|
||||||
|
tokio::fs::remove_file(&source).await.map_err(|error| {
|
||||||
|
AppError::Internal(format!("could not remove temporary torrent metadata: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(target.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn remove_torrent_metadata(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn enqueue_download(
|
async fn enqueue_download(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
mut item: queue::EnqueueItem,
|
mut item: queue::EnqueueItem,
|
||||||
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
||||||
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
|
if item.is_torrent.unwrap_or(false) {
|
||||||
.await
|
validate_torrent_enqueue(&app_handle, &item)
|
||||||
.map_err(AppError::Internal)?;
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
} else {
|
||||||
|
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
}
|
||||||
let id = item.id.clone();
|
let id = item.id.clone();
|
||||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||||
let accepted_filename = item.filename.clone();
|
let accepted_filename = item.filename.clone();
|
||||||
@@ -5540,6 +5920,47 @@ async fn enqueue_download(
|
|||||||
.await;
|
.await;
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
|
match expected_torrent_output_paths(&app_handle, &item) {
|
||||||
|
Ok(Some(paths)) => {
|
||||||
|
let primary = match crate::download_ownership::expected_primary_path(
|
||||||
|
&app_handle,
|
||||||
|
&item.destination,
|
||||||
|
&item.filename,
|
||||||
|
) {
|
||||||
|
Ok(primary) => primary,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Internal(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
|
||||||
|
&app_handle,
|
||||||
|
&item.id,
|
||||||
|
&primary,
|
||||||
|
&paths,
|
||||||
|
) {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Internal(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::Internal(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
@@ -5583,7 +6004,12 @@ async fn enqueue_many(
|
|||||||
let mut results = Vec::with_capacity(items.len());
|
let mut results = Vec::with_capacity(items.len());
|
||||||
for mut item in items {
|
for mut item in items {
|
||||||
let id = item.id.clone();
|
let id = item.id.clone();
|
||||||
if let Err(error) = validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await {
|
let validation = if item.is_torrent.unwrap_or(false) {
|
||||||
|
validate_torrent_enqueue(&app_handle, &item).await
|
||||||
|
} else {
|
||||||
|
validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await
|
||||||
|
};
|
||||||
|
if let Err(error) = validation {
|
||||||
results.push(crate::ipc::EnqueueResult {
|
results.push(crate::ipc::EnqueueResult {
|
||||||
id,
|
id,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -5640,6 +6066,65 @@ async fn enqueue_many(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
match expected_torrent_output_paths(&app_handle, &item) {
|
||||||
|
Ok(Some(paths)) => {
|
||||||
|
let primary = match crate::download_ownership::expected_primary_path(
|
||||||
|
&app_handle,
|
||||||
|
&item.destination,
|
||||||
|
&item.filename,
|
||||||
|
) {
|
||||||
|
Ok(primary) => primary,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
|
||||||
|
&app_handle,
|
||||||
|
&item.id,
|
||||||
|
&primary,
|
||||||
|
&paths,
|
||||||
|
) {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
|
||||||
|
.await;
|
||||||
|
results.push(crate::ipc::EnqueueResult {
|
||||||
|
id,
|
||||||
|
success: false,
|
||||||
|
filename: None,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
@@ -9317,6 +9802,46 @@ pub fn run() {
|
|||||||
|
|
||||||
let database = crate::db::init(&storage_layout)
|
let database = crate::db::init(&storage_layout)
|
||||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||||
|
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||||
|
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||||
|
}
|
||||||
|
let retained_torrent_ids = database
|
||||||
|
.lock()
|
||||||
|
.and_then(|connection| crate::db::load_downloads(&connection))
|
||||||
|
.and_then(|records| {
|
||||||
|
records
|
||||||
|
.into_iter()
|
||||||
|
.map(|record| {
|
||||||
|
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("could not decode persisted download metadata: {error}")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
})
|
||||||
|
.map(|downloads| {
|
||||||
|
downloads
|
||||||
|
.into_iter()
|
||||||
|
.filter(|download| {
|
||||||
|
download.is_torrent.unwrap_or(false)
|
||||||
|
&& download.torrent_path.is_some()
|
||||||
|
})
|
||||||
|
.map(|download| download.id)
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
});
|
||||||
|
match retained_torrent_ids {
|
||||||
|
Ok(retained_torrent_ids) => {
|
||||||
|
if let Err(error) = crate::torrent::remove_orphaned_cached_torrents(
|
||||||
|
app.handle(),
|
||||||
|
&retained_torrent_ids,
|
||||||
|
) {
|
||||||
|
log::warn!("could not remove orphaned torrent metadata: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("could not identify retained torrent metadata: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
let initial_pairing_token = {
|
let initial_pairing_token = {
|
||||||
// Generate a temporary session token for the extension server on startup.
|
// Generate a temporary session token for the extension server on startup.
|
||||||
// The frontend will hydrate the real token via IPC once it mounts,
|
// The frontend will hydrate the real token via IPC once it mounts,
|
||||||
@@ -9979,7 +10504,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
||||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
||||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
|
pause_download, resume_download, fetch_metadata, inspect_torrent, rekey_torrent_metadata, remove_torrent_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
|
||||||
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
|
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
|
||||||
ack_schedule_trigger,
|
ack_schedule_trigger,
|
||||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||||
@@ -9990,7 +10515,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download,
|
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
@@ -123,12 +123,12 @@ pub fn path_is_within(path: &Path, root: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
left.to_string_lossy()
|
left.to_string_lossy()
|
||||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
left == right
|
left == right
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-18
@@ -1,3 +1,4 @@
|
|||||||
|
use base64::Engine as _;
|
||||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||||
use crate::power::PowerManager;
|
use crate::power::PowerManager;
|
||||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
||||||
@@ -201,6 +202,9 @@ pub struct SpawnPayload {
|
|||||||
pub format_selector: Option<String>,
|
pub format_selector: Option<String>,
|
||||||
pub cookie_source: Option<String>,
|
pub cookie_source: Option<String>,
|
||||||
pub is_media: bool,
|
pub is_media: bool,
|
||||||
|
pub is_torrent: bool,
|
||||||
|
pub torrent_path: Option<String>,
|
||||||
|
pub torrent_file_indices: Option<Vec<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
|
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
|
||||||
@@ -294,6 +298,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
|||||||
|
|
||||||
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||||
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
||||||
|
/// Initial aria2 addUri handoffs that have not yet either published a GID
|
||||||
|
/// or removed a stale late GID. Removal waits for these handoffs before
|
||||||
|
/// deleting owned assets so a magnet cannot leave an orphaned output.
|
||||||
|
aria2_dispatch_inflight: Mutex<HashMap<String, HashSet<u64>>>,
|
||||||
|
aria2_dispatch_notify: Notify,
|
||||||
|
|
||||||
/// The daemon-wide download cap currently applied to aria2. This mirrors
|
/// The daemon-wide download cap currently applied to aria2. This mirrors
|
||||||
/// successful RPC changes so the poller can avoid treating an intentional
|
/// successful RPC changes so the poller can avoid treating an intentional
|
||||||
@@ -372,6 +381,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||||
aria2_payloads: Mutex::new(HashMap::new()),
|
aria2_payloads: Mutex::new(HashMap::new()),
|
||||||
|
aria2_dispatch_inflight: Mutex::new(HashMap::new()),
|
||||||
|
aria2_dispatch_notify: Notify::new(),
|
||||||
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
||||||
aria2_retry_strikes: Mutex::new(HashMap::new()),
|
aria2_retry_strikes: Mutex::new(HashMap::new()),
|
||||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||||
@@ -1199,7 +1210,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
pub(crate) async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
||||||
let _admission_gate = self.admission_gate.lock().await;
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut permits = self.active_permits.lock().await;
|
let mut permits = self.active_permits.lock().await;
|
||||||
@@ -1388,6 +1399,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
if let Some(epoch) = aria2_lifecycle_epoch {
|
||||||
|
self.begin_aria2_dispatch(&id, epoch).await;
|
||||||
|
}
|
||||||
self.emit_state(&id, DownloadStatus::Downloading);
|
self.emit_state(&id, DownloadStatus::Downloading);
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
|
|
||||||
@@ -1395,7 +1409,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
TaskKind::Aria2 => {
|
TaskKind::Aria2 => {
|
||||||
let lifecycle_epoch = aria2_lifecycle_epoch
|
let lifecycle_epoch = aria2_lifecycle_epoch
|
||||||
.expect("aria2 dispatch must initialize a control epoch");
|
.expect("aria2 dispatch must initialize a control epoch");
|
||||||
match self.spawner.add_uri(&id, &task.payload).await {
|
let add_result = self.spawner.add_uri(&id, &task.payload).await;
|
||||||
|
match add_result {
|
||||||
Ok(gid) => {
|
Ok(gid) => {
|
||||||
let control_guard = self.acquire_aria2_control(&id).await;
|
let control_guard = self.acquire_aria2_control(&id).await;
|
||||||
let cancelled = self.aria2_retry_cancelled.lock().await.contains(&id);
|
let cancelled = self.aria2_retry_cancelled.lock().await.contains(&id);
|
||||||
@@ -1410,6 +1425,23 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
id,
|
id,
|
||||||
gid
|
gid
|
||||||
);
|
);
|
||||||
|
if task.payload.is_torrent {
|
||||||
|
if let Err(error) = self
|
||||||
|
.reconcile_aria2_torrent_ownership_for_payload(
|
||||||
|
&id,
|
||||||
|
&gid,
|
||||||
|
&task.payload,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"aria2 dispatch cancellation [{}]: could not resolve torrent output paths for gid {}: {}",
|
||||||
|
id,
|
||||||
|
gid,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(error) = self.spawner.remove_uri(&gid).await {
|
if let Err(error) = self.spawner.remove_uri(&gid).await {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
|
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
|
||||||
@@ -1419,6 +1451,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.ignore_aria2_gid(&gid).await;
|
self.ignore_aria2_gid(&gid).await;
|
||||||
|
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
|
||||||
if current_lifecycle {
|
if current_lifecycle {
|
||||||
self.clear_aria2_retry_state(&id).await;
|
self.clear_aria2_retry_state(&id).await;
|
||||||
self.release_permit(&id).await;
|
self.release_permit(&id).await;
|
||||||
@@ -1426,6 +1459,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
|
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
|
||||||
|
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
if let Some(outcome) = buffered_outcome {
|
if let Some(outcome) = buffered_outcome {
|
||||||
self.handle_aria2_event(&gid, outcome).await;
|
self.handle_aria2_event(&gid, outcome).await;
|
||||||
@@ -1450,6 +1484,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
id
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1594,6 +1629,16 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
/// and lets commands reconcile an Aria2 terminal status without releasing
|
/// and lets commands reconcile an Aria2 terminal status without releasing
|
||||||
/// the lock first.
|
/// the lock first.
|
||||||
pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) {
|
pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) {
|
||||||
|
if let Some(gid) = self.aria2_gid_for_download(id) {
|
||||||
|
if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await {
|
||||||
|
log::debug!(
|
||||||
|
"aria2 torrent ownership [{}]: could not resolve files for gid {}: {}",
|
||||||
|
id,
|
||||||
|
gid,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// A terminal event invalidates every delayed retry or control worker
|
// A terminal event invalidates every delayed retry or control worker
|
||||||
// from the previous lifecycle before releasing its permit.
|
// from the previous lifecycle before releasing its permit.
|
||||||
self.next_aria2_control_epoch(id).await;
|
self.next_aria2_control_epoch(id).await;
|
||||||
@@ -1609,11 +1654,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
PendingOutcome::Error(error) => {
|
PendingOutcome::Error(error) => {
|
||||||
if error.to_ascii_lowercase().contains("checksum") {
|
if error.to_ascii_lowercase().contains("checksum") {
|
||||||
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
||||||
if let Ok(primary_path) =
|
if let Ok(paths) =
|
||||||
crate::download_ownership::primary_path_for_id(&self.app_handle, id)
|
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
|
||||||
{
|
{
|
||||||
if let Some(path) = primary_path.as_deref() {
|
for path in paths {
|
||||||
let _ = crate::remove_download_assets(path, &self.app_handle).await;
|
let _ = crate::remove_download_assets(&path, &self.app_handle).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1741,6 +1786,160 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
|
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn begin_aria2_dispatch(&self, id: &str, epoch: u64) {
|
||||||
|
self.aria2_dispatch_inflight
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.entry(id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(epoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_aria2_dispatch(&self, id: &str, epoch: u64) {
|
||||||
|
let mut inflight = self.aria2_dispatch_inflight.lock().await;
|
||||||
|
let removed = if let Some(epochs) = inflight.get_mut(id) {
|
||||||
|
let removed = epochs.remove(&epoch);
|
||||||
|
if epochs.is_empty() {
|
||||||
|
inflight.remove(id);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
drop(inflight);
|
||||||
|
if removed {
|
||||||
|
self.aria2_dispatch_notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_for_aria2_dispatch(&self, id: &str) {
|
||||||
|
loop {
|
||||||
|
let notified = self.aria2_dispatch_notify.notified();
|
||||||
|
tokio::pin!(notified);
|
||||||
|
notified.as_mut().enable();
|
||||||
|
if !self
|
||||||
|
.aria2_dispatch_inflight
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.contains_key(id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_aria2_torrent_ownership_for_payload(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
gid: &str,
|
||||||
|
payload: &SpawnPayload,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let state = self.app_handle.state::<crate::AppState>();
|
||||||
|
let result = crate::rpc_call(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
"aria2.getFiles",
|
||||||
|
serde_json::json!([gid]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.persist_aria2_torrent_ownership(id, payload, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_aria2_torrent_ownership(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
payload: &SpawnPayload,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let paths = result
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|file| file.get("selected").and_then(|value| value.as_str()) != Some("false"))
|
||||||
|
.filter_map(|file| file.get("path").and_then(|value| value.as_str()))
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("aria2 returned no selected torrent output paths".to_string());
|
||||||
|
}
|
||||||
|
let destination = crate::resolve_path(&payload.destination, &self.app_handle);
|
||||||
|
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
|
||||||
|
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
|
||||||
|
if paths.iter().any(|path| {
|
||||||
|
crate::canonicalize_with_missing_components(path)
|
||||||
|
.is_none_or(|path| !crate::platform::path_is_within(&path, &canonical_destination))
|
||||||
|
}) {
|
||||||
|
return Err("aria2 returned a torrent output path outside its destination".to_string());
|
||||||
|
}
|
||||||
|
let primary = if paths.len() == 1 {
|
||||||
|
paths[0].clone()
|
||||||
|
} else {
|
||||||
|
let canonical_paths = paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| crate::canonicalize_with_missing_components(path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut common = canonical_paths
|
||||||
|
.first()
|
||||||
|
.and_then(|path| path.parent())
|
||||||
|
.map(std::path::Path::to_path_buf)
|
||||||
|
.ok_or_else(|| "torrent output path has no parent".to_string())?;
|
||||||
|
for path in canonical_paths.iter().skip(1) {
|
||||||
|
while !crate::platform::path_is_within(path, &common) {
|
||||||
|
let Some(parent) = common.parent() else {
|
||||||
|
return Err("torrent output paths have no common directory".to_string());
|
||||||
|
};
|
||||||
|
if parent == common {
|
||||||
|
return Err("torrent output paths have no common directory".to_string());
|
||||||
|
}
|
||||||
|
common = parent.to_path_buf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
common
|
||||||
|
};
|
||||||
|
crate::download_ownership::set_owned_paths_with_primary(
|
||||||
|
&self.app_handle,
|
||||||
|
id,
|
||||||
|
&primary,
|
||||||
|
&paths,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh ownership from Aria2's resolved file list before a torrent
|
||||||
|
/// lifecycle is forgotten. Magnet metadata is not available when the row
|
||||||
|
/// is enqueued, so the user-provided display name is not a safe ownership
|
||||||
|
/// path until Aria2 reports the actual files.
|
||||||
|
pub async fn reconcile_aria2_torrent_ownership(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
gid: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = self.aria2_payloads.lock().await.get(id).cloned();
|
||||||
|
let Some(payload) = payload.filter(|payload| payload.is_torrent) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mapping = self
|
||||||
|
.aria2_gid_mapping(gid)
|
||||||
|
.filter(|mapping| mapping.id == id)
|
||||||
|
.ok_or_else(|| "aria2 torrent ownership has no current gid mapping".to_string())?;
|
||||||
|
let state = self.app_handle.state::<crate::AppState>();
|
||||||
|
let result = crate::rpc_call(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
"aria2.getFiles",
|
||||||
|
serde_json::json!([gid]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !self.is_current_aria2_gid_mapping(gid, &mapping)
|
||||||
|
|| !self
|
||||||
|
.is_aria2_control_epoch_current(id, mapping.epoch)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err("aria2 torrent lifecycle changed while resolving output paths".to_string());
|
||||||
|
}
|
||||||
|
self.persist_aria2_torrent_ownership(id, &payload, result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Capture the GID ownership token used by the poller. The mapping's
|
/// Capture the GID ownership token used by the poller. The mapping's
|
||||||
/// epoch must still be current when an asynchronous status snapshot is
|
/// epoch must still be current when an asynchronous status snapshot is
|
||||||
/// emitted; otherwise a late response from an older GID can be attributed
|
/// emitted; otherwise a late response from an older GID can be attributed
|
||||||
@@ -2367,6 +2566,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn remove_from_pending_for_generation(&self, id: &str, generation: u64) -> bool {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
let before = pending.len();
|
||||||
|
pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation));
|
||||||
|
let removed = pending.len() < before;
|
||||||
|
if removed {
|
||||||
|
self.notify.notify_one();
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
|
||||||
@@ -2490,7 +2700,7 @@ fn proxy_scheme(proxy: &str) -> Option<String> {
|
|||||||
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
|
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
|
pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
|
||||||
let proxy = proxy.trim();
|
let proxy = proxy.trim();
|
||||||
if proxy.is_empty() {
|
if proxy.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -2666,9 +2876,10 @@ impl ProductionSpawner {
|
|||||||
Self { app_handle }
|
Self { app_handle }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_uri_rpc(
|
async fn add_transfer_rpc(
|
||||||
&self,
|
&self,
|
||||||
state: &crate::AppState,
|
state: &crate::AppState,
|
||||||
|
method: &str,
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
@@ -2676,7 +2887,7 @@ impl ProductionSpawner {
|
|||||||
match crate::rpc_call(
|
match crate::rpc_call(
|
||||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
&state.aria2_secret,
|
&state.aria2_secret,
|
||||||
"aria2.addUri",
|
method,
|
||||||
params.clone(),
|
params.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2714,7 +2925,9 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
);
|
);
|
||||||
let safe_filename =
|
let safe_filename =
|
||||||
crate::download_ownership::canonical_download_filename(&payload.filename);
|
crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
if !payload.is_torrent {
|
||||||
|
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||||
|
}
|
||||||
let conn = effective_aria2_connections(id, payload).await;
|
let conn = effective_aria2_connections(id, payload).await;
|
||||||
apply_aria2_connection_options(&mut options, conn);
|
apply_aria2_connection_options(&mut options, conn);
|
||||||
let mt = aria2_attempt_limit(payload.max_tries);
|
let mt = aria2_attempt_limit(payload.max_tries);
|
||||||
@@ -2766,23 +2979,73 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if let Some(prox) = proxy_value {
|
if let Some(prox) = proxy_value {
|
||||||
options.insert("all-proxy".to_string(), serde_json::json!(prox));
|
options.insert("all-proxy".to_string(), serde_json::json!(prox));
|
||||||
}
|
}
|
||||||
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
|
||||||
let params = serde_json::json!([uris, options]);
|
|
||||||
|
|
||||||
match self.add_uri_rpc(&state, ¶ms).await {
|
let (method, params) = if payload.is_torrent {
|
||||||
|
if let Some(path) = payload.torrent_path.as_deref() {
|
||||||
|
let path = crate::torrent::validate_managed_torrent_path(
|
||||||
|
&self.app_handle,
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
)?;
|
||||||
|
let bytes = tokio::fs::read(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
|
||||||
|
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
options.insert(
|
||||||
|
"index-out".to_string(),
|
||||||
|
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)),
|
||||||
|
);
|
||||||
|
let selected = crate::torrent::validate_selected_indices(
|
||||||
|
payload.torrent_file_indices.as_deref(),
|
||||||
|
metadata.files.len(),
|
||||||
|
)?;
|
||||||
|
if let Some(indices) = selected {
|
||||||
|
options.insert(
|
||||||
|
"select-file".to_string(),
|
||||||
|
serde_json::json!(indices.iter().map(u32::to_string).collect::<Vec<_>>().join(",")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
let uris = payload
|
||||||
|
.mirrors
|
||||||
|
.as_deref()
|
||||||
|
.map(|mirrors| crate::collect_download_uris("", Some(mirrors)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
("aria2.addTorrent", serde_json::json!([encoded, uris, options]))
|
||||||
|
} else {
|
||||||
|
let parsed = url::Url::parse(&payload.url)
|
||||||
|
.map_err(|_| "invalid magnet URI".to_string())?;
|
||||||
|
if parsed.scheme() != "magnet" {
|
||||||
|
return Err("torrent transfer has no magnet URI or cached metadata".to_string());
|
||||||
|
}
|
||||||
|
let selected = crate::torrent::validate_selected_indices(
|
||||||
|
payload.torrent_file_indices.as_deref(),
|
||||||
|
usize::MAX,
|
||||||
|
)?;
|
||||||
|
if selected.is_some() {
|
||||||
|
return Err("magnet file selection requires resolved torrent metadata".to_string());
|
||||||
|
}
|
||||||
|
("aria2.addUri", serde_json::json!([[payload.url.clone()], options]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
||||||
|
("aria2.addUri", serde_json::json!([uris, options]))
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.add_transfer_rpc(&state, method, ¶ms).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let gid = result.as_str().unwrap_or("").to_string();
|
let gid = result.as_str().unwrap_or("").to_string();
|
||||||
if gid.is_empty() {
|
if gid.is_empty() {
|
||||||
Err("aria2.addUri returned an empty gid".to_string())
|
Err(format!("{method} returned an empty gid"))
|
||||||
} else {
|
} else {
|
||||||
log::info!("aria2 addUri [{}]: created gid {}", id, gid);
|
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
|
||||||
Ok(gid)
|
Ok(gid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let safe_error = crate::redact_sensitive_text(&e);
|
let safe_error = crate::redact_sensitive_text(&e);
|
||||||
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
|
log::error!("aria2 {} [{}] failed: {}", method, id, safe_error);
|
||||||
Err(format!("aria2 addUri failed: {safe_error}"))
|
Err(format!("aria2 {method} failed: {safe_error}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2797,7 +3060,14 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
match result.as_str() {
|
match result.as_str() {
|
||||||
Some(returned_gid) if returned_gid == gid => Ok(()),
|
Some(returned_gid) if returned_gid == gid => {
|
||||||
|
crate::wait_for_aria2_stopped(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
gid,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
Some(returned_gid) => Err(format!(
|
Some(returned_gid) => Err(format!(
|
||||||
"aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}"
|
"aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}"
|
||||||
)),
|
)),
|
||||||
@@ -3108,6 +3378,18 @@ pub struct EnqueueItem {
|
|||||||
pub is_media: Option<bool>,
|
pub is_media: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub is_torrent: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_file_indices: Option<Vec<u32>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_info_hash: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
pub lifecycle_generation: Option<String>,
|
pub lifecycle_generation: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3147,6 +3429,9 @@ impl EnqueueItem {
|
|||||||
format_selector: self.format_selector,
|
format_selector: self.format_selector,
|
||||||
cookie_source: self.cookie_source,
|
cookie_source: self.cookie_source,
|
||||||
is_media: media,
|
is_media: media,
|
||||||
|
is_torrent: self.is_torrent.unwrap_or(false),
|
||||||
|
torrent_path: self.torrent_path,
|
||||||
|
torrent_file_indices: self.torrent_file_indices,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,784 @@
|
|||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::ipc::{TorrentFile, TorrentMetadata};
|
||||||
|
|
||||||
|
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParsedTorrent {
|
||||||
|
pub name: String,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub files: Vec<TorrentFile>,
|
||||||
|
pub info_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum BencodeValue {
|
||||||
|
Integer(i64),
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
List(Vec<BencodeValue>),
|
||||||
|
Dict(BTreeMap<Vec<u8>, BencodeValue>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Parser<'a> {
|
||||||
|
input: &'a [u8],
|
||||||
|
position: usize,
|
||||||
|
depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Parser<'a> {
|
||||||
|
fn new(input: &'a [u8]) -> Self {
|
||||||
|
Self { input, position: 0, depth: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(mut self) -> Result<BencodeValue, String> {
|
||||||
|
let value = self.parse_value()?;
|
||||||
|
if self.position != self.input.len() {
|
||||||
|
return Err("torrent metadata has trailing data".to_string());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_value(&mut self) -> Result<BencodeValue, String> {
|
||||||
|
if self.depth >= 128 {
|
||||||
|
return Err("torrent metadata nesting is too deep".to_string());
|
||||||
|
}
|
||||||
|
let byte = *self
|
||||||
|
.input
|
||||||
|
.get(self.position)
|
||||||
|
.ok_or_else(|| "torrent metadata is truncated".to_string())?;
|
||||||
|
match byte {
|
||||||
|
b'i' => self.parse_integer(),
|
||||||
|
b'l' => self.parse_list(),
|
||||||
|
b'd' => self.parse_dict(),
|
||||||
|
b'0'..=b'9' => self.parse_bytes(),
|
||||||
|
_ => Err("torrent metadata contains an invalid bencode value".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_integer(&mut self) -> Result<BencodeValue, String> {
|
||||||
|
self.position += 1;
|
||||||
|
let start = self.position;
|
||||||
|
while let Some(byte) = self.input.get(self.position) {
|
||||||
|
if *byte == b'e' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.position += 1;
|
||||||
|
}
|
||||||
|
if self.input.get(self.position) != Some(&b'e') {
|
||||||
|
return Err("torrent integer is truncated".to_string());
|
||||||
|
}
|
||||||
|
let raw = std::str::from_utf8(&self.input[start..self.position])
|
||||||
|
.map_err(|_| "torrent integer is not valid UTF-8".to_string())?;
|
||||||
|
if raw.is_empty() || (raw.starts_with('0') && raw.len() > 1) || raw == "-0" {
|
||||||
|
return Err("torrent integer has invalid encoding".to_string());
|
||||||
|
}
|
||||||
|
let value = raw
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| "torrent integer is outside the supported range".to_string())?;
|
||||||
|
self.position += 1;
|
||||||
|
Ok(BencodeValue::Integer(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bytes(&mut self) -> Result<BencodeValue, String> {
|
||||||
|
let start = self.position;
|
||||||
|
while let Some(byte) = self.input.get(self.position) {
|
||||||
|
if *byte == b':' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !byte.is_ascii_digit() {
|
||||||
|
return Err("torrent byte string has an invalid length".to_string());
|
||||||
|
}
|
||||||
|
self.position += 1;
|
||||||
|
}
|
||||||
|
if self.input.get(self.position) != Some(&b':') {
|
||||||
|
return Err("torrent byte string is truncated".to_string());
|
||||||
|
}
|
||||||
|
let length = std::str::from_utf8(&self.input[start..self.position])
|
||||||
|
.map_err(|_| "torrent byte string length is invalid".to_string())?
|
||||||
|
.to_owned();
|
||||||
|
if (length.starts_with('0') && length.len() > 1) || length.is_empty() {
|
||||||
|
return Err("torrent byte string has invalid length encoding".to_string());
|
||||||
|
}
|
||||||
|
let length = length
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| "torrent byte string length is too large".to_string())?;
|
||||||
|
self.position += 1;
|
||||||
|
let end = self
|
||||||
|
.position
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or_else(|| "torrent byte string length overflowed".to_string())?;
|
||||||
|
if end > self.input.len() {
|
||||||
|
return Err("torrent byte string is truncated".to_string());
|
||||||
|
}
|
||||||
|
let value = self.input[self.position..end].to_vec();
|
||||||
|
self.position = end;
|
||||||
|
Ok(BencodeValue::Bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_list(&mut self) -> Result<BencodeValue, String> {
|
||||||
|
self.position += 1;
|
||||||
|
self.depth += 1;
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while self.input.get(self.position) != Some(&b'e') {
|
||||||
|
if self.input.get(self.position).is_none() {
|
||||||
|
self.depth -= 1;
|
||||||
|
return Err("torrent list is truncated".to_string());
|
||||||
|
}
|
||||||
|
values.push(self.parse_value()?);
|
||||||
|
}
|
||||||
|
self.position += 1;
|
||||||
|
self.depth -= 1;
|
||||||
|
Ok(BencodeValue::List(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dict(&mut self) -> Result<BencodeValue, String> {
|
||||||
|
self.position += 1;
|
||||||
|
self.depth += 1;
|
||||||
|
let mut values = BTreeMap::new();
|
||||||
|
let mut previous_key: Option<Vec<u8>> = None;
|
||||||
|
while self.input.get(self.position) != Some(&b'e') {
|
||||||
|
if self.input.get(self.position).is_none() {
|
||||||
|
self.depth -= 1;
|
||||||
|
return Err("torrent dictionary is truncated".to_string());
|
||||||
|
}
|
||||||
|
let key = match self.parse_bytes()? {
|
||||||
|
BencodeValue::Bytes(key) => key,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if previous_key.as_ref().is_some_and(|previous| previous >= &key) {
|
||||||
|
self.depth -= 1;
|
||||||
|
return Err("torrent dictionary keys are not sorted".to_string());
|
||||||
|
}
|
||||||
|
previous_key = Some(key.clone());
|
||||||
|
values.insert(key, self.parse_value()?);
|
||||||
|
}
|
||||||
|
self.position += 1;
|
||||||
|
self.depth -= 1;
|
||||||
|
Ok(BencodeValue::Dict(values))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(value: &BencodeValue, output: &mut Vec<u8>) {
|
||||||
|
match value {
|
||||||
|
BencodeValue::Integer(value) => output.extend_from_slice(format!("i{value}e").as_bytes()),
|
||||||
|
BencodeValue::Bytes(value) => {
|
||||||
|
output.extend_from_slice(value.len().to_string().as_bytes());
|
||||||
|
output.push(b':');
|
||||||
|
output.extend_from_slice(value);
|
||||||
|
}
|
||||||
|
BencodeValue::List(values) => {
|
||||||
|
output.push(b'l');
|
||||||
|
for value in values {
|
||||||
|
encode(value, output);
|
||||||
|
}
|
||||||
|
output.push(b'e');
|
||||||
|
}
|
||||||
|
BencodeValue::Dict(values) => {
|
||||||
|
output.push(b'd');
|
||||||
|
for (key, value) in values {
|
||||||
|
encode(&BencodeValue::Bytes(key.clone()), output);
|
||||||
|
encode(value, output);
|
||||||
|
}
|
||||||
|
output.push(b'e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_text(value: Option<&BencodeValue>, field: &str) -> Result<String, String> {
|
||||||
|
let bytes = match value {
|
||||||
|
Some(BencodeValue::Bytes(bytes)) => bytes,
|
||||||
|
_ => return Err(format!("torrent metadata is missing {field}")),
|
||||||
|
};
|
||||||
|
String::from_utf8(bytes.clone()).map_err(|_| format!("torrent {field} is not valid UTF-8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive_length(value: Option<&BencodeValue>, field: &str) -> Result<u64, String> {
|
||||||
|
match value {
|
||||||
|
Some(BencodeValue::Integer(value)) if *value >= 0 => Ok(*value as u64),
|
||||||
|
_ => Err(format!("torrent {field} is invalid")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_path_component(value: &str, field: &str) -> Result<String, String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) {
|
||||||
|
return Err(format!("torrent {field} contains an unsafe path"));
|
||||||
|
}
|
||||||
|
Ok(crate::download_ownership::canonical_download_filename(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_info(info: &BencodeValue) -> Result<ParsedTorrent, String> {
|
||||||
|
let info_dict = match info {
|
||||||
|
BencodeValue::Dict(value) => value,
|
||||||
|
_ => return Err("torrent info dictionary is invalid".to_string()),
|
||||||
|
};
|
||||||
|
let name = info_dict
|
||||||
|
.get(b"name.utf-8".as_slice())
|
||||||
|
.or_else(|| info_dict.get(b"name".as_slice()))
|
||||||
|
.map(|value| bytes_text(Some(value), "name"))
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| "torrent metadata is missing name".to_string())?;
|
||||||
|
let name = crate::download_ownership::canonical_download_filename(&safe_path_component(&name, "name")?);
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let total_bytes = if let Some(files_value) = info_dict.get(b"files".as_slice()) {
|
||||||
|
let entries = match files_value {
|
||||||
|
BencodeValue::List(entries) => entries,
|
||||||
|
_ => return Err("torrent files field is invalid".to_string()),
|
||||||
|
};
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut paths = HashSet::new();
|
||||||
|
for (position, entry) in entries.iter().enumerate() {
|
||||||
|
let entry = match entry {
|
||||||
|
BencodeValue::Dict(value) => value,
|
||||||
|
_ => return Err(format!("torrent file entry {} is invalid", position + 1)),
|
||||||
|
};
|
||||||
|
let path_value = entry
|
||||||
|
.get(b"path.utf-8".as_slice())
|
||||||
|
.or_else(|| entry.get(b"path".as_slice()))
|
||||||
|
.ok_or_else(|| format!("torrent file entry {} has no path", position + 1))?;
|
||||||
|
let path_parts = match path_value {
|
||||||
|
BencodeValue::List(parts) => parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| bytes_text(Some(part), "file path"))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
_ => return Err(format!("torrent file entry {} path is invalid", position + 1)),
|
||||||
|
};
|
||||||
|
if path_parts.is_empty() {
|
||||||
|
return Err(format!("torrent file entry {} path is empty", position + 1));
|
||||||
|
}
|
||||||
|
let path = path_parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| safe_path_component(part, "file path"))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.join("/");
|
||||||
|
if !paths.insert(path.clone()) {
|
||||||
|
return Err("torrent contains duplicate output paths".to_string());
|
||||||
|
}
|
||||||
|
let length = positive_length(entry.get(b"length".as_slice()), "file length")?;
|
||||||
|
total = total
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or_else(|| "torrent total size is too large".to_string())?;
|
||||||
|
files.push(TorrentFile {
|
||||||
|
index: (position + 1) as u32,
|
||||||
|
path,
|
||||||
|
length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err("torrent has no files".to_string());
|
||||||
|
}
|
||||||
|
total
|
||||||
|
} else {
|
||||||
|
let length = positive_length(info_dict.get(b"length".as_slice()), "length")?;
|
||||||
|
files.push(TorrentFile {
|
||||||
|
index: 1,
|
||||||
|
path: name.clone(),
|
||||||
|
length,
|
||||||
|
});
|
||||||
|
length
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode(info, &mut encoded);
|
||||||
|
let digest = Sha1::digest(encoded);
|
||||||
|
let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
|
||||||
|
Ok(ParsedTorrent { name, total_bytes, files, info_hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_btih(value: &str) -> Option<String> {
|
||||||
|
let normalized = value.trim().to_ascii_lowercase();
|
||||||
|
if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return Some(normalized);
|
||||||
|
}
|
||||||
|
if normalized.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decoded = Vec::with_capacity(20);
|
||||||
|
let mut accumulator = 0u64;
|
||||||
|
let mut bits = 0u8;
|
||||||
|
for byte in normalized.bytes() {
|
||||||
|
let value = match byte {
|
||||||
|
b'a'..=b'z' => byte - b'a',
|
||||||
|
b'2'..=b'7' => byte - b'2' + 26,
|
||||||
|
_ => return None,
|
||||||
|
} as u64;
|
||||||
|
accumulator = (accumulator << 5) | value;
|
||||||
|
bits += 5;
|
||||||
|
while bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
decoded.push(((accumulator >> bits) & 0xff) as u8);
|
||||||
|
}
|
||||||
|
if bits == 0 {
|
||||||
|
accumulator = 0;
|
||||||
|
} else {
|
||||||
|
accumulator &= (1u64 << bits) - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits != 0 || decoded.len() != 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(decoded.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_info_hash(expected: Option<&str>, actual: &str) -> Result<(), String> {
|
||||||
|
let Some(expected) = expected else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let expected = canonical_btih(expected)
|
||||||
|
.ok_or_else(|| "torrent metadata has an invalid expected info hash".to_string())?;
|
||||||
|
if expected != actual {
|
||||||
|
return Err("torrent metadata changed before it was queued".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
|
return Err(format!("torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"));
|
||||||
|
}
|
||||||
|
let root = Parser::new(bytes).parse()?;
|
||||||
|
let root = match root {
|
||||||
|
BencodeValue::Dict(value) => value,
|
||||||
|
_ => return Err("torrent root is not a dictionary".to_string()),
|
||||||
|
};
|
||||||
|
let info = root
|
||||||
|
.get(b"info".as_slice())
|
||||||
|
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
||||||
|
parse_info(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
|
||||||
|
let parsed = url::Url::parse(source).map_err(|_| "invalid magnet URI".to_string())?;
|
||||||
|
if parsed.scheme() != "magnet" {
|
||||||
|
return Err("unsupported torrent source".to_string());
|
||||||
|
}
|
||||||
|
let info_hash = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find_map(|(key, value)| {
|
||||||
|
if key != "xt" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.strip_prefix("urn:btih:")?;
|
||||||
|
canonical_btih(value)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?;
|
||||||
|
let name = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find_map(|(key, value)| (key == "dn").then_some(value.into_owned()))
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| crate::download_ownership::canonical_download_filename(&value))
|
||||||
|
.unwrap_or_else(|| format!("torrent-{info_hash}"));
|
||||||
|
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_torrent_path(source: &str) -> Result<PathBuf, String> {
|
||||||
|
let path = match url::Url::parse(source) {
|
||||||
|
Ok(parsed) if parsed.scheme() == "file" => parsed
|
||||||
|
.to_file_path()
|
||||||
|
.map_err(|_| "invalid local torrent path".to_string())?,
|
||||||
|
_ => PathBuf::from(source),
|
||||||
|
};
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("torrent source is not an absolute local file".to_string());
|
||||||
|
}
|
||||||
|
let path = std::fs::canonicalize(&path)
|
||||||
|
.map_err(|error| format!("could not resolve torrent file: {error}"))?;
|
||||||
|
if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("torrent")) != Some(true) {
|
||||||
|
return Err("torrent files must use the .torrent extension".to_string());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect_source(source: &str) -> Result<ParsedTorrent, String> {
|
||||||
|
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
||||||
|
return magnet_metadata(source.trim());
|
||||||
|
}
|
||||||
|
let path = local_torrent_path(source)?;
|
||||||
|
let bytes = std::fs::read(&path).map_err(|error| format!("could not read torrent file: {error}"))?;
|
||||||
|
parse_torrent_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option<String>) -> TorrentMetadata {
|
||||||
|
TorrentMetadata {
|
||||||
|
name: parsed.name,
|
||||||
|
total_bytes: parsed.total_bytes,
|
||||||
|
files: parsed.files,
|
||||||
|
info_hash: parsed.info_hash,
|
||||||
|
torrent_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep
|
||||||
|
/// these values derived from the validated, canonical paths so the daemon's
|
||||||
|
/// actual files stay aligned with Firelink's ownership registry.
|
||||||
|
pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec<String> {
|
||||||
|
parsed
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| {
|
||||||
|
let output = if parsed.files.len() == 1 {
|
||||||
|
file.path.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", parsed.name, file.path)
|
||||||
|
};
|
||||||
|
format!("{}={output}", file.index)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec<String> {
|
||||||
|
parsed
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| selected.is_none_or(|indices| indices.contains(&file.index)))
|
||||||
|
.map(|file| {
|
||||||
|
if parsed.files.len() == 1 {
|
||||||
|
file.path.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", parsed.name, file.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_torrent_path<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') {
|
||||||
|
return Err("invalid torrent download id".to_string());
|
||||||
|
}
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
Ok(root.join(format!("{id}.torrent")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_torrent_storage_root<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let root = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("could not resolve torrent storage: {error}"))?
|
||||||
|
.join("torrents");
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_orphaned_probe_dirs<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
remove_orphaned_probe_dirs_at(&root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_orphaned_probe_dirs_at(root: &Path) -> Result<usize, String> {
|
||||||
|
let entries = match std::fs::read_dir(&root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||||
|
Err(error) => return Err(format!("could not inspect torrent probe storage: {error}")),
|
||||||
|
};
|
||||||
|
let mut removed = 0;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("could not inspect torrent probe storage: {error}"))?;
|
||||||
|
let is_probe = entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.starts_with(".probe-"));
|
||||||
|
if !is_probe || !entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| format!("could not inspect torrent probe entry: {error}"))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match std::fs::remove_dir_all(entry.path()) {
|
||||||
|
Ok(()) => removed += 1,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(format!("could not remove orphaned torrent probe: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_orphaned_cached_torrents<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
retained_ids: &HashSet<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
remove_orphaned_cached_torrents_at(&root, retained_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_orphaned_cached_torrents_at(
|
||||||
|
root: &Path,
|
||||||
|
retained_ids: &HashSet<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let entries = match std::fs::read_dir(&root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||||
|
Err(error) => return Err(format!("could not inspect torrent metadata storage: {error}")),
|
||||||
|
};
|
||||||
|
let mut removed = 0;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?;
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| format!("could not inspect torrent metadata entry: {error}"))?;
|
||||||
|
if !file_type.is_file() || entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if retained_ids.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match std::fs::remove_file(path) {
|
||||||
|
Ok(()) => removed += 1,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!("could not remove orphaned torrent metadata: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_selected_indices(
|
||||||
|
selected: Option<&[u32]>,
|
||||||
|
file_count: usize,
|
||||||
|
) -> Result<Option<Vec<u32>>, String> {
|
||||||
|
let Some(selected) = selected else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if selected.is_empty() || selected.iter().any(|index| *index == 0 || *index as usize > file_count) {
|
||||||
|
return Err("torrent file selection is invalid".to_string());
|
||||||
|
}
|
||||||
|
let mut normalized = selected.to_vec();
|
||||||
|
normalized.sort_unstable();
|
||||||
|
normalized.dedup();
|
||||||
|
Ok(Some(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prepare_local_torrent<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
source: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(ParsedTorrent, String), String> {
|
||||||
|
let source_path = local_torrent_path(source)?;
|
||||||
|
let file_metadata = tokio::fs::metadata(&source_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not inspect torrent file: {error}"))?;
|
||||||
|
if file_metadata.len() == 0 || file_metadata.len() > MAX_TORRENT_BYTES as u64 {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&source_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not read torrent file: {error}"))?;
|
||||||
|
let parsed = parse_torrent_bytes(&bytes)?;
|
||||||
|
let destination = cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||||
|
Ok((parsed, destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_torrent_bytes<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let destination = managed_torrent_path(app_handle, id)?;
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not create torrent storage: {error}"))?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(&destination, bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not cache torrent metadata: {error}"))?;
|
||||||
|
Ok(destination.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_managed_torrent_path<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let expected = managed_torrent_path(app_handle, id)?;
|
||||||
|
let candidate = std::fs::canonicalize(path)
|
||||||
|
.map_err(|error| format!("could not access cached torrent metadata: {error}"))?;
|
||||||
|
let expected_parent = expected
|
||||||
|
.parent()
|
||||||
|
.and_then(|parent| std::fs::canonicalize(parent).ok())
|
||||||
|
.ok_or_else(|| "cached torrent storage is unavailable".to_string())?;
|
||||||
|
if candidate.parent() != Some(expected_parent.as_path()) || candidate.file_name() != expected.file_name() {
|
||||||
|
return Err("cached torrent metadata path is invalid".to_string());
|
||||||
|
}
|
||||||
|
Ok(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_managed_torrent<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
) {
|
||||||
|
if let Ok(path) = managed_torrent_path(app_handle, id) {
|
||||||
|
let _ = tokio::fs::remove_file(path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_single_file_torrent_and_hashes_info_dictionary() {
|
||||||
|
let parsed = parse_torrent_bytes(b"d4:infod6:lengthi5e4:name4:testee")
|
||||||
|
.expect("single-file torrent should parse");
|
||||||
|
assert_eq!(parsed.name, "test");
|
||||||
|
assert_eq!(parsed.total_bytes, 5);
|
||||||
|
assert_eq!(parsed.files[0].index, 1);
|
||||||
|
assert_eq!(parsed.files[0].path, "test");
|
||||||
|
assert_eq!(parsed.info_hash.len(), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_multi_file_torrent_and_rejects_traversal() {
|
||||||
|
let parsed = parse_torrent_bytes(
|
||||||
|
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
|
||||||
|
)
|
||||||
|
.expect("multi-file torrent should parse");
|
||||||
|
assert_eq!(parsed.total_bytes, 5);
|
||||||
|
assert_eq!(parsed.files.len(), 2);
|
||||||
|
assert_eq!(parsed.files[1].path, "root/b.bin");
|
||||||
|
|
||||||
|
let error = parse_torrent_bytes(
|
||||||
|
b"d4:infod5:filesld6:lengthi1e4:pathl2:..4:evileee4:name4:rootee",
|
||||||
|
)
|
||||||
|
.expect_err("path traversal must be rejected");
|
||||||
|
assert!(error.contains("unsafe path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_magnet_identity_without_logging_or_persisting_tracker_data() {
|
||||||
|
let parsed = inspect_source(
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret",
|
||||||
|
)
|
||||||
|
.expect("magnet should parse");
|
||||||
|
assert_eq!(parsed.name, "Example Torrent");
|
||||||
|
assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567");
|
||||||
|
assert!(parsed.files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalizes_base32_magnet_hashes_to_hex() {
|
||||||
|
let parsed = inspect_source(
|
||||||
|
"magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=Base32",
|
||||||
|
)
|
||||||
|
.expect("a valid Base32 hash should parse");
|
||||||
|
assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_expected_hashes_across_hex_and_base32_encodings() {
|
||||||
|
validate_info_hash(
|
||||||
|
Some("AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH"),
|
||||||
|
"0123456789abcdef0123456789abcdef01234567",
|
||||||
|
)
|
||||||
|
.expect("equivalent Base32 and hexadecimal hashes should match");
|
||||||
|
assert!(validate_info_hash(
|
||||||
|
Some("0123456789abcdef0123456789abcdef01234567"),
|
||||||
|
"fedcba9876543210fedcba9876543210fedcba98",
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
validate_info_hash(None, "not-used").expect("missing legacy identity should remain compatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_base32_magnet_hashes() {
|
||||||
|
let error = inspect_source(
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcde!&dn=Invalid",
|
||||||
|
)
|
||||||
|
.expect_err("a 32-character hash with non-base32 characters must be rejected");
|
||||||
|
assert!(error.contains("valid BitTorrent info hash"));
|
||||||
|
|
||||||
|
let error = inspect_source(
|
||||||
|
"magnet:?xt=urn:btih:00000000000000000000000000000000&dn=Invalid",
|
||||||
|
)
|
||||||
|
.expect_err("characters outside RFC 4648 Base32 must be rejected");
|
||||||
|
assert!(error.contains("valid BitTorrent info hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_and_bounds_selected_file_indices() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_selected_indices(Some(&[3, 1, 3]), 3).unwrap(),
|
||||||
|
Some(vec![1, 3])
|
||||||
|
);
|
||||||
|
assert!(validate_selected_indices(Some(&[0]), 3).is_err());
|
||||||
|
assert!(validate_selected_indices(Some(&[4]), 3).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_validated_torrent_files_to_aria2_index_outputs() {
|
||||||
|
let parsed = parse_torrent_bytes(
|
||||||
|
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
|
||||||
|
)
|
||||||
|
.expect("multi-file torrent should parse");
|
||||||
|
assert_eq!(
|
||||||
|
aria2_index_outputs(&parsed),
|
||||||
|
vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_noncanonical_lengths_and_invalid_files_field() {
|
||||||
|
assert!(parse_torrent_bytes(b"d4:infod6:lengthi5e4:name04:testee").is_err());
|
||||||
|
assert!(parse_torrent_bytes(b"d4:infod5:filesi1e6:lengthi5e4:name4:testee").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_only_orphaned_probe_directories() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary torrent storage should exist");
|
||||||
|
let root = temporary.path();
|
||||||
|
std::fs::create_dir(root.join(".probe-stale")).expect("probe directory should exist");
|
||||||
|
std::fs::write(root.join(".probe-file"), b"not a directory")
|
||||||
|
.expect("probe marker file should exist");
|
||||||
|
std::fs::create_dir(root.join("retained-dir")).expect("unrelated directory should exist");
|
||||||
|
|
||||||
|
assert_eq!(remove_orphaned_probe_dirs_at(root).unwrap(), 1);
|
||||||
|
assert!(!root.join(".probe-stale").exists());
|
||||||
|
assert!(root.join(".probe-file").is_file());
|
||||||
|
assert!(root.join("retained-dir").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_unretained_torrent_files_but_preserves_retained_and_unrelated_entries() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary torrent storage should exist");
|
||||||
|
let root = temporary.path();
|
||||||
|
std::fs::write(root.join("keep-id.torrent"), b"retained")
|
||||||
|
.expect("retained metadata should exist");
|
||||||
|
std::fs::write(root.join("orphan-id.torrent"), b"orphan")
|
||||||
|
.expect("orphan metadata should exist");
|
||||||
|
std::fs::write(root.join("notes.txt"), b"unrelated")
|
||||||
|
.expect("unrelated file should exist");
|
||||||
|
let retained = HashSet::from(["keep-id".to_string()]);
|
||||||
|
|
||||||
|
assert_eq!(remove_orphaned_cached_torrents_at(root, &retained).unwrap(), 1);
|
||||||
|
assert!(root.join("keep-id.torrent").is_file());
|
||||||
|
assert!(!root.join("orphan-id.torrent").exists());
|
||||||
|
assert!(root.join("notes.txt").is_file());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const STOP_POLL_ATTEMPTS: usize = 30;
|
||||||
|
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
const CANCELLATION_CLEANUP_ATTEMPTS: usize = 3;
|
||||||
|
const CANCELLATION_CLEANUP_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub(crate) trait RpcClient: Send + Sync {
|
||||||
|
async fn call(&self, method: &str, params: Value) -> Result<Value, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ProbeFailure {
|
||||||
|
Metadata(String),
|
||||||
|
Cleanup(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_metadata_probe<C: RpcClient + 'static>(
|
||||||
|
client: Arc<C>,
|
||||||
|
source: &str,
|
||||||
|
options: Map<String, Value>,
|
||||||
|
metadata_path: &Path,
|
||||||
|
timeout: Duration,
|
||||||
|
poll_interval: Duration,
|
||||||
|
) -> Result<Vec<u8>, ProbeFailure> {
|
||||||
|
let mut cleanup_guard = ProbeCleanupGuard::new(Arc::clone(&client), metadata_path);
|
||||||
|
let result = match client
|
||||||
|
.call("aria2.addUri", json!([[source], options]))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
cleanup_guard.disarm();
|
||||||
|
return Err(ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 could not start magnet metadata resolution: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let gid = match result.as_str().filter(|value| !value.is_empty()) {
|
||||||
|
Some(gid) => gid.to_string(),
|
||||||
|
None => {
|
||||||
|
cleanup_guard.disarm();
|
||||||
|
return Err(ProbeFailure::Metadata(
|
||||||
|
"Aria2 returned an empty metadata probe GID".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanup_guard.set_gid(gid.clone());
|
||||||
|
|
||||||
|
let metadata_result = async {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let status = match client
|
||||||
|
.call(
|
||||||
|
"aria2.tellStatus",
|
||||||
|
json!([&gid, ["status", "errorCode", "errorMessage"]]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(error) if crate::aria2_gid_not_found(&error) => {
|
||||||
|
return Err(ProbeFailure::Metadata(
|
||||||
|
"Aria2 removed the magnet metadata probe before metadata was saved"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) if crate::retry::is_transient_network_error(&error) => {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 metadata resolution status failed: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 metadata resolution status failed: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match status.get("status").and_then(Value::as_str) {
|
||||||
|
Some("complete") => break,
|
||||||
|
Some("error") | Some("removed") => {
|
||||||
|
let error_code = status
|
||||||
|
.get("errorCode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let error_message = status
|
||||||
|
.get("errorMessage")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("metadata probe ended without a torrent file");
|
||||||
|
let detail = match error_code {
|
||||||
|
Some(code) => format!("aria2 error code {code}: {error_message}"),
|
||||||
|
None => error_message.to_string(),
|
||||||
|
};
|
||||||
|
return Err(ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 could not resolve magnet metadata: {}",
|
||||||
|
crate::redact_sensitive_text(&detail)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Some("active") | Some("waiting") | Some("paused") => {}
|
||||||
|
None => {
|
||||||
|
return Err(ProbeFailure::Metadata(
|
||||||
|
"Aria2 returned an invalid metadata probe status".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(status) => {
|
||||||
|
return Err(ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 returned an unsupported metadata probe status: {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(ProbeFailure::Metadata(
|
||||||
|
"Aria2 magnet metadata resolution timed out".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::read(metadata_path).await.map_err(|error| {
|
||||||
|
ProbeFailure::Metadata(format!(
|
||||||
|
"Aria2 did not save magnet metadata ({:?})",
|
||||||
|
error.kind()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let cleanup_result = cleanup_metadata_probe(client.as_ref(), &gid).await;
|
||||||
|
if let Err(error) = cleanup_result {
|
||||||
|
return Err(ProbeFailure::Cleanup(error));
|
||||||
|
}
|
||||||
|
cleanup_guard.disarm();
|
||||||
|
metadata_result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProbeCleanupGuard<C: RpcClient + 'static> {
|
||||||
|
client: Arc<C>,
|
||||||
|
gid: Option<String>,
|
||||||
|
probe_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: RpcClient + 'static> ProbeCleanupGuard<C> {
|
||||||
|
fn new(client: Arc<C>, metadata_path: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
gid: None,
|
||||||
|
probe_dir: metadata_path
|
||||||
|
.parent()
|
||||||
|
.filter(|path| !path.as_os_str().is_empty())
|
||||||
|
.map(Path::to_path_buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_gid(&mut self, gid: String) {
|
||||||
|
self.gid = Some(gid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.gid = None;
|
||||||
|
self.probe_dir = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: RpcClient + 'static> Drop for ProbeCleanupGuard<C> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let gid = self.gid.take();
|
||||||
|
let probe_dir = self.probe_dir.take();
|
||||||
|
if gid.is_none() && probe_dir.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(runtime) = tokio::runtime::Handle::try_current().ok() else {
|
||||||
|
log::warn!("magnet metadata probe was canceled without an active Tokio runtime");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let client = Arc::clone(&self.client);
|
||||||
|
runtime.spawn(async move {
|
||||||
|
let cleanup_result = if let Some(gid) = gid {
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 0..CANCELLATION_CLEANUP_ATTEMPTS {
|
||||||
|
match cleanup_metadata_probe(client.as_ref(), &gid).await {
|
||||||
|
Ok(()) => {
|
||||||
|
last_error = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(error);
|
||||||
|
if attempt + 1 < CANCELLATION_CLEANUP_ATTEMPTS {
|
||||||
|
tokio::time::sleep(CANCELLATION_CLEANUP_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_error
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(error) = cleanup_result {
|
||||||
|
log::warn!(
|
||||||
|
"canceled magnet metadata probe cleanup failed: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
);
|
||||||
|
// Do not delete a directory while Aria2 may still own the
|
||||||
|
// GID. The startup reaper removes this orphan before the
|
||||||
|
// next daemon launch.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(probe_dir) = probe_dir {
|
||||||
|
if let Err(error) = tokio::fs::remove_dir_all(&probe_dir).await {
|
||||||
|
if error.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
log::warn!(
|
||||||
|
"canceled magnet metadata probe directory cleanup failed ({:?})",
|
||||||
|
error.kind()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_metadata_probe<C: RpcClient>(client: &C, gid: &str) -> Result<(), String> {
|
||||||
|
match client.call("aria2.forceRemove", json!([gid])).await {
|
||||||
|
Ok(result) => {
|
||||||
|
crate::ensure_aria2_gid_result("forceRemove", gid, &result)?;
|
||||||
|
wait_for_stopped(client, gid).await
|
||||||
|
}
|
||||||
|
Err(error) if crate::aria2_gid_not_found(&error) => Ok(()),
|
||||||
|
Err(error) => match aria2_status(client, gid).await {
|
||||||
|
Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => Ok(()),
|
||||||
|
Err(status_error) if crate::aria2_gid_not_found(&status_error) => Ok(()),
|
||||||
|
_ => Err(format!(
|
||||||
|
"failed to remove aria2 gid {gid}: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn aria2_status<C: RpcClient>(client: &C, gid: &str) -> Result<String, String> {
|
||||||
|
let result = client
|
||||||
|
.call("aria2.tellStatus", json!([gid, ["status"]]))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to query aria2 gid {gid}: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
result
|
||||||
|
.get("status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_stopped<C: RpcClient>(client: &C, gid: &str) -> Result<(), String> {
|
||||||
|
let mut last_transient_error = None;
|
||||||
|
for _ in 0..STOP_POLL_ATTEMPTS {
|
||||||
|
match aria2_status(client, gid).await {
|
||||||
|
Ok(status)
|
||||||
|
if matches!(status.as_str(), "paused" | "complete" | "error" | "removed") =>
|
||||||
|
{
|
||||||
|
return Ok(())
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if crate::aria2_gid_not_found(&error) => return Ok(()),
|
||||||
|
Err(error) if crate::retry::is_transient_network_error(&error) => {
|
||||||
|
last_transient_error = Some(error);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(STOP_POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
match last_transient_error {
|
||||||
|
Some(error) => Err(format!(
|
||||||
|
"aria2 gid {gid} did not stop within 3 seconds after forceRemove: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)),
|
||||||
|
None => Err(format!(
|
||||||
|
"aria2 gid {gid} did not stop within 3 seconds after forceRemove"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
struct FakeRpc {
|
||||||
|
statuses: Mutex<VecDeque<Result<Value, String>>>,
|
||||||
|
force_remove: Mutex<Result<Value, String>>,
|
||||||
|
removed: AtomicBool,
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
call_notification: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeRpc {
|
||||||
|
fn new(
|
||||||
|
statuses: impl IntoIterator<Item = Result<Value, String>>,
|
||||||
|
force_remove: Result<Value, String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
statuses: Mutex::new(statuses.into_iter().collect()),
|
||||||
|
force_remove: Mutex::new(force_remove),
|
||||||
|
removed: AtomicBool::new(false),
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
call_notification: tokio::sync::Notify::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(name: &str) -> Result<Value, String> {
|
||||||
|
Ok(json!({ "status": name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_names(&self) -> Vec<String> {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.expect("call log lock should work")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_call(&self, method: &str) {
|
||||||
|
loop {
|
||||||
|
if self.call_names().iter().any(|call| call == method) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.call_notification.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RpcClient for FakeRpc {
|
||||||
|
async fn call(&self, method: &str, _params: Value) -> Result<Value, String> {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.expect("call log lock should work")
|
||||||
|
.push(method.to_string());
|
||||||
|
self.call_notification.notify_one();
|
||||||
|
match method {
|
||||||
|
"aria2.addUri" => Ok(json!("gid-1")),
|
||||||
|
"aria2.tellStatus" => {
|
||||||
|
if let Some(status) = self
|
||||||
|
.statuses
|
||||||
|
.lock()
|
||||||
|
.expect("status queue lock should work")
|
||||||
|
.pop_front()
|
||||||
|
{
|
||||||
|
status
|
||||||
|
} else if self.removed.load(Ordering::Acquire) {
|
||||||
|
Self::status("removed")
|
||||||
|
} else {
|
||||||
|
Self::status("active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"aria2.forceRemove" => {
|
||||||
|
let result = self
|
||||||
|
.force_remove
|
||||||
|
.lock()
|
||||||
|
.expect("forceRemove lock should work");
|
||||||
|
if result.is_ok() {
|
||||||
|
self.removed.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
match &*result {
|
||||||
|
Ok(value) => Ok(value.clone()),
|
||||||
|
Err(error) => Err(error.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(format!("unexpected RPC method {method}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_fake_probe(
|
||||||
|
rpc: Arc<FakeRpc>,
|
||||||
|
timeout: Duration,
|
||||||
|
poll_interval: Duration,
|
||||||
|
) -> Result<Vec<u8>, ProbeFailure> {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
||||||
|
let metadata_path = temporary.path().join("metadata.torrent");
|
||||||
|
tokio::fs::write(&metadata_path, b"torrent metadata")
|
||||||
|
.await
|
||||||
|
.expect("metadata fixture should be writable");
|
||||||
|
run_metadata_probe(
|
||||||
|
rpc,
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
||||||
|
Map::new(),
|
||||||
|
&metadata_path,
|
||||||
|
timeout,
|
||||||
|
poll_interval,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn resolves_active_waiting_complete_and_cleans_the_probe() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[
|
||||||
|
FakeRpc::status("active"),
|
||||||
|
FakeRpc::status("waiting"),
|
||||||
|
FakeRpc::status("complete"),
|
||||||
|
FakeRpc::status("removed"),
|
||||||
|
],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect("complete probe should return metadata");
|
||||||
|
assert_eq!(bytes, b"torrent metadata");
|
||||||
|
assert_eq!(
|
||||||
|
rpc.call_names(),
|
||||||
|
vec![
|
||||||
|
"aria2.addUri",
|
||||||
|
"aria2.tellStatus",
|
||||||
|
"aria2.tellStatus",
|
||||||
|
"aria2.tellStatus",
|
||||||
|
"aria2.forceRemove",
|
||||||
|
"aria2.tellStatus",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn returns_metadata_error_for_failed_status_and_cleans_the_probe() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[FakeRpc::status("error"), FakeRpc::status("removed")],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect_err("error status should fail the probe");
|
||||||
|
assert!(
|
||||||
|
matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn retries_transient_status_errors_during_metadata_polling() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[
|
||||||
|
Err("connection reset by peer".to_string()),
|
||||||
|
FakeRpc::status("complete"),
|
||||||
|
FakeRpc::status("removed"),
|
||||||
|
],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect("a transient status error should be retried");
|
||||||
|
assert_eq!(bytes, b"torrent metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn rejects_a_malformed_status_response_instead_of_waiting_for_timeout() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new([Ok(json!({}))], Ok(json!("gid-1"))));
|
||||||
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect_err("a missing status field should fail immediately");
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
ProbeFailure::Metadata(message)
|
||||||
|
if message.contains("invalid metadata probe status")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn does_not_expose_metadata_path_when_reading_fails() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
||||||
|
let metadata_path = temporary.path().join("missing-metadata.torrent");
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[FakeRpc::status("complete"), FakeRpc::status("removed")],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
let error = run_metadata_probe(
|
||||||
|
Arc::clone(&rpc),
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
||||||
|
Map::new(),
|
||||||
|
&metadata_path,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("missing metadata should fail");
|
||||||
|
let ProbeFailure::Metadata(message) = error else {
|
||||||
|
panic!("metadata read failure should remain a metadata error");
|
||||||
|
};
|
||||||
|
assert!(!message.contains(&*temporary.path().to_string_lossy()));
|
||||||
|
assert!(message.contains("NotFound"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn redacts_status_rpc_errors_during_cleanup() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[
|
||||||
|
FakeRpc::status("complete"),
|
||||||
|
Err("token=super-secret-value".to_string()),
|
||||||
|
],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect_err("cleanup status failure should be reported");
|
||||||
|
let ProbeFailure::Cleanup(message) = error else {
|
||||||
|
panic!("cleanup status failure should remain a cleanup error");
|
||||||
|
};
|
||||||
|
assert!(!message.contains("super-secret-value"));
|
||||||
|
assert!(message.contains("[redacted]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn retries_transient_status_errors_after_force_remove() {
|
||||||
|
let rpc = Arc::new(FakeRpc::new(
|
||||||
|
[
|
||||||
|
FakeRpc::status("complete"),
|
||||||
|
Err("connection reset by peer".to_string()),
|
||||||
|
FakeRpc::status("removed"),
|
||||||
|
],
|
||||||
|
Ok(json!("gid-1")),
|
||||||
|
));
|
||||||
|
run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect("a transient cleanup status error should be retried");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn handles_missing_gid_and_timeout_without_leaking_cleanup() {
|
||||||
|
let missing = Arc::new(FakeRpc::new(
|
||||||
|
[
|
||||||
|
Err("aria2 gid gid-1 not found".to_string()),
|
||||||
|
Err("aria2 gid gid-1 not found".to_string()),
|
||||||
|
],
|
||||||
|
Err("temporary forceRemove transport failure".to_string()),
|
||||||
|
));
|
||||||
|
let missing_error =
|
||||||
|
run_fake_probe(Arc::clone(&missing), Duration::from_secs(1), Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect_err("missing gid should fail the probe");
|
||||||
|
assert!(
|
||||||
|
matches!(missing_error, ProbeFailure::Metadata(message) if message.contains("removed"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let timeout = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1"))));
|
||||||
|
let timeout_error = run_fake_probe(
|
||||||
|
Arc::clone(&timeout),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("active probe should time out");
|
||||||
|
assert!(
|
||||||
|
matches!(timeout_error, ProbeFailure::Metadata(message) if message.contains("timed out"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn accepts_force_remove_race_only_after_terminal_status() {
|
||||||
|
let terminal_race = Arc::new(FakeRpc::new(
|
||||||
|
[FakeRpc::status("error"), FakeRpc::status("complete")],
|
||||||
|
Err("temporary forceRemove transport failure".to_string()),
|
||||||
|
));
|
||||||
|
let error = run_fake_probe(
|
||||||
|
Arc::clone(&terminal_race),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("metadata error should still be returned");
|
||||||
|
assert!(
|
||||||
|
matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let active_race = Arc::new(FakeRpc::new(
|
||||||
|
[FakeRpc::status("error"), FakeRpc::status("active")],
|
||||||
|
Err("temporary forceRemove transport failure".to_string()),
|
||||||
|
));
|
||||||
|
let cleanup_error = run_fake_probe(
|
||||||
|
Arc::clone(&active_race),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("active race must report cleanup failure");
|
||||||
|
assert!(
|
||||||
|
matches!(cleanup_error, ProbeFailure::Cleanup(message) if message.contains("failed to remove"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn cancellation_still_removes_the_remote_gid_and_probe_directory() {
|
||||||
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
||||||
|
let metadata_path = temporary.path().join("metadata.torrent");
|
||||||
|
tokio::fs::write(&metadata_path, b"torrent metadata")
|
||||||
|
.await
|
||||||
|
.expect("metadata fixture should be writable");
|
||||||
|
let rpc = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1"))));
|
||||||
|
let probe = run_metadata_probe(
|
||||||
|
Arc::clone(&rpc),
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
||||||
|
Map::new(),
|
||||||
|
&metadata_path,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(20), probe)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"the active probe should be canceled while polling"
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
rpc.wait_for_call("aria2.forceRemove"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("cancellation cleanup should force-remove the GID");
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while temporary.path().exists() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cancellation cleanup should remove the probe directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,21 @@ cd src-tauri
|
|||||||
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
|
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
|
||||||
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
|
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the real local Torrent runtime smoke test against the host bundled Aria2
|
||||||
|
binary. It starts a local tracker and two Aria2 daemons, then covers magnet
|
||||||
|
metadata resolution, saved-metadata hash validation, selected-file output,
|
||||||
|
pause/resume, ownership reporting, and cancel/remove:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run smoke:torrent
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `node scripts/smoke-torrent.js --binary /path/to/aria2c` when validating a
|
||||||
|
packaged or target-specific Aria2 binary.
|
||||||
|
|
||||||
|
Run the deterministic unavailable-tracker and Aria2-daemon-exit checks with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run smoke:torrent:failure-paths
|
||||||
|
```
|
||||||
|
|||||||
@@ -2043,7 +2043,9 @@ async fn late_aria2_gid_after_cancellation_is_removed_without_leaking_permit() {
|
|||||||
manager.release_registered_id("late").await;
|
manager.release_registered_id("late").await;
|
||||||
manager.release_permit("late").await;
|
manager.release_permit("late").await;
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
timeout(Duration::from_secs(1), manager.wait_for_aria2_dispatch("late"))
|
||||||
|
.await
|
||||||
|
.expect("late dispatch must be fully removed before it is considered finished");
|
||||||
|
|
||||||
assert!(manager.aria2_gid_for_download("late").is_none());
|
assert!(manager.aria2_gid_for_download("late").is_none());
|
||||||
assert_eq!(manager.available_permits(), 1);
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
|
|
||||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
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, 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, 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, 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, lifecycle_generation?: string, };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type TorrentFile = { index: number, path: string, length: number, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { TorrentFile } from "./TorrentFile";
|
||||||
|
|
||||||
|
export type TorrentMetadata = { name: string, totalBytes: number, files: Array<TorrentFile>, infoHash: string, torrentPath?: string, };
|
||||||
@@ -160,11 +160,47 @@ export const AddDownloadsModal = () => {
|
|||||||
const [urls, setUrls] = useState('');
|
const [urls, setUrls] = useState('');
|
||||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||||
|
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
|
||||||
|
const addModalOpenRef = useRef(isAddModalOpen);
|
||||||
const metadataRequestsRef = useRef(new Set<string>());
|
const metadataRequestsRef = useRef(new Set<string>());
|
||||||
const playlistRequestsRef = useRef(new Set<string>());
|
const playlistRequestsRef = useRef(new Set<string>());
|
||||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||||
|
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
|
||||||
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
||||||
|
|
||||||
|
parsedItemsRef.current = parsedItems;
|
||||||
|
addModalOpenRef.current = isAddModalOpen;
|
||||||
|
|
||||||
|
const cleanupDraftTorrentCache = useCallback((ids?: Iterable<string>) => {
|
||||||
|
const idsToRemove = ids
|
||||||
|
? Array.from(ids)
|
||||||
|
: Array.from(cachedTorrentDraftIdsRef.current);
|
||||||
|
idsToRemove.forEach(id => cachedTorrentDraftIdsRef.current.delete(id));
|
||||||
|
idsToRemove.forEach(id => {
|
||||||
|
void invoke('remove_torrent_metadata', { id }).catch(error => {
|
||||||
|
console.warn('Failed to remove temporary torrent metadata:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAddModalOpen) cleanupDraftTorrentCache();
|
||||||
|
}, [cleanupDraftTorrentCache, isAddModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeDraftIds = new Set<string>();
|
||||||
|
for (const row of parsedItems) {
|
||||||
|
if (!row.isTorrent) continue;
|
||||||
|
activeDraftIds.add(row.torrentCacheId || row.id);
|
||||||
|
activeDraftIds.add(`${row.id}-${row.generation}`);
|
||||||
|
}
|
||||||
|
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
|
||||||
|
.filter(id => !activeDraftIds.has(id));
|
||||||
|
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
|
||||||
|
}, [cleanupDraftTorrentCache, parsedItems]);
|
||||||
|
|
||||||
|
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
|
||||||
|
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||||
const modalRef = useModalFocus(isAddModalOpen);
|
const modalRef = useModalFocus(isAddModalOpen);
|
||||||
@@ -192,6 +228,28 @@ export const AddDownloadsModal = () => {
|
|||||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||||
const freeSpaceRequestRef = useRef(0);
|
const freeSpaceRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const addTorrentFiles = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: true,
|
||||||
|
directory: false,
|
||||||
|
title: 'Choose torrent files',
|
||||||
|
filters: [{ name: 'Torrent', extensions: ['torrent'] }]
|
||||||
|
});
|
||||||
|
const paths = Array.isArray(selected)
|
||||||
|
? selected
|
||||||
|
: selected
|
||||||
|
? [selected]
|
||||||
|
: [];
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
setUrls(current => [...current.split('\n').map(line => line.trim()).filter(Boolean), ...paths]
|
||||||
|
.filter((value, index, values) => values.indexOf(value) === index)
|
||||||
|
.join('\n'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select torrent files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [useAuth, setUseAuth] = useState(false);
|
const [useAuth, setUseAuth] = useState(false);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -493,10 +551,61 @@ export const AddDownloadsModal = () => {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const settingsStore = useSettingsStore.getState();
|
const settingsStore = useSettingsStore.getState();
|
||||||
const proxy = await getProxyArgs(settingsStore);
|
|
||||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||||
const contextUrl = requestContextUrlForRow(row);
|
const contextUrl = requestContextUrlForRow(row);
|
||||||
const requestContext = requestContextForUrl(contextUrl);
|
const requestContext = requestContextForUrl(contextUrl);
|
||||||
|
if (row.isTorrent) {
|
||||||
|
const torrentCacheId = row.torrentCacheId || `${row.id}-${row.generation}`;
|
||||||
|
const proxy = row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||||
|
? await getProxyArgs(settingsStore)
|
||||||
|
: undefined;
|
||||||
|
const torrentData = await invoke('inspect_torrent', {
|
||||||
|
source: row.sourceUrl,
|
||||||
|
id: torrentCacheId,
|
||||||
|
cache: true,
|
||||||
|
proxy: proxy ?? undefined
|
||||||
|
});
|
||||||
|
const isCurrentTorrentDraft = addModalOpenRef.current
|
||||||
|
&& parsedItemsRef.current.some(currentRow =>
|
||||||
|
currentRow.id === row.id
|
||||||
|
&& currentRow.sourceUrl === row.sourceUrl
|
||||||
|
&& currentRow.generation === row.generation
|
||||||
|
&& (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId
|
||||||
|
);
|
||||||
|
if (torrentData.torrentPath && isCurrentTorrentDraft) {
|
||||||
|
cachedTorrentDraftIdsRef.current.add(torrentCacheId);
|
||||||
|
} else if (torrentData.torrentPath) {
|
||||||
|
void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => {
|
||||||
|
console.warn('Failed to remove stale torrent metadata:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const totalBytes = torrentData.totalBytes || undefined;
|
||||||
|
setParsedItems(current => updateRowIfCurrent(
|
||||||
|
current,
|
||||||
|
row.id,
|
||||||
|
row.sourceUrl,
|
||||||
|
row.generation,
|
||||||
|
currentRow => ({
|
||||||
|
...currentRow,
|
||||||
|
downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||||
|
? 'torrent:' + torrentData.infoHash
|
||||||
|
: row.sourceUrl,
|
||||||
|
file: canonicalizeDownloadFileName(torrentData.name),
|
||||||
|
size: totalBytes ? formatBytes(totalBytes) : undefined,
|
||||||
|
sizeBytes: totalBytes,
|
||||||
|
status: 'ready',
|
||||||
|
isTorrent: true,
|
||||||
|
torrentPath: torrentData.torrentPath,
|
||||||
|
torrentCacheId,
|
||||||
|
torrentInfoHash: torrentData.infoHash,
|
||||||
|
torrentFiles: torrentData.files,
|
||||||
|
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
|
||||||
|
?.filter(index => torrentData.files.some(file => file.index === index))
|
||||||
|
})
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proxy = await getProxyArgs(settingsStore);
|
||||||
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
|
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
|
||||||
settingsStore.setShowKeychainModal(true);
|
settingsStore.setShowKeychainModal(true);
|
||||||
return;
|
return;
|
||||||
@@ -1184,15 +1293,19 @@ export const AddDownloadsModal = () => {
|
|||||||
if (!existingItem) {
|
if (!existingItem) {
|
||||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||||
}
|
}
|
||||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||||
const mediaFormatChanged = item.isMedia
|
const mediaFormatChanged = item.isMedia
|
||||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||||
if (existingItem.status === 'completed' || mediaFormatChanged) {
|
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||||
// Completed replacements must remove the old file so the
|
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||||
// new transfer cannot be treated as an already-complete
|
// Completed replacements must remove the old file so the
|
||||||
// aria2 target. Unfinished rows use the in-place path to
|
// new transfer cannot be treated as an already-complete
|
||||||
// preserve their resumable assets and progress.
|
// aria2 target. A torrent replacement also needs a fresh
|
||||||
await store.removeDownload(existingItem.id, true, false);
|
// identity because its cached metadata is keyed by the
|
||||||
|
// new row ID and its output contract differs from a normal
|
||||||
|
// file transfer. Unfinished ordinary rows use the in-place
|
||||||
|
// path to preserve their resumable assets and progress.
|
||||||
|
await store.removeDownload(existingItem.id, true, false);
|
||||||
} else {
|
} else {
|
||||||
const contextUrl = requestContextUrlForRow(item);
|
const contextUrl = requestContextUrlForRow(item);
|
||||||
const replaced = await store.replaceDownload(existingItem.id, {
|
const replaced = await store.replaceDownload(existingItem.id, {
|
||||||
@@ -1223,8 +1336,33 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
let allocatedId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
allocatedId = id;
|
||||||
|
let torrentPath = item.torrentPath;
|
||||||
|
if (item.isTorrent) {
|
||||||
|
if (item.torrentPath) {
|
||||||
|
torrentPath = await invoke('rekey_torrent_metadata', {
|
||||||
|
sourceId: item.torrentCacheId || item.id,
|
||||||
|
targetId: id
|
||||||
|
});
|
||||||
|
cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id);
|
||||||
|
} else {
|
||||||
|
// Keep a safe fallback for rows restored from an older draft
|
||||||
|
// shape that did not retain the preview cache identity.
|
||||||
|
const proxy = item.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||||
|
? await getProxyArgs(useSettingsStore.getState())
|
||||||
|
: undefined;
|
||||||
|
const torrentData = await invoke('inspect_torrent', {
|
||||||
|
source: item.sourceUrl,
|
||||||
|
id,
|
||||||
|
cache: true,
|
||||||
|
proxy: proxy ?? undefined
|
||||||
|
});
|
||||||
|
torrentPath = torrentData.torrentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
let finalFile = item.isMedia
|
let finalFile = item.isMedia
|
||||||
? mediaFileNameForSelectedFormat(item.file, item)
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
: canonicalizeDownloadFileName(item.file);
|
: canonicalizeDownloadFileName(item.file);
|
||||||
@@ -1260,6 +1398,10 @@ export const AddDownloadsModal = () => {
|
|||||||
resumable: item.resumable,
|
resumable: item.resumable,
|
||||||
mediaFormatSelector: formatSelector,
|
mediaFormatSelector: formatSelector,
|
||||||
mediaQuality: mediaQualityForRow(item),
|
mediaQuality: mediaQualityForRow(item),
|
||||||
|
isTorrent: item.isTorrent,
|
||||||
|
torrentPath,
|
||||||
|
torrentInfoHash: item.torrentInfoHash,
|
||||||
|
torrentFileIndices: item.selectedTorrentFileIndices,
|
||||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes
|
||||||
}, action);
|
}, action);
|
||||||
@@ -1268,6 +1410,11 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
addedCount += 1;
|
addedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (item.isTorrent && allocatedId) {
|
||||||
|
await invoke('remove_torrent_metadata', { id: allocatedId }).catch(error => {
|
||||||
|
console.warn('Failed to remove cached torrent metadata after add failure:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error("Invalid URL or failed to add:", e);
|
console.error("Invalid URL or failed to add:", e);
|
||||||
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
}
|
}
|
||||||
@@ -1369,6 +1516,23 @@ export const AddDownloadsModal = () => {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTorrentFile = (index: number) => {
|
||||||
|
if (selectedItemIndex === null) return;
|
||||||
|
setParsedItems(items => items.map((item, itemIndex) => {
|
||||||
|
if (itemIndex !== selectedItemIndex || !item.torrentFiles?.length) return item;
|
||||||
|
const allIndices = item.torrentFiles.map(file => file.index);
|
||||||
|
const selectedIndices = item.selectedTorrentFileIndices ?? allIndices;
|
||||||
|
if (selectedIndices.length === 1 && selectedIndices[0] === index) return item;
|
||||||
|
const next = selectedIndices.includes(index)
|
||||||
|
? selectedIndices.filter(value => value !== index)
|
||||||
|
: [...selectedIndices, index].sort((left, right) => left - right);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
selectedTorrentFileIndices: next.length === allIndices.length ? undefined : next
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||||
@@ -1635,6 +1799,15 @@ export const AddDownloadsModal = () => {
|
|||||||
value={urls}
|
value={urls}
|
||||||
onChange={(e) => setUrls(e.target.value)}
|
onChange={(e) => setUrls(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void addTorrentFiles()}
|
||||||
|
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
||||||
|
>
|
||||||
|
<FolderPlus size={12} /> {t($ => $.addDownloads.chooseTorrentFiles)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{playlistSummaries.map(([sourceUrl, playlist]) => {
|
{playlistSummaries.map(([sourceUrl, playlist]) => {
|
||||||
const total = playlist.entry_count || playlist.entries.length;
|
const total = playlist.entry_count || playlist.entries.length;
|
||||||
return (
|
return (
|
||||||
@@ -1766,6 +1939,46 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
|
||||||
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||||
|
<section className="add-download-section relative overflow-hidden p-4">
|
||||||
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
|
<FileText size={16} className="text-blue-500" /> {t($ => $.addDownloads.torrentFiles)}
|
||||||
|
</div>
|
||||||
|
{parsedItems[selectedItemIndex].torrentFiles?.length ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1 max-h-64 overflow-y-auto pe-1"
|
||||||
|
role="group"
|
||||||
|
aria-label={t($ => $.addDownloads.torrentFiles)}
|
||||||
|
>
|
||||||
|
{parsedItems[selectedItemIndex].torrentFiles!.map(file => {
|
||||||
|
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
|
||||||
|
const checked = !selectedIndices || selectedIndices.includes(file.index);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={file.index}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleTorrentFile(file.index)}
|
||||||
|
aria-label={file.path}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="truncate flex-1" title={file.path}>{file.path}</span>
|
||||||
|
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{t($ => $.addDownloads.torrentMetadataPending)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Media Format (Dynamic) */}
|
{/* Media Format (Dynamic) */}
|
||||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||||
|
|||||||
@@ -241,6 +241,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
{mediaQualityLabel}
|
{mediaQualityLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{download.isTorrent ? (
|
||||||
|
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrentFiles)}>
|
||||||
|
{t($ => $.addDownloads.torrentFiles)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1365,6 +1365,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||||
|
if (item.isTorrent) {
|
||||||
|
try {
|
||||||
|
const ownedPath = await invoke('get_download_primary_path', { id: item.id });
|
||||||
|
if (ownedPath) return ownedPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resolve torrent output path:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
const fileName = item.fileName?.trim();
|
const fileName = item.fileName?.trim();
|
||||||
if (!fileName) return null;
|
if (!fileName) return null;
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
@@ -1377,12 +1385,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const revealDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||||
|
const pathToReveal = await getDownloadPath(item);
|
||||||
|
|
||||||
|
if (!pathToReveal) {
|
||||||
|
openProperties(item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to show in Finder:", error);
|
||||||
|
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||||
|
}
|
||||||
|
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||||
|
|
||||||
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||||
if (item.status !== 'completed') {
|
if (item.status !== 'completed') {
|
||||||
openProperties(item.id);
|
openProperties(item.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.isTorrent) {
|
||||||
|
await revealDownloadFile(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fullPath = await getDownloadPath(item);
|
const fullPath = await getDownloadPath(item);
|
||||||
if (!fullPath) {
|
if (!fullPath) {
|
||||||
openProperties(item.id);
|
openProperties(item.id);
|
||||||
@@ -1395,23 +1424,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
console.error("Failed to open file:", error);
|
console.error("Failed to open file:", error);
|
||||||
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
||||||
}
|
}
|
||||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
}, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]);
|
||||||
|
|
||||||
const revealDownloadFile = async (item: DownloadItem) => {
|
|
||||||
const pathToReveal = await getDownloadPath(item);
|
|
||||||
|
|
||||||
if (!pathToReveal) {
|
|
||||||
openProperties(item.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to show in Finder:", error);
|
|
||||||
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||||
if (item.status === 'completed') {
|
if (item.status === 'completed') {
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const common = {
|
|||||||
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
||||||
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
||||||
downloadLinks: 'Download Links',
|
downloadLinks: 'Download Links',
|
||||||
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
|
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, SFTP, or magnet URLs here... You can also choose .torrent files below.\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
|
||||||
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (safe entry limit reached)',
|
safeEntryLimit: ' (safe entry limit reached)',
|
||||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||||
@@ -454,6 +454,9 @@ const common = {
|
|||||||
selectAll: 'Select all',
|
selectAll: 'Select all',
|
||||||
refreshMetadata: 'Refresh Metadata',
|
refreshMetadata: 'Refresh Metadata',
|
||||||
files: 'Files',
|
files: 'Files',
|
||||||
|
torrentFiles: 'Torrent files',
|
||||||
|
chooseTorrentFiles: 'Add .torrent files',
|
||||||
|
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
|
||||||
required: 'Required',
|
required: 'Required',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const fa = {
|
|||||||
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
||||||
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
||||||
downloadLinks: 'پیوندهای دانلود',
|
downloadLinks: 'پیوندهای دانلود',
|
||||||
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جایگذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
pastePlaceholder: 'URLهای HTTP، HTTPS، FTP، SFTP یا magnet را اینجا جایگذاری کنید… همچنین میتوانید فایلهای .torrent را از پایین انتخاب کنید.\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
||||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||||
@@ -454,6 +454,9 @@ const fa = {
|
|||||||
selectAll: 'انتخاب همه',
|
selectAll: 'انتخاب همه',
|
||||||
refreshMetadata: 'تازهسازی متادیتا',
|
refreshMetadata: 'تازهسازی متادیتا',
|
||||||
files: 'فایلها',
|
files: 'فایلها',
|
||||||
|
torrentFiles: 'فایلهای تورنت',
|
||||||
|
chooseTorrentFiles: 'افزودن فایلهای .torrent',
|
||||||
|
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت میکند.',
|
||||||
required: 'الزامی',
|
required: 'الزامی',
|
||||||
free: 'فضای آزاد',
|
free: 'فضای آزاد',
|
||||||
preview: 'پیشنمایش',
|
preview: 'پیشنمایش',
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const he = {
|
|||||||
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
||||||
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
||||||
downloadLinks: 'קישורי הורדה',
|
downloadLinks: 'קישורי הורדה',
|
||||||
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
pastePlaceholder: 'הדבק כאן כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069, \u2066SFTP\u2069 או magnet… אפשר גם לבחור קובצי .torrent למטה.\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
||||||
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||||
@@ -454,6 +454,9 @@ const he = {
|
|||||||
selectAll: 'בחירת הכל',
|
selectAll: 'בחירת הכל',
|
||||||
refreshMetadata: 'רענון מטא נתונים',
|
refreshMetadata: 'רענון מטא נתונים',
|
||||||
files: 'קבצים',
|
files: 'קבצים',
|
||||||
|
torrentFiles: 'קובצי טורנט',
|
||||||
|
chooseTorrentFiles: 'הוספת קובצי .torrent',
|
||||||
|
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
|
||||||
required: 'נדרש',
|
required: 'נדרש',
|
||||||
free: 'פנוי',
|
free: 'פנוי',
|
||||||
preview: 'תצוגה מקדימה',
|
preview: 'תצוגה מקדימה',
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const ru = {
|
|||||||
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
|
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
|
||||||
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
|
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
|
||||||
downloadLinks: 'Ссылки для скачивания',
|
downloadLinks: 'Ссылки для скачивания',
|
||||||
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
|
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP, SFTP или magnet… Также можно выбрать файлы .torrent ниже.\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
|
||||||
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||||
@@ -454,6 +454,9 @@ const ru = {
|
|||||||
selectAll: 'Выбрать все',
|
selectAll: 'Выбрать все',
|
||||||
refreshMetadata: 'Обновить метаданные',
|
refreshMetadata: 'Обновить метаданные',
|
||||||
files: 'Файлы',
|
files: 'Файлы',
|
||||||
|
torrentFiles: 'Торрент-файлы',
|
||||||
|
chooseTorrentFiles: 'Добавить файлы .torrent',
|
||||||
|
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
|
||||||
required: 'Требуется',
|
required: 'Требуется',
|
||||||
free: 'Свободно',
|
free: 'Свободно',
|
||||||
preview: 'Предпросмотр',
|
preview: 'Предпросмотр',
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const uk = {
|
|||||||
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
|
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
|
||||||
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
|
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
|
||||||
downloadLinks: 'Посилання для завантаження',
|
downloadLinks: 'Посилання для завантаження',
|
||||||
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
|
pastePlaceholder: 'Вставте сюди URL-адреси HTTP, HTTPS, FTP, SFTP або magnet… Також можна вибрати файли .torrent нижче.\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
|
||||||
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||||
@@ -454,6 +454,9 @@ const uk = {
|
|||||||
selectAll: 'Вибрати всі',
|
selectAll: 'Вибрати всі',
|
||||||
refreshMetadata: 'Оновити метадані',
|
refreshMetadata: 'Оновити метадані',
|
||||||
files: 'Файли',
|
files: 'Файли',
|
||||||
|
torrentFiles: 'Торрент-файли',
|
||||||
|
chooseTorrentFiles: 'Додати файли .torrent',
|
||||||
|
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
|
||||||
required: 'Обов\'язково',
|
required: 'Обов\'язково',
|
||||||
free: 'Вільно',
|
free: 'Вільно',
|
||||||
preview: 'Попередній перегляд',
|
preview: 'Попередній перегляд',
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ const zhCN = {
|
|||||||
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
|
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
|
||||||
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
|
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
|
||||||
downloadLinks: '下载链接',
|
downloadLinks: '下载链接',
|
||||||
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP、SFTP 或 magnet URL…也可以在下方选择 .torrent 文件。\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
||||||
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
||||||
safeEntryLimit: ' (达到安全条目限制)',
|
safeEntryLimit: ' (达到安全条目限制)',
|
||||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||||
@@ -454,6 +454,9 @@ const zhCN = {
|
|||||||
selectAll: '全选',
|
selectAll: '全选',
|
||||||
refreshMetadata: '刷新元数据',
|
refreshMetadata: '刷新元数据',
|
||||||
files: '文件',
|
files: '文件',
|
||||||
|
torrentFiles: '种子文件',
|
||||||
|
chooseTorrentFiles: '添加 .torrent 文件',
|
||||||
|
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
|
||||||
required: '必需',
|
required: '必需',
|
||||||
free: '可用空间',
|
free: '可用空间',
|
||||||
preview: '预览',
|
preview: '预览',
|
||||||
|
|||||||
+14
@@ -18,6 +18,7 @@ import type { EnqueueItem } from './bindings/EnqueueItem';
|
|||||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
|
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||||
|
|
||||||
type CommandMap = {
|
type CommandMap = {
|
||||||
fetch_metadata: {
|
fetch_metadata: {
|
||||||
@@ -32,6 +33,18 @@ type CommandMap = {
|
|||||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||||
result: MediaPlaylistMetadata;
|
result: MediaPlaylistMetadata;
|
||||||
};
|
};
|
||||||
|
inspect_torrent: {
|
||||||
|
args: { source: string; id: string; cache?: boolean; proxy?: string };
|
||||||
|
result: TorrentMetadata;
|
||||||
|
};
|
||||||
|
rekey_torrent_metadata: {
|
||||||
|
args: { sourceId: string; targetId: string };
|
||||||
|
result: string;
|
||||||
|
};
|
||||||
|
remove_torrent_metadata: {
|
||||||
|
args: { id: string };
|
||||||
|
result: void;
|
||||||
|
};
|
||||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
@@ -41,6 +54,7 @@ type CommandMap = {
|
|||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||||
|
get_download_primary_path: { args: { id: string }; result: string | null };
|
||||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||||
begin_dock_badge_session: { args: undefined; result: number };
|
begin_dock_badge_session: { args: undefined; result: number };
|
||||||
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
||||||
|
|||||||
@@ -341,6 +341,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
format_selector: item.mediaFormatSelector || null,
|
format_selector: item.mediaFormatSelector || null,
|
||||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||||
is_media: item.isMedia || false,
|
is_media: item.isMedia || false,
|
||||||
|
is_torrent: item.isTorrent || false,
|
||||||
|
torrent_path: item.torrentPath || undefined,
|
||||||
|
torrent_file_indices: item.torrentFileIndices || undefined,
|
||||||
|
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||||
lifecycle_generation: lifecycleGeneration.toString(),
|
lifecycle_generation: lifecycleGeneration.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2040,6 +2044,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
format_selector: item.mediaFormatSelector || null,
|
format_selector: item.mediaFormatSelector || null,
|
||||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||||
is_media: item.isMedia || false,
|
is_media: item.isMedia || false,
|
||||||
|
is_torrent: item.isTorrent || false,
|
||||||
|
torrent_path: item.torrentPath || undefined,
|
||||||
|
torrent_file_indices: item.torrentFileIndices || undefined,
|
||||||
|
torrent_info_hash: item.torrentInfoHash || undefined,
|
||||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,60 @@ describe('add download metadata workflow', () => {
|
|||||||
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('admits magnets and local torrent files through the Add window metadata path', () => {
|
||||||
|
const rows = reconcileDownloadRows(
|
||||||
|
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example\nfile:///tmp/Example.torrent',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
isTorrent: true,
|
||||||
|
isMedia: false,
|
||||||
|
status: 'loading'
|
||||||
|
});
|
||||||
|
expect(rows[1]).toMatchObject({
|
||||||
|
isTorrent: true,
|
||||||
|
isMedia: false,
|
||||||
|
sourceUrl: 'file:///tmp/Example.torrent',
|
||||||
|
status: 'loading'
|
||||||
|
});
|
||||||
|
expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`);
|
||||||
|
expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives refreshed torrent metadata a new cache identity', () => {
|
||||||
|
const existing = row({
|
||||||
|
id: 'torrent-row',
|
||||||
|
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
downloadUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
isTorrent: true,
|
||||||
|
torrentCacheId: 'torrent-row-1',
|
||||||
|
torrentPath: '/managed/torrent-row-1.torrent',
|
||||||
|
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
generation: 1,
|
||||||
|
requestContextVersion: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = reconcileDownloadRows(
|
||||||
|
existing.sourceUrl,
|
||||||
|
[existing],
|
||||||
|
undefined,
|
||||||
|
new Set(),
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ [existing.sourceUrl]: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshed[0]).toMatchObject({
|
||||||
|
generation: 2,
|
||||||
|
torrentCacheId: 'torrent-row-2',
|
||||||
|
torrentPath: undefined,
|
||||||
|
torrentInfoHash: undefined,
|
||||||
|
torrentFiles: undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||||
const rows = reconcileDownloadRows(
|
const rows = reconcileDownloadRows(
|
||||||
'https://www.youtube.com/playlist?list=PL123',
|
'https://www.youtube.com/playlist?list=PL123',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isMediaUrl
|
isMediaUrl
|
||||||
} from './downloads';
|
} from './downloads';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||||
|
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { localePluralVariant } from '../i18n/locales';
|
import { localePluralVariant } from '../i18n/locales';
|
||||||
|
|
||||||
@@ -52,6 +53,12 @@ export interface AddDownloadDraftRow {
|
|||||||
playlistError?: string;
|
playlistError?: string;
|
||||||
metadataBlockedReason?: 'unsafe-url';
|
metadataBlockedReason?: 'unsafe-url';
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
isTorrent?: boolean;
|
||||||
|
torrentPath?: string;
|
||||||
|
torrentCacheId?: string;
|
||||||
|
torrentInfoHash?: string;
|
||||||
|
torrentFiles?: TorrentFile[];
|
||||||
|
selectedTorrentFileIndices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,12 +68,27 @@ export interface AddDownloadDraftRow {
|
|||||||
*/
|
*/
|
||||||
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
|
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
|
||||||
|
|
||||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:', 'magnet:']);
|
||||||
|
|
||||||
|
const isLocalTorrentPath = (value: string): boolean => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol === 'file:') {
|
||||||
|
return parsed.pathname.toLowerCase().endsWith('.torrent');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A native Windows path is not a URL, even though URL parsing may treat
|
||||||
|
// its drive letter as a scheme.
|
||||||
|
}
|
||||||
|
return value.toLowerCase().endsWith('.torrent')
|
||||||
|
&& (value.startsWith('/') || /^[a-z]:[\\/]/i.test(value));
|
||||||
|
};
|
||||||
|
|
||||||
type ParsedInput = {
|
type ParsedInput = {
|
||||||
identity: string;
|
identity: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
|
isTorrent?: boolean;
|
||||||
isPlaylist?: boolean;
|
isPlaylist?: boolean;
|
||||||
playlistSourceUrl?: string;
|
playlistSourceUrl?: string;
|
||||||
playlistTitle?: string;
|
playlistTitle?: string;
|
||||||
@@ -115,10 +137,18 @@ const parseInputLines = (
|
|||||||
|
|
||||||
let sourceUrl = line;
|
let sourceUrl = line;
|
||||||
let valid = false;
|
let valid = false;
|
||||||
|
let isTorrent = false;
|
||||||
|
if (isLocalTorrentPath(line)) {
|
||||||
|
valid = true;
|
||||||
|
isTorrent = true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(line);
|
if (!isTorrent) {
|
||||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
const url = new URL(line);
|
||||||
if (valid) sourceUrl = url.href;
|
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||||
|
isTorrent = valid && url.protocol === 'magnet:';
|
||||||
|
if (valid) sourceUrl = url.href;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
valid = false;
|
valid = false;
|
||||||
}
|
}
|
||||||
@@ -166,6 +196,7 @@ const parseInputLines = (
|
|||||||
identity,
|
identity,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
valid,
|
valid,
|
||||||
|
isTorrent,
|
||||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||||
@@ -207,6 +238,7 @@ export const reconcileDownloadRows = (
|
|||||||
|| preserved.playlistCount !== input.playlistCount
|
|| preserved.playlistCount !== input.playlistCount
|
||||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||||
|
const nextGeneration = preserved.generation + 1;
|
||||||
const requestedFilename = input.playlistSourceUrl
|
const requestedFilename = input.playlistSourceUrl
|
||||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||||
: requestFilenames[input.sourceUrl];
|
: requestFilenames[input.sourceUrl];
|
||||||
@@ -216,9 +248,10 @@ export const reconcileDownloadRows = (
|
|||||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||||
: preserved.file,
|
: preserved.file,
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: preserved.generation + 1,
|
generation: nextGeneration,
|
||||||
requestContextVersion,
|
requestContextVersion,
|
||||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||||
|
isTorrent: input.isTorrent,
|
||||||
size: undefined,
|
size: undefined,
|
||||||
sizeBytes: undefined,
|
sizeBytes: undefined,
|
||||||
resumable: undefined,
|
resumable: undefined,
|
||||||
@@ -235,7 +268,12 @@ export const reconcileDownloadRows = (
|
|||||||
playlistCount: input.playlistCount,
|
playlistCount: input.playlistCount,
|
||||||
playlistEntryTitle: input.playlistEntryTitle,
|
playlistEntryTitle: input.playlistEntryTitle,
|
||||||
playlistError: undefined,
|
playlistError: undefined,
|
||||||
metadataBlockedReason: undefined
|
metadataBlockedReason: undefined,
|
||||||
|
torrentPath: undefined,
|
||||||
|
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||||
|
torrentInfoHash: undefined,
|
||||||
|
torrentFiles: undefined,
|
||||||
|
selectedTorrentFileIndices: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return preserved;
|
return preserved;
|
||||||
@@ -249,13 +287,15 @@ export const reconcileDownloadRows = (
|
|||||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const id = createId();
|
||||||
|
const generation = input.valid ? 1 : 0;
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id,
|
||||||
sourceUrl: input.sourceUrl,
|
sourceUrl: input.sourceUrl,
|
||||||
downloadUrl: input.sourceUrl,
|
downloadUrl: input.sourceUrl,
|
||||||
file: fallback,
|
file: fallback,
|
||||||
status: input.valid ? 'loading' : 'invalid',
|
status: input.valid ? 'loading' : 'invalid',
|
||||||
generation: input.valid ? 1 : 0,
|
generation,
|
||||||
requestContextVersion: input.requestContextVersion,
|
requestContextVersion: input.requestContextVersion,
|
||||||
isMedia: input.valid && (
|
isMedia: input.valid && (
|
||||||
Boolean(input.isPlaylist)
|
Boolean(input.isPlaylist)
|
||||||
@@ -263,6 +303,7 @@ export const reconcileDownloadRows = (
|
|||||||
|| forceMediaUrls.has(input.sourceUrl)
|
|| forceMediaUrls.has(input.sourceUrl)
|
||||||
|| isMediaUrl(input.sourceUrl)
|
|| isMediaUrl(input.sourceUrl)
|
||||||
),
|
),
|
||||||
|
isTorrent: input.valid && Boolean(input.isTorrent),
|
||||||
isPlaylist: input.isPlaylist,
|
isPlaylist: input.isPlaylist,
|
||||||
playlistSourceUrl: input.playlistSourceUrl,
|
playlistSourceUrl: input.playlistSourceUrl,
|
||||||
playlistTitle: input.playlistTitle,
|
playlistTitle: input.playlistTitle,
|
||||||
@@ -270,6 +311,7 @@ export const reconcileDownloadRows = (
|
|||||||
playlistCount: input.playlistCount,
|
playlistCount: input.playlistCount,
|
||||||
playlistEntryTitle: input.playlistEntryTitle,
|
playlistEntryTitle: input.playlistEntryTitle,
|
||||||
metadataBlockedReason: undefined,
|
metadataBlockedReason: undefined,
|
||||||
|
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
|
||||||
selected: input.selected !== false
|
selected: input.selected !== false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe('download connection resolution', () => {
|
|||||||
describe('download filename matching', () => {
|
describe('download filename matching', () => {
|
||||||
it('matches frontend filenames to the backend Windows device-name canonicalization', () => {
|
it('matches frontend filenames to the backend Windows device-name canonicalization', () => {
|
||||||
expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt');
|
expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt');
|
||||||
|
expect(canonicalizeDownloadFileName('CON .txt')).toBe('CON -.txt');
|
||||||
expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip');
|
expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip');
|
||||||
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
|
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
|
||||||
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
|
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const canonicalizeDownloadFileName = (fileName: string): string => {
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/[. ]+$/g, '');
|
.replace(/[. ]+$/g, '');
|
||||||
let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||||
const reservedStem = canonical.split('.')[0]?.toUpperCase();
|
const reservedStem = canonical.split('.')[0]?.trimEnd().toUpperCase();
|
||||||
if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) {
|
if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) {
|
||||||
const extensionStart = canonical.lastIndexOf('.');
|
const extensionStart = canonical.lastIndexOf('.');
|
||||||
const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;
|
const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;
|
||||||
|
|||||||
Reference in New Issue
Block a user