mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 17:25:42 +00:00
fix(downloads): report torrent allocation and remove files asynchronously
- Add lifecycle-fenced Aria2 allocation telemetry and shared animated status presentation. - Persist background removal jobs, retain Removing rows, and provide explicit retry after failures. - Fence cleanup against stale dispatch, native controls, completion races, and replaced assets across restart. - Preserve completed and seeding payloads through Trash and permit safe permission-repair retries. - Require patched engine capabilities and reproducible Windows/Linux provisioning; isolate packaged smoke storage. - Verify 561 frontend tests, 672 native tests, 72 script tests, i18n/build checks, and macOS package/engine smoke checks.
This commit is contained in:
@@ -126,8 +126,25 @@ jobs:
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: src-tauri
|
||||
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
|
||||
- name: Install Aria2 source build dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||
- name: Install Aria2 source build dependencies (Windows)
|
||||
id: aria2-msys
|
||||
if: runner.os == 'Windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: >-
|
||||
base-devel autoconf automake libtool gettext-devel
|
||||
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Stage and verify engines
|
||||
env:
|
||||
|
||||
@@ -100,8 +100,25 @@ jobs:
|
||||
desktop-file-utils \
|
||||
xdg-utils
|
||||
- run: npm ci
|
||||
- name: Install Aria2 source build dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
|
||||
- name: Install Aria2 source build dependencies (Windows)
|
||||
id: aria2-msys
|
||||
if: runner.os == 'Windows'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: >-
|
||||
base-devel autoconf automake libtool gettext-devel
|
||||
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
|
||||
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
|
||||
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
|
||||
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
|
||||
- name: Provision locked engines
|
||||
if: runner.os != 'macOS'
|
||||
env:
|
||||
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
|
||||
run: node scripts/provision-engines.js --target ${{ matrix.target }}
|
||||
- name: Build package
|
||||
if: runner.os != 'Linux'
|
||||
|
||||
+10
@@ -26,6 +26,16 @@ Firelink never falls back to system-installed media tools.
|
||||
invocation-owned temporary workspace.
|
||||
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
|
||||
|
||||
Aria2 allocation telemetry is a required bundle capability. Windows and Linux
|
||||
provisioning now builds the checksum-pinned upstream source archive with
|
||||
`scripts/aria2/firelink.patch`; this patch also retains Firelink's native DNS,
|
||||
network target policy, and Torrent routing changes. CI installs the compiler
|
||||
and static-library prerequisites. Windows uses the MSYS2 installation returned
|
||||
by the setup action (`FIRELINK_MSYS2_ROOT`, default `C:/msys64` for local builds).
|
||||
The patch checksum is recorded in both source and payload provenance. Never
|
||||
replace these builds with stock Aria2 archives: package verification requires
|
||||
`firelinkAllocationTelemetry: true` from `aria2.getVersion`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
"sha256": "41d735c9364a8deda25b3bd5f05abf37720316be9495edfe94f51bc088ce9d86"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
|
||||
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||
"buildFromSource": true,
|
||||
"patch": "scripts/aria2/firelink.patch",
|
||||
"patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6",
|
||||
"allocationTelemetry": true,
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
@@ -40,11 +50,19 @@
|
||||
"sha256": "1a4fa0f89f690bd81bddbb7cf3a65554095e8e9f9dc98693494e6f5fb97918c5"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
|
||||
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
|
||||
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
|
||||
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
|
||||
"buildFromSource": true,
|
||||
"patch": "scripts/aria2/firelink.patch",
|
||||
"patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6",
|
||||
"allocationTelemetry": true,
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -12,14 +12,16 @@
|
||||
"aria2c": {
|
||||
"version": "1.37.0-firelink-native-dns-v1",
|
||||
"source": "https://github.com/aria2/aria2/tree/release-1.37.0",
|
||||
"build": "Firelink native-async DNS and network-target-policy patch set; arm64 executable with adjacent aria2-libs",
|
||||
"build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs",
|
||||
"firelinkRouteContract": {
|
||||
"revision": "firelink-native-dns-v1",
|
||||
"dnsResolver": "native-async",
|
||||
"networkTargetPolicy": "firelink-v1",
|
||||
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
|
||||
},
|
||||
"sha256": "b6e51125007860e1a58c75737faa2cd0d1d5372c1c9ca82d1bf87d2c40081aac"
|
||||
"sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7",
|
||||
"allocationTelemetry": true,
|
||||
"patchSha256": "b696988effe116fab40b52df61dab8af8042c65894a492e490d109f410a8ade6"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "9.0.1",
|
||||
|
||||
@@ -130,3 +130,9 @@ export function assertAria2RouteSource(source, target) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAria2AllocationCapabilities(version) {
|
||||
if (version?.firelinkAllocationTelemetry !== true) {
|
||||
throw new Error('Bundled Aria2 does not expose file allocation telemetry');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertAria2AllocationCapabilities,
|
||||
ARIA2_DNS_RESOLVER,
|
||||
ARIA2_FIRELINK_REVISION,
|
||||
ARIA2_NETWORK_TARGET_POLICY,
|
||||
@@ -81,3 +82,9 @@ test('system resolver options cannot retain the custom target policy', () => {
|
||||
/active target policy/,
|
||||
);
|
||||
});
|
||||
|
||||
test('allocation telemetry is mandatory and must be a JSON boolean capability', () => {
|
||||
assert.throws(() => assertAria2AllocationCapabilities({ version: '1.37.0' }));
|
||||
assert.throws(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: 'true' }));
|
||||
assert.doesNotThrow(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: true }));
|
||||
});
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Build from the checksum-pinned upstream archive plus the reviewed patch.
|
||||
source_root="$1"
|
||||
patch_file="$2"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
source_root="$(cygpath -u "$source_root")"
|
||||
patch_file="$(cygpath -u "$patch_file")"
|
||||
export PATH="/mingw64/bin:/usr/bin:$PATH"
|
||||
export PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
|
||||
fi
|
||||
cd "$source_root"
|
||||
patch --batch -p1 < "$patch_file"
|
||||
autoreconf -fi
|
||||
mkdir firelink-build
|
||||
cd firelink-build
|
||||
# Linux and Windows payloads are self-contained; do not inherit host dylibs.
|
||||
export LDFLAGS="-static ${LDFLAGS:-}"
|
||||
export PKG_CONFIG="pkg-config --static"
|
||||
../configure --enable-static --disable-shared --disable-nls \
|
||||
--without-gnutls --with-openssl --without-libxml2 --with-libexpat \
|
||||
--without-libgmp --without-libnettle --without-libgcrypt \
|
||||
--with-libssh2 --with-libcares
|
||||
make -j2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ function writePayloadManifest() {
|
||||
version: source.version,
|
||||
url: source.url || source.sourceUrl,
|
||||
sha256: source.sha256 || source.sourceSha256,
|
||||
...(source.buildFromSource ? { patchSha256: source.patchSha256, allocationTelemetry: true } : {}),
|
||||
...(name === 'aria2c' && source.firelinkRouteContract
|
||||
? { firelinkRouteContract: source.firelinkRouteContract }
|
||||
: {})
|
||||
@@ -194,7 +195,25 @@ try {
|
||||
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
|
||||
|
||||
const aria2 = await download('aria2c', targetSources.aria2c);
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
const aria2Source = targetSources.aria2c;
|
||||
if (aria2Source.buildFromSource !== true || aria2Source.allocationTelemetry !== true) {
|
||||
throw new Error('Aria2 provisioning requires the allocation telemetry source build.');
|
||||
}
|
||||
const patchFile = path.join(repoRoot, aria2Source.patch);
|
||||
if (sha256(patchFile) !== aria2Source.patchSha256) throw new Error('Aria2 source patch checksum mismatch');
|
||||
const sourceRoots = fs.readdirSync(aria2, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(aria2, entry.name, 'configure.ac')))
|
||||
.map(entry => path.join(aria2, entry.name));
|
||||
if (sourceRoots.length !== 1) throw new Error('Aria2 archive must contain exactly one source root');
|
||||
const [sourceRoot] = sourceRoots;
|
||||
const bash = isWindows ? path.join(process.env.FIRELINK_MSYS2_ROOT || 'C:/msys64', 'usr/bin/bash.exe') : 'bash';
|
||||
await execFileAsync(bash, [path.join(repoRoot, 'scripts/aria2/build.sh').replaceAll('\\', '/'), sourceRoot, patchFile], {
|
||||
signal: provisioningAbortController.signal,
|
||||
env: { ...process.env, ...(isWindows ? { MSYSTEM: 'MINGW64' } : {}) },
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
copyExecutable(path.join(sourceRoot, 'firelink-build', 'src', `aria2c${executableSuffix}`), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
throwIfProvisioningAborted();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -24,12 +25,16 @@ const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
|
||||
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
|
||||
: 5000;
|
||||
const READY_PORT_TIMEOUT_MS = 500;
|
||||
// Portable-package checks intentionally inspect their disposable bundle's
|
||||
// data directory. Every other smoke run gets its own disposable profile.
|
||||
const smokeStorageRoot = assertPortableData ? null : fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-smoke-')));
|
||||
const child = spawn(executable, [], {
|
||||
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
|
||||
detached: process.platform !== 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
FIRELINK_SMOKE_TEST: '1',
|
||||
FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '',
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
|
||||
GDK_BACKEND: 'x11',
|
||||
},
|
||||
@@ -401,5 +406,7 @@ try {
|
||||
if (!await terminateChild()) {
|
||||
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
|
||||
process.exitCode = 1;
|
||||
} else if (smokeStorageRoot) {
|
||||
fs.rmSync(smokeStorageRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
|
||||
assertAria2Baseline,
|
||||
assertAria2AllocationCapabilities,
|
||||
} from './aria2-route-contract.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -569,6 +570,7 @@ if (canExecuteTarget) {
|
||||
const resp = JSON.parse(result.data);
|
||||
if (resp?.result?.version) {
|
||||
assertAria2Baseline(resp.result);
|
||||
assertAria2AllocationCapabilities(resp.result);
|
||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||
} else {
|
||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||
|
||||
Binary file not shown.
+45
-2
@@ -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 = 3;
|
||||
const CURRENT_SCHEMA_VERSION: i64 = 4;
|
||||
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
|
||||
@@ -227,6 +227,13 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
|
||||
.map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?;
|
||||
}
|
||||
|
||||
if from_version < 4 {
|
||||
transaction.execute_batch("CREATE TABLE download_removal_jobs (
|
||||
id TEXT PRIMARY KEY, data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE download_removal_assets (id TEXT PRIMARY KEY, data TEXT NOT NULL);").map_err(|error| format!("failed to migrate removal jobs: {error}"))?;
|
||||
}
|
||||
|
||||
transaction
|
||||
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
||||
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
||||
@@ -1582,12 +1589,17 @@ fn sanitize_persisted_downloads(connection: &mut Connection) -> Result<(), Strin
|
||||
|
||||
fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> {
|
||||
transaction
|
||||
.execute("DELETE FROM downloads", [])
|
||||
.execute("DELETE FROM downloads WHERE id NOT IN (SELECT id FROM download_removal_jobs)", [])
|
||||
.map_err(|error| format!("failed to clear downloads: {error}"))?;
|
||||
for data in downloads {
|
||||
let value: Value = serde_json::from_str(data)
|
||||
.map_err(|error| format!("failed to decode download: {error}"))?;
|
||||
let id = required_string(&value, "id")?;
|
||||
// Native removal intent and terminal tombstones outrank renderer snapshots.
|
||||
if transaction.query_row("SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", [id], |row| row.get::<_, bool>(0))
|
||||
.map_err(|error| error.to_string())? {
|
||||
continue;
|
||||
}
|
||||
let status = required_string(&value, "status")?;
|
||||
let queue_id = value.get("queueId").and_then(Value::as_str);
|
||||
transaction
|
||||
@@ -2400,6 +2412,37 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn removal_jobs_preserve_intent_and_prevent_stale_snapshot_resurrection() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let db = init_at_path(root.path()).unwrap();
|
||||
let mut connection = db.lock().unwrap();
|
||||
let original = r#"[{"id":"remove-me","status":"paused","fileName":"payload"},{"id":"keep-me","status":"paused"}]"#;
|
||||
replace_downloads(&mut connection, original, false).unwrap();
|
||||
connection.execute("INSERT INTO download_removal_jobs VALUES ('remove-me', ?1)",
|
||||
[r#"{"id":"remove-me","deleteAssets":true,"phase":"pending","error":null}"#]).unwrap();
|
||||
replace_downloads(&mut connection, r#"[{"id":"keep-me","status":"completed"}]"#, false).unwrap();
|
||||
assert_eq!(load_downloads(&connection).unwrap().len(), 2);
|
||||
mutate_download(&mut connection, "remove-me", false, |row| {
|
||||
row.insert("status".into(), json!("completed"));
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
replace_downloads(&mut connection, original, false).unwrap();
|
||||
let completed: String = connection.query_row("SELECT status FROM downloads WHERE id='remove-me'", [], |row| row.get(0)).unwrap();
|
||||
assert_eq!(completed, "completed");
|
||||
connection.execute("DELETE FROM downloads WHERE id='remove-me'", []).unwrap();
|
||||
connection.execute("UPDATE download_removal_jobs SET data=?1 WHERE id='remove-me'",
|
||||
[r#"{"id":"remove-me","deleteAssets":true,"phase":"completed","error":null}"#]).unwrap();
|
||||
replace_downloads(&mut connection, original, false).unwrap();
|
||||
let saved = load_downloads(&connection).unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
assert!(saved[0].contains("keep-me"));
|
||||
drop(connection);
|
||||
drop(db);
|
||||
let reopened = init_at_path(root.path()).unwrap();
|
||||
assert_eq!(load_downloads(&reopened.lock().unwrap()).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_login_settings_update_preserves_envelope_without_password() {
|
||||
let original = json!({
|
||||
|
||||
@@ -1011,3 +1011,20 @@ impl DownloadStateEvent {
|
||||
(error, error_kind)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadRemovalJob {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub revision: u32,
|
||||
pub delete_assets: bool,
|
||||
pub phase: DownloadRemovalPhase,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadRemovalPhase { Pending, Running, Failed, Completed }
|
||||
|
||||
+160
-9
@@ -1,4 +1,5 @@
|
||||
#![allow(unexpected_cfgs)]
|
||||
mod removal_jobs;
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use futures_util::StreamExt;
|
||||
@@ -5253,6 +5254,7 @@ async fn pause_download(
|
||||
log::info!("pause_download called for id: {}", id);
|
||||
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
let registered_lifecycle_generation = state
|
||||
.queue_manager
|
||||
@@ -5551,6 +5553,7 @@ async fn resume_download(
|
||||
return Err("Queue id cannot be empty".to_string());
|
||||
}
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
||||
log::info!(
|
||||
"aria2 resume [{}]: no mapped gid; re-enqueue is permitted",
|
||||
@@ -6127,6 +6130,42 @@ async fn remove_download(
|
||||
expected_lifecycle_generation: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
remove_download_inner(app_handle, state, id, delete_assets, preserve_resumable,
|
||||
asset_removal_policy, expected_lifecycle_generation, false).await
|
||||
}
|
||||
|
||||
fn removal_status_has_completed_payload(status: &serde_json::Value) -> bool {
|
||||
status.get("status").and_then(serde_json::Value::as_str) == Some("complete")
|
||||
|| status.get("seeder").is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true"))
|
||||
}
|
||||
|
||||
async fn removal_payload_completed(port: u16, secret: &str, gid: &str) -> Result<bool, String> {
|
||||
match rpc_call(port, secret, "aria2.tellStatus",
|
||||
serde_json::json!([gid, ["status", "seeder", "totalLength", "completedLength"]])).await {
|
||||
Ok(status) => {
|
||||
let bytes = |key: &str| status.get(key).and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
Ok(removal_status_has_completed_payload(&status)
|
||||
|| matches!((bytes("totalLength"), bytes("completedLength")), (Some(total), Some(done)) if total > 0 && done == total))
|
||||
}
|
||||
// A purged result cannot prove that the payload was unfinished.
|
||||
// Preserve it through Trash instead of permitting irreversible cleanup.
|
||||
Err(error) if aria2_gid_not_found(&error) => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_download_inner(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
delete_assets: bool,
|
||||
preserve_resumable: Option<bool>,
|
||||
asset_removal_policy: Option<crate::ipc::DownloadAssetRemovalPolicy>,
|
||||
expected_lifecycle_generation: Option<String>,
|
||||
durable_removal_job: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
let preserve_resumable = preserve_resumable.unwrap_or(false);
|
||||
// The permanent policy is deliberately opt-in and is resolved against the
|
||||
@@ -6144,21 +6183,36 @@ async fn remove_download(
|
||||
})
|
||||
.transpose()?;
|
||||
let mut control_guard = Some(state.queue_manager.acquire_aria2_control(&id).await);
|
||||
if !durable_removal_job {
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
}
|
||||
let mut cleanup_control_guard: Option<queue::Aria2ControlGuard> = None;
|
||||
// Classify the removal from the durable row only after taking the same
|
||||
// lifecycle guard that protects stopping the native owner. This prevents
|
||||
// a completed/unfinished decision from racing a terminal transition or a
|
||||
// replacement lifecycle for the same download id.
|
||||
let permanent_asset_removal = if permanent_if_unfinished_requested {
|
||||
let mut permanent_asset_removal = if permanent_if_unfinished_requested {
|
||||
let persisted = load_persisted_download_item(
|
||||
&app_handle.state::<crate::db::DbState>(),
|
||||
&id,
|
||||
)?;
|
||||
!matches!(persisted.status, crate::ipc::DownloadStatus::Completed)
|
||||
let daemon_completed = if let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) {
|
||||
removal_payload_completed(state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret, &gid).await?
|
||||
} else { false };
|
||||
if daemon_completed {
|
||||
let db = app_handle.state::<crate::db::DbState>();
|
||||
crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| {
|
||||
row.insert("status".into(), serde_json::json!("completed"));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
!daemon_completed && !matches!(persisted.status, crate::ipc::DownloadStatus::Completed | crate::ipc::DownloadStatus::Seeding | crate::ipc::DownloadStatus::WaitingToSeed)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let removal_stop_started = Instant::now();
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
let registered_lifecycle_generation = state
|
||||
.queue_manager
|
||||
@@ -6238,6 +6292,17 @@ async fn remove_download(
|
||||
state.queue_manager.allow_aria2_retries(&id).await;
|
||||
return Err(error);
|
||||
}
|
||||
if permanent_asset_removal && removal_payload_completed(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret, gid).await?
|
||||
{
|
||||
let db = app_handle.state::<crate::db::DbState>();
|
||||
crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| {
|
||||
row.insert("status".into(), serde_json::json!("completed"));
|
||||
Ok(())
|
||||
})?;
|
||||
permanent_asset_removal = false;
|
||||
}
|
||||
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_torrent_telemetry(&id).await;
|
||||
@@ -6327,6 +6392,17 @@ async fn remove_download(
|
||||
state.queue_manager.allow_aria2_retries(&id).await;
|
||||
return Err(error);
|
||||
}
|
||||
if permanent_asset_removal && removal_payload_completed(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret, &late_gid).await?
|
||||
{
|
||||
let db = app_handle.state::<crate::db::DbState>();
|
||||
crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| {
|
||||
row.insert("status".into(), serde_json::json!("completed"));
|
||||
Ok(())
|
||||
})?;
|
||||
permanent_asset_removal = false;
|
||||
}
|
||||
state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_torrent_telemetry(&id).await;
|
||||
@@ -6360,6 +6436,19 @@ async fn remove_download(
|
||||
}
|
||||
};
|
||||
|
||||
if permanent_asset_removal && state.queue_manager
|
||||
.media_payload_completed(&id, media_lifecycle_generation).await
|
||||
{
|
||||
let db = app_handle.state::<crate::db::DbState>();
|
||||
crate::db::mutate_download(&mut *db.lock()?, &id, db.is_portable(), |row| {
|
||||
row.insert("status".into(), serde_json::json!("completed"));
|
||||
Ok(())
|
||||
})?;
|
||||
permanent_asset_removal = false;
|
||||
}
|
||||
|
||||
log::info!("download removal [id={} stage=stop elapsed_ms={}]", id, removal_stop_started.elapsed().as_millis());
|
||||
let removal_cleanup_started = Instant::now();
|
||||
let owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?;
|
||||
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
|
||||
let torrent_removal_paths =
|
||||
@@ -6462,6 +6551,25 @@ async fn remove_download(
|
||||
}
|
||||
}
|
||||
|
||||
if durable_removal_job {
|
||||
let mut roots = Vec::new();
|
||||
if should_delete_assets {
|
||||
roots.extend(owned_paths.iter().cloned());
|
||||
roots.extend(primary_path.iter().cloned());
|
||||
roots.extend(unowned_replacement_target.iter().map(|target| target.target.clone()));
|
||||
for path in roots.clone() {
|
||||
for suffix in [".aria2", ".part", ".ytdl"] {
|
||||
let mut sidecar = path.as_os_str().to_os_string();
|
||||
sidecar.push(suffix);
|
||||
roots.push(sidecar.into());
|
||||
}
|
||||
roots.extend(collect_media_processing_artifacts_for_permanent_removal(&path, &app_handle).await?);
|
||||
}
|
||||
}
|
||||
roots.push(crate::torrent::managed_torrent_path(&app_handle, &id)?);
|
||||
removal_jobs::fence_assets(&app_handle, &id, &roots)?;
|
||||
}
|
||||
|
||||
let cleanup_result = async {
|
||||
if should_delete_assets {
|
||||
for path in &owned_paths {
|
||||
@@ -6492,16 +6600,19 @@ async fn remove_download(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if permanent_asset_removal {
|
||||
if permanent_asset_removal || durable_removal_job {
|
||||
remove_managed_torrent_permanently(&app_handle, &id).await?;
|
||||
} else {
|
||||
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
|
||||
}
|
||||
crate::download_ownership::remove(&app_handle, &id)?;
|
||||
if !removal_jobs::has_job(&app_handle, &id)? {
|
||||
crate::download_ownership::remove(&app_handle, &id)?;
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
.await;
|
||||
drop(cleanup_target_guards);
|
||||
log::info!("download removal [id={} stage=cleanup success={} elapsed_ms={}]", id, cleanup_result.is_ok(), removal_cleanup_started.elapsed().as_millis());
|
||||
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
cleanup_result
|
||||
@@ -7500,6 +7611,7 @@ async fn detach_download_for_reconfigure(
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
log::info!("detach_download_for_reconfigure called for id: {}", id);
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
detach_download_for_reconfigure_locked(
|
||||
&app_handle,
|
||||
state.inner(),
|
||||
@@ -9611,6 +9723,7 @@ async fn enqueue_download_locked(
|
||||
mut item: queue::EnqueueItem,
|
||||
_control_guard: &queue::Aria2ControlGuard,
|
||||
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
|
||||
removal_jobs::ensure_not_removing(app_handle, &item.id).map_err(AppError::Internal)?;
|
||||
if item.is_torrent.unwrap_or(false) {
|
||||
validate_torrent_enqueue(app_handle, &mut item)
|
||||
.await
|
||||
@@ -9799,7 +9912,9 @@ async fn enqueue_many(
|
||||
// serialized with pause/resume/remove for this download. The guard is
|
||||
// intentionally held through every early-continue path below.
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
let validation = if item.is_torrent.unwrap_or(false) {
|
||||
let validation = if let Err(error) = removal_jobs::ensure_not_removing(&app_handle, &id) {
|
||||
Err(error)
|
||||
} else if item.is_torrent.unwrap_or(false) {
|
||||
validate_torrent_enqueue(&app_handle, &mut item).await
|
||||
} else {
|
||||
match queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) {
|
||||
@@ -10090,6 +10205,7 @@ async fn remove_from_queue(
|
||||
) -> Result<bool, AppError> {
|
||||
properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?;
|
||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id).map_err(AppError::Internal)?;
|
||||
let removed = state.queue_manager.remove_from_pending(&id).await;
|
||||
if removed {
|
||||
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||
@@ -10430,6 +10546,7 @@ async fn set_torrent_file_selection(
|
||||
) -> Result<crate::ipc::TorrentFileSelectionSnapshot, String> {
|
||||
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
let item = load_persisted_torrent_item(database.inner(), &id)?;
|
||||
if item.is_torrent != Some(true) {
|
||||
return Err("file selection is available only for Torrent downloads".to_string());
|
||||
@@ -11252,6 +11369,7 @@ async fn move_torrent_data(
|
||||
Some(session_id)
|
||||
};
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
if let Some(session_id) = properties_session_id.as_deref() {
|
||||
if !properties.session_matches(caller.label(), session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
@@ -11788,6 +11906,7 @@ async fn verify_torrent_data(
|
||||
// replacement with pause/resume/remove so a paused GID cannot reject the
|
||||
// maintenance enqueue as a duplicate task or race it with a late event.
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&app_handle, &id)?;
|
||||
let item = load_persisted_torrent_item(database.inner(), &id)?;
|
||||
if item.is_torrent != Some(true) {
|
||||
return Err("integrity verification is available only for Torrent downloads".to_string());
|
||||
@@ -12139,6 +12258,7 @@ async fn set_torrent_web_seeds(
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
removal_jobs::ensure_not_removing(&state.queue_manager.app_handle(), &id)?;
|
||||
let active = state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
|
||||
let normalized = if active {
|
||||
@@ -14116,6 +14236,17 @@ fn ack_extension_download(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn removal_recognizes_completed_seeders_without_confusing_downloaders() {
|
||||
for status in [serde_json::json!({"status": "complete"}),
|
||||
serde_json::json!({"status": "active", "seeder": "true"}),
|
||||
serde_json::json!({"status": "paused", "seeder": true})] {
|
||||
assert!(super::removal_status_has_completed_payload(&status));
|
||||
}
|
||||
assert!(!super::removal_status_has_completed_payload(&serde_json::json!({"status": "active", "seeder": "false"})));
|
||||
assert!(!super::removal_status_has_completed_payload(&serde_json::json!({})));
|
||||
}
|
||||
|
||||
use super::{
|
||||
aggregate_media_byte_progress, aggregate_media_fraction, append_ytdlp_config_option,
|
||||
append_ytdlp_http_headers,
|
||||
@@ -19485,8 +19616,12 @@ pub fn run() {
|
||||
"errorCode",
|
||||
"errorMessage",
|
||||
"verifiedLength",
|
||||
"verifyIntegrityPending"
|
||||
"verifyIntegrityPending",
|
||||
"fileAllocationPending"
|
||||
]]);
|
||||
let allocation_mappings: HashMap<_, _> = poll_mgr.aria2_gid_mappings().into_iter()
|
||||
.filter_map(|(gid, _)| poll_mgr.aria2_gid_mapping(&gid).map(|mapping| (gid, mapping)))
|
||||
.collect();
|
||||
let active_poll_started = Instant::now();
|
||||
let active_list = match rpc_call(
|
||||
poll_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -19877,9 +20012,17 @@ pub fn run() {
|
||||
// notification was lost, this is sufficient
|
||||
// to end the allocation phase for the same
|
||||
// mapped lifecycle.
|
||||
poll_mgr
|
||||
.complete_aria2_allocation_for_gid(gid, completed)
|
||||
.await;
|
||||
if let Some(pending) = status_info.get("fileAllocationPending").and_then(|value| value.as_bool()) {
|
||||
if let Some(observed) = allocation_mappings.get(gid) {
|
||||
poll_mgr.observe_aria2_allocation(gid, observed, pending && !verify_pending && verified_length.is_none()).await;
|
||||
}
|
||||
} else if is_torrent {
|
||||
if let Some(observed) = allocation_mappings.get(gid) {
|
||||
poll_mgr.observe_aria2_allocation(gid, observed, false).await;
|
||||
}
|
||||
} else {
|
||||
poll_mgr.complete_aria2_allocation_for_gid(gid, completed).await;
|
||||
}
|
||||
if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping)
|
||||
|| !poll_mgr
|
||||
.is_aria2_control_epoch_current(&id, control_epoch)
|
||||
@@ -20393,6 +20536,10 @@ pub fn run() {
|
||||
]);
|
||||
let download_queue_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
|
||||
remove_download,
|
||||
removal_jobs::submit_download_removals,
|
||||
removal_jobs::list_download_removals,
|
||||
removal_jobs::resume_download_removals,
|
||||
removal_jobs::retry_download_removal,
|
||||
get_download_primary_path,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download,
|
||||
@@ -20522,6 +20669,10 @@ pub fn run() {
|
||||
| "set_torrent_overall_upload_limit"
|
||||
| "set_global_speed_limit" => torrent_storage_handler(invoke),
|
||||
"remove_download"
|
||||
| "submit_download_removals"
|
||||
| "list_download_removals"
|
||||
| "resume_download_removals"
|
||||
| "retry_download_removal"
|
||||
| "get_download_primary_path"
|
||||
| "detach_download_for_reconfigure"
|
||||
| "enqueue_download"
|
||||
|
||||
@@ -39,7 +39,7 @@ fn windows_directory_identity(path: &Path) -> io::Result<String> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn file_identity(path: &Path) -> Option<String> {
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
+103
-1
@@ -1317,6 +1317,7 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// Current Aria2 lifecycles whose files are expected to be preallocated.
|
||||
/// The generation fences late start/clear events from an older GID.
|
||||
aria2_allocation_pending: Mutex<HashMap<String, (u64, u64)>>,
|
||||
media_completed_generations: Mutex<HashMap<String, u64>>,
|
||||
|
||||
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
||||
@@ -1426,6 +1427,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_download_starts: Arc::new(Mutex::new(HashSet::new())),
|
||||
aria2_allocation_pending: Mutex::new(HashMap::new()),
|
||||
media_completed_generations: Mutex::new(HashMap::new()),
|
||||
aria2_payloads: Mutex::new(HashMap::new()),
|
||||
aria2_connection_options: Mutex::new(HashMap::new()),
|
||||
aria2_dispatch_inflight: Mutex::new(HashMap::new()),
|
||||
@@ -1852,6 +1854,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|| !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2))
|
||||
{
|
||||
self.abandon_seed_start(&id);
|
||||
if self.removal_requested(&id) { self.release_seed_tracking(&id); }
|
||||
return;
|
||||
}
|
||||
let control_epoch = self.current_aria2_control_epoch(&id).await;
|
||||
@@ -1875,10 +1878,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|| !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2))
|
||||
|| self.aria2_gid_for_download(&id).as_deref() != Some(gid.as_str())
|
||||
|| !self.is_aria2_control_epoch_current(&id, control_epoch).await
|
||||
|| self.removal_requested(&id)
|
||||
{
|
||||
self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation)
|
||||
.await;
|
||||
self.abandon_seed_start(&id);
|
||||
if self.removal_requested(&id) { self.release_seed_tracking(&id); }
|
||||
return;
|
||||
}
|
||||
let epoch = self.next_aria2_control_epoch(&id).await;
|
||||
@@ -2091,6 +2096,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
|
||||
pub async fn release_registered_id(&self, id: &str) {
|
||||
self.media_completed_generations.lock().await.remove(id);
|
||||
self.registered_ids.lock().await.remove(id);
|
||||
self.registered_lifecycle_generations.lock().await.remove(id);
|
||||
// A released lifecycle cannot be resumed by a delayed retry worker.
|
||||
@@ -2142,6 +2148,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
};
|
||||
if released {
|
||||
self.media_completed_generations.lock().await.remove(id);
|
||||
self.aria2_retry_cancelled.lock().await.remove(id);
|
||||
self.release_seed_tracking(id);
|
||||
self.notify.notify_waiters();
|
||||
@@ -4270,6 +4277,27 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply explicit engine telemetry only to the observed native lifecycle.
|
||||
pub async fn observe_aria2_allocation(&self, gid: &str, mapping: &Aria2GidMapping, pending: bool) {
|
||||
let _guard = self.acquire_aria2_control(&mapping.id).await;
|
||||
if !self.is_current_aria2_gid_mapping(gid, mapping)
|
||||
|| !self.is_aria2_control_epoch_current(&mapping.id, mapping.epoch).await {
|
||||
return;
|
||||
}
|
||||
let Some(generation) = self.registered_lifecycle_generation(&mapping.id).await else { return; };
|
||||
let changed = {
|
||||
let mut entries = self.aria2_allocation_pending.lock().await;
|
||||
if pending {
|
||||
entries.insert(mapping.id.clone(), (mapping.epoch, generation)) != Some((mapping.epoch, generation))
|
||||
} else {
|
||||
entries.remove(&mapping.id).is_some()
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.emit_allocation_event(&mapping.id, pending, generation);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete_aria2_allocation_for_gid(&self, gid: &str, downloaded_bytes: u64) {
|
||||
// Aria2 reports an active GID with completedLength=0 while it is still
|
||||
// creating preallocated files. That observation is not native
|
||||
@@ -4291,6 +4319,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
};
|
||||
mapping
|
||||
};
|
||||
if self.aria2_is_torrent(&mapping.id).await { return; }
|
||||
if !self
|
||||
.is_current_aria2_gid_mapping(gid, &mapping)
|
||||
|| !self
|
||||
@@ -4324,6 +4353,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
};
|
||||
mapping
|
||||
};
|
||||
if self.aria2_is_torrent(&mapping.id).await { return; }
|
||||
if !self
|
||||
.is_current_aria2_gid_mapping(gid, &mapping)
|
||||
|| !self
|
||||
@@ -4381,6 +4411,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
/// The long-running dispatcher. One instance is spawned in setup().
|
||||
/// It scans for a queue with capacity before reserving the global slot, so
|
||||
/// a saturated front queue cannot block later eligible queues.
|
||||
fn removal_requested(&self, id: &str) -> bool {
|
||||
let Some(db) = self.app_handle.try_state::<crate::db::DbState>() else { return false; };
|
||||
let Ok(connection) = db.lock() else { return true; };
|
||||
connection.query_row("SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)", [id], |row| row.get::<_, bool>(0)).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub async fn run_dispatcher(self: Arc<Self>) {
|
||||
loop {
|
||||
let notified = self.notify.notified();
|
||||
@@ -4417,7 +4453,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
// permit and active kind under the same guard prevents that command
|
||||
// from observing a half-started lifecycle.
|
||||
let control_guard = self.acquire_aria2_control(&id).await;
|
||||
if !self
|
||||
if self.removal_requested(&id) || !self
|
||||
.is_registered_generation(&id, lifecycle_generation)
|
||||
.await
|
||||
{
|
||||
@@ -4645,6 +4681,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn media_payload_completed(&self, id: &str, generation: u64) -> bool {
|
||||
self.media_completed_generations.lock().await.get(id).copied() == Some(generation)
|
||||
}
|
||||
|
||||
fn persist_completed_removal_payload(&self, id: &str) -> Result<(), String> {
|
||||
if !self.removal_requested(id) { return Ok(()); }
|
||||
if let Some(db) = self.app_handle.try_state::<crate::db::DbState>() {
|
||||
crate::db::mutate_download(&mut *db.lock()?, id, db.is_portable(), |row| {
|
||||
row.insert("status".into(), serde_json::json!("completed"));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Terminal handler for non-aria2 transfers. Emits state and frees the permit.
|
||||
/// Intentional cancellation is silent, but still releases backend ownership.
|
||||
/// Note: `id` is the frontend download UUID, which survives indefinitely as
|
||||
@@ -4671,6 +4722,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
if self.persist_completed_removal_payload(id).is_err() {
|
||||
log::error!("could not persist media completion during removal [id={}]", id);
|
||||
return;
|
||||
}
|
||||
self.emit_state(id, DownloadStatus::Completed);
|
||||
self.release_registered_id_for_generation(id, lifecycle_generation)
|
||||
.await;
|
||||
@@ -4861,6 +4916,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
})
|
||||
.unwrap_or((None, false, false))
|
||||
};
|
||||
if matches!(outcome, PendingOutcome::Complete | PendingOutcome::Seeding)
|
||||
&& !(verification_only && !verification_observed)
|
||||
&& self.persist_completed_removal_payload(id).is_err()
|
||||
{
|
||||
log::error!("could not persist completion during removal [id={}]", id);
|
||||
return;
|
||||
}
|
||||
let outcome = match outcome {
|
||||
PendingOutcome::Complete if verification_only && !verification_observed => {
|
||||
PendingOutcome::Error(
|
||||
@@ -9010,6 +9072,13 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
.await
|
||||
};
|
||||
if let Ok(path) = outcome.as_ref() {
|
||||
// Publish completion evidence before Finished acknowledges a racing
|
||||
// removal. The control lock may be held by that waiting remover.
|
||||
state.queue_manager.media_completed_generations.lock().await
|
||||
.insert(id.to_string(), lifecycle_generation);
|
||||
if state.queue_manager.persist_completed_removal_payload(id).is_err() {
|
||||
log::error!("could not persist media completion before removal acknowledgement [id={}]", id);
|
||||
}
|
||||
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, path);
|
||||
if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
|
||||
use tauri::Emitter;
|
||||
@@ -9323,6 +9392,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn media_removal_completion_evidence_is_lifecycle_fenced() {
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets())).unwrap();
|
||||
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
|
||||
manager.reserve_enqueue_generation("media-removal", 7).await.unwrap();
|
||||
manager.media_completed_generations.lock().await.insert("media-removal".into(), 7);
|
||||
assert!(manager.media_payload_completed("media-removal", 7).await);
|
||||
assert!(!manager.media_payload_completed("media-removal", 8).await);
|
||||
manager.release_registered_id_for_generation("media-removal", 6).await;
|
||||
assert!(manager.media_payload_completed("media-removal", 7).await);
|
||||
manager.release_registered_id_for_generation("media-removal", 7).await;
|
||||
assert!(!manager.media_payload_completed("media-removal", 7).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_allocation_telemetry_ends_without_payload_and_rejects_old_epoch() {
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets())).unwrap();
|
||||
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
|
||||
manager.reserve_enqueue_generation("telemetry", 7).await.unwrap();
|
||||
manager.next_aria2_control_epoch("telemetry").await;
|
||||
manager.remember_gid("telemetry".into(), "telemetry-gid".into()).await;
|
||||
let observed = manager.aria2_gid_mapping("telemetry-gid").unwrap();
|
||||
manager.observe_aria2_allocation("telemetry-gid", &observed, true).await;
|
||||
assert_eq!(allocation_pending_epoch(&manager, "telemetry"), Some((observed.epoch, 7)));
|
||||
manager.observe_aria2_allocation("telemetry-gid", &observed, false).await;
|
||||
assert_eq!(allocation_pending_epoch(&manager, "telemetry"), None);
|
||||
manager.next_aria2_control_epoch("telemetry").await;
|
||||
manager.observe_aria2_allocation("telemetry-gid", &observed, true).await;
|
||||
assert_eq!(allocation_pending_epoch(&manager, "telemetry"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allocation_stays_pending_while_async_add_uri_is_in_flight() {
|
||||
let app = tauri::test::mock_builder()
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Durable removal intent is independent of renderer download snapshots. Completed
|
||||
//! jobs remain as tombstones, so an old save can never recreate a deleted UUID.
|
||||
use crate::ipc::{DownloadAssetRemovalPolicy, DownloadRemovalJob, DownloadRemovalPhase as Phase};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
static WORKER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn jobs(connection: &Connection) -> Result<Vec<DownloadRemovalJob>, String> {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT data FROM download_removal_jobs ORDER BY rowid")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.map(|row| {
|
||||
serde_json::from_str(&row.map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save(connection: &Connection, job: &DownloadRemovalJob) -> Result<(), String> {
|
||||
connection.execute("INSERT INTO download_removal_jobs(id,data) VALUES(?1,?2) ON CONFLICT(id) DO UPDATE SET data=excluded.data",
|
||||
params![job.id, serde_json::to_string(job).map_err(|e| e.to_string())?]).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn has_job(app: &tauri::AppHandle, id: &str) -> Result<bool, String> {
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let connection = db.lock()?;
|
||||
let exists: bool = connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM download_removal_jobs WHERE id=?1)",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_not_removing(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
if has_job(app, id)? {
|
||||
Err("Download removal is pending or requires retry".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_download_removals(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Vec<DownloadRemovalJob>, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
jobs(&*app.state::<crate::db::DbState>().lock()?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn submit_download_removals(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
ids: Vec<String>,
|
||||
delete_assets: bool,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
// Fence each admission before recording intent, retaining all existing rows
|
||||
// and ownership records until physical cleanup has actually succeeded.
|
||||
let result = async {
|
||||
let state = app.state::<crate::AppState>();
|
||||
for id in ids {
|
||||
let _guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||
let job = {
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let connection = db.lock()?;
|
||||
let existing: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT data FROM download_removal_jobs WHERE id=?1",
|
||||
[&id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if existing.is_some() {
|
||||
continue;
|
||||
}
|
||||
let exists: bool = connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM downloads WHERE id=?1)",
|
||||
[&id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !exists {
|
||||
return Err("Download is not durably saved".into());
|
||||
}
|
||||
let job = DownloadRemovalJob {
|
||||
id: id.clone(),
|
||||
revision: 1,
|
||||
delete_assets,
|
||||
phase: Phase::Pending,
|
||||
error: None,
|
||||
};
|
||||
save(&connection, &job)?;
|
||||
job
|
||||
};
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||
if state.queue_manager.is_waiting_to_seed(&id) {
|
||||
state.queue_manager.release_seed_tracking(&id);
|
||||
}
|
||||
let _ = app.emit("download-removal", &job);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
kick(&app);
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_download_removals(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
kick(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn retry_download_removal(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
{
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let connection = db.lock()?;
|
||||
let mut job = jobs(&connection)?
|
||||
.into_iter()
|
||||
.find(|job| job.id == id)
|
||||
.ok_or("Removal job not found")?;
|
||||
if job.phase != Phase::Failed {
|
||||
return Ok(());
|
||||
}
|
||||
job.revision = job.revision.saturating_add(1);
|
||||
job.phase = Phase::Pending;
|
||||
job.error = None;
|
||||
save(&connection, &job)?;
|
||||
let _ = app.emit("download-removal", &job);
|
||||
}
|
||||
kick(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kick(app: &tauri::AppHandle) {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _worker = WORKER.lock().await;
|
||||
// Filesystem guards include synchronous platform APIs. Run the entire
|
||||
// cleanup on a blocking thread, with async RPC/timers using the runtime.
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let result = tauri::async_runtime::spawn_blocking(move || runtime.block_on(run(app))).await;
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
log::error!("download removal worker stopped; durable jobs retained for recovery");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run(app: tauri::AppHandle) -> Result<(), String> {
|
||||
loop {
|
||||
let next = {
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let connection = db.lock()?;
|
||||
jobs(&connection)?
|
||||
.into_iter()
|
||||
.find(|job| matches!(job.phase, Phase::Pending | Phase::Running))
|
||||
};
|
||||
let Some(mut job) = next else {
|
||||
return Ok(());
|
||||
};
|
||||
job.revision = job.revision.saturating_add(1);
|
||||
job.phase = Phase::Running;
|
||||
let saved = app
|
||||
.state::<crate::db::DbState>()
|
||||
.lock()
|
||||
.and_then(|connection| save(&connection, &job));
|
||||
if let Err(error) = saved {
|
||||
emit_persistence_failure(&app, &mut job);
|
||||
return Err(error);
|
||||
}
|
||||
let _ = app.emit("download-removal", &job);
|
||||
let started = std::time::Instant::now();
|
||||
let result = crate::remove_download_inner(
|
||||
app.clone(),
|
||||
app.state::<crate::AppState>(),
|
||||
job.id.clone(),
|
||||
job.delete_assets,
|
||||
Some(false),
|
||||
Some(DownloadAssetRemovalPolicy::PermanentIfUnfinished),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
job.revision = job.revision.saturating_add(1);
|
||||
let committed = (|| -> Result<(), String> {
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let mut connection = db.lock()?;
|
||||
let tx = connection.transaction().map_err(|e| e.to_string())?;
|
||||
if result.is_ok() {
|
||||
tx.execute("DELETE FROM download_ownership WHERE id=?1", [&job.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM download_owned_paths WHERE id=?1", [&job.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM download_removal_paths WHERE id=?1", [&job.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM download_removal_assets WHERE id=?1", [&job.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM downloads WHERE id=?1", [&job.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
job.phase = Phase::Completed;
|
||||
job.error = None;
|
||||
} else {
|
||||
job.phase = Phase::Failed;
|
||||
// Native errors can contain private paths. Keep only actionable,
|
||||
// safe UI guidance in the durable record and public event.
|
||||
job.error = Some("Removal could not finish. Close programs using the files, check drive access and permissions, then retry removal.".into());
|
||||
}
|
||||
save(&tx, &job)?;
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
})();
|
||||
if let Err(error) = committed {
|
||||
emit_persistence_failure(&app, &mut job);
|
||||
return Err(error);
|
||||
}
|
||||
log::info!(
|
||||
"download removal [id={} phase={:?} elapsed_ms={}]",
|
||||
job.id,
|
||||
job.phase,
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
let _ = app.emit("download-removal", &job);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_persistence_failure(app: &tauri::AppHandle, job: &mut DownloadRemovalJob) {
|
||||
job.phase = Phase::Failed;
|
||||
job.error = Some(
|
||||
"Removal could not be saved. Check disk space and drive access, then retry removal.".into(),
|
||||
);
|
||||
let _ = app.emit("download-removal", &*job);
|
||||
}
|
||||
|
||||
// Kept in a private table, never in shared IPC job data: paths and filesystem
|
||||
// identities are authorization evidence, not diagnostic or presentation data.
|
||||
type AssetManifest = std::collections::BTreeMap<std::path::PathBuf, String>;
|
||||
|
||||
fn snapshot_assets(roots: &[std::path::PathBuf]) -> Result<AssetManifest, String> {
|
||||
let mut pending = roots.to_vec();
|
||||
let mut manifest = AssetManifest::new();
|
||||
while let Some(path) = pending.pop() {
|
||||
if manifest.contains_key(&path) {
|
||||
continue;
|
||||
}
|
||||
let metadata = match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(_) => return Err("Could not inspect removal assets".into()),
|
||||
};
|
||||
if crate::metadata_is_link_or_reparse(&metadata) || crate::path_has_symlink_component(&path)
|
||||
{
|
||||
return Err("Removal asset contains a symbolic link or reparse point".into());
|
||||
}
|
||||
let identity = crate::target_identity(&path, &metadata);
|
||||
if identity.starts_with("windows-path:") || identity == "portable" {
|
||||
return Err("Could not establish removal asset identity".into());
|
||||
}
|
||||
let signature = if metadata.is_dir() {
|
||||
// Directory mtime changes as its children are removed; identity and
|
||||
// birth time remain stable across partial cleanup and restart.
|
||||
pending.extend(
|
||||
std::fs::read_dir(&path)
|
||||
.map_err(|_| "Could not inspect removal directory")?
|
||||
.map(|entry| entry.map(|entry| entry.path()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| "Could not inspect removal entry")?,
|
||||
);
|
||||
format!("dir:{identity}:{:?}", metadata.created().ok())
|
||||
} else if metadata.is_file() {
|
||||
format!(
|
||||
"file:{identity}:{:?}:{}:{}",
|
||||
metadata.created().ok(),
|
||||
metadata.len(),
|
||||
crate::target_modified(&metadata)
|
||||
)
|
||||
} else {
|
||||
return Err("Removal asset is not a regular file or directory".into());
|
||||
};
|
||||
manifest.insert(path, signature);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn validate_manifest(expected: &AssetManifest, current: &AssetManifest) -> Result<(), String> {
|
||||
// Missing entries are expected after interrupted cleanup. Newly created or
|
||||
// replaced entries never inherit authorization from the old path owner.
|
||||
if current
|
||||
.iter()
|
||||
.any(|(path, signature)| expected.get(path) != Some(signature))
|
||||
{
|
||||
return Err("Removal assets changed since cleanup began".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn fence_assets(
|
||||
app: &tauri::AppHandle,
|
||||
id: &str,
|
||||
roots: &[std::path::PathBuf],
|
||||
) -> Result<(), String> {
|
||||
let current = snapshot_assets(roots)?;
|
||||
let db = app.state::<crate::db::DbState>();
|
||||
let connection = db.lock()?;
|
||||
let previous: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT data FROM download_removal_assets WHERE id=?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(previous) = previous {
|
||||
validate_manifest(
|
||||
&serde_json::from_str(&previous).map_err(|_| "Invalid removal asset manifest")?,
|
||||
¤t,
|
||||
)
|
||||
} else {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO download_removal_assets(id,data) VALUES(?1,?2)",
|
||||
params![
|
||||
id,
|
||||
serde_json::to_string(¤t).map_err(|e| e.to_string())?
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn interrupted_cleanup_rejects_replacement_and_new_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let a = root.join("a");
|
||||
let b = root.join("b");
|
||||
std::fs::write(&a, b"original").unwrap();
|
||||
std::fs::write(&b, b"original").unwrap();
|
||||
let roots = vec![root.clone()];
|
||||
let manifest = snapshot_assets(&roots).unwrap();
|
||||
std::fs::remove_file(&a).unwrap();
|
||||
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok());
|
||||
let replacement = root.join("replacement");
|
||||
std::fs::write(&replacement, b"original").unwrap();
|
||||
std::fs::rename(&replacement, &a).unwrap();
|
||||
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||
std::fs::remove_file(&a).unwrap();
|
||||
std::fs::write(directory.path().join("new"), b"unrelated").unwrap();
|
||||
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn retry_allows_permission_repair_but_rejects_content_changes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = directory.path().canonicalize().unwrap().join("file");
|
||||
std::fs::write(&file, b"original").unwrap();
|
||||
let roots = vec![file.clone()];
|
||||
let manifest = snapshot_assets(&roots).unwrap();
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_ok());
|
||||
std::fs::write(&file, b"changed content").unwrap();
|
||||
assert!(validate_manifest(&manifest, &snapshot_assets(&roots).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn removal_manifest_does_not_follow_links() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
std::os::unix::fs::symlink(&root, root.join("link")).unwrap();
|
||||
assert!(snapshot_assets(&[root]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@ pub enum StorageMode {
|
||||
|
||||
impl StorageMode {
|
||||
pub fn detect() -> Self {
|
||||
// Packaged smoke runs must never migrate or mutate the installed app's
|
||||
// database. The harness creates an isolated root before launching us.
|
||||
if std::env::var("FIRELINK_SMOKE_TEST").as_deref() == Ok("1") {
|
||||
if let Some(root) = std::env::var_os("FIRELINK_SMOKE_STORAGE_ROOT").filter(|root| !root.is_empty()) {
|
||||
let root = PathBuf::from(root);
|
||||
assert!(root.is_absolute() && root.is_dir(), "invalid smoke storage root");
|
||||
return Self::Portable { root };
|
||||
}
|
||||
}
|
||||
let Some(executable) = std::env::current_exe().ok() else {
|
||||
return Self::Standard;
|
||||
};
|
||||
|
||||
@@ -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 { DownloadRemovalPhase } from "./DownloadRemovalPhase";
|
||||
|
||||
export type DownloadRemovalJob = { id: string, revision: number, deleteAssets: boolean, phase: DownloadRemovalPhase, error: string | null, };
|
||||
@@ -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 DownloadRemovalPhase = "pending" | "running" | "failed" | "completed";
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -6,20 +6,11 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
|
||||
export const DeleteConfirmationModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const { deleteModalState, closeDeleteModal, requestRemovals, downloads } = useDownloadStore();
|
||||
const modalRef = useModalFocus(deleteModalState.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteModalState.isOpen) {
|
||||
setIsRemoving(false);
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [deleteModalState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteModalState.isOpen || isRemoving) return;
|
||||
if (!deleteModalState.isOpen) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
|
||||
event.preventDefault();
|
||||
@@ -28,7 +19,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [closeDeleteModal, deleteModalState.isOpen, isRemoving]);
|
||||
}, [closeDeleteModal, deleteModalState.isOpen]);
|
||||
|
||||
if (!deleteModalState.isOpen) return null;
|
||||
|
||||
@@ -43,35 +34,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemoving(true);
|
||||
setErrorMessage('');
|
||||
let succeeded = 0;
|
||||
const failures: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await removeDownload(
|
||||
id,
|
||||
deleteFile,
|
||||
false,
|
||||
deleteFile ? 'permanentIfUnfinished' : undefined
|
||||
);
|
||||
succeeded += 1;
|
||||
} catch (error) {
|
||||
failures.push(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
|
||||
succeeded,
|
||||
failed: failures.length,
|
||||
detail: failures[0],
|
||||
}));
|
||||
setIsRemoving(false);
|
||||
return;
|
||||
}
|
||||
setIsRemoving(false);
|
||||
closeDeleteModal();
|
||||
await requestRemovals(ids, deleteFile);
|
||||
};
|
||||
|
||||
const handleRemoveFromList = () => removeMany(false);
|
||||
@@ -87,7 +50,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isRemoving) handleCancel();
|
||||
if (event.target === event.currentTarget) handleCancel();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -116,27 +79,23 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
{t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isRemoving}
|
||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.actions.cancel)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveFromList}
|
||||
disabled={isRemoving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.dialogs.removeDownload.remove)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteFile}
|
||||
disabled={isRemoving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.dialogs.removeDownload.deleteFile)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDownloadStore } from "../store/useDownloadStore";
|
||||
import React from 'react';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
|
||||
@@ -79,6 +80,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
const removal = useDownloadStore(state => state.removalJobs[download.id]);
|
||||
const removing = !!removal && removal.phase !== "failed" && removal.phase !== "completed";
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -87,7 +90,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const [isActionHovered, setIsActionHovered] = React.useState(false);
|
||||
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||
const waitingForPeers = isTorrentWaitingForPeers({
|
||||
const waitingForPeers = !removal && isTorrentWaitingForPeers({
|
||||
isTorrent: download.isTorrent,
|
||||
status: download.status,
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
@@ -95,9 +98,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
connectedPeers: liveProgress?.active_connections,
|
||||
connectedSeeders: liveProgress?.num_seeders,
|
||||
});
|
||||
const allocationVisible = download.isTorrent !== true
|
||||
&& isAllocationPhaseVisible(allocationPending, download.status);
|
||||
const hasRowActions = download.status !== 'completed';
|
||||
const allocationVisible = !removal && isAllocationPhaseVisible(allocationPending, download.status);
|
||||
const hasRowActions = !removal && download.status !== 'completed';
|
||||
const isBulkSelection = isSelected && selectedDownloadCount > 1;
|
||||
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
|
||||
? selectedActionCounts.pause
|
||||
@@ -216,7 +218,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
status: download.status,
|
||||
});
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
const displaySpeed = allocationVisible
|
||||
const displaySpeed = removal || allocationVisible
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? liveProgress?.upload_speed ?? '-'
|
||||
@@ -225,7 +227,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: download.status === 'processing'
|
||||
? t($ => $.downloads.values.processing)
|
||||
: '-';
|
||||
const displayEta = allocationVisible
|
||||
const displayEta = removal || allocationVisible
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
||||
@@ -248,7 +250,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const downloadStatusLabel = allocationVisible
|
||||
const downloadStatusLabel = removal
|
||||
? t($ => removal.phase === 'failed' ? $.downloads.removal.error : $.downloads.removal.removing)
|
||||
: allocationVisible
|
||||
? t($ => $.downloads.status.allocatingFiles)
|
||||
: waitingForPeers
|
||||
? t($ => $.downloads.status.waitingForPeers)
|
||||
@@ -344,14 +348,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div className="download-cell-content download-status-content">
|
||||
<div
|
||||
className="download-progress-track"
|
||||
aria-label={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
aria-busy={allocationVisible ? true : undefined}
|
||||
aria-valuetext={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
role={allocationVisible || waitingForPeers ? 'progressbar' : undefined}
|
||||
aria-label={allocationVisible || waitingForPeers || removing ? downloadStatusLabel : undefined}
|
||||
aria-busy={allocationVisible || removing ? true : undefined}
|
||||
aria-valuetext={allocationVisible || waitingForPeers || removing ? downloadStatusLabel : undefined}
|
||||
role={allocationVisible || waitingForPeers || removing ? 'progressbar' : undefined}
|
||||
>
|
||||
<div
|
||||
className={`download-progress-fill ${
|
||||
allocationVisible ? 'allocating' :
|
||||
allocationVisible || removing ? 'allocating' :
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'seeding' ? 'seeding' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
@@ -360,7 +364,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
style={{ width: allocationVisible ? undefined : `${displayFraction * 100}%` }}
|
||||
style={{ width: allocationVisible || removing ? undefined : `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
@@ -384,7 +388,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: downloadStatusLabel
|
||||
}
|
||||
className={`download-status flex items-center gap-1.5 ${
|
||||
allocationVisible ? 'download-status-downloading' :
|
||||
removal?.phase === 'failed' ? 'download-status-failed' :
|
||||
removing || allocationVisible ? 'download-status-downloading' :
|
||||
download.status === 'paused' ? 'download-status-paused' :
|
||||
download.status === 'seeding' ? 'download-status-seeding' :
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
@@ -396,7 +401,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||
}`}
|
||||
>
|
||||
{allocationVisible ? (
|
||||
{removal ? (
|
||||
<>
|
||||
{removing && <RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />}
|
||||
<span role="status" className="truncate" title={removal.phase === 'failed' ? t($ => $.downloads.removal.failed) : undefined}>{downloadStatusLabel}</span>
|
||||
{removal.phase === 'failed' && <button className="app-button shrink-0" onClick={event => { event.stopPropagation(); void useDownloadStore.getState().retryRemoval(download.id); }}>{t($ => $.downloads.removal.retry)}</button>}
|
||||
</>
|
||||
) : allocationVisible ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{downloadStatusLabel}</span>
|
||||
@@ -567,7 +578,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
// capture the pointer and suppress the click that applies Cmd/Ctrl or
|
||||
// Shift selection.
|
||||
if (
|
||||
isQueueReorderable &&
|
||||
!removal && isQueueReorderable &&
|
||||
!event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
@@ -579,7 +590,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick={(e) => onClick(e, download)}
|
||||
onKeyDown={event => {
|
||||
if (
|
||||
isQueueReorderable &&
|
||||
!removal && isQueueReorderable &&
|
||||
event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
@@ -595,6 +606,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (removal) return;
|
||||
const isKeyboard = (e.clientX === 0 && e.clientY === 0) || (e.button === 0 && e.detail === 0);
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
@@ -1671,18 +1671,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}
|
||||
}, [queueReorderableDownloads, queueReorderingEnabled]);
|
||||
|
||||
const removalJobs = useDownloadStore(state => state.removalJobs);
|
||||
const selectedDownloads = useMemo(
|
||||
() => filteredDownloads.filter(download => selectedIds.has(download.id)),
|
||||
[filteredDownloads, selectedIds]
|
||||
);
|
||||
const selectedActionCounts = useMemo(
|
||||
() => countDownloadActions(selectedDownloads),
|
||||
[selectedDownloads]
|
||||
() => countDownloadActions(selectedDownloads.filter(download => !removalJobs[download.id])),
|
||||
[selectedDownloads, removalJobs]
|
||||
);
|
||||
const hasStartableDownloads = downloads.some(download =>
|
||||
download.status === 'queued' || canStartDownload(download.status)
|
||||
!removalJobs[download.id] && (download.status === 'queued' || canStartDownload(download.status))
|
||||
);
|
||||
const hasPausableDownloads = downloads.some(download => canPauseDownload(download.status));
|
||||
const hasPausableDownloads = downloads.some(download => !removalJobs[download.id] && canPauseDownload(download.status));
|
||||
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
|
||||
const downloadSummary = useMemo(
|
||||
() => summarizeDownloads(summaryDownloads, progressMap),
|
||||
@@ -1997,7 +1998,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
|
||||
const getCurrentSelectedDownloads = useCallback(() => {
|
||||
const selected = selectedIdsRef.current;
|
||||
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id));
|
||||
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id) && !useDownloadStore.getState().removalJobs[download.id]);
|
||||
}, []);
|
||||
|
||||
const handlePauseSelected = useCallback(async () => {
|
||||
|
||||
@@ -1154,17 +1154,17 @@ export const PropertiesWindowApp = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
||||
const liveNormalSpeedEnabled = pendingAction === null
|
||||
const editingEnabled = !snapshot.removalPhase && pendingAction === null && isEditableStatus(snapshot.status);
|
||||
const liveNormalSpeedEnabled = !snapshot.removalPhase && pendingAction === null
|
||||
&& snapshot.isMedia !== true
|
||||
&& snapshot.isTorrent !== true
|
||||
&& isLiveNormalSpeedStatus(snapshot.status);
|
||||
const liveTorrentOptionsEnabled = pendingAction === null
|
||||
const liveTorrentOptionsEnabled = !snapshot.removalPhase && pendingAction === null
|
||||
&& snapshot.isTorrent === true
|
||||
&& isLiveTorrentControlStatus(snapshot.status);
|
||||
const liveTorrentSpeedEnabled = liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status);
|
||||
const liveTorrentSpeedEnabled = !snapshot.removalPhase && liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status);
|
||||
const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status);
|
||||
const torrentMoveAvailable = ['paused', 'completed', 'failed'].includes(snapshot.status);
|
||||
const torrentMoveAvailable = !snapshot.removalPhase && ['paused', 'completed', 'failed'].includes(snapshot.status);
|
||||
const progress = getPropertiesProgress(snapshot);
|
||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||
const footerActions = getPropertiesFooterActions({
|
||||
@@ -1181,12 +1181,13 @@ export const PropertiesWindowApp = () => {
|
||||
connectedPeers: snapshot.torrentConnectedPeers,
|
||||
connectedSeeders: snapshot.torrentConnectedSeeders,
|
||||
});
|
||||
const allocationPending = snapshot.isTorrent !== true
|
||||
&& isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status);
|
||||
const allocationPending = !snapshot.removalPhase && isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status);
|
||||
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||
? t($ => $.addDownloads.unknownSize)
|
||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||
const statusLabel = allocationPending
|
||||
const statusLabel = snapshot.removalPhase
|
||||
? t($ => snapshot.removalPhase === 'failed' ? $.downloads.removal.error : $.downloads.removal.removing)
|
||||
: allocationPending
|
||||
? t($ => $.downloads.status.allocatingFiles)
|
||||
: waitingForPeers
|
||||
? t($ => $.downloads.status.waitingForPeers)
|
||||
@@ -1230,8 +1231,9 @@ export const PropertiesWindowApp = () => {
|
||||
snapshot.queuePosition,
|
||||
position => t($ => $.properties.queuePosition, { position }),
|
||||
);
|
||||
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
||||
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||
const indeterminate = allocationPending || snapshot.removalPhase === "pending" || snapshot.removalPhase === "running";
|
||||
const progressPercent = indeterminate ? '—' : `${Math.round(progress * 100)}%`;
|
||||
const statusTone = indeterminate ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||
const lifecycleLabel = lifecycleAction === 'pause'
|
||||
? t($ => $.downloads.actions.pause)
|
||||
: lifecycleAction === 'resume'
|
||||
@@ -1312,24 +1314,24 @@ export const PropertiesWindowApp = () => {
|
||||
<div
|
||||
className="properties-window-progress-track"
|
||||
aria-label={t($ => $.properties.progress)}
|
||||
aria-busy={allocationPending}
|
||||
aria-valuetext={allocationPending ? statusLabel : undefined}
|
||||
aria-busy={indeterminate}
|
||||
aria-valuetext={indeterminate ? statusLabel : undefined}
|
||||
role="progressbar"
|
||||
aria-valuemin={allocationPending ? undefined : 0}
|
||||
aria-valuemax={allocationPending ? undefined : 100}
|
||||
aria-valuenow={allocationPending ? undefined : Math.round(progress * 100)}
|
||||
aria-valuemin={indeterminate ? undefined : 0}
|
||||
aria-valuemax={indeterminate ? undefined : 100}
|
||||
aria-valuenow={indeterminate ? undefined : Math.round(progress * 100)}
|
||||
>
|
||||
<div
|
||||
className={`properties-window-progress-fill ${allocationPending ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
|
||||
style={{ width: allocationPending ? undefined : `${progress * 100}%` }}
|
||||
className={`properties-window-progress-fill ${indeterminate ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
|
||||
style={{ width: indeterminate ? undefined : `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="properties-window-progress-percent">{progressPercent}</span>
|
||||
</div>
|
||||
<div className="properties-window-metrics" dir="ltr">
|
||||
<div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{indeterminate ? '—' : snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{indeterminate ? '—' : snapshot.eta || '—'}</strong></div></div>
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
@@ -1720,7 +1722,7 @@ export const PropertiesWindowApp = () => {
|
||||
</section>
|
||||
|
||||
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
|
||||
{isPromptFooter ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.keepEditing)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{footerActions.includes('discardChanges') && <><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.window.close)}</button></div></div>}
|
||||
{isPromptFooter ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={!!snapshot.removalPhase || pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.keepEditing)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{footerActions.includes('discardChanges') && <><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={!!snapshot.removalPhase || pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.window.close)}</button></div></div>}
|
||||
</div>}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -336,6 +336,7 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
queueName: queue?.name,
|
||||
windowChrome,
|
||||
allocationPending: store.allocationPendingIds.has(downloadId),
|
||||
removalPhase: store.removalJobs[downloadId]?.phase,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
@@ -416,6 +417,9 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
const item = store.downloads.find(download => download.id === request.downloadId);
|
||||
if (!item) throw new Error('Download no longer exists');
|
||||
|
||||
if (useDownloadStore.getState().removalJobs[request.downloadId]) {
|
||||
throw new Error(i18n.t($ => $.downloads.removal.pending));
|
||||
}
|
||||
switch (request.action) {
|
||||
case 'apply-properties': {
|
||||
await assertCurrentAction(request);
|
||||
@@ -749,6 +753,7 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||
} else if (
|
||||
next !== before
|
||||
|| state.removalJobs[downloadId] !== previous.removalJobs[downloadId]
|
||||
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
|
||||
) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
|
||||
@@ -73,6 +73,7 @@ const common = {
|
||||
maximize: 'Maximize',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "Removing…", error: "Removal failed", retry: "Retry removal", pending: "Download removal is pending.", failed: "Close programs using the files, check drive access and permissions, then retry removal." },
|
||||
actions: {
|
||||
moveUp: 'Move Up',
|
||||
moveDown: 'Move Down',
|
||||
|
||||
@@ -73,6 +73,7 @@ const fa = {
|
||||
maximize: 'بیشینه کردن',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "در حال حذف…", error: "حذف ناموفق بود", retry: "تلاش دوباره برای حذف", pending: "حذف دانلود در انتظار انجام است.", failed: "برنامههایی را که از فایلها استفاده میکنند ببندید، دسترسی به درایو و مجوزها را بررسی کنید و دوباره حذف کنید." },
|
||||
actions: {
|
||||
moveUp: 'انتقال به بالا',
|
||||
moveDown: 'انتقال به پایین',
|
||||
|
||||
@@ -73,6 +73,7 @@ const he = {
|
||||
maximize: 'הגדלה',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "מסיר…", error: "ההסרה נכשלה", retry: "נסה להסיר שוב", pending: "הסרת ההורדה ממתינה לביצוע.", failed: "סגור תוכניות שמשתמשות בקבצים, בדוק גישה לכונן והרשאות ונסה להסיר שוב." },
|
||||
actions: {
|
||||
moveUp: 'הזזה למעלה',
|
||||
moveDown: 'הזזה למטה',
|
||||
|
||||
@@ -73,6 +73,7 @@ const ru = {
|
||||
maximize: 'Развернуть',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "Удаление…", error: "Не удалось удалить", retry: "Повторить удаление", pending: "Удаление загрузки ожидает выполнения.", failed: "Закройте программы, использующие файлы, проверьте доступ к диску и разрешения, затем повторите удаление." },
|
||||
actions: {
|
||||
moveUp: 'Переместить вверх',
|
||||
moveDown: 'Переместить вниз',
|
||||
|
||||
@@ -73,6 +73,7 @@ const uk = {
|
||||
maximize: 'Розгорнути',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "Видалення…", error: "Не вдалося видалити", retry: "Повторити видалення", pending: "Видалення завантаження очікує на виконання.", failed: "Закрийте програми, що використовують файли, перевірте доступ до диска й дозволи та повторіть видалення." },
|
||||
actions: {
|
||||
moveUp: 'Перемістити вгору',
|
||||
moveDown: 'Перемістити вниз',
|
||||
|
||||
@@ -73,6 +73,7 @@ const zhCN = {
|
||||
maximize: '最大化',
|
||||
},
|
||||
downloads: {
|
||||
removal: { removing: "正在移除…", error: "移除失败", retry: "重试移除", pending: "下载移除操作正在等待执行。", failed: "请关闭正在使用文件的程序,检查磁盘访问权限,然后重试移除。" },
|
||||
actions: {
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DownloadRemovalJob } from "./bindings/DownloadRemovalJob";
|
||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||
import { error as logError } from './utils/logger';
|
||||
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
@@ -72,6 +73,10 @@ type CommandMap = {
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||
submit_download_removals: { args: { ids: string[]; deleteAssets: boolean }; result: void };
|
||||
list_download_removals: { args: undefined; result: DownloadRemovalJob[] };
|
||||
resume_download_removals: { args: undefined; result: void };
|
||||
retry_download_removal: { args: { id: string }; result: void };
|
||||
remove_download: {
|
||||
args: {
|
||||
id: string;
|
||||
@@ -213,6 +218,7 @@ export function invokeCommand<K extends CommandName>(
|
||||
type EventMap = {
|
||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-removal': DownloadRemovalJob;
|
||||
'download-allocation': DownloadAllocationEvent;
|
||||
'download-state': DownloadStateEvent;
|
||||
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||
|
||||
@@ -169,6 +169,7 @@ export type PropertiesSnapshotContext = {
|
||||
queueName?: string;
|
||||
windowChrome?: PropertiesWindowChrome;
|
||||
allocationPending?: boolean;
|
||||
removalPhase?: "pending" | "running" | "failed" | "completed";
|
||||
};
|
||||
|
||||
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||
@@ -176,6 +177,7 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
||||
windowChrome: PropertiesWindowChrome;
|
||||
queueName?: string;
|
||||
allocationPending?: boolean;
|
||||
removalPhase?: "pending" | "running" | "failed" | "completed";
|
||||
lastErrorKind?: DownloadErrorKind;
|
||||
lastResolverFallback?: boolean;
|
||||
activeConnections?: number;
|
||||
@@ -411,6 +413,7 @@ const copyWithoutSecrets = (
|
||||
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||
...(lastErrorKind ? { lastErrorKind } : {}),
|
||||
...(context?.queueName ? { queueName: context.queueName } : {}),
|
||||
...(context?.removalPhase ? { removalPhase: context.removalPhase } : {}),
|
||||
...(context?.allocationPending === true ? { allocationPending: true } : {}),
|
||||
...(live?.progress ? {
|
||||
fraction: live.progress.fraction,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
listenEvent: vi.fn().mockResolvedValue(() => {}),
|
||||
}));
|
||||
|
||||
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
|
||||
@@ -113,6 +114,64 @@ describe('useDownloadStore', () => {
|
||||
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
||||
});
|
||||
|
||||
it('closes confirmation before slow removal and keeps other downloads controllable', async () => {
|
||||
const item = { id: 'slow-remove', url: 'https://example.com/file', fileName: 'file', status: 'paused' as const, fraction: 0, speed: '-', eta: '-', category: 'Other' as const, dateAdded: '2026-09-06', queueId: MAIN_QUEUE_ID };
|
||||
useDownloadStore.setState({ downloads: [item], deleteModalState: { isOpen: true, downloadIds: [item.id] } });
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'submit_download_removals') return blocked;
|
||||
return undefined as never;
|
||||
});
|
||||
const removing = useDownloadStore.getState().requestRemovals([item.id], true);
|
||||
expect(useDownloadStore.getState().deleteModalState.isOpen).toBe(false);
|
||||
expect(useDownloadStore.getState().downloads).toHaveLength(1);
|
||||
expect(useDownloadStore.getState().removalJobs[item.id].phase).toBe('pending');
|
||||
await expect(useDownloadStore.getState().resumeDownload(item.id)).rejects.toThrow();
|
||||
useDownloadStore.getState().openAddModalWithUrls('https://example.com/other');
|
||||
expect(useDownloadStore.getState().isAddModalOpen).toBe(true);
|
||||
release();
|
||||
await removing;
|
||||
useDownloadStore.getState().applyRemovalJob({ id: item.id, revision: 1, deleteAssets: true, phase: 'completed', error: null });
|
||||
expect(useDownloadStore.getState().downloads).toHaveLength(0);
|
||||
useDownloadStore.getState().updateDownload(item.id, { status: 'downloading' });
|
||||
expect(useDownloadStore.getState().downloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not let a stale removal event replace a newer completion', () => {
|
||||
const completed = { id: 'revision-test', revision: 3, deleteAssets: true, phase: 'completed' as const, error: null };
|
||||
useDownloadStore.getState().applyRemovalJob(completed);
|
||||
useDownloadStore.getState().applyRemovalJob({ ...completed, revision: 2, phase: 'running' });
|
||||
expect(useDownloadStore.getState().removalJobs[completed.id]).toEqual(completed);
|
||||
});
|
||||
|
||||
it('keeps partial removal failures visible and does not repeat successful jobs', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
const failed = { id: 'failed-removal', revision: 1, deleteAssets: true, phase: 'failed' as const, error: 'Drive unavailable' };
|
||||
useDownloadStore.getState().applyRemovalJob(failed);
|
||||
await useDownloadStore.getState().requestRemovals([failed.id], true);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('submit_download_removals', expect.anything());
|
||||
expect(useDownloadStore.getState().removalJobs[failed.id]).toEqual(failed);
|
||||
});
|
||||
|
||||
it('does not resurrect a row when completion races startup hydration', async () => {
|
||||
const completed = { id: 'hydration-removal', revision: 3, deleteAssets: true, phase: 'completed' as const, error: null };
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'list_download_removals') {
|
||||
useDownloadStore.getState().applyRemovalJob(completed);
|
||||
return [{ ...completed, revision: 1, phase: 'pending' }] as never;
|
||||
}
|
||||
if (command === 'db_get_all_queues') return [] as never;
|
||||
if (command === 'db_get_all_downloads') return [JSON.stringify({
|
||||
id: completed.id, status: 'paused', queueId: MAIN_QUEUE_ID,
|
||||
})] as never;
|
||||
return undefined as never;
|
||||
});
|
||||
await useDownloadStore.getState().initDB();
|
||||
expect(useDownloadStore.getState().removalJobs[completed.id]).toEqual(completed);
|
||||
expect(useDownloadStore.getState().downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
||||
const initialVersion = useDownloadStore.getState().pendingAddRequestVersion;
|
||||
|
||||
@@ -847,6 +906,7 @@ describe('useDownloadStore', () => {
|
||||
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
||||
];
|
||||
}
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') return [];
|
||||
return undefined;
|
||||
});
|
||||
@@ -877,6 +937,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') return [];
|
||||
return undefined;
|
||||
});
|
||||
@@ -891,6 +952,7 @@ describe('useDownloadStore', () => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
return [JSON.stringify({ id: 'legacy-main', name: 'Primary', isMain: true })];
|
||||
}
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
JSON.stringify({
|
||||
@@ -925,6 +987,7 @@ describe('useDownloadStore', () => {
|
||||
it('skips malformed persisted download records without blocking startup', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
'{not-json',
|
||||
@@ -954,6 +1017,7 @@ describe('useDownloadStore', () => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
return [JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false })];
|
||||
}
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
JSON.stringify({ id: 'active', status: 'downloading', queueId: 'queue-a', queuePosition: 0 }),
|
||||
@@ -983,6 +1047,7 @@ describe('useDownloadStore', () => {
|
||||
it('removes persisted temporary media estimates that are smaller than downloaded bytes', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'stale-media-estimate',
|
||||
@@ -3255,6 +3320,7 @@ describe('useDownloadStore', () => {
|
||||
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-failed',
|
||||
@@ -3290,6 +3356,7 @@ describe('useDownloadStore', () => {
|
||||
it('keeps startup destination permission failures retryable without backend registration', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-destination-access',
|
||||
@@ -3333,6 +3400,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return Promise.resolve([]) as never;
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return Promise.resolve([JSON.stringify({
|
||||
id: 'startup-torrent-allocation',
|
||||
@@ -3388,6 +3456,7 @@ describe('useDownloadStore', () => {
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'db_get_all_queues') return [];
|
||||
if (command === 'list_download_removals') return [];
|
||||
if (command === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-proxy-blocked',
|
||||
@@ -3419,6 +3488,7 @@ describe('useDownloadStore', () => {
|
||||
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-accepted',
|
||||
@@ -3459,6 +3529,7 @@ describe('useDownloadStore', () => {
|
||||
it('does not restore a registration after a fast startup terminal event', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-completed',
|
||||
@@ -3499,6 +3570,7 @@ describe('useDownloadStore', () => {
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-credential-gated',
|
||||
@@ -3548,6 +3620,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-single-flight',
|
||||
@@ -3808,6 +3881,7 @@ describe('useDownloadStore', () => {
|
||||
it('migrates legacy downloads without queue ids into the main queue', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'list_download_removals') return Promise.resolve([]);
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'legacy',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DownloadRemovalJob } from "../bindings/DownloadRemovalJob";
|
||||
import { listenEvent } from "../ipc";
|
||||
import { create } from 'zustand';
|
||||
import { info } from '../utils/logger';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
@@ -25,6 +27,7 @@ import i18n from '../i18n';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
let removalListener: (() => void) | undefined;
|
||||
const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
||||
const downloadLifecycleGenerations = new Map<string, bigint>();
|
||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||
@@ -75,14 +78,23 @@ const runDownloadLifecycleOperation = <T>(
|
||||
coalesce = true,
|
||||
preemptKinds: readonly string[] = []
|
||||
): Promise<T> => {
|
||||
if (kind !== 'background-remove' && useDownloadStore.getState().removalJobs[id]) {
|
||||
return Promise.reject(new Error(i18n.t($ => $.downloads.removal.pending)));
|
||||
}
|
||||
const current = downloadLifecycleOperations.get(id);
|
||||
if (coalesce && current?.kind === kind) {
|
||||
return current.promise as Promise<T>;
|
||||
}
|
||||
|
||||
const guardedOperation = () => {
|
||||
if (kind !== 'background-remove' && useDownloadStore.getState().removalJobs[id]) {
|
||||
return Promise.reject(new Error(i18n.t($ => $.downloads.removal.pending)));
|
||||
}
|
||||
return operation();
|
||||
};
|
||||
const operationPromise = current && !preemptKinds.includes(current.kind)
|
||||
? current.promise.catch(() => undefined).then(operation)
|
||||
: operation();
|
||||
? current.promise.catch(() => undefined).then(guardedOperation)
|
||||
: guardedOperation();
|
||||
const trackedOperation = operationPromise.finally(() => {
|
||||
if (downloadLifecycleOperations.get(id)?.promise === trackedOperation) {
|
||||
downloadLifecycleOperations.delete(id);
|
||||
@@ -372,6 +384,7 @@ async function dispatchItemInternal(
|
||||
options: DispatchOptions = {}
|
||||
): Promise<boolean> {
|
||||
await waitForPendingStartupResume();
|
||||
if (useDownloadStore.getState().removalJobs[id]) return false;
|
||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||
|
||||
const promise = (async () => {
|
||||
@@ -1117,6 +1130,10 @@ export type DeleteModalState = {
|
||||
};
|
||||
|
||||
interface DownloadState {
|
||||
removalJobs: Record<string, DownloadRemovalJob>;
|
||||
requestRemovals: (ids: string[], deleteAssets: boolean) => Promise<void>;
|
||||
applyRemovalJob: (job: DownloadRemovalJob) => void;
|
||||
retryRemoval: (id: string) => Promise<void>;
|
||||
downloads: DownloadItem[];
|
||||
queues: Queue[];
|
||||
pendingOrder: string[];
|
||||
@@ -1762,6 +1779,63 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
console.error("Failed to remove item from queue:", e);
|
||||
}
|
||||
},
|
||||
removalJobs: {},
|
||||
applyRemovalJob: (job) => {
|
||||
if (!job || typeof job.id !== 'string' || !Number.isSafeInteger(job.revision)
|
||||
|| job.revision < 0 || !['pending', 'running', 'failed', 'completed'].includes(job.phase)) return;
|
||||
if ((get().removalJobs[job.id]?.revision ?? -1) > job.revision) return;
|
||||
set(state => ({
|
||||
removalJobs: { ...state.removalJobs, [job.id]: job },
|
||||
downloads: job.phase === 'completed'
|
||||
? state.downloads.filter(item => item.id !== job.id)
|
||||
: job.phase === 'failed' && job.revision > 0
|
||||
? state.downloads.map(item => item.id === job.id ? { ...item, status: 'failed' as const, speed: '-', eta: '-' } : item)
|
||||
: state.downloads,
|
||||
pendingOrder: state.pendingOrder.filter(id => id !== job.id),
|
||||
allocationPendingIds: new Set([...state.allocationPendingIds].filter(id => id !== job.id)),
|
||||
backendRegisteredIds: job.phase === 'completed'
|
||||
? new Set([...state.backendRegisteredIds].filter(id => id !== job.id))
|
||||
: state.backendRegisteredIds,
|
||||
}));
|
||||
if (job.phase === 'completed') useDownloadProgressStore.getState().resetDownloadProgress(job.id);
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
requestRemovals: async (ids, deleteAssets) => {
|
||||
const selected = [...new Set(ids)].filter(id => !get().removalJobs[id]);
|
||||
for (const id of selected) {
|
||||
get().applyRemovalJob({ id, revision: 0, deleteAssets, phase: 'pending', error: null });
|
||||
}
|
||||
// Close before any persistence, RPC, or filesystem work is awaited.
|
||||
get().closeDeleteModal();
|
||||
await Promise.all(selected.map(id => runDownloadLifecycleOperation(id, 'background-remove', async () => {
|
||||
try {
|
||||
await waitForPendingStartupResume();
|
||||
await invalidateAndWaitForDispatch(id);
|
||||
await flushDownloadPersistence();
|
||||
await invoke('submit_download_removals', { ids: [id], deleteAssets });
|
||||
} catch {
|
||||
get().applyRemovalJob({ id, revision: 0, deleteAssets, phase: 'failed', error: i18n.t($ => $.downloads.removal.failed) });
|
||||
}
|
||||
})));
|
||||
},
|
||||
retryRemoval: async (id) => {
|
||||
const job = get().removalJobs[id];
|
||||
if (!job || job.phase !== 'failed') return;
|
||||
try {
|
||||
const durable = await invoke('list_download_removals');
|
||||
const current = durable.find(entry => entry.id === id);
|
||||
if (current) {
|
||||
get().applyRemovalJob(current);
|
||||
if (current.phase === 'failed') await invoke('retry_download_removal', { id });
|
||||
else if (current.phase !== 'completed') await invoke('resume_download_removals');
|
||||
} else {
|
||||
await flushDownloadPersistence();
|
||||
await invoke('submit_download_removals', { ids: [id], deleteAssets: job.deleteAssets });
|
||||
}
|
||||
} catch {
|
||||
get().applyRemovalJob({ ...job, phase: 'failed', error: i18n.t($ => $.downloads.removal.failed) });
|
||||
}
|
||||
},
|
||||
backendRegisteredIds: new Set(),
|
||||
allocationPendingIds: new Set(),
|
||||
registerBackendIds: (ids) => set((state) => {
|
||||
@@ -2041,6 +2115,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
preemptDispatch
|
||||
),
|
||||
updateDownload: (id, updates) => {
|
||||
if (get().removalJobs[id] && updates.status !== 'completed') return;
|
||||
set((state) => {
|
||||
const current = state.downloads.find(download => download.id === id);
|
||||
if (!current) return { downloads: state.downloads };
|
||||
@@ -2326,7 +2401,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
await waitForPendingStartupResume();
|
||||
const runnable = get().downloads
|
||||
.filter(item =>
|
||||
(item.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
!get().removalJobs[item.id] && (item.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
(item.status === 'queued' || canStartDownload(item.status))
|
||||
)
|
||||
.sort(queuePositionComparator);
|
||||
@@ -2462,7 +2537,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
advanceQueueControlGeneration(queueId);
|
||||
const activeIds = get().downloads
|
||||
.filter(item =>
|
||||
(item.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
!get().removalJobs[item.id] && (item.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
|
||||
)
|
||||
.map(item => item.id);
|
||||
@@ -2777,7 +2852,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
// queued Torrent using its persisted remaining seed budget, then let
|
||||
// the normal backend admission path assign a fresh lifecycle/GID.
|
||||
const waitingToSeedIds = get().downloads
|
||||
.filter(download => download.status === 'waitingToSeed')
|
||||
.filter(download => !get().removalJobs[download.id] && download.status === 'waitingToSeed')
|
||||
.map(download => download.id);
|
||||
if (waitingToSeedIds.length > 0) {
|
||||
set(state => ({
|
||||
@@ -2791,7 +2866,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
// crash during enqueue_many cannot lose the restartable row.
|
||||
await commitDownloadState();
|
||||
const active = get().downloads
|
||||
.filter(d => d.status === 'queued')
|
||||
.filter(d => !get().removalJobs[d.id] && d.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
if (active.length === 0) return;
|
||||
|
||||
@@ -2992,6 +3067,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
let dispatchableItems = itemsToEnqueue.filter(item => {
|
||||
const current = currentItems.get(item.id);
|
||||
return current &&
|
||||
!get().removalJobs[item.id] &&
|
||||
current.status === 'queued' &&
|
||||
!get().backendRegisteredIds.has(item.id) &&
|
||||
!backendDispatchPromises.has(item.id) &&
|
||||
@@ -3007,6 +3083,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
dispatchableItems = dispatchableItems.filter(item => {
|
||||
const current = latestItems.get(item.id);
|
||||
return current &&
|
||||
!get().removalJobs[item.id] &&
|
||||
current.status === 'queued' &&
|
||||
!get().backendRegisteredIds.has(item.id) &&
|
||||
!backendDispatchPromises.has(item.id) &&
|
||||
@@ -3139,6 +3216,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
// Register before recovery starts; no worker runs until snapshots hydrate.
|
||||
if (!removalListener) removalListener = await listenEvent('download-removal', event => get().applyRemovalJob(event.payload));
|
||||
const removalJobs = await invoke('list_download_removals');
|
||||
for (const job of removalJobs) get().applyRemovalJob(job);
|
||||
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
||||
try {
|
||||
return [JSON.parse(value) as PersistedQueue];
|
||||
@@ -3168,11 +3249,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
return normalizePersistedDownloadProgress({ ...download, queueId });
|
||||
});
|
||||
|
||||
set(() => ({
|
||||
set(state => ({
|
||||
queues,
|
||||
downloads: normalizeQueuePositions(downloads)
|
||||
downloads: normalizeQueuePositions(downloads.filter(download =>
|
||||
state.removalJobs[download.id]?.phase !== 'completed'
|
||||
).map(download => state.removalJobs[download.id]?.phase === 'failed'
|
||||
? { ...download, status: 'failed' as const, speed: '-', eta: '-' }
|
||||
: download))
|
||||
}));
|
||||
|
||||
await invoke('resume_download_removals');
|
||||
|
||||
// A process can die after Aria2 has removed the unselected files but
|
||||
// before the terminal event clears Firelink's reservation. Reclaim
|
||||
// only the conservative terminal cases in the backend before queued
|
||||
@@ -3191,7 +3278,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
// Reset interrupted active downloads to queued.
|
||||
set((state) => ({
|
||||
downloads: normalizeQueuePositions(state.downloads.map(d =>
|
||||
isActiveDownloadStatus(d.status) && d.status !== 'queued'
|
||||
!state.removalJobs[d.id] && isActiveDownloadStatus(d.status) && d.status !== 'queued'
|
||||
? { ...d, status: 'queued' as const }
|
||||
: d
|
||||
))
|
||||
@@ -3345,6 +3432,9 @@ export const flushDownloadPersistence = async (): Promise<void> => {
|
||||
let downloadPersistenceUnsubscribe: (() => void) | null = null;
|
||||
|
||||
export const resetDownloadStoreModuleStateForTests = (): void => {
|
||||
removalListener?.();
|
||||
removalListener = undefined;
|
||||
useDownloadStore.setState({ removalJobs: {} });
|
||||
downloadPersistenceUnsubscribe?.();
|
||||
downloadPersistenceUnsubscribe = null;
|
||||
backendDispatchPromises.clear();
|
||||
|
||||
@@ -224,7 +224,10 @@ describe('credential-bearing extension header names', () => {
|
||||
describe('allocation phase visibility', () => {
|
||||
it('does not override paused or completed statuses', () => {
|
||||
expect(isAllocationPhaseVisible(true, 'ready')).toBe(true);
|
||||
expect(isAllocationPhaseVisible(true, 'failed')).toBe(true);
|
||||
expect(isAllocationPhaseVisible(true, 'failed')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(true, 'verifying')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(true, 'seeding')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(true, 'waitingToSeed')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(true, 'paused')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(true, 'completed')).toBe(false);
|
||||
expect(isAllocationPhaseVisible(false, 'downloading')).toBe(false);
|
||||
|
||||
@@ -73,13 +73,12 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
export const isAllocationPhaseVisible = (
|
||||
allocationPending: boolean,
|
||||
status: DownloadStatus,
|
||||
): boolean => allocationPending && status !== 'completed' && status !== 'paused';
|
||||
): boolean => allocationPending && ['ready', 'staged', 'queued', 'downloading', 'retrying'].includes(status);
|
||||
|
||||
/**
|
||||
* Allocation is a transient admission phase. Normal downloads retain the
|
||||
* existing preallocation behavior; Torrent rows use Aria2's Torrent-specific
|
||||
* allocation setting without exposing the normal-download hint, including
|
||||
* for verification-only work.
|
||||
* existing preallocation hint. Torrent rows require explicit engine telemetry
|
||||
* instead of inferring allocation from admission or the configured option.
|
||||
*/
|
||||
export const isAllocationPhaseEligible = (
|
||||
download: Pick<DownloadItem, 'isMedia' | 'isTorrent' | 'torrentFileAllocation' | 'torrentVerifyOnly'>,
|
||||
|
||||
Reference in New Issue
Block a user