mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-01 07:02:41 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b51ca86e | |||
| 1bf8b675e4 | |||
| ebd11b26f6 | |||
| ae5199132d | |||
| 6336ff6f5d | |||
| a8bd80d557 | |||
| 186e189277 | |||
| 2c1bd9cdf1 | |||
| 292447d417 | |||
| 31fbc5495b | |||
| 33dd2cb9c6 | |||
| 924e108f3c | |||
| 1634118f11 | |||
| 31cf4a1c50 | |||
| 4e504b8e74 |
@@ -93,12 +93,11 @@ jobs:
|
||||
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
|
||||
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
|
||||
- name: Build Linux AppImage
|
||||
- name: Build Linux AppImage from existing binary
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
|
||||
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
|
||||
- name: Install pinned appimagetool (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
|
||||
+20
-4
@@ -5,6 +5,25 @@ All notable changes to Firelink will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.3.1] - 2026-07-30
|
||||
|
||||
This hotfix improves filename handling, localized display details, and release reliability across platforms.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prevent filename errors for long downloads and Windows-reserved names such as `CON` and `COM1`. Names are shortened safely while keeping their file extensions, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
|
||||
- Correct Persian date formatting in affected settings and timezone cases, while preserving Hebrew locale formatting.
|
||||
- Fix remaining light-theme overlay and contrast details reported with the broader appearance and localization work in [#31](https://github.com/nimbold/Firelink/issues/31).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Make Linux AppImage packaging more reliable and avoid compiling the same Rust release binary twice.
|
||||
- Strengthen cross-platform release checks so Windows and Linux test builds handle macOS-only code correctly.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Firelink Companion `2.1.0` remains the current compatible extension. No extension update is required for this hotfix; use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest).
|
||||
|
||||
## [1.3.0] - 2026-07-29
|
||||
|
||||
This release focuses on smoother queue control, a more comfortable interface, and safer recovery when downloads or browser handoffs are interrupted.
|
||||
@@ -30,12 +49,9 @@ This release focuses on smoother queue control, a more comfortable interface, an
|
||||
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
|
||||
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the user’s settings.
|
||||
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
|
||||
- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
|
||||
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
|
||||
|
||||
### Known limitation
|
||||
|
||||
- Very long media-generated filenames can still exceed an operating system’s filename limit. For [#29](https://github.com/nimbold/Firelink/issues/29), the current workaround is to shorten the filename in **Properties** before starting the download.
|
||||
|
||||
## [1.2.0] - 2026-07-22
|
||||
|
||||
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
|
||||
|
||||
@@ -28,9 +28,9 @@ It uses a Rust and Tauri backend with a React and TypeScript interface. Required
|
||||
|
||||
## Status
|
||||
|
||||
Firelink `1.3.0` is the latest desktop release.
|
||||
Firelink `1.3.1` is the latest desktop release.
|
||||
|
||||
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0).
|
||||
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0). The Companion did not change for this hotfix.
|
||||
|
||||
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
|
||||
|
||||
@@ -76,7 +76,7 @@ All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separat
|
||||
|
||||
## Browser integration
|
||||
|
||||
[Firelink Companion `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release.
|
||||
[Firelink Companion `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release; no extension update is needed for Firelink `1.3.1`.
|
||||
|
||||
Captured links open Firelink's Add window for review before they are started or queued.
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Firelink never falls back to system-installed media tools.
|
||||
- `scripts/stage-engines.js` creates one target-specific bundle payload.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
|
||||
|
||||
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -33,6 +33,8 @@
|
||||
"build": "tsc && vite build",
|
||||
"check:i18n": "vitest run src/i18n/resources.test.ts",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -53,11 +52,4 @@ process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`
|
||||
run(process.execPath, ['scripts/stage-engines.js']);
|
||||
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
|
||||
|
||||
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
|
||||
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
|
||||
fs.rmSync(engineDist, { recursive: true, force: true });
|
||||
fs.mkdirSync(engineDist, { recursive: true });
|
||||
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
|
||||
}
|
||||
|
||||
runNpmScript('build');
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
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, '..');
|
||||
let activeChild;
|
||||
let receivedSignal;
|
||||
|
||||
export const APPIMAGE_CONFIG = JSON.stringify({
|
||||
bundle: {
|
||||
resources: {
|
||||
// Tauri merges --config values using JSON Merge Patch. An omitted key
|
||||
// would leave the base engine resource enabled; null explicitly removes
|
||||
// it for this first packaging pass. The verified payload is added back
|
||||
// by repack-linux-appimage-engines.js after linuxdeploy finishes.
|
||||
'engine-dist/': null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
return {
|
||||
SIGHUP: 129,
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
}[signal] ?? 1;
|
||||
}
|
||||
|
||||
function npmInvocation(args) {
|
||||
if (process.env.npm_execpath) {
|
||||
return [process.execPath, [process.env.npm_execpath, ...args]];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]];
|
||||
}
|
||||
|
||||
return ['npm', args];
|
||||
}
|
||||
|
||||
export function appImageBundleArguments(target) {
|
||||
return [
|
||||
'run',
|
||||
'tauri',
|
||||
'--',
|
||||
'bundle',
|
||||
'-vv',
|
||||
'--target',
|
||||
target,
|
||||
'--bundles',
|
||||
'appimage',
|
||||
'--config',
|
||||
APPIMAGE_CONFIG,
|
||||
];
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
windowsHide: true,
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
|
||||
activeChild = child;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
child.once('error', error => {
|
||||
if (activeChild === child) activeChild = undefined;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.once('close', (code, signal) => {
|
||||
if (activeChild === child) activeChild = undefined;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ code, signal });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runChecked(command, args, label = command) {
|
||||
let result;
|
||||
try {
|
||||
result = await run(command, args);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
const error = new Error(`${label} was terminated by ${result.signal}.`);
|
||||
error.exitCode = signalExitCode(result.signal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
const error = new Error(`${label} exited with status ${result.code}.`);
|
||||
error.exitCode = result.code ?? 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertSafeTarget(target) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) {
|
||||
throw new Error(`Invalid target triple: ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE;
|
||||
if (!target) {
|
||||
throw new Error('Pass --target <triple>.');
|
||||
}
|
||||
assertSafeTarget(target);
|
||||
|
||||
// The native-package build has already staged and verified the engines.
|
||||
// Verify once more before creating the AppImage so a failed preparation
|
||||
// cannot produce an artifact that later appears valid only because its
|
||||
// payload is absent.
|
||||
await runChecked(
|
||||
process.execPath,
|
||||
['scripts/verify-binaries.js', '--staged', '--target', target],
|
||||
'staged engine verification'
|
||||
);
|
||||
|
||||
if (receivedSignal) {
|
||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||
error.exitCode = signalExitCode(receivedSignal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
|
||||
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling');
|
||||
|
||||
if (receivedSignal) {
|
||||
const error = new Error(`Build interrupted by ${receivedSignal}.`);
|
||||
error.exitCode = signalExitCode(receivedSignal);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Built the Linux AppImage from the existing ${target} release binary.`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
const handleSignal = signal => {
|
||||
receivedSignal ??= signal;
|
||||
|
||||
// npm and Tauri can have Rust/packaging descendants. On POSIX, the child
|
||||
// is a detached process-group leader, so signal the whole group instead of
|
||||
// leaving descendants running after the wrapper exits.
|
||||
if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) {
|
||||
try {
|
||||
if (process.platform === 'win32' || !activeChild.pid) {
|
||||
activeChild.kill(signal);
|
||||
} else {
|
||||
process.kill(-activeChild.pid, signal);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') {
|
||||
console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.exitCode = signalExitCode(signal);
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, () => handleSignal(signal));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(error => {
|
||||
console.error(`[FAIL] ${error.message}`);
|
||||
process.exitCode = error.exitCode ?? 1;
|
||||
})
|
||||
.finally(() => {
|
||||
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||
process.removeAllListeners(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js';
|
||||
|
||||
test('AppImage config explicitly removes the staged engine payload', () => {
|
||||
assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), {
|
||||
bundle: {
|
||||
resources: {
|
||||
'engine-dist/': null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('AppImage bundling uses the existing binary instead of tauri build', () => {
|
||||
const args = appImageBundleArguments('x86_64-unknown-linux-gnu');
|
||||
assert.equal(args[3], 'bundle');
|
||||
assert.equal(args.includes('build'), false);
|
||||
assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]);
|
||||
});
|
||||
@@ -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
+15
-2
@@ -1364,11 +1364,12 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "firelink"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"hmac 0.13.0",
|
||||
@@ -1383,6 +1384,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.10.7",
|
||||
"sha2 0.11.0",
|
||||
"sysinfo",
|
||||
"sysproxy",
|
||||
@@ -4281,6 +4283,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -5632,7 +5645,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log 0.4.33",
|
||||
"rand 0.10.2",
|
||||
"sha1",
|
||||
"sha1 0.11.0",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
@@ -40,6 +40,8 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
|
||||
app_handle: tauri::AppHandle,
|
||||
path: 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(|| {
|
||||
format!(
|
||||
"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)?)
|
||||
}
|
||||
|
||||
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> {
|
||||
crate::download_ownership::known_primary_paths(app_handle)
|
||||
}
|
||||
|
||||
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) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(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());
|
||||
}
|
||||
}
|
||||
@@ -140,8 +159,9 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
std::fs::symlink_metadata(candidate)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
|
||||
(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 LEGACY_STORE_NAME: &str = "store.bin";
|
||||
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 const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||
// 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,
|
||||
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 (
|
||||
key TEXT PRIMARY KEY,
|
||||
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
|
||||
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
||||
.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 Ok(mut parsed) = url::Url::parse(url) {
|
||||
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 had_userinfo || had_query_or_fragment {
|
||||
if parsed.scheme() == "magnet" {
|
||||
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_password(None);
|
||||
parsed.set_query(None);
|
||||
parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str()));
|
||||
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 {
|
||||
if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash {
|
||||
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 {
|
||||
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}'"))
|
||||
}
|
||||
|
||||
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
|
||||
.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}"))?;
|
||||
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}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("failed to read ownership data: {error}"))
|
||||
}
|
||||
|
||||
pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> {
|
||||
let existing_owner = connection
|
||||
.query_row(
|
||||
"SELECT id FROM download_ownership
|
||||
WHERE primary_path = ?1 AND id <> ?2
|
||||
LIMIT 1",
|
||||
params![path, id],
|
||||
|row| row.get::<_, String>(0),
|
||||
pub fn set_ownership_paths(
|
||||
connection: &Connection,
|
||||
id: &str,
|
||||
primary_path: &str,
|
||||
paths: &[String],
|
||||
) -> Result<(), String> {
|
||||
let mut statement = connection
|
||||
.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 check download ownership path: {error}"))?;
|
||||
if existing_owner.is_some() {
|
||||
return Err("Download destination is already owned by another Firelink download".to_string());
|
||||
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
|
||||
let existing = statement
|
||||
.query_map(params![id], |row| {
|
||||
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
|
||||
.execute(
|
||||
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)
|
||||
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}"))?;
|
||||
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(())
|
||||
}
|
||||
|
||||
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
|
||||
.execute("DELETE FROM download_ownership WHERE id = ?1", params![id])
|
||||
.map_err(|error| format!("failed to delete ownership data: {error}"))?;
|
||||
@@ -1937,6 +2026,29 @@ mod tests {
|
||||
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]
|
||||
fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -2142,15 +2254,62 @@ mod tests {
|
||||
let state = init_at_path(temp.path()).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
|
||||
set_ownership(&connection, "first", "/downloads/file.bin").unwrap();
|
||||
let error = set_ownership(&connection, "second", "/downloads/file.bin")
|
||||
set_ownership_paths(
|
||||
&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");
|
||||
|
||||
assert!(error.contains("already owned"));
|
||||
assert_eq!(load_ownership(&connection).unwrap(), vec![(
|
||||
"first".to_string(),
|
||||
"/downloads/file.bin".to_string()
|
||||
)]);
|
||||
set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap();
|
||||
assert_eq!(
|
||||
load_ownership(&connection).unwrap(),
|
||||
vec![(
|
||||
"first".to_string(),
|
||||
"/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,9 +7,13 @@ use tauri::Manager;
|
||||
struct DownloadOwnershipRecord {
|
||||
id: String,
|
||||
primary_path: String,
|
||||
owned_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn canonical_download_filename(filename: &str) -> String {
|
||||
const MAX_FILENAME_BYTES: usize = 255;
|
||||
const TRUNCATION_MARKER: &str = "…";
|
||||
|
||||
let leaf = filename.replace('\\', "/");
|
||||
let leaf = Path::new(&leaf)
|
||||
.file_name()
|
||||
@@ -31,7 +35,7 @@ pub fn canonical_download_filename(filename: &str) -> String {
|
||||
})
|
||||
.collect::<String>();
|
||||
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
|
||||
if sanitized.is_empty() || matches!(sanitized, "." | "..") {
|
||||
let canonical = if sanitized.is_empty() || matches!(sanitized, "." | "..") {
|
||||
"download".to_string()
|
||||
} else if crate::platform::is_windows_reserved_filename(sanitized) {
|
||||
let path = Path::new(sanitized);
|
||||
@@ -45,7 +49,46 @@ pub fn canonical_download_filename(filename: &str) -> String {
|
||||
}
|
||||
} else {
|
||||
sanitized.to_string()
|
||||
};
|
||||
|
||||
if canonical.len() <= MAX_FILENAME_BYTES {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
let extension = Path::new(&canonical)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| format!(".{value}"))
|
||||
.unwrap_or_default();
|
||||
let base = canonical
|
||||
.strip_suffix(&extension)
|
||||
.unwrap_or(canonical.as_str());
|
||||
let base_budget = MAX_FILENAME_BYTES
|
||||
.saturating_sub(extension.len())
|
||||
.saturating_sub(TRUNCATION_MARKER.len());
|
||||
|
||||
if base_budget == 0 {
|
||||
return truncate_utf8_to_bytes(&canonical, MAX_FILENAME_BYTES);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}{}{}",
|
||||
truncate_utf8_to_bytes(base, base_budget),
|
||||
TRUNCATION_MARKER,
|
||||
extension
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
|
||||
let mut end = 0;
|
||||
for (index, character) in value.char_indices() {
|
||||
let next = index + character.len_utf8();
|
||||
if next > max_bytes {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
pub fn expected_primary_path(
|
||||
@@ -82,6 +125,63 @@ pub fn set_primary_path(
|
||||
id: &str,
|
||||
path: &Path,
|
||||
) -> 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() {
|
||||
return Err("Download ownership path must be absolute".to_string());
|
||||
}
|
||||
@@ -98,10 +198,10 @@ pub fn set_primary_path(
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
if !crate::is_safe_path(&canonical_path, app_handle) {
|
||||
return Err("Download ownership path is outside an allowed download location".to_string());
|
||||
}
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
@@ -120,10 +220,25 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||
.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> {
|
||||
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||
.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();
|
||||
|
||||
// One-time compatibility for downloads created before the backend-owned
|
||||
@@ -143,7 +258,11 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
|
||||
crate::db::load_ownership(&connection).map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path })
|
||||
.map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
|
||||
id,
|
||||
primary_path,
|
||||
owned_paths,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
@@ -256,6 +375,24 @@ mod tests {
|
||||
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_filenames_by_utf8_bytes_and_preserves_extension() {
|
||||
let filename = canonical_download_filename(&format!("{}.mp4", "title ".repeat(100)));
|
||||
|
||||
assert!(filename.len() <= 255);
|
||||
assert!(filename.ends_with(".mp4"));
|
||||
assert!(filename.contains('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_multibyte_filenames_at_character_boundaries() {
|
||||
let filename = canonical_download_filename(&format!("{}.mkv", "😀".repeat(100)));
|
||||
|
||||
assert!(filename.len() <= 255);
|
||||
assert!(filename.ends_with(".mkv"));
|
||||
assert!(!filename.contains('\u{fffd}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
|
||||
let valid = json!({
|
||||
|
||||
@@ -144,6 +144,42 @@ pub struct DownloadItem {
|
||||
pub last_error: Option<String>,
|
||||
#[ts(optional)]
|
||||
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)]
|
||||
|
||||
+547
-19
@@ -2918,6 +2918,8 @@ mod platform;
|
||||
pub mod queue;
|
||||
pub mod process;
|
||||
pub mod retry;
|
||||
mod torrent_probe;
|
||||
pub mod torrent;
|
||||
mod settings;
|
||||
mod storage;
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") {
|
||||
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") {
|
||||
continue;
|
||||
}
|
||||
let url = url.to_string();
|
||||
@@ -3274,6 +3276,22 @@ pub(crate) async fn rpc_call(
|
||||
.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]
|
||||
async fn test_aria2c(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -4883,19 +4901,37 @@ async fn remove_download(
|
||||
) -> Result<(), String> {
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
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 media_lifecycle_generation = state
|
||||
let registered_lifecycle_generation = state
|
||||
.queue_manager
|
||||
.registered_lifecycle_generation(&id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
.await;
|
||||
let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default();
|
||||
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);
|
||||
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
|
||||
// confirmed. If removal fails, terminal events from this lifecycle
|
||||
// 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
|
||||
// GID yet. Invalidate that pending lifecycle before acknowledging the
|
||||
// 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;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
@@ -4947,11 +4983,79 @@ async fn remove_download(
|
||||
.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.clear_aria2_retry_state(&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;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
@@ -4959,16 +5063,15 @@ async fn remove_download(
|
||||
);
|
||||
|
||||
let preserve_assets = preserve_resumable
|
||||
&& primary_path
|
||||
.as_deref()
|
||||
.is_some_and(has_resumable_download_assets);
|
||||
&& owned_paths.iter().any(|path| has_resumable_download_assets(path));
|
||||
|
||||
let cleanup_result = async {
|
||||
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?;
|
||||
}
|
||||
}
|
||||
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
|
||||
crate::download_ownership::remove(&app_handle, &id)?;
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
@@ -4978,6 +5081,15 @@ async fn remove_download(
|
||||
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 {
|
||||
[".aria2", ".part", ".ytdl"].iter().any(|suffix| {
|
||||
let mut candidate = primary.as_os_str().to_os_string();
|
||||
@@ -5334,6 +5446,7 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<()
|
||||
static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn should_apply_dock_badge_update(
|
||||
current_session: u64,
|
||||
current_generation: u64,
|
||||
@@ -5509,15 +5622,283 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S
|
||||
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]
|
||||
async fn enqueue_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
mut item: queue::EnqueueItem,
|
||||
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
||||
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
if item.is_torrent.unwrap_or(false) {
|
||||
validate_torrent_enqueue(&app_handle, &item)
|
||||
.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();
|
||||
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
|
||||
let accepted_filename = item.filename.clone();
|
||||
@@ -5539,6 +5920,47 @@ async fn enqueue_download(
|
||||
.await;
|
||||
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
|
||||
.queue_manager
|
||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||
@@ -5582,7 +6004,12 @@ async fn enqueue_many(
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
for mut item in items {
|
||||
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 {
|
||||
id,
|
||||
success: false,
|
||||
@@ -5639,6 +6066,65 @@ async fn enqueue_many(
|
||||
});
|
||||
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
|
||||
.queue_manager
|
||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||
@@ -6856,7 +7342,6 @@ mod tests {
|
||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||
has_resumable_download_assets, is_media_artifact_name,
|
||||
should_cleanup_media_artifacts_after_failure,
|
||||
should_apply_dock_badge_update,
|
||||
should_retry_without_browser_cookies,
|
||||
retry_metadata_with_cookies, should_retry_metadata_with_cookies,
|
||||
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
|
||||
@@ -6869,6 +7354,8 @@ mod tests {
|
||||
normalize_media_connections,
|
||||
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::should_apply_dock_badge_update;
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -8041,6 +8528,7 @@ mod tests {
|
||||
assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn dock_badge_updates_reject_stale_sessions_and_generations() {
|
||||
assert!(should_apply_dock_badge_update(1, 99, 2, 1));
|
||||
@@ -9314,6 +9802,46 @@ pub fn run() {
|
||||
|
||||
let database = crate::db::init(&storage_layout)
|
||||
.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 = {
|
||||
// Generate a temporary session token for the extension server on startup.
|
||||
// The frontend will hydrate the real token via IPC once it mounts,
|
||||
@@ -9976,7 +10504,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
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,
|
||||
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,
|
||||
ack_schedule_trigger,
|
||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||
@@ -9987,7 +10515,7 @@ pub fn run() {
|
||||
authorize_keychain_access,
|
||||
acknowledge_pairing_token_change,
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -123,12 +123,12 @@ pub fn path_is_within(path: &Path, root: &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()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
left == right
|
||||
}
|
||||
|
||||
+303
-18
@@ -1,3 +1,4 @@
|
||||
use base64::Engine as _;
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||
use crate::power::PowerManager;
|
||||
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 cookie_source: Option<String>,
|
||||
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
|
||||
@@ -294,6 +298,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
|
||||
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||
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
|
||||
/// 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())),
|
||||
pending_completion: Arc::new(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_retry_strikes: Mutex::new(HashMap::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 removed = {
|
||||
let mut permits = self.active_permits.lock().await;
|
||||
@@ -1388,6 +1399,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(epoch) = aria2_lifecycle_epoch {
|
||||
self.begin_aria2_dispatch(&id, epoch).await;
|
||||
}
|
||||
self.emit_state(&id, DownloadStatus::Downloading);
|
||||
drop(control_guard);
|
||||
|
||||
@@ -1395,7 +1409,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
TaskKind::Aria2 => {
|
||||
let lifecycle_epoch = aria2_lifecycle_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) => {
|
||||
let control_guard = self.acquire_aria2_control(&id).await;
|
||||
let cancelled = self.aria2_retry_cancelled.lock().await.contains(&id);
|
||||
@@ -1410,6 +1425,23 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
id,
|
||||
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 {
|
||||
log::warn!(
|
||||
"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.finish_aria2_dispatch(&id, lifecycle_epoch).await;
|
||||
if current_lifecycle {
|
||||
self.clear_aria2_retry_state(&id).await;
|
||||
self.release_permit(&id).await;
|
||||
@@ -1426,6 +1459,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
return;
|
||||
}
|
||||
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
|
||||
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
|
||||
drop(control_guard);
|
||||
if let Some(outcome) = buffered_outcome {
|
||||
self.handle_aria2_event(&gid, outcome).await;
|
||||
@@ -1450,6 +1484,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
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
|
||||
/// the lock first.
|
||||
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
|
||||
// from the previous lifecycle before releasing its permit.
|
||||
self.next_aria2_control_epoch(id).await;
|
||||
@@ -1609,11 +1654,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
PendingOutcome::Error(error) => {
|
||||
if error.to_ascii_lowercase().contains("checksum") {
|
||||
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
||||
if let Ok(primary_path) =
|
||||
crate::download_ownership::primary_path_for_id(&self.app_handle, id)
|
||||
if let Ok(paths) =
|
||||
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
|
||||
{
|
||||
if let Some(path) = primary_path.as_deref() {
|
||||
let _ = crate::remove_download_assets(path, &self.app_handle).await;
|
||||
for path in paths {
|
||||
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()))
|
||||
}
|
||||
|
||||
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
|
||||
/// epoch must still be current when an asynchronous status snapshot is
|
||||
/// emitted; otherwise a late response from an older GID can be attributed
|
||||
@@ -2367,6 +2566,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
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 {
|
||||
@@ -2490,7 +2700,7 @@ fn proxy_scheme(proxy: &str) -> Option<String> {
|
||||
.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();
|
||||
if proxy.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -2666,9 +2876,10 @@ impl ProductionSpawner {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
async fn add_uri_rpc(
|
||||
async fn add_transfer_rpc(
|
||||
&self,
|
||||
state: &crate::AppState,
|
||||
method: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
@@ -2676,7 +2887,7 @@ impl ProductionSpawner {
|
||||
match crate::rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
"aria2.addUri",
|
||||
method,
|
||||
params.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -2714,7 +2925,9 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
);
|
||||
let safe_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;
|
||||
apply_aria2_connection_options(&mut options, conn);
|
||||
let mt = aria2_attempt_limit(payload.max_tries);
|
||||
@@ -2766,23 +2979,73 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
if let Some(prox) = proxy_value {
|
||||
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) => {
|
||||
let gid = result.as_str().unwrap_or("").to_string();
|
||||
if gid.is_empty() {
|
||||
Err("aria2.addUri returned an empty gid".to_string())
|
||||
Err(format!("{method} returned an empty gid"))
|
||||
} else {
|
||||
log::info!("aria2 addUri [{}]: created gid {}", id, gid);
|
||||
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
|
||||
Ok(gid)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let safe_error = crate::redact_sensitive_text(&e);
|
||||
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
|
||||
Err(format!("aria2 addUri failed: {safe_error}"))
|
||||
log::error!("aria2 {} [{}] failed: {}", method, id, safe_error);
|
||||
Err(format!("aria2 {method} failed: {safe_error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2797,7 +3060,14 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
)
|
||||
.await?;
|
||||
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!(
|
||||
"aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}"
|
||||
)),
|
||||
@@ -3108,6 +3378,18 @@ pub struct EnqueueItem {
|
||||
pub is_media: Option<bool>,
|
||||
#[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>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub lifecycle_generation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3147,6 +3429,9 @@ impl EnqueueItem {
|
||||
format_selector: self.format_selector,
|
||||
cookie_source: self.cookie_source,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
|
||||
@@ -23,3 +23,21 @@ cd src-tauri
|
||||
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
|
||||
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_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_eq!(manager.available_permits(), 1);
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
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.
|
||||
|
||||
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, };
|
||||
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -160,11 +160,47 @@ export const AddDownloadsModal = () => {
|
||||
const [urls, setUrls] = useState('');
|
||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
|
||||
const addModalOpenRef = useRef(isAddModalOpen);
|
||||
const metadataRequestsRef = useRef(new Set<string>());
|
||||
const playlistRequestsRef = useRef(new Set<string>());
|
||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
|
||||
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 [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||
const modalRef = useModalFocus(isAddModalOpen);
|
||||
@@ -192,6 +228,28 @@ export const AddDownloadsModal = () => {
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
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 [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -493,10 +551,61 @@ export const AddDownloadsModal = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const proxy = await getProxyArgs(settingsStore);
|
||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||
const contextUrl = requestContextUrlForRow(row);
|
||||
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) {
|
||||
settingsStore.setShowKeychainModal(true);
|
||||
return;
|
||||
@@ -1078,8 +1187,6 @@ export const AddDownloadsModal = () => {
|
||||
);
|
||||
|
||||
let count = 1;
|
||||
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
|
||||
let newName = finalFile;
|
||||
let exists = true;
|
||||
const batchTargets: Array<{ location: string; fileName: string }> = [];
|
||||
@@ -1098,7 +1205,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
|
||||
while (exists && count < 1000) {
|
||||
newName = `${base} (${count})${ext}`;
|
||||
newName = downloadFileNameWithSuffix(finalFile, ` (${count})`);
|
||||
let storeHas = false;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
for (const download of useDownloadStore.getState().downloads) {
|
||||
@@ -1186,15 +1293,19 @@ export const AddDownloadsModal = () => {
|
||||
if (!existingItem) {
|
||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||
}
|
||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||
const mediaFormatChanged = item.isMedia
|
||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||
if (existingItem.status === 'completed' || mediaFormatChanged) {
|
||||
// Completed replacements must remove the old file so the
|
||||
// new transfer cannot be treated as an already-complete
|
||||
// aria2 target. Unfinished rows use the in-place path to
|
||||
// preserve their resumable assets and progress.
|
||||
await store.removeDownload(existingItem.id, true, false);
|
||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||
const mediaFormatChanged = item.isMedia
|
||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||
// Completed replacements must remove the old file so the
|
||||
// new transfer cannot be treated as an already-complete
|
||||
// aria2 target. A torrent replacement also needs a fresh
|
||||
// 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 {
|
||||
const contextUrl = requestContextUrlForRow(item);
|
||||
const replaced = await store.replaceDownload(existingItem.id, {
|
||||
@@ -1225,8 +1336,33 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
||||
if (!item) continue;
|
||||
let allocatedId: string | null = null;
|
||||
try {
|
||||
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
|
||||
? mediaFileNameForSelectedFormat(item.file, item)
|
||||
: canonicalizeDownloadFileName(item.file);
|
||||
@@ -1262,6 +1398,10 @@ export const AddDownloadsModal = () => {
|
||||
resumable: item.resumable,
|
||||
mediaFormatSelector: formatSelector,
|
||||
mediaQuality: mediaQualityForRow(item),
|
||||
isTorrent: item.isTorrent,
|
||||
torrentPath,
|
||||
torrentInfoHash: item.torrentInfoHash,
|
||||
torrentFileIndices: item.selectedTorrentFileIndices,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -1270,6 +1410,11 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
addedCount += 1;
|
||||
} 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);
|
||||
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -1371,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 selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||
@@ -1637,6 +1799,15 @@ export const AddDownloadsModal = () => {
|
||||
value={urls}
|
||||
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]) => {
|
||||
const total = playlist.entry_count || playlist.entries.length;
|
||||
return (
|
||||
@@ -1768,6 +1939,46 @@ export const AddDownloadsModal = () => {
|
||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||
<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) */}
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||
<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}
|
||||
</span>
|
||||
) : null}
|
||||
{download.isTorrent ? (
|
||||
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrentFiles)}>
|
||||
{t($ => $.addDownloads.torrentFiles)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1365,6 +1365,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, []);
|
||||
|
||||
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();
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
@@ -1377,12 +1385,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
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) => {
|
||||
if (item.status !== 'completed') {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isTorrent) {
|
||||
await revealDownloadFile(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = await getDownloadPath(item);
|
||||
if (!fullPath) {
|
||||
openProperties(item.id);
|
||||
@@ -1395,23 +1424,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
console.error("Failed to open file:", error);
|
||||
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
||||
}
|
||||
}, [getDownloadPath, openProperties, 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);
|
||||
}
|
||||
};
|
||||
}, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]);
|
||||
|
||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||
if (item.status === 'completed') {
|
||||
|
||||
@@ -446,7 +446,7 @@ const common = {
|
||||
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
||||
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
||||
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}}',
|
||||
safeEntryLimit: ' (safe entry limit reached)',
|
||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||
@@ -454,6 +454,9 @@ const common = {
|
||||
selectAll: 'Select all',
|
||||
refreshMetadata: 'Refresh Metadata',
|
||||
files: 'Files',
|
||||
torrentFiles: 'Torrent files',
|
||||
chooseTorrentFiles: 'Add .torrent files',
|
||||
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
|
||||
required: 'Required',
|
||||
free: 'Free',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -446,7 +446,7 @@ const fa = {
|
||||
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
||||
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
||||
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}}',
|
||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||
@@ -454,6 +454,9 @@ const fa = {
|
||||
selectAll: 'انتخاب همه',
|
||||
refreshMetadata: 'تازهسازی متادیتا',
|
||||
files: 'فایلها',
|
||||
torrentFiles: 'فایلهای تورنت',
|
||||
chooseTorrentFiles: 'افزودن فایلهای .torrent',
|
||||
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت میکند.',
|
||||
required: 'الزامی',
|
||||
free: 'فضای آزاد',
|
||||
preview: 'پیشنمایش',
|
||||
|
||||
@@ -446,7 +446,7 @@ const he = {
|
||||
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
||||
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
||||
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}}',
|
||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||
@@ -454,6 +454,9 @@ const he = {
|
||||
selectAll: 'בחירת הכל',
|
||||
refreshMetadata: 'רענון מטא נתונים',
|
||||
files: 'קבצים',
|
||||
torrentFiles: 'קובצי טורנט',
|
||||
chooseTorrentFiles: 'הוספת קובצי .torrent',
|
||||
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
|
||||
required: 'נדרש',
|
||||
free: 'פנוי',
|
||||
preview: 'תצוגה מקדימה',
|
||||
|
||||
@@ -446,7 +446,7 @@ const ru = {
|
||||
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
|
||||
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
|
||||
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}}',
|
||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||
@@ -454,6 +454,9 @@ const ru = {
|
||||
selectAll: 'Выбрать все',
|
||||
refreshMetadata: 'Обновить метаданные',
|
||||
files: 'Файлы',
|
||||
torrentFiles: 'Торрент-файлы',
|
||||
chooseTorrentFiles: 'Добавить файлы .torrent',
|
||||
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
|
||||
required: 'Требуется',
|
||||
free: 'Свободно',
|
||||
preview: 'Предпросмотр',
|
||||
|
||||
@@ -446,7 +446,7 @@ const uk = {
|
||||
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
|
||||
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
|
||||
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}}',
|
||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||
@@ -454,6 +454,9 @@ const uk = {
|
||||
selectAll: 'Вибрати всі',
|
||||
refreshMetadata: 'Оновити метадані',
|
||||
files: 'Файли',
|
||||
torrentFiles: 'Торрент-файли',
|
||||
chooseTorrentFiles: 'Додати файли .torrent',
|
||||
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
|
||||
required: 'Обов\'язково',
|
||||
free: 'Вільно',
|
||||
preview: 'Попередній перегляд',
|
||||
|
||||
@@ -446,7 +446,7 @@ const zhCN = {
|
||||
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
|
||||
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
|
||||
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}}',
|
||||
safeEntryLimit: ' (达到安全条目限制)',
|
||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||
@@ -454,6 +454,9 @@ const zhCN = {
|
||||
selectAll: '全选',
|
||||
refreshMetadata: '刷新元数据',
|
||||
files: '文件',
|
||||
torrentFiles: '种子文件',
|
||||
chooseTorrentFiles: '添加 .torrent 文件',
|
||||
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
|
||||
required: '必需',
|
||||
free: '可用空间',
|
||||
preview: '预览',
|
||||
|
||||
+6
-5
@@ -22,7 +22,8 @@
|
||||
--bg-input: 0 0% 100%;
|
||||
--border-modal: 0 0% 85%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--surface-overlay: 0 0% 100% / 0.95;
|
||||
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
||||
--surface-overlay: 0 0% 100%;
|
||||
--shadow-color: 220 10% 20% / 0.1;
|
||||
--sidebar-shell-bg: 0 0% 92%;
|
||||
--sidebar-panel-bg: 0 0% 96%;
|
||||
@@ -66,7 +67,7 @@
|
||||
--bg-input: 0 0% 100%;
|
||||
--border-modal: 0 0% 85%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--surface-overlay: 0 0% 100% / 0.95;
|
||||
--surface-overlay: 0 0% 100%;
|
||||
--shadow-color: 220 10% 20% / 0.1;
|
||||
--sidebar-shell-bg: 0 0% 92%;
|
||||
--sidebar-panel-bg: 0 0% 96%;
|
||||
@@ -911,7 +912,7 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
.add-download-playlist-media-type-option.is-selected {
|
||||
background: hsl(var(--surface-raised) / 0.9);
|
||||
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
|
||||
box-shadow: 0 1px 2px hsl(var(--shadow-color));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
@@ -2508,7 +2509,7 @@ html[data-list-density="relaxed"] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
border-bottom-color: hsl(var(--border-color) / 0.72);
|
||||
border-bottom-color: hsl(var(--border-color));
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-secondary));
|
||||
@@ -3106,7 +3107,7 @@ body.is-queue-dragging * {
|
||||
|
||||
.add-download-nested-fields {
|
||||
padding-inline-start: 1.25rem;
|
||||
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5);
|
||||
border-inline-start: 2px solid hsl(var(--border-modal));
|
||||
}
|
||||
|
||||
.add-download-advanced-fields {
|
||||
|
||||
+14
@@ -18,6 +18,7 @@ import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
|
||||
type CommandMap = {
|
||||
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 };
|
||||
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_ytdlp_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 };
|
||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||
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 };
|
||||
begin_dock_badge_session: { args: undefined; result: number };
|
||||
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimat
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
@@ -113,6 +114,27 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
|
||||
});
|
||||
|
||||
it('normalizes an overlong filename edited in Properties before persisting it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'properties-long-name',
|
||||
url: 'https://example.com/video',
|
||||
fileName: 'video.mp4',
|
||||
status: 'ready',
|
||||
category: 'Movies',
|
||||
dateAdded: ''
|
||||
}] as any[]
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().applyProperties('properties-long-name', {
|
||||
fileName: `${'title '.repeat(100)}.mp4`
|
||||
});
|
||||
|
||||
const fileName = useDownloadStore.getState().downloads[0].fileName;
|
||||
expect(new TextEncoder().encode(fileName).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(fileName.endsWith('.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces stale media intent when an appended handoff reuses a URL', () => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', true
|
||||
@@ -1563,7 +1585,7 @@ describe('useDownloadStore', () => {
|
||||
})];
|
||||
}
|
||||
if (cmd === 'enqueue_many') {
|
||||
return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }];
|
||||
return [{ id: 'startup-accepted', success: true, filename: 'normalized.bin' }];
|
||||
}
|
||||
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
|
||||
if (cmd === 'resume_download') return true;
|
||||
@@ -1575,6 +1597,7 @@ describe('useDownloadStore', () => {
|
||||
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fileName: 'normalized.bin',
|
||||
status: 'queued',
|
||||
hasBeenDispatched: true
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -341,6 +341,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -809,13 +813,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return;
|
||||
const normalizedUpdates = updates.fileName === undefined
|
||||
? updates
|
||||
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
|
||||
|
||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
||||
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||
}
|
||||
|
||||
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -828,7 +835,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
state.unregisterBackendIds([id]);
|
||||
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
if (isRegistered || wasDispatching) {
|
||||
const dispatched = await dispatchItemInternal(id);
|
||||
if (dispatched) {
|
||||
@@ -847,7 +854,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
state.unregisterBackendIds([id]);
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1306,21 +1313,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: async (item, action) => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = await effectiveDestinationForItem(item, settings);
|
||||
const normalizedItem = {
|
||||
...item,
|
||||
fileName: canonicalizeDownloadFileName(item.fileName)
|
||||
};
|
||||
const destPath = await effectiveDestinationForItem(normalizedItem, settings);
|
||||
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
|
||||
const queueItems = get().downloads.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId
|
||||
);
|
||||
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
||||
const queuePosition = maxPos + 1;
|
||||
const { sizeBytes, ...downloadDraft } = item;
|
||||
const { sizeBytes, ...downloadDraft } = normalizedItem;
|
||||
const ownedItem: DownloadItem = {
|
||||
...downloadDraft,
|
||||
totalBytes: item.totalBytes ?? sizeBytes,
|
||||
totalIsEstimate: item.totalIsEstimate ?? (
|
||||
item.isMedia === true && item.size?.trim().startsWith('~')
|
||||
totalBytes: normalizedItem.totalBytes ?? sizeBytes,
|
||||
totalIsEstimate: normalizedItem.totalIsEstimate ?? (
|
||||
normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
|
||||
),
|
||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
|
||||
destination: destPath,
|
||||
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
||||
queueId,
|
||||
@@ -2033,6 +2044,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -2055,6 +2070,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
);
|
||||
const acceptedFilenames = new Map(
|
||||
results
|
||||
.filter(result => result.success && Boolean(result.filename))
|
||||
.map(result => [result.id, result.filename!])
|
||||
);
|
||||
const acceptedIdSet = new Set(registeredIds);
|
||||
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
|
||||
|
||||
@@ -2089,7 +2109,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
lastError: failedErrors.get(download.id)
|
||||
}
|
||||
: liveAcceptedIds.has(download.id)
|
||||
? { ...download, hasBeenDispatched: true, lastError: undefined }
|
||||
? {
|
||||
...download,
|
||||
...(acceptedFilenames.has(download.id)
|
||||
? {
|
||||
fileName: acceptedFilenames.get(download.id),
|
||||
category: categoryForFileName(acceptedFilenames.get(download.id)!)
|
||||
}
|
||||
: {}),
|
||||
hasBeenDispatched: true,
|
||||
lastError: undefined
|
||||
}
|
||||
: download
|
||||
)
|
||||
};
|
||||
|
||||
@@ -84,6 +84,60 @@ describe('add download metadata workflow', () => {
|
||||
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', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://www.youtube.com/playlist?list=PL123',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isMediaUrl
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import i18n from '../i18n';
|
||||
import { localePluralVariant } from '../i18n/locales';
|
||||
|
||||
@@ -52,6 +53,12 @@ export interface AddDownloadDraftRow {
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
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();
|
||||
|
||||
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 = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
valid: boolean;
|
||||
isTorrent?: boolean;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
@@ -115,10 +137,18 @@ const parseInputLines = (
|
||||
|
||||
let sourceUrl = line;
|
||||
let valid = false;
|
||||
let isTorrent = false;
|
||||
if (isLocalTorrentPath(line)) {
|
||||
valid = true;
|
||||
isTorrent = true;
|
||||
}
|
||||
try {
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
if (valid) sourceUrl = url.href;
|
||||
if (!isTorrent) {
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
isTorrent = valid && url.protocol === 'magnet:';
|
||||
if (valid) sourceUrl = url.href;
|
||||
}
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
@@ -166,6 +196,7 @@ const parseInputLines = (
|
||||
identity,
|
||||
sourceUrl,
|
||||
valid,
|
||||
isTorrent,
|
||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||
@@ -207,6 +238,7 @@ export const reconcileDownloadRows = (
|
||||
|| preserved.playlistCount !== input.playlistCount
|
||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||
const nextGeneration = preserved.generation + 1;
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl];
|
||||
@@ -216,9 +248,10 @@ export const reconcileDownloadRows = (
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
generation: nextGeneration,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
isTorrent: input.isTorrent,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
@@ -235,7 +268,12 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined
|
||||
metadataBlockedReason: undefined,
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
@@ -249,13 +287,15 @@ export const reconcileDownloadRows = (
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
);
|
||||
|
||||
const id = createId();
|
||||
const generation = input.valid ? 1 : 0;
|
||||
return {
|
||||
id: createId(),
|
||||
id,
|
||||
sourceUrl: input.sourceUrl,
|
||||
downloadUrl: input.sourceUrl,
|
||||
file: fallback,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
generation,
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
isMedia: input.valid && (
|
||||
Boolean(input.isPlaylist)
|
||||
@@ -263,6 +303,7 @@ export const reconcileDownloadRows = (
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isTorrent: input.valid && Boolean(input.isTorrent),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
@@ -270,6 +311,7 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -17,13 +17,53 @@ describe('date/time formatting', () => {
|
||||
});
|
||||
|
||||
it('supports the opt-in Persian and Hebrew calendars', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
|
||||
const hebrewOptions: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian' })).toBe('۱۴۰۵/۰۱/۰۱');
|
||||
expect(formatDateTime(instant, {
|
||||
locale: 'fa',
|
||||
calendar: 'persian',
|
||||
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||
})).toContain('۱۴۰۵/۰۱/۰۱');
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options: hebrewOptions })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', hebrewOptions).format(instant)
|
||||
);
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
|
||||
});
|
||||
|
||||
it('keeps Persian dates zero-padded when the caller requests weekday or time', () => {
|
||||
const formatted = formatDateTime(instant, {
|
||||
locale: 'fa',
|
||||
calendar: 'persian',
|
||||
options: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}
|
||||
});
|
||||
|
||||
expect(formatted).toContain('۱۴۰۵/۰۱/۰۱');
|
||||
});
|
||||
|
||||
it('does not drop Persian time-only Intl fields', () => {
|
||||
const formatted = formatDateTime(
|
||||
new Date('2026-03-21T14:30:00.123Z'),
|
||||
{
|
||||
locale: 'en',
|
||||
calendar: 'persian',
|
||||
options: { fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions
|
||||
}
|
||||
);
|
||||
|
||||
expect(formatted).toContain('123');
|
||||
});
|
||||
|
||||
it('uses the default Persian date when only locale or timezone options are supplied', () => {
|
||||
expect(formatDateTime(instant, {
|
||||
locale: 'fa',
|
||||
calendar: 'persian',
|
||||
options: { timeZone: 'UTC' }
|
||||
})).toBe('۱۴۰۵/۰۱/۰۱');
|
||||
});
|
||||
|
||||
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
|
||||
|
||||
@@ -28,6 +28,103 @@ const localeWithCalendar = (locale: string | null | undefined, calendar: Calenda
|
||||
const dateFromInput = (value: DateTimeInput): Date =>
|
||||
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||
|
||||
const DATE_OPTION_KEYS = new Set([
|
||||
'dateStyle',
|
||||
'era',
|
||||
'month',
|
||||
'day',
|
||||
'year',
|
||||
'weekday'
|
||||
]);
|
||||
|
||||
const TIME_OPTION_KEYS = [
|
||||
'hour',
|
||||
'hour12',
|
||||
'hourCycle',
|
||||
'minute',
|
||||
'second',
|
||||
'timeZoneName',
|
||||
'dayPeriod',
|
||||
'fractionalSecondDigits'
|
||||
] as const;
|
||||
|
||||
const persianDateParts = (
|
||||
date: Date,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
const values = new Map(formatter.formatToParts(date)
|
||||
.filter(part => part.type === 'year' || part.type === 'month' || part.type === 'day')
|
||||
.map(part => [part.type, part.value]));
|
||||
const year = values.get('year');
|
||||
const month = values.get('month');
|
||||
const day = values.get('day');
|
||||
if (!year || !month || !day) return formatter.format(date);
|
||||
return `${year}/${month}/${day}`;
|
||||
};
|
||||
|
||||
const formatPersianDateTime = (
|
||||
date: Date,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const hasDateOptions = Object.keys(options).some(key => DATE_OPTION_KEYS.has(key));
|
||||
const hasTimeOptions = options.timeStyle !== undefined ||
|
||||
TIME_OPTION_KEYS.some(key => (options as unknown as Record<string, unknown>)[key] !== undefined);
|
||||
// Locale and timezone options do not select a date or time field by
|
||||
// themselves. Match Intl's default date behavior for those option-only
|
||||
// calls instead of returning an empty string.
|
||||
const includeDate = hasDateOptions || !hasTimeOptions;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (includeDate) {
|
||||
let dateText = persianDateParts(date, locale, options);
|
||||
if (options.weekday) {
|
||||
const weekday = new Intl.DateTimeFormat(locale, {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone,
|
||||
weekday: options.weekday
|
||||
}).format(date);
|
||||
dateText = `${weekday}, ${dateText}`;
|
||||
}
|
||||
parts.push(dateText);
|
||||
}
|
||||
|
||||
if (hasTimeOptions) {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone
|
||||
};
|
||||
for (const key of TIME_OPTION_KEYS) {
|
||||
const value = (options as unknown as Record<string, unknown>)[key];
|
||||
if (value !== undefined) Object.assign(timeOptions, { [key]: value });
|
||||
}
|
||||
if (options.timeStyle) {
|
||||
timeOptions.hour = 'numeric';
|
||||
timeOptions.minute = '2-digit';
|
||||
if (options.timeStyle === 'medium' || options.timeStyle === 'long' || options.timeStyle === 'full') {
|
||||
timeOptions.second = '2-digit';
|
||||
}
|
||||
if (options.timeStyle === 'long' || options.timeStyle === 'full') {
|
||||
timeOptions.timeZoneName = options.timeStyle === 'full' ? 'long' : 'short';
|
||||
}
|
||||
}
|
||||
parts.push(new Intl.DateTimeFormat(locale, timeOptions).format(date));
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a user-facing timestamp with an explicit calendar. Gregorian is
|
||||
* passed explicitly because some localized browser defaults use a regional
|
||||
@@ -46,6 +143,13 @@ export const formatDateTime = (
|
||||
const options = config.options ?? {};
|
||||
|
||||
try {
|
||||
if (calendar === 'persian') {
|
||||
return formatPersianDateTime(
|
||||
date,
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
);
|
||||
}
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
downloadFileNamesMatch,
|
||||
downloadFileNameWithSuffix,
|
||||
downloadMediaKindsMatch,
|
||||
MAX_DOWNLOAD_FILENAME_BYTES,
|
||||
canonicalizeDownloadFileName,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
} from './downloads';
|
||||
@@ -63,6 +66,37 @@ describe('download connection resolution', () => {
|
||||
});
|
||||
|
||||
describe('download filename matching', () => {
|
||||
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('com1.archive.zip')).toBe('com1.archive-.zip');
|
||||
expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true);
|
||||
expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
|
||||
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith('.mp4')).toBe(true);
|
||||
expect(filename).toContain('…');
|
||||
});
|
||||
|
||||
it('does not split a multibyte character at the filesystem boundary', () => {
|
||||
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith('.mkv')).toBe(true);
|
||||
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps alternate names unique and bounded after long-name truncation', () => {
|
||||
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith(' (1).mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches case and path spelling while preserving the actual filename', () => {
|
||||
expect(downloadFileNamesMatch(
|
||||
'Media\\Example.Show.S01E01.MKV',
|
||||
|
||||
+83
-2
@@ -43,6 +43,38 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
// Keep every filename component within the common cross-platform filesystem
|
||||
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
|
||||
// bound is also conservative for Windows filename components.
|
||||
export const MAX_DOWNLOAD_FILENAME_BYTES = 255;
|
||||
const FILENAME_TRUNCATION_MARKER = '…';
|
||||
const WINDOWS_RESERVED_FILENAME_STEMS = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'CLOCK$',
|
||||
'CONIN$',
|
||||
'CONOUT$',
|
||||
...Array.from({ length: 9 }, (_, index) => `COM${index + 1}`),
|
||||
...Array.from({ length: 9 }, (_, index) => `LPT${index + 1}`)
|
||||
]);
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
|
||||
|
||||
const truncateUtf8ToBytes = (value: string, maxBytes: number): string => {
|
||||
if (maxBytes <= 0) return '';
|
||||
let bytes = 0;
|
||||
let result = '';
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8ByteLength(character);
|
||||
if (bytes + characterBytes > maxBytes) break;
|
||||
result += character;
|
||||
bytes += characterBytes;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
@@ -126,10 +158,59 @@ export const fileNameFromUrl = (rawUrl: string): string => {
|
||||
export const canonicalizeDownloadFileName = (fileName: string): string => {
|
||||
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
|
||||
const sanitized = leaf
|
||||
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '');
|
||||
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
const reservedStem = canonical.split('.')[0]?.trimEnd().toUpperCase();
|
||||
if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) {
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;
|
||||
const extension = extensionStart > 0 ? canonical.slice(extensionStart) : '';
|
||||
canonical = `${base}-${extension}`;
|
||||
}
|
||||
if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical;
|
||||
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const hasExtension = extensionStart > 0;
|
||||
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
|
||||
const extension = hasExtension ? canonical.slice(extensionStart) : '';
|
||||
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
|
||||
- utf8ByteLength(extension)
|
||||
- utf8ByteLength(FILENAME_TRUNCATION_MARKER);
|
||||
|
||||
if (baseBudget <= 0) {
|
||||
return truncateUtf8ToBytes(canonical, MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
}
|
||||
|
||||
return `${truncateUtf8ToBytes(base, baseBudget)}${FILENAME_TRUNCATION_MARKER}${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a deterministic alternate filename without exceeding the same
|
||||
* component limit as canonicalizeDownloadFileName. The suffix is intended for
|
||||
* trusted generated values such as " (1)".
|
||||
*/
|
||||
export const downloadFileNameWithSuffix = (fileName: string, suffix: string): string => {
|
||||
const canonical = canonicalizeDownloadFileName(fileName);
|
||||
const safeSuffix = suffix
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
|
||||
.replace(/[. ]+$/g, '');
|
||||
if (!safeSuffix.trim()) return canonical;
|
||||
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const hasExtension = extensionStart > 0;
|
||||
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
|
||||
const extension = hasExtension ? canonical.slice(extensionStart) : '';
|
||||
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
|
||||
- utf8ByteLength(safeSuffix)
|
||||
- utf8ByteLength(extension);
|
||||
|
||||
if (baseBudget <= 0) {
|
||||
return truncateUtf8ToBytes(`${base}${safeSuffix}${extension}`, MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
}
|
||||
|
||||
return `${truncateUtf8ToBytes(base, baseBudget)}${safeSuffix}${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user