From 21c8fc26aa4a754c7ef9638b08166ab2d322cae9 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 27 Aug 2026 19:54:55 +0330 Subject: [PATCH] test(startup): compare v1.3.1 source (#37) - Issue #37: compare the pre-1.4.0 application source with the current packaged-app stability workflow.\n- Keep the current workflow and 5-second delayed-crash check so the Windows result is comparable.\n\nRefs #37. --- src-tauri/Cargo.lock | 609 +- src-tauri/Cargo.toml | 4 - src-tauri/capabilities/default.json | 3 +- src-tauri/capabilities/properties.json | 19 - src-tauri/src/commands.rs | 32 +- src-tauri/src/db.rs | 1841 +-- src-tauri/src/download_ownership.rs | 322 +- src-tauri/src/engines.rs | 47 +- src-tauri/src/extension_server.rs | 171 +- src-tauri/src/ipc.rs | 568 +- src-tauri/src/lib.rs | 10547 +--------------- src-tauri/src/parity.rs | 29 +- src-tauri/src/platform.rs | 440 +- src-tauri/src/properties_window.rs | 786 -- src-tauri/src/queue.rs | 9709 +------------- src-tauri/src/retry.rs | 198 +- src-tauri/src/scheduler.rs | 114 +- src-tauri/src/settings.rs | 786 +- src-tauri/src/storage.rs | 279 +- src-tauri/src/torrent.rs | 1855 --- src-tauri/src/torrent_probe.rs | 1438 --- src-tauri/src/window_geometry.rs | 97 - src-tauri/tauri.conf.json | 20 +- src-tauri/tauri.linux.conf.json | 2 +- src-tauri/tauri.macos.conf.json | 2 +- src-tauri/tauri.windows.conf.json | 2 +- src/App.tsx | 355 +- src/bindings/DownloadAllocationEvent.ts | 3 - src/bindings/DownloadAssetRemovalPolicy.ts | 3 - src/bindings/DownloadCategory.ts | 2 +- src/bindings/DownloadErrorKind.ts | 3 - src/bindings/DownloadItem.ts | 4 +- src/bindings/DownloadProgressEvent.ts | 2 +- src/bindings/DownloadStateEvent.ts | 4 +- src/bindings/DownloadStateProgress.ts | 3 - src/bindings/DownloadStatus.ts | 2 +- src/bindings/DownloadTargetInfo.ts | 4 - src/bindings/DownloadTargetKind.ts | 3 - src/bindings/EnqueueItem.ts | 3 +- src/bindings/ExtensionDownload.ts | 2 +- src/bindings/MainWindowSize.ts | 3 - src/bindings/PersistedSettings.ts | 3 +- src/bindings/TorrentAvailabilityBucket.ts | 3 - src/bindings/TorrentAvailabilitySnapshot.ts | 4 - src/bindings/TorrentDetails.ts | 3 - src/bindings/TorrentFile.ts | 3 - src/bindings/TorrentFileProgress.ts | 3 - src/bindings/TorrentFileProgressSnapshot.ts | 4 - src/bindings/TorrentFileSelectionEntry.ts | 3 - src/bindings/TorrentFileSelectionSnapshot.ts | 4 - src/bindings/TorrentMetadata.ts | 4 - src/bindings/TorrentMoveProgressEvent.ts | 3 - src/bindings/TorrentPeer.ts | 3 - src/bindings/TorrentPeerDiagnostics.ts | 4 - src/bindings/TorrentPieceProgressSnapshot.ts | 3 - src/bindings/TorrentWebSeed.ts | 3 - src/components/AddDownloadsModal.tsx | 1259 +- src/components/DeleteConfirmationModal.tsx | 19 +- src/components/DownloadItem.tsx | 130 +- src/components/DownloadTable.tsx | 191 +- .../DuplicateResolutionModal.test.ts | 26 - src/components/DuplicateResolutionModal.tsx | 27 +- src/components/FloatingQueueSubmenu.tsx | 4 +- src/components/PropertiesModal.tsx | 743 ++ src/components/PropertiesWindowApp.tsx | 1730 --- src/components/PropertiesWindowBridgeHost.tsx | 814 -- src/components/SchedulerView.tsx | 56 +- src/components/SettingsView.tsx | 618 +- src/components/Sidebar.tsx | 81 +- src/components/TorrentWebSeedEditor.test.tsx | 39 - src/components/TorrentWebSeedEditor.tsx | 135 - src/components/WindowControls.tsx | 5 +- src/i18n/catalogs/en.ts | 351 +- src/i18n/catalogs/fa.ts | 351 +- src/i18n/catalogs/he.ts | 351 +- src/i18n/catalogs/ru.ts | 351 +- src/i18n/catalogs/uk.ts | 351 +- src/i18n/catalogs/zh-CN.ts | 351 +- src/i18n/resources.test.ts | 12 - src/index.css | 1278 +- src/ipc.ts | 91 +- src/main.tsx | 75 +- src/propertiesBridge.test.ts | 644 - src/propertiesBridge.ts | 564 - src/store/downloadProgressStore.ts | 94 +- src/store/downloadStore.test.ts | 717 +- src/store/downloadStore.ts | 445 +- src/store/useDownloadStore.test.ts | 1771 +-- src/store/useDownloadStore.ts | 1426 +-- src/store/useSettingsStore.test.ts | 308 - src/store/useSettingsStore.ts | 369 +- src/utils/addDownloadMetadata.test.ts | 220 - src/utils/addDownloadMetadata.ts | 197 +- src/utils/clipboard.test.ts | 9 +- src/utils/columnResize.test.ts | 85 - src/utils/columnResize.ts | 61 - src/utils/documentAppearance.test.ts | 62 - src/utils/documentAppearance.ts | 55 - src/utils/downloadActions.test.ts | 6 +- src/utils/downloadActions.ts | 10 +- src/utils/downloadErrors.ts | 30 - src/utils/downloadLocations.test.ts | 24 - src/utils/downloadLocations.ts | 12 +- src/utils/downloadProgress.test.ts | 66 +- src/utils/downloadProgress.ts | 80 - src/utils/downloadSelection.test.ts | 16 +- src/utils/downloadSelection.ts | 11 - src/utils/downloadSummary.ts | 7 +- src/utils/downloads.test.ts | 309 - src/utils/downloads.ts | 589 +- src/utils/engineStatusRequests.test.ts | 22 - src/utils/engineStatusRequests.ts | 15 - src/utils/floatingPosition.test.ts | 10 +- src/utils/floatingPosition.ts | 3 - src/utils/logEntries.test.ts | 32 - src/utils/logEntries.ts | 27 +- src/utils/mainWindowState.test.ts | 151 - src/utils/mainWindowState.ts | 176 - src/utils/propertiesDiagnostics.test.ts | 70 - src/utils/propertiesDiagnostics.ts | 78 - src/utils/propertiesFooter.test.ts | 21 - src/utils/propertiesFooter.ts | 15 - src/utils/propertiesPresentation.test.ts | 109 - src/utils/propertiesPresentation.ts | 58 - src/utils/propertiesTabs.test.ts | 30 - src/utils/propertiesTabs.ts | 40 - src/utils/propertiesTorrentLifecycle.test.ts | 12 - src/utils/propertiesTorrentLifecycle.ts | 10 - src/utils/propertiesUrl.test.ts | 24 - src/utils/propertiesUrl.ts | 11 - src/utils/schedulerCompletion.test.ts | 4 - src/utils/schedulerCompletion.ts | 2 - src/utils/schedulerControl.test.ts | 37 - src/utils/schedulerControl.ts | 46 - src/utils/serialTaskQueue.test.ts | 45 - src/utils/serialTaskQueue.ts | 13 - src/utils/sidebarResize.test.ts | 76 - src/utils/sidebarResize.ts | 70 - src/utils/torrentFilePath.test.ts | 14 - src/utils/torrentFilePath.ts | 9 - src/utils/torrentNetworkInput.test.ts | 16 - src/utils/torrentNetworkInput.ts | 6 - src/utils/torrentPresentation.test.ts | 56 - src/utils/torrentPresentation.ts | 46 - src/utils/url.ts | 10 +- src/utils/windowConfiguration.test.ts | 26 - src/utils/windowControlStyle.test.ts | 21 +- src/utils/windowControlStyle.ts | 20 - 148 files changed, 2403 insertions(+), 47020 deletions(-) delete mode 100644 src-tauri/capabilities/properties.json delete mode 100644 src-tauri/src/properties_window.rs delete mode 100644 src-tauri/src/torrent.rs delete mode 100644 src-tauri/src/torrent_probe.rs delete mode 100644 src-tauri/src/window_geometry.rs delete mode 100644 src/bindings/DownloadAllocationEvent.ts delete mode 100644 src/bindings/DownloadAssetRemovalPolicy.ts delete mode 100644 src/bindings/DownloadErrorKind.ts delete mode 100644 src/bindings/DownloadStateProgress.ts delete mode 100644 src/bindings/DownloadTargetInfo.ts delete mode 100644 src/bindings/DownloadTargetKind.ts delete mode 100644 src/bindings/MainWindowSize.ts delete mode 100644 src/bindings/TorrentAvailabilityBucket.ts delete mode 100644 src/bindings/TorrentAvailabilitySnapshot.ts delete mode 100644 src/bindings/TorrentDetails.ts delete mode 100644 src/bindings/TorrentFile.ts delete mode 100644 src/bindings/TorrentFileProgress.ts delete mode 100644 src/bindings/TorrentFileProgressSnapshot.ts delete mode 100644 src/bindings/TorrentFileSelectionEntry.ts delete mode 100644 src/bindings/TorrentFileSelectionSnapshot.ts delete mode 100644 src/bindings/TorrentMetadata.ts delete mode 100644 src/bindings/TorrentMoveProgressEvent.ts delete mode 100644 src/bindings/TorrentPeer.ts delete mode 100644 src/bindings/TorrentPeerDiagnostics.ts delete mode 100644 src/bindings/TorrentPieceProgressSnapshot.ts delete mode 100644 src/bindings/TorrentWebSeed.ts delete mode 100644 src/components/DuplicateResolutionModal.test.ts create mode 100644 src/components/PropertiesModal.tsx delete mode 100644 src/components/PropertiesWindowApp.tsx delete mode 100644 src/components/PropertiesWindowBridgeHost.tsx delete mode 100644 src/components/TorrentWebSeedEditor.test.tsx delete mode 100644 src/components/TorrentWebSeedEditor.tsx delete mode 100644 src/propertiesBridge.test.ts delete mode 100644 src/propertiesBridge.ts delete mode 100644 src/utils/columnResize.test.ts delete mode 100644 src/utils/columnResize.ts delete mode 100644 src/utils/documentAppearance.test.ts delete mode 100644 src/utils/documentAppearance.ts delete mode 100644 src/utils/downloadErrors.ts delete mode 100644 src/utils/engineStatusRequests.test.ts delete mode 100644 src/utils/engineStatusRequests.ts delete mode 100644 src/utils/mainWindowState.test.ts delete mode 100644 src/utils/mainWindowState.ts delete mode 100644 src/utils/propertiesDiagnostics.test.ts delete mode 100644 src/utils/propertiesDiagnostics.ts delete mode 100644 src/utils/propertiesFooter.test.ts delete mode 100644 src/utils/propertiesFooter.ts delete mode 100644 src/utils/propertiesPresentation.test.ts delete mode 100644 src/utils/propertiesPresentation.ts delete mode 100644 src/utils/propertiesTabs.test.ts delete mode 100644 src/utils/propertiesTabs.ts delete mode 100644 src/utils/propertiesTorrentLifecycle.test.ts delete mode 100644 src/utils/propertiesTorrentLifecycle.ts delete mode 100644 src/utils/propertiesUrl.test.ts delete mode 100644 src/utils/propertiesUrl.ts delete mode 100644 src/utils/schedulerControl.test.ts delete mode 100644 src/utils/schedulerControl.ts delete mode 100644 src/utils/serialTaskQueue.test.ts delete mode 100644 src/utils/serialTaskQueue.ts delete mode 100644 src/utils/sidebarResize.test.ts delete mode 100644 src/utils/sidebarResize.ts delete mode 100644 src/utils/torrentFilePath.test.ts delete mode 100644 src/utils/torrentFilePath.ts delete mode 100644 src/utils/torrentNetworkInput.test.ts delete mode 100644 src/utils/torrentNetworkInput.ts delete mode 100644 src/utils/torrentPresentation.test.ts delete mode 100644 src/utils/torrentPresentation.ts delete mode 100644 src/utils/windowConfiguration.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e3f4ca..b00e423 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -30,9 +30,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -66,14 +66,14 @@ checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", "env_filter", - "log 0.4.34", + "log 0.4.33", ] [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] @@ -86,12 +86,12 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apple-native-keyring-store" -version = "1.0.2" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" dependencies = [ "keyring-core", - "log 0.4.34", + "log 0.4.33", "security-framework", ] @@ -103,7 +103,7 @@ checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -238,13 +238,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -414,9 +414,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.7.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ "async-channel", "async-task", @@ -542,7 +542,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -566,9 +566,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -659,9 +659,9 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", @@ -704,9 +704,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.2" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "time", "version_check", @@ -772,9 +772,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -938,9 +938,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dbus" @@ -953,37 +953,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.20", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1109,7 +1078,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -1278,7 +1247,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ - "log 0.4.34", + "log 0.4.33", "regex 1.13.1", ] @@ -1311,9 +1280,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.4.0" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "event-listener" @@ -1374,7 +1343,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" dependencies = [ - "log 0.4.34", + "log 0.4.33", ] [[package]] @@ -1389,24 +1358,23 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firelink" -version = "1.4.0" +version = "1.3.1" dependencies = [ "apple-native-keyring-store", "async-trait", "axum", - "base64 0.22.1", "chrono", "futures-util", "hmac 0.13.0", "keepawake", "keyring-core", - "log 0.4.34", + "log 0.4.33", "objc", "regex 1.13.1", "reqwest", @@ -1415,7 +1383,6 @@ dependencies = [ "semver", "serde", "serde_json", - "sha1 0.10.7", "sha2 0.11.0", "sysinfo", "sysproxy", @@ -1431,17 +1398,15 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tokio-tungstenite", "tower-http 0.7.0", "trash", "ts-rs", - "unicode-normalization", "url", "uuid", "windows-native-keyring-store", - "windows-sys 0.61.2", "zbus-secret-service-keyring-store", ] @@ -1497,7 +1462,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -1517,24 +1482,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1543,9 +1508,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1562,32 +1527,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-io", @@ -2032,15 +1997,15 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ - "log 0.4.34", + "log 0.4.33", "markup5ever", ] [[package]] name = "http" -version = "1.5.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2058,9 +2023,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2083,9 +2048,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -2159,7 +2124,7 @@ dependencies = [ "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log 0.4.34", + "log 0.4.33", "wasm-bindgen", "windows-core 0.62.2", ] @@ -2185,9 +2150,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -2199,9 +2164,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2212,9 +2177,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2226,17 +2191,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2247,15 +2211,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2367,9 +2331,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-docker" @@ -2419,59 +2383,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "jiff" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log 0.4.34", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link 0.2.1", -] - -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - -[[package]] -name = "jiff-static" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - [[package]] name = "jni" version = "0.21.1" @@ -2482,7 +2393,7 @@ dependencies = [ "cfg-if", "combine", "jni-sys 0.3.1", - "log 0.4.34", + "log 0.4.33", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", @@ -2498,9 +2409,9 @@ dependencies = [ "combine", "jni-macros", "jni-sys 0.4.1", - "log 0.4.34", + "log 0.4.33", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.19", "walkdir", "windows-link 0.2.1", ] @@ -2548,9 +2459,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2581,15 +2492,15 @@ dependencies = [ [[package]] name = "keepawake" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d950f8c457c023d71a2b7fa0a3550e8eb5127811895b666086ff10304911d9a" +checksum = "5521b450ec179362595d5cbda7c3abd5da3af3dff58456434ad3ca33c95226b7" dependencies = [ "cfg-if", "derive_builder", "objc2-core-foundation", "objc2-io-kit", - "thiserror 2.0.20", + "thiserror 2.0.19", "windows 0.62.2", "zbus", ] @@ -2611,7 +2522,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" dependencies = [ - "log 0.4.34", + "log 0.4.33", ] [[package]] @@ -2636,7 +2547,7 @@ dependencies = [ "gtk", "gtk-sys", "libappindicator-sys", - "log 0.4.34", + "log 0.4.33", ] [[package]] @@ -2677,18 +2588,18 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -2703,9 +2614,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2722,14 +2633,14 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" dependencies = [ - "log 0.4.34", + "log 0.4.33", ] [[package]] name = "log" -version = "0.4.34" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "mac-notification-sys" @@ -2738,7 +2649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ "cc", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-foundation", "time", @@ -2760,7 +2671,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ - "log 0.4.34", + "log 0.4.33", "tendril", "web_atoms", ] @@ -2849,7 +2760,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", + "thiserror 2.0.19", "windows-sys 0.61.2", ] @@ -2861,7 +2772,7 @@ checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.13.1", "jni-sys 0.3.1", - "log 0.4.34", + "log 0.4.33", "ndk-sys", "num_enum", "raw-window-handle", @@ -2912,7 +2823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ "futures-lite", - "log 0.4.34", + "log 0.4.33", "mac-notification-sys", "serde", "tauri-winrt-notification", @@ -2969,9 +2880,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -3278,9 +3189,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.2" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "dunce", "is-wsl", @@ -3478,9 +3389,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" @@ -3535,26 +3446,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3759,27 +3655,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] name = "ref-cast" -version = "1.0.27" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.27" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -3801,7 +3697,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ - "aho-corasick 1.1.5", + "aho-corasick 1.1.4", "memchr", "regex-automata", "regex-syntax 0.8.11", @@ -3809,11 +3705,11 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ - "aho-corasick 1.1.5", + "aho-corasick 1.1.4", "memchr", "regex-syntax 0.8.11", ] @@ -3850,7 +3746,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "js-sys", - "log 0.4.34", + "log 0.4.33", "percent-encoding", "pin-project-lite", "rustls", @@ -3884,7 +3780,7 @@ dependencies = [ "gobject-sys", "gtk-sys", "js-sys", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -3917,14 +3813,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -3975,9 +3871,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "once_cell", "ring", @@ -4017,7 +3913,7 @@ dependencies = [ "core-foundation", "core-foundation-sys", "jni 0.22.4", - "log 0.4.34", + "log 0.4.33", "once_cell", "rustls", "rustls-native-certs", @@ -4037,9 +3933,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4184,7 +4080,7 @@ dependencies = [ "bitflags 2.13.1", "cssparser", "derive_more", - "log 0.4.34", + "log 0.4.33", "new_debug_unreachable", "phf", "phf_codegen", @@ -4243,7 +4139,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -4289,7 +4185,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -4324,9 +4220,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -4334,7 +4230,6 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", - "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -4345,9 +4240,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4386,17 +4281,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -4632,9 +4516,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" dependencies = [ "base64 0.21.7", "serde", @@ -4664,9 +4548,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -4763,7 +4647,7 @@ dependencies = [ "gtk", "jni 0.21.1", "libc", - "log 0.4.34", + "log 0.4.33", "ndk", "ndk-sys", "objc2", @@ -4820,7 +4704,7 @@ dependencies = [ "image", "jni 0.21.1", "libc", - "log 0.4.34", + "log 0.4.33", "mime", "muda", "objc2", @@ -4842,7 +4726,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tray-icon", "url", @@ -4893,7 +4777,7 @@ dependencies = [ "sha2 0.10.9", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.19", "time", "url", "uuid", @@ -4937,12 +4821,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" dependencies = [ "arboard", - "log 0.4.34", + "log 0.4.33", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.19", ] [[package]] @@ -4959,7 +4843,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.19", "tracing", "url", "windows-registry", @@ -4972,7 +4856,7 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" dependencies = [ - "log 0.4.34", + "log 0.4.33", "raw-window-handle", "rfd", "serde", @@ -4980,7 +4864,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.20", + "thiserror 2.0.19", "url", ] @@ -4993,7 +4877,7 @@ dependencies = [ "anyhow", "dunce", "glob", - "log 0.4.34", + "log 0.4.33", "objc2-foundation", "percent-encoding", "schemars 0.8.22", @@ -5003,7 +4887,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.19", "toml 1.1.4+spec-1.1.0", "url", ] @@ -5016,7 +4900,7 @@ checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" dependencies = [ "android_logger", "fern", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-foundation", "serde", @@ -5025,7 +4909,7 @@ dependencies = [ "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.19", "time", ] @@ -5035,7 +4919,7 @@ version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ - "log 0.4.34", + "log 0.4.33", "notify-rust", "rand 0.9.5", "serde", @@ -5043,7 +4927,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.19", "time", "url", ] @@ -5064,7 +4948,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.19", "url", "windows 0.61.3", "zbus", @@ -5077,7 +4961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" dependencies = [ "encoding_rs", - "log 0.4.34", + "log 0.4.33", "open", "os_pipe", "regex 1.13.1", @@ -5087,7 +4971,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", ] @@ -5101,7 +4985,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.20", + "thiserror 2.0.19", "tokio", "tracing", "windows-sys 0.60.2", @@ -5126,7 +5010,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.19", "url", "webkit2gtk", "webview2-com", @@ -5142,7 +5026,7 @@ dependencies = [ "gtk", "http", "jni 0.21.1", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-app-kit", "once_cell", @@ -5175,7 +5059,7 @@ dependencies = [ "http", "infer", "json-patch", - "log 0.4.34", + "log 0.4.33", "memchr", "phf", "plist", @@ -5189,7 +5073,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.20", + "thiserror 2.0.19", "toml 1.1.4+spec-1.1.0", "url", "urlpattern", @@ -5214,7 +5098,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.19", "windows 0.61.3", "windows-version", ] @@ -5261,11 +5145,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.19", ] [[package]] @@ -5281,13 +5165,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.3", ] [[package]] @@ -5315,9 +5199,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.55" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "libc", @@ -5356,9 +5240,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5397,13 +5281,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", ] [[package]] @@ -5423,7 +5307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", - "log 0.4.34", + "log 0.4.33", "tokio", "tungstenite", ] @@ -5630,7 +5514,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log 0.4.34", + "log 0.4.33", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5664,7 +5548,7 @@ checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" dependencies = [ "chrono", "libc", - "log 0.4.34", + "log 0.4.33", "objc2", "objc2-foundation", "once_cell", @@ -5692,7 +5576,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", + "thiserror 2.0.19", "windows-sys 0.61.2", ] @@ -5719,7 +5603,7 @@ version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.19", "ts-rs-macros", "uuid", ] @@ -5746,10 +5630,10 @@ dependencies = [ "data-encoding", "http", "httparse", - "log 0.4.34", + "log 0.4.33", "rand 0.10.2", - "sha1 0.11.0", - "thiserror 2.0.20", + "sha1", + "thiserror 2.0.19", ] [[package]] @@ -5828,15 +5712,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5894,9 +5769,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.25.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5978,9 +5853,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5991,9 +5866,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6001,9 +5876,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6011,9 +5886,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -6024,9 +5899,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -6046,9 +5921,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.17" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", @@ -6116,9 +5991,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6126,9 +6001,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf", "phf_codegen", @@ -6220,7 +6095,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.19", "windows 0.61.3", "windows-core 0.61.2", ] @@ -6849,10 +6724,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ "libc", - "log 0.4.34", + "log 0.4.33", "os_pipe", "rustix", - "thiserror 2.0.20", + "thiserror 2.0.19", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -6862,9 +6737,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" @@ -6899,7 +6774,7 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.20", + "thiserror 2.0.19", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6973,9 +6848,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-executor", @@ -7008,9 +6883,9 @@ dependencies = [ [[package]] name = "zbus-secret-service-keyring-store" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" dependencies = [ "keyring-core", "secret-service", @@ -7019,14 +6894,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.19.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -7043,29 +6918,20 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", @@ -7101,9 +6967,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -7112,9 +6978,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -7123,13 +6989,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", ] [[package]] @@ -7140,9 +7006,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" @@ -7155,41 +7021,40 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", - "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.119", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.2.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.4", + "syn 2.0.119", "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a6f29d4..33c6a33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,8 +40,6 @@ tauri-plugin-clipboard-manager = "2.3.2" sysinfo = "0.39.3" hmac = "0.13" sha2 = "0.11" -sha1 = "0.10" -base64 = "0.22" tauri-plugin-deep-link = "2" tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } tempfile = "3" @@ -65,11 +63,9 @@ keyring-core = "1.0.0" [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] } objc = "0.2.7" -unicode-normalization = "0.1.25" [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = "1.1.0" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } [target.'cfg(target_os = "linux")'.dependencies] zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9fe1b22..8c351e7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,7 +14,6 @@ "log:default", "notification:default", "notification:allow-is-permission-granted", - "clipboard-manager:allow-read-text", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-read-text" ] } diff --git a/src-tauri/capabilities/properties.json b/src-tauri/capabilities/properties.json deleted file mode 100644 index f7898e9..0000000 --- a/src-tauri/capabilities/properties.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "properties-window", - "description": "Minimal capability for Firelink Properties windows", - "windows": ["properties-*"], - "permissions": [ - "core:window:allow-close", - "core:window:allow-destroy", - "core:window:allow-minimize", - "core:window:allow-set-title", - "core:window:allow-start-dragging", - "core:window:allow-toggle-maximize", - "core:event:allow-listen", - "core:event:allow-unlisten", - "dialog:default", - "clipboard-manager:allow-write-text", - "log:default" - ] -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d13d4b7..ce12a18 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,12 +4,10 @@ use tauri_plugin_opener::OpenerExt; #[tauri::command] pub async fn reveal_in_file_manager( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String, ) -> Result<(), String> { - crate::properties_window::ensure_main_window(&caller)?; - let primary = authorize_reveal_path(&app_handle, &path)?; + let primary = authorize_download_path(&app_handle, &path)?; let path = existing_download_asset(&primary).ok_or_else(|| { format!( "Downloaded file or partial file is missing: {}", @@ -31,11 +29,9 @@ pub async fn reveal_in_file_manager( #[tauri::command] pub async fn open_downloaded_file( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String, ) -> Result<(), String> { - crate::properties_window::ensure_main_window(&caller)?; let path = authorize_download_path(&app_handle, &path)?; if !path.exists() { return Err(format!("Downloaded file is missing: {}", path.display())); @@ -60,37 +56,18 @@ fn authorize_download_path( authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?) } -fn authorize_reveal_path( - app_handle: &tauri::AppHandle, - requested: &str, -) -> Result { - authorize_exact_path_with_directory( - Path::new(requested), - &known_download_paths(app_handle)?, - true, - ) -} - fn known_download_paths(app_handle: &tauri::AppHandle) -> Result, String> { crate::download_ownership::known_primary_paths(app_handle) } fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result { - authorize_exact_path_with_directory(requested, allowed_paths, false) -} - -fn authorize_exact_path_with_directory( - requested: &Path, - allowed_paths: &[PathBuf], - allow_directory: bool, -) -> Result { if crate::path_has_symlink_component(requested) { return Err("Download path may not contain symlink components".to_string()); } let requested = canonicalize_with_missing_leaf(requested)?; if let Ok(metadata) = std::fs::metadata(&requested) { - if !metadata.is_file() && !(allow_directory && metadata.is_dir()) { + if !metadata.is_file() { return Err("Download path is not a file".to_string()); } } @@ -163,9 +140,8 @@ fn existing_download_asset(primary: &Path) -> Option { ] .into_iter() .find(|candidate| { - std::fs::symlink_metadata(candidate).is_ok_and(|metadata| { - (metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink() - }) + std::fs::symlink_metadata(candidate) + .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 642ee83..27ccf77 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,13 +1,13 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde_json::Value; use std::fs; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; 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 = 1; 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 @@ -66,28 +66,7 @@ fn init_at_path_internal( fs::create_dir_all(app_data_dir) .map_err(|error| format!("failed to create app data directory: {error}"))?; let database_path = app_data_dir.join(DATABASE_NAME); - let existed = match fs::symlink_metadata(&database_path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "persistence database is a symbolic link: '{}'", - database_path.display() - )); - } - Ok(metadata) if !metadata.is_file() => { - return Err(format!( - "persistence database is not a regular file: '{}'", - database_path.display() - )); - } - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(error) => { - return Err(format!( - "failed to inspect persistence database '{}': {error}", - database_path.display() - )); - } - }; + let existed = database_path.exists(); let mut connection = Connection::open(&database_path) .map_err(|error| format!("failed to open database: {error}"))?; @@ -148,10 +127,6 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), id TEXT PRIMARY KEY, primary_path TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS download_owned_paths ( - id TEXT PRIMARY KEY, - paths TEXT NOT NULL - ); CREATE TABLE IF NOT EXISTS migration_events ( key TEXT PRIMARY KEY, consumed INTEGER NOT NULL DEFAULT 0 @@ -201,32 +176,6 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), } } - if from_version < 2 { - transaction - .execute_batch( - " - CREATE TABLE IF NOT EXISTS download_owned_paths ( - id TEXT PRIMARY KEY, - paths TEXT NOT NULL - ); - ", - ) - .map_err(|error| format!("failed to migrate download ownership paths: {error}"))?; - } - - if from_version < 3 { - transaction - .execute_batch( - " - CREATE TABLE IF NOT EXISTS download_removal_paths ( - id TEXT PRIMARY KEY, - paths TEXT NOT NULL - ); - ", - ) - .map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?; - } - transaction .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .map_err(|error| format!("failed to update database schema version: {error}"))?; @@ -310,27 +259,6 @@ fn import_legacy_data( } fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "legacy persistence source is a symbolic link: '{}'", - path.display() - )); - } - Ok(metadata) if !metadata.is_file() => { - return Err(format!( - "legacy persistence source is not a regular file: '{}'", - path.display() - )); - } - Ok(_) => {} - Err(error) => { - return Err(format!( - "failed to inspect legacy persistence source '{}': {error}", - path.display() - )); - } - } if is_database_path(path) { let mut connection = Connection::open(path).map_err(|error| { format!( @@ -431,27 +359,12 @@ fn write_sanitized_legacy_store( path.display() ) })?; - temporary.as_file().sync_all().map_err(|error| { - format!( - "failed to synchronize temporary sanitized legacy store beside '{}': {error}", - path.display() - ) - })?; temporary.persist(path).map_err(|error| { format!( "failed to replace legacy store '{}' without losing the original: {}", path.display(), error.error ) })?; - #[cfg(unix)] - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| { - format!( - "failed to synchronize legacy store directory '{}': {error}", - parent.display() - ) - })?; Ok(()) } @@ -648,18 +561,6 @@ fn query_string_column(connection: &Connection, query: &str) -> Result Result { - let source_metadata = fs::symlink_metadata(path).map_err(|error| { - format!( - "failed to inspect persistence file '{}': {error}", - path.display() - ) - })?; - if source_metadata.file_type().is_symlink() || !source_metadata.is_file() { - return Err(format!( - "persistence backup source is not a regular file: '{}'", - path.display() - )); - } let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let file_name = path .file_name() @@ -668,7 +569,6 @@ fn backup_file(path: &Path, reason: &str) -> Result { let backup_prefix = format!("{file_name}.backup-{reason}-"); if let Some(existing) = path.parent().and_then(|parent| { fs::read_dir(parent).ok()?.flatten().find_map(|entry| { - entry.file_type().ok().filter(|kind| kind.is_file())?; entry .file_name() .to_string_lossy() @@ -678,50 +578,17 @@ fn backup_file(path: &Path, reason: &str) -> Result { }) { return Ok(existing); } - let backup_path = path.with_file_name(format!( - "{file_name}.backup-{reason}-{timestamp}-{}", - uuid::Uuid::new_v4().simple() - )); - let result = (|| { - use std::io::{copy, BufReader}; - - let source = fs::File::open(path).map_err(|error| { - format!( - "failed to open persistence file '{}' for backup: {error}", - path.display() - ) - })?; - let mut source = BufReader::new(source); - let destination = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&backup_path) - .map_err(|error| { - format!( - "failed to create persistence backup '{}': {error}", - backup_path.display() - ) - })?; - let mut destination = destination; - copy(&mut source, &mut destination).map_err(|error| { - format!( - "failed to back up persistence file '{}' to '{}': {error}", - path.display(), - backup_path.display() - ) - })?; - destination.sync_all().map_err(|error| { - format!( - "failed to synchronize persistence backup '{}': {error}", - backup_path.display() - ) - })?; - Ok::<(), String>(()) - })(); - if let Err(error) = result { - let _ = fs::remove_file(&backup_path); - return Err(error); + let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); + if backup_path.exists() { + return Ok(backup_path); } + fs::copy(path, &backup_path).map_err(|error| { + format!( + "failed to back up persistence file '{}' to '{}': {error}", + path.display(), + backup_path.display() + ) + })?; Ok(backup_path) } @@ -731,10 +598,7 @@ fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| format!("invalid database path '{}'", path.display()))?; - let backup_path = path.with_file_name(format!( - "{file_name}.backup-{reason}-{timestamp}-{}", - uuid::Uuid::new_v4().simple() - )); + let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); connection .execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()]) .map_err(|error| { @@ -1023,7 +887,18 @@ pub fn replace_downloads( data: &str, portable: bool, ) -> Result<(), String> { - let strings = prepare_download_strings(data, portable)?; + let values: Vec = serde_json::from_str(data) + .map_err(|error| format!("failed to decode downloads: {error}"))?; + let strings = values + .into_iter() + .map(|mut value| { + if portable { + remove_persisted_transfer_secrets(&mut value); + } + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode download: {error}")) + }) + .collect::, _>>()?; let transaction = connection .transaction() .map_err(|error| format!("failed to begin download save: {error}"))?; @@ -1033,121 +908,7 @@ pub fn replace_downloads( .map_err(|error| format!("failed to commit download save: {error}")) } -pub fn replace_downloads_and_queues( - connection: &mut Connection, - downloads_data: &str, - queues_data: &str, - portable: bool, -) -> Result<(), String> { - let downloads = prepare_download_strings(downloads_data, portable)?; - let queues = prepare_queue_strings(queues_data)?; - let transaction = connection - .transaction() - .map_err(|error| format!("failed to begin download state save: {error}"))?; - replace_downloads_tx(&transaction, &downloads)?; - replace_queues_tx(&transaction, &queues)?; - transaction - .commit() - .map_err(|error| format!("failed to commit download state save: {error}")) -} - -/// Mutate exactly one persisted download inside a database transaction. -/// -/// Native lifecycle code must not rebuild the renderer-owned download array: -/// doing so can overwrite a newer renderer snapshot, and encoding the loaded -/// JSON strings as an array produces double-encoded records. Validate the -/// complete persisted set before changing the target, then update only that -/// row while keeping the indexed columns in sync with its JSON document. -pub fn mutate_download( - connection: &mut Connection, - id: &str, - portable: bool, - mutate: F, -) -> Result -where - F: FnOnce(&mut serde_json::Map) -> Result, -{ - let transaction = connection - .transaction() - .map_err(|error| format!("failed to begin download mutation: {error}"))?; - let records = { - let mut statement = transaction - .prepare("SELECT id, data FROM downloads ORDER BY rowid") - .map_err(|error| format!("failed to prepare download mutation: {error}"))?; - let rows = statement - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|error| format!("failed to read downloads for mutation: {error}"))?; - rows.collect::, _>>() - .map_err(|error| format!("failed to read download for mutation: {error}"))? - }; - - let mut target = None; - for (stored_id, data) in records { - let value: Value = serde_json::from_str(&data) - .map_err(|error| format!("persisted download '{stored_id}' is malformed: {error}"))?; - let document_id = required_string(&value, "id")?; - required_string(&value, "status")?; - if document_id != stored_id { - return Err(format!( - "persisted download '{stored_id}' has mismatched document id" - )); - } - if stored_id == id { - target = Some(value); - } - } - - let mut value = target.ok_or_else(|| "download is no longer persisted".to_string())?; - let original_value = value.clone(); - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - let result = mutate(object)?; - if object.get("id").and_then(Value::as_str) != Some(id) { - return Err("persisted download mutation cannot change its id".to_string()); - } - remove_live_download_metadata(&mut value); - if portable { - remove_persisted_transfer_secrets(&mut value); - } - if value == original_value { - return Ok(result); - } - let document_id = required_string(&value, "id")?; - let status = required_string(&value, "status")?; - let queue_id = value.get("queueId").and_then(Value::as_str); - let data = serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}"))?; - let changed = transaction - .execute( - "UPDATE downloads - SET status = ?1, queue_id = ?2, data = ?3 - WHERE id = ?4", - params![status, queue_id, data, document_id], - ) - .map_err(|error| format!("failed to mutate download '{id}': {error}"))?; - if changed != 1 { - return Err("download is no longer persisted".to_string()); - } - transaction - .commit() - .map_err(|error| format!("failed to commit download mutation: {error}"))?; - Ok(result) -} - -fn remove_live_download_metadata(value: &mut Value) { - if let Some(object) = value.as_object_mut() { - // Error classifications and resolver phase are process-local - // presentation metadata; never retain them in the persisted contract. - object.remove("lastErrorKind"); - object.remove("lastResolverFallback"); - } -} - fn remove_persisted_transfer_secrets(value: &mut Value) { - remove_live_download_metadata(value); let Some(object) = value.as_object_mut() else { return; }; @@ -1155,16 +916,11 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { // These values are accepted from users, browser extensions, or URLs and // may contain credentials or bearer tokens. Portable queues keep their // useful metadata, but never persist these values beside the executable. - // Safe, stable browser context is sanitized in place so a normal captured - // download does not become unresumable merely because it has a Referer or - // User-Agent. Unknown and credential-bearing headers still fail closed. - let is_torrent = object.get("isTorrent").and_then(Value::as_bool) == Some(true); - let mut removed_transfer_context = sanitize_portable_request_headers(object); - for key in ["password", "cookies", "mirrors", "proxy"] { + let mut removed_transfer_context = false; + for key in ["password", "cookies", "headers", "mirrors", "proxy"] { if object .get(key) .is_some_and(|value| !value.is_null() && !value_is_empty(value)) - && !(is_torrent && matches!(key, "password" | "cookies")) { removed_transfer_context = true; } @@ -1178,49 +934,22 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { ); } - sanitize_portable_torrent_trackers(object); - sanitize_portable_torrent_exclude_trackers(object); - if let Some(url) = object.get("url").and_then(Value::as_str) { if let Ok(mut parsed) = url::Url::parse(url) { let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); - if parsed.scheme() == "magnet" { - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - let mut retained_info_hash = false; - let mut removed_query_context = false; - for (key, value) in parsed.query_pairs() { - if key == "xt" || key == "dn" { - retained_info_hash |= key == "xt"; - serializer.append_pair(&key, &value); - } else { - removed_query_context = true; - } - } - let removed_fragment = parsed.fragment().is_some(); - let safe_query = serializer.finish(); + let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); + if had_userinfo || had_query_or_fragment { let _ = parsed.set_username(""); let _ = parsed.set_password(None); - parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str())); + parsed.set_query(None); parsed.set_fragment(None); object.insert("url".to_string(), Value::String(parsed.to_string())); - if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash { - mark_portable_download_unresumable(object); - } - } else { - let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); - if had_userinfo || had_query_or_fragment { - let _ = parsed.set_username(""); - let _ = parsed.set_password(None); - parsed.set_query(None); - parsed.set_fragment(None); - object.insert("url".to_string(), Value::String(parsed.to_string())); - // A queued transfer whose URL depended on query/fragment - // credentials must not silently auto-resume with a truncated - // URL after a portable restart. - if had_userinfo || had_query_or_fragment { - mark_portable_download_unresumable(object); - } + // A queued transfer whose URL depended on query/fragment + // credentials must not silently auto-resume with a truncated + // URL after a portable restart. + if had_userinfo || had_query_or_fragment { + mark_portable_download_unresumable(object); } } } else { @@ -1238,266 +967,6 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { } } -const PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS: &[&str] = &[ - "accept", - "accept-charset", - "accept-encoding", - "accept-language", - "cache-control", - "connection", - "dnt", - "host", - "if-match", - "if-modified-since", - "if-none-match", - "if-range", - "if-unmodified-since", - "origin", - "pragma", - "priority", - "range", - "referer", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "sec-fetch-dest", - "sec-fetch-mode", - "sec-fetch-site", - "sec-fetch-user", - "sec-gpc", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "user-agent", - "via", - "warning", -]; - -const PORTABLE_PERSISTABLE_REQUEST_HEADERS: &[&str] = &[ - "accept", - "accept-charset", - "accept-encoding", - "accept-language", - "cache-control", - "dnt", - "origin", - "pragma", - "priority", - "referer", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "sec-fetch-dest", - "sec-fetch-mode", - "sec-fetch-site", - "sec-fetch-user", - "sec-gpc", - "user-agent", -]; - -fn portable_header_is_known_non_credential(name: &str) -> bool { - PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS - .iter() - .any(|candidate| *candidate == name) -} - -fn portable_header_is_persistable(name: &str) -> bool { - PORTABLE_PERSISTABLE_REQUEST_HEADERS - .iter() - .any(|candidate| *candidate == name) -} - -fn sanitize_portable_request_header_value(name: &str, value: &str) -> Option { - if value.chars().any(char::is_control) { - return None; - } - - match name { - "referer" => { - let mut parsed = url::Url::parse(value).ok()?; - if !matches!(parsed.scheme(), "http" | "https") { - return None; - } - let _ = parsed.set_username(""); - let _ = parsed.set_password(None); - parsed.set_query(None); - parsed.set_fragment(None); - Some(parsed.to_string()) - } - "origin" => { - let parsed = url::Url::parse(value).ok()?; - matches!(parsed.scheme(), "http" | "https") - .then(|| parsed.origin().ascii_serialization()) - } - _ => Some(value.to_string()), - } -} - -fn portable_request_header_requires_recovery(name: &str, value: &str) -> bool { - if value.trim().is_empty() { - return false; - } - if value.chars().any(char::is_control) { - return true; - } - - if name == "referer" || name == "origin" { - let Ok(parsed) = url::Url::parse(value) else { - return true; - }; - if !matches!(parsed.scheme(), "http" | "https") { - return true; - } - if !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return true; - } - return name == "origin" && !parsed.path().is_empty() && parsed.path() != "/"; - } - - false -} - -/// Remove unsafe request headers from portable persistence while retaining -/// sanitized, stable browser context. The boolean reports whether the input -/// contained credential-bearing, unknown, or malformed header context that -/// makes automatic restart unsafe. Torrent browser context is metadata-only: -/// it is removed without making a cached-metadata Torrent unresumable. -fn sanitize_portable_request_headers(object: &mut serde_json::Map) -> bool { - let Some(raw_value) = object.get("headers").cloned() else { - return false; - }; - if raw_value.is_null() || value_is_empty(&raw_value) { - object.remove("headers"); - return false; - } - - let Some(raw_headers) = raw_value.as_str() else { - object.remove("headers"); - return true; - }; - - if object.get("isTorrent").and_then(Value::as_bool) == Some(true) { - object.remove("headers"); - return false; - } - - let mut retained = Vec::new(); - let mut removed_context = false; - for line in raw_headers.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Some((raw_name, raw_value)) = trimmed.split_once(':') else { - removed_context = true; - continue; - }; - let name = raw_name.trim().to_ascii_lowercase(); - if !portable_header_is_known_non_credential(&name) { - removed_context = true; - continue; - } - if portable_request_header_requires_recovery(&name, raw_value.trim()) { - removed_context = true; - } - let Some(sanitized_value) = sanitize_portable_request_header_value(&name, raw_value.trim()) - else { - continue; - }; - if portable_header_is_persistable(&name) { - retained.push(format!("{}: {sanitized_value}", raw_name.trim())); - } - } - - if retained.is_empty() { - object.remove("headers"); - } else { - object.insert("headers".to_string(), Value::String(retained.join("\n"))); - } - removed_context -} - -fn sanitize_portable_torrent_tracker_field( - object: &mut serde_json::Map, - key: &str, - normalize: fn(Option<&str>) -> Result, String>, -) { - let Some(raw_value) = object.get(key).cloned() else { - return; - }; - let Some(raw) = raw_value.as_str().map(str::to_string) else { - object.remove(key); - mark_portable_download_unresumable(object); - return; - }; - let raw = raw.trim(); - if raw.is_empty() { - object.remove(key); - return; - } - let Some(normalized) = normalize(Some(raw)).ok().flatten() else { - object.remove(key); - mark_portable_download_unresumable(object); - return; - }; - - let mut sanitized = Vec::new(); - let mut removed_context = false; - for token in normalized.split(',') { - if token == "*" { - sanitized.push(token.to_string()); - continue; - } - let Ok(mut parsed) = url::Url::parse(token) else { - object.remove(key); - mark_portable_download_unresumable(object); - return; - }; - let had_context = !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some(); - if had_context { - let _ = parsed.set_username(""); - let _ = parsed.set_password(None); - parsed.set_query(None); - parsed.set_fragment(None); - removed_context = true; - } - sanitized.push(parsed.to_string()); - } - - if sanitized.is_empty() { - object.remove(key); - } else { - object.insert(key.to_string(), Value::String(sanitized.join(","))); - } - if removed_context { - mark_portable_download_unresumable(object); - } -} - -fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map) { - sanitize_portable_torrent_tracker_field( - object, - "torrentTrackers", - crate::queue::normalize_torrent_trackers, - ); -} - -fn sanitize_portable_torrent_exclude_trackers(object: &mut serde_json::Map) { - sanitize_portable_torrent_tracker_field( - object, - "torrentExcludeTrackers", - crate::queue::normalize_torrent_exclude_trackers, - ); -} - fn value_is_empty(value: &Value) -> bool { value.as_str().is_some_and(str::is_empty) || value.as_array().is_some_and(Vec::is_empty) @@ -1605,7 +1074,14 @@ pub fn load_queues(connection: &Connection) -> Result, String> { } pub fn replace_queues(connection: &mut Connection, data: &str) -> Result<(), String> { - let strings = prepare_queue_strings(data)?; + let values: Vec = + serde_json::from_str(data).map_err(|error| format!("failed to decode queues: {error}"))?; + let strings = values + .iter() + .map(|value| { + serde_json::to_string(value).map_err(|error| format!("failed to encode queue: {error}")) + }) + .collect::, _>>()?; let transaction = connection .transaction() .map_err(|error| format!("failed to begin queue save: {error}"))?; @@ -1615,33 +1091,6 @@ pub fn replace_queues(connection: &mut Connection, data: &str) -> Result<(), Str .map_err(|error| format!("failed to commit queue save: {error}")) } -fn prepare_download_strings(data: &str, portable: bool) -> Result, String> { - let values: Vec = serde_json::from_str(data) - .map_err(|error| format!("failed to decode downloads: {error}"))?; - values - .into_iter() - .map(|mut value| { - remove_live_download_metadata(&mut value); - if portable { - remove_persisted_transfer_secrets(&mut value); - } - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode download: {error}")) - }) - .collect() -} - -fn prepare_queue_strings(data: &str) -> Result, String> { - let values: Vec = - serde_json::from_str(data).map_err(|error| format!("failed to decode queues: {error}"))?; - values - .iter() - .map(|value| { - serde_json::to_string(value).map_err(|error| format!("failed to encode queue: {error}")) - }) - .collect() -} - fn replace_queues_tx(transaction: &Transaction<'_>, queues: &[String]) -> Result<(), String> { transaction .execute("DELETE FROM queues", []) @@ -1668,330 +1117,49 @@ fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { .ok_or_else(|| format!("persisted item is missing '{key}'")) } -pub fn load_ownership(connection: &Connection) -> Result)>, String> { +pub fn load_ownership(connection: &Connection) -> Result, String> { let mut statement = connection - .prepare( - "SELECT ownership.id, ownership.primary_path, paths.paths - FROM download_ownership AS ownership - LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id", - ) + .prepare("SELECT id, primary_path FROM download_ownership") .map_err(|error| format!("failed to prepare ownership query: {error}"))?; let rows = statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - }) + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .map_err(|error| format!("failed to query ownership data: {error}"))?; - let mut ownership = Vec::new(); - for row in rows { - let (id, primary_path, encoded_paths) = - row.map_err(|error| format!("failed to read ownership data: {error}"))?; - let owned_paths = match encoded_paths { - Some(encoded) => { - let paths = serde_json::from_str::>(&encoded).map_err(|error| { - format!("failed to decode owned paths for download '{id}': {error}") - })?; - if paths.is_empty() { - vec![primary_path.clone()] - } else { - paths - } - } - None => vec![primary_path.clone()], - }; - ownership.push((id, primary_path, owned_paths)); - } - Ok(ownership) + rows.collect::, _>>() + .map_err(|error| format!("failed to read ownership data: {error}")) } -pub fn set_ownership_paths( - connection: &Connection, - id: &str, - primary_path: &str, - paths: &[String], -) -> Result<(), String> { - // The path collision check and both ownership writes must be one SQLite - // transaction. Otherwise two concurrent admissions can both observe an - // empty registry and claim the same output before either insert becomes - // visible to the other. - let transaction = connection - .unchecked_transaction() - .map_err(|error| format!("failed to begin download ownership transaction: {error}"))?; - set_ownership_paths_checked(&transaction, id, primary_path, paths, &[])?; - transaction - .commit() - .map_err(|error| format!("failed to commit download ownership transaction: {error}")) -} - -fn set_ownership_paths_checked( - connection: &Connection, - id: &str, - primary_path: &str, - paths: &[String], - removal_paths: &[String], -) -> Result<(), String> { - let mut statement = connection - .prepare( - "SELECT ownership.id, ownership.primary_path, paths.paths, removal.paths - FROM download_ownership AS ownership - LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id - LEFT JOIN download_removal_paths AS removal ON removal.id = ownership.id - WHERE ownership.id <> ?1", +pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> { + let existing_owner = connection + .query_row( + "SELECT id FROM download_ownership + WHERE primary_path = ?1 AND id <> ?2 + LIMIT 1", + params![path, id], + |row| row.get::<_, String>(0), ) - .map_err(|error| format!("failed to prepare download ownership check: {error}"))?; - let existing = statement - .query_map(params![id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - }) - .map_err(|error| format!("failed to check download ownership paths: {error}"))?; - for row in existing { - let (existing_id, existing_primary, encoded_owned, encoded_removal) = - row.map_err(|error| format!("failed to read download ownership paths: {error}"))?; - let owned = match encoded_owned { - Some(encoded) => { - let paths = serde_json::from_str::>(&encoded).map_err(|error| { - format!("failed to decode owned paths for download '{existing_id}': {error}") - })?; - if paths.is_empty() { - vec![existing_primary.clone()] - } else { - paths - } - } - None => vec![existing_primary.clone()], - }; - let removal = match encoded_removal { - Some(encoded) => serde_json::from_str::>(&encoded).map_err(|error| { - format!( - "failed to decode removal paths for download '{existing_id}': {error}" - ) - })?, - None => Vec::new(), - }; - let new_paths = std::iter::once(primary_path) - .chain(paths.iter().map(String::as_str)) - .chain(removal_paths.iter().map(String::as_str)); - let existing_paths = std::iter::once(existing_primary.as_str()) - .chain(owned.iter().map(String::as_str)) - .chain(removal.iter().map(String::as_str)); - if new_paths.clone().any(|new_path| { - existing_paths - .clone() - .any(|existing_path| crate::platform::paths_equal(Path::new(new_path), Path::new(existing_path))) - }) { - return Err("Download destination is already owned by another Firelink download".to_string()); - } + .optional() + .map_err(|error| format!("failed to check download ownership path: {error}"))?; + if existing_owner.is_some() { + return Err("Download destination is already owned by another Firelink download".to_string()); } connection .execute( "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path", - params![id, primary_path], + params![id, path], ) .map_err(|error| format!("failed to save ownership data: {error}"))?; - let encoded_paths = serde_json::to_string(paths) - .map_err(|error| format!("failed to encode download ownership paths: {error}"))?; - connection - .execute( - "INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2) - ON CONFLICT(id) DO UPDATE SET paths = excluded.paths", - params![id, encoded_paths], - ) - .map_err(|error| format!("failed to save download ownership path list: {error}"))?; Ok(()) } -pub fn set_ownership_and_removal_paths( - connection: &Connection, - id: &str, - primary_path: &str, - paths: &[String], - removal_paths: &[String], -) -> Result<(), String> { - // Ownership and the planned deletion reservation are one safety - // boundary. Do not leave a partially-written reservation behind if the - // second table write fails or the process crashes between writes. - let transaction = connection - .unchecked_transaction() - .map_err(|error| format!("failed to begin torrent ownership transaction: {error}"))?; - set_ownership_paths_checked(&transaction, id, primary_path, paths, removal_paths)?; - if removal_paths.is_empty() { - transaction - .execute( - "DELETE FROM download_removal_paths WHERE id = ?1", - params![id], - ) - .map_err(|error| format!("failed to clear torrent removal paths: {error}"))?; - } else { - let encoded_paths = serde_json::to_string(removal_paths) - .map_err(|error| format!("failed to encode torrent removal paths: {error}"))?; - transaction - .execute( - "INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2) - ON CONFLICT(id) DO UPDATE SET paths = excluded.paths", - params![id, encoded_paths], - ) - .map_err(|error| format!("failed to save torrent removal paths: {error}"))?; - } - transaction - .commit() - .map_err(|error| format!("failed to commit torrent ownership transaction: {error}")) -} - -/// Reclaim reservations left behind by a process crash after a terminal -/// Torrent outcome. Active, queued, and paused records remain reserved: a -/// future lifecycle may still ask Aria2 to remove those paths. A terminal -/// record is reclaimed only after every reserved path is absent, proving that -/// Aria2's unselected-file cleanup was observed. Failed records are treated -/// conservatively as well because Aria2 may leave unselected files behind on -/// an error. -pub fn reconcile_torrent_removal_paths_after_restart( - connection: &Connection, -) -> Result { - let mut statement = connection - .prepare( - "SELECT removal.id, removal.paths, downloads.status - FROM download_removal_paths AS removal - LEFT JOIN downloads ON downloads.id = removal.id", - ) - .map_err(|error| format!("failed to prepare torrent removal recovery query: {error}"))?; - let rows = statement - .query_map([], |row| { - let id: String = row.get(0)?; - let paths: String = row.get(1)?; - let status: Option = row.get(2)?; - Ok((id, paths, status)) - }) - .map_err(|error| format!("failed to query torrent removal recovery data: {error}"))?; - - let mut reclaim = Vec::new(); - for row in rows { - let (id, encoded_paths, status) = - row.map_err(|error| format!("failed to read torrent removal recovery data: {error}"))?; - let paths = match serde_json::from_str::>(&encoded_paths) { - Ok(paths) - if !paths.is_empty() - && paths.iter().all(|path| { - let path = Path::new(path); - path.is_absolute() - && !path.components().any(|component| { - matches!(component, Component::CurDir | Component::ParentDir) - }) - }) => - { - paths - } - // Malformed or empty reservations are retained for conservative - // manual recovery rather than being silently discarded. - _ => continue, - }; - let Some(status) = status else { - continue; - }; - let should_reclaim = match status.as_str() { - "failed" | "completed" => paths - .iter() - .all(|path| torrent_removal_path_is_absent(Path::new(path))), - _ => false, - }; - if should_reclaim { - reclaim.push(id); - } - } - - let mut reclaimed = 0; - for id in reclaim { - reclaimed += connection - .execute( - "DELETE FROM download_removal_paths WHERE id = ?1", - params![id], - ) - .map_err(|error| format!("failed to reclaim torrent removal paths: {error}"))?; - } - Ok(reclaimed) -} - -fn torrent_removal_path_is_absent(path: &Path) -> bool { - matches!( - fs::symlink_metadata(path), - Err(error) if error.kind() == std::io::ErrorKind::NotFound - ) -} - pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { - connection - .execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id]) - .map_err(|error| format!("failed to delete torrent removal paths: {error}"))?; - connection - .execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id]) - .map_err(|error| format!("failed to delete download ownership paths: {error}"))?; connection .execute("DELETE FROM download_ownership WHERE id = ?1", params![id]) .map_err(|error| format!("failed to delete ownership data: {error}"))?; Ok(()) } -pub fn remove_torrent_removal_paths(connection: &Connection, id: &str) -> Result<(), String> { - connection - .execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id]) - .map_err(|error| format!("failed to clear torrent removal paths: {error}"))?; - Ok(()) -} - -pub fn load_torrent_removal_paths( - connection: &Connection, - id: &str, -) -> Result, String> { - connection - .query_row( - "SELECT paths FROM download_removal_paths WHERE id = ?1", - params![id], - |row| row.get::<_, String>(0), - ) - .optional() - .map_err(|error| format!("failed to read torrent removal paths: {error}"))? - .map(|value| { - serde_json::from_str::>(&value) - .map_err(|error| format!("failed to decode torrent removal paths: {error}")) - }) - .transpose() - .map(|paths| paths.unwrap_or_default()) -} - -pub fn load_all_torrent_removal_paths( - connection: &Connection, -) -> Result)>, String> { - let mut statement = connection - .prepare("SELECT id, paths FROM download_removal_paths") - .map_err(|error| format!("failed to prepare torrent removal ownership query: {error}"))?; - let rows = statement - .query_map([], |row| { - let id: String = row.get(0)?; - let encoded: String = row.get(1)?; - Ok((id, encoded)) - }) - .map_err(|error| format!("failed to query torrent removal ownership: {error}"))?; - - rows.map(|row| { - let (id, encoded) = row - .map_err(|error| format!("failed to read torrent removal ownership: {error}"))?; - let paths = serde_json::from_str::>(&encoded).map_err(|error| { - format!("failed to decode torrent removal paths for download '{id}': {error}") - })?; - Ok((id, paths)) - }) - .collect() -} - pub fn has_user_data(connection: &Connection) -> Result { connection .query_row( @@ -2523,94 +1691,6 @@ mod tests { })); } - #[test] - fn migrates_v1_database_and_creates_backup() { - let temp = TempDir::new().unwrap(); - let path = temp.path().join(DATABASE_NAME); - let connection = Connection::open(&path).unwrap(); - connection - .execute_batch( - " - CREATE TABLE downloads ( - id TEXT PRIMARY KEY, - status TEXT NOT NULL, - queue_id TEXT, - data TEXT NOT NULL - ); - CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); - CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); - CREATE TABLE download_ownership ( - id TEXT PRIMARY KEY, - primary_path TEXT NOT NULL - ); - INSERT INTO download_ownership VALUES ('download-1', '/downloads/file.bin'); - PRAGMA user_version = 1; - ", - ) - .unwrap(); - drop(connection); - - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - let version: i64 = connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - assert_eq!(version, CURRENT_SCHEMA_VERSION); - assert!(table_exists(&connection, "download_owned_paths").unwrap()); - assert!(table_exists(&connection, "download_removal_paths").unwrap()); - assert_eq!( - load_ownership(&connection).unwrap(), - vec![( - "download-1".to_string(), - "/downloads/file.bin".to_string(), - vec!["/downloads/file.bin".to_string()] - )] - ); - assert!(fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with("firelink.sqlite.backup-schema-v1-") - })); - } - - #[cfg(unix)] - #[test] - fn refuses_to_open_a_database_symlink() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let target = temp.path().join("outside.sqlite"); - symlink(&target, temp.path().join(DATABASE_NAME)).unwrap(); - - let result = init_at_path(temp.path()); - let error = result.err().expect("database symlink must fail closed"); - assert!(error.contains("symbolic link")); - assert!(!target.exists()); - } - - #[cfg(unix)] - #[test] - fn ignores_symlinked_legacy_backup_candidates() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let source = temp.path().join(LEGACY_STORE_NAME); - let outside = temp.path().join("outside-store.bin"); - let linked_backup = temp - .path() - .join("store.bin.backup-legacy-import-attacker"); - fs::write(&source, b"trusted-source").unwrap(); - fs::write(&outside, b"outside-content").unwrap(); - symlink(&outside, &linked_backup).unwrap(); - - let backup = backup_file(&source, "legacy-import").unwrap(); - assert_ne!(backup, linked_backup); - assert!(fs::symlink_metadata(&backup).unwrap().is_file()); - assert_eq!(fs::read(&backup).unwrap(), b"trusted-source"); - assert_eq!(fs::read(&outside).unwrap(), b"outside-content"); - } - #[test] fn portable_migration_does_not_create_raw_schema_backup() { let temp = TempDir::new().unwrap(); @@ -2814,9 +1894,7 @@ mod tests { "cookies": "session=secret", "headers": "Authorization: Bearer secret", "mirrors": "https://user:secret@example.com/mirror", - "proxy": "http://user:secret@example.com:8080", - "torrentTrackers": "https://tracker.example/announce?passkey=secret", - "torrentExcludeTrackers": "https://tracker.example/exclude?passkey=secret" + "proxy": "http://user:secret@example.com:8080" }]) .to_string(); @@ -2830,417 +1908,6 @@ mod tests { for key in ["password", "cookies", "headers", "mirrors", "proxy"] { assert!(saved.get(key).is_none(), "portable data retained {key}"); } - assert_eq!(saved["torrentTrackers"], "https://tracker.example/announce"); - assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude"); - } - - #[test] - fn portable_download_persistence_keeps_sanitized_safe_browser_context() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-safe-headers", - "status": "queued", - "queueId": "main", - "url": "https://example.com/file", - "headers": "Referer: https://example.com/page\nUser-Agent: Firelink-Test" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["status"], "queued"); - assert_ne!(saved.get("resumable"), Some(&Value::Bool(false))); - assert_eq!( - saved["headers"], - "Referer: https://example.com/page\nUser-Agent: Firelink-Test" - ); - } - - #[test] - fn portable_download_persistence_rejects_sensitive_referer_context() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-sensitive-referer", - "status": "queued", - "queueId": "main", - "url": "https://example.com/file", - "headers": "Referer: https://example.com/page?token=secret#fragment" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); - assert_eq!(saved["headers"], "Referer: https://example.com/page"); - assert!(!saved.to_string().contains("secret")); - } - - #[test] - fn portable_download_persistence_rejects_unknown_header_context() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-unknown-header", - "status": "queued", - "queueId": "main", - "url": "https://example.com/file", - "headers": "X-Request-Signature:" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); - assert!(saved.get("headers").is_none()); - } - - #[test] - fn portable_torrent_persistence_strips_metadata_credentials_without_blocking_restart() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "torrent-browser-context", - "status": "queued", - "queueId": "main", - "isTorrent": true, - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "password": "metadata-only-secret", - "cookies": "session=metadata-only", - "headers": "User-Agent: Browser" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["status"], "queued"); - assert_ne!(saved.get("resumable"), Some(&Value::Bool(false))); - assert!(saved.get("password").is_none()); - assert!(saved.get("cookies").is_none()); - assert!(saved.get("headers").is_none()); - assert!(!saved.to_string().contains("metadata-only-secret")); - } - - #[test] - fn download_state_commit_is_atomic_across_downloads_and_queues() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([{ - "id": "old-download", - "status": "paused", - "queueId": "old-queue" - }]) - .to_string(), - false, - ) - .unwrap(); - replace_queues( - &mut connection, - &json!([{ - "id": "old-queue", - "name": "Old Queue", - "isMain": true - }]) - .to_string(), - ) - .unwrap(); - - let result = replace_downloads_and_queues( - &mut connection, - &json!([{ - "id": "new-download", - "status": "queued", - "queueId": "new-queue" - }]) - .to_string(), - &json!([{ - "name": "missing-id" - }]) - .to_string(), - false, - ); - assert!(result.is_err()); - assert!(load_downloads(&connection).unwrap()[0].contains("old-download")); - assert!(load_queues(&connection).unwrap()[0].contains("old-queue")); - - replace_downloads_and_queues( - &mut connection, - &json!([{ - "id": "new-download", - "status": "queued", - "queueId": "new-queue" - }]) - .to_string(), - &json!([{ - "id": "new-queue", - "name": "New Queue", - "isMain": true - }]) - .to_string(), - false, - ) - .unwrap(); - assert!(load_downloads(&connection).unwrap()[0].contains("new-download")); - assert!(load_queues(&connection).unwrap()[0].contains("new-queue")); - } - - #[test] - fn native_download_mutation_keeps_object_records_and_unrelated_rows_unchanged() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([ - { - "id": "torrent-1", - "status": "paused", - "queueId": "main", - "torrentUploadedBytes": 1 - }, - { - "id": "unrelated", - "status": "queued", - "queueId": "secondary", - "customMarker": {"revision": 7} - } - ]) - .to_string(), - false, - ) - .unwrap(); - let unrelated_before = load_downloads(&connection).unwrap()[1].clone(); - - mutate_download(&mut connection, "torrent-1", false, |object| { - object.insert("torrentUploadedBytes".to_string(), json!(99)); - Ok(()) - }) - .unwrap(); - - let saved = load_downloads(&connection).unwrap(); - assert_eq!(saved[1], unrelated_before); - for record in &saved { - let value: Value = serde_json::from_str(record).unwrap(); - assert!(value.is_object()); - assert!(required_string(&value, "id").is_ok()); - assert!(required_string(&value, "status").is_ok()); - } - let target: Value = serde_json::from_str(&saved[0]).unwrap(); - assert_eq!(target["torrentUploadedBytes"], 99); - - let changes_before = connection.total_changes(); - mutate_download(&mut connection, "torrent-1", false, |object| { - object.insert("torrentUploadedBytes".to_string(), json!(99)); - Ok(()) - }) - .unwrap(); - assert_eq!(connection.total_changes(), changes_before); - } - - #[test] - fn native_download_mutation_rolls_back_on_malformed_unrelated_row() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([{ - "id": "torrent-1", - "status": "paused", - "torrentUploadedBytes": 1 - }]) - .to_string(), - false, - ) - .unwrap(); - connection - .execute( - "INSERT INTO downloads (id, status, data) VALUES (?1, ?2, ?3)", - params!["broken", "queued", "\"double-encoded\""], - ) - .unwrap(); - let target_before = load_downloads(&connection).unwrap()[0].clone(); - - let error = mutate_download(&mut connection, "torrent-1", false, |object| { - object.insert("torrentUploadedBytes".to_string(), json!(99)); - Ok(()) - }) - .unwrap_err(); - - assert!(error.contains("persisted item is missing 'id'")); - assert_eq!(load_downloads(&connection).unwrap()[0], target_before); - } - - #[test] - fn native_download_mutation_rolls_back_closure_errors_and_rejects_missing_targets() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([{"id": "torrent-1", "status": "paused"}]).to_string(), - false, - ) - .unwrap(); - let before = load_downloads(&connection).unwrap()[0].clone(); - - let error = mutate_download(&mut connection, "torrent-1", false, |object| { - object.insert("status".to_string(), json!("queued")); - Err::<(), _>("mutation rejected".to_string()) - }) - .unwrap_err(); - assert_eq!(error, "mutation rejected"); - assert_eq!(load_downloads(&connection).unwrap()[0], before); - - let missing = mutate_download(&mut connection, "missing", false, |_| Ok(())) - .unwrap_err(); - assert_eq!(missing, "download is no longer persisted"); - } - - #[test] - fn native_download_mutation_applies_portable_redaction_at_commit() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([{ - "id": "torrent-1", - "status": "paused", - "url": "https://example.test/file", - "password": "secret", - "lastErrorKind": "nameResolution" - }]) - .to_string(), - false, - ) - .unwrap(); - - mutate_download(&mut connection, "torrent-1", true, |object| { - object.insert("torrentUploadedBytes".to_string(), json!(9)); - Ok(()) - }) - .unwrap(); - - let saved: Value = - serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["torrentUploadedBytes"], 9); - assert!(saved.get("password").is_none()); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); - assert!(saved.get("lastErrorKind").is_none()); - } - - #[test] - fn native_download_mutation_drops_live_error_metadata_in_standard_mode() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - replace_downloads( - &mut connection, - &json!([{ - "id": "download-live-metadata", - "status": "paused" - }]) - .to_string(), - false, - ) - .unwrap(); - - mutate_download(&mut connection, "download-live-metadata", false, |object| { - object.insert("status".to_string(), json!("queued")); - object.insert("lastErrorKind".to_string(), json!("nameResolution")); - object.insert("lastResolverFallback".to_string(), json!(true)); - Ok(()) - }) - .unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert!(saved.get("lastErrorKind").is_none()); - assert!(saved.get("lastResolverFallback").is_none()); - } - - #[test] - fn portable_download_persistence_drops_malformed_tracker_fields() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-malformed-trackers", - "status": "queued", - "queueId": "main", - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "torrentTrackers": { "token": "secret" }, - "torrentExcludeTrackers": { "token": "secret" } - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert!(saved.get("torrentTrackers").is_none()); - assert!(saved.get("torrentExcludeTrackers").is_none()); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); - assert!(!saved.to_string().contains("secret")); - } - - #[test] - fn portable_download_persistence_keeps_wildcard_tracker_exclusion() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-wildcard-exclusion", - "status": "queued", - "queueId": "main", - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "torrentExcludeTrackers": "*" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["torrentExcludeTrackers"], "*"); - } - - #[test] - fn portable_download_persistence_drops_invalid_tracker_urls() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "download-invalid-trackers", - "status": "queued", - "queueId": "main", - "url": "https://example.com/file.bin", - "torrentTrackers": "ftp://tracker.example/announce", - "torrentExcludeTrackers": "ftp://tracker.example/announce" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert!(saved.get("torrentTrackers").is_none()); - assert!(saved.get("torrentExcludeTrackers").is_none()); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); } #[test] @@ -3270,29 +1937,6 @@ mod tests { assert!(saved.get("headers").is_none()); } - #[test] - fn portable_magnet_persistence_keeps_identity_but_does_not_resume_after_tracker_removal() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let mut connection = state.lock().unwrap(); - let data = json!([{ - "id": "magnet-download", - "status": "queued", - "queueId": "main", - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret" - }]) - .to_string(); - - replace_downloads(&mut connection, &data, true).unwrap(); - - let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); - assert_eq!(saved["url"], "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=Example+Torrent"); - assert_eq!(saved["status"], "failed"); - assert_eq!(saved["resumable"], false); - assert!(!saved.to_string().contains("tracker.invalid")); - assert!(!saved.to_string().contains("secret")); - } - #[test] fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() { let temp = TempDir::new().unwrap(); @@ -3498,360 +2142,15 @@ mod tests { let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); - set_ownership_paths( - &connection, - "first", - "/downloads/file.bin", - &["/downloads/file.bin".to_string()], - ) - .unwrap(); - let error = set_ownership_paths( - &connection, - "second", - "/downloads/file.bin", - &["/downloads/file.bin".to_string()], - ) + set_ownership(&connection, "first", "/downloads/file.bin").unwrap(); + let error = set_ownership(&connection, "second", "/downloads/file.bin") .expect_err("a primary path must have one live owner"); assert!(error.contains("already owned")); - assert_eq!( - load_ownership(&connection).unwrap(), - vec![( - "first".to_string(), - "/downloads/file.bin".to_string(), - vec!["/downloads/file.bin".to_string()] - )] - ); - set_ownership_paths( - &connection, - "first", - "/downloads/renamed.bin", - &["/downloads/renamed.bin".to_string()], - ) - .unwrap(); - } - - #[test] - fn rejects_overlap_with_any_owned_torrent_path() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - - set_ownership_paths( - &connection, - "first", - "/downloads/root", - &[ - "/downloads/root/a.bin".to_string(), - "/downloads/root/b.bin".to_string(), - ], - ) - .unwrap(); - let error = set_ownership_paths( - &connection, - "second", - "/downloads/root", - &["/downloads/root/c.bin".to_string()], - ) - .expect_err("a torrent root must not be reused"); - assert!(error.contains("already owned")); - } - - #[test] - fn removal_reservations_block_later_download_ownership_claims() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - - set_ownership_and_removal_paths( - &connection, - "torrent", - "/downloads/selected.bin", - &["/downloads/selected.bin".to_string()], - &["/downloads/unselected.bin".to_string()], - ) - .unwrap(); - let error = set_ownership_paths( - &connection, - "later", - "/downloads/unselected.bin", - &["/downloads/unselected.bin".to_string()], - ) - .expect_err("a planned Torrent deletion must reserve its path"); - - assert!(error.contains("already owned")); - remove_torrent_removal_paths(&connection, "torrent").unwrap(); - set_ownership_paths( - &connection, - "later", - "/downloads/unselected.bin", - &["/downloads/unselected.bin".to_string()], - ) - .unwrap(); - } - - #[test] - fn malformed_owned_path_json_fails_closed_for_loading_and_new_claims() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - connection - .execute( - "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)", - params!["broken-owned", "/downloads/broken.bin"], - ) - .unwrap(); - connection - .execute( - "INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2)", - params!["broken-owned", "{not-json"], - ) - .unwrap(); - - let error = - load_ownership(&connection).expect_err("malformed owned paths must not be ignored"); - assert!(error.contains("broken-owned")); - - let error = set_ownership_paths( - &connection, - "later", - "/downloads/later.bin", - &["/downloads/later.bin".to_string()], - ) - .expect_err("new ownership claims must fail closed"); - assert!(error.contains("broken-owned")); - assert_eq!( - connection - .query_row( - "SELECT COUNT(*) FROM download_ownership WHERE id = 'later'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 0 - ); - } - - #[test] - fn malformed_removal_path_json_fails_closed_for_loading_and_new_claims() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - connection - .execute( - "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)", - params!["broken-removal", "/downloads/broken.bin"], - ) - .unwrap(); - connection - .execute( - "INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)", - params!["broken-removal", "{not-json"], - ) - .unwrap(); - - let error = load_all_torrent_removal_paths(&connection) - .expect_err("malformed removal paths must not be ignored"); - assert!(error.contains("broken-removal")); - - let error = set_ownership_paths( - &connection, - "later", - "/downloads/later.bin", - &["/downloads/later.bin".to_string()], - ) - .expect_err("new ownership claims must fail closed"); - assert!(error.contains("broken-removal")); - assert_eq!( - connection - .query_row( - "SELECT COUNT(*) FROM download_ownership WHERE id = 'later'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 0 - ); - } - - #[test] - fn torrent_ownership_and_removal_reservation_commit_atomically() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - connection - .execute_batch( - "CREATE TRIGGER reject_torrent_removal - BEFORE INSERT ON download_removal_paths - BEGIN SELECT RAISE(ABORT, 'test rejection'); END;", - ) - .unwrap(); - - let error = set_ownership_and_removal_paths( - &connection, - "torrent", - "/downloads/selected.bin", - &["/downloads/selected.bin".to_string()], - &["/downloads/unselected.bin".to_string()], - ) - .expect_err("the injected reservation failure must abort the transaction"); - - assert!(error.contains("test rejection")); - assert!(load_ownership(&connection).unwrap().is_empty()); - assert!(load_torrent_removal_paths(&connection, "torrent") - .unwrap() - .is_empty()); - } - - #[test] - fn restart_reclaims_only_observed_terminal_torrent_reservations() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - let completed_missing = temp.path().join("completed-missing.bin"); - let completed_present = temp.path().join("completed-present.bin"); - let failed_present = temp.path().join("failed-present.bin"); - let queued_missing = temp.path().join("queued-missing.bin"); - fs::write(&completed_present, b"old").unwrap(); - fs::write(&failed_present, b"old").unwrap(); - - for (id, status, path) in [ - ("completed-missing", "completed", &completed_missing), - ("completed-present", "completed", &completed_present), - ("failed-present", "failed", &failed_present), - ("queued-missing", "queued", &queued_missing), - ] { - let selected_path = temp.path().join(format!("{id}-selected.bin")); - set_ownership_and_removal_paths( - &connection, - id, - &selected_path.to_string_lossy(), - &[selected_path.to_string_lossy().to_string()], - &[path.to_string_lossy().to_string()], - ) - .unwrap(); - let record = json!({ - "id": id, - "status": status, - "isTorrent": true, - "torrentRemoveUnselectedFile": true - }) - .to_string(); - connection - .execute( - "INSERT INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)", - params![id, status, "main", record], - ) - .unwrap(); - } - - assert_eq!( - reconcile_torrent_removal_paths_after_restart(&connection).unwrap(), - 1 - ); - assert!(load_torrent_removal_paths(&connection, "completed-missing") - .unwrap() - .is_empty()); - assert_eq!( - load_torrent_removal_paths(&connection, "failed-present").unwrap(), - vec![failed_present.to_string_lossy().to_string()] - ); - assert_eq!( - load_torrent_removal_paths(&connection, "completed-present") - .unwrap(), - vec![completed_present.to_string_lossy().to_string()] - ); - assert_eq!( - load_torrent_removal_paths(&connection, "queued-missing") - .unwrap(), - vec![queued_missing.to_string_lossy().to_string()] - ); - - set_ownership_paths( - &connection, - "replacement", - &completed_missing.to_string_lossy(), - &[completed_missing.to_string_lossy().to_string()], - ) - .unwrap(); - - let error = set_ownership_paths( - &connection, - "failed-replacement", - &failed_present.to_string_lossy(), - &[failed_present.to_string_lossy().to_string()], - ) - .expect_err("an unobserved failed Torrent cleanup must keep its path reserved"); - assert!(error.contains("already owned")); - } - - #[test] - fn restart_keeps_orphaned_torrent_removal_reservations_conservative() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - let reserved = temp.path().join("orphaned-unselected.bin"); - set_ownership_and_removal_paths( - &connection, - "orphaned", - &temp.path().join("orphaned-selected.bin").to_string_lossy(), - &[temp - .path() - .join("orphaned-selected.bin") - .to_string_lossy() - .to_string()], - &[reserved.to_string_lossy().to_string()], - ) - .unwrap(); - - assert_eq!( - reconcile_torrent_removal_paths_after_restart(&connection).unwrap(), - 0 - ); - assert_eq!( - load_torrent_removal_paths(&connection, "orphaned").unwrap(), - vec![reserved.to_string_lossy().to_string()] - ); - } - - #[test] - fn restart_keeps_malformed_torrent_removal_reservations_conservative() { - let temp = TempDir::new().unwrap(); - let state = init_at_path(temp.path()).unwrap(); - let connection = state.lock().unwrap(); - for (id, encoded_paths) in [ - ("empty", "[]"), - ("relative", r#"["relative-file.bin"]"#), - ("empty-path", r#"[""]"#), - ] { - connection - .execute( - "INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)", - params![id, encoded_paths], - ) - .unwrap(); - connection - .execute( - "INSERT INTO downloads (id, status, queue_id, data) VALUES - (?1, 'completed', 'main', '{}')", - params![id], - ) - .unwrap(); - } - - assert_eq!( - reconcile_torrent_removal_paths_after_restart(&connection).unwrap(), - 0 - ); - assert_eq!( - connection - .query_row( - "SELECT COUNT(*) FROM download_removal_paths", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 3 - ); + assert_eq!(load_ownership(&connection).unwrap(), vec![( + "first".to_string(), + "/downloads/file.bin".to_string() + )]); + set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap(); } } diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 18f499f..80350e1 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -7,7 +7,6 @@ use tauri::Manager; struct DownloadOwnershipRecord { id: String, primary_path: String, - owned_paths: Vec, } pub fn canonical_download_filename(filename: &str) -> String { @@ -91,8 +90,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String { value[..end].to_string() } -pub fn expected_primary_path( - app_handle: &tauri::AppHandle, +pub fn expected_primary_path( + app_handle: &tauri::AppHandle, destination: &str, filename: &str, ) -> Result { @@ -110,138 +109,21 @@ pub fn expected_primary_path( .ok_or_else(|| "Download path could not be canonicalized".to_string()) } -pub fn set_primary_path( - app_handle: &tauri::AppHandle, +pub fn register_expected( + app_handle: &tauri::AppHandle, + id: &str, + destination: &str, + filename: &str, +) -> Result<(), String> { + let path = expected_primary_path(app_handle, destination, filename)?; + set_primary_path(app_handle, id, &path) +} + +pub fn set_primary_path( + app_handle: &tauri::AppHandle, id: &str, path: &Path, ) -> Result<(), String> { - set_owned_paths(app_handle, id, &[path.to_path_buf()]) -} - -pub fn set_owned_paths( - app_handle: &tauri::AppHandle, - id: &str, - paths: &[PathBuf], -) -> Result<(), String> { - let primary = paths - .first() - .ok_or_else(|| "Download ownership requires at least one path".to_string())?; - set_owned_paths_with_primary(app_handle, id, primary, paths) -} - -pub fn set_owned_paths_with_primary( - app_handle: &tauri::AppHandle, - id: &str, - primary: &Path, - paths: &[PathBuf], -) -> Result<(), String> { - if paths.is_empty() { - return Err("Download ownership requires at least one path".to_string()); - } - - let canonical_primary = canonical_owned_path(app_handle, primary)?; - let mut canonical_paths = Vec::with_capacity(paths.len()); - for path in paths { - if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) { - return Err("Download ownership file path is a directory".to_string()); - } - let canonical_path = canonical_owned_path(app_handle, path)?; - if !canonical_paths - .iter() - .any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path)) - { - canonical_paths.push(canonical_path); - } - } - - let path_strings = canonical_paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect::>(); - let database = app_handle.state::(); - let connection = database.lock()?; - crate::db::set_ownership_paths( - &connection, - id, - &canonical_primary.to_string_lossy(), - &path_strings, - ) -} - -pub fn set_owned_paths_with_primary_and_removal( - app_handle: &tauri::AppHandle, - id: &str, - primary: &Path, - paths: &[PathBuf], - removal_paths: &[PathBuf], -) -> Result<(), String> { - if paths.is_empty() { - return Err("Download ownership requires at least one path".to_string()); - } - - let canonical_primary = canonical_owned_path(app_handle, primary)?; - let canonical_paths = canonical_file_paths(app_handle, paths)?; - let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?; - let mut current_paths = owned_paths_for_id(app_handle, id)?; - if let Some(primary) = primary_path_for_id(app_handle, id)? { - current_paths.push(primary); - } - let known_paths = known_primary_paths(app_handle)?; - if canonical_removal_paths.iter().any(|candidate| { - known_paths.iter().any(|known| { - crate::platform::paths_equal(candidate, known) - && !current_paths - .iter() - .any(|current| crate::platform::paths_equal(candidate, current)) - }) - }) { - return Err( - "Torrent removal would delete a file owned by another Firelink download".to_string(), - ); - } - let path_strings = canonical_paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect::>(); - let removal_strings = canonical_removal_paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect::>(); - let database = app_handle.state::(); - let connection = database.lock()?; - crate::db::set_ownership_and_removal_paths( - &connection, - id, - &canonical_primary.to_string_lossy(), - &path_strings, - &removal_strings, - ) -} - -fn canonical_file_paths( - app_handle: &tauri::AppHandle, - paths: &[PathBuf], -) -> Result, String> { - let mut canonical_paths = Vec::with_capacity(paths.len()); - for path in paths { - if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) { - return Err("Download ownership file path is a directory".to_string()); - } - let canonical_path = canonical_owned_path(app_handle, path)?; - if !canonical_paths - .iter() - .any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path)) - { - canonical_paths.push(canonical_path); - } - } - Ok(canonical_paths) -} - -fn canonical_owned_path( - app_handle: &tauri::AppHandle, - path: &Path, -) -> Result { if !path.is_absolute() { return Err("Download ownership path must be absolute".to_string()); } @@ -258,61 +140,18 @@ fn canonical_owned_path( } let canonical_path = crate::canonicalize_with_missing_components(path) .ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?; - if !crate::is_safe_path(&canonical_path, app_handle) { - return Err("Download ownership path is outside an allowed download location".to_string()); - } - Ok(canonical_path) + + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy()) } -pub fn remove( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result<(), String> { +pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { let database = app_handle.state::(); let connection = database.lock()?; crate::db::remove_ownership(&connection, id) } -pub fn clear_torrent_removal_paths( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result<(), String> { - let database = app_handle.state::(); - let connection = database.lock()?; - crate::db::remove_torrent_removal_paths(&connection, id) -} - -/// Clear a Torrent removal reservation only after every reserved path is -/// absent. The reservation protects paths that Aria2 may still remove after -/// a terminal event has been observed; callers must not release it merely -/// because the daemon reported completion or failure. -pub fn clear_torrent_removal_paths_if_absent( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result { - let paths = torrent_removal_paths_for_id(app_handle, id)?; - if paths.iter().any(|path| { - !matches!( - std::fs::symlink_metadata(path), - Err(error) if error.kind() == std::io::ErrorKind::NotFound - ) - }) { - return Ok(false); - } - clear_torrent_removal_paths(app_handle, id)?; - Ok(true) -} - -pub fn torrent_removal_paths_for_id( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result, String> { - let database = app_handle.state::(); - let connection = database.lock()?; - crate::db::load_torrent_removal_paths(&connection, id) - .map(|paths| paths.into_iter().map(PathBuf::from).collect()) -} - pub fn primary_path_for_id( app_handle: &tauri::AppHandle, id: &str, @@ -323,50 +162,16 @@ pub fn primary_path_for_id( .map(|record| PathBuf::from(record.primary_path))) } -pub fn owned_paths_for_id( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result, String> { - Ok(load_records(app_handle)? - .into_iter() - .find(|record| record.id == id) - .map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect()) - .unwrap_or_default()) -} - -pub fn known_primary_paths( - app_handle: &tauri::AppHandle, -) -> Result, String> { +pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result, String> { let mut paths: Vec = load_records(app_handle)? .into_iter() - .flat_map(|record| { - std::iter::once(PathBuf::from(record.primary_path)).chain( - record.owned_paths.into_iter().map(PathBuf::from), - ) - }) + .map(|record| PathBuf::from(record.primary_path)) .collect(); - let database = app_handle.state::(); - let connection = database.lock()?; - for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? { - for path in removal_paths.into_iter().map(PathBuf::from) { - if !paths - .iter() - .any(|existing| crate::platform::paths_equal(existing, &path)) - { - paths.push(path); - } - } - } - drop(connection); - - // Compatibility for downloads created before the backend-owned registry - // existed. Import only the exact persisted queue paths. - for (_, path) in legacy_download_queue_path_records(app_handle)? { - if !paths - .iter() - .any(|existing| crate::platform::paths_equal(existing, &path)) - { + // One-time compatibility for downloads created before the backend-owned + // registry existed. This imports the exact persisted queue path only. + for path in legacy_download_queue_paths(app_handle)? { + if !paths.iter().any(|existing| existing == &path) { paths.push(path); } } @@ -374,83 +179,18 @@ pub fn known_primary_paths( Ok(paths) } -/// Return the Firelink download that owns an exact output path, if any. -/// -/// This is intentionally based on the persisted ownership registry rather -/// than on the visible download list. The renderer can be stale while a -/// queued/native lifecycle is being admitted, so duplicate replacement must -/// make this decision at the native boundary. -pub fn owner_for_path( - app_handle: &tauri::AppHandle, - path: &Path, -) -> Result, String> { - let canonical = crate::canonicalize_with_missing_components(path) - .ok_or_else(|| "Download target could not be canonicalized".to_string())?; - let mut owners = Vec::new(); - for record in load_records(app_handle)? { - let primary = PathBuf::from(&record.primary_path); - if crate::platform::paths_equal(&primary, &canonical) - || record - .owned_paths - .iter() - .map(PathBuf::from) - .any(|owned| crate::platform::paths_equal(&owned, &canonical)) - { - owners.push(record.id); - } - } - - let database = app_handle.state::(); - let connection = database.lock()?; - for (id, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? { - if removal_paths - .into_iter() - .map(PathBuf::from) - .any(|removal| crate::platform::paths_equal(&removal, &canonical)) - && !owners.contains(&id) - { - owners.push(id); - } - } - drop(connection); - - // Older rows may predate the ownership registry. They still represent - // Firelink-owned targets and must not be downgraded to unmanaged disk - // files merely because their migration record is absent. - for (id, legacy_path) in legacy_download_queue_path_records(app_handle)? { - if crate::platform::paths_equal(&legacy_path, &canonical) && !owners.contains(&id) { - owners.push(id); - } - } - - match owners.len() { - 0 => Ok(None), - 1 => Ok(owners.pop()), - _ => Err(format!( - "Download target is claimed by multiple Firelink downloads: {}", - owners.join(", ") - )), - } -} - fn load_records(app_handle: &tauri::AppHandle) -> Result, String> { let database = app_handle.state::(); let connection = database.lock()?; crate::db::load_ownership(&connection).map(|records| { records .into_iter() - .map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord { - id, - primary_path, - owned_paths, - }) + .map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path }) .collect() }) } -fn legacy_download_queue_path_records( - app_handle: &tauri::AppHandle, -) -> Result, String> { +fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result, String> { let settings = crate::settings::load_settings(app_handle).ok(); let downloads = { @@ -459,7 +199,7 @@ fn legacy_download_queue_path_records( parse_legacy_download_items(crate::db::load_downloads(&connection)?) }; - let mut paths: Vec<(String, PathBuf)> = Vec::new(); + let mut paths = Vec::new(); for download in downloads { let category = format!("{:?}", download.category); let mut destinations = Vec::new(); @@ -518,10 +258,8 @@ fn legacy_download_queue_path_records( for destination in destinations { if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) { - if !paths.iter().any(|(id, existing)| { - id == &download.id && crate::platform::paths_equal(existing, &path) - }) { - paths.push((download.id.clone(), path)); + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); } } } diff --git a/src-tauri/src/engines.rs b/src-tauri/src/engines.rs index 5da82d8..88d7e65 100644 --- a/src-tauri/src/engines.rs +++ b/src-tauri/src/engines.rs @@ -11,7 +11,7 @@ pub fn resolve_bundled_binary_path( if let Ok(resource_dir) = app_handle.path().resource_dir() { for candidate in packaged_candidates(&resource_dir, &target, &binary_name) { if candidate.is_file() { - log::info!("Resolved bundled '{}' for target '{}'", engine, target); + log::info!("Resolved bundled '{}' at: {:?}", engine, candidate); return Ok(candidate); } } @@ -20,24 +20,19 @@ pub fn resolve_bundled_binary_path( if let Ok(exe_path) = std::env::current_exe() { for candidate in executable_relative_candidates(&exe_path, &target, &binary_name) { if candidate.is_file() { - log::info!("Resolved bundled '{}' for target '{}'", engine, target); + log::info!("Resolved bundled '{}' at: {:?}", engine, candidate); return Ok(candidate); } } } - // Development payloads are intentionally discoverable from the checkout, - // but a packaged/release app must never execute an engine selected by its - // working directory. If the packaged resource or executable-relative - // payload is missing, fail closed instead of allowing a same-named binary - // from an untrusted CWD to take over the media/download process. if let Ok(cwd) = std::env::current_dir() { - for candidate in development_candidates_for_runtime(&cwd, &target, &binary_name) { + for candidate in development_candidates(&cwd, &target, &binary_name) { if candidate.is_file() { let absolute = candidate.canonicalize().map_err(|error| { format!("Failed to canonicalize '{}': {error}", candidate.display()) })?; - log::info!("Resolved bundled '{}' for target '{}'", engine, target); + log::info!("Resolved bundled '{}' at: {:?}", engine, absolute); return Ok(absolute); } } @@ -49,22 +44,6 @@ pub fn resolve_bundled_binary_path( )) } -fn development_candidates_for_runtime( - cwd: &Path, - target: &str, - binary_name: &str, -) -> Vec { - #[cfg(debug_assertions)] - { - development_candidates(cwd, target, binary_name) - } - #[cfg(not(debug_assertions))] - { - let _ = (cwd, target, binary_name); - Vec::new() - } -} - fn packaged_candidates(resource_dir: &Path, target: &str, binary_name: &str) -> Vec { let mut candidates = vec![ resource_dir @@ -119,7 +98,6 @@ fn executable_relative_candidates( candidates } -#[cfg(any(debug_assertions, test))] fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec { let roots = [cwd.to_path_buf(), cwd.join("src-tauri")]; let mut candidates = Vec::new(); @@ -163,7 +141,7 @@ fn aria2_openssl_modules_dir(binary_path: &Path) -> Option { #[cfg(test)] mod tests { - use super::{development_candidates, development_candidates_for_runtime, packaged_candidates}; + use super::{development_candidates, packaged_candidates}; use std::path::Path; #[test] @@ -193,19 +171,4 @@ mod tests { Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe") ); } - - #[test] - fn development_resolution_is_disabled_in_release_builds() { - let candidates = development_candidates_for_runtime( - Path::new("/repo"), - "x86_64-unknown-linux-gnu", - "yt-dlp-x86_64-unknown-linux-gnu", - ); - - if cfg!(debug_assertions) { - assert!(!candidates.is_empty()); - } else { - assert!(candidates.is_empty()); - } - } } diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 3fd8a4c..5f3033c 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -36,7 +36,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; -const PROTOCOL_VERSION: &str = "5"; +const PROTOCOL_VERSION: &str = "4"; const MAX_PENDING_EXTENSION_ACKS: usize = 64; const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); @@ -75,8 +75,6 @@ struct ExtensionRequest { #[serde(default)] media: bool, #[serde(default)] - torrent: bool, - #[serde(default)] batch: bool, #[serde(default)] batch_name: Option, @@ -102,7 +100,6 @@ pub struct ExtensionDownload { cookies: Option, cookie_scopes: Option>, media: bool, - torrent: bool, batch: bool, batch_name: Option, } @@ -308,16 +305,18 @@ async fn download_handler( None => return Err(StatusCode::BAD_REQUEST), }; - let is_hidden = state - .app_handle - .get_webview_window("main") - .and_then(|window| window.is_visible().ok()) - .is_some_and(|is_visible| !is_visible); - crate::restore_main_window(&state.app_handle); - if is_hidden { - // Sleep briefly to let the webview wake up from macOS App Nap - // otherwise the IPC event emitted immediately after is dropped. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; + if let Some(window) = state.app_handle.get_webview_window("main") { + let is_visible = window.is_visible().unwrap_or(true); + if !is_visible { + let _ = window.show(); + let _ = window.set_focus(); + // Sleep briefly to let the webview wake up from macOS App Nap + // otherwise the IPC event emitted immediately after is dropped. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + } else { + let _ = window.show(); + let _ = window.set_focus(); + } } if !wait_for_frontend(&state.frontend_ready).await { @@ -429,20 +428,6 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option Option, media: bool) -> Option { .lines() .filter(|line| { line.split_once(':') - .map(|(name, _)| !crate::queue::header_name_has_credential_material(name)) - .unwrap_or(false) + .map(|(name, _)| { + !matches!( + name.trim().to_ascii_lowercase().as_str(), + "authorization" + | "cookie" + | "cookie2" + | "proxy-authorization" + | "set-cookie" + | "set-cookie2" + ) + }) + .unwrap_or(true) }) .collect::>() .join("\n"); @@ -574,15 +568,7 @@ fn normalize_headers(headers: Option, media: bool) -> Option { fn normalize_url(raw_url: &str) -> Option { let url = Url::parse(raw_url.trim()).ok()?; - matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") - .then(|| url.to_string()) -} - -fn filename_is_torrent(filename: Option<&str>) -> bool { - filename - .and_then(|value| Path::new(value.trim()).file_name()) - .and_then(|value| value.to_str()) - .is_some_and(|value| value.to_ascii_lowercase().ends_with(".torrent")) + matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string()) } fn sanitize_filename(filename: &str) -> Option { @@ -766,7 +752,7 @@ mod tests { assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!( response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), - "5" + "4" ); server.abort(); @@ -830,7 +816,6 @@ mod tests { cookies: None, cookie_scopes: None, media: true, - torrent: false, batch: false, batch_name: None, }); @@ -851,7 +836,6 @@ mod tests { cookies: None, cookie_scopes: None, media: false, - torrent: false, batch: false, batch_name: None, }); @@ -907,13 +891,12 @@ mod tests { silent: false, filename: None, headers: Some(format!( - "Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nX-Api-Key: stale\nX-Auth-Token: stale\nX-Access-Token: stale\nX-Request-Signature: stale\nX-Session: stale\n: malformed\nUser-Agent: Firefox\nX-Trace: safe", + "Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox", "x".repeat(64 * 1024) )), cookies: Some(format!("large={}", "x".repeat(64 * 1024))), cookie_scopes: None, media: true, - torrent: false, batch: false, batch_name: None, }) @@ -921,10 +904,7 @@ mod tests { assert!(download.media); assert!(download.cookies.is_none()); - assert_eq!( - download.headers.as_deref(), - Some("User-Agent: Firefox\nX-Trace: safe") - ); + assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); } #[test] @@ -938,7 +918,6 @@ mod tests { cookies: Some("session=browser-cookie-header".to_string()), cookie_scopes: None, media: false, - torrent: false, batch: false, batch_name: None, }) @@ -951,94 +930,6 @@ mod tests { ); } - #[test] - fn multi_url_capture_drops_shared_credentials_but_keeps_safe_headers() { - let download = normalize_download(ExtensionRequest { - urls: vec![ - "https://one.example/file.zip".to_string(), - "https://two.example/file.zip".to_string(), - ], - referer: None, - silent: false, - filename: None, - headers: Some( - "X-Api-Key: shared-secret\nX-Request-Signature: signature-secret\n: malformed\nUser-Agent: Firefox\nX-Trace: safe" - .to_string(), - ), - cookies: Some("session=must-not-cross-hosts".to_string()), - cookie_scopes: None, - media: false, - torrent: false, - batch: true, - batch_name: Some("batch".to_string()), - }) - .expect("valid multi-url handoff"); - - assert!(download.batch); - assert!(download.cookies.is_none()); - assert_eq!( - download.headers.as_deref(), - Some("User-Agent: Firefox\nX-Trace: safe") - ); - } - - #[test] - fn torrent_handoff_accepts_magnets_and_preserves_the_intent() { - let download = normalize_download(ExtensionRequest { - urls: vec![ - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(), - ], - referer: None, - silent: false, - filename: None, - headers: None, - cookies: None, - cookie_scopes: None, - media: false, - torrent: true, - batch: false, - batch_name: None, - }) - .expect("valid magnet torrent handoff"); - - assert!(download.torrent); - assert_eq!(download.urls[0], "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"); - - let opaque = normalize_download(ExtensionRequest { - urls: vec!["https://example.com/download?id=opaque".to_string()], - referer: None, - silent: true, - filename: None, - headers: None, - cookies: None, - cookie_scopes: None, - media: false, - torrent: true, - batch: false, - batch_name: None, - }) - .expect("explicit opaque torrent handoff"); - assert!(opaque.torrent); - - let legacy_magnet = normalize_download(ExtensionRequest { - urls: vec![ - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(), - ], - referer: None, - silent: false, - filename: None, - headers: None, - cookies: None, - cookie_scopes: None, - media: false, - torrent: false, - batch: false, - batch_name: None, - }) - .expect("legacy magnet handoff"); - assert!(legacy_magnet.torrent); - } - #[test] fn regular_capture_normalizes_host_scoped_cookie_headers() { let download = normalize_download(ExtensionRequest { @@ -1063,7 +954,6 @@ mod tests { }, ]), media: false, - torrent: false, batch: false, batch_name: None, }) @@ -1094,7 +984,6 @@ mod tests { cookies: Some("session=secret".to_string()), cookie_scopes: None, media: false, - torrent: false, batch: false, batch_name: None, }) @@ -1118,7 +1007,6 @@ mod tests { cookies: None, cookie_scopes: None, media: false, - torrent: false, batch: true, batch_name: Some("Example Gallery / Chapter: 1".to_string()), }) @@ -1142,7 +1030,6 @@ mod tests { cookies: None, cookie_scopes: None, media: false, - torrent: false, batch: true, batch_name: Some("Example Gallery".to_string()), }) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index ff79b67..625a5a7 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -14,50 +14,6 @@ fn default_sidebar_position() -> String { "auto".to_string() } -fn default_torrent_enable_dht() -> bool { - true -} - -fn default_torrent_enable_dht6() -> bool { - false -} - -fn default_torrent_enable_pex() -> bool { - true -} - -fn default_torrent_enable_lpd() -> bool { - false -} - -fn default_torrent_max_open_files() -> u32 { - crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES -} - -fn default_torrent_dht_message_timeout() -> u32 { - crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT -} - -fn default_torrent_separate_seed_slots() -> bool { - false -} - -fn default_torrent_max_concurrent_seeds() -> u32 { - crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS -} - -fn default_torrent_ipv6_enabled() -> bool { - true -} - -fn default_aria2_disk_cache() -> String { - crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string() -} - -fn default_adaptive_mirror_selection() -> bool { - true -} - #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -70,13 +26,6 @@ pub enum DownloadStatus { /// Post-download media processing such as yt-dlp/ffmpeg merging or /// extraction. The queue permit is still held. Processing, - /// A BitTorrent download has all selected data and is still seeding. - /// The Aria2 GID and queue permit remain live until seeding ends. - Seeding, - /// A BitTorrent download is complete but paused while waiting for a - /// Firelink-owned seeding slot. - #[serde(rename = "waitingToSeed")] - WaitingToSeed, Paused, Completed, Failed, @@ -84,11 +33,6 @@ pub enum DownloadStatus { /// Transient state: a connection-aware retry is in progress with /// exponential backoff. The download slot/permit is still held. Retrying, - /// Aria2 is verifying already-present Torrent data before transfer or - /// after an explicit integrity check. - Verifying, - /// Firelink is moving owned Torrent data between managed destinations. - Moving, } impl DownloadStatus { @@ -98,15 +42,11 @@ impl DownloadStatus { Self::Staged => "staged", Self::Downloading => "downloading", Self::Processing => "processing", - Self::Seeding => "seeding", - Self::WaitingToSeed => "waitingToSeed", Self::Paused => "paused", Self::Completed => "completed", Self::Failed => "failed", Self::Queued => "queued", Self::Retrying => "retrying", - Self::Verifying => "verifying", - Self::Moving => "moving", } } } @@ -120,7 +60,6 @@ pub enum DownloadCategory { Documents, Pictures, Applications, - Torrents, Other, } @@ -145,46 +84,6 @@ pub struct QueueConcurrencyConfig { pub max_concurrent: Option, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub enum DownloadErrorKind { - NameResolution, - DestinationAccess, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub enum DownloadTargetKind { - Missing, - RegularFile, - Directory, - Symlink, - Special, -} - -#[derive(Clone, Debug, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct DownloadTargetInfo { - pub kind: DownloadTargetKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub fingerprint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub owned_by: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub enum DownloadAssetRemovalPolicy { - Trash, - PermanentIfUnfinished, -} - #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -220,8 +119,6 @@ pub struct DownloadItem { #[ts(optional)] pub password: Option, #[ts(optional)] - pub sftp_host_key_md: Option, - #[ts(optional)] pub headers: Option, #[ts(optional)] pub checksum: Option, @@ -245,282 +142,8 @@ pub struct DownloadItem { pub has_been_dispatched: Option, #[ts(optional)] pub last_error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub credentials_required: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub last_error_kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub last_resolver_fallback: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub replace_existing_fingerprint: Option, #[ts(optional)] pub last_try: Option, - #[serde(default)] - #[ts(optional)] - pub is_torrent: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_path: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_file_indices: Option>, - #[serde(default)] - #[ts(optional)] - pub torrent_info_hash: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_time: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_ratio: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_remaining: Option, - #[serde(default)] - #[ts(optional, type = "number")] - pub torrent_uploaded_bytes: Option, - #[serde(default)] - #[ts(optional, type = "number")] - pub torrent_seeded_seconds: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_relocation_check_pending: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_move_destination: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_move_restore_status: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_web_seeds: Option>, - #[serde(default)] - #[ts(optional)] - pub torrent_web_seeds_native: Option>, - #[serde(default)] - #[ts(optional)] - pub torrent_upload_limit: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_max_peers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_peer_speed_limit: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_check_integrity: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_trackers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_exclude_trackers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_connect_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_interval: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_stop_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_prioritize_piece: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_remove_unselected_file: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_encryption_policy: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_file_allocation: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_verify_only: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_verify_restore_status: Option, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentPeer { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub ip: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub port: Option, - #[ts(type = "number")] - pub download_speed: u64, - #[ts(type = "number")] - pub upload_speed: u64, - pub seeder: bool, - pub am_choking: bool, - pub peer_choking: bool, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentPeerDiagnostics { - #[ts(type = "number")] - pub listed_peers: u32, - #[ts(type = "number")] - pub listed_seeders: u32, - pub peers: Vec, - pub truncated: bool, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentFileProgress { - pub index: u32, - pub relative_path: String, - #[ts(type = "number")] - pub length: u64, - #[ts(type = "number")] - pub completed_length: u64, - pub selected: bool, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentFileProgressSnapshot { - pub files: Vec, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentPieceProgressSnapshot { - #[ts(type = "number")] - pub piece_length: u64, - #[ts(type = "number")] - pub num_pieces: u64, - #[ts(type = "number")] - pub completed_pieces: u64, - pub buckets: Vec, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentFileSelectionEntry { - pub index: u32, - pub relative_path: String, - #[ts(type = "number")] - pub length: u64, - pub selected: bool, - #[ts(type = "number")] - #[ts(optional)] - pub completed_length: Option, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentFileSelectionSnapshot { - pub files: Vec, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentDetails { - pub info_hash: String, - pub display_name: String, - #[ts(type = "number")] - pub total_bytes: u64, - #[ts(type = "number")] - pub file_count: u32, - #[ts(type = "number")] - pub piece_length: u64, - #[ts(type = "number")] - pub piece_count: u64, - pub private: bool, - pub creation_date: Option, - pub creator: Option, - pub comment: Option, - pub trackers: Vec, - pub web_seeds: Vec, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentAvailabilityBucket { - #[ts(type = "number")] - pub minimum_copies: u16, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentAvailabilitySnapshot { - #[ts(type = "number")] - pub piece_count: u64, - pub availability: f64, - #[ts(type = "number")] - pub connected_peers: u32, - pub buckets: Vec, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentMoveProgressEvent { - pub id: String, - pub fraction: f64, - #[ts(type = "number")] - pub copied_bytes: u64, - #[ts(type = "number")] - pub total_bytes: u64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentWebSeed { - #[ts(type = "number")] - pub file_index: u32, - pub uri: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentFile { - pub index: u32, - pub path: String, - #[ts(type = "number")] - pub length: u64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentMetadata { - pub name: String, - #[ts(type = "number")] - pub total_bytes: u64, - pub files: Vec, - pub info_hash: String, - #[serde(default)] - #[ts(optional)] - pub torrent_path: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] @@ -689,14 +312,6 @@ pub struct SchedulerSettings { pub post_queue_action: PostQueueAction, } -#[derive(Clone, Debug, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct MainWindowSize { - pub width: u32, - pub height: u32, -} - #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -717,16 +332,9 @@ pub struct PersistedSettings { pub approved_download_roots: Vec, pub max_concurrent_downloads: usize, pub global_speed_limit: String, - #[serde(default)] - pub torrent_overall_upload_limit: String, pub speed_limit_preset_values: Vec, pub logs_enabled: bool, pub is_sidebar_visible: bool, - #[serde(default)] - pub is_folders_collapsed: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub main_window_size: Option, #[serde(default = "default_sidebar_position")] pub sidebar_position: String, pub active_settings_tab: SettingsTab, @@ -734,21 +342,12 @@ pub struct PersistedSettings { pub scheduler_running: bool, pub scheduler_active_download_ids: Vec, pub scheduler_last_start_key: String, - #[serde(default)] - #[ts(optional)] - pub scheduler_triggered_start_key: Option, pub scheduler_last_stop_key: String, pub last_custom_speed_limit_ki_b: u32, #[serde(default = "default_speed_limit_unit")] pub last_custom_speed_limit_unit: String, pub per_server_connections: i32, pub max_automatic_retries: i32, - #[serde(default)] - pub minimum_normal_download_speed_ki_b: u32, - #[serde(default)] - pub retry_not_found_errors: bool, - #[serde(default = "default_adaptive_mirror_selection")] - pub adaptive_mirror_selection: bool, pub show_notifications: bool, pub play_completion_sound: bool, #[serde(default)] @@ -760,46 +359,6 @@ pub struct PersistedSettings { pub proxy_mode: ProxyMode, pub proxy_host: String, pub proxy_port: u16, - #[serde(default = "default_torrent_enable_dht")] - pub torrent_enable_dht: bool, - #[serde(default = "default_torrent_enable_dht6")] - pub torrent_enable_dht6: bool, - #[serde(default = "default_torrent_enable_pex")] - pub torrent_enable_pex: bool, - #[serde(default = "default_torrent_enable_lpd")] - pub torrent_enable_lpd: bool, - #[serde(default = "default_torrent_max_open_files")] - pub torrent_max_open_files: u32, - #[serde(default = "default_torrent_dht_message_timeout")] - pub torrent_dht_message_timeout: u32, - #[serde(default = "default_torrent_separate_seed_slots")] - pub torrent_separate_seed_slots: bool, - #[serde(default = "default_torrent_max_concurrent_seeds")] - pub torrent_max_concurrent_seeds: u32, - #[serde(default = "default_torrent_ipv6_enabled")] - pub torrent_ipv6_enabled: bool, - #[serde(default)] - pub torrent_listen_port: String, - #[serde(default)] - pub torrent_dht_listen_port: String, - #[serde(default)] - pub torrent_external_ip: String, - #[serde(default)] - pub torrent_dht_entry_point: String, - #[serde(default)] - pub torrent_dht_entry_point6: String, - #[serde(default)] - pub torrent_dht_listen_addr6: String, - #[serde(default)] - pub torrent_lpd_interface: String, - #[serde(default)] - pub torrent_peer_id_prefix: String, - #[serde(default)] - pub torrent_peer_agent: String, - #[serde(default)] - pub torrent_bind_address: String, - #[serde(default = "default_aria2_disk_cache")] - pub aria2_disk_cache: String, pub custom_user_agent: String, pub ask_where_to_save_each_file: bool, pub remember_last_used_download_directory: bool, @@ -831,31 +390,6 @@ pub enum QueueDirection { Down, } -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct DownloadStateProgress { - pub fraction: f64, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub downloaded_bytes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub total_bytes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub total_is_estimate: Option, -} - -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct DownloadAllocationEvent { - pub id: String, - pub pending: bool, - pub lifecycle_generation: String, -} - #[derive(Clone, Debug, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -863,22 +397,8 @@ pub struct DownloadStateEvent { pub id: String, pub status: String, pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub error_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub resolver_fallback: Option, #[ts(optional)] pub file_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub destination: Option, - #[ts(optional)] - pub torrent_seed_remaining: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub progress: Option, } impl DownloadStateEvent { @@ -887,56 +407,25 @@ impl DownloadStateEvent { id: id.into(), status: status.as_str().to_string(), error: None, - error_kind: None, - resolver_fallback: None, file_name: None, - destination: None, - torrent_seed_remaining: None, - progress: None, } } pub fn failed(id: impl Into, error: impl Into) -> Self { - let (error, error_kind) = Self::safe_error(error); Self { id: id.into(), status: DownloadStatus::Failed.as_str().to_string(), - error: Some(error), - error_kind, - resolver_fallback: None, + error: Some(error.into()), file_name: None, - destination: None, - torrent_seed_remaining: None, - progress: None, } } pub fn paused_with_error(id: impl Into, error: impl Into) -> Self { - let (error, error_kind) = Self::safe_error(error); Self { id: id.into(), status: DownloadStatus::Paused.as_str().to_string(), - error: Some(error), - error_kind, - resolver_fallback: None, + error: Some(error.into()), file_name: None, - destination: None, - torrent_seed_remaining: None, - progress: None, - } - } - - pub fn paused_with_seed_remaining(id: impl Into, remaining: Option) -> Self { - Self { - id: id.into(), - status: DownloadStatus::Paused.as_str().to_string(), - error: None, - error_kind: None, - resolver_fallback: None, - file_name: None, - destination: None, - torrent_seed_remaining: remaining, - progress: None, } } @@ -945,69 +434,18 @@ impl DownloadStateEvent { id: id.into(), status: DownloadStatus::Completed.as_str().to_string(), error: None, - error_kind: None, - resolver_fallback: None, file_name: Some(file_name.into()), - destination: None, - torrent_seed_remaining: None, - progress: None, } } /// Transient retry state. Carries the human-readable reason so the UI can /// surface "network dropped, retrying in 5s…". The slot is still held. pub fn retrying(id: impl Into, reason: impl Into) -> Self { - let (reason, error_kind) = Self::safe_error(reason); Self { id: id.into(), status: DownloadStatus::Retrying.as_str().to_string(), - error: Some(reason), - error_kind, - resolver_fallback: None, + error: Some(reason.into()), file_name: None, - destination: None, - torrent_seed_remaining: None, - progress: None, } } - - pub fn waiting_to_seed(id: impl Into, remaining: Option) -> Self { - Self { - id: id.into(), - status: DownloadStatus::WaitingToSeed.as_str().to_string(), - error: None, - error_kind: None, - resolver_fallback: None, - file_name: None, - destination: None, - torrent_seed_remaining: remaining, - progress: None, - } - } - - pub fn retrying_with_resolver_fallback( - id: impl Into, - reason: impl Into, - ) -> Self { - let mut event = Self::retrying(id, reason); - event.resolver_fallback = Some(true); - event - } - - pub fn with_destination(mut self, destination: impl Into) -> Self { - self.destination = Some(destination.into()); - self - } - - pub fn with_progress(mut self, progress: DownloadStateProgress) -> Self { - self.progress = Some(progress); - self - } - - fn safe_error(error: impl Into) -> (String, Option) { - let error = crate::redact_sensitive_text(&error.into()); - let error_kind = crate::retry::is_aria2_name_resolution_error(&error) - .then_some(DownloadErrorKind::NameResolution); - (error, error_kind) - } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9edbd8f..c400c7e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,10 @@ #![allow(unexpected_cfgs)] // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -use futures_util::StreamExt; use regex::Regex; use serde::Serialize; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::hash::{Hash, Hasher}; -use std::ffi::OsStr; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{Duration, Instant}; @@ -351,7 +349,7 @@ fn parse_media_playlist_metadata( let playlist_id = value .get("id") .and_then(|id| id.as_str()) - .map(|value| value.trim()) + .map(str::trim) .filter(|id| !id.is_empty()) .map(ToOwned::to_owned); @@ -473,7 +471,7 @@ fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value .get(key) .and_then(|v| v.as_str()) - .map(|value| value.trim()) + .map(str::trim) .filter(|v| !v.is_empty()) } @@ -1470,11 +1468,6 @@ fn emit_media_progress( total_is_estimate, active_connections: None, requested_connections: None, - effective_connections: None, - uploaded_bytes: None, - upload_speed: None, - num_seeders: None, - torrent_seeded_seconds: None, }, ); state.last_progress_at = now; @@ -1633,10 +1626,10 @@ fn append_ytdlp_add_header(config: &mut String, header: &str) -> Result = if let Ok(ip) = lookup_host.parse::() { vec![std::net::SocketAddr::new(ip, port)] } else { - tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::net::lookup_host((lookup_host, port)), - ) - .await - .map_err(|_| "SSRF blocked: DNS resolution timed out")? - .map_err(|_| "SSRF blocked: DNS resolution failed")? - .collect() + tokio::net::lookup_host((lookup_host, port)) + .await + .map_err(|_| "SSRF blocked: DNS resolution failed")? + .collect() }; let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?; if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) { @@ -1751,182 +1740,6 @@ async fn validate_url_ssrf(url: &str) -> Result bool { - if crate::torrent::is_remote_torrent_url(source) { - return true; - } - if !explicitly_torrent { - return false; - } - reqwest::Url::parse(source.trim()) - .ok() - .is_some_and(|url| matches!(url.scheme(), "http" | "https")) -} - -/// Fetch remote `.torrent` metadata through the same SSRF, redirect, proxy, -/// timeout, and bounded-body rules used by Firelink's metadata path. The -/// bytes are parsed and cached before they can enter the Aria2 lifecycle. -async fn fetch_remote_torrent_bytes( - source: &str, - proxy: Option<&str>, - headers: Option<&str>, - cookies: Option<&str>, - cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>, -) -> Result, String> { - ensure_reqwest_crypto_provider(); - let proxy = proxy - .map(crate::queue::aria2_all_proxy_value) - .transpose()? - .flatten(); - let mut current = reqwest::Url::parse(source) - .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; - let original_origin = Some(current.clone()); - let cookies_available = metadata_cookie_header_present(headers, cookies, cookie_scopes); - let mut send_cookies = !cookies_available; - let mut cookie_retry_attempted = false; - - let mut redirect_count = 0; - loop { - if redirect_count > MAX_REMOTE_TORRENT_REDIRECTS { - return Err("Too many redirects while fetching torrent metadata".to_string()); - } - if !matches!(current.scheme(), "http" | "https") { - return Err("Remote torrent metadata must use HTTP or HTTPS".to_string()); - } - if !current.username().is_empty() || current.password().is_some() { - return Err("Torrent metadata URLs must not contain credentials".to_string()); - } - - let (host, address) = validate_url_ssrf(current.as_str()) - .await? - .ok_or_else(|| "SSRF blocked: No host".to_string())?; - let mut builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(FILE_METADATA_TIMEOUT) - .user_agent("Firelink torrent metadata"); - match proxy.as_deref().map(str::trim) { - Some("") => builder = builder.no_proxy(), - Some(proxy) => { - builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); - } - None => {} - } - builder = builder.resolve(&host, address); - let same_origin_credentials = should_send_metadata_credentials( - original_origin.as_ref(), - Some(¤t), - redirect_count, - ); - let scoped_cookie = if send_cookies { - cookie_scope_for_url(current.as_str(), cookie_scopes) - } else { - None - }; - let cookie_header = if same_origin_credentials { - scoped_cookie.or(cookies) - } else { - scoped_cookie - }; - let header_map = if same_origin_credentials { - metadata_headers(headers, cookie_header, send_cookies) - } else { - metadata_headers(None, cookie_header, send_cookies) - }; - builder = builder.default_headers(header_map); - let client = builder - .build() - .map_err(|error| format!("could not create torrent metadata client: {error}"))?; - let response = client - .get(current.clone()) - .header( - reqwest::header::ACCEPT, - "application/x-bittorrent, application/octet-stream", - ) - .send() - .await - .map_err(|error| { - format!( - "remote torrent metadata request failed: {}", - crate::redact_sensitive_text(&error.to_string()) - ) - })?; - - if should_retry_metadata_with_cookies( - response.status(), - cookies_available && !send_cookies, - cookie_retry_attempted, - ) { - send_cookies = true; - cookie_retry_attempted = true; - current = reqwest::Url::parse(source) - .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; - redirect_count = 0; - continue; - } - - if response.status().is_redirection() { - if redirect_count == MAX_REMOTE_TORRENT_REDIRECTS { - return Err("Too many redirects while fetching torrent metadata".to_string()); - } - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| "Torrent metadata redirect has no valid location".to_string())?; - current = current - .join(location) - .map_err(|_| "Torrent metadata redirect URL is invalid".to_string())?; - redirect_count += 1; - continue; - } - - if !response.status().is_success() { - return Err(format!( - "Remote torrent metadata request returned HTTP {}", - response.status().as_u16() - )); - } - if response - .content_length() - .is_some_and(|length| length > crate::torrent::MAX_TORRENT_BYTES as u64) - { - return Err(format!( - "torrent metadata must be at most {} bytes", - crate::torrent::MAX_TORRENT_BYTES - )); - } - - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| { - format!( - "remote torrent metadata response failed: {}", - crate::redact_sensitive_text(&error.to_string()) - ) - })?; - if bytes - .len() - .checked_add(chunk.len()) - .is_none_or(|length| length > crate::torrent::MAX_TORRENT_BYTES) - { - return Err(format!( - "torrent metadata must be at most {} bytes", - crate::torrent::MAX_TORRENT_BYTES - )); - } - bytes.extend_from_slice(&chunk); - } - if bytes.is_empty() { - return Err("remote torrent metadata response was empty".to_string()); - } - return Ok(bytes); - } - -} - fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { left.scheme() == right.scheme() && left.host() == right.host() @@ -1982,7 +1795,6 @@ fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) #[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed. #[tauri::command] async fn fetch_metadata( - caller: tauri::WebviewWindow, url: String, user_agent: Option, username: Option, @@ -1993,10 +1805,8 @@ async fn fetch_metadata( proxy: Option, defer_cookies: Option, ) -> Result { - properties_window::ensure_main_window(&caller)?; ensure_reqwest_crypto_provider(); - let metadata_started = Instant::now(); let mut current_url = url.clone(); let original_origin = reqwest::Url::parse(&url).ok(); let mut redirects = 0; @@ -2008,29 +1818,10 @@ async fn fetch_metadata( let defer_cookies = defer_cookies.unwrap_or(false); let mut send_cookies = !defer_cookies || !cookies_available; let mut cookie_retry_attempted = false; - log::info!( - "metadata [stage=metadata operation=start host={} credentials_available={} deferred_cookies={} proxy_route={}]", - original_origin - .as_ref() - .and_then(|value| value.host_str()) - .unwrap_or(""), - cookies_available, - defer_cookies, - crate::queue::proxy_route_for_log(proxy.as_deref()) - ); let res; loop { if redirects >= 5 { - log::warn!( - "metadata [stage=metadata operation=redirect_limit host={} redirects={} error_code=redirect_limit elapsed_ms={}]", - reqwest::Url::parse(¤t_url) - .ok() - .and_then(|value| value.host_str().map(str::to_string)) - .unwrap_or_else(|| "".to_string()), - redirects, - metadata_started.elapsed().as_millis() - ); return Err("Too many redirects".to_string()); } @@ -2041,21 +1832,7 @@ async fn fetch_metadata( if proxy.eq_ignore_ascii_case("none") { builder = builder.no_proxy(); } else { - let proxy = match reqwest::Proxy::all(proxy) { - Ok(proxy) => proxy, - Err(error) => { - log::warn!( - "metadata [stage=metadata operation=client result=failed host={} error_code=proxy_configuration elapsed_ms={}]", - reqwest::Url::parse(¤t_url) - .ok() - .and_then(|value| value.host_str().map(str::to_string)) - .unwrap_or_else(|| "".to_string()), - metadata_started.elapsed().as_millis() - ); - return Err(crate::redact_sensitive_text(&error.to_string())); - } - }; - builder = builder.proxy(proxy); + builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?); } } @@ -2070,49 +1847,14 @@ async fn fetch_metadata( builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"); } - let parsed_current_url = match reqwest::Url::parse(¤t_url) { - Ok(parsed) => parsed, - Err(_) => { - log::warn!( - "metadata [stage=metadata operation=parse host={} result=failed error_code=invalid_url elapsed_ms={}]", - "", - metadata_started.elapsed().as_millis() - ); - return Err("SSRF blocked: Invalid URL".to_string()); - } - }; - let metadata_host = parsed_current_url - .host_str() - .unwrap_or("") - .to_string(); - let dns_started = Instant::now(); + let parsed_current_url = reqwest::Url::parse(¤t_url) + .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; let resolved_addr = match parsed_current_url.scheme() { - "http" | "https" => validate_url_ssrf(¤t_url).await, + "http" | "https" => validate_url_ssrf(¤t_url).await?, "ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url) .await - .map(Some), - _ => Err("Unsupported URL scheme".to_string()), - }; - let resolved_addr = match resolved_addr { - Ok(resolved_addr) => { - log::info!( - "metadata [stage=dns operation=resolve host={} result=ok elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - dns_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - resolved_addr - } - Err(error) => { - log::warn!( - "metadata [stage=dns operation=resolve host={} result=failed error_code={} elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - metadata_error_code(&error), - dns_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - return Err(error); - } + .map(Some)?, + _ => return Err("Unsupported URL scheme".to_string()), }; if let Some((host, addr)) = resolved_addr { @@ -2146,18 +1888,7 @@ async fn fetch_metadata( }; builder = builder.default_headers(header_map); - let client = match builder.build() { - Ok(client) => client, - Err(error) => { - log::warn!( - "metadata [stage=metadata operation=client result=failed host={} error_code={} elapsed_ms={}]", - metadata_host, - reqwest_error_code(&error), - metadata_started.elapsed().as_millis() - ); - return Err(crate::redact_sensitive_text(&error.to_string())); - } - }; + let client = builder.build().map_err(|e| e.to_string())?; let request_url = current_url.clone(); let build_get_range = || { @@ -2182,52 +1913,15 @@ async fn fetch_metadata( } } } - let head_started = Instant::now(); let mut current_res = match head_req.send().await { - Ok(response) => { - log::info!( - "metadata [stage=metadata operation=head host={} result=ok status={} elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - response.status().as_u16(), - head_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - response - } - Err(head_error) => { - let head_elapsed_ms = head_started.elapsed().as_millis(); - let get_started = Instant::now(); - match build_get_range().send().await { - Ok(response) => { - log::warn!( - "metadata [stage=metadata operation=head result=fallback host={} error_code={} head_elapsed_ms={} get_status={} get_elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - reqwest_error_code(&head_error), - head_elapsed_ms, - response.status().as_u16(), - get_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - response - } - Err(get_error) => { - log::warn!( - "metadata [stage=metadata operation=head_get_fallback host={} result=failed error_code=head_{}+get_{} head_elapsed_ms={} get_elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - reqwest_error_code(&head_error), - reqwest_error_code(&get_error), - head_elapsed_ms, - get_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - return Err(format!( - "HEAD metadata request failed ({}); ranged GET fallback failed ({})", - reqwest_error_code(&head_error), - reqwest_error_code(&get_error) - )); - } - } - } + Ok(response) => response, + Err(head_error) => build_get_range().send().await.map_err(|get_error| { + let head_error = crate::redact_sensitive_text(&head_error.to_string()); + let get_error = crate::redact_sensitive_text(&get_error.to_string()); + format!( + "HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})" + ) + })?, }; if retry_metadata_with_cookies( @@ -2239,12 +1933,6 @@ async fn fetch_metadata( &mut current_url, &mut redirects, ) { - log::info!( - "metadata [stage=metadata operation=credential_retry host={} redirect_count={} elapsed_ms={}]", - metadata_host, - redirects, - metadata_started.elapsed().as_millis() - ); // Browser captures may carry a large cookie jar even when the // direct download is public. Probe without it first so a server // or proxy header limit cannot turn harmless metadata into the @@ -2261,29 +1949,7 @@ async fn fetch_metadata( } if needs_fallback { - let get_started = Instant::now(); - current_res = match build_get_range().send().await { - Ok(response) => { - log::info!( - "metadata [stage=metadata operation=get_range host={} result=ok status={} elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - response.status().as_u16(), - get_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - response - } - Err(error) => { - log::warn!( - "metadata [stage=metadata operation=get_range host={} result=failed error_code={} elapsed_ms={} total_elapsed_ms={}]", - metadata_host, - reqwest_error_code(&error), - get_started.elapsed().as_millis(), - metadata_started.elapsed().as_millis() - ); - return Err(error.to_string()); - } - }; + current_res = build_get_range().send().await.map_err(|e| e.to_string())?; if retry_metadata_with_cookies( current_res.status(), @@ -2294,12 +1960,6 @@ async fn fetch_metadata( &mut current_url, &mut redirects, ) { - log::info!( - "metadata [stage=metadata operation=credential_retry host={} redirect_count={} elapsed_ms={}]", - metadata_host, - redirects, - metadata_started.elapsed().as_millis() - ); // HEAD is advisory. Some origins reject it while requiring // the captured session for the ranged GET that follows. continue; @@ -2311,22 +1971,8 @@ async fn fetch_metadata( if let Ok(loc_str) = loc.to_str() { if let Ok(parsed_base) = reqwest::Url::parse(¤t_url) { if let Ok(new_url) = parsed_base.join(loc_str) { - let next_host = new_url.host_str().unwrap_or(""); - let credential_scope = if same_origin(&parsed_base, &new_url) { - "same_origin" - } else { - "cross_origin" - }; current_url = new_url.to_string(); redirects += 1; - log::info!( - "metadata [stage=redirect operation=follow host={} next_host={} redirect_count={} credential_scope={} elapsed_ms={}]", - metadata_host, - next_host, - redirects, - credential_scope, - metadata_started.elapsed().as_millis() - ); continue; } } @@ -2341,21 +1987,10 @@ async fn fetch_metadata( .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()), ) { - log::warn!( - "metadata [stage=metadata operation=authentication host={} result=failed error_code=authentication elapsed_ms={}]", - metadata_host, - metadata_started.elapsed().as_millis() - ); return Err(error); } if let Some(error) = metadata_response_error(current_res.status()) { - log::warn!( - "metadata [stage=metadata operation=response host={} status={} error_code=response elapsed_ms={}]", - metadata_host, - current_res.status().as_u16(), - metadata_started.elapsed().as_millis() - ); return Err(error); } @@ -2414,19 +2049,6 @@ async fn fetch_metadata( } } - log::info!( - "metadata [stage=metadata operation=complete host={} status={} redirects={} size_bytes={} resumable={} elapsed_ms={}]", - reqwest::Url::parse(¤t_url) - .ok() - .and_then(|value| value.host_str().map(str::to_string)) - .unwrap_or_else(|| "".to_string()), - res.status().as_u16(), - redirects, - size_bytes, - resumable, - metadata_started.elapsed().as_millis() - ); - Ok(MetadataResponse { url, filename, @@ -2436,55 +2058,11 @@ async fn fetch_metadata( }) } -fn reqwest_error_code(error: &reqwest::Error) -> &'static str { - if error.is_timeout() { - "timeout" - } else if error.is_connect() { - "connect" - } else if error.is_request() { - "request" - } else if error.is_body() { - "body" - } else { - "transport" - } -} - -fn metadata_error_code(error: &str) -> &'static str { - let lower = error.to_ascii_lowercase(); - if lower.contains("dns resolution timed out") { - "dns_timeout" - } else if lower.contains("dns resolution") || lower.contains("name resolution") { - "dns" - } else if lower.contains("private/local ip") { - "ssrf_private_address" - } else if lower.contains("invalid url") { - "invalid_url" - } else if lower.contains("unsupported url scheme") { - "unsupported_scheme" - } else if lower.contains("timeout") || lower.contains("timed out") { - "timeout" - } else { - crate::retry::network_error_class(error) - } -} - const MEDIA_METADATA_CACHE_TTL: Duration = Duration::from_secs(60); const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55); const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128; const FILE_METADATA_TIMEOUT: Duration = Duration::from_secs(20); const MAX_SHELL_OUTPUT_BYTES: usize = 32 * 1024 * 1024; -// Since early 2026, YouTube's default client set can expose only a -// progressive 360p stream without separate audio formats. Keep the default -// client for normal authenticated/public behavior, then let yt-dlp try the -// embedded player client for the richer format set recommended by yt-dlp's -// maintainers. -const YTDLP_YOUTUBE_PLAYER_CLIENTS: &str = "youtube:player_client=default,web_embedded"; - -fn ytdlp_youtube_extractor_args() -> [&'static str; 2] { - ["--extractor-args", YTDLP_YOUTUBE_PLAYER_CLIENTS] -} - fn normalize_media_connections(connections: Option) -> i32 { crate::queue::clamp_download_connections( connections.unwrap_or(crate::queue::DOWNLOAD_CONNECTIONS_MAX), @@ -2589,22 +2167,6 @@ static MEDIA_METADATA_CACHE: OnceLock>>>, > = OnceLock::new(); -static DOWNLOAD_TARGET_LOCKS: OnceLock< - tokio::sync::Mutex>>>, -> = OnceLock::new(); - -async fn download_target_lock(path: &std::path::Path) -> std::sync::Arc> { - let key = crate::platform::path_identity(path); - let locks = DOWNLOAD_TARGET_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); - let mut guard = locks.lock().await; - guard.retain(|_, lock| lock.strong_count() > 0); - if let Some(lock) = guard.get(&key).and_then(std::sync::Weak::upgrade) { - return lock; - } - let lock = std::sync::Arc::new(tokio::sync::Mutex::new(())); - guard.insert(key, std::sync::Arc::downgrade(&lock)); - lock -} #[allow(clippy::too_many_arguments)] // Hash every user-controlled yt-dlp input explicitly. fn media_metadata_cache_key( @@ -2677,7 +2239,6 @@ fn build_ytdlp_config_content( #[tauri::command] #[allow(clippy::too_many_arguments)] // Keep the generated TypeScript IPC contract flat and stable. async fn fetch_media_metadata( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, url: String, cookie_browser: Option, @@ -2688,7 +2249,6 @@ async fn fetch_media_metadata( cookies: Option, proxy: Option, ) -> Result { - properties_window::ensure_main_window(&caller)?; validate_url_ssrf(&url).await?; let cache_key = media_metadata_cache_key( &url, @@ -2814,7 +2374,6 @@ async fn fetch_media_metadata( #[tauri::command] #[allow(clippy::too_many_arguments)] async fn fetch_media_playlist_metadata( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, url: String, cookie_browser: Option, @@ -2825,7 +2384,6 @@ async fn fetch_media_playlist_metadata( cookies: Option, proxy: Option, ) -> Result { - properties_window::ensure_main_window(&caller)?; validate_url_ssrf(&url).await?; let result = fetch_media_playlist_metadata_uncached( @@ -3021,7 +2579,6 @@ async fn fetch_media_metadata_uncached( .arg("3") .arg("--compat-options") .arg("no-youtube-unavailable-videos") - .args(ytdlp_youtube_extractor_args()) .arg("--print") .arg("%(.{title,duration,thumbnail,formats})j"); @@ -3119,8 +2676,7 @@ async fn fetch_media_metadata_uncached( } #[tauri::command] -async fn test_ytdlp(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { - properties_window::ensure_main_window(&caller)?; +async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "yt-dlp", &["--version"]).await; match (version, error) { (Some(version), None) => Ok(version), @@ -3130,8 +2686,7 @@ async fn test_ytdlp(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) } #[tauri::command] -async fn test_ffmpeg(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { - properties_window::ensure_main_window(&caller)?; +async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "ffmpeg", &["-version"]).await; version .as_deref() @@ -3140,8 +2695,7 @@ async fn test_ffmpeg(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) } #[tauri::command] -async fn test_deno(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { - properties_window::ensure_main_window(&caller)?; +async fn test_deno(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "deno", &["--version"]).await; if let Some(text) = version { let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").unwrap(); @@ -3179,170 +2733,7 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle .any(|root| crate::platform::path_is_within(&canonical_path, &root)) } -const RETRYABLE_DESTINATION_ACCESS_PREFIX: &str = "destination access retryable:"; - -/// Verify the destination before queue ownership is committed. This is an -/// app-owned, uniquely named probe so macOS privacy prompts and ordinary -/// filesystem permission failures happen at admission time rather than -/// during cleanup or after Aria2 has already started. -fn preflight_download_destination_access( - app_handle: &tauri::AppHandle, - id: &str, - destination: &str, -) -> Result<(), String> { - let preflight_started = Instant::now(); - let resolved = resolve_path(destination.trim(), app_handle); - if !is_safe_path(&resolved, app_handle) { - log::warn!( - "download destination [stage=destination_access id={} operation=preflight result=failed error_code=unsafe_path elapsed_ms={}]", - id, - preflight_started.elapsed().as_millis() - ); - return Err(format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder is not approved" - )); - } - - if !resolved.exists() { - std::fs::create_dir_all(&resolved).map_err(|error| { - log::warn!( - "download destination [stage=destination_access id={} operation=create_directory result=failed error_code={} elapsed_ms={}]", - id, - destination_io_error_code(&error), - preflight_started.elapsed().as_millis() - ); - format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} grant Firelink access to the selected folder and retry" - ) - })?; - } - - let canonical = canonicalize_with_missing_components(&resolved).ok_or_else(|| { - log::warn!( - "download destination [stage=destination_access id={} operation=canonicalize result=failed error_code=canonicalization elapsed_ms={}]", - id, - preflight_started.elapsed().as_millis() - ); - format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder could not be verified" - ) - })?; - if !is_safe_path(&canonical, app_handle) { - log::warn!( - "download destination [stage=destination_access id={} operation=canonicalize result=failed error_code=unsafe_path elapsed_ms={}]", - id, - preflight_started.elapsed().as_millis() - ); - return Err(format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected folder is not approved" - )); - } - if !canonical.is_dir() { - log::warn!( - "download destination [stage=destination_access id={} operation=validate_directory result=failed error_code=not_directory elapsed_ms={}]", - id, - preflight_started.elapsed().as_millis() - ); - return Err(format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} selected destination is not a folder" - )); - } - - let probe_path = canonical.join(format!( - ".firelink-access-check-{}.probe", - uuid::Uuid::new_v4().simple() - )); - let probe_result = (|| -> Result<(), std::io::Error> { - use std::io::Write; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&probe_path)?; - file.write_all(b"firelink")?; - file.flush()?; - file.sync_all()?; - Ok(()) - })(); - let cleanup_result = std::fs::remove_file(&probe_path); - - if let Err(error) = probe_result { - log::warn!( - "download destination [stage=destination_access id={} operation=write_probe result=failed error_code={} elapsed_ms={}]", - id, - destination_io_error_code(&error), - preflight_started.elapsed().as_millis() - ); - if let Err(cleanup_error) = cleanup_result { - log::debug!( - "download destination [stage=destination_access id={} operation=cleanup_probe result=failed error_code={}]", - id, - destination_io_error_code(&cleanup_error) - ); - } - return Err(format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} Firelink could not write to the selected folder; grant access and retry" - )); - } - if let Err(error) = cleanup_result { - log::warn!( - "download destination [stage=destination_access id={} operation=cleanup_probe result=failed error_code={} elapsed_ms={}]", - id, - destination_io_error_code(&error), - preflight_started.elapsed().as_millis() - ); - return Err(format!( - "{RETRYABLE_DESTINATION_ACCESS_PREFIX} the selected folder could not be safely verified; retry" - )); - } - - log::info!( - "download destination [stage=destination_access id={} operation=preflight result=ok elapsed_ms={}]", - id, - preflight_started.elapsed().as_millis() - ); - Ok(()) -} - -fn destination_io_error_code(error: &std::io::Error) -> &'static str { - match error.kind() { - std::io::ErrorKind::PermissionDenied => "permission_denied", - std::io::ErrorKind::NotFound => "not_found", - std::io::ErrorKind::AlreadyExists => "already_exists", - std::io::ErrorKind::InvalidInput => "invalid_input", - _ => "io_error", - } -} - -fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; - return metadata.file_type().is_symlink() - || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; - } - #[cfg(not(windows))] - { - metadata.file_type().is_symlink() - } -} - pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { - path_has_component_matching(path, metadata_is_link_or_reparse) -} - -/// Detect only symbolic-link components, without treating every Windows -/// reparse point as a link. Trusted application-data directories may use -/// junctions for Windows folder redirection; user-selected download and -/// recovery paths continue to use the stricter helper above. -pub(crate) fn path_has_symbolic_link_component(path: &std::path::Path) -> bool { - path_has_component_matching(path, |metadata| metadata.file_type().is_symlink()) -} - -fn path_has_component_matching( - path: &std::path::Path, - matches: impl Fn(&std::fs::Metadata) -> bool, -) -> bool { use std::path::Component; let mut current = std::path::PathBuf::new(); @@ -3354,7 +2745,7 @@ fn path_has_component_matching( Component::Normal(name) => { current.push(name); if std::fs::symlink_metadata(¤t) - .is_ok_and(|metadata| matches(&metadata)) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) { return true; } @@ -3376,7 +2767,7 @@ pub(crate) fn canonicalize_with_missing_components( loop { match std::fs::symlink_metadata(existing) { Ok(metadata) => { - if metadata_is_link_or_reparse(&metadata) { + if metadata.file_type().is_symlink() { return None; } break; @@ -3430,12 +2821,7 @@ fn approved_download_roots(app_handle: &tauri::AppHandle) } #[tauri::command] -fn approve_download_root( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - path: String, -) -> Result { - properties_window::ensure_main_window(&caller)?; +fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result { let resolved = resolve_path(path.trim(), &app_handle); if !resolved.is_absolute() { return Err("Download root must be an absolute path".to_string()); @@ -3487,39 +2873,14 @@ fn push_unique_path(paths: &mut Vec, path: std::path::PathBu } } -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -struct FrontendExitFlush { - next_request: AtomicU64, - completed: tokio::sync::watch::Sender, -} - -impl FrontendExitFlush { - fn new() -> Self { - let (completed, _) = tokio::sync::watch::channel(0); - Self { - next_request: AtomicU64::new(0), - completed, - } - } - - fn request(&self) -> u64 { - self.next_request.fetch_add(1, Ordering::AcqRel) + 1 - } - - fn acknowledge(&self) { - let request = self.next_request.load(Ordering::Acquire); - let _ = self.completed.send(request); - } -} - struct Aria2DaemonGuard { child: Mutex>, startup_error: Mutex>, last_stderr: Mutex, config_path: Mutex>, - shutdown_state: AtomicU8, } impl Aria2DaemonGuard { @@ -3529,66 +2890,8 @@ impl Aria2DaemonGuard { startup_error: Mutex::new(None), last_stderr: Mutex::new(String::new()), config_path: Mutex::new(None), - shutdown_state: AtomicU8::new(0), } } - - fn exit_allowed(&self) -> bool { - self.shutdown_state.load(Ordering::SeqCst) == 2 - } - - fn begin_shutdown(&self) -> bool { - self.shutdown_state - .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - } - - fn allow_exit(&self) { - self.shutdown_state.store(2, Ordering::SeqCst); - } -} - -async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) { - let guard = app_handle.state::(); - if let Some(state) = app_handle.try_state::() { - let port = state.aria2_port.load(Ordering::Relaxed); - if port != 0 { - let shutdown = tokio::time::timeout( - std::time::Duration::from_secs(2), - rpc_call(port, &state.aria2_secret, "aria2.shutdown", serde_json::json!([])), - ) - .await; - match shutdown { - Ok(Ok(_)) => log::info!("aria2 graceful shutdown requested"), - Ok(Err(error)) => log::warn!("aria2 graceful shutdown failed: {error}"), - Err(_) => log::warn!("aria2 graceful shutdown timed out"), - } - } - } - - let child = guard.child.lock().ok().and_then(|mut child| child.take()); - if let Some(mut child) = child { - let _ = tokio::task::spawn_blocking(move || { - // Aria2 1.37.0 intentionally schedules an RPC-requested shutdown - // three seconds after replying. Leave a bounded margin for that - // timer and the subsequent state flush before forcing termination. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); - loop { - match child.try_wait() { - Ok(Some(_)) => return, - Ok(None) if std::time::Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(50)); - } - _ => { - let _ = child.kill(); - let _ = child.wait(); - return; - } - } - } - }) - .await; - } } impl Drop for Aria2DaemonGuard { @@ -3612,17 +2915,11 @@ pub mod ipc; mod parity; mod power; mod platform; -#[doc(hidden)] -pub use platform::atomic_write_replace; -mod properties_window; pub mod queue; pub mod process; pub mod retry; -mod torrent_probe; -pub mod torrent; mod settings; mod storage; -mod window_geometry; pub use error::AppError; // Retained only for compatibility with the optional aria2 diagnostic monitor. @@ -3647,7 +2944,6 @@ pub struct AppState { pub extension_acks: extension_server::SharedExtensionAcks, pub extension_server_port: extension_server::SharedServerPort, pub extension_server_shutdown: tokio::sync::watch::Sender, - frontend_exit_flush: Arc, pub aria2_port: std::sync::Arc, pub aria2_secret: String, pub media_semaphore: Arc, @@ -3659,7 +2955,6 @@ pub struct AppState { #[derive(Default)] struct MainWindowRestoreState { requested: AtomicBool, - startup_complete: AtomicBool, } #[cfg(target_os = "macos")] @@ -3741,16 +3036,6 @@ pub struct DownloadProgressEvent { active_connections: Option, #[ts(optional)] requested_connections: Option, - #[ts(optional)] - effective_connections: Option, - #[ts(optional)] - uploaded_bytes: Option, - #[ts(optional)] - upload_speed: Option, - #[ts(optional)] - num_seeders: Option, - #[ts(optional)] - torrent_seeded_seconds: Option, } #[derive(Debug, Clone, Serialize, TS)] @@ -3829,20 +3114,6 @@ enum FirelinkDeepLink { } fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { - if deep_link.scheme() == "magnet" { - return if deep_link.query().is_some() - && deep_link.fragment().is_none() - && deep_link.username().is_empty() - && deep_link.password().is_none() - && deep_link.port().is_none() - && deep_link.to_string().chars().count() < MAX_DEEP_LINK_PAYLOAD_LEN - { - FirelinkDeepLink::Add(vec![deep_link.to_string()]) - } else { - FirelinkDeepLink::Invalid - }; - } - if deep_link.scheme() != "firelink" || !deep_link.username().is_empty() || deep_link.password().is_some() @@ -3885,7 +3156,7 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { let Ok(url) = url::Url::parse(raw_url) else { continue; }; - if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") { + if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { continue; } let url = url.to_string(); @@ -3904,77 +3175,7 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { } } -fn normalize_opened_torrent_argument(raw: &str) -> Option { - let raw = raw.trim(); - if raw.is_empty() - || raw.starts_with('-') - || raw.contains('\r') - || raw.contains('\n') - || raw.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN - { - return None; - } - - let is_windows_absolute = cfg!(target_os = "windows") - && raw.len() >= 3 - && raw.as_bytes()[1] == b':' - && matches!(raw.as_bytes()[2], b'/' | b'\\'); - let path = if is_windows_absolute { - PathBuf::from(raw) - } else if let Ok(url) = url::Url::parse(raw) { - if url.scheme() != "file" { - return None; - } - url.to_file_path().ok()? - } else { - PathBuf::from(raw) - }; - if !path.is_absolute() && !is_windows_absolute { - return None; - } - if !path.extension().is_some_and(|extension| { - extension.to_string_lossy().eq_ignore_ascii_case("torrent") - }) { - return None; - } - let path = path.to_string_lossy().into_owned(); - (!path.contains('\r') && !path.contains('\n')).then_some(path) -} - -fn collect_opened_torrent_paths(arguments: I) -> Vec -where - I: IntoIterator, - S: AsRef, -{ - let mut seen = HashSet::new(); - arguments - .into_iter() - .filter_map(|argument| argument.as_ref().to_str().and_then(normalize_opened_torrent_argument)) - .filter(|path| seen.insert(path.clone())) - .take(MAX_DEEP_LINK_URLS) - .collect() -} - -pub(crate) fn restore_main_window(app_handle: &tauri::AppHandle) { - let startup_complete = app_handle - .try_state::() - .is_none_or(|state| state.startup_complete.load(Ordering::Acquire)); - if !startup_complete { - if let Some(state) = app_handle.try_state::() { - // The first window can be requested by a single-instance callback - // or an opened .torrent path while the native host is still being - // constructed. Do not touch the HWND until RunEvent::Ready. - state.requested.store(true, Ordering::Release); - // Ready may have won the transition between the first load and - // the request store. Re-check before returning so that request is - // serviced by this call instead of being left pending forever. - if !state.startup_complete.load(Ordering::Acquire) { - return; - } - } else { - return; - } - } +fn restore_main_window(app_handle: &tauri::AppHandle) { let Some(window) = app_handle.get_webview_window("main") else { if let Some(state) = app_handle.try_state::() { state.requested.store(true, Ordering::Release); @@ -3987,24 +3188,6 @@ pub(crate) fn restore_main_window(app_handle: &tauri::AppHandle) { let _ = window.set_focus(); } -fn mark_main_window_startup_complete(app_handle: &tauri::AppHandle) { - if let Some(state) = app_handle.try_state::() { - state.startup_complete.store(true, Ordering::Release); - } -} - -#[cfg(target_os = "windows")] -fn reveal_main_window(app_handle: &tauri::AppHandle) { - if let Some(window) = app_handle.get_webview_window("main") { - if let Err(error) = window.set_focusable(true) { - log::warn!("Could not make the main window focusable: {error}"); - } - if let Err(error) = window.show() { - eprintln!("Failed to reveal the main window: {error}"); - } - } -} - fn restore_pending_main_window(app_handle: &tauri::AppHandle) { if app_handle .try_state::() @@ -4050,24 +3233,7 @@ fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec) }); } -fn dispatch_opened_torrent_paths(app_handle: tauri::AppHandle, paths: Vec) { - if paths.is_empty() { - return; - } - restore_main_window(&app_handle); - let coordinator = app_handle.state::().download_coordinator.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = coordinator - .send(download::DownloadCmd::CaptureUrls(paths)) - .await - { - eprintln!("Failed to dispatch opened torrent files: {error}"); - } - }); -} - -#[doc(hidden)] -pub async fn rpc_call( +pub(crate) async fn rpc_call( port: u16, secret: &str, method: &str, @@ -4099,30 +3265,7 @@ pub async fn rpc_call( .await .map_err(|e| e.to_string())?; - let http_status = res.status(); - let body = res.text().await.map_err(|e| e.to_string())?; - let json: serde_json::Value = serde_json::from_str(&body).map_err(|error| { - if http_status.is_success() { - error.to_string() - } else { - format!( - "HTTP {} {}: invalid JSON-RPC response: {error}", - http_status.as_u16(), - http_status.canonical_reason().unwrap_or("response error") - ) - } - })?; - if !http_status.is_success() { - let detail = json - .get("error") - .map(serde_json::Value::to_string) - .unwrap_or_else(|| "aria2 returned no JSON-RPC error".to_string()); - return Err(format!( - "HTTP {} {}: {detail}", - http_status.as_u16(), - http_status.canonical_reason().unwrap_or("response error") - )); - } + let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; if let Some(error) = json.get("error") { return Err(error.to_string()); } @@ -4131,29 +3274,11 @@ pub async fn rpc_call( .ok_or_else(|| "aria2 returned no result".to_string()) } -struct Aria2RpcClient { - port: u16, - secret: String, -} - -#[async_trait::async_trait] -impl crate::torrent_probe::RpcClient for Aria2RpcClient { - async fn call( - &self, - method: &str, - params: serde_json::Value, - ) -> Result { - rpc_call(self.port, &self.secret, method, params).await - } -} - #[tauri::command] async fn test_aria2c( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; let guard = app_handle.state::(); let startup_err = guard .startup_error @@ -4689,11 +3814,9 @@ async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem { #[tauri::command] async fn get_engine_status( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let secret = state.aria2_secret.clone(); @@ -4711,11 +3834,9 @@ async fn get_engine_status( #[tauri::command] async fn get_aria2_engine_status( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; Ok(check_aria2( &app_handle, state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -4725,29 +3846,19 @@ async fn get_aria2_engine_status( } #[tauri::command] -async fn get_ytdlp_engine_status( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, -) -> Result { - properties_window::ensure_main_window(&caller)?; +async fn get_ytdlp_engine_status(app_handle: tauri::AppHandle) -> Result { Ok(check_ytdlp(&app_handle).await) } #[tauri::command] async fn get_ffmpeg_engine_status( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, ) -> Result { - properties_window::ensure_main_window(&caller)?; Ok(check_ffmpeg(&app_handle).await) } #[tauri::command] -async fn get_deno_engine_status( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, -) -> Result { - properties_window::ensure_main_window(&caller)?; +async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result { Ok(check_deno(&app_handle).await) } @@ -4758,31 +3869,6 @@ fn resolve_bundled_binary_path( crate::engines::resolve_bundled_binary_path(app_handle, binary_name) } -fn normalize_media_cookie_source(source: Option<&str>) -> Result, String> { - let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else { - return Ok(None); - }; - let source = source.to_ascii_lowercase(); - if source == "none" { - return Ok(None); - } - if matches!( - source.as_str(), - "safari" - | "chrome" - | "chromium" - | "firefox" - | "edge" - | "brave" - | "opera" - | "vivaldi" - | "whale" - ) { - return Ok(Some(source)); - } - Err("Unsupported media browser-cookie source".to_string()) -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn start_media_download_internal( app_handle: tauri::AppHandle, @@ -4803,7 +3889,6 @@ pub(crate) async fn start_media_download_internal( max_tries: Option, cancel_rx: &mut tokio::sync::watch::Receiver, ) -> Result { - let cookie_source = normalize_media_cookie_source(cookie_source.as_deref())?; let safe_filename = crate::download_ownership::canonical_download_filename(&filename); let resolved_dest = resolve_path(&destination, &app_handle); @@ -4863,10 +3948,12 @@ pub(crate) async fn start_media_download_internal( let mut progress_state = MediaProgressEmitterState::new(); // Resolve absolute paths to bundled binaries + let aria2c_path = resolve_bundled_binary_path(&app_handle, "aria2c")?; let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg")?; let deno_path = resolve_bundled_binary_path(&app_handle, "deno")?; - log::info!("Using bundled FFmpeg engine"); - log::info!("Using bundled Deno engine"); + log::info!("Using bundled aria2c: {:?}", aria2c_path); + log::info!("Using bundled ffmpeg: {:?}", ffmpeg_path); + log::info!("Using bundled deno: {:?}", deno_path); // yt-dlp accepts an absolute path for its external downloader. Keep every // engine explicit so behavior never depends on PATH, symlink privileges, @@ -4878,7 +3965,7 @@ pub(crate) async fn start_media_download_internal( .max(0) as usize; let concurrent_fragments = normalize_media_connections(connections); let mut strike = 0_usize; - let mut effective_cookie_source = cookie_source; + let mut effective_cookie_source = cookie_source.clone(); let mut browser_cookie_fallback_used = false; while strike <= max_retries { @@ -4918,7 +4005,6 @@ pub(crate) async fn start_media_download_internal( .arg("--continue") .arg("--compat-options") .arg("no-youtube-unavailable-videos") - .args(ytdlp_youtube_extractor_args()) .arg("--print") .arg("after_move:%(filepath)s") .arg("-o") @@ -5080,27 +4166,17 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, - effective_connections: None, - uploaded_bytes: None, - upload_speed: None, - num_seeders: None, - torrent_seeded_seconds: None, }); } let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("critical") { - log::error!( - "yt-dlp stderr [{}]: {}", - id, - redact_log_line_for_app(line.trim(), &app_handle) - ); + log::error!("yt-dlp stderr [{}]: {}", id, line.trim()); } } } Some(tauri_plugin_shell::process::CommandEvent::Error(err)) => { - let safe_error = redact_log_line_for_app(&err, &app_handle); - log::error!("yt-dlp shell error [{}]: {}", id, safe_error); - break safe_error; + log::error!("yt-dlp shell error [{}]: {}", id, err); + break err; } Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => { if let Some(line) = flush_media_output_line(&mut stdout_buffer) { @@ -5163,11 +4239,6 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, - effective_connections: None, - uploaded_bytes: None, - upload_speed: None, - num_seeders: None, - torrent_seeded_seconds: None, }); } } @@ -5177,7 +4248,7 @@ pub(crate) async fn start_media_download_internal( break if stderr_tail.is_empty() { format!("yt-dlp exited with code {:?}", payload.code) } else { - redact_log_line_for_app(&stderr_tail, &app_handle) + stderr_tail.clone() }; } Some(_) => {} @@ -5185,7 +4256,7 @@ pub(crate) async fn start_media_download_internal( break if stderr_tail.is_empty() { "yt-dlp process ended unexpectedly".to_string() } else { - redact_log_line_for_app(&stderr_tail, &app_handle) + stderr_tail.clone() }; } } @@ -5238,12 +4309,10 @@ pub(crate) async fn start_media_download_internal( #[tauri::command] async fn pause_download( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; log::info!("pause_download called for id: {}", id); let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; @@ -5256,14 +4325,8 @@ async fn pause_download( let removed_pending = state.queue_manager.remove_from_pending(&id).await; let gid = state.queue_manager.aria2_gid_for_download(&id); - if gid.is_none() { - // A queued or not-yet-registered transfer cannot be verified through - // Aria2. Removing it from pending is the terminal pause boundary for - // this lifecycle, so its native allocation marker can be cleared now. - state.queue_manager.clear_aria2_allocation(&id).await; - } if let Some(gid) = gid.as_deref() { - let (status, mut status_progress) = aria2_download_status_snapshot( + let status = aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, @@ -5271,7 +4334,6 @@ async fn pause_download( .await?; match status.as_str() { "paused" => { - state.queue_manager.clear_aria2_allocation(&id).await; state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; log::info!("aria2 pause [{}]: gid {} was already paused", id, gid); @@ -5290,16 +4352,14 @@ async fn pause_download( Err(error) => Err(format!("failed to pause aria2 gid {gid}: {error}")), }; if let Err(pause_error) = pause_result { - match aria2_download_status_snapshot( + match aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await { - Ok((status, progress)) if status == "paused" => { - state.queue_manager.clear_aria2_allocation(&id).await; - status_progress = progress.or(status_progress); + Ok(status) if status == "paused" => { // forcePause may have returned an RPC error after // the daemon actually paused the GID. Invalidate // terminal events already in flight before @@ -5312,47 +4372,27 @@ async fn pause_download( gid ); } - Ok((status, progress)) if status == "complete" => { - if state - .queue_manager - .aria2_direct_magnet_needs_child_handoff(&id, gid) - .await - { - state.queue_manager.emit_state( - id.clone(), - crate::ipc::DownloadStatus::Downloading, - ); - return Err(format!( - "{pause_error}; Torrent metadata is complete but its payload is still being discovered" - )); - } - state.queue_manager.clear_aria2_allocation(&id).await; + Ok(status) if status == "complete" => { state .queue_manager - .apply_completion_locked_with_progress( - &id, - crate::queue::PendingOutcome::Complete, - progress.or(status_progress.clone()), - ) + .apply_completion_locked(&id, crate::queue::PendingOutcome::Complete) .await; return Ok(()); } - Ok((status, progress)) if matches!(status.as_str(), "error" | "removed") => { - state.queue_manager.clear_aria2_allocation(&id).await; + Ok(status) if matches!(status.as_str(), "error" | "removed") => { let terminal_error = format!( "cannot pause aria2 gid {gid}: {pause_error}; daemon reports terminal state {status}" ); state .queue_manager - .apply_completion_locked_with_progress( + .apply_completion_locked( &id, crate::queue::PendingOutcome::Error(terminal_error.clone()), - progress.or(status_progress.clone()), ) .await; return Err(terminal_error); } - Ok((status, _)) => { + Ok(status) => { state.queue_manager.allow_aria2_retries(&id).await; return Err(format!( "{pause_error}; aria2 gid {gid} is still {status}" @@ -5366,31 +4406,10 @@ async fn pause_download( } } } - state.queue_manager.clear_aria2_allocation(&id).await; state.queue_manager.next_aria2_control_epoch(&id).await; log::info!("aria2 pause [{}]: gid {} paused", id, gid); } "complete" => { - if state - .queue_manager - .aria2_direct_magnet_needs_child_handoff(&id, gid) - .await - { - state.queue_manager.emit_state( - id.clone(), - crate::ipc::DownloadStatus::Downloading, - ); - log::info!( - "aria2 pause [{}]: gid {} completed metadata; deferring pause until the payload child is mapped", - id, - gid - ); - return Err( - "Torrent metadata is complete but its payload is still being discovered" - .to_string(), - ); - } - state.queue_manager.clear_aria2_allocation(&id).await; // Aria2 can reach complete before its terminal event updates // Firelink's row. Treat a pause request in that narrow window // as an idempotent completion reconciliation, not as an @@ -5402,16 +4421,11 @@ async fn pause_download( ); state .queue_manager - .apply_completion_locked_with_progress( - &id, - crate::queue::PendingOutcome::Complete, - status_progress.clone(), - ) + .apply_completion_locked(&id, crate::queue::PendingOutcome::Complete) .await; return Ok(()); } terminal => { - state.queue_manager.clear_aria2_allocation(&id).await; let retrying = state.queue_manager.has_aria2_retry_state(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; @@ -5426,14 +4440,13 @@ async fn pause_download( // to repair it. state.queue_manager.release_registered_id(&id).await; use tauri::Emitter; - let mut event = crate::ipc::DownloadStateEvent::new( - id, - crate::ipc::DownloadStatus::Paused, + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new( + id, + crate::ipc::DownloadStatus::Paused, + ), ); - if let Some(progress) = status_progress { - event = event.with_progress(progress); - } - let _ = app_handle.emit("download-state", event); return Ok(()); } state.queue_manager.release_registered_id(&id).await; @@ -5443,28 +4456,12 @@ async fn pause_download( } } - let seed_remaining = if state.queue_manager.is_seed_owner(&id) { - state.queue_manager.capture_seed_remaining(&id).await - } else { - None - }; - state.queue_manager.release_seed_tracking(&id); state.queue_manager.release_permit(&id).await; - let paused_progress = aria2_download_status_snapshot( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - gid, - ) - .await - .ok() - .and_then(|(_, progress)| progress) - .or(status_progress); use tauri::Emitter; - let mut event = crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining); - if let Some(progress) = paused_progress { - event = event.with_progress(progress); - } - let _ = app_handle.emit("download-state", event); + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), + ); return Ok(()); } @@ -5503,12 +4500,6 @@ async fn pause_download( .await; } - let seed_remaining = if state.queue_manager.is_seed_owner(&id) { - state.queue_manager.capture_seed_remaining(&id).await - } else { - None - }; - state.queue_manager.release_seed_tracking(&id); state.queue_manager.release_permit(&id).await; if registered_lifecycle_generation.is_some() || removed_pending @@ -5526,20 +4517,18 @@ async fn pause_download( use tauri::Emitter; let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining), + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), ); Ok(()) } #[tauri::command] async fn resume_download( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, queue_id: String, ) -> Result { - properties_window::ensure_main_window(&caller)?; let queue_id = queue_id.trim().to_string(); if queue_id.is_empty() { return Err("Queue id cannot be empty".to_string()); @@ -5557,62 +4546,12 @@ async fn resume_download( state.queue_manager.release_registered_id(&id).await; return Ok(false); }; - let waiting_to_seed = state.queue_manager.is_waiting_to_seed(&id); - let status = match aria2_download_status( + let status = aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, &gid, ) - .await - { - Ok(status) => status, - Err(error) if aria2_gid_not_found(&error) => { - // Aria2 GIDs are daemon-scoped. A daemon restart or an external - // removal can leave Firelink's mapping pointing at a GID that no - // longer exists. Treat only that verified absence as an - // unresumable lifecycle so the frontend can re-enqueue the saved - // payload; transport/RPC failures must remain errors because - // re-enqueueing without knowing the daemon state could duplicate - // an existing transfer. - log::warn!( - "aria2 resume [stage=status id={} gid={} result=stale_gid action=reenqueue]", - id, - gid - ); - state.queue_manager.next_aria2_control_epoch(&id).await; - state.queue_manager.cancel_aria2_retries(&id).await; - state.queue_manager.clear_aria2_retry_state(&id).await; - state.queue_manager.forget_aria2_gid(&id).await; - state.queue_manager.release_permit(&id).await; - state.queue_manager.release_registered_id(&id).await; - return Ok(false); - } - Err(error) if waiting_to_seed => { - // WaitingToSeed normally has no active download permit, so a - // temporary RPC outage must keep using the seed scheduler rather - // than turning a recoverable wake-up into a failed resume. The - // verified missing-GID case above still retires the stale - // lifecycle and permits a fresh enqueue. - log::warn!( - "aria2 resume [stage=status id={} gid={} result=unverified_waiting_seed_status error_class={}]", - id, - gid, - crate::retry::network_error_class(&error) - ); - state.queue_manager.wake_seed_waiters(); - drop(control_guard); - return Ok(true); - } - Err(error) => return Err(error), - }; - if waiting_to_seed { - // WaitingToSeed is an intentional paused GID with no download - // permit. A resume request only wakes the Firelink seed scheduler; - // it must not bypass the seed-slot admission gate. - state.queue_manager.wake_seed_waiters(); - drop(control_guard); - return Ok(true); - } + .await?; match status.as_str() { "paused" => { let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await; @@ -5697,9 +4636,6 @@ async fn resume_download( ) .await; if !parked { - queue_manager - .release_aria2_permit_candidate(&id_clone, lifecycle_generation) - .await; log::warn!( "aria2 resume [{}]: permit ownership was not established before unpause; leaving gid {} paused", id_clone, @@ -5708,6 +4644,13 @@ async fn resume_download( return; } } + let _ = app_handle_clone.emit( + "download-state", + crate::ipc::DownloadStateEvent::new( + &id_clone, + crate::ipc::DownloadStatus::Downloading, + ), + ); let unpause_error = match rpc_call( aria2_port, &aria2_secret, @@ -5722,37 +4665,8 @@ async fn resume_download( Err(error) => Some(format!("failed to resume aria2 gid {gid_clone}: {error}")), }; if let Some(unpause_error) = unpause_error { - match verify_aria2_resume_status_snapshot( - aria2_port, - &aria2_secret, - &gid_clone, - ) - .await - { - Ok((status, _)) if matches!(status.as_str(), "active" | "waiting") => { - let still_current = queue_manager - .is_aria2_control_epoch_current(&id_clone, control_epoch) - .await - && queue_manager.aria2_gid_for_download(&id_clone).as_deref() - == Some(gid_clone.as_str()); - if !still_current { - let _ = rpc_call( - aria2_port, - &aria2_secret, - "aria2.forcePause", - serde_json::json!([gid_clone]), - ) - .await; - return; - } - use tauri::Emitter; - let _ = app_handle_clone.emit( - "download-state", - crate::ipc::DownloadStateEvent::new( - &id_clone, - crate::ipc::DownloadStatus::Downloading, - ), - ); + match aria2_download_status(aria2_port, &aria2_secret, &gid_clone).await { + Ok(status) if matches!(status.as_str(), "active" | "waiting") => { log::warn!( "aria2 resume [{}]: {} but daemon reports gid {} as {}; retaining permit", id_clone, @@ -5762,22 +4676,7 @@ async fn resume_download( ); return; } - Ok((status, progress)) if status == "complete" => { - if queue_manager - .aria2_direct_magnet_needs_child_handoff(&id_clone, &gid_clone) - .await - { - queue_manager.emit_state( - id_clone.clone(), - crate::ipc::DownloadStatus::Downloading, - ); - log::info!( - "aria2 resume [{}]: gid {} completed metadata after an ambiguous unpause; deferring until the payload child is mapped", - id_clone, - gid_clone - ); - return; - } + Ok(status) if status == "complete" => { log::info!( "aria2 resume [{}]: {} but daemon reports gid {} complete; reconciling completion", id_clone, @@ -5785,15 +4684,14 @@ async fn resume_download( gid_clone ); queue_manager - .apply_completion_locked_with_progress( + .apply_completion_locked( &id_clone, crate::queue::PendingOutcome::Complete, - progress, ) .await; return; } - Ok((status, progress)) if status == "paused" => { + Ok(status) if status == "paused" => { queue_manager.next_aria2_control_epoch(&id_clone).await; queue_manager.cancel_aria2_retries(&id_clone).await; queue_manager.release_permit(&id_clone).await; @@ -5803,30 +4701,28 @@ async fn resume_download( unpause_error, gid_clone ); - let mut event = crate::ipc::DownloadStateEvent::paused_with_error( - &id_clone, - unpause_error, + let _ = app_handle_clone.emit( + "download-state", + crate::ipc::DownloadStateEvent::paused_with_error( + &id_clone, + unpause_error, + ), ); - if let Some(progress) = progress { - event = event.with_progress(progress); - } - let _ = app_handle_clone.emit("download-state", event); return; } - Ok((status, progress)) if matches!(status.as_str(), "error" | "removed") => { + Ok(status) if matches!(status.as_str(), "error" | "removed") => { let terminal_error = format!( "{unpause_error}; daemon reports gid {gid_clone} as {status}" ); queue_manager - .apply_completion_locked_with_progress( + .apply_completion_locked( &id_clone, crate::queue::PendingOutcome::Error(terminal_error), - progress, ) .await; return; } - Ok((status, _)) => { + Ok(status) => { // An unrecognized daemon state is not proof that // the transfer stopped. Keep its permit and // mapping so a later reconciliation can observe @@ -5852,100 +4748,6 @@ async fn resume_download( } } } - // A successful unpause RPC is not itself the postcondition: - // aria2 may still report the GID as paused, complete, or - // otherwise unavailable. Verify the daemon state before - // publishing Downloading to the renderer. - let (status_after_unpause, status_after_unpause_progress) = match verify_aria2_resume_status_snapshot( - aria2_port, - &aria2_secret, - &gid_clone, - ) - .await - { - Ok(status) => status, - Err(error) => { - log::error!( - "aria2 resume [{}]: unpause succeeded but gid {} could not be verified: {}; retaining permit", - id_clone, - gid_clone, - error - ); - return; - } - }; - match status_after_unpause.as_str() { - "active" | "waiting" => {} - "complete" => { - if queue_manager - .aria2_direct_magnet_needs_child_handoff(&id_clone, &gid_clone) - .await - { - queue_manager.emit_state( - id_clone.clone(), - crate::ipc::DownloadStatus::Downloading, - ); - log::info!( - "aria2 resume [{}]: gid {} completed metadata after unpause; deferring until the payload child is mapped", - id_clone, - gid_clone - ); - return; - } - queue_manager - .apply_completion_locked_with_progress( - &id_clone, - crate::queue::PendingOutcome::Complete, - status_after_unpause_progress, - ) - .await; - return; - } - "error" | "removed" => { - let terminal_error = format!( - "aria2 resume left gid {gid_clone} in terminal state {status_after_unpause}" - ); - queue_manager - .apply_completion_locked_with_progress( - &id_clone, - crate::queue::PendingOutcome::Error(terminal_error), - status_after_unpause_progress, - ) - .await; - return; - } - "paused" => { - queue_manager.next_aria2_control_epoch(&id_clone).await; - queue_manager.cancel_aria2_retries(&id_clone).await; - queue_manager.release_permit(&id_clone).await; - let error = "aria2 kept the download paused after resume".to_string(); - log::error!( - "aria2 resume [{}]: {}; gid {} remains paused", - id_clone, - error, - gid_clone - ); - let mut event = crate::ipc::DownloadStateEvent::paused_with_error( - &id_clone, - error, - ); - if let Some(progress) = status_after_unpause_progress { - event = event.with_progress(progress); - } - let _ = app_handle_clone.emit("download-state", event); - return; - } - other => { - log::error!( - "aria2 resume [{}]: unpause left gid {} in unexpected state {}; retaining permit", - id_clone, - gid_clone, - other - ); - return; - } - } - let current_epoch = queue_manager .is_aria2_control_epoch_current(&id_clone, control_epoch) .await; @@ -5963,14 +4765,6 @@ async fn resume_download( .await; return; } - use tauri::Emitter; - let _ = app_handle_clone.emit( - "download-state", - crate::ipc::DownloadStateEvent::new( - &id_clone, - crate::ipc::DownloadStatus::Downloading, - ), - ); log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone); }); Ok(true) @@ -6036,9 +4830,6 @@ async fn resume_download( ) .await; if !parked && !queue_manager.has_active_permit(&id_clone).await { - queue_manager - .release_aria2_permit_candidate(&id_clone, lifecycle_generation) - .await; return; } } @@ -6060,23 +4851,6 @@ async fn resume_download( Ok(true) } "complete" | "error" | "removed" => { - if status == "complete" - && state - .queue_manager - .aria2_direct_magnet_needs_child_handoff(&id, &gid) - .await - { - state.queue_manager.emit_state( - id.clone(), - crate::ipc::DownloadStatus::Downloading, - ); - log::info!( - "aria2 resume [{}]: gid {} completed metadata; deferring re-enqueue until the payload child is mapped", - id, - gid - ); - return Ok(true); - } drop(control_guard); state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; @@ -6099,116 +4873,29 @@ async fn resume_download( } } -fn stale_lifecycle_cleanup_is_noop( - expected_generation: Option, - current_generation: Option, -) -> bool { - matches!( - (expected_generation, current_generation), - (Some(expected), Some(current)) if expected != current - ) -} - #[tauri::command] async fn remove_download( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, delete_assets: bool, preserve_resumable: Option, - asset_removal_policy: Option, - expected_lifecycle_generation: Option, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; 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 - // durable row while the lifecycle guard is held. A missing or malformed - // row cannot safely be classified as unfinished, so refuse asset removal - // before stopping anything when this policy is requested. - let permanent_if_unfinished_requested = delete_assets - && !preserve_resumable - && asset_removal_policy == Some(crate::ipc::DownloadAssetRemovalPolicy::PermanentIfUnfinished); - let expected_lifecycle_generation = expected_lifecycle_generation - .map(|generation| { - generation - .parse::() - .map_err(|_| "Invalid expected download lifecycle generation".to_string()) - }) - .transpose()?; - let mut control_guard = Some(state.queue_manager.acquire_aria2_control(&id).await); - let mut cleanup_control_guard: Option = 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 persisted = load_persisted_download_item( - &app_handle.state::(), - &id, - )?; - !matches!(persisted.status, crate::ipc::DownloadStatus::Completed) - } else { - false - }; + let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?; + let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; - let registered_lifecycle_generation = state + let media_lifecycle_generation = state .queue_manager .registered_lifecycle_generation(&id) - .await; - // A ready/staged row normally has no native owner yet. Keep this - // admission fact so a missing ownership record can only be treated as a - // no-op cleanup for that exact pre-admission lifecycle; an admitted row - // must continue to fail closed if its ownership record is missing. - let native_lifecycle_was_admitted = - active_kind.is_some() || registered_lifecycle_generation.is_some(); - if stale_lifecycle_cleanup_is_noop( - expected_lifecycle_generation, - registered_lifecycle_generation, - ) { - // A renderer cleanup worker may be delayed until after the old native - // lifecycle has already completed and a newer generation has claimed - // the same download id. It must never remove that newer owner. - return Ok(()); - } - if let Some(expected_generation) = expected_lifecycle_generation { - // Registration normally exists before a pending task is committed. - // If a delayed cleanup observes a transient registry/pending gap, - // remove only the exact expected task and do not perform a broad - // download removal against an unowned lifecycle. - state - .queue_manager - .remove_from_pending_for_generation(&id, expected_generation) - .await; - if registered_lifecycle_generation.is_none() { - return Ok(()); - } - } else if let Some(generation) = registered_lifecycle_generation { - state - .queue_manager - .remove_from_pending_for_generation(&id, generation) - .await; - } else { - state.queue_manager.remove_from_pending(&id).await; - } - let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default(); + .await + .unwrap_or_default(); + state.queue_manager.remove_from_pending(&id).await; let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { - if let Err(error) = state - .queue_manager - .reconcile_aria2_torrent_ownership(&id, gid) - .await - { - log::debug!( - "aria2 torrent ownership [{}]: could not resolve files before removal of gid {}: {}", - id, - gid, - error - ); - } // Keep the current epoch and mapping alive until daemon removal is // confirmed. If removal fails, terminal events from this lifecycle // must still be accepted so the permit and mapping can be cleaned up. @@ -6234,7 +4921,6 @@ async fn remove_download( } 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; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; log::info!("aria2 remove [{}]: gid {} stopped and forgotten", id, gid); @@ -6242,7 +4928,7 @@ async fn remove_download( // There may be an addUri or retry handoff in flight with no mapped // GID yet. Invalidate that pending lifecycle before acknowledging the // removal so its late result cannot resurrect the download. - let removal_epoch = state.queue_manager.next_aria2_control_epoch(&id).await; + state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { @@ -6261,169 +4947,9 @@ async fn remove_download( .await; } - // The initial addUri call intentionally runs outside the control lock. - // Do not delete the guessed magnet path and ownership record until a - // late GID has been removed; otherwise the daemon can finish creating - // an output after cleanup and leave an untracked file behind. - drop(control_guard.take()); - state.queue_manager.wait_for_aria2_dispatch(&id).await; - let reacquired_control_guard = state.queue_manager.acquire_aria2_control(&id).await; - let current_generation = state - .queue_manager - .registered_lifecycle_generation(&id) - .await; - let current_active_kind = state.queue_manager.active_kind(&id).await; - let lifecycle_changed = registered_lifecycle_generation.is_some() - && current_generation != registered_lifecycle_generation - || registered_lifecycle_generation.is_none() && current_generation.is_some(); - let late_native_lifecycle = !native_lifecycle_was_admitted - && current_active_kind.is_some(); - if lifecycle_changed || late_native_lifecycle { - state - .queue_manager - .release_permit_for_generation(&id, media_lifecycle_generation) - .await; - state - .queue_manager - .clear_aria2_allocation_for_epoch(&id, removal_epoch) - .await; - return Err("download lifecycle changed while waiting for aria2 dispatch".to_string()); - } - if let Some(late_gid) = state.queue_manager.aria2_gid_for_download(&id) { - if let Err(error) = state - .queue_manager - .reconcile_aria2_torrent_ownership(&id, &late_gid) - .await - { - log::debug!( - "aria2 remove [{}]: could not resolve files for late gid {}: {}", - id, - late_gid, - error - ); - } - let removal_result = async { - force_remove_aria2_gid( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - &late_gid, - ) - .await?; - wait_for_aria2_stopped( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - &late_gid, - ) - .await - } - .await; - if let Err(error) = removal_result { - state.queue_manager.allow_aria2_retries(&id).await; - return Err(error); - } - state.queue_manager.next_aria2_control_epoch(&id).await; - state.queue_manager.clear_aria2_retry_state(&id).await; - state.queue_manager.forget_torrent_telemetry(&id).await; - state.queue_manager.forget_aria2_gid(&id).await; - } else if !state - .queue_manager - .is_aria2_control_epoch_current(&id, removal_epoch) - .await - { - // A same-generation pause/resume control action may advance the - // epoch while the late add is being retired. Removal still owns - // the registered row, so fence that action before cleaning it. - state.queue_manager.next_aria2_control_epoch(&id).await; - } state.queue_manager.release_permit(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; - state.queue_manager.forget_torrent_telemetry(&id).await; state.queue_manager.forget_aria2_gid(&id).await; - cleanup_control_guard = Some(reacquired_control_guard); - } - - // Keep lifecycle ownership fenced while the exact owned paths and the - // durable ownership record are inspected and cleaned. The no-GID branch - // already reacquired this guard after waiting for a late dispatch; the - // mapped-GID branch can reuse a fresh guard after the daemon stopped. - let _cleanup_control_guard = match cleanup_control_guard { - Some(guard) => guard, - None => { - drop(control_guard.take()); - state.queue_manager.acquire_aria2_control(&id).await - } - }; - - 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 = - crate::download_ownership::torrent_removal_paths_for_id(&app_handle, &id)?; - - let preserve_assets = preserve_resumable - && (owned_paths.iter().any(|path| has_resumable_download_assets(path)) - || primary_path - .as_ref() - .is_some_and(|path| has_resumable_download_assets(path))); - - let requested_asset_cleanup = delete_assets && !preserve_assets; - let ownership_is_missing = owned_paths.is_empty() && primary_path.is_none(); - let (unowned_unadmitted_cleanup_is_noop, unowned_replacement_target) = - if requested_asset_cleanup && ownership_is_missing && torrent_removal_paths.is_empty() { - let persisted = - load_persisted_download_item(&app_handle.state::(), &id)?; - let replacement_target = inspect_unowned_replacement_target( - &app_handle, - &persisted, - native_lifecycle_was_admitted, - )?; - let expected_assets_present = - expected_unadmitted_download_assets_present(&app_handle, &persisted)?; - ( - should_skip_unowned_asset_cleanup( - persisted.status, - persisted.has_been_dispatched.unwrap_or(false), - native_lifecycle_was_admitted, - expected_assets_present, - ), - replacement_target, - ) - } else { - (false, None) - }; - let should_delete_assets = requested_asset_cleanup - && (!unowned_unadmitted_cleanup_is_noop || unowned_replacement_target.is_some()); - - if requested_asset_cleanup { - if ownership_is_missing - && !unowned_unadmitted_cleanup_is_noop - && unowned_replacement_target.is_none() - { - return Err( - "Cannot remove download files because Firelink ownership is unavailable" - .to_string(), - ); - } - - // A legacy row can still claim a path even when the newer ownership - // registry has a record for this id. Revalidate every exact target - // immediately before cleanup so a stale or colliding registry entry - // cannot delete another download's payload. - let mut ownership_targets = owned_paths.clone(); - if let Some(primary) = primary_path.as_ref() { - ownership_targets.push(primary.clone()); - } - ownership_targets.sort_by_key(|path| crate::platform::path_identity(path)); - ownership_targets.dedup_by(|left, right| crate::platform::paths_equal(left, right)); - for target in ownership_targets { - if let Some(owner) = crate::download_ownership::owner_for_path(&app_handle, &target)? { - if owner != id { - return Err(format!( - "Cannot remove download files because '{}' is owned by another Firelink download", - target.display() - )); - } - } - } } use tauri::Emitter; @@ -6432,85 +4958,26 @@ async fn remove_download( crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); - // Asset cleanup and exact replacement share the same target namespace. - // Hold every affected target lock in a stable order until the ownership - // record is removed, so another download cannot claim a path between - // deleting its payload/sidecars and releasing Firelink ownership. - let mut cleanup_target_guards = Vec::new(); - if should_delete_assets { - let mut cleanup_targets = owned_paths.clone(); - if let Some(primary) = primary_path - .as_ref() - .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) - { - cleanup_targets.push(primary.clone()); - } - if let Some(replacement) = unowned_replacement_target.as_ref() { - cleanup_targets.push(replacement.target.clone()); - } - cleanup_targets.sort_by_key(|path| crate::platform::path_identity(path)); - cleanup_targets.dedup_by(|left, right| crate::platform::paths_equal(left, right)); - for target in cleanup_targets { - cleanup_target_guards.push(download_target_lock(&target).await.lock_owned().await); - } - } + let preserve_assets = preserve_resumable + && primary_path + .as_deref() + .is_some_and(has_resumable_download_assets); let cleanup_result = async { - if should_delete_assets { - for path in &owned_paths { - if permanent_asset_removal { - remove_download_assets_permanently(path, &app_handle).await?; - } else { - remove_download_assets(path, &app_handle).await?; - } + if delete_assets && !preserve_assets { + if let Some(path) = primary_path.as_deref() { + remove_download_assets(path, &app_handle).await?; } - if let Some(primary) = primary_path - .as_ref() - .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) - { - if permanent_asset_removal { - remove_download_container_assets_permanently(primary, &app_handle).await?; - } else { - remove_download_container_assets(primary, &app_handle).await?; - } - } - if let Some(replacement) = unowned_replacement_target.as_ref() { - remove_unowned_replacement_target( - &replacement.target, - &replacement.fingerprint, - &replacement.download_id, - permanent_asset_removal, - &app_handle, - ) - .await?; - } - } - if permanent_asset_removal { - 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)?; Ok::<(), String>(()) } .await; - drop(cleanup_target_guards); state.queue_manager.release_registered_id(&id).await; cleanup_result } -#[tauri::command] -fn get_download_primary_path( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - id: String, -) -> Result, String> { - properties_window::ensure_main_window(&caller)?; - crate::download_ownership::primary_path_for_id(&app_handle, &id) - .map(|path| path.map(|path| path.to_string_lossy().to_string())) -} - fn has_resumable_download_assets(primary: &std::path::Path) -> bool { [".aria2", ".part", ".ytdl"].iter().any(|suffix| { let mut candidate = primary.as_os_str().to_os_string(); @@ -6519,381 +4986,6 @@ fn has_resumable_download_assets(primary: &std::path::Path) -> bool { }) } -/// A ready/staged/paused row can be removed before native admission has established -/// an ownership record. Treat Delete file as a no-op only when the row is -/// still pre-admission and no exact output artifact can be found. Never use -/// this path for an admitted lifecycle: a missing ownership record there is -/// an integrity failure and must remain fail-closed. -fn should_skip_unowned_asset_cleanup( - status: crate::ipc::DownloadStatus, - has_been_dispatched: bool, - native_lifecycle_was_admitted: bool, - expected_assets_present: bool, -) -> bool { - matches!( - status, - crate::ipc::DownloadStatus::Ready - | crate::ipc::DownloadStatus::Staged - // pause_download can intentionally park a staged row without - // ever creating a native lifecycle. Treat that representation as - // pre-admission only when the durable dispatch marker is false; - // admitted paused Torrents still require ownership. - | crate::ipc::DownloadStatus::Paused - ) && !has_been_dispatched - && !native_lifecycle_was_admitted - && !expected_assets_present -} - -#[derive(Debug, Clone)] -struct UnownedReplacementTarget { - download_id: String, - target: std::path::PathBuf, - fingerprint: String, -} - -fn validate_unowned_replacement_target( - app_handle: &tauri::AppHandle, - target: &std::path::Path, - expected_fingerprint: &str, - metadata: &std::fs::Metadata, - allowed_legacy_owner_id: &str, -) -> Result<(), String> { - if metadata_is_link_or_reparse(metadata) || path_has_symlink_component(target) { - return Err(format!( - "Cannot remove replacement target '{}' because it is a symbolic link", - target.display() - )); - } - if !metadata.is_file() { - return Err(format!( - "Cannot remove replacement target '{}' because it is not a regular file", - target.display() - )); - } - if !is_safe_path(target, app_handle) { - return Err(format!( - "Download replacement target '{}' is outside an approved location", - target.display() - )); - } - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { - // owner_for_path also considers the persisted download row for - // compatibility with databases created before the native ownership - // registry. A staged replacement is expected to be its own legacy - // owner until native admission establishes the real ownership record; - // every other owner remains a hard conflict. - if owner != allowed_legacy_owner_id { - return Err(format!( - "Cannot remove replacement target because it is owned by Firelink download {owner}" - )); - } - } - if target_fingerprint(target, metadata) != expected_fingerprint { - return Err( - "The replacement target changed after duplicate resolution. Reopen the conflict and choose Replace again." - .to_string(), - ); - } - Ok(()) -} - -fn inspect_unowned_replacement_target( - app_handle: &tauri::AppHandle, - persisted: &crate::ipc::DownloadItem, - native_lifecycle_was_admitted: bool, -) -> Result, String> { - if native_lifecycle_was_admitted - || persisted.has_been_dispatched != Some(false) - || !matches!( - persisted.status, - crate::ipc::DownloadStatus::Ready | crate::ipc::DownloadStatus::Staged - ) - || persisted.is_torrent == Some(true) - { - return Ok(None); - } - - let Some(expected_fingerprint) = persisted - .replace_existing_fingerprint - .as_deref() - .filter(|fingerprint| !fingerprint.is_empty()) - else { - return Ok(None); - }; - let Some(destination) = persisted - .destination - .as_deref() - .map(str::trim) - .filter(|destination| !destination.is_empty()) - else { - return Ok(None); - }; - let target = crate::download_ownership::expected_primary_path( - app_handle, - destination, - &persisted.file_name, - )?; - let metadata = match std::fs::symlink_metadata(&target) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // A missing target is safe to treat as an idempotent cleanup, but - // an ownership record for that exact path still blocks the - // unowned replacement exception. - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, &target)? { - if owner != persisted.id { - return Err(format!( - "Cannot remove replacement target because it is owned by Firelink download {owner}" - )); - } - } - return Ok(None); - } - Err(error) => return Err(format!("Could not inspect replacement target: {error}")), - }; - - validate_unowned_replacement_target( - app_handle, - &target, - expected_fingerprint, - &metadata, - &persisted.id, - )?; - Ok(Some(UnownedReplacementTarget { - download_id: persisted.id.clone(), - target, - fingerprint: expected_fingerprint.to_string(), - })) -} - -async fn remove_unowned_replacement_target( - target: &std::path::Path, - expected_fingerprint: &str, - allowed_legacy_owner_id: &str, - permanent: bool, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - for attempt in 0..=5 { - let metadata = match tokio::fs::symlink_metadata(target).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "Could not inspect replacement target '{}': {error}", - target.display() - )); - } - }; - validate_unowned_replacement_target( - app_handle, - target, - expected_fingerprint, - &metadata, - allowed_legacy_owner_id, - )?; - - if permanent { - match tokio::fs::remove_file(target).await { - Ok(()) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) if attempt == 5 => { - return Err(format!( - "Could not permanently remove replacement target '{}' after retries: {error}", - target.display() - )); - } - Err(_) => {} - } - } else { - let remove_result = match trash::delete(target) { - Ok(()) => Ok(()), - Err(error) => { - log::warn!( - "failed to move authorized replacement target to Trash, attempting hard delete: {}", - error - ); - let current_metadata = match std::fs::symlink_metadata(target) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "Could not inspect replacement target '{}': {error}", - target.display() - )); - } - }; - validate_unowned_replacement_target( - app_handle, - target, - expected_fingerprint, - ¤t_metadata, - allowed_legacy_owner_id, - )?; - match std::fs::remove_file(target) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => Err(error.to_string()), - } - } - }; - match remove_result { - Ok(()) => return Ok(()), - Err(error) if attempt == 5 => { - return Err(format!( - "Could not remove replacement target '{}' after retries: {error}", - target.display() - )); - } - Err(_) => {} - } - } - - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - Ok(()) -} - -fn expected_unadmitted_download_assets_present( - app_handle: &tauri::AppHandle, - persisted: &crate::ipc::DownloadItem, -) -> Result { - let Some(destination) = persisted - .destination - .as_deref() - .map(str::trim) - .filter(|destination| !destination.is_empty()) - else { - // Without a durable destination we cannot prove that no output - // exists, so preserve the ownership guard. - return Ok(true); - }; - - if persisted.is_torrent == Some(true) { - return expected_unadmitted_torrent_assets_present(app_handle, persisted, destination); - } - - let primary = crate::download_ownership::expected_primary_path( - app_handle, - destination, - &persisted.file_name, - )?; - if download_asset_or_sidecar_exists(&primary)? { - return Ok(true); - } - - if persisted.is_media == Some(true) { - let Some(base_name) = primary.file_name().and_then(|name| name.to_str()) else { - return Ok(true); - }; - let base_stem = primary - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or(base_name); - let has_stable_output_name = persisted.media_format_selector.is_some() - || std::path::Path::new(base_name).extension().is_some(); - if !has_stable_output_name { - // yt-dlp may choose a title/id/extension-derived name for this - // shape, so an absent guessed path is not proof of absence. - return Ok(true); - } - - let Some(parent) = primary.parent() else { - return Ok(true); - }; - let mut entries = match std::fs::read_dir(parent) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(_) => return Err("Could not inspect expected download assets".to_string()), - }; - while let Some(entry) = entries - .next() - .transpose() - .map_err(|_| "Could not inspect expected download assets".to_string())? - { - let path = entry.path(); - if crate::platform::paths_equal(&path, &primary) { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !is_media_artifact_name(name, base_name, base_stem) { - continue; - } - match std::fs::symlink_metadata(&path) { - Ok(_) => return Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(_) => return Err("Could not inspect expected download assets".to_string()), - } - } - } - - Ok(false) -} - -fn expected_unadmitted_torrent_assets_present( - app_handle: &tauri::AppHandle, - persisted: &crate::ipc::DownloadItem, - destination: &str, -) -> Result { - let Some(torrent_path) = persisted.torrent_path.as_deref() else { - // A ready/staged/paused magnet without cached metainfo has not crossed - // native admission, so this lifecycle cannot have created a Torrent - // payload. Do not guess from the display name and risk treating an - // unrelated file as this download's asset. - return Ok(false); - }; - - let Some(expected) = expected_torrent_output_paths( - app_handle, - &persisted.id, - destination, - &persisted.file_name, - Some(torrent_path), - persisted.torrent_file_indices.as_deref(), - persisted.torrent_remove_unselected_file.unwrap_or(false), - )? - else { - return Ok(false); - }; - - // Include the primary output as well as every exact selected/removal path. - // For a multi-file Torrent the primary is the output directory, which - // catches an unowned file inside it without recursively inspecting or - // deleting that directory. - let mut candidates = vec![expected.primary]; - candidates.extend(expected.selected); - candidates.extend(expected.unselected); - for candidate in candidates { - if download_asset_or_sidecar_exists(&candidate)? { - return Ok(true); - } - } - Ok(false) -} - -fn download_asset_or_sidecar_exists(primary: &std::path::Path) -> Result { - for suffix in ["", ".aria2", ".part", ".ytdl"] { - let mut candidate = primary.as_os_str().to_os_string(); - candidate.push(suffix); - let candidate = std::path::PathBuf::from(candidate); - match std::fs::symlink_metadata(candidate) { - Ok(_) => return Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("Could not inspect expected download assets".to_string()), - } - } - Ok(false) -} - -fn primary_path_needs_container_cleanup( - primary: &std::path::Path, - owned_paths: &[std::path::PathBuf], -) -> bool { - !owned_paths - .iter() - .any(|owned| crate::platform::paths_equal(owned, primary)) -} - pub(crate) async fn remove_download_assets( primary: &std::path::Path, app_handle: &tauri::AppHandle, @@ -6906,15 +4998,7 @@ pub(crate) async fn remove_download_assets( let mut retries = 5; loop { let res = if primary.is_dir() { - if directory_has_non_metadata_entries(primary).await? { - return Err(format!( - "refusing to move non-empty download directory '{}' to Trash without explicit ownership", - primary.display() - )); - } - // Download directories must follow the same Trash contract as - // files. Do not fall back to recursive permanent deletion. - trash::delete(primary).map_err(|e| e.to_string()) + tokio::fs::remove_dir_all(primary).await.map_err(|e| e.to_string()) } else { if let Err(e) = trash::delete(primary) { log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e); @@ -6936,16 +5020,6 @@ pub(crate) async fn remove_download_assets( } } - remove_download_sidecars(primary, app_handle).await?; - - cleanup_media_processing_artifacts(primary).await; - Ok(()) -} - -async fn remove_download_sidecars( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { for suffix in [".aria2", ".part", ".ytdl"] { let mut candidate_os = primary.as_os_str().to_os_string(); candidate_os.push(suffix); @@ -6966,559 +5040,33 @@ async fn remove_download_sidecars( } } } - Ok(()) -} -async fn remove_exact_file_permanently( - path: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - let Some(expected_fingerprint) = exact_file_fingerprint_for_permanent_removal(path, app_handle).await? else { - return Ok(()); - }; - - for attempt in 0..=5 { - match exact_file_fingerprint_for_permanent_removal(path, app_handle).await? { - Some(current) if current == expected_fingerprint => {} - Some(_) => { - return Err(format!( - "refusing to permanently remove '{}' because it changed during cleanup", - path.display() - )); - } - None => return Ok(()), - } - match tokio::fs::remove_file(path).await { - Ok(()) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) if attempt == 5 => { - return Err(format!( - "could not permanently remove '{}' after retries: {error}", - path.display() - )); - } - Err(_) => { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - } - } - Ok(()) -} - -async fn exact_file_fingerprint_for_permanent_removal( - path: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result, String> { - let metadata = match tokio::fs::symlink_metadata(path).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("could not inspect '{}' before deletion: {error}", path.display())), - }; - if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(path) { - return Err(format!( - "refusing to permanently remove symbolic-link asset '{}'", - path.display() - )); - } - if !metadata.is_file() { - return Err(format!( - "refusing to permanently remove non-file asset '{}'", - path.display() - )); - } - if !is_safe_path(path, app_handle) { - return Err(format!( - "download asset path '{}' is outside an allowed download location", - path.display() - )); - } - Ok(Some(target_fingerprint(path, &metadata))) -} - -async fn validate_exact_file_for_permanent_removal( - path: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result { - Ok(exact_file_fingerprint_for_permanent_removal(path, app_handle) - .await? - .is_some()) -} - -async fn remove_download_sidecars_permanently( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - validate_download_sidecars_permanently(primary, app_handle).await?; - for suffix in [".aria2", ".part", ".ytdl"] { - let mut candidate_os = primary.as_os_str().to_os_string(); - candidate_os.push(suffix); - let candidate = std::path::PathBuf::from(candidate_os); - if validate_exact_file_for_permanent_removal(&candidate, app_handle).await? { - remove_exact_file_permanently(&candidate, app_handle).await?; - } - } - Ok(()) -} - -async fn validate_download_sidecars_permanently( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - for suffix in [".aria2", ".part", ".ytdl"] { - let mut candidate_os = primary.as_os_str().to_os_string(); - candidate_os.push(suffix); - let candidate = std::path::PathBuf::from(candidate_os); - let _ = validate_exact_file_for_permanent_removal(&candidate, app_handle).await?; - } - Ok(()) -} - -async fn collect_media_processing_artifacts_for_permanent_removal( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result, String> { - let Some(parent) = primary.parent() else { - return Ok(Vec::new()); - }; - if !is_safe_path(parent, app_handle) { - return Err("download media artifact directory is outside an allowed location".to_string()); - } - let Some(base_name) = primary.file_name().and_then(|name| name.to_str()) else { - return Ok(Vec::new()); - }; - let base_stem = primary - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or(base_name); - let mut entries = match tokio::fs::read_dir(parent).await { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(format!("could not inspect media artifacts: {error}")), - }; - let mut artifacts = Vec::new(); - while let Some(entry) = entries - .next_entry() - .await - .map_err(|error| format!("could not inspect media artifacts: {error}"))? - { - let path = entry.path(); - if crate::platform::paths_equal(&path, primary) { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !is_media_artifact_name(name, base_name, base_stem) { - continue; - } - validate_exact_file_for_permanent_removal(&path, app_handle).await?; - artifacts.push(path); - } - Ok(artifacts) -} - -async fn remove_download_assets_permanently( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - let artifacts = collect_media_processing_artifacts_for_permanent_removal(primary, app_handle).await?; - // Validate every exact sidecar before deleting the primary payload. A - // symlink or special-file substitution must leave the row/ownership - // intact rather than producing a partially-cleaned download silently. - validate_download_sidecars_permanently(primary, app_handle).await?; - validate_exact_file_for_permanent_removal(primary, app_handle).await?; - remove_exact_file_permanently(primary, app_handle).await?; - remove_download_sidecars_permanently(primary, app_handle).await?; - for artifact in artifacts { - remove_exact_file_permanently(&artifact, app_handle).await?; - } - Ok(()) -} - -async fn remove_download_container_assets_permanently( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - let metadata = match tokio::fs::symlink_metadata(primary).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // The output directory may already be gone while Aria2's exact - // sidecars remain. They are still safe to remove by name. - remove_download_sidecars_permanently(primary, app_handle).await?; - return Ok(()); - } - Err(error) => return Err(format!("could not inspect Torrent output directory: {error}")), - }; - if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(primary) { - return Err(format!( - "refusing to permanently remove symbolic-link Torrent directory '{}'", - primary.display() - )); - } - if !metadata.is_dir() { - return Err(format!( - "Torrent output container '{}' is not a directory", - primary.display() - )); - } - if !is_safe_path(primary, app_handle) { - return Err("Torrent output directory is outside an allowed download location".to_string()); - } - - // Validate sidecars before any exact entry is removed. The removal helper - // revalidates again under the lifecycle/target lock immediately before - // unlinking, so a later substitution still fails closed. - validate_download_sidecars_permanently(primary, app_handle).await?; - - // Inspect the complete tree without following links. If a real unowned - // entry is found, stop before deleting anything in the container. This - // preserves the whole user-owned subtree rather than trying to clean - // around it. - let mut pending = vec![(primary.to_path_buf(), false)]; - let mut empty_directories = Vec::new(); - let mut metadata_files = Vec::new(); - let mut has_unrelated_entries = false; - 'inspect: while let Some((directory, visited)) = pending.pop() { - if visited { - empty_directories.push(directory); - continue; - } - let mut entries = match tokio::fs::read_dir(&directory).await { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - continue; - } - Err(error) => { - return Err(format!( - "could not inspect Torrent output directory '{}': {error}", - directory.display() - )); - } - }; - let mut child_directories = Vec::new(); - while let Some(entry) = entries - .next_entry() - .await - .map_err(|error| format!("could not inspect Torrent output directory: {error}"))? - { - let path = entry.path(); - let entry_metadata = match tokio::fs::symlink_metadata(&path).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "could not inspect Torrent output entry '{}': {error}", - path.display() - )); - } - }; - if metadata_is_link_or_reparse(&entry_metadata) { - return Err(format!( - "refusing to remove Torrent output directory containing symbolic link '{}'", - path.display() - )); - } - if entry_metadata.is_dir() { - child_directories.push(path); - } else if is_os_directory_metadata(&entry.file_name()) { - if !entry_metadata.is_file() { - return Err(format!( - "refusing to remove non-file OS metadata entry '{}'", - path.display() - )); - } - validate_exact_file_for_permanent_removal(&path, app_handle).await?; - metadata_files.push(path); - } else { - has_unrelated_entries = true; - break 'inspect; - } - } - - pending.push((directory, true)); - pending.extend( - child_directories - .into_iter() - .rev() - .map(|child| (child, false)), - ); - } - - // Sidecars are still exact, independently owned assets, so remove them - // even when an unrelated entry means that the output container remains. - remove_download_sidecars_permanently(primary, app_handle).await?; - if has_unrelated_entries { - log::debug!( - "keeping Torrent output directory '{}': unrelated entries remain", - primary.display() - ); - return Ok(()); - } - - for path in metadata_files { - remove_exact_file_permanently(&path, app_handle).await?; - } - - // remove_dir is deliberately non-recursive. The visit markers put child - // directories before their parents, so a file or directory added after - // inspection makes that directory (and then its parents) fail closed - // without deleting newly-created unowned content. - for directory in &empty_directories { - let removed = remove_empty_directory_permanently(directory).await?; - if crate::platform::paths_equal(directory, primary) && !removed { - log::debug!( - "keeping Torrent output directory '{}': it became non-empty during cleanup", - primary.display() - ); - } - } - - if !empty_directories - .iter() - .any(|directory| crate::platform::paths_equal(directory, primary)) - { - log::debug!( - "keeping Torrent output directory '{}': it disappeared during cleanup", - primary.display() - ); - } - Ok(()) -} - -async fn remove_managed_torrent_permanently( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result<(), String> { - let path = crate::torrent::managed_torrent_path(app_handle, id)?; - let expected_fingerprint = match managed_torrent_metadata_fingerprint(&path, app_handle).await? { - Some(fingerprint) => fingerprint, - None => return Ok(()), - }; - - for attempt in 0..=5 { - let current_fingerprint = match managed_torrent_metadata_fingerprint(&path, app_handle).await? { - Some(fingerprint) => fingerprint, - None => return Ok(()), - }; - if current_fingerprint != expected_fingerprint { - return Err("managed Torrent metadata changed during cleanup".to_string()); - } - match tokio::fs::remove_file(&path).await { - Ok(()) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) if attempt == 5 => { - return Err(format!( - "could not permanently remove managed Torrent metadata after retries: {error}" - )); - } - Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await, - } - } - Ok(()) -} - -async fn managed_torrent_metadata_fingerprint( - path: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result, String> { - let metadata = match tokio::fs::symlink_metadata(path).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("could not inspect managed Torrent metadata: {error}")), - }; - let root = crate::torrent::managed_torrent_storage_root(app_handle)?; - if path_has_symlink_component(&root) || path_has_symlink_component(&path) { - return Err("refusing to permanently remove Torrent metadata through a symbolic link".to_string()); - } - let canonical_root = std::fs::canonicalize(&root) - .map_err(|error| format!("could not validate managed Torrent metadata storage: {error}"))?; - let canonical_parent = path - .parent() - .and_then(|parent| std::fs::canonicalize(parent).ok()) - .ok_or_else(|| "managed Torrent metadata storage is unavailable".to_string())?; - if !crate::platform::paths_equal(&canonical_root, &canonical_parent) { - return Err("managed Torrent metadata path is outside its app-owned storage".to_string()); - } - if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { - return Err("refusing to permanently remove non-regular managed Torrent metadata".to_string()); - } - Ok(Some(target_fingerprint(path, &metadata))) -} - -fn is_os_directory_metadata(name: &std::ffi::OsStr) -> bool { - matches!( - name.to_str(), - Some(".DS_Store" | ".localized" | "Icon\r" | "Thumbs.db" | "desktop.ini" | ".directory") - ) -} - -async fn directory_has_non_metadata_entries(path: &std::path::Path) -> Result { - let mut directories = vec![path.to_path_buf()]; - while let Some(directory) = directories.pop() { - let mut entries = match tokio::fs::read_dir(&directory).await { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "could not inspect directory '{}': {}", - directory.display(), - error - )); - } - }; - while let Some(entry) = entries.next_entry().await.map_err(|error| { - format!( - "could not inspect directory '{}': {}", - directory.display(), - error - ) - })? { - let metadata = match tokio::fs::symlink_metadata(entry.path()).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "could not inspect directory entry '{}': {}", - entry.path().display(), - error - )); - } - }; - if metadata_is_link_or_reparse(&metadata) { - return Ok(true); - } - if metadata.is_dir() { - directories.push(entry.path()); - } else if is_os_directory_metadata(&entry.file_name()) { - // These names are ignorable only for regular OS metadata - // files. A directory with a metadata-like name may contain - // real user content and must be inspected normally. - continue; - } else { - return Ok(true); - } - } - } - Ok(false) -} - -async fn remove_empty_directory_permanently(path: &std::path::Path) -> Result { - for attempt in 0..=5 { - match tokio::fs::remove_dir(path).await { - Ok(()) => return Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => { - return Ok(false); - } - Err(error) if attempt == 5 => { - return Err(format!( - "could not permanently remove empty Torrent directory '{}' after retries: {error}", - path.display() - )); - } - Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await, - } - } - Ok(false) -} - -async fn remove_download_container_assets( - primary: &std::path::Path, - app_handle: &tauri::AppHandle, -) -> Result<(), String> { - if !is_safe_path(primary, app_handle) { - return Err("Download asset path is outside an allowed download location".to_string()); - } - - // The primary path of a multi-file Torrent is its output directory, while - // owned_paths contains only the selected files. Remove the directory's - // Aria2 sidecars, then move the directory itself to Trash only when no - // unowned files remain inside it. - remove_download_sidecars(primary, app_handle).await?; - let is_directory = std::fs::symlink_metadata(primary) - .map(|metadata| metadata.file_type().is_dir()) - .unwrap_or(false); - if !is_directory { - return Ok(()); - } - if directory_has_non_metadata_entries(primary).await? { - log::debug!( - "keeping non-empty Torrent output directory '{}': it contains files outside the selected ownership set", - primary.display() - ); - return Ok(()); - } - - let mut retries = 5; - loop { - match trash::delete(primary) { - Ok(()) => break, - Err(error) => { - if directory_has_non_metadata_entries(primary).await? { - log::debug!( - "keeping non-empty Torrent output directory '{}': {}", - primary.display(), - error - ); - break; - } - if !primary.exists() { - return Ok(()); - } - if retries == 0 { - return Err(format!( - "failed to move Torrent output directory '{}' to Trash: {}", - primary.display(), - error - )); - } - retries -= 1; - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - } - } + cleanup_media_processing_artifacts(primary).await; Ok(()) } #[tauri::command] async fn detach_download_for_reconfigure( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { - 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; - detach_download_for_reconfigure_locked( - &app_handle, - state.inner(), - &id, - &control_guard, - ) - .await -} - -async fn detach_download_for_reconfigure_locked( - app_handle: &tauri::AppHandle, - state: &AppState, - id: &str, - _control_guard: &queue::Aria2ControlGuard, -) -> Result<(), String> { - let active_kind = state.queue_manager.active_kind(id).await; + let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let active_kind = state.queue_manager.active_kind(&id).await; let media_lifecycle_generation = state .queue_manager - .registered_lifecycle_generation(id) + .registered_lifecycle_generation(&id) .await .unwrap_or_default(); - state.queue_manager.remove_from_pending(id).await; - let gid = state.queue_manager.aria2_gid_for_download(id); + state.queue_manager.remove_from_pending(&id).await; + let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { // Do not invalidate the mapped lifecycle until the daemon confirms // the detach. A failed pause must leave terminal-event reconciliation // and permit ownership intact. - state.queue_manager.cancel_aria2_retries(id).await; + state.queue_manager.cancel_aria2_retries(&id).await; let removal_result = async { let pause_res = rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -7543,24 +5091,24 @@ async fn detach_download_for_reconfigure_locked( } .await; if let Err(error) = removal_result { - state.queue_manager.allow_aria2_retries(id).await; + state.queue_manager.allow_aria2_retries(&id).await; return Err(error); } - state.queue_manager.next_aria2_control_epoch(id).await; - state.queue_manager.clear_aria2_retry_state(id).await; - state.queue_manager.forget_aria2_gid(id).await; - state.queue_manager.release_permit(id).await; - state.queue_manager.release_registered_id(id).await; + state.queue_manager.next_aria2_control_epoch(&id).await; + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_permit(&id).await; + state.queue_manager.release_registered_id(&id).await; log::info!("aria2 detach [{}]: gid {} stopped and forgotten", id, gid); } else { // Invalidate a queued/addUri lifecycle that has not published a GID. - state.queue_manager.next_aria2_control_epoch(id).await; - state.queue_manager.cancel_aria2_retries(id).await; + state.queue_manager.next_aria2_control_epoch(&id).await; + state.queue_manager.cancel_aria2_retries(&id).await; let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state .download_coordinator - .pause_media_with_ack(id.to_string(), media_lifecycle_generation, tx) + .pause_media_with_ack(id.clone(), media_lifecycle_generation, tx) .await?; } else { let _ = tx.send(false); // Fallback if no task exists @@ -7569,20 +5117,20 @@ async fn detach_download_for_reconfigure_locked( if matches!(active_kind, Some(crate::queue::TaskKind::Media)) && !media_was_registered { state .download_coordinator - .finish_media(id.to_string(), media_lifecycle_generation) + .finish_media(id.clone(), media_lifecycle_generation) .await; } - state.queue_manager.release_permit(id).await; - state.queue_manager.clear_aria2_retry_state(id).await; - state.queue_manager.forget_aria2_gid(id).await; - state.queue_manager.release_registered_id(id).await; + state.queue_manager.release_permit(&id).await; + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_registered_id(&id).await; } use tauri::Emitter; let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), + crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); Ok(()) @@ -7602,34 +5150,6 @@ fn ensure_aria2_gid_result( } } -fn ensure_aria2_ok_result(method: &str, result: &serde_json::Value) -> Result<(), String> { - match result.as_str() { - Some("OK") => Ok(()), - Some(value) => Err(format!( - "aria2.{method} returned unexpected result {value}" - )), - None => Err(format!("aria2.{method} returned a non-string result")), - } -} - -const ARIA2_RECONCILIATION_STATUS_FIELDS: &[&str] = &[ - "status", - "seeder", - "errorCode", - "errorMessage", - "completedLength", - "totalLength", - "uploadLength", - // Direct magnets complete a metadata parent before Aria2 exposes the - // payload child. QueueManager validates this relationship before - // accepting a terminal event. - "followedBy", -]; - -fn aria2_reconciliation_status_params(gid: &str) -> serde_json::Value { - serde_json::json!([gid, ARIA2_RECONCILIATION_STATUS_FIELDS]) -} - async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result { let result = rpc_call( port, @@ -7646,87 +5166,6 @@ async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result Result<(String, Option), String> { - let result = rpc_call( - port, - secret, - "aria2.tellStatus", - serde_json::json!([gid, ["status", "completedLength", "totalLength"]]), - ) - .await - .map_err(|error| format!("failed to query aria2 gid {gid}: {error}"))?; - let status = result - .get("status") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}"))?; - let progress = aria2_download_state_progress(Some(&result)); - Ok((status, progress)) -} - -fn aria2_u64(value: Option<&serde_json::Value>) -> Option { - value.and_then(|value| { - value - .as_str() - .and_then(|value| value.parse::().ok()) - .or_else(|| value.as_u64()) - }) -} - -fn aria2_download_state_progress( - status: Option<&serde_json::Value>, -) -> Option { - let status = status?; - let downloaded = aria2_u64(status.get("completedLength")); - let total = aria2_u64(status.get("totalLength")); - if downloaded.is_none() && total.is_none() { - return None; - } - let is_complete = status.get("status").and_then(|value| value.as_str()) == Some("complete"); - let fraction = if is_complete { - 1.0 - } else if let Some(total) = total.filter(|total| *total > 0) { - (downloaded.unwrap_or_default().min(total) as f64 / total as f64).clamp(0.0, 1.0) - } else { - 0.0 - }; - let total_bytes = total.filter(|value| *value > 0).map(|value| value as f64); - Some(crate::ipc::DownloadStateProgress { - fraction, - downloaded_bytes: downloaded.map(|value| value as f64), - total_bytes, - total_is_estimate: total_bytes.map(|_| false), - }) -} - -/// Verify a retained-GID resume against Aria2's actual state. `unpause` can -/// return before a paused GID becomes observable as active, and a transient -/// tellStatus failure must not turn a successful resume into a false failure. -/// Retry only that ambiguous observation; terminal and active states return -/// immediately and remain authoritative. -async fn verify_aria2_resume_status_snapshot( - port: u16, - secret: &str, - gid: &str, -) -> Result<(String, Option), String> { - let mut last_observation = Err(format!("aria2 resume status for gid {gid} was not observed")); - for attempt in 0..4u32 { - last_observation = match aria2_download_status_snapshot(port, secret, gid).await { - Ok((status, progress)) if status != "paused" => return Ok((status, progress)), - Ok((status, progress)) => Ok((status, progress)), - Err(error) => Err(error), - }; - if attempt < 3 { - tokio::time::sleep(Duration::from_millis(25 * (1_u64 << attempt))).await; - } - } - last_observation -} - async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { let state = app_handle.state::(); let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); @@ -7738,7 +5177,7 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { port, &secret, "aria2.tellStatus", - aria2_reconciliation_status_params(&gid), + serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), ) .await { @@ -7784,17 +5223,6 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { let status_name = status.get("status").and_then(|value| value.as_str()); let outcome = match status_name { Some("complete") => Some(crate::queue::PendingOutcome::Complete), - Some("active") - if status.get("seeder").is_some_and(|value| { - value.as_str() == Some("true") || value.as_bool() == Some(true) - }) - && state - .queue_manager - .aria2_torrent_seeding_requested(&id) - .await => - { - Some(crate::queue::PendingOutcome::Seeding) - } Some("error") | Some("removed") => { let error_code = status .get("errorCode") @@ -7814,11 +5242,7 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { }; if let Some(outcome) = outcome { - let progress = aria2_download_state_progress(Some(&status)); - state - .queue_manager - .handle_aria2_event_with_progress(&gid, outcome, progress) - .await; + state.queue_manager.handle_aria2_event(&gid, outcome).await; } } } @@ -7928,13 +5352,11 @@ fn begin_dock_badge_session() -> u64 { #[tauri::command] #[allow(unused_variables)] fn update_dock_badge( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, count: i32, generation: u64, session: u64, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +) { #[cfg(target_os = "macos")] { use objc::runtime::Object; @@ -7972,41 +5394,29 @@ fn update_dock_badge( } }); } - Ok(()) } #[tauri::command] -fn get_platform_info( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, -) -> Result { - properties_window::ensure_main_window(&caller)?; - Ok(crate::ipc::PlatformInfo { +fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo { + crate::ipc::PlatformInfo { os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), target_triple: crate::platform::target_triple(), portable: state.storage_layout.is_portable(), - }) + } } #[tauri::command] -fn set_prevent_sleep( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - prevent: bool, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { state.power_manager.set_system_prevention(prevent) } #[tauri::command] fn set_power_preferences( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, prevent_system_sleep: bool, prevent_display_sleep: bool, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; state .power_manager .set_preferences(prevent_system_sleep, prevent_display_sleep) @@ -8026,32 +5436,20 @@ pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Resu } #[tauri::command] -async fn perform_system_action( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - action: crate::ipc::PostQueueAction, - force: bool, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - state.queue_manager.begin_system_action(force).await?; - let result = execute_system_action(action); - state.queue_manager.end_system_action(); - result +fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { + execute_system_action(action) } #[tauri::command] fn ack_schedule_trigger( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, action: String, key: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; crate::settings::update_settings_state(&app_handle, |state| match action.as_str() { "start" => { state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key)); - state.insert("schedulerTriggeredStartKey".to_string(), serde_json::json!("")); } "stop" => { state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key)); @@ -8064,7 +5462,6 @@ fn ack_schedule_trigger( if let Some(settings) = cached.as_mut() { if action == "start" { settings.scheduler_last_start_key = key; - settings.scheduler_triggered_start_key = None; } else { settings.scheduler_last_stop_key = key; } @@ -8078,11 +5475,9 @@ fn ack_schedule_trigger( #[tauri::command] async fn get_pending_order( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, queue_id: Option, ) -> Result, AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(state.queue_manager.pending_order(queue_id.as_deref()).await) } @@ -8115,1565 +5510,48 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S Ok(()) } -pub(crate) async fn validate_torrent_web_seed_destinations(seeds: &[String]) -> Result<(), String> { - for uri in seeds { - let parsed = reqwest::Url::parse(uri) - .map_err(|_| "Torrent web-seed URI is invalid".to_string())?; - if !matches!(parsed.scheme(), "http" | "https") - || parsed.host_str().is_none_or(str::is_empty) - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - { - return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string()); - } - resolve_and_validate_url_host(&parsed).await?; - } - Ok(()) -} - -/// Embedded web seeds are optional Torrent accelerators, not the transfer's -/// source of truth. Keep the peer-only path usable when a stale seed domain is -/// temporarily unavailable, while still rejecting destinations that resolve -/// to local or private addresses before they are offered to Aria2. -pub(crate) async fn filter_torrent_web_seed_destinations( - seeds: &[String], -) -> Result, String> { - let mut allowed = Vec::with_capacity(seeds.len()); - for seed in seeds { - let parsed = reqwest::Url::parse(seed) - .map_err(|_| "Torrent web-seed URI is invalid".to_string())?; - if !matches!(parsed.scheme(), "http" | "https") - || parsed.host_str().is_none_or(str::is_empty) - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - { - return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string()); - } - match resolve_and_validate_url_host(&parsed).await { - Ok(_) => allowed.push(seed.clone()), - Err(error) - if error == "SSRF blocked: DNS resolution failed" - || error == "SSRF blocked: DNS resolution timed out" - || error == "SSRF blocked: No DNS records" => - { - log::warn!( - "Skipping unavailable embedded Torrent web seed {}", - parsed.host_str().unwrap_or("") - ); - } - Err(error) => return Err(error), - } - } - Ok(allowed) -} - -async fn validate_torrent_enqueue( - app_handle: &tauri::AppHandle, - item: &mut queue::EnqueueItem, -) -> Result<(), String> { - item.strip_torrent_credentials(); - if item.is_media.unwrap_or(false) { - return Err("torrent transfer cannot be a media download".to_string()); - } - crate::torrent::validate_output_name(&item.filename)?; - item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?; - item.torrent_exclude_trackers = - queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?; - item.torrent_tracker_connect_timeout = queue::normalize_torrent_tracker_connect_timeout( - item.torrent_tracker_connect_timeout, - )?; - item.torrent_tracker_timeout = - queue::normalize_torrent_tracker_request_timeout(item.torrent_tracker_timeout)?; - item.torrent_tracker_interval = - queue::normalize_torrent_tracker_interval(item.torrent_tracker_interval)?; - item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?; - item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece( - item.torrent_prioritize_piece.as_deref(), - )?; - item.torrent_encryption_policy = queue::normalize_torrent_encryption_policy( - item.torrent_encryption_policy.as_deref(), - )?; - let legacy_web_seeds = queue::normalize_torrent_mirror_uris(item.mirrors.as_deref())?; - validate_torrent_web_seed_destinations(&legacy_web_seeds).await?; - item.mirrors = (!legacy_web_seeds.is_empty()).then_some(legacy_web_seeds.join("\n")); - if let Some(path) = item.torrent_path.as_deref() { - let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes_sync(&path) - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let normalized_user_web_seeds = queue::normalize_torrent_web_seeds( - item.torrent_web_seeds.as_deref(), - &metadata.files, - )?; - let user_web_seed_uris = normalized_user_web_seeds - .iter() - .map(|seed| seed.uri.clone()) - .collect::>(); - validate_torrent_web_seed_destinations(&user_web_seed_uris).await?; - item.torrent_web_seeds = (!normalized_user_web_seeds.is_empty()) - .then_some(normalized_user_web_seeds); - crate::torrent::validate_info_hash( - item.torrent_info_hash.as_deref(), - &metadata.info_hash, - )?; - let selected = crate::torrent::validate_selected_indices( - item.torrent_file_indices.as_deref(), - metadata.files.len(), - )?; - item.torrent_file_indices = selected.clone(); - if item.torrent_remove_unselected_file.unwrap_or(false) { - let Some(selected) = selected else { - return Err( - "removing unselected Torrent files requires selecting a subset of files" - .to_string(), - ); - }; - if selected.len() >= metadata.files.len() { - return Err( - "removing unselected Torrent files requires at least one unselected file" - .to_string(), - ); - } - } - return Ok(()); - } - - let parsed = url::Url::parse(&item.url).map_err(|_| "invalid magnet URI".to_string())?; - if parsed.scheme() != "magnet" { - return Err("torrent transfer has no magnet URI or cached metadata".to_string()); - } - if item.torrent_file_indices.is_some() { - return Err("magnet file selection requires resolved torrent metadata".to_string()); - } - if item.torrent_remove_unselected_file.unwrap_or(false) { - return Err( - "removing unselected Torrent files requires resolved torrent metadata".to_string(), - ); - } - let metadata = crate::torrent::inspect_source(&item.url)?; - crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash) -} - -struct ExpectedTorrentOutputPaths { - primary: std::path::PathBuf, - selected: Vec, - unselected: Vec, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct DownloadOwnershipSnapshot { - primary: Option, - owned: Vec, - removal: Vec, -} - -fn snapshot_download_ownership( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result { - Ok(DownloadOwnershipSnapshot { - primary: crate::download_ownership::primary_path_for_id(app_handle, id)?, - owned: crate::download_ownership::owned_paths_for_id(app_handle, id)?, - removal: crate::download_ownership::torrent_removal_paths_for_id(app_handle, id)?, - }) -} - -fn restore_download_ownership( - app_handle: &tauri::AppHandle, - id: &str, - snapshot: DownloadOwnershipSnapshot, -) -> Result<(), String> { - let Some(primary) = snapshot.primary else { - return crate::download_ownership::remove(app_handle, id); - }; - let owned = if snapshot.owned.is_empty() { - vec![primary.clone()] - } else { - snapshot.owned - }; - crate::download_ownership::set_owned_paths_with_primary_and_removal( - app_handle, - id, - &primary, - &owned, - &snapshot.removal, - ) -} - -fn restore_download_replacement_ownership( - app_handle: &tauri::AppHandle, - id: &str, - previous: Option, -) -> Result<(), String> { - match previous { - Some(snapshot) => restore_download_ownership(app_handle, id, snapshot), - // Journals created before the ownership snapshot was added can only - // be recovered safely by dropping the temporary ownership record. - None => crate::download_ownership::remove(app_handle, id), - } -} - -fn expected_torrent_output_paths( - app_handle: &tauri::AppHandle, - id: &str, - destination: &str, - filename: &str, - torrent_path: Option<&str>, - torrent_file_indices: Option<&[u32]>, - torrent_remove_unselected_file: bool, -) -> Result, String> { - let Some(torrent_path) = torrent_path else { - return Ok(None); - }; - let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, id, torrent_path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes_sync(&torrent_path) - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let selected = crate::torrent::validate_selected_indices( - torrent_file_indices, - metadata.files.len(), - )?; - let destination = crate::resolve_path(destination, app_handle); - if !crate::is_safe_path(&destination, app_handle) { - return Err("Path traversal blocked".to_string()); - } - let canonical_destination = crate::canonicalize_with_missing_components(&destination) - .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; - let selected_relative = crate::torrent::aria2_output_paths( - &metadata, - selected.as_deref(), - filename, - ); - let resolve_paths = |relative_paths: Vec| -> Result, String> { - let mut paths = Vec::new(); - for relative in relative_paths { - let relative = std::path::PathBuf::from(relative); - if relative.is_absolute() - || relative.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir | std::path::Component::CurDir - ) - }) - { - return Err("torrent output path is unsafe".to_string()); - } - let path = destination.join(relative); - let canonical_path = crate::canonicalize_with_missing_components(&path) - .ok_or_else(|| "torrent output path could not be canonicalized".to_string())?; - if !crate::platform::path_is_within(&canonical_path, &canonical_destination) { - return Err("torrent output path is outside its destination".to_string()); - } - paths.push(canonical_path); - } - Ok(paths) - }; - let selected_paths = resolve_paths(selected_relative)?; - let unselected_paths = if torrent_remove_unselected_file { - let selected_indices = selected - .as_deref() - .ok_or_else(|| "torrent file selection is required for unselected-file removal".to_string())?; - let selected_indices = selected_indices.iter().copied().collect::>(); - let unselected_relative = metadata - .files - .iter() - .filter(|file| !selected_indices.contains(&file.index)) - .map(|file| { - if metadata.files.len() == 1 { - file.path.clone() - } else { - format!("{}/{}", filename, file.path) - } - }) - .collect::>(); - let paths = resolve_paths(unselected_relative)?; - if paths.iter().any(|path| { - std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) - }) { - return Err("unselected Torrent output path is a directory".to_string()); - } - paths - } else { - Vec::new() - }; - let primary = if metadata.files.len() == 1 { - selected_paths - .first() - .cloned() - .ok_or_else(|| "Torrent selection produced no output path".to_string())? - } else { - resolve_paths(vec![filename.to_string()])? - .into_iter() - .next() - .ok_or_else(|| "Torrent output name is invalid".to_string())? - }; - Ok(Some(ExpectedTorrentOutputPaths { - primary, - selected: selected_paths, - unselected: unselected_paths, - })) -} - -/// Register the complete output ownership for one enqueue in a single -/// ownership transaction. In particular, do not first register only the -/// primary path and then add Torrent paths: a crash between those writes can -/// leave a partial ownership record that no longer describes the queued task. -fn register_download_ownership( - app_handle: &tauri::AppHandle, - item: &queue::EnqueueItem, -) -> Result<(), String> { - let output_paths = enqueue_output_paths(app_handle, item)?; - ensure_enqueue_output_paths_unclaimed(app_handle, &item.id, &output_paths)?; - if item.is_torrent.unwrap_or(false) { - return register_torrent_output_ownership( - app_handle, - &item.id, - &item.destination, - &item.filename, - item.torrent_path.as_deref(), - item.torrent_file_indices.as_deref(), - item.torrent_remove_unselected_file.unwrap_or(false), - ); - } - let primary = crate::download_ownership::expected_primary_path( - app_handle, - &item.destination, - &item.filename, - )?; - set_download_output_ownership( - app_handle, - &item.id, - primary.clone(), - vec![primary], - Vec::new(), - ) -} - -fn enqueue_output_paths( - app_handle: &tauri::AppHandle, - item: &queue::EnqueueItem, -) -> Result, String> { - if item.is_torrent.unwrap_or(false) { - if let Some(paths) = expected_torrent_output_paths( - app_handle, - &item.id, - &item.destination, - &item.filename, - item.torrent_path.as_deref(), - item.torrent_file_indices.as_deref(), - item.torrent_remove_unselected_file.unwrap_or(false), - )? { - let mut output_paths = Vec::with_capacity( - 1 + paths.selected.len() + paths.unselected.len(), - ); - output_paths.push(paths.primary); - output_paths.extend(paths.selected); - output_paths.extend(paths.unselected); - return Ok(output_paths); - } - } - - Ok(vec![crate::download_ownership::expected_primary_path( - app_handle, - &item.destination, - &item.filename, - )?]) -} - -fn enqueue_output_lock_path( - app_handle: &tauri::AppHandle, - item: &queue::EnqueueItem, -) -> Result { - enqueue_output_paths(app_handle, item)? - .into_iter() - .next() - .ok_or_else(|| "Download enqueue produced no output path".to_string()) -} - -fn ensure_enqueue_output_paths_unclaimed( - app_handle: &tauri::AppHandle, - id: &str, - paths: &[std::path::PathBuf], -) -> Result<(), String> { - let mut checked = Vec::new(); - for path in paths { - if checked - .iter() - .any(|existing: &std::path::PathBuf| crate::platform::paths_equal(existing, path)) - { - continue; - } - checked.push(path.clone()); - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, path)? { - if owner != id { - return Err(format!( - "Download destination is already owned by Firelink download {owner}" - )); - } - } - } - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct DownloadReplacementJournal { - id: String, - target: std::path::PathBuf, - quarantine: std::path::PathBuf, - fingerprint: String, - phase: String, - #[serde(default)] - previous_ownership: Option, -} - -#[derive(Debug, Clone)] -struct DownloadReplacementReservation { - journal_path: std::path::PathBuf, - target: std::path::PathBuf, - quarantine: std::path::PathBuf, - fingerprint: String, -} - -fn safe_replacement_component(value: &str) -> String { - value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { - character - } else { - '-' - } - }) - .collect() -} - -async fn write_download_replacement_journal( - path: &std::path::Path, - journal: &DownloadReplacementJournal, -) -> Result<(), String> { - let bytes = serde_json::to_vec_pretty(journal) - .map_err(|_| "Could not encode download replacement recovery".to_string())?; - crate::platform::atomic_write_replace(path, &bytes) - .await - .map_err(|_| "Could not commit download replacement recovery".to_string()) -} - -async fn mark_download_replacement_admitted( - reservation: &DownloadReplacementReservation, -) -> Result<(), String> { - let bytes = tokio::fs::read(&reservation.journal_path) - .await - .map_err(|error| format!("Could not read download replacement recovery: {error}"))?; - let mut journal: DownloadReplacementJournal = serde_json::from_slice(&bytes) - .map_err(|_| "Could not decode download replacement recovery".to_string())?; - if journal.target != reservation.target - || journal.quarantine != reservation.quarantine - || journal.fingerprint != reservation.fingerprint - { - return Err("Download replacement recovery no longer matches its reservation".to_string()); - } - journal.phase = "admitted".to_string(); - write_download_replacement_journal(&reservation.journal_path, &journal).await -} - -async fn restore_download_replacement( - reservation: &DownloadReplacementReservation, -) -> Result<(), String> { - let quarantine_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err( - "Cannot restore replacement because its quarantine is missing; recovery journal retained" - .to_string(), - ); - } - Err(error) => return Err(format!("Could not inspect replacement quarantine: {error}")), - }; - if metadata_is_link_or_reparse(&quarantine_metadata) || !quarantine_metadata.is_file() { - return Err("Cannot restore replacement quarantine because it is not a regular file".to_string()); - } - if target_fingerprint(&reservation.quarantine, &quarantine_metadata) != reservation.fingerprint { - return Err("Cannot restore replacement quarantine because its contents changed".to_string()); - } - if tokio::fs::symlink_metadata(&reservation.target).await.is_ok() { - return Err(format!( - "Cannot restore replaced target '{}': the destination changed while admission was in progress", - reservation.target.display() - )); - } - restore_quarantine_file_without_replacing(reservation, "replaced target").await -} - -async fn restore_staged_replacement_after_validation_failure( - reservation: &DownloadReplacementReservation, -) -> Result<(), String> { - let quarantine_metadata = tokio::fs::symlink_metadata(&reservation.quarantine) - .await - .map_err(|error| format!("Could not inspect staged replacement file: {error}"))?; - if metadata_is_link_or_reparse(&quarantine_metadata) || !quarantine_metadata.is_file() { - return Err("Cannot restore staged replacement because it is not a regular file".to_string()); - } - if tokio::fs::symlink_metadata(&reservation.target).await.is_ok() { - return Err(format!( - "Cannot restore staged replacement '{}': the destination changed while admission was in progress", - reservation.target.display() - )); - } - restore_quarantine_file_without_replacing(reservation, "staged replacement").await -} - -async fn restore_quarantine_file_without_replacing( - reservation: &DownloadReplacementReservation, - description: &str, -) -> Result<(), String> { - match tokio::fs::hard_link(&reservation.quarantine, &reservation.target).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - return Err(format!( - "Could not restore {description}: the destination changed while admission was in progress" - )); - } - Err(error) => { - return Err(format!("Could not restore {description}: {error}")); - } - } - tokio::fs::remove_file(&reservation.quarantine) - .await - .map_err(|error| format!("Could not clear {description} quarantine: {error}"))?; - tokio::fs::remove_file(&reservation.journal_path) - .await - .map_err(|error| format!("Could not clear replacement recovery: {error}")) -} - -fn restore_quarantine_file_without_replacing_sync( - quarantine: &std::path::Path, - target: &std::path::Path, -) -> Result<(), String> { - if std::fs::symlink_metadata(target).is_ok() { - return Err("the destination changed while admission was in progress".to_string()); - } - match std::fs::hard_link(quarantine, target) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - return Err("the destination changed while admission was in progress".to_string()); - } - Err(error) => return Err(format!("could not restore quarantined file: {error}")), - } - std::fs::remove_file(quarantine) - .map_err(|error| format!("could not clear quarantined file: {error}")) -} - -async fn finalize_download_replacement( - reservation: &DownloadReplacementReservation, -) { - let quarantine_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { - Ok(metadata) if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() => { - log::warn!( - "download replacement cleanup [{}]: retained recovery journal because quarantine is not a regular file", - reservation.target.display() - ); - return; - } - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let _ = tokio::fs::remove_file(&reservation.journal_path).await; - return; - } - Err(error) => { - log::warn!( - "download replacement cleanup [{}]: retained recovery journal because quarantine could not be inspected: {}", - reservation.target.display(), - error - ); - return; - } - }; - if target_fingerprint(&reservation.quarantine, &quarantine_metadata) != reservation.fingerprint { - log::warn!( - "download replacement cleanup [{}]: retained recovery journal because quarantine changed", - reservation.target.display() - ); - return; - } - match tokio::fs::remove_file(&reservation.quarantine).await { - Ok(()) => { - let _ = tokio::fs::remove_file(&reservation.journal_path).await; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let _ = tokio::fs::remove_file(&reservation.journal_path).await; - } - Err(error) => { - log::warn!( - "download replacement cleanup [{}]: retained recovery journal after quarantine removal failed: {}", - reservation.target.display(), - error - ); - } - } -} - -async fn prepare_download_replacement( - app_handle: &tauri::AppHandle, - state: &AppState, - item: &queue::EnqueueItem, - target: &std::path::Path, - lifecycle_generation: u64, - previous_ownership: &DownloadOwnershipSnapshot, -) -> Result, String> { - let Some(expected_fingerprint) = item.replace_existing_fingerprint.as_deref() else { - return Ok(None); - }; - if item.is_torrent.unwrap_or(false) { - return Err("Exact replacement is not supported for Torrent output directories".to_string()); - } - - let metadata = match std::fs::symlink_metadata(target) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("Could not inspect replacement target: {error}")), - }; - if metadata_is_link_or_reparse(&metadata) { - return Err("Cannot replace a symbolic-link download target".to_string()); - } - if !metadata.is_file() { - return Err("Exact replacement requires a regular file target".to_string()); - } - if !is_safe_path(target, app_handle) { - return Err("Download replacement target is outside an approved location".to_string()); - } - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { - return Err(format!( - "Download target is already owned by Firelink download {owner}" - )); - } - let current_fingerprint = target_fingerprint(target, &metadata); - if current_fingerprint != expected_fingerprint { - return Err( - "The file changed after duplicate resolution. Reopen the conflict and choose Replace again." - .to_string(), - ); - } - - let parent = target - .parent() - .ok_or_else(|| "Download replacement target has no parent directory".to_string())?; - let transaction_id = uuid::Uuid::new_v4().simple().to_string(); - let quarantine = parent.join(format!( - ".firelink-replaced-{}-{}.tmp", - safe_replacement_component(&item.id), - transaction_id - )); - if crate::path_has_symlink_component(&quarantine) { - return Err("Download replacement quarantine path contains a symbolic link".to_string()); - } - let journal_dir = state.storage_layout.data_dir().join("download-replacements"); - tokio::fs::create_dir_all(&journal_dir) - .await - .map_err(|error| format!("Could not prepare download replacement recovery: {error}"))?; - let journal_path = journal_dir.join(format!( - "{}-{}-{}.json", - safe_replacement_component(&item.id), - lifecycle_generation, - transaction_id - )); - let reservation = DownloadReplacementReservation { - journal_path: journal_path.clone(), - target: target.to_path_buf(), - quarantine: quarantine.clone(), - fingerprint: expected_fingerprint.to_string(), - }; - let mut journal = DownloadReplacementJournal { - id: item.id.clone(), - target: target.to_path_buf(), - quarantine, - fingerprint: expected_fingerprint.to_string(), - phase: "prepared".to_string(), - previous_ownership: Some(previous_ownership.clone()), - }; - write_download_replacement_journal(&journal_path, &journal).await?; - - if let Err(error) = tokio::fs::rename(target, &reservation.quarantine).await { - let _ = tokio::fs::remove_file(&journal_path).await; - return Err(format!("Could not stage the existing file for replacement: {error}")); - } - let staged_metadata = match tokio::fs::symlink_metadata(&reservation.quarantine).await { - Ok(metadata) => metadata, - Err(error) => { - if let Err(restore_error) = restore_download_replacement(&reservation).await { - log::warn!( - "download replacement [{}]: could not restore the staged file after verification failed: {}", - item.id, - restore_error - ); - } - return Err(format!("Could not verify staged replacement file: {error}")); - } - }; - if metadata_is_link_or_reparse(&staged_metadata) - || !staged_metadata.is_file() - || target_fingerprint(&reservation.quarantine, &staged_metadata) != expected_fingerprint - { - let _ = restore_staged_replacement_after_validation_failure(&reservation).await; - return Err("The replacement target changed while it was being staged".to_string()); - } - journal.phase = "quarantined".to_string(); - if let Err(error) = write_download_replacement_journal(&journal_path, &journal).await { - let _ = restore_download_replacement(&reservation).await; - return Err(error); - } - Ok(Some(reservation)) -} - -fn validate_unmanaged_download_target( - app_handle: &tauri::AppHandle, - id: &str, - target: &std::path::Path, -) -> Result<(), String> { - let metadata = match std::fs::symlink_metadata(target) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // A legacy Firelink row can own a path even while its payload is - // absent. Do not let a new row reserve that same output merely - // because there is no current directory entry to inspect. - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { - if owner != id { - return Err(format!( - "Download destination is already owned by Firelink download {owner}" - )); - } - } - return Ok(()) - } - Err(error) => return Err(format!("Could not inspect download target: {error}")), - }; - if metadata_is_link_or_reparse(&metadata) { - return Err("Download target is a symbolic link and cannot be replaced automatically".to_string()); - } - if metadata.is_dir() { - return Err("Download target is a directory; choose a different filename".to_string()); - } - if !metadata.is_file() { - return Err("Download target is a special file and cannot be replaced".to_string()); - } - if !is_safe_path(target, app_handle) { - return Err("Download target is outside an approved location".to_string()); - } - if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, target)? { - if owner == id { - return Ok(()); - } - return Err(format!( - "Download target is already owned by Firelink download {owner}" - )); - } - Err("Download target already exists; choose Replace or Rename".to_string()) -} - -fn recover_download_replacement_journals( - app_handle: &tauri::AppHandle, - storage_layout: &crate::storage::StorageLayout, -) -> Result<(), String> { - let directory = storage_layout.data_dir().join("download-replacements"); - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(format!("could not inspect download replacement recovery: {error}")), - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let journal: DownloadReplacementJournal = match std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - { - Some(journal) => journal, - None => { - log::warn!("leaving malformed download replacement journal for manual recovery"); - continue; - } - }; - if journal.id.is_empty() - || !matches!(journal.phase.as_str(), "prepared" | "quarantined" | "admitted") - || !is_safe_path(&journal.target, app_handle) - || journal - .quarantine - .parent() - .is_none_or(|parent| !is_safe_path(parent, app_handle)) - || journal - .quarantine - .parent() - .zip(journal.target.parent()) - .is_none_or(|(quarantine_parent, target_parent)| { - !crate::platform::paths_equal(quarantine_parent, target_parent) - }) - || journal - .quarantine - .file_name() - .and_then(|name| name.to_str()) - .is_none_or(|name| { - !name.starts_with(".firelink-replaced-") || !name.ends_with(".tmp") - }) - || path_has_symlink_component(&journal.quarantine) - { - log::warn!("leaving unsafe download replacement journal for manual recovery"); - continue; - } - let owner = crate::download_ownership::owner_for_path(app_handle, &journal.target)?; - let target_exists = match std::fs::symlink_metadata(&journal.target) { - Ok(metadata) if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() => { - log::warn!( - "leaving download replacement journal [{}]: target is not a regular file", - journal.id - ); - continue; - } - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(error) => { - log::warn!( - "leaving download replacement journal [{}]: target could not be inspected: {}", - journal.id, - error - ); - continue; - } - }; - let quarantine_exists = match std::fs::symlink_metadata(&journal.quarantine) { - Ok(metadata) - if metadata_is_link_or_reparse(&metadata) - || !metadata.is_file() - || target_fingerprint(&journal.quarantine, &metadata) != journal.fingerprint => - { - log::warn!( - "leaving download replacement journal [{}]: quarantine is not the original regular file", - journal.id - ); - continue; - } - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(error) => { - log::warn!( - "leaving download replacement journal [{}]: quarantine could not be inspected: {}", - journal.id, - error - ); - continue; - } - }; - if owner.as_deref() == Some(journal.id.as_str()) { - if journal.phase == "admitted" { - let quarantine_removed = if quarantine_exists { - match std::fs::remove_file(&journal.quarantine) { - Ok(()) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => { - log::warn!( - "download replacement recovery [{}] could not remove the admitted quarantine: {}", - journal.id, - error - ); - false - } - } - } else { - true - }; - if quarantine_removed { - if let Err(error) = std::fs::remove_file(&path) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "download replacement recovery [{}] could not clear its journal: {}", - journal.id, - error - ); - } - } - } - } else if quarantine_exists { - if target_exists { - // Before the durable admitted marker, a target at this - // path is ambiguous: it may be a new Firelink output or - // an unrelated file that appeared during the crash - // window. Never discard the quarantined original or - // adopt the replacement silently in that state. - log::warn!( - "leaving download replacement journal [{}]: target appeared before admission was durably recorded", - journal.id - ); - continue; - } - // Ownership is written before the queue commit so duplicate - // admission is fenced. If the process stopped in that - // window, restore both the original file and the ownership - // record that existed before replacement. - if let Err(error) = restore_quarantine_file_without_replacing_sync( - &journal.quarantine, - &journal.target, - ) { - log::warn!( - "download replacement recovery [{}] could not restore the pre-admission target: {}", - journal.id, - error - ); - } else if let Err(error) = restore_download_replacement_ownership( - app_handle, - &journal.id, - journal.previous_ownership.clone(), - ) { - log::warn!( - "download replacement recovery [{}] restored the file but could not restore ownership: {}", - journal.id, - error - ); - } else if let Err(error) = std::fs::remove_file(&path) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "download replacement recovery [{}] could not clear its journal: {}", - journal.id, - error - ); - } - } - } else { - log::warn!( - "leaving download replacement journal [{}]: ownership exists but quarantine is missing", - journal.id - ); - } - } else if owner.is_none() && !target_exists && quarantine_exists { - if let Err(error) = restore_quarantine_file_without_replacing_sync( - &journal.quarantine, - &journal.target, - ) { - log::warn!( - "download replacement recovery [{}] could not restore the old target: {}", - journal.id, - error - ); - } else if let Err(error) = restore_download_replacement_ownership( - app_handle, - &journal.id, - journal.previous_ownership.clone(), - ) { - log::warn!( - "download replacement recovery [{}] restored the file but could not restore ownership: {}", - journal.id, - error - ); - } else if let Err(error) = std::fs::remove_file(&path) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "download replacement recovery [{}] could not clear its journal: {}", - journal.id, - error - ); - } - } - } else if owner.is_none() && journal.phase == "admitted" && target_exists { - if quarantine_exists { - match std::fs::remove_file(&journal.quarantine) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - log::warn!( - "download replacement recovery [{}] could not remove the admitted quarantine: {}", - journal.id, - error - ); - continue; - } - } - } - if let Err(error) = std::fs::remove_file(&path) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "download replacement recovery [{}] could not clear its journal: {}", - journal.id, - error - ); - } - } - } else if owner.is_none() && !quarantine_exists && !target_exists { - // Both sides are already absent. Keep the recovery operation - // idempotent and remove only its own journal. - if let Err(error) = std::fs::remove_file(&path) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "download replacement recovery [{}] could not clear its journal: {}", - journal.id, - error - ); - } - } - } else { - log::warn!( - "leaving download replacement journal [{}]: target/quarantine ownership state is ambiguous", - journal.id - ); - } - } - Ok(()) -} - -fn register_torrent_output_ownership( - app_handle: &tauri::AppHandle, - id: &str, - destination: &str, - filename: &str, - torrent_path: Option<&str>, - torrent_file_indices: Option<&[u32]>, - torrent_remove_unselected_file: bool, -) -> Result<(), String> { - let Some(paths) = expected_torrent_output_paths( - app_handle, - id, - destination, - filename, - torrent_path, - torrent_file_indices, - torrent_remove_unselected_file, - )? else { - let primary = crate::download_ownership::expected_primary_path( - app_handle, - destination, - filename, - )?; - return set_download_output_ownership( - app_handle, - id, - primary.clone(), - vec![primary], - Vec::new(), - ); - }; - set_download_output_ownership( - app_handle, - id, - paths.primary, - paths.selected, - if torrent_remove_unselected_file { - paths.unselected - } else { - Vec::new() - }, - ) -} - -fn set_download_output_ownership( - app_handle: &tauri::AppHandle, - id: &str, - primary: std::path::PathBuf, - owned_paths: Vec, - removal_paths: Vec, -) -> Result<(), String> { - crate::download_ownership::set_owned_paths_with_primary_and_removal( - app_handle, - id, - &primary, - &owned_paths, - &removal_paths, - ) -} - -async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), String> { - match tokio::fs::remove_dir_all(path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "could not remove magnet metadata probe ({:?})", - error.kind() - )), - } -} - -struct MagnetMetadataProbeTelemetry { - info_hash: String, - started_at: Instant, - outcome: Option<&'static str>, -} - -impl MagnetMetadataProbeTelemetry { - fn new(info_hash: &str, tracker_bearing: bool, proxy_configured: bool) -> Self { - log::debug!( - "magnet metadata probe started: info_hash={info_hash}, tracker_bearing={tracker_bearing}, proxy_configured={proxy_configured}" - ); - Self { - info_hash: info_hash.to_string(), - started_at: Instant::now(), - outcome: None, - } - } - - fn finish(&mut self, outcome: &'static str) { - if self.outcome.is_some() { - return; - } - self.outcome = Some(outcome); - log::debug!( - "magnet metadata probe finished: info_hash={}, outcome={outcome}, elapsed_ms={}", - self.info_hash, - self.started_at.elapsed().as_millis() - ); - } -} - -impl Drop for MagnetMetadataProbeTelemetry { - fn drop(&mut self) { - if self.outcome.is_none() { - log::debug!( - "magnet metadata probe finished: info_hash={}, outcome=canceled, elapsed_ms={}", - self.info_hash, - self.started_at.elapsed().as_millis() - ); - } - } -} - -async fn resolve_magnet_metadata( - app_handle: &tauri::AppHandle, - state: &AppState, - source: &str, - id: &str, - proxy: Option<&str>, - headers: Option<&str>, - cookies: Option<&str>, - cache: bool, -) -> Result { - let expected = crate::torrent::inspect_source(source)?; - let managed_path = crate::torrent::managed_torrent_path(app_handle, id)?; - let proxy_value = proxy - .map(crate::queue::aria2_all_proxy_value) - .transpose()? - .flatten(); - if cache && crate::torrent::magnet_allows_cached_metadata(source) { - let cache_started_at = Instant::now(); - match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash) - .await - { - Ok(Some(bytes)) => { - let parsed = crate::torrent::parse_torrent_bytes(&bytes)?; - crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?; - let torrent_path = - crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?; - log::debug!( - "magnet metadata cache hit: info_hash={}, elapsed_ms={}", - expected.info_hash, - cache_started_at.elapsed().as_millis() - ); - return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path))); - } - Ok(None) => {} - Err(error) => { - log::warn!("could not inspect canonical torrent metadata cache: {error}"); - } - } - } - let storage_root = managed_path - .parent() - .ok_or_else(|| "torrent storage has no parent directory".to_string())?; - let probe_dir = storage_root.join(format!(".probe-{}", uuid::Uuid::new_v4().simple())); - tokio::fs::create_dir_all(&probe_dir) - .await - .map_err(|error| format!("could not create torrent metadata probe: {error}"))?; - - let port = state - .aria2_port - .load(std::sync::atomic::Ordering::Relaxed); - let secret = state.aria2_secret.clone(); - let metadata_path = probe_dir.join(format!("{}.torrent", expected.info_hash)); - let mut options = serde_json::Map::new(); - options.insert( - "dir".to_string(), - serde_json::json!(probe_dir.to_string_lossy().to_string()), - ); - options.insert("bt-metadata-only".to_string(), serde_json::json!("true")); - options.insert("bt-save-metadata".to_string(), serde_json::json!("true")); - options.insert("max-tries".to_string(), serde_json::json!("3")); - options.insert("retry-wait".to_string(), serde_json::json!("2")); - options.insert("connect-timeout".to_string(), serde_json::json!("20")); - options.insert("timeout".to_string(), serde_json::json!("60")); - options.insert("auto-file-renaming".to_string(), serde_json::json!("false")); - if proxy_value - .as_deref() - .is_none_or(|proxy| proxy.trim().is_empty()) - { - // Keep optional magnet metadata refreshes on the same system DNS - // route as direct Torrent transfers when a TUN client owns resolver - // interception. Explicit proxy routes retain their own DNS policy. - options.insert("async-dns".to_string(), serde_json::json!("false")); - } - if let Some(proxy) = proxy_value.as_deref() { - options.insert("all-proxy".to_string(), serde_json::json!(proxy)); - } - let mut header_list = Vec::new(); - if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) { - header_list.push(format!("Cookie: {cookies}")); - } - if let Some(headers) = headers { - header_list.extend( - headers - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_string), - ); - } - if !header_list.is_empty() { - options.insert("header".to_string(), serde_json::json!(header_list)); - } - - let client = std::sync::Arc::new(Aria2RpcClient { port, secret }); - let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?; - let mut telemetry = MagnetMetadataProbeTelemetry::new( - &expected.info_hash, - sanitized_source.contains("tr="), - proxy_value.is_some(), - ); - let metadata_result = crate::torrent_probe::run_metadata_probe( - client, - &sanitized_source, - options, - &metadata_path, - Duration::from_secs(60), - Duration::from_millis(250), - ) - .await; - - match &metadata_result { - Ok(_) => telemetry.finish("probe-success"), - Err(crate::torrent_probe::ProbeFailure::Metadata(_)) => telemetry.finish("metadata-failure"), - Err(crate::torrent_probe::ProbeFailure::Cleanup(_)) => telemetry.finish("cleanup-failure"), - } - - let bytes = match metadata_result { - Ok(bytes) => bytes, - Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => { - if let Err(remove_error) = remove_magnet_metadata_probe_dir(&probe_dir).await { - return Err(remove_error); - } - return Err(error); - } - Err(crate::torrent_probe::ProbeFailure::Cleanup(error)) => { - if aria2_daemon_process_exited(app_handle) { - if let Err(remove_error) = remove_magnet_metadata_probe_dir(&probe_dir).await { - return Err(format!( - "could not clean up magnet metadata probe: {error}; could not remove probe after aria2 exit: {remove_error}" - )); - } - return Err(format!( - "could not clean up magnet metadata probe after aria2 exit: {error}" - )); - } - return Err(format!("could not clean up magnet metadata probe: {error}")); - } - }; - - if let Err(error) = remove_magnet_metadata_probe_dir(&probe_dir).await { - return Err(error); - } - let parsed = crate::torrent::parse_torrent_bytes(&bytes)?; - crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?; - let torrent_path = if cache { - let torrent_path = crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?; - if let Err(error) = crate::torrent::cache_torrent_info_hash(app_handle, &bytes).await { - log::warn!("could not cache canonical torrent metadata: {error}"); - } - Some(torrent_path) - } else { - None - }; - Ok(crate::torrent::to_metadata(parsed, torrent_path)) -} - -#[tauri::command] -async fn inspect_torrent( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, - source: String, - id: String, - cache: Option, - proxy: Option, - headers: Option, - cookies: Option, - cookie_scopes: Option>, - torrent: Option, -) -> Result { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; - if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { - return resolve_magnet_metadata( - &app_handle, - state.inner(), - &source, - &id, - proxy.as_deref(), - headers.as_deref(), - cookies.as_deref(), - cache == Some(true), - ) - .await - .map_err(AppError::Internal); - } - if is_remote_torrent_source(&source, torrent == Some(true)) { - let remote_source = source.trim(); - let bytes = fetch_remote_torrent_bytes( - remote_source, - proxy.as_deref(), - headers.as_deref(), - cookies.as_deref(), - cookie_scopes.as_deref(), - ) - .await - .map_err(AppError::Internal)?; - let parsed = crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?; - let torrent_path = if cache != Some(false) { - let torrent_path = crate::torrent::cache_torrent_bytes(&app_handle, &id, &bytes) - .await - .map_err(AppError::Internal)?; - if let Err(error) = - crate::torrent::cache_torrent_info_hash(&app_handle, &bytes).await - { - log::warn!("could not cache canonical torrent metadata: {error}"); - } - Some(torrent_path) - } else { - None - }; - return Ok(crate::torrent::to_metadata(parsed, torrent_path)); - } - if cache == Some(false) { - return crate::torrent::inspect_source(&source) - .map(|parsed| crate::torrent::to_metadata(parsed, None)) - .map_err(AppError::Internal); - } - let (parsed, path) = crate::torrent::prepare_local_torrent(&app_handle, &source, &id) - .await - .map_err(AppError::Internal)?; - Ok(crate::torrent::to_metadata(parsed, Some(path))) -} - -#[tauri::command] -async fn rekey_torrent_metadata( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - source_id: String, - target_id: String, -) -> Result { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; - let source = crate::torrent::managed_torrent_path(&app_handle, &source_id) - .map_err(AppError::Internal)?; - let source = crate::torrent::validate_managed_torrent_path( - &app_handle, - &source_id, - &source.to_string_lossy(), - ) - .map_err(AppError::Internal)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&source) - .await - .map_err(|error| { - AppError::Internal(format!("could not read cached torrent metadata: {error}")) - })?; - crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?; - let target = crate::torrent::cache_torrent_bytes(&app_handle, &target_id, &bytes) - .await - .map_err(AppError::Internal)?; - if let Err(error) = crate::torrent::cache_torrent_info_hash(&app_handle, &bytes).await { - log::warn!("could not cache canonical torrent metadata during rekey: {error}"); - } - let target = crate::torrent::validate_managed_torrent_path( - &app_handle, - &target_id, - &target, - ) - .map_err(AppError::Internal)?; - if source != target { - tokio::fs::remove_file(&source).await.map_err(|error| { - AppError::Internal(format!("could not remove temporary torrent metadata: {error}")) - })?; - } - Ok(target.to_string_lossy().to_string()) -} - -#[tauri::command] -async fn remove_torrent_metadata( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - id: String, -) -> Result<(), AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; - crate::torrent::remove_managed_torrent(&app_handle, &id).await; - Ok(()) -} - #[tauri::command] async fn enqueue_download( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, - item: queue::EnqueueItem, -) -> Result { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; - let id = item.id.clone(); - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; - enqueue_download_locked(&app_handle, state.inner(), item, &control_guard).await -} - -async fn enqueue_download_locked( - app_handle: &tauri::AppHandle, - state: &AppState, mut item: queue::EnqueueItem, - _control_guard: &queue::Aria2ControlGuard, ) -> Result { - if item.is_torrent.unwrap_or(false) { - validate_torrent_enqueue(app_handle, &mut item) - .await - .map_err(AppError::Internal)?; - } else { - item.sftp_host_key_md = queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) - .map_err(AppError::Internal)?; - validate_enqueue_uris(&item.url, item.mirrors.as_deref()) - .await - .map_err(AppError::Internal)?; - } - preflight_download_destination_access(app_handle, &item.id, &item.destination) + validate_enqueue_uris(&item.url, item.mirrors.as_deref()) + .await .map_err(AppError::Internal)?; let id = item.id.clone(); item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let accepted_filename = item.filename.clone(); - let target_path = if item.is_torrent.unwrap_or(false) { - None - } else { - Some( - crate::download_ownership::expected_primary_path( - app_handle, - &item.destination, - &item.filename, - ) - .map_err(AppError::Internal)?, - ) - }; - let target_lock_path = enqueue_output_lock_path(app_handle, &item) - .map_err(AppError::Internal)?; - let _target_guard = Some(download_target_lock(&target_lock_path).await.lock_owned().await); let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?; let previous_generation = state .queue_manager .reserve_enqueue_generation(&id, lifecycle_generation) .await .map_err(AppError::Internal)?; - let previous_ownership = match snapshot_download_ownership(app_handle, &id) { - Ok(snapshot) => snapshot, - Err(error) => { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - return Err(AppError::Internal(error)); - } - }; - let replacement = if let Some(target) = target_path.as_ref() { - let replacement = match prepare_download_replacement( - app_handle, - state, - &item, - target, - lifecycle_generation, - &previous_ownership, - ) - .await - { - Ok(replacement) => replacement, - Err(error) => { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - return Err(AppError::Internal(error)); - } - }; - if replacement.is_none() { - item.replace_existing_fingerprint = None; - if let Err(error) = validate_unmanaged_download_target(app_handle, &item.id, target) { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - return Err(AppError::Internal(error)); - } - } - replacement - } else { - None - }; - if let Err(error) = register_download_ownership(app_handle, &item) { - if let Some(reservation) = replacement.as_ref() { - if let Err(restore_error) = restore_download_replacement(reservation).await { - log::error!( - "download replacement [{}]: failed to restore after ownership registration failed: {}", - id, - restore_error - ); - } - } - if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { - log::error!( - "download ownership [{}]: failed to restore the previous mapping after enqueue registration failed: {}", - id, - restore_error - ); - } + if let Err(error) = crate::download_ownership::register_expected( + &app_handle, + &item.id, + &item.destination, + &item.filename, + ) { state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; return Err(AppError::Internal(error)); } - let reservation_for_commit = replacement.clone(); - let commit_result = state + if let Err(error) = state .queue_manager - .commit_reserved_enqueue_with_finalizer( - item.into_task(), - lifecycle_generation, - previous_generation, - move || async move { - match reservation_for_commit.as_ref() { - Some(reservation) => mark_download_replacement_admitted(reservation).await, - None => Ok(()), - } - }, - ) - .await; - if let Err(error) = commit_result { - if let Some(reservation) = replacement.as_ref() { - if let Err(restore_error) = restore_download_replacement(reservation).await { - log::error!( - "download replacement [{}]: failed to restore after enqueue commit failed: {}", - id, - restore_error - ); - } - } - if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { - log::error!( - "download ownership [{}]: failed to restore the previous mapping after enqueue commit failed: {}", - id, - restore_error - ); - } + .commit_reserved_enqueue(item.into_task(), lifecycle_generation) + .await + { + let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; return Err(AppError::Internal(error)); } - if let Some(reservation) = replacement.as_ref() { - finalize_download_replacement(reservation).await; - } Ok(crate::ipc::EnqueueAccepted { id, filename: accepted_filename, @@ -9682,12 +5560,10 @@ async fn enqueue_download_locked( #[tauri::command] async fn cancel_enqueue_generation( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, generation: String, ) -> Result<(), AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let generation = generation .parse::() .map_err(|_| AppError::Internal("Invalid enqueue lifecycle generation".to_string()))?; @@ -9695,53 +5571,19 @@ async fn cancel_enqueue_generation( .queue_manager .cancel_enqueue_generation(&id, generation) .await; - state - .queue_manager - .clear_aria2_allocation_for_lifecycle_generation(&id, generation) - .await; Ok(()) } #[tauri::command] async fn enqueue_many( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, items: Vec, ) -> Result, AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let mut results = Vec::with_capacity(items.len()); for mut item in items { let id = item.id.clone(); - // Keep validation, ownership replacement, and enqueue reservation - // 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) { - validate_torrent_enqueue(&app_handle, &mut item).await - } else { - match queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) { - Ok(normalized) => { - item.sftp_host_key_md = normalized; - validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await - } - Err(error) => Err(error), - } - }; - if let Err(error) = validation { - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - if let Err(error) = preflight_download_destination_access( - &app_handle, - &id, - &item.destination, - ) { + if let Err(error) = validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await { results.push(crate::ipc::EnqueueResult { id, success: false, @@ -9752,39 +5594,6 @@ async fn enqueue_many( } item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let filename = item.filename.clone(); - let target_path = if item.is_torrent.unwrap_or(false) { - None - } else { - match crate::download_ownership::expected_primary_path( - &app_handle, - &item.destination, - &item.filename, - ) { - Ok(path) => Some(path), - Err(error) => { - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - } - }; - let target_lock_path = match enqueue_output_lock_path(&app_handle, &item) { - Ok(path) => path, - Err(error) => { - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - }; - let _target_guard = download_target_lock(&target_lock_path).await.lock_owned().await; let lifecycle_generation = match enqueue_lifecycle_generation(&item) { Ok(generation) => generation, Err(error) => { @@ -9813,85 +5622,12 @@ async fn enqueue_many( continue; } }; - let previous_ownership = match snapshot_download_ownership(&app_handle, &id) { - Ok(snapshot) => snapshot, - Err(error) => { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - }; - let replacement = if let Some(target) = target_path.as_ref() { - match prepare_download_replacement( - &app_handle, - state.inner(), - &item, - target, - lifecycle_generation, - &previous_ownership, - ) - .await - { - Ok(Some(replacement)) => Some(replacement), - Ok(None) => { - item.replace_existing_fingerprint = None; - if let Err(error) = validate_unmanaged_download_target(&app_handle, &id, target) { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - None - } - Err(error) => { - state - .queue_manager - .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) - .await; - results.push(crate::ipc::EnqueueResult { - id, - success: false, - filename: None, - error: Some(error), - }); - continue; - } - } - } else { - None - }; - if let Err(error) = register_download_ownership(&app_handle, &item) { - if let Some(replacement) = replacement.as_ref() { - if let Err(restore_error) = restore_download_replacement(replacement).await { - log::error!( - "download replacement [{}]: failed to restore after batch ownership registration failed: {}", - id, - restore_error - ); - } - } - if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { - log::error!( - "download ownership [{}]: failed to restore the previous mapping after batch enqueue registration failed: {}", - id, - restore_error - ); - } + if let Err(error) = crate::download_ownership::register_expected( + &app_handle, + &item.id, + &item.destination, + &item.filename, + ) { state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -9904,38 +5640,12 @@ async fn enqueue_many( }); continue; } - let reservation_for_commit = replacement.clone(); - let commit_result = state + if let Err(error) = state .queue_manager - .commit_reserved_enqueue_with_finalizer( - item.into_task(), - lifecycle_generation, - previous_generation, - move || async move { - match reservation_for_commit.as_ref() { - Some(reservation) => mark_download_replacement_admitted(reservation).await, - None => Ok(()), - } - }, - ) - .await; - if let Err(error) = commit_result { - if let Some(replacement) = replacement.as_ref() { - if let Err(restore_error) = restore_download_replacement(replacement).await { - log::error!( - "download replacement [{}]: failed to restore after batch enqueue commit failed: {}", - id, - restore_error - ); - } - } - if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { - log::error!( - "download ownership [{}]: failed to restore the previous mapping after batch enqueue commit failed: {}", - id, - restore_error - ); - } + .commit_reserved_enqueue(item.into_task(), lifecycle_generation) + .await + { + let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -9948,9 +5658,6 @@ async fn enqueue_many( }); continue; } - if let Some(replacement) = replacement.as_ref() { - finalize_download_replacement(replacement).await; - } results.push(crate::ipc::EnqueueResult { id, success: true, @@ -9964,13 +5671,11 @@ async fn enqueue_many( #[tauri::command] async fn move_in_queue( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, queue_id: String, direction: crate::ipc::QueueDirection, ) -> Result, AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(state .queue_manager .move_in_queue(&id, &queue_id, direction) @@ -9979,14 +5684,12 @@ async fn move_in_queue( #[tauri::command] async fn move_many_in_queue( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, ids: Vec, queue_id: String, direction: crate::ipc::QueueDirection, target_index: Option, ) -> Result, AppError> { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(match target_index { Some(target_index) => state .queue_manager @@ -10001,13 +5704,10 @@ async fn move_many_in_queue( #[tauri::command] async fn remove_from_queue( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result { - properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; - let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let removed = state.queue_manager.remove_from_pending(&id).await; if removed { let _ = crate::download_ownership::remove(&app_handle, &id); @@ -10018,22 +5718,18 @@ async fn remove_from_queue( #[tauri::command] async fn set_concurrent_limit( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: usize, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; state.queue_manager.set_capacity(limit.clamp(1, 12)); Ok(()) } #[tauri::command] async fn set_queue_concurrency_limits( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limits: Vec, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; state .queue_manager .replace_queue_limits( @@ -10047,2105 +5743,16 @@ async fn set_queue_concurrency_limits( #[tauri::command] async fn set_download_speed_limit( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, limit: Option, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; state .queue_manager .set_aria2_download_speed_limit(&id, limit) .await } -#[tauri::command] -async fn set_torrent_upload_limit( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - id: String, - limit: Option, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - state - .queue_manager - .set_aria2_torrent_upload_limit(&id, limit) - .await -} - -#[tauri::command] -async fn set_torrent_peer_options( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - id: String, - max_peers: Option, - peer_speed_limit: Option, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - state - .queue_manager - .set_aria2_torrent_peer_options(&id, max_peers, peer_speed_limit) - .await -} - -#[tauri::command] -async fn get_torrent_peers( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - state.queue_manager.get_aria2_torrent_peers(&id).await -} - -#[tauri::command] -async fn get_torrent_availability( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - state.queue_manager.get_aria2_torrent_availability(&id).await -} - -#[tauri::command] -async fn get_torrent_file_progress( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - state - .queue_manager - .get_aria2_torrent_file_progress(&id) - .await -} - -#[tauri::command] -async fn get_torrent_piece_progress( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - state - .queue_manager - .get_aria2_torrent_piece_progress(&id) - .await -} - -fn torrent_file_selection_snapshot( - metadata: &crate::torrent::ParsedTorrent, - selected: Option<&[u32]>, -) -> crate::ipc::TorrentFileSelectionSnapshot { - crate::ipc::TorrentFileSelectionSnapshot { - files: metadata - .files - .iter() - .map(|file| crate::ipc::TorrentFileSelectionEntry { - index: file.index, - relative_path: file.path.clone(), - length: file.length, - selected: selected.is_none_or(|indices| indices.contains(&file.index)), - completed_length: None, - }) - .collect(), - } -} - -fn load_persisted_download_item( - database: &crate::db::DbState, - id: &str, -) -> Result { - let connection = database.lock()?; - crate::db::load_downloads(&connection)? - .into_iter() - .find_map(|record| { - serde_json::from_str::(&record) - .ok() - .filter(|item| item.id == id) - }) - .ok_or_else(|| "download is not persisted".to_string()) -} - -fn load_persisted_torrent_item( - database: &crate::db::DbState, - id: &str, -) -> Result { - load_persisted_download_item(database, id) -} - -fn persist_torrent_destination( - database: &crate::db::DbState, - id: &str, - destination: &str, - relocation_check_pending: bool, -) -> Result<(), String> { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - object.insert( - "destination".to_string(), - serde_json::Value::String(destination.to_string()), - ); - if relocation_check_pending { - object.insert( - "torrentRelocationCheckPending".to_string(), - serde_json::Value::Bool(true), - ); - } else { - object.remove("torrentRelocationCheckPending"); - } - object.insert( - "torrentMoveDestination".to_string(), - serde_json::Value::String(destination.to_string()), - ); - Ok(()) - }, - ) -} - -fn persist_torrent_telemetry( - database: &crate::db::DbState, - id: &str, - snapshot: crate::queue::TorrentTelemetrySnapshot, -) -> Result<(), String> { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - object.insert( - "torrentUploadedBytes".to_string(), - serde_json::Value::from(snapshot.uploaded_bytes), - ); - object.insert( - "torrentSeededSeconds".to_string(), - serde_json::Value::from(snapshot.seeded_seconds), - ); - Ok(()) - }, - ) -} - -fn persist_torrent_relocation_check( - database: &crate::db::DbState, - id: &str, - pending: bool, -) -> Result<(), String> { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - if pending { - object.insert( - "torrentRelocationCheckPending".to_string(), - serde_json::Value::Bool(true), - ); - } else { - object.remove("torrentRelocationCheckPending"); - } - Ok(()) - }, - ) -} - -fn persist_torrent_file_selection( - database: &crate::db::DbState, - id: &str, - expected: Option<&[u32]>, - selected: Option<&[u32]>, -) -> Result<(), String> { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - let current = match object.get("torrentFileIndices") { - None => None, - Some(serde_json::Value::Array(indices)) => Some( - indices - .iter() - .map(serde_json::Value::as_u64) - .collect::>>() - .ok_or_else(|| { - "persisted Torrent file selection is malformed".to_string() - })? - .into_iter() - .map(|index| { - u32::try_from(index).map_err(|_| { - "persisted Torrent file selection is out of range".to_string() - }) - }) - .collect::, String>>()?, - ), - Some(_) => { - return Err("persisted Torrent file selection is malformed".to_string()) - } - }; - if current.as_deref() != expected { - return Err("Torrent file selection changed; reload before applying".to_string()); - } - if let Some(selected) = selected { - object.insert("torrentFileIndices".to_string(), serde_json::json!(selected)); - } else { - object.remove("torrentFileIndices"); - } - Ok(()) - }, - ) -} - -#[tauri::command] -async fn get_torrent_file_selection( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, crate::db::DbState>, - app_handle: tauri::AppHandle, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let item = load_persisted_torrent_item(state.inner(), &id)?; - if item.is_torrent != Some(true) { - return Err("file selection is available only for Torrent downloads".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let selected = crate::torrent::validate_selected_indices( - item.torrent_file_indices.as_deref(), - metadata.files.len(), - )?; - Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref())) -} - -#[tauri::command] -async fn set_torrent_file_selection( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - database: tauri::State<'_, crate::db::DbState>, - app_handle: tauri::AppHandle, - id: String, - selected_indices: Option>, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; - 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()); - } - if !matches!( - item.status, - crate::ipc::DownloadStatus::Ready - | crate::ipc::DownloadStatus::Staged - | crate::ipc::DownloadStatus::Queued - | crate::ipc::DownloadStatus::Paused - | crate::ipc::DownloadStatus::Failed - ) { - return Err("pause the Torrent before changing file selection".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let selected = crate::torrent::validate_selected_indices( - selected_indices.as_deref(), - metadata.files.len(), - )?; - if item.torrent_remove_unselected_file == Some(true) && selected.is_none() { - return Err("removing unselected Torrent files requires selecting a subset of files".to_string()); - } - - // Queued tasks already contain the fully resolved payload (proxy, - // credentials, destination, and retry settings) that the frontend cannot - // safely reconstruct in this native command. Remove that exact task from - // the admission queue while ownership and persistence are updated, then - // restore it at its original position with only the selection changed. - let pending_task = if matches!(item.status, crate::ipc::DownloadStatus::Queued) { - let pending_task = - state - .queue_manager - .take_pending_task(&id) - .await - .ok_or_else(|| { - "Torrent lifecycle changed; retry selection after the queued task settles" - .to_string() - })?; - if pending_task - .1 - .payload - .torrent_file_indices - .as_deref() - != item.torrent_file_indices.as_deref() - { - state - .queue_manager - .restore_pending_task(pending_task.0, pending_task.1) - .await; - return Err("Torrent lifecycle changed; reload before changing file selection".to_string()); - } - Some(pending_task) - } else { - None - }; - - if matches!(item.status, crate::ipc::DownloadStatus::Paused) { - if let Err(error) = detach_download_for_reconfigure_locked( - &app_handle, - state.inner(), - &id, - &control_guard, - ) - .await - { - return Err(error); - } - } - - let (destination, filename) = pending_task - .as_ref() - .map(|(_, task)| (task.payload.destination.as_str(), task.payload.filename.as_str())) - .unwrap_or_else(|| { - let destination = item.destination.as_deref().unwrap_or(""); - (destination, item.file_name.as_str()) - }); - let destination = if destination.trim().is_empty() { - verification_destination(&app_handle, &item)? - } else { - destination.to_string() - }; - let previous_ownership = snapshot_download_ownership(&app_handle, &id)?; - if let Err(error) = register_torrent_output_ownership( - &app_handle, - &id, - &destination, - filename, - item.torrent_path.as_deref(), - selected.as_deref(), - item.torrent_remove_unselected_file.unwrap_or(false), - ) { - if let Some((index, task)) = pending_task.as_ref() { - state - .queue_manager - .restore_pending_task(*index, task.clone()) - .await; - } - return Err(error); - } - - if let Err(error) = persist_torrent_file_selection( - database.inner(), - &id, - item.torrent_file_indices.as_deref(), - selected.as_deref(), - ) { - if let Err(restore_error) = restore_download_ownership( - &app_handle, - &id, - previous_ownership, - ) { - log::error!( - "torrent selection [{}]: failed to restore ownership after persistence failure: {}", - id, - restore_error - ); - } - if let Some((index, task)) = pending_task.as_ref() { - state - .queue_manager - .restore_pending_task(*index, task.clone()) - .await; - } - return Err(error); - } - - if let Some((index, mut task)) = pending_task { - task.payload.torrent_file_indices = selected.clone(); - state.queue_manager.restore_pending_task(index, task).await; - } - Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref())) -} - -#[tauri::command] -async fn get_torrent_details( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, crate::db::DbState>, - app_handle: tauri::AppHandle, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let item = load_persisted_torrent_item(state.inner(), &id)?; - if item.is_torrent != Some(true) { - return Err("details are available only for Torrent downloads".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - crate::torrent::torrent_details_from_bytes(&bytes) -} - -fn torrent_identity_magnet(details: &crate::ipc::TorrentDetails) -> Result { - let info_hash = crate::torrent::canonical_info_hash(&details.info_hash) - .ok_or_else(|| "Torrent metadata has an invalid info hash".to_string())?; - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - serializer.append_pair("xt", &format!("urn:btih:{info_hash}")); - if let Some(name) = sanitize_metadata_filename(&details.display_name) { - let bounded = name.chars().take(255).collect::(); - if !bounded.is_empty() { - serializer.append_pair("dn", &bounded); - } - } - Ok(format!("magnet:?{}", serializer.finish())) -} - -#[tauri::command] -async fn get_torrent_magnet_link( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, crate::db::DbState>, - app_handle: tauri::AppHandle, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let item = load_persisted_torrent_item(state.inner(), &id)?; - if item.is_torrent != Some(true) { - return Err("magnet links are available only for Torrent downloads".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|_| "Torrent metadata is unavailable".to_string())?; - let details = crate::torrent::torrent_details_from_bytes(&bytes)?; - crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &details.info_hash)?; - torrent_identity_magnet(&details) -} - -#[tauri::command] -async fn export_torrent_metadata( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, crate::db::DbState>, - app_handle: tauri::AppHandle, - id: String, - destination: String, -) -> Result<(), String> { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let destination = std::path::PathBuf::from(destination.trim()); - if !destination.is_absolute() - || destination.extension().and_then(|value| value.to_str()) - .is_none_or(|value| !value.eq_ignore_ascii_case("torrent")) - { - return Err("Choose an absolute .torrent destination".to_string()); - } - let metadata = std::fs::symlink_metadata(&destination); - if let Ok(metadata) = metadata { - if metadata_is_link_or_reparse(&metadata) { - return Err("The export destination cannot be a symbolic link".to_string()); - } - return Err("The export destination already exists".to_string()); - } - if let Err(error) = metadata { - if error.kind() != std::io::ErrorKind::NotFound { - return Err("The export destination cannot be accessed".to_string()); - } - } - let parent = destination - .parent() - .filter(|path| path.is_dir()) - .ok_or_else(|| "The export destination folder is unavailable".to_string())?; - let _canonical_parent = std::fs::canonicalize(parent) - .map_err(|_| "The export destination folder is unavailable".to_string())?; - - let item = load_persisted_torrent_item(state.inner(), &id)?; - if item.is_torrent != Some(true) { - return Err("metadata export is available only for Torrent downloads".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|_| "Torrent metadata is unavailable".to_string())?; - let details = crate::torrent::torrent_details_from_bytes(&bytes)?; - crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &details.info_hash)?; - - use tokio::io::AsyncWriteExt; - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&destination) - .await - .map_err(|error| match error.kind() { - std::io::ErrorKind::AlreadyExists => "The export destination already exists".to_string(), - _ => "The Torrent metadata could not be exported".to_string(), - })?; - if file.write_all(&bytes).await.is_err() { - let _ = tokio::fs::remove_file(&destination).await; - return Err("The Torrent metadata could not be exported".to_string()); - } - if file.sync_all().await.is_err() { - let _ = tokio::fs::remove_file(&destination).await; - return Err("The Torrent metadata could not be exported".to_string()); - } - Ok(()) -} - -fn torrent_move_journal_path(state: &AppState, id: &str) -> Result { - if id.is_empty() - || !id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err("invalid Torrent download id".to_string()); - } - Ok(state - .storage_layout - .data_dir() - .join("torrent-moves") - .join(format!("{id}.json"))) -} - -async fn write_torrent_move_journal( - path: &std::path::Path, - phase: &str, - id: &str, - old_destination: &std::path::Path, - new_destination: &std::path::Path, - old_primary: Option<&std::path::Path>, - old_removal_paths: &[std::path::PathBuf], - new_primary: &std::path::Path, - new_removal_paths: &[std::path::PathBuf], - staging_root: &std::path::Path, - staging_paths: &[std::path::PathBuf], - old_paths: &[std::path::PathBuf], - new_paths: &[std::path::PathBuf], - total_bytes: u64, -) -> Result<(), String> { - let data = serde_json::json!({ - "phase": phase, - "id": id, - "oldDestination": old_destination, - "newDestination": new_destination, - "oldPrimary": old_primary, - "oldRemovalPaths": old_removal_paths, - "newPrimary": new_primary, - "newRemovalPaths": new_removal_paths, - "stagingRoot": staging_root, - "stagingPaths": staging_paths, - "oldPaths": old_paths, - "newPaths": new_paths, - "totalBytes": total_bytes, - }); - let bytes = serde_json::to_vec_pretty(&data) - .map_err(|_| "could not encode Torrent move journal".to_string())?; - crate::platform::atomic_write_replace(path, &bytes) - .await - .map_err(|_| "could not commit Torrent move journal".to_string()) -} - -#[derive(serde::Deserialize)] -struct TorrentMoveJournal { - phase: String, - id: String, - old_destination: std::path::PathBuf, - new_destination: std::path::PathBuf, - #[serde(default)] - old_primary: Option, - #[serde(default)] - old_removal_paths: Vec, - #[serde(default)] - new_primary: Option, - #[serde(default)] - new_removal_paths: Vec, - #[serde(default)] - staging_root: Option, - #[serde(default)] - staging_paths: Vec, - old_paths: Vec, - new_paths: Vec, -} - -fn validate_torrent_move_recovery_path( - app_handle: &tauri::AppHandle, - path: &std::path::Path, -) -> Result<(), String> { - if !path.is_absolute() - || path.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir | std::path::Component::CurDir - ) - }) - || crate::path_has_symlink_component(path) - || !crate::is_safe_path(path, app_handle) - { - return Err("Torrent move journal contains an unsafe path".to_string()); - } - Ok(()) -} - -fn remove_torrent_move_file(path: &std::path::Path) -> Result<(), String> { - match std::fs::symlink_metadata(path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("Torrent move recovery could not inspect a managed path".to_string()), - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - Err("Torrent move recovery found a non-file managed path".to_string()) - } - Ok(_) => std::fs::remove_file(path) - .map_err(|_| "Torrent move recovery could not remove a managed file".to_string()), - } -} - -fn remove_torrent_move_staging_root(path: &std::path::Path) -> Result<(), String> { - match std::fs::symlink_metadata(path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("Torrent move recovery could not inspect staging".to_string()), - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - Err("Torrent move recovery found an unsafe staging path".to_string()) - } - Ok(_) => { - let mut entries = std::fs::read_dir(path) - .map_err(|_| "Torrent move recovery could not inspect staging".to_string())?; - if entries.next().is_some() { - return Err("Torrent move recovery found unexpected staging data".to_string()); - } - std::fs::remove_dir(path) - .map_err(|_| "Torrent move recovery could not remove staging".to_string()) - } - } -} - -fn remove_empty_torrent_move_directories( - files: &[std::path::PathBuf], - boundary: &std::path::Path, -) -> Result<(), String> { - let mut directories = Vec::new(); - for file in files { - let mut current = file.parent(); - while let Some(directory) = current { - if crate::platform::paths_equal(directory, boundary) - || !crate::platform::path_is_within(directory, boundary) - { - break; - } - if !directories - .iter() - .any(|existing: &std::path::PathBuf| { - crate::platform::paths_equal(existing, directory) - }) - { - directories.push(directory.to_path_buf()); - } - current = directory.parent(); - } - } - directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); - for directory in directories { - match std::fs::symlink_metadata(&directory) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(_) => return Err("Torrent move cleanup could not inspect a directory".to_string()), - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err("Torrent move cleanup found an unsafe directory".to_string()); - } - Ok(_) => { - let mut entries = std::fs::read_dir(&directory) - .map_err(|_| "Torrent move cleanup could not inspect a directory".to_string())?; - if entries.next().is_some() { - continue; - } - std::fs::remove_dir(&directory).map_err(|_| { - "Torrent move cleanup could not remove an empty directory".to_string() - })?; - } - } - } - Ok(()) -} - -async fn copy_torrent_move_file( - source: &std::path::Path, - target: &std::path::Path, -) -> Result<(), String> { - let source_metadata = tokio::fs::symlink_metadata(source) - .await - .map_err(|_| "Torrent source changed during relocation".to_string())?; - if !source_metadata.is_file() || source_metadata.file_type().is_symlink() { - return Err("Torrent source changed to a non-file during relocation".to_string()); - } - let mut input = tokio::fs::File::open(source) - .await - .map_err(|_| "Torrent source could not be opened".to_string())?; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut output = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(target) - .await - .map_err(|error| match error.kind() { - std::io::ErrorKind::AlreadyExists => { - "the Torrent move destination already contains managed output".to_string() - } - _ => "Torrent move destination could not be created".to_string(), - })?; - let mut buffer = [0u8; 1024 * 1024]; - loop { - let read = input - .read(&mut buffer) - .await - .map_err(|_| "Torrent source could not be read".to_string())?; - if read == 0 { - break; - } - output - .write_all(&buffer[..read]) - .await - .map_err(|_| "Torrent move destination could not be written".to_string())?; - } - output - .sync_all() - .await - .map_err(|_| "Torrent move destination could not be synchronized".to_string()) -} - -async fn cleanup_torrent_move_outputs( - new_paths: &[std::path::PathBuf], - staging_paths: &[std::path::PathBuf], - staging_root: &std::path::Path, -) { - for path in new_paths.iter().chain(staging_paths.iter()) { - let _ = tokio::fs::remove_file(path).await; - } - let _ = tokio::fs::remove_dir(staging_root).await; -} - -async fn digest_torrent_move_file(path: &std::path::Path) -> Result<[u8; 32], String> { - let metadata = tokio::fs::symlink_metadata(path) - .await - .map_err(|_| "Torrent move file could not be verified".to_string())?; - if !metadata.is_file() || metadata.file_type().is_symlink() { - return Err("Torrent move file is not a regular file".to_string()); - } - let mut file = tokio::fs::File::open(path) - .await - .map_err(|_| "Torrent move file could not be opened for verification".to_string())?; - use sha2::Digest; - use tokio::io::AsyncReadExt; - let mut digest = sha2::Sha256::new(); - let mut buffer = [0u8; 1024 * 1024]; - loop { - let read = file - .read(&mut buffer) - .await - .map_err(|_| "Torrent move file could not be read for verification".to_string())?; - if read == 0 { - break; - } - digest.update(&buffer[..read]); - } - Ok(digest.finalize().into()) -} - -fn recover_torrent_move_journals( - app_handle: &tauri::AppHandle, - database: &crate::db::DbState, - storage_layout: &crate::storage::StorageLayout, -) -> Result<(), String> { - let directory = storage_layout.data_dir().join("torrent-moves"); - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(_) => return Err("could not inspect Torrent move recovery".to_string()), - }; - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - let path = entry.path(); - let name = entry.file_name(); - let name = name.to_string_lossy(); - let file_type = match entry.file_type() { - Ok(file_type) => file_type, - Err(_) => continue, - }; - if crate::platform::is_atomic_temp_file_name(&name) { - if file_type.is_file() || file_type.is_symlink() { - let _ = std::fs::remove_file(&path); - } - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let journal: TorrentMoveJournal = match std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - { - Some(journal) => journal, - None => { - log::warn!("leaving malformed Torrent move journal for manual recovery"); - continue; - } - }; - if journal.id.is_empty() - || path.file_stem().and_then(|value| value.to_str()) != Some(journal.id.as_str()) - || journal.old_paths.is_empty() - || journal.new_paths.len() != journal.old_paths.len() - { - log::warn!("leaving invalid Torrent move journal for manual recovery"); - continue; - } - let all_paths = journal - .old_paths - .iter() - .chain(journal.new_paths.iter()) - .chain(journal.old_removal_paths.iter()) - .chain(journal.new_removal_paths.iter()) - .chain(journal.old_primary.iter()) - .chain(journal.new_primary.iter()); - if all_paths - .chain([&journal.old_destination, &journal.new_destination]) - .any(|path| validate_torrent_move_recovery_path(app_handle, path).is_err()) - { - log::warn!("leaving unsafe Torrent move journal for manual recovery"); - continue; - } - if journal.staging_root.as_ref().is_some_and(|root| { - validate_torrent_move_recovery_path(app_handle, root).is_err() - || !crate::platform::path_is_within(root, &journal.new_destination) - }) || journal.staging_paths.iter().any(|path| { - validate_torrent_move_recovery_path(app_handle, path).is_err() - || journal - .staging_root - .as_ref() - .is_none_or(|root| !crate::platform::path_is_within(path, root)) - }) { - log::warn!("leaving unsafe Torrent staging journal for manual recovery"); - continue; - } - let mut old_owned_paths = journal - .old_paths - .iter() - .chain(journal.old_removal_paths.iter()) - .chain(journal.old_primary.iter()); - let mut new_owned_paths = journal - .new_paths - .iter() - .chain(journal.new_removal_paths.iter()) - .chain(journal.new_primary.iter()); - if old_owned_paths.any(|path| { - !crate::platform::path_is_within(path, &journal.old_destination) - }) || new_owned_paths.any(|path| { - !crate::platform::path_is_within(path, &journal.new_destination) - }) { - log::warn!("leaving Torrent move journal with paths outside its roots"); - continue; - } - let item = match load_persisted_torrent_item(database, &journal.id) { - Ok(item) => item, - Err(_) => { - log::warn!( - "leaving Torrent move journal {} because its download row is absent", - journal.id - ); - continue; - } - }; - let current_destination = item - .destination - .as_deref() - .map(|value| crate::resolve_path(value, app_handle)); - let database_committed = current_destination - .as_deref() - .is_some_and(|value| crate::platform::paths_equal(value, &journal.new_destination)); - if database_committed - || matches!(journal.phase.as_str(), "databaseCommitted" | "sourceCleanupPending") - { - let mut cleanup_error = None; - for old_path in &journal.old_paths { - if let Err(error) = remove_torrent_move_file(old_path) { - cleanup_error = Some(error); - break; - } - } - if cleanup_error.is_none() { - if let Err(error) = remove_empty_torrent_move_directories( - &journal.old_paths, - &journal.old_destination, - ) { - cleanup_error = Some(error); - } - } - if cleanup_error.is_none() { - for staging_path in &journal.staging_paths { - if let Err(error) = remove_torrent_move_file(staging_path) { - cleanup_error = Some(error); - break; - } - } - } - if cleanup_error.is_none() { - if let Some(root) = journal.staging_root.as_ref() { - if let Err(error) = remove_torrent_move_staging_root(root) { - cleanup_error = Some(error); - } - } - } - if let Some(error) = cleanup_error { - log::warn!( - "Torrent move recovery [{}] retained the journal: {}", - journal.id, - error - ); - let _ = persist_torrent_relocation_check(database, &journal.id, true); - continue; - } - let _ = std::fs::remove_file(&path); - continue; - } - let database_is_old = current_destination - .as_deref() - .is_some_and(|value| crate::platform::paths_equal(value, &journal.old_destination)); - if !database_is_old { - log::warn!( - "leaving Torrent move journal {} because its destination is neither transaction endpoint", - journal.id - ); - continue; - } - let mut cleanup_error = None; - for new_path in &journal.new_paths { - if let Err(error) = remove_torrent_move_file(new_path) { - cleanup_error = Some(error); - break; - } - } - if cleanup_error.is_none() { - for staging_path in &journal.staging_paths { - if let Err(error) = remove_torrent_move_file(staging_path) { - cleanup_error = Some(error); - break; - } - } - } - if cleanup_error.is_none() { - if let Some(root) = journal.staging_root.as_ref() { - if let Err(error) = remove_torrent_move_staging_root(root) { - cleanup_error = Some(error); - } - } - } - if let Some(error) = cleanup_error { - log::warn!( - "Torrent move recovery [{}] retained the journal: {}", - journal.id, - error - ); - continue; - } - let old_primary = journal - .old_primary - .as_ref() - .or_else(|| journal.old_paths.first()); - let old_owned_paths = journal - .old_paths - .iter() - .filter(|path| { - !journal - .old_removal_paths - .iter() - .any(|removal| crate::platform::paths_equal(path, removal)) - }) - .cloned() - .collect::>(); - if let Some(old_primary) = old_primary { - if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal( - app_handle, - &journal.id, - old_primary, - &old_owned_paths, - &journal.old_removal_paths, - ) { - log::warn!( - "Torrent move recovery [{}] could not restore ownership: {}", - journal.id, - error - ); - continue; - } - } - let _ = std::fs::remove_file(&path); - } - Ok(()) -} - -fn torrent_move_path_pair( - old_root: &std::path::Path, - new_root: &std::path::Path, - path: &std::path::Path, -) -> Result { - if crate::platform::paths_equal(path, old_root) { - if crate::path_has_symlink_component(new_root) { - return Err("Torrent move destination contains a symbolic link".to_string()); - } - return crate::canonicalize_with_missing_components(new_root) - .ok_or_else(|| "Torrent move destination could not be canonicalized".to_string()); - } - let relative = path - .strip_prefix(old_root) - .map_err(|_| "managed Torrent output is outside its current root".to_string())?; - if relative.as_os_str().is_empty() - || relative.is_absolute() - || relative.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir | std::path::Component::CurDir - ) - }) - { - return Err("managed Torrent output path is unsafe".to_string()); - } - let target = new_root.join(relative); - if crate::path_has_symlink_component(&target) { - return Err("Torrent move destination contains a symbolic link".to_string()); - } - Ok(crate::canonicalize_with_missing_components(&target) - .ok_or_else(|| "Torrent move destination could not be canonicalized".to_string())?) -} - -#[tauri::command] -async fn move_torrent_data( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, - database: tauri::State<'_, crate::db::DbState>, - id: String, - destination: String, - session_id: Option, -) -> Result<(), String> { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - let properties_session_id = if caller.label() == "main" { - None - } else { - let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; - if !properties.session_matches(caller.label(), &session_id)? { - return Err("Properties window session is no longer current".to_string()); - } - Some(session_id) - }; - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; - 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()); - } - } - let item = load_persisted_torrent_item(database.inner(), &id)?; - if item.is_torrent != Some(true) { - return Err("data relocation is available only for Torrent downloads".to_string()); - } - if !matches!(item.status, crate::ipc::DownloadStatus::Paused | crate::ipc::DownloadStatus::Completed | crate::ipc::DownloadStatus::Failed) { - return Err("pause the Torrent before moving its data".to_string()); - } - if state.queue_manager.aria2_gid_for_download(&id).is_some() { - return Err("the Torrent still has an active daemon lifecycle".to_string()); - } - let old_destination = crate::resolve_path( - item.destination - .as_deref() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Torrent data has no current destination".to_string())?, - &app_handle, - ); - if !old_destination.is_absolute() - || crate::path_has_symlink_component(&old_destination) - || !crate::is_safe_path(&old_destination, &app_handle) - { - return Err("Torrent current destination is unsafe".to_string()); - } - let old_destination = std::fs::canonicalize(&old_destination) - .map_err(|_| "Torrent current destination is unavailable".to_string())?; - let new_destination = std::path::PathBuf::from(destination.trim()); - if !new_destination.is_absolute() - || !new_destination.is_dir() - || crate::path_has_symlink_component(&new_destination) - || !crate::is_safe_path(&new_destination, &app_handle) - { - return Err("choose an existing managed destination folder without symbolic links".to_string()); - } - let new_destination = std::fs::canonicalize(&new_destination) - .map_err(|_| "Torrent move destination could not be canonicalized".to_string())?; - if crate::platform::paths_equal(&old_destination, &new_destination) { - return Err("the Torrent is already in that destination".to_string()); - } - - let torrent_path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let torrent_path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, torrent_path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&torrent_path) - .await - .map_err(|_| "Torrent metadata is unavailable".to_string())?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?; - - let ownership = snapshot_download_ownership(&app_handle, &id)?; - let old_paths = ownership.owned.clone(); - if old_paths.is_empty() { - return Err("Torrent data has no managed output files".to_string()); - } - let old_root = if metadata.files.len() == 1 { - old_destination.clone() - } else { - old_destination.join(&item.file_name) - }; - let new_root = if metadata.files.len() == 1 { - new_destination.clone() - } else { - new_destination.join(&item.file_name) - }; - let new_paths = old_paths - .iter() - .map(|path| { - if !path.is_file() - || std::fs::symlink_metadata(path) - .is_ok_and(|metadata| metadata.file_type().is_symlink()) - { - return Err("managed Torrent output is missing or symbolic".to_string()); - } - torrent_move_path_pair(&old_root, &new_root, path) - }) - .collect::, _>>()?; - let old_primary = ownership - .primary - .as_ref() - .ok_or_else(|| "Torrent ownership is unavailable".to_string())?; - let new_primary = torrent_move_path_pair(&old_root, &new_root, old_primary)?; - let new_removal_paths = ownership - .removal - .iter() - .map(|path| torrent_move_path_pair(&old_root, &new_root, path)) - .collect::, _>>()?; - let mut move_old_paths = old_paths.clone(); - let mut move_new_paths = new_paths.clone(); - for (old_path, new_path) in ownership.removal.iter().zip(new_removal_paths.iter()) { - match std::fs::symlink_metadata(old_path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err("managed Torrent removal path is not a regular file".to_string()); - } - Ok(_) => { - if !move_old_paths - .iter() - .any(|path| crate::platform::paths_equal(path, old_path)) - { - move_old_paths.push(old_path.clone()); - move_new_paths.push(new_path.clone()); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("managed Torrent removal path could not be inspected".to_string()), - } - } - if new_paths.iter().any(|path| std::fs::symlink_metadata(path).is_ok()) - || new_removal_paths - .iter() - .any(|path| std::fs::symlink_metadata(path).is_ok()) - { - return Err("the Torrent move destination already contains managed output".to_string()); - } - let total_bytes = move_old_paths - .iter() - .map(|path| std::fs::metadata(path).map(|metadata| metadata.len())) - .collect::, _>>() - .map_err(|_| "could not read Torrent data size".to_string())? - .into_iter() - .try_fold(0u64, u64::checked_add) - .ok_or_else(|| "Torrent move size overflowed".to_string())?; - let staging_root = new_destination.join(format!(".firelink-torrent-move-{id}")); - if crate::path_has_symlink_component(&staging_root) - || std::fs::symlink_metadata(&staging_root).is_ok() - { - return Err("the Torrent move staging location is unavailable".to_string()); - } - let staging_paths = move_new_paths - .iter() - .enumerate() - .map(|(index, _)| staging_root.join(format!("{index:08}.part"))) - .collect::>(); - for path in &move_new_paths { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|_| "could not prepare the Torrent move destination".to_string())?; - } - } - - let journal = torrent_move_journal_path(&state, &id)?; - if let Some(parent) = journal.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|_| "could not prepare Torrent move recovery".to_string())?; - } - if let Some(session_id) = properties_session_id.as_deref() { - properties.with_current_session(caller.label(), session_id, || { - Ok(()) - })?; - } - state.queue_manager.begin_torrent_move(&id).await?; - if let Some(session_id) = properties_session_id.as_deref() { - if let Err(error) = properties.with_current_session(caller.label(), session_id, || Ok(())) { - state.queue_manager.finish_torrent_move(&id); - return Err(error); - } - } - if let Err(error) = write_torrent_move_journal( - &journal, - "reserved", - &id, - &old_destination, - &new_destination, - ownership.primary.as_deref(), - &ownership.removal, - &new_primary, - &new_removal_paths, - &staging_root, - &staging_paths, - &move_old_paths, - &move_new_paths, - total_bytes, - ) - .await - { - state.queue_manager.finish_torrent_move(&id); - return Err(error); - } - if let Err(error) = tokio::fs::create_dir(&staging_root).await { - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - return Err(format!("could not prepare Torrent move staging: {error}")); - } - if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal( - &app_handle, - &id, - &new_primary, - &new_paths, - &new_removal_paths, - ) { - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - return Err(error); - } - - use tauri::Emitter; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Moving)); - let move_restore_event = || { - crate::ipc::DownloadStateEvent::new(&id, item.status) - .with_destination(old_destination.to_string_lossy()) - }; - let mut copied_bytes = 0u64; - for (source, target) in move_old_paths.iter().zip(staging_paths.iter()) { - if state.queue_manager.torrent_move_cancelled(&id) { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err("Torrent move canceled".to_string()); - } - if let Err(error) = copy_torrent_move_file(source, target).await { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err(error); - } - let source_size = match tokio::fs::metadata(source).await { - Ok(metadata) => metadata.len(), - Err(_) => { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - move_restore_event(), - ); - return Err("Torrent source changed during relocation".to_string()); - } - }; - let target_size = match tokio::fs::metadata(target).await { - Ok(metadata) => metadata.len(), - Err(_) => { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - move_restore_event(), - ); - return Err("Torrent destination could not be verified".to_string()); - } - }; - if source_size != target_size { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err("Torrent data changed during relocation".to_string()); - } - let source_digest = digest_torrent_move_file(source).await; - let target_digest = digest_torrent_move_file(target).await; - if source_digest.is_err() || target_digest.is_err() || source_digest.ok() != target_digest.ok() { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err("Torrent data changed during relocation".to_string()); - } - copied_bytes = copied_bytes.saturating_add(target_size); - let _ = app_handle.emit("torrent-move-progress", crate::ipc::TorrentMoveProgressEvent { - id: id.clone(), - fraction: if total_bytes == 0 { 1.0 } else { copied_bytes as f64 / total_bytes as f64 }, - copied_bytes, - total_bytes, - }); - } - if state.queue_manager.torrent_move_cancelled(&id) { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err("Torrent move canceled".to_string()); - } - for (staged, target) in staging_paths.iter().zip(move_new_paths.iter()) { - if tokio::fs::rename(staged, target).await.is_err() { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - move_restore_event(), - ); - return Err("Torrent move destination could not be published".to_string()); - } - } - if tokio::fs::remove_dir(&staging_root).await.is_err() { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - move_restore_event(), - ); - return Err("Torrent move staging could not be finalized".to_string()); - } - if let Err(error) = write_torrent_move_journal( - &journal, - "copied", - &id, - &old_destination, - &new_destination, - ownership.primary.as_deref(), - &ownership.removal, - &new_primary, - &new_removal_paths, - &staging_root, - &staging_paths, - &move_old_paths, - &move_new_paths, - total_bytes, - ) - .await - { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - move_restore_event(), - ); - return Err(error); - } - let relocation_check_pending = item.torrent_relocation_check_pending == Some(true) - || matches!( - item.status, - crate::ipc::DownloadStatus::Paused | crate::ipc::DownloadStatus::Failed - ); - if let Err(error) = persist_torrent_destination( - database.inner(), - &id, - &new_destination.to_string_lossy(), - relocation_check_pending, - ) { - cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; - let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit("download-state", move_restore_event()); - return Err(error); - } - if let Err(error) = write_torrent_move_journal( - &journal, - "databaseCommitted", - &id, - &old_destination, - &new_destination, - ownership.primary.as_deref(), - &ownership.removal, - &new_primary, - &new_removal_paths, - &staging_root, - &staging_paths, - &move_old_paths, - &move_new_paths, - total_bytes, - ) - .await - { - // The database is already authoritative. Keep the prior journal and - // let startup recovery finish old-source cleanup from the committed - // destination rather than rolling the row back after commit. - let _ = persist_torrent_relocation_check(database.inner(), &id, true); - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status) - .with_destination(new_destination.to_string_lossy()), - ); - return Err(format!("Torrent data moved; cleanup recovery remains pending: {error}")); - } - let mut cleanup_error: Option = None; - for path in &move_old_paths { - if let Err(error) = tokio::fs::remove_file(path).await { - if error.kind() != std::io::ErrorKind::NotFound { - cleanup_error = Some(error.to_string()); - } - } - } - if cleanup_error.is_none() { - if let Err(error) = remove_empty_torrent_move_directories( - &move_old_paths, - &old_destination, - ) { - cleanup_error = Some(error); - } - } - if let Some(error) = cleanup_error { - let _ = persist_torrent_destination(database.inner(), &id, &new_destination.to_string_lossy(), true); - if let Err(journal_error) = write_torrent_move_journal( - &journal, - "sourceCleanupPending", - &id, - &old_destination, - &new_destination, - ownership.primary.as_deref(), - &ownership.removal, - &new_primary, - &new_removal_paths, - &staging_root, - &staging_paths, - &move_old_paths, - &move_new_paths, - total_bytes, - ).await { - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status) - .with_destination(new_destination.to_string_lossy()), - ); - return Err(format!( - "Torrent data moved, but cleanup recovery could not be recorded: {journal_error}" - )); - } - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status) - .with_destination(new_destination.to_string_lossy()), - ); - drop(control_guard); - return Err(format!("Torrent data moved, but old files need cleanup: {error}")); - } - let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id); - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status) - .with_destination(new_destination.to_string_lossy()), - ); - drop(control_guard); - Ok(()) -} - -#[tauri::command] -async fn cancel_torrent_move_data( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, - session_id: Option, -) -> Result<(), String> { - if id.trim().is_empty() { - return Err("invalid Torrent download id".to_string()); - } - if caller.label() == "main" { - properties_window::ensure_main_window(&caller)?; - } else { - let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - properties.with_current_session(caller.label(), &session_id, || { - state.queue_manager.cancel_torrent_move(&id); - Ok(()) - })?; - return Ok(()); - } - state.queue_manager.cancel_torrent_move(&id); - Ok(()) -} - -fn verification_destination( - app_handle: &tauri::AppHandle, - item: &crate::ipc::DownloadItem, -) -> Result { - if let Some(destination) = item - .destination - .as_deref() - .map(str::trim) - .filter(|destination| !destination.is_empty()) - { - return Ok(destination.to_string()); - } - - let settings = crate::settings::load_settings(app_handle)?; - let base = settings.base_download_folder.trim(); - let base = if base.is_empty() { "~/Downloads" } else { base }; - let base_path = crate::resolve_path(base, app_handle); - if !settings.category_subfolders_enabled { - return Ok(base_path.to_string_lossy().to_string()); - } - - let category = format!("{:?}", item.category); - if let Some(override_path) = settings - .category_directory_overrides - .get(&category) - .map(|value| value.trim()) - .filter(|override_path| !override_path.is_empty()) - { - return Ok(crate::resolve_path(override_path, app_handle) - .to_string_lossy() - .to_string()); - } - let subfolder = settings - .category_subfolders - .get(&category) - .map(|value| value.trim()) - .unwrap_or(""); - if subfolder.is_empty() { - Ok(base_path.to_string_lossy().to_string()) - } else { - Ok(base_path.join(subfolder).to_string_lossy().to_string()) - } -} - -#[tauri::command] -async fn verify_torrent_data( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, - database: tauri::State<'_, crate::db::DbState>, - id: String, -) -> Result<(), String> { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - // Verification replaces the current Aria2 lifecycle. Serialize that - // 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; - 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()); - } - if !matches!( - item.status, - crate::ipc::DownloadStatus::Completed - | crate::ipc::DownloadStatus::Paused - | crate::ipc::DownloadStatus::Failed - ) { - return Err("pause the Torrent before verifying its data".to_string()); - } - let path = item - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?; - let restore_status = item.status.as_str().to_string(); - let restore_status_value = item.status; - let restore_has_been_dispatched = item.has_been_dispatched; - let destination = verification_destination(&app_handle, &item)?; - if matches!(item.status, crate::ipc::DownloadStatus::Paused) { - detach_download_for_reconfigure_locked( - &app_handle, - state.inner(), - &id, - &control_guard, - ) - .await?; - } else if state.queue_manager.is_registered(&id).await - || state.queue_manager.aria2_gid_for_download(&id).is_some() - { - return Err("Torrent lifecycle changed; pause it before verifying its data".to_string()); - } - // Allocate the internally-created generation after detaching any retained - // paused lifecycle, so the value includes every cancellation watermark - // observed during that transition. Reservation still remains the atomic - // acceptance point immediately before ownership registration. - let verification_generation = state.queue_manager.next_enqueue_generation(&id).await?; - let enqueue_item = queue::EnqueueItem { - id: item.id.clone(), - queue_id: item - .queue_id - .clone() - .unwrap_or_else(|| "00000000-0000-0000-0000-000000000001".to_string()), - url: item.url.clone(), - destination, - filename: item.file_name.clone(), - connections: item.connections, - speed_limit: item.speed_limit.clone(), - username: item.username.clone(), - password: item.password.clone(), - sftp_host_key_md: item.sftp_host_key_md.clone(), - headers: item.headers.clone(), - checksum: item.checksum.clone(), - cookies: item.cookies.clone(), - mirrors: None, - user_agent: None, - max_tries: Some(0), - minimum_normal_download_speed_kib: None, - retry_not_found_errors: None, - adaptive_mirror_selection: None, - proxy: None, - format_selector: None, - cookie_source: None, - is_media: Some(false), - is_torrent: Some(true), - torrent_path: item.torrent_path.clone(), - torrent_file_indices: item.torrent_file_indices.clone(), - torrent_info_hash: item.torrent_info_hash.clone(), - torrent_seed_time: None, - torrent_seed_ratio: None, - torrent_seed_remaining: None, - torrent_web_seeds: None, - torrent_upload_limit: None, - torrent_max_peers: None, - torrent_peer_speed_limit: None, - torrent_check_integrity: Some(true), - torrent_trackers: None, - torrent_exclude_trackers: None, - torrent_tracker_connect_timeout: None, - torrent_tracker_timeout: None, - torrent_tracker_interval: None, - torrent_stop_timeout: None, - torrent_prioritize_piece: None, - // Preserve the existing removal reservation in the ownership record, - // but the verify-only option path returns before emitting - // bt-remove-unselected-file, so this maintenance lifecycle cannot - // delete data. - torrent_remove_unselected_file: item.torrent_remove_unselected_file, - torrent_encryption_policy: None, - torrent_file_allocation: item.torrent_file_allocation.clone(), - torrent_verify_only: Some(true), - torrent_verify_restore_status: Some(restore_status.clone()), - lifecycle_generation: Some(verification_generation.to_string()), - replace_existing_fingerprint: None, - }; - - { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - &id, - database.is_portable(), - |object| { - object.insert("status".to_string(), serde_json::json!("queued")); - object.insert("hasBeenDispatched".to_string(), serde_json::json!(false)); - object.insert("torrentVerifyOnly".to_string(), serde_json::json!(true)); - object.insert( - "torrentVerifyRestoreStatus".to_string(), - serde_json::json!(restore_status), - ); - object.insert( - "torrentVerifyNative".to_string(), - serde_json::json!(restore_status), - ); - Ok(()) - }, - )?; - } - - if let Err(error) = - enqueue_download_locked(&app_handle, state.inner(), enqueue_item, &control_guard).await - { - // Roll back only this verification marker, and only while the row is - // still this verification lifecycle (queued or already restored by a - // renderer snapshot). Never restore the stale full array captured - // before enqueue: frontend persistence or another command may have - // changed unrelated rows in the meantime. - let rollback_result = (|| { - let mut connection = database.lock()?; - crate::db::mutate_download( - &mut connection, - &id, - database.is_portable(), - |object| { - let has_verification_marker = object - .get("torrentVerifyOnly") - .and_then(serde_json::Value::as_bool) - == Some(true) - && object - .get("torrentVerifyRestoreStatus") - .and_then(serde_json::Value::as_str) - == Some(restore_status.as_str()); - let current_status = object - .get("status") - .and_then(serde_json::Value::as_str); - let is_verification_marker = has_verification_marker - && (current_status == Some("queued") - || current_status == Some(restore_status.as_str())); - // The native marker is an additional persistence fence, - // not a prerequisite for rollback. A renderer snapshot - // may have already acknowledged and removed that native - // marker while enqueue is still in flight; the failed - // operation must still clear its own queued lifecycle. - if is_verification_marker { - object.insert( - "status".to_string(), - serde_json::json!(restore_status), - ); - match restore_has_been_dispatched { - Some(value) => { - object.insert( - "hasBeenDispatched".to_string(), - serde_json::json!(value), - ); - } - None => { - object.remove("hasBeenDispatched"); - } - } - object.remove("torrentVerifyOnly"); - object.remove("torrentVerifyRestoreStatus"); - object.remove("torrentVerifyNative"); - } - Ok(is_verification_marker) - }, - ) - })(); - let rollback_applied = match rollback_result { - Ok(applied) => applied, - Err(rollback_error) => { - return Err(format!( - "{error}; failed to roll back Torrent verification state: {rollback_error}" - )); - } - }; - if rollback_applied { - use tauri::Emitter; - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(&id, restore_status_value), - ); - } - return Err(error.to_string()); - } - Ok(()) -} - -fn replace_persisted_torrent_web_seeds( - database: &crate::db::DbState, - id: &str, - seeds: &[crate::ipc::TorrentWebSeed], -) -> Result, String> { - let mut connection = database.lock()?; - let next_seeds = serde_json::to_value(seeds) - .map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - let previous_seeds = object.get("torrentWebSeeds").cloned(); - object.insert("torrentWebSeeds".to_string(), next_seeds.clone()); - object.insert("torrentWebSeedsNative".to_string(), next_seeds.clone()); - Ok(previous_seeds) - }, - ) -} - -fn restore_persisted_torrent_web_seeds( - database: &crate::db::DbState, - id: &str, - expected_seeds: &[crate::ipc::TorrentWebSeed], - previous_seeds: Option, -) -> Result<(), String> { - let mut connection = database.lock()?; - let expected_value = serde_json::to_value(expected_seeds) - .map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?; - crate::db::mutate_download( - &mut connection, - id, - database.is_portable(), - |object| { - if object.get("torrentWebSeeds") == Some(&expected_value) { - match previous_seeds.clone() { - Some(previous) => { - object.insert("torrentWebSeeds".to_string(), previous); - } - None => { - object.remove("torrentWebSeeds"); - } - } - object.remove("torrentWebSeedsNative"); - } else { - log::warn!( - "Torrent web-seed rollback [{}] skipped because persisted state changed concurrently", - id - ); - } - Ok(()) - }, - ) -} - -async fn normalize_persisted_torrent_web_seeds( - database: &crate::db::DbState, - app_handle: &tauri::AppHandle, - id: &str, - seeds: &[crate::ipc::TorrentWebSeed], -) -> Result, String> { - let record = { - let connection = database.lock()?; - crate::db::load_downloads(&connection)? - .into_iter() - .find_map(|record| { - serde_json::from_str::(&record) - .ok() - .filter(|item| item.id == id) - }) - .ok_or_else(|| "download is not persisted".to_string())? - }; - let path = record - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(app_handle, id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached Torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let normalized = crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)?; - let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::>(); - validate_torrent_web_seed_destinations(&web_seed_uris).await?; - Ok(normalized) -} - -#[tauri::command] -async fn get_torrent_web_seeds( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - database: tauri::State<'_, crate::db::DbState>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result, String> { - properties_window::ensure_properties_or_main( - &caller, - &properties, - &id, - )?; - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; - if state.queue_manager.is_registered(&id).await - && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)) - { - return state - .queue_manager - .get_aria2_torrent_web_seeds_locked(&id, &control_guard) - .await; - } - let persisted_seeds = { - let connection = database.lock()?; - crate::db::load_downloads(&connection)? - .into_iter() - .find_map(|record| { - serde_json::from_str::(&record) - .ok() - .filter(|item| item.id == id) - .map(|item| item.torrent_web_seeds) - }) - .ok_or_else(|| "download is not persisted".to_string())? - }; - let Some(seeds) = persisted_seeds else { - return Ok(Vec::new()); - }; - if seeds.is_empty() { - return Ok(seeds); - } - normalize_persisted_torrent_web_seeds( - database.inner(), - &state.queue_manager.app_handle(), - &id, - &seeds, - ) - .await -} - -#[tauri::command] -async fn set_torrent_web_seeds( - caller: tauri::WebviewWindow, - database: tauri::State<'_, crate::db::DbState>, - state: tauri::State<'_, AppState>, - id: String, - seeds: Vec, -) -> Result, String> { - properties_window::ensure_main_window(&caller)?; - let control_guard = state.queue_manager.acquire_aria2_control(&id).await; - 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 { - state - .queue_manager - .normalize_aria2_torrent_web_seeds_locked(&id, &seeds, &control_guard) - .await? - } else { - normalize_persisted_torrent_web_seeds( - database.inner(), - &state.queue_manager.app_handle(), - &id, - &seeds, - ) - .await? - }; - let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::>(); - validate_torrent_web_seed_destinations(&web_seed_uris).await?; - let previous_seeds = - replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?; - // The download can cross the queued/active boundary while metadata is - // being normalized and persistence is updated. Recheck before returning - // so a newly active Torrent receives the live Aria2 change instead of - // waiting for a restart to apply its persisted value. - let active_now = state.queue_manager.is_registered(&id).await - && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)); - if !active_now { - return Ok(normalized); - } - match state - .queue_manager - .set_aria2_torrent_web_seeds_locked(&id, normalized.clone(), &control_guard) - .await - { - Ok((result, previous_live_seeds)) => { - if let Err(persist_error) = - replace_persisted_torrent_web_seeds(database.inner(), &id, &result) - { - // The live operation succeeded, but the durable value must - // remain transactional. Try to restore the exact value that - // was persisted before this command before reporting the - // persistence failure to the caller. - if let Err(rollback_error) = state - .queue_manager - .set_aria2_torrent_web_seeds_locked( - &id, - previous_live_seeds, - &control_guard, - ) - .await - { - log::error!( - "Torrent web-seed live rollback [{}] failed after persistence error: {}", - id, - rollback_error - ); - } - if let Err(restore_error) = restore_persisted_torrent_web_seeds( - database.inner(), - &id, - &normalized, - previous_seeds.clone(), - ) { - log::error!( - "Torrent web-seed persistence rollback [{}] failed: {}", - id, - restore_error - ); - } - return Err(format!( - "Torrent web seeds changed live but could not be persisted: {persist_error}" - )); - } - Ok(result) - } - Err(error) => { - if let Err(restore_error) = restore_persisted_torrent_web_seeds( - database.inner(), - &id, - &normalized, - previous_seeds, - ) { - log::error!("Torrent web-seed rollback [{}] failed: {}", id, restore_error); - } - Err(error) - } - } -} - pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { let trimmed = limit.trim(); if trimmed.is_empty() { @@ -12170,191 +5777,14 @@ pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { }) } -fn normalize_torrent_overall_upload_limit(limit: Option<&str>) -> Result, String> { - let Some(limit) = limit else { - return Ok(None); - }; - if limit.trim().is_empty() { - return Ok(None); - } - normalize_speed_limit_for_aria2(limit) - .map(Some) - .ok_or_else(|| "Torrent overall upload limit is invalid".to_string()) -} - -fn apply_aria2_torrent_peer_discovery_options( - command: &mut std::process::Command, - enable_dht: bool, - enable_dht6: bool, - enable_pex: bool, - enable_lpd: bool, -) { - // These are daemon-global BitTorrent options. Keep them explicit at - // launch so Firelink does not silently inherit a different aria2 - // configuration from the host or a future bundled-engine default. - command - .arg(format!("--enable-dht={enable_dht}")) - .arg(format!("--enable-dht6={enable_dht6}")) - .arg(format!("--enable-peer-exchange={enable_pex}")) - .arg(format!("--bt-enable-lpd={enable_lpd}")); -} - -fn apply_aria2_torrent_network_options( - command: &mut std::process::Command, - listen_port: &str, - dht_listen_port: &str, - external_ip: &str, - dht_entry_point: &str, - dht_entry_point6: &str, - dht_listen_addr6: &str, - lpd_interface: &str, - bind_address: &str, - ipv6_enabled: bool, -) { - for (option, value) in [ - ("--listen-port", listen_port), - ("--dht-listen-port", dht_listen_port), - ("--bt-external-ip", external_ip), - ("--dht-entry-point", dht_entry_point), - ("--dht-entry-point6", dht_entry_point6), - ("--dht-listen-addr6", dht_listen_addr6), - ("--bt-lpd-interface", lpd_interface), - ] { - let value = value.trim(); - if !value.is_empty() { - command.arg(format!("{option}={value}")); - } - } - if !bind_address.trim().is_empty() { - command.arg(format!("--interface={}", bind_address.trim())); - } - if !ipv6_enabled { - command.arg("--disable-ipv6=true"); - } -} - -fn apply_aria2_torrent_dht_paths( - command: &mut std::process::Command, - dht_path: &std::path::Path, - dht6_path: &std::path::Path, -) { - command - .arg(format!("--dht-file-path={}", dht_path.display())) - .arg(format!("--dht-file-path6={}", dht6_path.display())); -} - -fn apply_aria2_torrent_dht_options( - command: &mut std::process::Command, - message_timeout: u32, -) { - let timeout = queue::normalize_torrent_dht_message_timeout(message_timeout) - .unwrap_or(queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT); - command.arg(format!("--dht-message-timeout={timeout}")); -} - -fn apply_aria2_server_stat_options( - command: &mut std::process::Command, - path: Option<&std::path::Path>, -) { - let Some(path) = path else { - return; - }; - command - .arg(format!("--server-stat-if={}", path.display())) - .arg(format!("--server-stat-of={}", path.display())) - .arg("--server-stat-timeout=86400"); -} - -fn apply_aria2_torrent_peer_identity_options( - command: &mut std::process::Command, - peer_id_prefix: &str, - peer_agent: &str, -) { - for (option, value) in [ - ("--peer-id-prefix", peer_id_prefix), - ("--peer-agent", peer_agent), - ] { - let value = value.trim(); - if !value.is_empty() { - command.arg(format!("{option}={value}")); - } - } -} - -fn aria2_rpc_port_is_occupied(port: u16) -> bool { - std::net::TcpListener::bind(("127.0.0.1", port)).is_err() -} - -fn apply_aria2_torrent_global_options( - command: &mut std::process::Command, - max_open_files: u32, - overall_upload_limit: Option<&str>, - disk_cache: &str, -) { - let max_open_files = queue::normalize_torrent_max_open_files(max_open_files) - .unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES); - command.arg(format!("--bt-max-open-files={max_open_files}")); - let disk_cache = queue::normalize_aria2_disk_cache(Some(disk_cache)) - .unwrap_or_else(|_| queue::DEFAULT_ARIA2_DISK_CACHE.to_string()); - command.arg(format!("--disk-cache={disk_cache}")); - if let Some(limit) = overall_upload_limit.and_then(normalize_speed_limit_for_aria2) { - command.arg(format!("--max-overall-upload-limit={limit}")); - } -} - -#[tauri::command(rename_all = "snake_case")] -async fn set_torrent_max_open_files( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - max_open_files: u32, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)?; - rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.changeGlobalOption", - serde_json::json!([{"bt-max-open-files": max_open_files.to_string()}]), - ) - .await - .and_then(|result| ensure_aria2_ok_result("changeGlobalOption", &result)) - .map_err(|error| format!("Failed to set Torrent maximum open files: {error}")) -} - -#[tauri::command] -async fn set_torrent_overall_upload_limit( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - limit: Option, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - let normalized_limit = normalize_torrent_overall_upload_limit(limit.as_deref())?; - let limit_str = normalized_limit.as_deref().unwrap_or("0"); - rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.changeGlobalOption", - serde_json::json!([{"max-overall-upload-limit": limit_str}]), - ) - .await - .and_then(|result| ensure_aria2_ok_result("changeGlobalOption", &result)) - .map_err(|error| format!("Failed to set Torrent overall upload limit: {error}")) -} - #[tauri::command] async fn set_global_speed_limit( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: Option, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - let normalized_limit = match limit.as_deref().map(str::trim) { - None | Some("") => None, - Some(raw) => Some( - normalize_speed_limit_for_aria2(raw) - .ok_or_else(|| "Global speed limit is invalid".to_string())?, - ), - }; + let normalized_limit = limit + .as_deref() + .and_then(normalize_speed_limit_for_aria2); let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string()); rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -12363,7 +5793,6 @@ async fn set_global_speed_limit( serde_json::json!([{"max-overall-download-limit": limit_str}]), ) .await - .and_then(|result| ensure_aria2_ok_result("changeGlobalOption", &result)) .map(|_| { state .queue_manager @@ -12376,8 +5805,7 @@ async fn set_global_speed_limit( } #[tauri::command] -fn check_automation_permission(caller: tauri::WebviewWindow) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn check_automation_permission() -> Result<(), String> { #[cfg(target_os = "macos")] { use objc::runtime::Object; @@ -12415,8 +5843,7 @@ fn check_automation_permission(caller: tauri::WebviewWindow) -> Result<(), Strin } #[tauri::command] -fn request_automation_permission(caller: tauri::WebviewWindow) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn request_automation_permission() -> Result<(), String> { #[cfg(target_os = "macos")] { system_shutdown::request_permission_dialog() @@ -12429,11 +5856,7 @@ fn request_automation_permission(caller: tauri::WebviewWindow) -> Result<(), Str #[tauri::command] #[allow(unused_variables)] -fn open_automation_settings( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] { use tauri_plugin_opener::OpenerExt; @@ -12452,12 +5875,7 @@ fn open_automation_settings( } #[tauri::command] -fn get_free_space( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - path: String, -) -> Result { - properties_window::ensure_main_window(&caller)?; +fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result { use sysinfo::Disks; let disks = Disks::new_with_refreshed_list(); @@ -12495,13 +5913,11 @@ fn get_free_space( #[tauri::command(async)] fn set_keychain_password( - caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, password: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -12515,11 +5931,9 @@ fn set_keychain_password( #[tauri::command(async)] fn get_keychain_password( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result { - properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -12531,11 +5945,9 @@ fn get_keychain_password( #[tauri::command(async)] fn delete_keychain_password( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -12547,7 +5959,6 @@ fn delete_keychain_password( #[tauri::command(async)] fn save_site_login( - caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, @@ -12555,7 +5966,6 @@ fn save_site_login( username: String, password: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; require_keychain_access(&state)?; let connection = database.lock()?; crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) @@ -12563,12 +5973,10 @@ fn save_site_login( #[tauri::command(async)] fn delete_site_login( - caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; require_keychain_access(&state)?; let connection = database.lock()?; crate::db::delete_site_login(&connection, &id) @@ -12578,11 +5986,7 @@ fn delete_site_login( /// decision. The process-local gate prevents any stale or early IPC caller /// from producing a native OS prompt before the explanation is visible. #[tauri::command] -fn authorize_keychain_access( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> { if state.storage_layout.is_portable() { return Ok(()); } @@ -12625,11 +6029,9 @@ struct KeychainGrantStatus { /// token with the portable settings folder. #[tauri::command(async)] fn hydrate_extension_pairing_token( - caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; let connection = database.lock()?; if app_state.storage_layout.is_portable() { @@ -12679,10 +6081,8 @@ fn hydrate_extension_pairing_token( /// waits for an explicit user decision. #[tauri::command] fn get_session_pairing_token( - caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; let token = app_state .extension_pairing_token .read() @@ -12701,11 +6101,9 @@ fn get_session_pairing_token( #[tauri::command(async)] fn regenerate_pairing_token( - caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { - properties_window::ensure_main_window(&caller)?; let connection = database.lock()?; let generated = crate::db::generate_pairing_token(); @@ -12763,11 +6161,9 @@ fn regenerate_pairing_token( // also perform synchronous IPC to their desktop credential service. #[tauri::command] async fn grant_keychain_access( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, request_id: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let app_state = app_handle.state::(); let grant_guard = begin_keychain_grant(app_state.inner(), &request_id)?; @@ -12797,11 +6193,9 @@ async fn grant_keychain_access( #[tauri::command] fn get_keychain_grant_status( - caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result { - properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let grant = app_state .keychain_grant @@ -12833,11 +6227,9 @@ fn get_keychain_grant_status( #[tauri::command] fn accept_keychain_grant( - caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result { - properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let mut grant = app_state .keychain_grant @@ -12868,11 +6260,9 @@ fn accept_keychain_grant( #[tauri::command] fn abandon_keychain_grant( - caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result, String> { - properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let mut grant = app_state .keychain_grant @@ -12979,22 +6369,18 @@ fn grant_keychain_access_blocking( #[tauri::command] fn acknowledge_pairing_token_change( - caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::acknowledge_pairing_token_notice(&connection) } #[tauri::command] fn db_save_settings( - caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, data: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; let existing = crate::db::load_settings(&connection)?; let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?; @@ -13004,22 +6390,13 @@ fn db_save_settings( let sanitized = crate::db::strip_pairing_token_from_settings(&merged)?; crate::db::preserve_legacy_pairing_token(existing.as_deref(), &sanitized)? }; - let merged = crate::settings::canonicalize_torrent_network_settings(&merged)?; - let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged.clone()))?; - // Validate the complete settings document before replacing the durable - // copy. A malformed renderer snapshot must not poison restart hydration or - // leave the previous valid settings unrecoverable after this command - // reports an error. crate::db::save_settings(&connection, &merged)?; + let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; let prevent_system_sleep = decoded.prevents_sleep_while_downloading; let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading; if let Ok(mut cached) = app_state.scheduler_settings.write() { - *cached = Some(decoded.clone()); + *cached = Some(decoded); } - app_state.queue_manager.configure_seed_capacity( - decoded.torrent_separate_seed_slots, - decoded.torrent_max_concurrent_seeds, - ); if let Err(error) = app_state .power_manager .set_preferences(prevent_system_sleep, prevent_display_sleep) @@ -13030,425 +6407,57 @@ fn db_save_settings( } #[tauri::command] -fn db_load_settings( - caller: tauri::WebviewWindow, - state: tauri::State<'_, crate::db::DbState>, -) -> Result, String> { - properties_window::ensure_main_window(&caller)?; +fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { let connection = state.lock()?; let settings = crate::db::load_settings(&connection)?; if state.is_portable() { - return settings - .map(|data| crate::settings::canonicalize_torrent_network_settings(&data)) - .transpose(); + return Ok(settings); } settings .map(|data| crate::db::strip_pairing_token_from_settings(&data)) - .transpose()? - .map(|data| crate::settings::canonicalize_torrent_network_settings(&data)) .transpose() } -#[tauri::command] -fn canonicalize_torrent_network_setting( - caller: tauri::WebviewWindow, - field: String, - value: String, -) -> Result { - properties_window::ensure_main_window(&caller)?; - crate::settings::canonicalize_torrent_network_setting(&field, &value) -} - #[tauri::command] fn db_get_all_downloads( - caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, ) -> Result, String> { - properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::load_downloads(&connection) } -#[tauri::command] -async fn clear_torrent_removal_paths( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, - id: String, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - // This command is reached from the trusted renderer, but it still owns a - // destructive reservation boundary. Serialize it with terminal/control - // transitions and refuse to clear a lifecycle that the backend still - // owns. The frontend's registration set is only a hint and must not be - // the safety check. - let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; - if state.queue_manager.aria2_gid_for_download(&id).is_some() - || state.queue_manager.is_registered(&id).await - { - return Err( - "cannot clear Torrent removal paths while the backend owns the download".to_string(), - ); - } - crate::download_ownership::clear_torrent_removal_paths(&app_handle, &id) -} - -#[tauri::command] -fn reconcile_torrent_removal_reservations( - caller: tauri::WebviewWindow, - state: tauri::State<'_, crate::db::DbState>, -) -> Result { - properties_window::ensure_main_window(&caller)?; - let connection = state.lock()?; - crate::db::reconcile_torrent_removal_paths_after_restart(&connection) -} - -fn retained_torrent_id_from_persisted_record(record: &str) -> Option { - let value = serde_json::from_str::(record).ok()?; - let object = value.as_object()?; - if object.get("isTorrent").and_then(serde_json::Value::as_bool) != Some(true) - || object.get("torrentPath").is_none_or(serde_json::Value::is_null) - { - return None; - } - object - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty()) - .map(ToOwned::to_owned) -} - -fn retained_torrent_info_hash_from_persisted_record(record: &str) -> Option { - let value = serde_json::from_str::(record).ok()?; - let object = value.as_object()?; - if object.get("isTorrent").and_then(serde_json::Value::as_bool) != Some(true) - || object.get("torrentPath").is_none_or(serde_json::Value::is_null) - { - return None; - } - object - .get("torrentInfoHash") - .and_then(serde_json::Value::as_str) - .and_then(crate::torrent::canonical_info_hash) -} - #[tauri::command] fn db_replace_downloads( - caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; let portable = state.is_portable(); let mut connection = state.lock()?; - let existing = crate::db::load_downloads(&connection)?; - let data = merge_durable_torrent_telemetry(&existing, &data)?; crate::db::replace_downloads(&mut connection, &data, portable) } #[tauri::command] -fn db_commit_download_state( - caller: tauri::WebviewWindow, - state: tauri::State<'_, crate::db::DbState>, - downloads_data: String, - queues_data: String, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - let portable = state.is_portable(); - let mut connection = state.lock()?; - let existing = crate::db::load_downloads(&connection)?; - let downloads_data = merge_durable_torrent_telemetry(&existing, &downloads_data)?; - crate::db::replace_downloads_and_queues( - &mut connection, - &downloads_data, - &queues_data, - portable, - ) -} - -fn persisted_destinations_equal(left: &str, right: &str) -> bool { - let left = std::path::Path::new(left.trim()); - let right = std::path::Path::new(right.trim()); - crate::platform::paths_equal(left, right) - || std::fs::canonicalize(left) - .ok() - .zip(std::fs::canonicalize(right).ok()) - .is_some_and(|(left, right)| crate::platform::paths_equal(&left, &right)) -} - -/// The renderer saves complete download snapshots, while native commands and -/// the poller checkpoint Torrent state independently. Merge these writers at -/// the persistence boundary so an older renderer snapshot cannot lower native -/// lifetime totals or roll back a just-committed relocation. -fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result { - let mut native_state: HashMap< - String, - ( - u64, - u64, - Option, - bool, - Option, - Option, - ), - > = HashMap::new(); - for record in existing { - let Ok(value) = serde_json::from_str::(record) else { - continue; - }; - let Some(object) = value.as_object() else { - continue; - }; - let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else { - continue; - }; - let uploaded = object - .get("torrentUploadedBytes") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let seeded = object - .get("torrentSeededSeconds") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - native_state.insert( - id.to_string(), - ( - uploaded, - seeded, - object - .get("torrentMoveDestination") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - object - .get("torrentRelocationCheckPending") - .and_then(serde_json::Value::as_bool) - == Some(true), - object.get("torrentWebSeedsNative").cloned(), - object - .get("torrentVerifyNative") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string), - ), - ); - } - - let mut values: serde_json::Value = serde_json::from_str(data) - .map_err(|error| format!("failed to decode downloads: {error}"))?; - let records = values - .as_array_mut() - .ok_or_else(|| "persisted downloads must be an array".to_string())?; - for value in records { - let Some(object) = value.as_object_mut() else { - continue; - }; - let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else { - continue; - }; - let Some(( - existing_uploaded, - existing_seeded, - native_destination, - relocation_check_pending, - native_web_seeds, - native_verify_restore_status, - )) = native_state.get(id).cloned() - else { - continue; - }; - if let Some(native_destination) = native_destination { - let incoming_destination = object - .get("destination") - .and_then(serde_json::Value::as_str); - if incoming_destination - .is_some_and(|value| persisted_destinations_equal(value, &native_destination)) - { - object.remove("torrentMoveDestination"); - } else { - object.insert( - "destination".to_string(), - serde_json::Value::String(native_destination.clone()), - ); - object.insert( - "torrentMoveDestination".to_string(), - serde_json::Value::String(native_destination), - ); - } - } - if relocation_check_pending { - object.insert( - "torrentRelocationCheckPending".to_string(), - serde_json::Value::Bool(true), - ); - } - if let Some(native_web_seeds) = native_web_seeds { - if object.get("torrentWebSeeds") == Some(&native_web_seeds) { - object.remove("torrentWebSeedsNative"); - } else { - object.insert("torrentWebSeeds".to_string(), native_web_seeds.clone()); - object.insert("torrentWebSeedsNative".to_string(), native_web_seeds); - } - } - if let Some(restore_status) = native_verify_restore_status { - let incoming_acknowledges_marker = object - .get("torrentVerifyOnly") - .and_then(serde_json::Value::as_bool) - == Some(true) - && object - .get("torrentVerifyRestoreStatus") - .and_then(serde_json::Value::as_str) - == Some(restore_status.as_str()); - object.insert( - "torrentVerifyOnly".to_string(), - serde_json::Value::Bool(true), - ); - object.insert( - "torrentVerifyRestoreStatus".to_string(), - serde_json::Value::String(restore_status.clone()), - ); - if incoming_acknowledges_marker { - object.remove("torrentVerifyNative"); - } else { - object.insert( - "torrentVerifyNative".to_string(), - serde_json::Value::String(restore_status), - ); - } - } - let uploaded = object - .get("torrentUploadedBytes") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - .max(existing_uploaded); - let seeded = object - .get("torrentSeededSeconds") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - .max(existing_seeded); - if existing_uploaded > 0 || object.contains_key("torrentUploadedBytes") { - object.insert( - "torrentUploadedBytes".to_string(), - serde_json::Value::from(uploaded), - ); - } - if existing_seeded > 0 || object.contains_key("torrentSeededSeconds") { - object.insert( - "torrentSeededSeconds".to_string(), - serde_json::Value::from(seeded), - ); - } - } - serde_json::to_string(&values) - .map_err(|error| format!("failed to encode downloads: {error}")) -} - -#[tauri::command] -fn db_get_all_queues( - caller: tauri::WebviewWindow, - state: tauri::State<'_, crate::db::DbState>, -) -> Result, String> { - properties_window::ensure_main_window(&caller)?; +fn db_get_all_queues(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { let connection = state.lock()?; crate::db::load_queues(&connection) } #[tauri::command] fn db_replace_queues( - caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; let mut connection = state.lock()?; crate::db::replace_queues(&mut connection, &data) } -fn target_fingerprint(path: &std::path::Path, metadata: &std::fs::Metadata) -> String { - #[cfg(not(target_os = "windows"))] - let _ = path; - #[cfg(unix)] - let identity = { - use std::os::unix::fs::MetadataExt; - format!("unix:{}:{}", metadata.dev(), metadata.ino()) - }; - #[cfg(windows)] - let identity = crate::platform::file_identity(path) - .map(|identity| format!("windows:{identity}")) - .unwrap_or_else(|| format!("windows-path:{}", crate::platform::path_identity(path))); - #[cfg(not(any(unix, windows)))] - let identity = "portable".to_string(); - let modified = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|value| format!("{}:{}", value.as_secs(), value.subsec_nanos())) - .unwrap_or_else(|| "unknown".to_string()); - format!("{}:{}:{}", identity, metadata.len(), modified) -} - -fn classify_download_target(metadata: &std::fs::Metadata) -> crate::ipc::DownloadTargetKind { - if metadata_is_link_or_reparse(metadata) { - crate::ipc::DownloadTargetKind::Symlink - } else if metadata.is_file() { - crate::ipc::DownloadTargetKind::RegularFile - } else if metadata.is_dir() { - crate::ipc::DownloadTargetKind::Directory - } else { - crate::ipc::DownloadTargetKind::Special - } -} - #[tauri::command] -fn inspect_download_target( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - path: String, -) -> Result { - properties_window::ensure_main_window(&caller)?; - let resolved = resolve_path(&path, &app_handle); - let metadata = match std::fs::symlink_metadata(&resolved) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - if let Some(parent) = resolved.parent() { - if !is_safe_path(parent, &app_handle) { - return Err("Download target is outside an approved location".to_string()); - } - } - let owned_by = crate::download_ownership::owner_for_path(&app_handle, &resolved)?; - return Ok(crate::ipc::DownloadTargetInfo { - kind: crate::ipc::DownloadTargetKind::Missing, - fingerprint: None, - owned_by, - }); - } - Err(error) => return Err(format!("Could not inspect download target: {error}")), - }; - - let kind = classify_download_target(&metadata); - if kind == crate::ipc::DownloadTargetKind::Symlink { - if resolved - .parent() - .is_none_or(|parent| !is_safe_path(parent, &app_handle)) - { - return Err("Download target is outside an approved location".to_string()); - } - return Ok(crate::ipc::DownloadTargetInfo { - kind: crate::ipc::DownloadTargetKind::Symlink, - fingerprint: None, - owned_by: None, - }); +fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { + let resolved_dest = resolve_path(&path, &app_handle); + if !is_safe_path(&resolved_dest, &app_handle) { + return false; } - if !is_safe_path(&resolved, &app_handle) { - return Err("Download target is outside an approved location".to_string()); - } - - let owned_by = crate::download_ownership::owner_for_path(&app_handle, &resolved)?; - Ok(crate::ipc::DownloadTargetInfo { - kind, - fingerprint: (kind == crate::ipc::DownloadTargetKind::RegularFile) - .then(|| target_fingerprint(&resolved, &metadata)), - owned_by, - }) + resolved_dest.exists() } fn collect_log_files(log_dir: &std::path::Path) -> Result, String> { @@ -13510,28 +6519,30 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { use std::sync::OnceLock; static SECRET: OnceLock = OnceLock::new(); static QUOTED_SECRET: OnceLock = OnceLock::new(); - static COOKIE_HEADER: OnceLock = OnceLock::new(); + static HEADER: OnceLock = OnceLock::new(); static QUERY: OnceLock = OnceLock::new(); static USERINFO: OnceLock = OnceLock::new(); static FRAGMENT: OnceLock = OnceLock::new(); let secret = SECRET.get_or_init(|| { regex::Regex::new( - r"(?i)(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)\s*[:=]\s*([^\r\n,;]+)", + r"(?i)(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)\s*[:=]\s*([^\r\n,;]+)", ) .expect("valid secret redaction regex") }); let quoted_secret = QUOTED_SECRET.get_or_init(|| { regex::Regex::new( - r#"(?i)(["'])(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, + r#"(?i)(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, ) .expect("valid quoted secret redaction regex") }); - let cookie_header = COOKIE_HEADER.get_or_init(|| { - regex::Regex::new(r"(?i)((?:set-)?cookie2?)\s*[:=]\s*[^\r\n]+") - .expect("valid cookie header redaction regex") + let header = HEADER.get_or_init(|| { + regex::Regex::new( + r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+", + ) + .expect("valid sensitive header redaction regex") }); let query = QUERY.get_or_init(|| { - regex::Regex::new(r#"([A-Za-z][A-Za-z0-9+.-]*://[^\s?\"'<>},\]]+)\?[^\s\"'<>},\]]+"#) + regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?]+)\?[^\s]+") .expect("valid URL query redaction regex") }); let userinfo = USERINFO.get_or_init(|| { @@ -13542,13 +6553,13 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?#]+)#\S+") .expect("valid URL fragment redaction regex") }); - let redacted = query.replace_all(line, "$1?[redacted]"); - let redacted = fragment.replace_all(&redacted, "$1#[redacted]"); - let redacted = userinfo.replace_all(&redacted, "$1[redacted]@"); - let redacted = cookie_header.replace_all(&redacted, "$1: [redacted]"); + let redacted = header.replace_all(line, "$1: [redacted]"); let redacted = quoted_secret.replace_all(&redacted, "$1$2$3$4[redacted]"); - secret - .replace_all(&redacted, "$1=[redacted]") + let redacted = secret.replace_all(&redacted, "$1=[redacted]"); + let redacted = userinfo.replace_all(&redacted, "$1[redacted]@"); + let redacted = query.replace_all(&redacted, "$1?[redacted]"); + fragment + .replace_all(&redacted, "$1#[redacted]") .into_owned() } @@ -13592,12 +6603,7 @@ fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String } #[tauri::command] -async fn read_logs( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - limit: usize, -) -> Result, String> { - properties_window::ensure_main_window(&caller)?; +async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result, String> { let mut lines = Vec::new(); for file in log_files(&app_handle).await? { let content = tokio::fs::read_to_string(&file) @@ -13617,8 +6623,7 @@ async fn read_logs( } #[tauri::command] -async fn clear_logs(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +async fn clear_logs(app_handle: tauri::AppHandle) -> Result<(), String> { for file in log_files(&app_handle).await? { tokio::fs::write(&file, "") .await @@ -13629,12 +6634,10 @@ async fn clear_logs(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) #[tauri::command] async fn export_logs( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, destination: Option, ) -> Result { - properties_window::ensure_main_window(&caller)?; let mut output = format!( "Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n", env!("CARGO_PKG_VERSION"), @@ -13708,12 +6711,7 @@ async fn export_logs( } #[tauri::command] -fn toggle_tray_icon( - caller: tauri::WebviewWindow, - app_handle: tauri::AppHandle, - show: bool, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { if show { build_main_tray(&app_handle) } else { @@ -13791,11 +6789,9 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> { #[tauri::command] fn set_extension_pairing_token( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, token: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; if token.is_empty() || token.len() > 512 { return Err("Invalid extension pairing token".to_string()); } @@ -13809,13 +6805,7 @@ fn set_extension_pairing_token( } #[tauri::command] -fn get_extension_server_port( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, -) -> Option { - if properties_window::ensure_main_window(&caller).is_err() { - return None; - } +fn get_extension_server_port(state: tauri::State<'_, AppState>) -> Option { state .extension_server_port .read() @@ -13824,12 +6814,7 @@ fn get_extension_server_port( } #[tauri::command] -fn set_extension_frontend_ready( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, - ready: bool, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) { state .extension_frontend_ready .store(ready, Ordering::Release); @@ -13839,26 +6824,13 @@ fn set_extension_frontend_ready( .send(download::DownloadCmd::FrontendReady(ready)) .await; }); - Ok(()) -} - -#[tauri::command] -fn ack_frontend_exit( - caller: tauri::WebviewWindow, - state: tauri::State<'_, AppState>, -) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; - state.frontend_exit_flush.acknowledge(); - Ok(()) } #[tauri::command] fn ack_extension_download( - caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, request_id: String, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; if request_id.len() != 32 || !request_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("Invalid extension request id".to_string()); } @@ -13880,56 +6852,26 @@ mod tests { media_progress_speed, cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, - metadata_error_code, - is_remote_torrent_source, normalize_speed_limit_for_aria2, - normalize_torrent_overall_upload_limit, - apply_aria2_torrent_global_options, - apply_aria2_torrent_network_options, - apply_aria2_torrent_peer_identity_options, - apply_aria2_torrent_peer_discovery_options, - apply_aria2_torrent_dht_paths, - apply_aria2_torrent_dht_options, - apply_aria2_server_stat_options, - aria2_rpc_port_is_occupied, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, - collect_opened_torrent_paths, - normalize_opened_torrent_argument, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, has_resumable_download_assets, is_media_artifact_name, - directory_has_non_metadata_entries, primary_path_needs_container_cleanup, should_cleanup_media_artifacts_after_failure, should_retry_without_browser_cookies, retry_metadata_with_cookies, should_retry_metadata_with_cookies, should_send_metadata_credentials, collect_log_files, FirelinkDeepLink, - ytdlp_youtube_extractor_args, YTDLP_YOUTUBE_PLAYER_CLIENTS, percent_decode_metadata_value, MediaProgress, MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, observe_aria2_connections, observe_aria2_connections_with_epoch, Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason, - FrontendExitFlush, - aria2_active_connection_count, aria2_nonnegative_count, + aria2_active_connection_count, parse_media_playlist_metadata, normalize_media_connections, - normalize_media_cookie_source, validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id, - aria2_gid_not_found, - aria2_download_state_progress, preflight_download_destination_access, - retained_torrent_id_from_persisted_record, - retained_torrent_info_hash_from_persisted_record, - merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair, - Aria2DaemonGuard, stale_lifecycle_cleanup_is_noop, - should_skip_unowned_asset_cleanup, expected_unadmitted_download_assets_present, - download_asset_or_sidecar_exists, - classify_download_target, download_target_lock, inspect_unowned_replacement_target, - remove_unowned_replacement_target, - remove_download_assets_permanently, remove_download_container_assets_permanently, - restore_download_replacement, target_fingerprint, DownloadReplacementReservation, }; #[cfg(target_os = "macos")] use super::should_apply_dock_badge_update; use serde_json::json; - use crate::queue; use std::time::{Duration, Instant}; #[test] @@ -13940,915 +6882,6 @@ mod tests { assert!(validate_keychain_grant_request_id(&"x".repeat(129)).is_err()); } - #[test] - fn aria2_missing_gid_detection_is_distinct_from_transport_and_resource_errors() { - assert!(aria2_gid_not_found( - r#"{"code": 1, "message": "GID#abc is not found"}"# - )); - assert!(!aria2_gid_not_found("error trying to connect: connection refused")); - assert!(!aria2_gid_not_found("aria2 error code 3: Resource not found")); - } - - #[test] - fn stale_lifecycle_cleanup_only_targets_the_expected_native_owner() { - assert!(!stale_lifecycle_cleanup_is_noop(Some(7), Some(7))); - assert!(stale_lifecycle_cleanup_is_noop(Some(7), Some(8))); - assert!(!stale_lifecycle_cleanup_is_noop(Some(7), None)); - assert!(!stale_lifecycle_cleanup_is_noop(None, Some(8))); - } - - #[test] - fn unowned_asset_cleanup_only_skips_a_missing_pre_admission_lifecycle() { - assert!(should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Ready, - false, - false, - false - )); - assert!(should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Staged, - false, - false, - false - )); - assert!(should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Paused, - false, - false, - false - )); - assert!(!should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Staged, - true, - false, - false - )); - assert!(!should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Staged, - false, - true, - false - )); - assert!(!should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Staged, - false, - false, - true - )); - assert!(!should_skip_unowned_asset_cleanup( - crate::ipc::DownloadStatus::Completed, - false, - false, - false - )); - } - - #[test] - fn unowned_asset_inspection_detects_primary_files_sidecars_and_missing_paths() { - let directory = tempfile::tempdir().unwrap(); - let primary = directory.path().join("download.bin"); - - assert!(!download_asset_or_sidecar_exists(&primary).unwrap()); - - std::fs::write(&primary, b"payload").unwrap(); - assert!(download_asset_or_sidecar_exists(&primary).unwrap()); - std::fs::remove_file(&primary).unwrap(); - - std::fs::write(directory.path().join("download.bin.part"), b"partial").unwrap(); - assert!(download_asset_or_sidecar_exists(&primary).unwrap()); - } - - #[tokio::test] - async fn pre_admission_torrent_asset_inspection_uses_the_exact_persisted_output_path() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-no-assets", - "url": "https://example.com/file.bin", - "fileName": "file.bin", - "status": "staged", - "category": "Other", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "hasBeenDispatched": false - })) - .unwrap(); - - assert!(!expected_unadmitted_download_assets_present(app.handle(), &item).unwrap()); - std::fs::write(download_root.join("file.bin.aria2"), b"partial").unwrap(); - assert!(expected_unadmitted_download_assets_present(app.handle(), &item).unwrap()); - std::fs::remove_file(download_root.join("file.bin.aria2")).unwrap(); - - let media_item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-media", - "url": "https://example.com/video", - "fileName": "clip.mp4", - "status": "staged", - "category": "Movies", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "isMedia": true, - "hasBeenDispatched": false - })) - .unwrap(); - assert!(!expected_unadmitted_download_assets_present( - app.handle(), - &media_item - ) - .unwrap()); - std::fs::write(download_root.join("clip.f137.mp4"), b"media-part").unwrap(); - assert!(expected_unadmitted_download_assets_present( - app.handle(), - &media_item - ) - .unwrap()); - - let dynamic_media_item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-dynamic-media", - "url": "https://example.com/video", - "fileName": "clip", - "status": "staged", - "category": "Movies", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "isMedia": true, - "hasBeenDispatched": false - })) - .unwrap(); - assert!(expected_unadmitted_download_assets_present( - app.handle(), - &dynamic_media_item - ) - .unwrap()); - - let torrent_item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-torrent", - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "fileName": "torrent-name", - "status": "staged", - "category": "Torrents", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "isTorrent": true, - "hasBeenDispatched": false - })) - .unwrap(); - assert!(!expected_unadmitted_download_assets_present( - app.handle(), - &torrent_item - ) - .unwrap()); - std::fs::write(download_root.join("torrent-name"), b"unrelated").unwrap(); - assert!(!expected_unadmitted_download_assets_present( - app.handle(), - &torrent_item - ) - .unwrap()); - std::fs::remove_file(download_root.join("torrent-name")).unwrap(); - - let cached_torrent_bytes = b"d4:infod6:lengthi5e4:name4:testee"; - let cached_torrent_path = crate::torrent::cache_torrent_bytes( - app.handle(), - "staged-cached-torrent", - cached_torrent_bytes, - ) - .await - .unwrap(); - let cached_torrent_item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-cached-torrent", - "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "fileName": "test", - "status": "staged", - "category": "Torrents", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "isTorrent": true, - "torrentPath": cached_torrent_path, - "hasBeenDispatched": false - })) - .unwrap(); - assert!(!expected_unadmitted_download_assets_present( - app.handle(), - &cached_torrent_item - ) - .unwrap()); - std::fs::write(download_root.join("test.aria2"), b"partial").unwrap(); - assert!(expected_unadmitted_download_assets_present( - app.handle(), - &cached_torrent_item - ) - .unwrap()); - std::fs::remove_file(download_root.join("test.aria2")).unwrap(); - std::fs::write(&cached_torrent_path, b"malformed torrent metadata").unwrap(); - assert!(expected_unadmitted_download_assets_present( - app.handle(), - &cached_torrent_item - ) - .is_err()); - } - - #[tokio::test] - async fn unowned_replacement_authorization_requires_the_original_target() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let target = download_root.join("replace.bin"); - std::fs::write(&target, b"original").unwrap(); - let fingerprint = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()); - let item: crate::ipc::DownloadItem = serde_json::from_value(json!({ - "id": "staged-replacement", - "url": "https://example.com/file.bin", - "fileName": "replace.bin", - "status": "staged", - "category": "Other", - "dateAdded": "", - "destination": download_root.to_string_lossy(), - "hasBeenDispatched": false, - "replaceExistingFingerprint": fingerprint - })) - .unwrap(); - - let authorized = inspect_unowned_replacement_target(app.handle(), &item, false) - .unwrap() - .expect("matching pre-admission replacement should be authorized"); - assert_eq!(authorized.target, target); - - let mut ambiguous_item = item.clone(); - ambiguous_item.has_been_dispatched = None; - assert!( - inspect_unowned_replacement_target(app.handle(), &ambiguous_item, false) - .unwrap() - .is_none() - ); - - crate::download_ownership::set_primary_path(app.handle(), "other-owner", &target).unwrap(); - let error = inspect_unowned_replacement_target(app.handle(), &item, false) - .expect_err("a Firelink-owned target must not use unowned replacement cleanup"); - assert!(error.contains("owned by Firelink download other-owner")); - crate::download_ownership::remove(app.handle(), "other-owner").unwrap(); - - // Persisted pre-admission rows are included by the legacy ownership - // fallback. That self-claim must not block cleanup of the row's own - // exact replacement target. - let database = app.handle().state::(); - let mut connection = database.lock().unwrap(); - crate::db::replace_downloads( - &mut connection, - &serde_json::to_string(&[item.clone()]).unwrap(), - false, - ) - .unwrap(); - drop(connection); - assert!( - inspect_unowned_replacement_target(app.handle(), &item, false) - .unwrap() - .is_some() - ); - remove_unowned_replacement_target( - &target, - &fingerprint, - &item.id, - true, - app.handle(), - ) - .await - .expect("a staged row may remove its own legacy replacement target"); - assert!(!target.exists()); - - std::fs::write(&target, b"changed target").unwrap(); - let error = inspect_unowned_replacement_target(app.handle(), &item, false) - .expect_err("a changed target must not inherit replacement authorization"); - assert!(error.contains("changed after duplicate resolution")); - } - - #[tokio::test] - async fn unowned_replacement_cleanup_removes_only_a_matching_primary_file() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let target = download_root.join("replace.bin"); - std::fs::write(&target, b"original").unwrap(); - let fingerprint = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()); - - remove_unowned_replacement_target( - &target, - &fingerprint, - "staged-replacement", - true, - app.handle(), - ) - .await - .expect("matching replacement target should be removable"); - assert!(!target.exists()); - - std::fs::write(&target, b"new file").unwrap(); - let error = remove_unowned_replacement_target( - &target, - &fingerprint, - "staged-replacement", - true, - app.handle(), - ) - .await - .expect_err("a replacement target with a new fingerprint must be preserved"); - assert!(error.contains("changed after duplicate resolution")); - assert_eq!(std::fs::read(&target).unwrap(), b"new file"); - } - - #[test] - fn terminal_aria2_status_preserves_exact_progress_snapshot() { - let snapshot = aria2_download_state_progress(Some(&json!({ - "status": "error", - "completedLength": "420", - "totalLength": "1000" - }))) - .expect("length fields should produce a progress snapshot"); - - assert!((snapshot.fraction - 0.42).abs() < f64::EPSILON); - assert_eq!(snapshot.downloaded_bytes, Some(420.0)); - assert_eq!(snapshot.total_bytes, Some(1000.0)); - assert_eq!(snapshot.total_is_estimate, Some(false)); - } - - #[test] - fn destination_preflight_returns_the_retryable_marker_before_admission() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let error = preflight_download_destination_access( - app.handle(), - "preflight-only", - "relative/not-approved", - ) - .expect_err("an unapproved destination must not enter admission"); - - assert!(error.starts_with("destination access retryable:")); - } - - #[test] - fn renderer_download_snapshots_cannot_lower_native_torrent_totals() { - let existing = vec![ - json!({ - "id": "torrent-1", - "torrentUploadedBytes": 900, - "torrentSeededSeconds": 45 - }) - .to_string(), - ]; - let merged = merge_durable_torrent_telemetry( - &existing, - &json!([ - { "id": "torrent-1", "torrentUploadedBytes": 12, "torrentSeededSeconds": 90 }, - { "id": "file-1" } - ]) - .to_string(), - ) - .unwrap(); - let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); - assert_eq!(records[0]["torrentUploadedBytes"], 900); - assert_eq!(records[0]["torrentSeededSeconds"], 90); - } - - #[test] - fn renderer_download_snapshots_cannot_roll_back_a_native_relocation() { - let existing = vec![ - json!({ - "id": "torrent-1", - "destination": "/downloads/new", - "torrentMoveDestination": "/downloads/new" - }) - .to_string(), - ]; - let merged = merge_durable_torrent_telemetry( - &existing, - &json!([{ "id": "torrent-1", "destination": "/downloads/old" }]).to_string(), - ) - .unwrap(); - let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); - assert_eq!(records[0]["destination"], "/downloads/new"); - assert_eq!(records[0]["torrentMoveDestination"], "/downloads/new"); - - let acknowledged = merge_durable_torrent_telemetry( - &existing, - &json!([{ "id": "torrent-1", "destination": "/downloads/new" }]).to_string(), - ) - .unwrap(); - let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap(); - assert!(acknowledged[0].get("torrentMoveDestination").is_none()); - } - - #[test] - fn renderer_download_snapshots_cannot_clear_native_relocation_or_verification_markers() { - let existing = vec![ - json!({ - "id": "torrent-1", - "status": "queued", - "destination": "/downloads/new", - "torrentMoveDestination": "/downloads/new", - "torrentRelocationCheckPending": true, - "torrentVerifyOnly": true, - "torrentVerifyRestoreStatus": "paused", - "torrentVerifyNative": "paused" - }) - .to_string(), - ]; - let merged = merge_durable_torrent_telemetry( - &existing, - &json!([{ - "id": "torrent-1", - "status": "paused", - "destination": "/downloads/old" - }]) - .to_string(), - ) - .unwrap(); - let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); - assert_eq!(records[0]["destination"], "/downloads/new"); - assert_eq!(records[0]["torrentRelocationCheckPending"], true); - assert_eq!(records[0]["torrentVerifyOnly"], true); - assert_eq!(records[0]["torrentVerifyRestoreStatus"], "paused"); - assert_eq!(records[0]["torrentVerifyNative"], "paused"); - - let acknowledged = merge_durable_torrent_telemetry( - &existing, - &json!([{ - "id": "torrent-1", - "status": "queued", - "destination": "/downloads/new", - "torrentVerifyOnly": true, - "torrentVerifyRestoreStatus": "paused" - }]) - .to_string(), - ) - .unwrap(); - let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap(); - assert_eq!(acknowledged[0]["torrentRelocationCheckPending"], true); - assert!(acknowledged[0].get("torrentMoveDestination").is_none()); - assert!(acknowledged[0].get("torrentVerifyNative").is_none()); - assert_eq!(acknowledged[0]["torrentVerifyOnly"], true); - } - - #[test] - fn renderer_download_snapshots_cannot_roll_back_native_web_seed_changes() { - let native_seeds = json!([{ "fileIndex": 1, "uri": "https://mirror.example/file" }]); - let existing = vec![ - json!({ - "id": "torrent-1", - "torrentWebSeeds": native_seeds.clone(), - "torrentWebSeedsNative": native_seeds.clone() - }) - .to_string(), - ]; - let merged = merge_durable_torrent_telemetry( - &existing, - &json!([{ "id": "torrent-1", "torrentWebSeeds": [] }]).to_string(), - ) - .unwrap(); - let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); - assert_eq!(records[0]["torrentWebSeeds"][0]["uri"], "https://mirror.example/file"); - assert!(records[0].get("torrentWebSeedsNative").is_some()); - - let acknowledged = merge_durable_torrent_telemetry( - &existing, - &json!([{ "id": "torrent-1", "torrentWebSeeds": native_seeds }]).to_string(), - ) - .unwrap(); - let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap(); - assert!(acknowledged[0].get("torrentWebSeedsNative").is_none()); - } - - #[test] - fn torrent_identity_magnet_is_bounded_and_credential_free() { - let details = crate::ipc::TorrentDetails { - info_hash: "0123456789abcdef0123456789abcdef01234567".to_string(), - display_name: "folder/preview & name".to_string(), - total_bytes: 1, - file_count: 1, - piece_length: 1, - piece_count: 1, - private: true, - creation_date: None, - creator: None, - comment: None, - trackers: vec!["https://tracker.example/passkey".to_string()], - web_seeds: vec!["https://cdn.example/file".to_string()], - }; - let magnet = torrent_identity_magnet(&details).unwrap(); - assert_eq!( - magnet, - "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=preview+%26+name" - ); - assert!(!magnet.contains("tracker")); - assert!(!magnet.contains("passkey")); - } - - #[test] - fn aria2_global_option_results_require_the_documented_ok_value() { - assert!(super::ensure_aria2_ok_result("changeGlobalOption", &json!("OK")).is_ok()); - let unexpected = super::ensure_aria2_ok_result("changeGlobalOption", &json!("accepted")) - .expect_err("unexpected global-option results must not claim success"); - assert!(unexpected.contains("unexpected result accepted")); - let malformed = super::ensure_aria2_ok_result("changeGlobalOption", &json!({})) - .expect_err("non-string global-option results must not claim success"); - assert!(malformed.contains("non-string result")); - } - - #[test] - fn aria2_reconciliation_requests_upload_length_for_torrent_telemetry() { - let params = super::aria2_reconciliation_status_params("gid-1"); - let fields = params - .get(1) - .and_then(serde_json::Value::as_array) - .expect("reconciliation params should contain a status field list"); - assert!(fields - .iter() - .any(|field| field.as_str() == Some("uploadLength"))); - assert!(fields - .iter() - .any(|field| field.as_str() == Some("followedBy"))); - } - - #[test] - fn torrent_move_root_mapping_handles_single_file_output() { - let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let old_root = root.path().join("old"); - let new_root = root.path().join("new"); - std::fs::create_dir_all(&old_root).unwrap(); - std::fs::create_dir_all(&new_root).unwrap(); - let mapped = torrent_move_path_pair(&old_root, &new_root, &old_root).unwrap(); - assert_eq!(mapped, new_root.canonicalize().unwrap()); - let child = old_root.join("file.bin"); - assert_eq!( - torrent_move_path_pair(&old_root, &new_root, &child).unwrap(), - new_root.join("file.bin") - ); - } - - #[test] - fn aria2_torrent_peer_discovery_options_are_explicit_and_launch_scoped() { - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_peer_discovery_options(&mut command, false, true, false, true); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - assert_eq!( - args, - vec![ - "--enable-dht=false", - "--enable-dht6=true", - "--enable-peer-exchange=false", - "--bt-enable-lpd=true", - ] - ); - } - - #[test] - fn aria2_torrent_dht_paths_are_explicit_and_owned_by_firelink() { - let root = tempfile::tempdir().unwrap(); - let dht_path = root.path().join("aria2/dht.dat"); - let dht6_path = root.path().join("aria2/dht6.dat"); - let mut command = std::process::Command::new("aria2c"); - - apply_aria2_torrent_dht_paths(&mut command, &dht_path, &dht6_path); - - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec![ - format!("--dht-file-path={}", dht_path.display()), - format!("--dht-file-path6={}", dht6_path.display()), - ] - ); - } - - #[test] - fn aria2_torrent_dht_message_timeout_is_bounded_and_launch_scoped() { - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_dht_options(&mut command, 42); - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--dht-message-timeout=42"] - ); - - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_dht_options(&mut command, 0); - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--dht-message-timeout=10"] - ); - - let mut maximum = std::process::Command::new("aria2c"); - apply_aria2_torrent_dht_options(&mut maximum, queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT); - assert_eq!( - maximum - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--dht-message-timeout=60"] - ); - - let mut out_of_range = std::process::Command::new("aria2c"); - apply_aria2_torrent_dht_options(&mut out_of_range, 61); - assert_eq!( - out_of_range - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--dht-message-timeout=10"] - ); - } - - #[test] - fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("server-stat.txt"); - let mut command = std::process::Command::new("aria2c"); - apply_aria2_server_stat_options(&mut command, Some(&path)); - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec![ - format!("--server-stat-if={}", path.display()), - format!("--server-stat-of={}", path.display()), - "--server-stat-timeout=86400".to_string(), - ] - ); - - let mut disabled = std::process::Command::new("aria2c"); - apply_aria2_server_stat_options(&mut disabled, None); - assert_eq!(disabled.get_args().count(), 0); - } - - #[test] - fn aria2_shutdown_blocks_repeated_exit_requests_until_cleanup_finishes() { - let guard = Aria2DaemonGuard::new(); - assert!(!guard.exit_allowed()); - assert!(guard.begin_shutdown()); - assert!(!guard.begin_shutdown()); - assert!(!guard.exit_allowed()); - - guard.allow_exit(); - assert!(guard.exit_allowed()); - assert!(!guard.begin_shutdown()); - } - - #[test] - fn frontend_exit_flush_acknowledges_the_current_request_generation() { - let flush = FrontendExitFlush::new(); - let completed = flush.completed.subscribe(); - let request = flush.request(); - assert_eq!(*completed.borrow(), 0); - - flush.acknowledge(); - - assert_eq!(*completed.borrow(), request); - } - - #[test] - fn aria2_torrent_global_options_are_bounded_and_explicit() { - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options(&mut command, 256, Some("2M"), "16M"); - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec![ - "--bt-max-open-files=256", - "--disk-cache=16M", - "--max-overall-upload-limit=2M", - ] - ); - let mut fallback_command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options( - &mut fallback_command, - queue::MAX_TORRENT_MAX_OPEN_FILES + 1, - Some("not-a-rate"), - "not-a-cache", - ); - assert_eq!( - fallback_command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--bt-max-open-files=100", "--disk-cache=16M"] - ); - let mut unlimited_command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options(&mut unlimited_command, 256, None, "0"); - assert_eq!( - unlimited_command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--bt-max-open-files=256", "--disk-cache=0"] - ); - } - - #[test] - fn torrent_overall_upload_limit_rejects_invalid_values_but_accepts_unlimited() { - assert_eq!( - normalize_torrent_overall_upload_limit(None).unwrap(), - None - ); - assert_eq!( - normalize_torrent_overall_upload_limit(Some(" ")).unwrap(), - None - ); - assert_eq!( - normalize_torrent_overall_upload_limit(Some("1.5 MB/s")).unwrap(), - Some("1.5M".to_string()) - ); - assert!(normalize_torrent_overall_upload_limit(Some("not-a-rate")).is_err()); - } - - #[test] - fn aria2_torrent_network_options_are_explicit_and_omit_defaults() { - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_network_options( - &mut command, - "6881-6999", - "6881", - "203.0.113.7", - "router.example:6881", - "[2001:db8::1]:6881", - "2001:db8::2", - "en0", - "192.0.2.10", - true, - ); - assert_eq!( - command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec![ - "--listen-port=6881-6999", - "--dht-listen-port=6881", - "--bt-external-ip=203.0.113.7", - "--dht-entry-point=router.example:6881", - "--dht-entry-point6=[2001:db8::1]:6881", - "--dht-listen-addr6=2001:db8::2", - "--bt-lpd-interface=en0", - "--interface=192.0.2.10", - ] - ); - - let mut defaults = std::process::Command::new("aria2c"); - apply_aria2_torrent_network_options(&mut defaults, "", "", "", "", "", "", "", "", true); - assert!(defaults.get_args().next().is_none()); - - let mut ipv4_only = std::process::Command::new("aria2c"); - apply_aria2_torrent_network_options(&mut ipv4_only, "", "", "", "", "", "", "", "192.0.2.10", false); - assert_eq!( - ipv4_only - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(), - vec!["--interface=192.0.2.10", "--disable-ipv6=true"] - ); - } - - #[test] - fn aria2_torrent_peer_identity_options_are_explicit_and_omit_defaults() { - let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_peer_identity_options( - &mut command, - "-FL-1-3-1-", - "Firelink/1.3.1", - ); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - assert_eq!( - args, - vec![ - "--peer-id-prefix=-FL-1-3-1-", - "--peer-agent=Firelink/1.3.1", - ] - ); - - let mut defaults = std::process::Command::new("aria2c"); - apply_aria2_torrent_peer_identity_options(&mut defaults, "", ""); - assert!(defaults.get_args().next().is_none()); - } - - #[test] - fn aria2_rpc_port_occupancy_is_detected_without_claiming_free_ports() { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let port = listener.local_addr().unwrap().port(); - assert!(aria2_rpc_port_is_occupied(port)); - drop(listener); - assert!(!aria2_rpc_port_is_occupied(port)); - } - - #[test] - fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() { - let record = json!({ - "id": "retained-torrent", - "isTorrent": true, - "torrentPath": "/tmp/retained-torrent.torrent", - "torrentMaxPeers": "corrupted" - }) - .to_string(); - - assert_eq!( - retained_torrent_id_from_persisted_record(&record).as_deref(), - Some("retained-torrent") - ); - assert!(retained_torrent_id_from_persisted_record("not-json").is_none()); - assert!(retained_torrent_id_from_persisted_record( - &json!({ "id": "ordinary", "isTorrent": false }).to_string() - ) - .is_none()); - } - - #[test] - fn retained_torrent_info_hash_is_canonicalized_only_for_live_torrents() { - let record = json!({ - "id": "retained-torrent", - "isTorrent": true, - "torrentPath": "/tmp/retained-torrent.torrent", - "torrentInfoHash": "AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH" - }) - .to_string(); - - assert_eq!( - retained_torrent_info_hash_from_persisted_record(&record).as_deref(), - Some("0123456789abcdef0123456789abcdef01234567") - ); - assert!(retained_torrent_info_hash_from_persisted_record( - &json!({ - "id": "not-live", - "isTorrent": true, - "torrentInfoHash": "0123456789abcdef0123456789abcdef01234567" - }) - .to_string() - ) - .is_none()); - assert!(retained_torrent_info_hash_from_persisted_record( - &json!({ - "id": "invalid-hash", - "isTorrent": true, - "torrentPath": "/tmp/invalid-hash.torrent", - "torrentInfoHash": "not-a-hash" - }) - .to_string() - ) - .is_none()); - } - #[test] fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() { assert_eq!( @@ -14868,10 +6901,6 @@ mod tests { 0 ); assert_eq!(aria2_active_connection_count(&json!({})), 0); - assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": "6"}), "numSeeders"), Some(6)); - assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": 6}), "numSeeders"), Some(6)); - assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": "-1"}), "numSeeders"), None); - assert_eq!(aria2_nonnegative_count(&json!({}), "numSeeders"), None); } #[test] @@ -14908,42 +6937,6 @@ mod tests { ); } - #[tokio::test] - async fn remote_torrent_fetch_rejects_non_http_and_embedded_credentials() { - assert_eq!( - super::fetch_remote_torrent_bytes( - "ftp://example.com/sample.torrent", - None, - None, - None, - None, - ) - .await, - Err("Remote torrent metadata must use HTTP or HTTPS".to_string()) - ); - assert_eq!( - super::fetch_remote_torrent_bytes( - "https://user:pass@example.com/sample.torrent", - None, - None, - None, - None, - ) - .await, - Err("Torrent metadata URLs must not contain credentials".to_string()) - ); - let proxy_error = super::fetch_remote_torrent_bytes( - "https://example.com/sample.torrent", - Some("socks5://127.0.0.1:1080"), - None, - None, - None, - ) - .await - .expect_err("remote Torrent metadata must use the shared proxy policy"); - assert!(proxy_error.contains("SOCKS")); - } - #[tokio::test] async fn enqueue_uri_validation_covers_mirrors_not_only_the_primary_url() { assert_eq!( @@ -15041,37 +7034,6 @@ mod tests { ); } - #[test] - fn torrent_zero_progress_does_not_trigger_connection_recovery() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in [0, 31, 62] { - assert_eq!( - observe_aria2_connections_with_epoch( - &mut observation, - Aria2ConnectionSample { - gid: "torrent-gid", - control_epoch: 4, - status: "active", - total: 2 * 1024 * 1024 * 1024, - completed: 0, - speed_bytes: 0.0, - active_connections: 0, - effective_connections: 1, - speed_limited: false, - is_torrent: true, - now: start + Duration::from_secs(offset), - }, - ), - None, - "peer discovery must not recycle a Torrent GID after a zero-byte wait" - ); - } - assert_eq!(observation.recovery_attempts, 0); - assert!(observation.no_progress_since.is_none()); - } - #[test] fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() { let start = Instant::now(); @@ -15160,9 +7122,8 @@ mod tests { completed, speed_bytes: speed, active_connections: 16, - effective_connections: 16, + requested_connections: 16, speed_limited: false, - is_torrent: false, now: start + Duration::from_secs(offset), }, ); @@ -15177,9 +7138,8 @@ mod tests { completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 16, - effective_connections: 16, + requested_connections: 16, speed_limited: false, - is_torrent: false, now: start + Duration::from_secs(31), }, ); @@ -15196,9 +7156,8 @@ mod tests { completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 1, - effective_connections: 16, + requested_connections: 16, speed_limited: false, - is_torrent: false, now: start + Duration::from_secs(62), }, ), @@ -15230,9 +7189,8 @@ mod tests { completed: 10, speed_bytes: 1024.0, active_connections: 16, - effective_connections: 16, + requested_connections: 16, speed_limited: false, - is_torrent: false, now, }, ), @@ -15253,9 +7211,8 @@ mod tests { completed: 10, speed_bytes: 1024.0, active_connections: 16, - effective_connections: 16, + requested_connections: 16, speed_limited: false, - is_torrent: false, now: now + Duration::from_secs(1), }, ); @@ -15393,379 +7350,6 @@ mod tests { ); } - #[test] - fn partial_connection_pool_collapse_recovers_when_throughput_stays_healthy() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in 0..3 { - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - (10 + offset) * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 16, - 16, - false, - start + Duration::from_secs(offset), - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 13 * 1024 * 1024, - 1.4 * 1024.0 * 1024.0, - 12, - 16, - false, - start + Duration::from_secs(31), - ), - None, - "the first sustained underfill sample starts the recovery timer" - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 14 * 1024 * 1024, - 1.4 * 1024.0 * 1024.0, - 12, - 16, - false, - start + Duration::from_secs(62), - ), - Some(Aria2RecoveryReason::ConnectionPoolCollapse), - "a persistent 12/16 pool must recover even when remaining throughput is above half of peak" - ); - } - - #[test] - fn partial_connection_pool_recovery_keeps_a_resumed_sixteen_connection_baseline() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 0, - 0, - 0.0, - 1, - 16, - false, - start, - ), - None - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 500 * 1024 * 1024, - 0.0, - 16, - 16, - false, - start + Duration::from_secs(2), - ), - None, - "a resumed transfer can report its full pool before the first speed sample" - ); - - for (offset, active_connections, completed, speed) in - [(20, 15, 600, 7.0), (40, 15, 700, 5.0), (60, 14, 800, 5.0)] - { - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - completed * 1024 * 1024, - speed * 1024.0 * 1024.0, - active_connections, - 16, - false, - start + Duration::from_secs(offset), - ), - None - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 900 * 1024 * 1024, - 4.0 * 1024.0 * 1024.0, - 12, - 16, - false, - start + Duration::from_secs(90), - ), - None, - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 950 * 1024 * 1024, - 4.0 * 1024.0 * 1024.0, - 11, - 16, - false, - start + Duration::from_secs(100), - ), - None, - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 1050 * 1024 * 1024, - 8.0 * 1024.0 * 1024.0, - 11, - 16, - false, - start + Duration::from_secs(121), - ), - Some(Aria2RecoveryReason::ConnectionPoolCollapse), - "a monotonic pool collapse remains recoverable when throughput temporarily rebounds" - ); - } - - #[test] - fn stable_underfilled_connection_pool_does_not_trigger_recovery() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in 0..3 { - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - (10 + offset) * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 16, - 16, - false, - start + Duration::from_secs(offset), - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 13 * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 12, - 16, - false, - start + Duration::from_secs(31), - ), - None - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 14 * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 12, - 16, - false, - start + Duration::from_secs(62), - ), - None, - "a stable 12/16 pool at the healthy rate is not a collapse" - ); - assert_eq!(observation.recovery_attempts, 0); - } - - #[test] - fn effective_connection_reduction_does_not_trigger_partial_recovery() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in 0..3 { - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - (10 + offset) * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 16, - 16, - false, - start + Duration::from_secs(offset), - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 13 * 1024 * 1024, - 1.7 * 1024.0 * 1024.0, - 4, - 4, - false, - start + Duration::from_secs(31), - ), - None - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 14 * 1024 * 1024, - 1.7 * 1024.0 * 1024.0, - 4, - 4, - false, - start + Duration::from_secs(62), - ), - None, - "a reduced effective pool must not be compared with its stale larger baseline" - ); - assert_eq!(observation.recovery_attempts, 0); - } - - #[test] - fn one_lost_connection_in_a_small_pool_does_not_trigger_partial_recovery() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in 0..3 { - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - (10 + offset) * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 4, - 4, - false, - start + Duration::from_secs(offset), - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 13 * 1024 * 1024, - 1.7 * 1024.0 * 1024.0, - 3, - 4, - false, - start + Duration::from_secs(31), - ), - None - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 14 * 1024 * 1024, - 1.7 * 1024.0 * 1024.0, - 3, - 4, - false, - start + Duration::from_secs(62), - ), - None, - "a single lost connection in a small pool is not enough evidence of collapse" - ); - } - - #[test] - fn one_connection_after_a_healthy_pool_recovers_even_at_full_rate() { - let start = Instant::now(); - let mut observation = Aria2ConnectionObservation::default(); - - for offset in 0..3 { - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - (10 + offset) * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 16, - 16, - false, - start + Duration::from_secs(offset), - ); - } - - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 13 * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 1, - 16, - false, - start + Duration::from_secs(31), - ), - None - ); - assert_eq!( - observe_aria2_connections( - &mut observation, - "gid-1", - "active", - 8 * 1024 * 1024 * 1024, - 14 * 1024 * 1024, - 2.2 * 1024.0 * 1024.0, - 1, - 16, - false, - start + Duration::from_secs(62), - ), - Some(Aria2RecoveryReason::ConnectionPoolCollapse), - "a collapse to one connection must not depend on throughput falling first" - ); - } - #[test] fn partial_connection_pool_collapse_stays_quiet_while_speed_limited() { let start = Instant::now(); @@ -16056,14 +7640,6 @@ mod tests { assert_eq!(template, destination.join("clip.mp4")); } - #[test] - fn uses_youtube_clients_that_preserve_adaptive_video_and_audio_formats() { - assert_eq!( - ytdlp_youtube_extractor_args(), - ["--extractor-args", "youtube:player_client=default,web_embedded"] - ); - } - #[test] fn parses_playlist_entries_and_skips_duplicates_or_unusable_entries() { let metadata = parse_media_playlist_metadata( @@ -16310,26 +7886,6 @@ mod tests { )); } - #[test] - fn explicit_torrent_intent_allows_opaque_http_sources() { - assert!(is_remote_torrent_source( - "https://example.com/download?id=opaque", - true - )); - assert!(!is_remote_torrent_source( - "https://example.com/download?id=opaque", - false - )); - assert!(!is_remote_torrent_source( - "ftp://example.com/download?id=opaque", - true - )); - assert!(is_remote_torrent_source( - "https://example.com/files/sample.torrent?download=1", - false - )); - } - #[test] fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() { let directory = tempfile::tempdir().unwrap(); @@ -16342,51 +7898,6 @@ mod tests { assert!(has_resumable_download_assets(&primary)); } - #[test] - fn multi_file_torrent_primary_directory_is_cleaned_separately_from_owned_files() { - let primary = std::path::PathBuf::from("/Downloads/Torrents/Example"); - let selected_file = primary.join("disc/file.bin"); - - assert!(primary_path_needs_container_cleanup(&primary, &[selected_file])); - assert!(!primary_path_needs_container_cleanup( - &primary, - std::slice::from_ref(&primary) - )); - } - - #[tokio::test] - async fn non_empty_torrent_container_is_not_eligible_for_trash_cleanup() { - let directory = tempfile::tempdir().unwrap(); - - assert!(!directory_has_non_metadata_entries(directory.path()) - .await - .unwrap()); - std::fs::write(directory.path().join(".DS_Store"), b"finder metadata").unwrap(); - assert!(!directory_has_non_metadata_entries(directory.path()) - .await - .unwrap()); - let empty_nested = directory.path().join("MD5").join("nested"); - std::fs::create_dir_all(&empty_nested).unwrap(); - assert!(!directory_has_non_metadata_entries(directory.path()) - .await - .unwrap()); - std::fs::write(empty_nested.join("unselected"), b"unselected").unwrap(); - assert!(directory_has_non_metadata_entries(directory.path()) - .await - .unwrap()); - - let metadata_named_root = tempfile::tempdir().unwrap(); - let metadata_named_directory = metadata_named_root.path().join(".localized"); - std::fs::create_dir_all(&metadata_named_directory).unwrap(); - std::fs::write(metadata_named_directory.join("unselected"), b"unselected").unwrap(); - assert!( - directory_has_non_metadata_entries(metadata_named_root.path()) - .await - .unwrap() - ); - drop(metadata_named_root); - } - #[test] fn ytdlp_progress_args_force_progress_in_quiet_print_mode() { let args = media_progress_args(); @@ -16433,11 +7944,10 @@ mod tests { #[test] fn ytdlp_media_headers_reject_invalid_lines() { let mut config = String::new(); - let error = append_ytdlp_http_headers(&mut config, Some("Cookie=super-secret-value"), None) + let error = append_ytdlp_http_headers(&mut config, Some("not a header"), None) .expect_err("invalid header line should be rejected"); assert!(error.contains("invalid HTTP header")); - assert!(!error.contains("super-secret-value")); } #[test] @@ -16533,341 +8043,6 @@ mod tests { assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video")); } - #[test] - fn replacement_fingerprints_change_when_the_target_changes() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("replace.bin"); - std::fs::write(&target, b"old").unwrap(); - let initial = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()); - assert_eq!( - initial, - target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()) - ); - - std::fs::write(&target, b"new content").unwrap(); - let changed = target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()); - assert_ne!(initial, changed); - } - - #[test] - fn target_inspection_classifies_regular_directories_and_links_without_following_them() { - let directory = tempfile::tempdir().unwrap(); - let regular = directory.path().join("regular.bin"); - let child_directory = directory.path().join("child"); - std::fs::write(®ular, b"file").unwrap(); - std::fs::create_dir(&child_directory).unwrap(); - - assert_eq!( - classify_download_target(&std::fs::symlink_metadata(®ular).unwrap()), - crate::ipc::DownloadTargetKind::RegularFile - ); - assert_eq!( - classify_download_target(&std::fs::symlink_metadata(&child_directory).unwrap()), - crate::ipc::DownloadTargetKind::Directory - ); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(®ular, directory.path().join("link")).unwrap(); - assert_eq!( - classify_download_target( - &std::fs::symlink_metadata(directory.path().join("link")).unwrap() - ), - crate::ipc::DownloadTargetKind::Symlink - ); - - let socket_path = directory.path().join("socket"); - let _socket = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); - assert_eq!( - classify_download_target(&std::fs::symlink_metadata(socket_path).unwrap()), - crate::ipc::DownloadTargetKind::Special - ); - } - } - - #[tokio::test] - async fn replacement_target_lock_is_shared_by_concurrent_admissions() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("same-target.bin"); - let first = download_target_lock(&target).await; - let second = download_target_lock(&target).await; - - assert!(std::sync::Arc::ptr_eq(&first, &second)); - let guard = first.lock().await; - assert!(second.try_lock().is_err()); - drop(guard); - assert!(second.try_lock().is_ok()); - } - - #[tokio::test] - async fn replacement_rollback_restores_quarantine_and_clears_its_journal() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("target.bin"); - let quarantine = directory.path().join(".firelink-replaced-target.tmp"); - let journal = directory.path().join("replacement.json"); - std::fs::write(&quarantine, b"original").unwrap(); - let fingerprint = - target_fingerprint(&quarantine, &std::fs::symlink_metadata(&quarantine).unwrap()); - std::fs::write(&journal, b"journal").unwrap(); - - restore_download_replacement(&DownloadReplacementReservation { - journal_path: journal.clone(), - target: target.clone(), - quarantine: quarantine.clone(), - fingerprint, - }) - .await - .expect("a staged target should be restorable"); - - assert_eq!(std::fs::read(&target).unwrap(), b"original"); - assert!(!quarantine.exists()); - assert!(!journal.exists()); - } - - #[tokio::test] - async fn replacement_rollback_never_overwrites_a_new_target() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("target.bin"); - let quarantine = directory.path().join(".firelink-replaced-target.tmp"); - let journal = directory.path().join("replacement.json"); - std::fs::write(&target, b"new-admission").unwrap(); - std::fs::write(&quarantine, b"old-file").unwrap(); - let fingerprint = - target_fingerprint(&quarantine, &std::fs::symlink_metadata(&quarantine).unwrap()); - std::fs::write(&journal, b"journal").unwrap(); - - let error = restore_download_replacement(&DownloadReplacementReservation { - journal_path: journal.clone(), - target: target.clone(), - quarantine: quarantine.clone(), - fingerprint, - }) - .await - .expect_err("rollback must stop when a new target already exists"); - - assert!(error.contains("destination changed")); - assert_eq!(std::fs::read(&target).unwrap(), b"new-admission"); - assert_eq!(std::fs::read(&quarantine).unwrap(), b"old-file"); - assert!(journal.exists()); - } - - #[tokio::test] - async fn replacement_rollback_rejects_a_changed_quarantine() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("target.bin"); - let quarantine = directory.path().join(".firelink-replaced-target.tmp"); - let journal = directory.path().join("replacement.json"); - std::fs::write(&quarantine, b"old-file").unwrap(); - let fingerprint = - target_fingerprint(&quarantine, &std::fs::symlink_metadata(&quarantine).unwrap()); - std::fs::write(&quarantine, b"tampered-file").unwrap(); - std::fs::write(&journal, b"journal").unwrap(); - - let error = restore_download_replacement(&DownloadReplacementReservation { - journal_path: journal.clone(), - target: target.clone(), - quarantine: quarantine.clone(), - fingerprint, - }) - .await - .expect_err("rollback must reject a changed quarantine"); - - assert!(error.contains("contents changed")); - assert!(!target.exists()); - assert!(quarantine.exists()); - assert!(journal.exists()); - } - - #[tokio::test] - async fn replacement_rollback_retains_journal_when_quarantine_is_missing() { - let directory = tempfile::tempdir().unwrap(); - let target = directory.path().join("target.bin"); - let quarantine = directory.path().join(".firelink-replaced-target.tmp"); - let journal = directory.path().join("replacement.json"); - std::fs::write(&journal, b"journal").unwrap(); - - let error = restore_download_replacement(&DownloadReplacementReservation { - journal_path: journal.clone(), - target, - quarantine, - fingerprint: String::new(), - }) - .await - .expect_err("rollback must retain recovery evidence when quarantine is missing"); - - assert!(error.contains("quarantine is missing")); - assert!(journal.exists()); - } - - fn configure_test_download_root( - app_handle: &tauri::AppHandle, - root: &std::path::Path, - ) -> std::path::PathBuf { - use tauri::Manager; - - std::fs::create_dir_all(root).unwrap(); - let root = std::fs::canonicalize(root).unwrap(); - let root_text = root.to_string_lossy().into_owned(); - let settings = json!({ - "state": { - "baseDownloadFolder": root_text, - "approvedDownloadRoots": [root.to_string_lossy().into_owned()] - }, - "version": 3 - }); - let database = app_handle.state::(); - let connection = database.lock().unwrap(); - crate::db::save_settings(&connection, &settings.to_string()).unwrap(); - root - } - - #[tokio::test] - async fn permanent_media_cleanup_removes_only_exact_payload_and_sidecars() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let directory = tempfile::tempdir_in(&download_root).unwrap(); - let primary = directory.path().join("video.mp4"); - std::fs::write(&primary, b"payload").unwrap(); - for suffix in [".aria2", ".part", ".ytdl"] { - let mut path = primary.as_os_str().to_os_string(); - path.push(suffix); - std::fs::write(path, b"sidecar").unwrap(); - } - let unrelated = directory.path().join("keep.me"); - std::fs::write(&unrelated, b"unrelated").unwrap(); - - remove_download_assets_permanently(&primary, app.handle()) - .await - .expect("unfinished media cleanup should remove exact assets"); - - assert!(!primary.exists()); - assert!(!directory.path().join("video.mp4.aria2").exists()); - assert!(!directory.path().join("video.mp4.part").exists()); - assert!(!directory.path().join("video.mp4.ytdl").exists()); - assert!(unrelated.exists()); - drop(directory); - } - - #[cfg(unix)] - #[tokio::test] - async fn permanent_cleanup_refuses_symlink_payloads() { - use std::os::unix::fs::symlink; - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let directory = tempfile::tempdir_in(&download_root).unwrap(); - let outside = tempfile::tempdir().unwrap(); - let outside_payload = outside.path().join("payload"); - let link = directory.path().join("video.mp4"); - std::fs::write(&outside_payload, b"outside").unwrap(); - symlink(&outside_payload, &link).unwrap(); - - let error = remove_download_assets_permanently(&link, app.handle()) - .await - .expect_err("symbolic links must never be followed or removed"); - - assert!(error.contains("symbolic-link")); - assert!(link.exists()); - assert_eq!(std::fs::read(&outside_payload).unwrap(), b"outside"); - drop(directory); - } - - #[tokio::test] - async fn permanent_torrent_container_cleanup_preserves_unrelated_files() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let directory = tempfile::tempdir_in(&download_root).unwrap(); - let container = directory.path().join("torrent-output"); - std::fs::create_dir(&container).unwrap(); - std::fs::write(container.join(".DS_Store"), b"metadata").unwrap(); - let unrelated = container.join("unselected").join("unselected.bin"); - std::fs::create_dir_all(unrelated.parent().unwrap()).unwrap(); - std::fs::write(&unrelated, b"preserve").unwrap(); - - remove_download_container_assets_permanently(&container, app.handle()) - .await - .expect("container cleanup should be conservative"); - - assert!(container.is_dir()); - assert!(unrelated.exists()); - drop(directory); - } - - #[tokio::test] - async fn permanent_torrent_container_cleanup_removes_empty_nested_directories() { - use tauri::Manager; - - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); - let storage_layout = crate::storage::StorageLayout::resolve( - app.handle(), - crate::storage::StorageMode::Portable { - root: storage_root.path().to_path_buf(), - }, - ) - .unwrap(); - app.manage(crate::db::init(&storage_layout).unwrap()); - let download_root = - configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); - let directory = tempfile::tempdir_in(&download_root).unwrap(); - let container = directory.path().join("torrent-output"); - let empty_nested = container.join("MD5").join("nested"); - std::fs::create_dir_all(&empty_nested).unwrap(); - std::fs::create_dir(container.join(".localized")).unwrap(); - std::fs::write(container.join(".DS_Store"), b"metadata").unwrap(); - std::fs::write(container.join("MD5").join(".DS_Store"), b"metadata").unwrap(); - - remove_download_container_assets_permanently(&container, app.handle()) - .await - .expect("an empty Torrent output tree should be permanently removable"); - - assert!(!container.exists()); - drop(directory); - } - #[cfg(target_os = "macos")] #[test] fn dock_badge_updates_reject_stale_sessions_and_generations() { @@ -16891,19 +8066,6 @@ mod tests { ); } - #[test] - fn metadata_diagnostic_codes_preserve_dns_and_policy_boundaries() { - assert_eq!( - metadata_error_code("SSRF blocked: DNS resolution timed out"), - "dns_timeout" - ); - assert_eq!( - metadata_error_code("SSRF blocked: Private/local IP not allowed"), - "ssrf_private_address" - ); - assert_eq!(metadata_error_code("HEAD request timed out"), "timeout"); - } - #[test] fn metadata_filename_reads_redirect_disposition_query_before_opaque_path() { let redirected = "https://release-assets.githubusercontent.com/github-production-release-asset/1117828249/7aae36e6-00ec-4e7d-8dec-f14ace170bdb?rscd=attachment%3B+filename%3DOnionHop-3.5-macOS-arm64.dmg"; @@ -17055,40 +8217,6 @@ mod tests { assert!(redacted.contains("[redacted]")); } - #[test] - fn redacts_legacy_cookie_and_compound_custom_headers() { - let line = "Set-Cookie2: legacy-cookie X-Session: id=session-secret; key=compound-secret"; - let redacted = redact_log_line(line); - assert!(!redacted.contains("legacy-cookie")); - assert!(!redacted.contains("session-secret")); - assert!(!redacted.contains("compound-secret")); - assert!(redacted.contains("[redacted]")); - } - - #[test] - fn redacts_all_pairs_in_cookie_headers() { - let redacted = redact_log_line("Cookie: a=1; user_id=secret; state=xyz"); - assert!(!redacted.contains("a=1")); - assert!(!redacted.contains("user_id=secret")); - assert!(!redacted.contains("state=xyz")); - assert!(redacted.contains("[redacted]")); - - let redacted = redact_log_line("Cookie2=a=1; user_id=secret; state=xyz"); - assert!(!redacted.contains("user_id=secret")); - assert!(!redacted.contains("state=xyz")); - } - - #[test] - fn preserves_compact_json_delimiters_while_redacting_url_queries() { - let redacted = redact_log_line( - r#"{"url":"https://example.com/file?token=secret","next":1}"#, - ); - assert_eq!( - redacted, - r#"{"url":"https://example.com/file?[redacted]","next":1}"# - ); - } - #[test] fn collects_primary_url_and_unique_mirrors_in_order() { let uris = collect_download_uris( @@ -17135,48 +8263,6 @@ mod tests { ); } - #[test] - fn accepts_direct_magnet_deep_links() { - let deep_link = url::Url::parse( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example", - ) - .unwrap(); - assert_eq!( - parse_firelink_deep_link(&deep_link), - FirelinkDeepLink::Add(vec![deep_link.to_string()]) - ); - } - - #[test] - fn filters_opened_torrent_arguments_to_absolute_files() { - assert_eq!( - normalize_opened_torrent_argument("file:///tmp/example%20one.torrent"), - Some("/tmp/example one.torrent".to_string()) - ); - assert!(normalize_opened_torrent_argument("relative/example.torrent").is_none()); - assert!(normalize_opened_torrent_argument("--flag.torrent").is_none()); - assert!(normalize_opened_torrent_argument("/tmp/line\nbreak.torrent").is_none()); - assert!(normalize_opened_torrent_argument("file:///tmp/line%0Abreak.torrent").is_none()); - assert!(normalize_opened_torrent_argument("/tmp/example.zip").is_none()); - if cfg!(target_os = "windows") { - assert!(normalize_opened_torrent_argument("C:/example.torrent").is_some()); - } else { - assert!(normalize_opened_torrent_argument("C:/example.torrent").is_none()); - } - - let paths = collect_opened_torrent_paths([ - "--new-instance", - "/tmp/one.torrent", - "/tmp/one.torrent", - "/tmp/two.TORRENT", - "/tmp/readme.txt", - ]); - assert_eq!(paths, vec![ - "/tmp/one.torrent".to_string(), - "/tmp/two.TORRENT".to_string(), - ]); - } - #[test] fn rejects_launch_variants_and_nested_schemes() { let links = [ @@ -17393,18 +8479,6 @@ mod tests { )); } - #[test] - fn media_cookie_source_accepts_only_configured_browsers() { - assert_eq!(normalize_media_cookie_source(None).unwrap(), None); - assert_eq!(normalize_media_cookie_source(Some(" NONE ")).unwrap(), None); - assert_eq!( - normalize_media_cookie_source(Some(" Chrome ")).unwrap(), - Some("chrome".to_string()) - ); - assert!(normalize_media_cookie_source(Some("profile=secret")).is_err()); - assert!(normalize_media_cookie_source(Some("unknown-browser")).is_err()); - } - #[test] #[ignore = "requires network and a local yt-dlp executable"] fn filters_live_youtube_metadata_from_env() { @@ -17423,8 +8497,6 @@ mod tests { "3", "--compat-options", "no-youtube-unavailable-videos", - "--extractor-args", - YTDLP_YOUTUBE_PLAYER_CLIENTS, "--", &url, ]) @@ -17880,17 +8952,7 @@ struct Aria2ConnectionObservation { no_progress_since: Option, last_refreshed_at: Option, peak_speed_bytes: f64, - peak_active_connections: i32, - last_active_connections: Option, - connection_decline_steps: u8, last_completed: u64, - last_logged_active_connections: Option, - last_connection_logged_at: Option, - started_at: Option, - torrent_tracker_count: Option, - torrent_tracker_count_loaded: bool, - seeder: bool, - verifying: bool, } struct Aria2ConnectionSample<'a> { @@ -17901,29 +8963,22 @@ struct Aria2ConnectionSample<'a> { completed: u64, speed_bytes: f64, active_connections: i32, - effective_connections: i32, + requested_connections: i32, speed_limited: bool, - is_torrent: bool, now: Instant, } -fn aria2_count_value(value: &serde_json::Value) -> Option { - value - .as_str() - .and_then(|value| value.parse::().ok()) - .or_else(|| value.as_i64()) - .and_then(|value| i32::try_from(value).ok()) -} - -fn aria2_nonnegative_count(status_info: &serde_json::Value, key: &str) -> Option { - status_info - .get(key) - .and_then(aria2_count_value) - .filter(|value| *value >= 0) -} - fn aria2_active_connection_count(status_info: &serde_json::Value) -> i32 { - aria2_nonnegative_count(status_info, "connections").unwrap_or(0) + status_info + .get("connections") + .and_then(|value| { + value + .as_str() + .and_then(|value| value.parse::().ok()) + .or_else(|| value.as_i64().and_then(|value| i32::try_from(value).ok())) + }) + .filter(|value| *value >= 0) + .unwrap_or(0) } const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30); @@ -17932,12 +8987,8 @@ const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024; const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0; const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20; const ARIA2_CONNECTION_POOL_DEGRADED_FRACTION: f64 = 0.75; -const ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION: f64 = 0.80; -const ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY: i32 = 2; -const ARIA2_MIN_CONNECTION_DECLINE_STEPS_FOR_PARTIAL_RECOVERY: u8 = 2; const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3; const ARIA2_MAX_CONSECUTIVE_RECOVERY_ATTEMPTS: u8 = 3; -const ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL: Duration = Duration::from_secs(30); #[cfg(test)] // Keep the test scenarios explicit while the production path uses the typed sample. @@ -17950,7 +9001,7 @@ fn observe_aria2_connections( completed: u64, speed_bytes: f64, active_connections: i32, - effective_connections: i32, + requested_connections: i32, speed_limited: bool, now: Instant, ) -> Option { @@ -17964,9 +9015,8 @@ fn observe_aria2_connections( completed, speed_bytes, active_connections, - effective_connections, + requested_connections, speed_limited, - is_torrent: false, now, }, ) @@ -17984,9 +9034,8 @@ fn observe_aria2_connections_with_epoch( completed, speed_bytes, active_connections, - effective_connections, + requested_connections, speed_limited, - is_torrent, now, } = sample; if observation.gid != gid || observation.control_epoch != control_epoch { @@ -18004,26 +9053,6 @@ fn observe_aria2_connections_with_epoch( }; } - // BitTorrent peer discovery is intentionally open-ended. A Torrent can - // remain active at zero bytes and zero connections while DHT, trackers, - // or PEX are still converging. Recreating its GID destroys that discovery - // lifecycle and was the source of the observed thirty-second restart - // loop. Missing-GID reconciliation remains owned by the poller and is - // therefore unaffected by this transfer-kind gate. - if is_torrent { - observation.started_at.get_or_insert(now); - observation.degraded_since = None; - observation.no_progress_since = None; - observation.last_active_connections = None; - observation.connection_decline_steps = 0; - observation.healthy_speed_samples = 0; - observation.healthy_samples_since_recovery = 0; - observation.recovery_attempts = 0; - observation.last_refreshed_at = None; - observation.last_completed = completed; - return None; - } - let remaining = total.saturating_sub(completed); let mut reason = None; if status == "active" && total > completed { @@ -18043,32 +9072,9 @@ fn observe_aria2_connections_with_epoch( observation.no_progress_since = None; } - let multi_connection_candidate = effective_connections > 1 + let multi_connection_candidate = requested_connections > 1 && remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY; if multi_connection_candidate { - let observed_active_connections = active_connections.min(effective_connections).max(0); - // A resumed transfer can report its already-downloaded bytes and - // full connection pool before Aria2 has produced a non-zero - // instantaneous speed sample. Keep that pool as the baseline; - // healthy speed evidence remains a separate requirement below. - if observed_active_connections > 1 && (completed > 0 || speed_bytes > 0.0) { - observation.saw_multiple_connections = true; - if let Some(last_active_connections) = observation.last_active_connections { - if observed_active_connections < last_active_connections { - observation.connection_decline_steps = - observation.connection_decline_steps.saturating_add(1); - } else if observed_active_connections > last_active_connections { - observation.connection_decline_steps = 0; - } - } - observation.last_active_connections = Some(observed_active_connections); - observation.peak_active_connections = observation - .peak_active_connections - .max(observed_active_connections); - } else { - observation.last_active_connections = None; - observation.connection_decline_steps = 0; - } let healthy_connection_sample = active_connections > 1 && speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes >= observation.peak_speed_bytes * 0.5; @@ -18087,24 +9093,12 @@ fn observe_aria2_connections_with_epoch( let partial_connection_pool_collapse = !speed_limited && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES - && effective_connections >= 4 - && observation.peak_active_connections.min(effective_connections) >= 4 - && active_connections.min(effective_connections) - <= observation - .peak_active_connections - .min(effective_connections) - .saturating_sub(ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY) - && (active_connections.min(effective_connections) as f64) - <= (observation.peak_active_connections.min(effective_connections) as f64) - * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION + && requested_connections >= 4 + && (active_connections as f64) + <= (requested_connections as f64) * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION && observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes > 0.0 - // A monotonic loss from the observed healthy pool remains - // evidence even when instantaneous throughput rebounds. - && (speed_bytes - < observation.peak_speed_bytes * ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION - || observation.connection_decline_steps - >= ARIA2_MIN_CONNECTION_DECLINE_STEPS_FOR_PARTIAL_RECOVERY); + && speed_bytes < observation.peak_speed_bytes * 0.5; let connection_pool_collapse = !speed_limited && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES @@ -18143,16 +9137,15 @@ fn observe_aria2_connections_with_epoch( observation.degraded_since = None; } + if active_connections > 1 { + observation.saw_multiple_connections = true; + } } else { observation.degraded_since = None; - observation.last_active_connections = None; - observation.connection_decline_steps = 0; } } else { observation.degraded_since = None; observation.no_progress_since = None; - observation.last_active_connections = None; - observation.connection_decline_steps = 0; } observation.last_completed = completed; @@ -18168,11 +9161,6 @@ fn observe_aria2_connections_with_epoch( observation.last_refreshed_at = Some(now); observation.recovery_attempts = observation.recovery_attempts.saturating_add(1); observation.healthy_samples_since_recovery = 0; - if reason == Aria2RecoveryReason::ConnectionPoolCollapse { - // Require a new downward sequence after a refresh rather than - // repeatedly treating the same historical decline as fresh. - observation.connection_decline_steps = 0; - } // Start a fresh observation window after every attempt. This permits // bounded repeated healing while preventing a refresh loop every poll // tick or indefinitely repeating against a host-side connection cap. @@ -18193,25 +9181,18 @@ static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[tauri::command] -fn toggle_log_pause(caller: tauri::WebviewWindow, pause: bool) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; +fn toggle_log_pause(pause: bool) { LOG_PAUSED.store(pause, std::sync::atomic::Ordering::Relaxed); - Ok(()) } #[tauri::command] -fn is_log_paused(caller: tauri::WebviewWindow) -> bool { - properties_window::ensure_main_window(&caller).is_ok() - && LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) +fn is_log_paused() -> bool { + LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) } #[tauri::command] -fn set_log_stream_active(caller: tauri::WebviewWindow, active: bool) -> Result<(), String> { - if let Err(error) = properties_window::ensure_main_window(&caller) { - return Err(error); - } +fn set_log_stream_active(active: bool) { LOG_STREAM_ACTIVE.store(active, std::sync::atomic::Ordering::Release); - Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -18242,7 +9223,6 @@ pub fn run() { let server_extension_port = extension_server_port.clone(); let (extension_server_shutdown_tx, extension_server_shutdown_rx) = tokio::sync::watch::channel(false); - let frontend_exit_flush = Arc::new(FrontendExitFlush::new()); let initial_aria2_port = 6800; // Will be determined dynamically in background let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(initial_aria2_port)); @@ -18250,11 +9230,8 @@ pub fn run() { let aria2_secret = uuid::Uuid::new_v4().to_string(); tauri::Builder::default() .manage(MainWindowRestoreState::default()) - .manage(properties_window::PropertiesWindowRegistry::default()) - .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { restore_main_window(app); - let paths = collect_opened_torrent_paths(args); - dispatch_opened_torrent_paths(app.clone(), paths); })) .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard::new()) @@ -18340,85 +9317,6 @@ pub fn run() { let database = crate::db::init(&storage_layout) .map_err(|error| format!("failed to initialize persistence: {error}"))?; - // Replacement recovery consults the persisted ownership registry. - // Register the database before running any recovery pass so a - // malformed or interrupted transaction cannot reach a missing - // Tauri state entry at startup. - app.manage(database); - if let Err(error) = recover_torrent_move_journals( - app.handle(), - &*app.state::(), - &storage_layout, - ) { - log::warn!("Torrent move recovery did not complete: {error}"); - } - if let Err(error) = recover_download_replacement_journals( - app.handle(), - &storage_layout, - ) { - log::warn!("Download replacement recovery did not complete: {error}"); - } - // Establish Firelink-owned Aria2 routing-table paths after the - // existing data-root initializer has created the selected storage - // directory, but before the daemon launcher is scheduled. A - // conflict must fail startup; silently allowing Aria2 to fall back - // to a user-global dht.dat would escape the storage boundary. - let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?; - let aria2_server_stat_path = match storage_layout.prepare_aria2_server_stat_path() { - Ok(path) => Some(path), - Err(error) => { - log::warn!( - "adaptive mirror history is disabled for this session: {error}" - ); - None - } - }; - if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) { - log::warn!("could not remove orphaned torrent probes: {error}"); - } - let retained_torrent_metadata = app - .state::() - .lock() - .and_then(|connection| crate::db::load_downloads(&connection)) - .map(|records| { - let mut retained_ids = HashSet::new(); - let mut retained_info_hashes = HashSet::new(); - for record in records { - let retained_id = retained_torrent_id_from_persisted_record(&record); - let retained_info_hash = - retained_torrent_info_hash_from_persisted_record(&record); - let has_retained_metadata = - retained_id.is_some() || retained_info_hash.is_some(); - if let Some(id) = retained_id { - retained_ids.insert(id); - } - if let Some(info_hash) = retained_info_hash { - retained_info_hashes.insert(info_hash); - } - if !has_retained_metadata - && serde_json::from_str::(&record).is_err() - { - log::warn!( - "skipping malformed persisted download during torrent metadata retention" - ); - } - } - (retained_ids, retained_info_hashes) - }); - match retained_torrent_metadata { - Ok((retained_torrent_ids, retained_torrent_info_hashes)) => { - if let Err(error) = crate::torrent::remove_orphaned_cached_torrents( - app.handle(), - &retained_torrent_ids, - &retained_torrent_info_hashes, - ) { - log::warn!("could not remove orphaned torrent metadata: {error}"); - } - } - Err(error) => { - log::warn!("could not identify retained torrent metadata: {error}"); - } - } let initial_pairing_token = { // Generate a temporary session token for the extension server on startup. // The frontend will hydrate the real token via IPC once it mounts, @@ -18435,6 +9333,7 @@ pub fn run() { .map_err(|_| "extension pairing token lock is unavailable".to_string())?; *pairing_token = initial_pairing_token; } + app.manage(database); let persisted_settings = crate::settings::load_settings(app.handle()).ok(); let logs_enabled = persisted_settings .as_ref() @@ -18471,12 +9370,6 @@ pub fn run() { let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone())); let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); - if let Some(settings) = persisted_settings.as_ref() { - queue_manager.configure_seed_capacity( - settings.torrent_separate_seed_slots, - settings.torrent_max_concurrent_seeds, - ); - } let power_manager = queue_manager.power_manager(); if let Some(settings) = persisted_settings.as_ref() { let _ = power_manager.set_preferences( @@ -18494,7 +9387,6 @@ pub fn run() { }); let queue_manager_poll = Arc::clone(&queue_manager); - let queue_manager_capability = Arc::clone(&queue_manager); app.manage(AppState { download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), @@ -18507,7 +9399,6 @@ pub fn run() { extension_acks, extension_server_port, extension_server_shutdown: extension_server_shutdown_tx.clone(), - frontend_exit_flush, aria2_port: aria2_port.clone(), aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), @@ -18523,54 +9414,17 @@ pub fn run() { // Build the window only after all command state is registered. This // prevents the frontend from racing startup and invoking IPC before // the database and portable storage layout are available. - let startup_size = persisted_settings - .as_ref() - .and_then(|settings| settings.main_window_size.as_ref()) - .and_then(|size| crate::window_geometry::normalize_main_window_size(Some(size))) - .unwrap_or_else(crate::window_geometry::default_main_window_size); - let startup_size = app - .primary_monitor() - .ok() - .flatten() - .and_then(|monitor| { - let scale_factor = monitor.scale_factor(); - if !scale_factor.is_finite() || scale_factor <= 0.0 { - return None; - } - let work_area = monitor.work_area().size; - let logical_width = (work_area.width as f64 / scale_factor).round() as u32; - let logical_height = (work_area.height as f64 / scale_factor).round() as u32; - Some(crate::window_geometry::clamp_main_window_size( - startup_size.clone(), - logical_width, - logical_height, - )) - }) - .unwrap_or(startup_size); - main_window_builder = main_window_builder - .inner_size(startup_size.width as f64, startup_size.height as f64) - .prevent_overflow(); - #[cfg(target_os = "windows")] - { - // Wry installs its parent WM_SETFOCUS handler while WebView2 - // is still being initialized. Keep the host hidden and - // non-activatable until RunEvent::Ready so Windows cannot - // re-enter that handler during native construction. - main_window_builder = main_window_builder - .visible(false) - .focused(false) - .focusable(false); - } main_window_builder .build() .map_err(|error| format!("failed to create main window: {error}"))?; restore_pending_main_window(app.handle()); - #[cfg(any(target_os = "windows", target_os = "linux"))] - dispatch_opened_torrent_paths( - app.handle().clone(), - collect_opened_torrent_paths(std::env::args_os().skip(1)), - ); + #[cfg(target_os = "windows")] + if let Some(window) = app.get_webview_window("main") { + window + .set_decorations(false) + .map_err(|error| format!("failed to disable Windows native frame: {error}"))?; + } let deep_link_app = app.handle().clone(); #[cfg(target_os = "linux")] @@ -18591,39 +9445,6 @@ pub fn run() { .as_ref() .map(|settings| settings.global_speed_limit.clone()) .unwrap_or_default(); - let torrent_overall_upload_limit = persisted_settings - .as_ref() - .map(|settings| settings.torrent_overall_upload_limit.clone()) - .unwrap_or_default(); - let torrent_peer_discovery = persisted_settings - .as_ref() - .map(|settings| { - ( - settings.torrent_enable_dht, - settings.torrent_enable_dht6 && settings.torrent_ipv6_enabled, - settings.torrent_enable_pex, - settings.torrent_enable_lpd, - ) - }) - .unwrap_or((true, false, true, false)); - let torrent_max_open_files = persisted_settings - .as_ref() - .map(|settings| settings.torrent_max_open_files) - .unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES); - let torrent_max_open_files = match queue::normalize_torrent_max_open_files( - torrent_max_open_files, - ) { - Ok(value) => value, - Err(error) => { - log::error!( - "invalid persisted Torrent open-file limit; using Aria2 default: {error}" - ); - queue::DEFAULT_TORRENT_MAX_OPEN_FILES - } - }; - let torrent_startup_settings = - crate::settings::torrent_startup_settings(persisted_settings.as_ref()); - let torrent_peer_discovery_for_poll = torrent_peer_discovery; let aria2_secret_clone = aria2_secret.clone(); let app_handle_bg = app.handle().clone(); @@ -18633,20 +9454,7 @@ pub fn run() { match resolve_bundled_binary_path(&app_handle_bg, "aria2c") { Ok(binary_path) => { let mut success = false; - let mut attempted_rpc_port = false; - let mut startup_failure = None; for attempt_port in 6800..6900 { - if queue::torrent_port_spec_contains( - if torrent_startup_settings.listen_port.is_empty() { - queue::DEFAULT_TORRENT_LISTEN_PORT_SPEC - } else { - &torrent_startup_settings.listen_port - }, - attempt_port, - ) { - continue; - } - attempted_rpc_port = true; let mut cmd = std::process::Command::new(&binary_path); crate::platform::hide_child_console(&mut cmd); crate::engines::apply_aria2_environment(&mut cmd, &binary_path); @@ -18671,51 +9479,6 @@ pub fn run() { .arg("--check-certificate=true") .arg(format!("--stop-with-process={}", std::process::id())); - apply_aria2_torrent_global_options( - &mut cmd, - torrent_max_open_files, - Some(&torrent_overall_upload_limit), - &torrent_startup_settings.disk_cache, - ); - apply_aria2_torrent_dht_paths( - &mut cmd, - &aria2_dht_paths.0, - &aria2_dht_paths.1, - ); - apply_aria2_torrent_dht_options( - &mut cmd, - torrent_startup_settings.dht_message_timeout, - ); - apply_aria2_server_stat_options( - &mut cmd, - aria2_server_stat_path.as_deref(), - ); - - apply_aria2_torrent_peer_discovery_options( - &mut cmd, - torrent_peer_discovery.0, - torrent_peer_discovery.1, - torrent_peer_discovery.2, - torrent_peer_discovery.3, - ); - apply_aria2_torrent_network_options( - &mut cmd, - &torrent_startup_settings.listen_port, - &torrent_startup_settings.dht_listen_port, - &torrent_startup_settings.external_ip, - &torrent_startup_settings.dht_entry_point, - &torrent_startup_settings.dht_entry_point6, - &torrent_startup_settings.dht_listen_addr6, - &torrent_startup_settings.lpd_interface, - &torrent_startup_settings.bind_address, - torrent_startup_settings.ipv6_enabled, - ); - apply_aria2_torrent_peer_identity_options( - &mut cmd, - &torrent_startup_settings.peer_id_prefix, - &torrent_startup_settings.peer_agent, - ); - if let Some(limit) = normalize_speed_limit_for_aria2(&global_speed_limit) { cmd.arg(format!("--max-overall-download-limit={}", limit)); } @@ -18727,34 +9490,9 @@ pub fn run() { Ok(mut child) => { // Give it a moment to fail if port is in use tokio::time::sleep(std::time::Duration::from_millis(200)).await; - if let Ok(Some(status)) = child.try_wait() { - use std::io::Read; - let mut stderr = String::new(); - if let Some(pipe) = child.stderr.take() { - // A failed child is not trusted to produce a bounded - // diagnostic. Keep startup error handling bounded so a - // pathological stderr stream cannot stall or exhaust - // the launcher while it is deciding whether to retry. - let _ = pipe.take(4096).read_to_string(&mut stderr); - } - let stderr = stderr.trim().to_string(); - if aria2_rpc_port_is_occupied(attempt_port) { - // The RPC port is occupied by another process; retry - // with the next candidate. A configured Torrent port - // cannot cause this branch because overlapping RPC - // candidates are skipped above. - continue; - } - startup_failure = Some(if stderr.is_empty() { - format!( - "aria2 exited before startup on RPC port {attempt_port} with status {status}" - ) - } else { - format!( - "aria2 exited before startup on RPC port {attempt_port}: {stderr}" - ) - }); - break; + if let Ok(Some(_)) = child.try_wait() { + // Process exited, likely port collision, try next + continue; } log::info!("aria2c spawned successfully on port {}", attempt_port); @@ -18797,23 +9535,7 @@ pub fn run() { match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await { Ok(ver) => { let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown"); - let async_dns_supported = ver - .get("enabledFeatures") - .and_then(|features| features.as_array()) - .map(|features| { - features.iter().any(|feature| { - feature.as_str() == Some("Async DNS") - }) - }) - .unwrap_or(false); - queue_manager_capability - .set_aria2_async_dns_supported(async_dns_supported); - log::info!( - "aria2 daemon ready (version {}) on port {} (async DNS: {})", - v, - attempt_port, - async_dns_supported - ); + log::info!("aria2 daemon ready (version {}) on port {}", v, attempt_port); ready = true; break; } @@ -18831,20 +9553,16 @@ pub fn run() { break; } Err(e) => { - startup_failure = Some(format!("Failed to spawn aria2c: {e}")); + log::error!("Failed to spawn aria2c: {}", e); + let guard = app_handle_bg.state::(); + *guard.startup_error.lock().unwrap() = Some(format!("Failed to spawn aria2c: {e}")); break; } } } if !success { let guard = app_handle_bg.state::(); - *guard.startup_error.lock().unwrap() = Some(startup_failure.unwrap_or_else(|| { - if attempted_rpc_port { - "Failed to find open RPC port for aria2c".to_string() - } else { - "No Aria2 RPC port is available outside the configured Torrent TCP listen ports".to_string() - } - })); + *guard.startup_error.lock().unwrap() = Some("Failed to find open port for aria2c".to_string()); } } Err(e) => { @@ -18891,95 +9609,41 @@ pub fn run() { if let Some(event) = params.first().and_then(|p| p.as_object()) { if let Some(gid) = event.get("gid").and_then(|g| g.as_str()) { let state = app_handle_bg.state::(); - let mut progress = None; let outcome = match method { - "aria2.onDownloadStart" => { - let downloaded_bytes = aria2_download_status_snapshot( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - gid, - ) - .await - .ok() - .and_then(|(_, progress)| progress) - .and_then(|progress| progress.downloaded_bytes) - .filter(|value| value.is_finite() && *value > 0.0) - .map(|value| value as u64) - .unwrap_or_default(); - state - .queue_manager - .handle_aria2_download_start(gid, downloaded_bytes) - .await; - None - } - "aria2.onDownloadComplete" => { - progress = aria2_download_state_progress( - rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.tellStatus", - serde_json::json!([gid, ["status", "completedLength", "totalLength", "followedBy"]]), - ) - .await - .ok() - .as_ref(), - ); - Some(crate::queue::PendingOutcome::Complete) - } - "aria2.onBtDownloadComplete" => { - progress = aria2_download_state_progress( - rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.tellStatus", - serde_json::json!([gid, ["status", "completedLength", "totalLength"]]), - ) - .await - .ok() - .as_ref(), - ); - Some(crate::queue::PendingOutcome::Seeding) - } + "aria2.onDownloadComplete" => Some(crate::queue::PendingOutcome::Complete), "aria2.onDownloadError" => { let mut msg = event.get("error_message").and_then(|m| m.as_str()).unwrap_or("aria2 download error").to_string(); - let status = rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.tellStatus", - serde_json::json!([gid, ["errorCode", "errorMessage", "completedLength", "totalLength", "status"]]), - ) - .await - .ok(); - if let Some(status) = status.as_ref() { - progress = aria2_download_state_progress(Some(status)); - let err_msg = status - .get("errorMessage") - .and_then(|m| m.as_str()) - .filter(|m| !m.is_empty()); - let err_code = status - .get("errorCode") - .and_then(|m| m.as_str()) - .filter(|m| !m.is_empty()); - match (err_code, err_msg) { - (Some(code), Some(message)) => { - msg = format!("aria2 error code {code}: {message}"); - } - (Some(code), None) => { - msg = format!("aria2 error code {code}: {msg}"); - } - (None, Some(message)) => { - msg = message.to_string(); - } - (None, None) => {} - } + let aria2_port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); + let aria2_secret = state.aria2_secret.clone(); + if let Ok(status) = rpc_call(aria2_port, &aria2_secret, "aria2.tellStatus", serde_json::json!([gid, ["errorCode", "errorMessage"]])).await { + let err_msg = status + .get("errorMessage") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()); + let err_code = status + .get("errorCode") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()); + match (err_code, err_msg) { + (Some(code), Some(message)) => { + msg = format!("aria2 error code {code}: {message}"); } + (Some(code), None) => { + msg = format!("aria2 error code {code}: {msg}"); + } + (None, Some(message)) => { + msg = message.to_string(); + } + (None, None) => {} + } + } Some(crate::queue::PendingOutcome::Error(msg)) } _ => None, }; if let Some(outcome) = outcome { Arc::clone(&state.queue_manager) - .handle_aria2_event_with_progress(gid, outcome, progress) + .handle_aria2_event(gid, outcome) .await; } } @@ -19010,12 +9674,6 @@ pub fn run() { let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut observations: HashMap = HashMap::new(); let mut missing_gid_recovery_at: HashMap = HashMap::new(); - let mut telemetry_hydrated = HashSet::new(); - let mut telemetry_persisted: HashMap< - String, - crate::queue::TorrentTelemetrySnapshot, - > = HashMap::new(); - let mut relocation_checks = HashSet::new(); loop { interval.tick().await; // Terminal cleanup removes a download's GID mapping. Do @@ -19028,47 +9686,9 @@ pub fn run() { .collect(); observations.retain(|id, _| mapped_ids.contains(id)); missing_gid_recovery_at.retain(|id, _| mapped_ids.contains(id)); - telemetry_hydrated.retain(|id| mapped_ids.contains(id)); - telemetry_persisted.retain(|id, _| mapped_ids.contains(id)); - relocation_checks.retain(|id| mapped_ids.contains(id)); - let params = serde_json::json!([[ - "gid", - "status", - "totalLength", - "completedLength", - "downloadSpeed", - "uploadLength", - "uploadSpeed", - "numSeeders", - "seeder", - "connections", - "errorCode", - "errorMessage", - "verifiedLength", - "verifyIntegrityPending" - ]]); - let active_poll_started = Instant::now(); - let active_list = match rpc_call( - poll_port.load(std::sync::atomic::Ordering::Relaxed), - &poll_secret, - "aria2.tellActive", - params, - ) - .await - { - Ok(active_list) => active_list, - Err(error) => { - log::warn!( - "aria2 poller [stage=poll operation=tell_active result=failed error_class={} error_code={} elapsed_ms={}]", - crate::retry::network_error_class(&error), - crate::retry::aria2_error_code(&error) - .unwrap_or_else(|| "none".to_string()), - active_poll_started.elapsed().as_millis() - ); - continue; - } - }; - if let Some(active_arr) = active_list.as_array() { + let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]); + if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await { + if let Some(active_arr) = active_list.as_array() { let mut seen_ids = HashSet::new(); let mut seen_gids = HashSet::new(); for status_info in active_arr { @@ -19081,27 +9701,7 @@ pub fn run() { let status = status_info.get("status").and_then(|value| value.as_str()).unwrap_or(""); let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); - let verified_length = status_info - .get("verifiedLength") - .and_then(|value| { - value - .as_str() - .and_then(|value| value.parse::().ok()) - .or_else(|| value.as_u64()) - }); - let verify_pending = status_info - .get("verifyIntegrityPending") - .is_some_and(|value| { - value.as_bool() == Some(true) - || value.as_str() == Some("true") - }); let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0.0); - let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); - let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); - let num_seeders = aria2_nonnegative_count(status_info, "numSeeders"); - let is_seeder = status_info.get("seeder").is_some_and(|value| { - value.as_str() == Some("true") || value.as_bool() == Some(true) - }); let active_connections = aria2_active_connection_count(status_info); let requested_connections = poll_mgr @@ -19109,18 +9709,10 @@ pub fn run() { .await .unwrap_or(1) .max(1); - let effective_connections = poll_mgr - .aria2_effective_connections(&id, mapping.epoch) - .await - .unwrap_or(requested_connections) - .max(1); let speed_limited = poll_mgr.aria2_speed_limited(&id).await; - let is_torrent = poll_mgr.aria2_is_torrent(&id).await; - let is_verification = - poll_mgr.aria2_is_torrent_verification(&id).await; let control_epoch = mapping.epoch; - // The status snapshot and both connection - // lookups await. A pause, + // The status snapshot and the requested + // connection lookup both await. A pause, // retry, or same-GID resume may replace the // mapping while those awaits are in // flight. Only emit telemetry if this @@ -19132,33 +9724,10 @@ pub fn run() { { continue; } - let should_load_torrent_tracker_count = is_torrent - && observations - .get(&id) - .is_none_or(|observation| { - !observation.torrent_tracker_count_loaded - }); - let torrent_tracker_count = if should_load_torrent_tracker_count { - Some(poll_mgr.aria2_torrent_tracker_count(&id).await) - } else { - None - }; - if should_load_torrent_tracker_count - && (!poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await) - { - continue; - } seen_ids.insert(id.clone()); seen_gids.insert(gid.to_string()); let now = Instant::now(); let observation = observations.entry(id.clone()).or_default(); - if let Some(torrent_tracker_count) = torrent_tracker_count { - observation.torrent_tracker_count = torrent_tracker_count; - observation.torrent_tracker_count_loaded = true; - } let recovery_reason = observe_aria2_connections_with_epoch( observation, Aria2ConnectionSample { @@ -19169,259 +9738,15 @@ pub fn run() { completed, speed_bytes, active_connections, - effective_connections, + requested_connections, speed_limited, - is_torrent, now, }, ); - let entering_seeding = is_seeder && !observation.seeder; - observation.seeder = is_seeder; - if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await - { - continue; - } - let retry_strike = if !is_torrent { - poll_mgr.aria2_retry_strike(&id).await - } else { - 0 - }; - if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await - { - continue; - } - if observation.last_logged_active_connections - != Some(active_connections) - || observation.last_connection_logged_at.is_none_or( - |logged_at| { - now.duration_since(logged_at) - >= ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL - }, - ) - { - if is_torrent { - let error_code = status_info - .get("errorCode") - .and_then(|value| value.as_str()) - .filter(|value| !value.is_empty()) - .unwrap_or("none"); - let elapsed_ms = observation - .started_at - .map(|started_at| now.duration_since(started_at).as_millis()) - .unwrap_or_default(); - let seeders = num_seeders - .map(|value| value.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - log::info!( - "aria2 Torrent diagnostics [id={} gid={} epoch={} status={} error_code={} active_connections={} seeders={} elapsed_ms={} tracker_count={} dht={} dht6={} pex={} lpd={}]", - id, - gid, - control_epoch, - status, - error_code, - active_connections, - seeders, - elapsed_ms, - observation - .torrent_tracker_count - .map(|count| count.to_string()) - .unwrap_or_else(|| "unknown".to_string()), - torrent_peer_discovery_for_poll.0, - torrent_peer_discovery_for_poll.1, - torrent_peer_discovery_for_poll.2, - torrent_peer_discovery_for_poll.3, - ); - } else { - log::info!( - "aria2 progress [stage=connections id={} gid={} epoch={} retry_strike={} status={} active_connections={} requested_connections={} effective_connections={} completed_bytes={} total_bytes={} speed_bytes_per_second={}]", - id, - gid, - control_epoch, - retry_strike, - status, - active_connections, - requested_connections, - effective_connections, - completed, - total, - speed_bytes - ); - } - observation.last_logged_active_connections = - Some(active_connections); - observation.last_connection_logged_at = Some(now); - } - let torrent_telemetry = if is_torrent { - if !telemetry_hydrated.contains(&id) { - match load_persisted_torrent_item( - &app_handle_poll.state::(), - &id, - ) { - Ok(item) => { - poll_mgr - .hydrate_torrent_telemetry( - &id, - item.torrent_uploaded_bytes.unwrap_or(0), - item.torrent_seeded_seconds.unwrap_or(0), - ) - .await; - if item.torrent_relocation_check_pending - == Some(true) - { - relocation_checks.insert(id.clone()); - } - telemetry_hydrated.insert(id.clone()); - } - Err(error) => { - log::debug!( - "aria2 telemetry [{}]: could not hydrate durable totals: {}", - id, - error - ); - } - } - } - let seed_permit_owned = poll_mgr - .aria2_torrent_seed_permit_owned(&id) - .await; - Some( - poll_mgr - .observe_torrent_telemetry( - &id, - gid, - control_epoch, - uploaded_bytes, - is_seeder && seed_permit_owned, - Instant::now(), - ) - .await, - ) - } else { - None - }; - if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await - { - continue; - } - if let Some(snapshot) = torrent_telemetry { - if telemetry_persisted.get(&id) != Some(&snapshot) { - if let Err(error) = persist_torrent_telemetry( - &app_handle_poll.state::(), - &id, - snapshot, - ) { - log::debug!( - "aria2 telemetry [{}]: could not persist durable totals: {}", - id, - error - ); - } else { - telemetry_persisted.insert(id.clone(), snapshot); - } - } - } - let is_verifying = is_torrent - && (verify_pending - || (status == "active" - && total > 0 - && completed >= total - && verified_length.is_some_and(|value| value < total))); - let entering_verifying = is_verifying && !observation.verifying; - let leaving_verifying = !is_verifying && observation.verifying; - observation.verifying = is_verifying; - if relocation_checks.contains(&id) - && leaving_verifying - && matches!(status, "active" | "waiting") - && poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - && poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await - { - if let Err(error) = persist_torrent_relocation_check( - &app_handle_poll.state::(), - &id, - false, - ) { - log::debug!( - "aria2 relocation check [{}]: could not clear one-shot marker: {}", - id, - error - ); - } else { - let _ = poll_mgr - .clear_torrent_relocation_check(&id, control_epoch) - .await; - relocation_checks.remove(&id); - } - } - if is_verification - && leaving_verifying - && total > 0 - && verified_length.is_some_and(|value| value >= total) - { - // A verification lifecycle is considered - // observed only after Aria2 leaves its - // pending/hash-check phase for this - // same GID epoch. This evidence is used - // by terminal reconciliation instead - // of treating a bare complete event as - // proof that data was verified. - poll_mgr - .record_torrent_verified_length( - &id, - control_epoch, - total, - ) - .await; - } - - if entering_verifying { - let _ = app_handle_poll.emit( - "download-state", - crate::ipc::DownloadStateEvent::new( - &id, - crate::ipc::DownloadStatus::Verifying, - ), - ); - } else if leaving_verifying && matches!(status, "active" | "waiting") { - let _ = app_handle_poll.emit( - "download-state", - crate::ipc::DownloadStateEvent::new( - &id, - crate::ipc::DownloadStatus::Downloading, - ), - ); - } - - let fraction = if is_verifying { - if total > 0 { - (verified_length.unwrap_or(0).min(total) as f64 / total as f64) - .clamp(0.0, 1.0) - } else { - 0.0 - } - } else if total > 0 { - (completed.min(total) as f64 / total as f64).clamp(0.0, 1.0) - } else { - 0.0 - }; - let speed = if is_verifying { - "0 B/s".to_string() - } else { - crate::download::format_speed(speed_bytes) - }; - let upload_speed = upload_speed_bytes.map(crate::download::format_speed); - let eta = if !is_verifying && speed_bytes > 0.0 && total > completed { + let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 }; + let speed = crate::download::format_speed(speed_bytes); + let eta = if speed_bytes > 0.0 && total > completed { crate::download::format_duration((total - completed) as f64 / speed_bytes) } else { "-".to_string() @@ -19432,22 +9757,6 @@ pub fn run() { None }; - // tellActive is the poller's confirmed native - // progress path. If the WebSocket start - // 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 !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, control_epoch) - .await - { - continue; - } - use tauri::Emitter; let _ = app_handle_poll.emit("download-progress", DownloadProgressEvent { id: id.clone(), @@ -19460,51 +9769,28 @@ pub fn run() { total_bytes: (total > 0).then_some(total as f64), total_is_estimate: Some(false), active_connections: Some(active_connections), - requested_connections: (!is_torrent).then_some(requested_connections), - effective_connections: (!is_torrent) - .then_some(effective_connections), - uploaded_bytes: torrent_telemetry - .map(|value| value.uploaded_bytes as f64) - .or_else(|| uploaded_bytes.map(|value| value as f64)), - upload_speed, - num_seeders, - torrent_seeded_seconds: torrent_telemetry - .map(|value| value.seeded_seconds as f64), + requested_connections: Some(requested_connections), }); - if entering_seeding - && poll_mgr.aria2_torrent_seeding_requested(&id).await - { - poll_mgr - .handle_aria2_event( - &gid, - crate::queue::PendingOutcome::Seeding, - ) - .await; - } - if let Some(reason) = recovery_reason { log::warn!( - "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={} effective_connections={}", + "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}", id, gid, reason.as_str(), speed_bytes, active_connections, - requested_connections, - effective_connections + requested_connections ); if let Err(error) = poll_mgr .refresh_aria2_connections(&id, gid, control_epoch) .await { log::warn!( - "aria2 connection recovery [stage=recovery id={} gid={} operation=refresh result=failed error_class={} error_code={}]", + "aria2 connection recovery [{}] for gid {} failed: {}", id, gid, - crate::retry::network_error_class(&error), - crate::retry::aria2_error_code(&error) - .unwrap_or_else(|| "none".to_string()) + error ); } } @@ -19525,19 +9811,17 @@ pub fn run() { poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellStatus", - aria2_reconciliation_status_params(&gid), + serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), ) .await { Ok(status) => status, Err(error) => { log::debug!( - "aria2 poller [stage=poll operation=tell_status id={} gid={} result=failed error_class={} error_code={}]", + "aria2 poller reconciliation [{}]: could not query gid {}: {}", id, gid, - crate::retry::network_error_class(&error), - crate::retry::aria2_error_code(&error) - .unwrap_or_else(|| "none".to_string()) + error ); let recovery_allowed = missing_gid_recovery_at .get(&id) @@ -19573,16 +9857,10 @@ pub fn run() { } Err(recovery_error) => { log::warn!( - "aria2 poller [stage=recovery operation=missing_gid id={} gid={} result=failed error_class={} error_code={}]", + "aria2 poller reconciliation [{}]: payload recovery for missing gid {} failed: {}", id, gid, - crate::retry::network_error_class( - &recovery_error, - ), - crate::retry::aria2_error_code( - &recovery_error, - ) - .unwrap_or_else(|| "none".to_string()) + recovery_error ); } } @@ -19591,105 +9869,12 @@ pub fn run() { continue; } }; - let Some(mapping) = poll_mgr - .aria2_gid_mapping(&gid) - .filter(|mapping| mapping.id == id) - else { - continue; - }; - let is_torrent = poll_mgr.aria2_is_torrent(&id).await; - if is_torrent - && poll_mgr - .is_aria2_control_epoch_current(&id, mapping.epoch) - .await - { - let upload_length = status - .get("uploadLength") - .and_then(|value| value.as_str()) - .and_then(|value| value.parse::().ok()); - let is_seeder = status.get("seeder").is_some_and(|value| { - value.as_str() == Some("true") - || value.as_bool() == Some(true) - }); - let seed_permit_owned = poll_mgr - .aria2_torrent_seed_permit_owned(&id) - .await; - let snapshot = poll_mgr - .observe_torrent_telemetry( - &id, - &gid, - mapping.epoch, - upload_length, - is_seeder && seed_permit_owned, - Instant::now(), - ) - .await; - if poll_mgr.is_current_aria2_gid_mapping(&gid, &mapping) - && poll_mgr - .is_aria2_control_epoch_current(&id, mapping.epoch) - .await - && telemetry_persisted.get(&id) != Some(&snapshot) - { - if let Err(error) = persist_torrent_telemetry( - &app_handle_poll.state::(), - &id, - snapshot, - ) { - log::debug!( - "aria2 terminal telemetry [{}]: could not persist durable totals: {}", - id, - error - ); - } else { - telemetry_persisted.insert(id.clone(), snapshot); - } - } - } - let retry_strike = if !is_torrent { - poll_mgr.aria2_retry_strike(&id).await - } else { - 0 - }; - let requested_connections = if !is_torrent { - poll_mgr - .aria2_requested_connections(&id) - .await - .unwrap_or(1) - .max(1) - } else { - 1 - }; - let effective_connections = if !is_torrent { - poll_mgr - .aria2_effective_connections(&id, mapping.epoch) - .await - .unwrap_or(requested_connections) - .max(1) - } else { - 1 - }; - if !poll_mgr.is_current_aria2_gid_mapping(&gid, &mapping) - || !poll_mgr - .is_aria2_control_epoch_current(&id, mapping.epoch) - .await - { - continue; - } let status_name = status .get("status") .and_then(|value| value.as_str()) .unwrap_or(""); let outcome = match status_name { "complete" => Some(crate::queue::PendingOutcome::Complete), - "active" - if status.get("seeder").is_some_and(|value| { - value.as_str() == Some("true") - || value.as_bool() == Some(true) - }) - && poll_mgr.aria2_torrent_seeding_requested(&id).await => - { - Some(crate::queue::PendingOutcome::Seeding) - } "error" | "removed" => { let error_code = status .get("errorCode") @@ -19708,39 +9893,18 @@ pub fn run() { _ => None, }; if let Some(outcome) = outcome { - let progress = aria2_download_state_progress(Some(&status)); - let terminal_error = match &outcome { - crate::queue::PendingOutcome::Error(error) => Some(error.as_str()), - _ => None, - }; log::info!( - "aria2 poller reconciliation [stage=terminal id={} gid={} epoch={} retry_strike={} status={} requested_connections={} effective_connections={} error_class={} error_code={} source=tell_status_outside_active]", + "aria2 poller reconciliation [{}]: gid {} reported terminal status {} outside tellActive", id, gid, - mapping.epoch, - retry_strike, - status_name, - requested_connections, - effective_connections, - terminal_error - .map(crate::retry::network_error_class) - .unwrap_or("none"), - terminal_error - .and_then(crate::retry::aria2_error_code) - .unwrap_or_else(|| "none".to_string()) + status_name ); - poll_mgr - .handle_aria2_event_with_progress(&gid, outcome, progress) - .await; + poll_mgr.handle_aria2_event(&gid, outcome).await; } } observations.retain(|id, _| seen_ids.contains(id)); - } else { - log::warn!( - "aria2 poller [stage=poll operation=tell_active result=malformed elapsed_ms={}]", - active_poll_started.elapsed().as_millis() - ); } + } } }); @@ -19805,51 +9969,6 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_clipboard_manager::init()) .on_window_event(|window, event| { - if properties_window::is_properties_window_label(window.label()) { - let resize = match event { - tauri::WindowEvent::Resized(size) => { - window.scale_factor().ok().map(|scale_factor| { - (size.width, size.height, scale_factor) - }) - } - tauri::WindowEvent::ScaleFactorChanged { - scale_factor, - new_inner_size, - .. - } => Some((new_inner_size.width, new_inner_size.height, *scale_factor)), - tauri::WindowEvent::CloseRequested { .. } => window - .inner_size() - .ok() - .and_then(|size| { - window - .scale_factor() - .ok() - .map(|scale_factor| (size.width, size.height, scale_factor)) - }), - _ => None, - }; - if let Some((width, height, scale_factor)) = resize { - if let Some(registry) = window - .app_handle() - .try_state::() - { - let _ = registry.remember_size( - window.label(), - width, - height, - scale_factor, - ); - } - } - if matches!(event, tauri::WindowEvent::Destroyed) { - if let Some(registry) = window.app_handle().try_state::() { - let _ = registry.remove_window(window.label()); - } - let _ = window - .app_handle() - .emit_to("main", "properties-window-closed", window.label().to_string()); - } - } if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -19860,7 +9979,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, - pause_download, resume_download, fetch_metadata, inspect_torrent, rekey_torrent_metadata, remove_torrent_metadata, fetch_media_metadata, fetch_media_playlist_metadata, + pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, @@ -19870,25 +9989,14 @@ pub fn run() { get_keychain_grant_status, accept_keychain_grant, abandon_keychain_grant, authorize_keychain_access, acknowledge_pairing_token_change, - inspect_download_target, toggle_tray_icon, set_extension_pairing_token, - get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, + check_file_exists, toggle_tray_icon, set_extension_pairing_token, + get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, - properties_window::open_download_properties_window, - properties_window::get_properties_window_download_id, - properties_window::properties_window_send_ready, - properties_window::properties_window_reveal, - properties_window::properties_window_send_action, - properties_window::validate_properties_window_request, - properties_window::close_download_properties_window, - properties_window::properties_window_registry_remove_for_download, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, parity::create_category_directories, - db_save_settings, db_load_settings, canonicalize_torrent_network_setting, - db_get_all_downloads, db_replace_downloads, - db_commit_download_state, - clear_torrent_removal_paths, reconcile_torrent_removal_reservations, + db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, db_get_all_queues, db_replace_queues, read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs, set_log_stream_active @@ -19896,20 +10004,6 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| match event { - tauri::RunEvent::Ready => { - mark_main_window_startup_complete(app_handle); - #[cfg(target_os = "windows")] - reveal_main_window(app_handle); - #[cfg(not(target_os = "windows"))] - restore_pending_main_window(app_handle); - } - #[cfg(target_os = "macos")] - tauri::RunEvent::Opened { urls } => { - let paths = collect_opened_torrent_paths( - urls.into_iter().map(|url| url.to_string()), - ); - dispatch_opened_torrent_paths(app_handle.clone(), paths); - } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { has_visible_windows, @@ -19919,44 +10013,9 @@ pub fn run() { restore_main_window(app_handle); } } - tauri::RunEvent::ExitRequested { code, api, .. } => { - let guard = app_handle.state::(); - if !guard.exit_allowed() { - api.prevent_exit(); - if guard.begin_shutdown() { - let app = app_handle.clone(); - tauri::async_runtime::spawn(async move { - let frontend_exit_flush = app.state::().frontend_exit_flush.clone(); - let extension_server_shutdown = app - .state::() - .extension_server_shutdown - .clone(); - let request = frontend_exit_flush.request(); - let mut completed = frontend_exit_flush.completed.subscribe(); - let _ = app.emit_to("main", "app-exit-requested", ()); - let flush_wait = async move { - loop { - if *completed.borrow() >= request { - break; - } - if completed.changed().await.is_err() { - break; - } - } - }; - if tokio::time::timeout(Duration::from_secs(2), flush_wait) - .await - .is_err() - { - log::warn!("frontend persistence flush timed out during exit"); - } - let _ = extension_server_shutdown.send(true); - shutdown_aria2_daemon(app.clone()).await; - app.state::().allow_exit(); - app.exit(code.unwrap_or(0)); - }); - } - } + tauri::RunEvent::ExitRequested { .. } => { + let state = app_handle.state::(); + let _ = state.extension_server_shutdown.send(true); } _ => {} }); diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index ffc6a53..d90cb59 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -6,8 +6,7 @@ use ts_rs::TS; use crate::ipc::DownloadCategory; #[tauri::command] -pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result, String> { - crate::properties_window::ensure_main_window(&caller)?; +pub async fn get_system_proxy() -> Result, String> { match native_system_proxy() { Ok(Some(proxy)) => Ok(Some(proxy)), Ok(None) => Ok(proxy_from_environment()), @@ -486,9 +485,7 @@ pub fn get_file_category(filename: String) -> DownloadCategory { "run", "sh", "bin", "jar", ]; - if ext == "torrent" { - DownloadCategory::Torrents - } else if music_exts.contains(&ext.as_str()) { + if music_exts.contains(&ext.as_str()) { DownloadCategory::Musics } else if movie_exts.contains(&ext.as_str()) { DownloadCategory::Movies @@ -542,10 +539,8 @@ struct GitHubRelease { #[tauri::command] pub async fn check_for_updates( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, ) -> Result { - crate::properties_window::ensure_main_window(&caller)?; let current_version = app_handle.package_info().version.to_string(); crate::ensure_reqwest_crypto_provider(); @@ -611,12 +606,10 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering { #[tauri::command] pub async fn create_category_directories( - caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, base_folder: String, subfolders: std::collections::HashMap, ) -> Result<(), String> { - crate::properties_window::ensure_main_window(&caller)?; let base = crate::resolve_path(&base_folder, &app_handle); let mut errors = Vec::new(); @@ -691,21 +684,3 @@ pub fn is_supported_media(url: String) -> bool { } false } - -#[cfg(test)] -mod tests { - use super::get_file_category; - use crate::ipc::DownloadCategory; - - #[test] - fn classifies_torrent_files_as_torrents() { - assert!(matches!( - get_file_category("Example.TORRENT".to_string()), - DownloadCategory::Torrents - )); - assert!(matches!( - get_file_category("Example.mkv".to_string()), - DownloadCategory::Movies - )); - } -} diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 2911588..3d983ef 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -1,255 +1,6 @@ use std::ffi::OsString; -use std::io; use std::path::{Path, PathBuf}; -/// Return a stable filesystem identity for an existing Windows file without -/// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with -/// delete sharing so inspection does not unnecessarily block normal cleanup -/// or replacement; callers still validate the path with `symlink_metadata` -/// before using this identity. -#[cfg(target_os = "windows")] -pub fn file_identity(path: &Path) -> Option { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_NORMAL, - FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - OPEN_EXISTING, - }; - - let wide_path = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // A zero desired-access mask requests metadata access only. Opening with - // all sharing flags avoids introducing a lock that changes the outcome of - // a subsequent exact replacement or cleanup operation. - let handle = unsafe { - CreateFileW( - wide_path.as_ptr(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return None; - } - - let mut metadata = BY_HANDLE_FILE_INFORMATION::default(); - let result = unsafe { - let succeeded = GetFileInformationByHandle(handle, &mut metadata) != 0; - let _ = CloseHandle(handle); - succeeded - }; - result.then(|| { - format!( - "{}:{}:{}", - metadata.dwVolumeSerialNumber, metadata.nFileIndexHigh, metadata.nFileIndexLow - ) - }) -} - -const ATOMIC_TEMP_PREFIX: &str = ".firelink-atomic-"; - -/// Write bytes to a same-directory temporary file, synchronize them, and -/// replace the destination without ever opening the destination for writing. -/// -/// The destination is checked with `symlink_metadata` so managed callers fail -/// closed when an attacker or another process has substituted a link or a -/// non-file. The final rename is atomic on Unix and uses Windows replace -/// semantics rather than the non-replacing `std::fs::rename` behavior. -pub async fn atomic_write_replace(path: &Path, bytes: &[u8]) -> io::Result<()> { - let parent = path - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "atomic path has no parent"))?; - validate_atomic_parent(parent).await?; - - match tokio::fs::symlink_metadata(path).await { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "atomic destination cannot be a symbolic link", - )); - } - Ok(metadata) if !metadata.file_type().is_file() => { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "atomic destination is not a regular file", - )); - } - Ok(_) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - - let temporary = parent.join(format!( - "{ATOMIC_TEMP_PREFIX}{}.tmp", - uuid::Uuid::new_v4().simple() - )); - let write_result = async { - use tokio::io::AsyncWriteExt; - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) - .await?; - file.write_all(bytes).await?; - file.sync_all().await - } - .await; - - if let Err(error) = write_result { - let _ = tokio::fs::remove_file(&temporary).await; - return Err(error); - } - - if let Err(error) = replace_staged_file(&temporary, path) { - let _ = tokio::fs::remove_file(&temporary).await; - return Err(error); - } - - #[cfg(unix)] - { - // A directory sync makes the rename durable across a power loss on - // platforms that support opening directories as file descriptors. - std::fs::File::open(parent)?.sync_all()?; - } - - Ok(()) -} - -async fn validate_atomic_parent(parent: &Path) -> io::Result<()> { - use std::path::Component; - - let mut current = PathBuf::new(); - for component in parent.components() { - match component { - Component::Prefix(prefix) => current.push(prefix.as_os_str()), - Component::RootDir => current.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "atomic parent contains a parent-directory component", - )); - } - Component::Normal(name) => { - current.push(name); - let metadata = tokio::fs::symlink_metadata(¤t).await?; - if metadata.file_type().is_symlink() { - if let Some(canonical_alias) = resolve_atomic_system_alias(¤t)? { - current = canonical_alias; - continue; - } - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "atomic parent cannot contain a symbolic link", - )); - } - if !metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - "atomic parent is not a directory", - )); - } - } - } - } - Ok(()) -} - -fn resolve_atomic_system_alias(path: &Path) -> io::Result> { - #[cfg(target_os = "macos")] - { - let expected = match path { - path if path == Path::new("/tmp") => Some(Path::new("/private/tmp")), - path if path == Path::new("/var") => Some(Path::new("/private/var")), - path if path == Path::new("/etc") => Some(Path::new("/private/etc")), - _ => None, - }; - if let Some(expected) = expected { - let canonical = std::fs::canonicalize(path)?; - if canonical == expected { - return Ok(Some(canonical)); - } - } - } - - let _ = path; - Ok(None) -} - -pub fn is_atomic_temp_file_name(name: &str) -> bool { - let Some(suffix) = name.strip_prefix(ATOMIC_TEMP_PREFIX) else { - return false; - }; - let Some(identifier) = suffix.strip_suffix(".tmp") else { - return false; - }; - identifier.len() == 32 && identifier.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn replace_staged_file(temporary: &Path, destination: &Path) -> io::Result<()> { - #[cfg(not(target_os = "windows"))] - { - std::fs::rename(temporary, destination) - } - - #[cfg(target_os = "windows")] - { - use std::os::windows::ffi::OsStrExt; - use std::thread; - use std::time::Duration; - use windows_sys::Win32::Foundation::{ - GetLastError, ERROR_LOCK_VIOLATION, ERROR_SHARING_VIOLATION, - }; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - - let temporary = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let destination = destination - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - - for attempt in 0..5 { - // SAFETY: both paths are NUL-terminated UTF-16 buffers owned for - // the duration of the call, and the flags request same-volume - // replacement with write-through semantics. - let replaced = unsafe { - MoveFileExW( - temporary.as_ptr(), - destination.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if replaced != 0 { - return Ok(()); - } - - let error = unsafe { GetLastError() }; - if !matches!(error, ERROR_LOCK_VIOLATION | ERROR_SHARING_VIOLATION) || attempt == 4 { - return Err(io::Error::from_raw_os_error(error as i32)); - } - thread::sleep(Duration::from_millis(25 * (attempt + 1) as u64)); - } - - unreachable!("atomic Windows replacement loop always returns"); - } -} - pub fn target_arch() -> &'static str { if cfg!(target_arch = "aarch64") { "aarch64" @@ -357,115 +108,29 @@ fn trusted_system_path_entries() -> Vec { pub fn path_is_within(path: &Path, root: &Path) -> bool { #[cfg(target_os = "windows")] { - let path = path_identity(path); - let root = path_identity(root); + let path = path.to_string_lossy().to_lowercase(); + let root = root.to_string_lossy().to_lowercase(); path == root - || (root.len() == 3 - && root.ends_with('/') - && root.as_bytes()[1] == b':' - && path.starts_with(&root)) || path .strip_prefix(&root) - .is_some_and(|suffix| suffix.starts_with('/')) + .is_some_and(|suffix| suffix.starts_with(['\\', '/'])) } - #[cfg(target_os = "macos")] - { - // Containment is a scope check, not an equality check. Do not fold - // case here: case-sensitive APFS/HFS+ volumes are valid macOS - // configurations, and lowercasing could admit `/Users/nima2` or a - // differently-cased sibling outside the approved root. Callers pass - // canonical paths (with only missing leaf components preserved), so - // NFC normalization is enough to compare macOS path spellings. - use unicode_normalization::UnicodeNormalization; - - let path = path.to_string_lossy().nfc().collect::(); - let root = root.to_string_lossy().nfc().collect::(); - let root = root.trim_end_matches('/'); - if path == root || (root.is_empty() && path == "/") { - return true; - } - - if root.is_empty() { - return path.starts_with('/'); - } - - path.strip_prefix(root) - .is_some_and(|suffix| suffix.starts_with('/')) - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - path.starts_with(root) - } - - #[cfg(not(any(unix, target_os = "windows", target_os = "macos")))] + #[cfg(not(target_os = "windows"))] { path.starts_with(root) } } pub fn paths_equal(left: &Path, right: &Path) -> bool { - path_identity(left) == path_identity(right) -} - -/// Return the in-process lock identity for a path using the same platform -/// equivalence rules as `paths_equal`. Callers use this for serialization, -/// not for display or persistence. -pub fn path_identity(path: &Path) -> String { #[cfg(target_os = "windows")] { - let mut normalized = path.to_string_lossy().replace('\\', "/"); - if normalized - .get(..8) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) - { - normalized.replace_range(..8, "//"); - } else if normalized - .get(..4) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/")) - { - normalized.replace_range(..4, ""); - } - - let is_unc = normalized.starts_with("//"); - let mut collapsed = String::with_capacity(normalized.len()); - for character in normalized.chars() { - if character == '/' && collapsed.ends_with('/') && !(is_unc && collapsed.len() == 1) { - continue; - } - collapsed.push(character); - } - while collapsed.len() > 1 - && collapsed.ends_with('/') - && !(collapsed.len() == 3 && collapsed.as_bytes()[1] == b':') - { - collapsed.pop(); - } - collapsed.to_lowercase() + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) } - #[cfg(target_os = "macos")] + #[cfg(not(target_os = "windows"))] { - use unicode_normalization::UnicodeNormalization; - - path.to_string_lossy() - .to_lowercase() - .nfc() - .collect::() - } - #[cfg(all(unix, not(target_os = "macos")))] - { - use std::os::unix::ffi::OsStrExt; - - path.as_os_str() - .as_bytes() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() - } - #[cfg(not(any(unix, target_os = "windows", target_os = "macos")))] - { - path.to_string_lossy().to_string() + left == right } } @@ -490,10 +155,7 @@ fn numbered_windows_device(stem: &str, prefix: &str) -> bool { #[cfg(test)] mod tests { - #[cfg(any(target_os = "windows", target_os = "macos"))] - use super::path_is_within; - use super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple}; - use std::path::Path; + use super::{engine_binary_name, is_windows_reserved_filename, target_triple}; #[test] fn target_engine_name_uses_current_rust_target() { @@ -524,88 +186,4 @@ mod tests { assert!(!is_windows_reserved_filename(filename), "{filename}"); } } - - #[test] - fn path_identity_matches_the_host_filesystem_case_contract() { - let left = Path::new("/downloads/Selected/File.bin"); - let right = Path::new("/Downloads/selected/file.BIN"); - if cfg!(any(target_os = "windows", target_os = "macos")) { - assert!(paths_equal(left, right)); - } else { - assert!(!paths_equal(left, right)); - } - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_path_identity_normalizes_separators_and_verbatim_prefixes() { - assert!(paths_equal( - Path::new(r"C:\downloads\file.bin"), - Path::new("c:/DOWNLOADS/file.bin") - )); - assert!(paths_equal( - Path::new(r"C:\downloads\file.bin"), - Path::new(r"\\?\C:\downloads\file.bin") - )); - assert!(paths_equal( - Path::new(r"\\server\share\file.bin"), - Path::new(r"\\?\UNC\server\share\file.bin") - )); - assert!(path_is_within( - Path::new("c:/downloads/file.bin"), - Path::new(r"C:\downloads") - )); - } - - #[test] - fn path_identity_handles_non_ascii_case_differences() { - let left = Path::new("/downloads/Ärt/File.bin"); - let right = Path::new("/DOWNLOADS/ärt/file.BIN"); - if cfg!(any(target_os = "windows", target_os = "macos")) { - assert!(paths_equal(left, right)); - } else { - assert!(!paths_equal(left, right)); - } - } - - #[test] - fn path_identity_handles_macos_unicode_normalization() { - let composed = Path::new("/downloads/café/File.bin"); - let decomposed = Path::new("/DOWNLOADS/cafe\u{301}/file.BIN"); - if cfg!(target_os = "macos") { - assert!(paths_equal(composed, decomposed)); - } else { - assert!(!paths_equal(composed, decomposed)); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_path_is_within_preserves_scope_and_unicode_identity() { - assert!(path_is_within( - Path::new("/Downloads/cafe\u{301}/movie.bin"), - Path::new("/Downloads/café") - )); - assert!(path_is_within( - Path::new("/Downloads/movie.bin"), - Path::new("/Downloads") - )); - assert!(path_is_within( - Path::new("/Downloads"), - Path::new("/Downloads/") - )); - assert!(path_is_within(Path::new("/"), Path::new("////"))); - assert!(path_is_within( - Path::new("/Downloads/movie.bin"), - Path::new("/") - )); - assert!(!path_is_within( - Path::new("/downloads/cafeteria/movie.bin"), - Path::new("/Downloads/café") - )); - assert!(!path_is_within( - Path::new("/downloads/movie.bin"), - Path::new("/Downloads") - )); - } } diff --git a/src-tauri/src/properties_window.rs b/src-tauri/src/properties_window.rs deleted file mode 100644 index a7939d7..0000000 --- a/src-tauri/src/properties_window.rs +++ /dev/null @@ -1,786 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::sync::Mutex; - -use serde::Serialize; -use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; -use uuid::Uuid; - -const MAIN_WINDOW_LABEL: &str = "main"; -const PROPERTIES_LABEL_PREFIX: &str = "properties-"; -const PROPERTIES_WINDOW_TITLE: &str = "Properties - Firelink"; -const PROPERTIES_DEFAULT_WIDTH: f64 = 960.0; -const PROPERTIES_DEFAULT_HEIGHT: f64 = 640.0; -const PROPERTIES_MIN_WIDTH: f64 = 680.0; -const PROPERTIES_MIN_HEIGHT: f64 = 500.0; -const PROPERTIES_WINDOW_READY_EVENT: &str = "properties-window-ready"; -const PROPERTIES_WINDOW_ACTION_REQUEST_EVENT: &str = "properties-window-action-request"; -const MAX_PROPERTIES_ACTION_PAYLOAD_BYTES: usize = 64 * 1024; -const MAX_PROPERTIES_SESSION_ID_BYTES: usize = 128; -const MAX_PROPERTIES_REQUEST_ID: u64 = 9_007_199_254_740_991; -const MAX_RETIRED_PROPERTIES_SESSIONS: usize = 256; -const PROPERTIES_SESSION_HISTORY_EXHAUSTED: &str = - "Properties window session history is exhausted; close and reopen the window"; - -#[derive(Default)] -pub struct PropertiesWindowRegistry { - state: Mutex, -} - -#[derive(Default)] -struct RegistryState { - by_download: HashMap, - by_window: HashMap, - ready_windows: HashSet, - sessions_by_window: HashMap, - retired_sessions_by_window: HashMap>, - remembered_size: Option, -} - -#[derive(Clone, Copy)] -struct PropertiesWindowSize { - width: f64, - height: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct PropertiesWindowReadyEvent { - window_label: String, - download_id: String, - session_id: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct PropertiesWindowActionEvent { - window_label: String, - download_id: String, - session_id: String, - request_id: u64, - action: String, - payload: Option, -} - -impl PropertiesWindowRegistry { - pub(crate) fn remember_size( - &self, - window_label: &str, - physical_width: u32, - physical_height: u32, - scale_factor: f64, - ) -> Result<(), String> { - if !scale_factor.is_finite() || scale_factor <= 0.0 { - return Ok(()); - } - - let width = (f64::from(physical_width) / scale_factor).round(); - let height = (f64::from(physical_height) / scale_factor).round(); - if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { - return Ok(()); - } - - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - if !state.by_window.contains_key(window_label) { - return Ok(()); - } - - state.remembered_size = Some(PropertiesWindowSize { - width: width.max(PROPERTIES_MIN_WIDTH), - height: height.max(PROPERTIES_MIN_HEIGHT), - }); - Ok(()) - } - - fn remembered_size(&self) -> Result, String> { - Ok(self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .remembered_size - .map(|size| (size.width, size.height))) - } - - pub fn allocate(&self, download_id: &str) -> Result { - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - if let Some(label) = state.by_download.get(download_id) { - return Ok(label.clone()); - } - - let label = format!("{PROPERTIES_LABEL_PREFIX}{}", Uuid::new_v4().simple()); - state.by_download.insert(download_id.to_string(), label.clone()); - state.by_window.insert(label.clone(), download_id.to_string()); - Ok(label) - } - - pub fn download_for_window(&self, label: &str) -> Result, String> { - Ok(self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .by_window - .get(label) - .cloned()) - } - - pub fn remove_window(&self, label: &str) -> Result, String> { - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - let download_id = state.by_window.remove(label); - state.ready_windows.remove(label); - state.sessions_by_window.remove(label); - state.retired_sessions_by_window.remove(label); - if let Some(download_id) = &download_id { - state.by_download.remove(download_id); - } - Ok(download_id) - } - - pub fn remove_download(&self, download_id: &str) -> Result, String> { - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - let label = state.by_download.remove(download_id); - if let Some(label) = &label { - state.by_window.remove(label); - state.ready_windows.remove(label); - state.sessions_by_window.remove(label); - state.retired_sessions_by_window.remove(label); - } - Ok(label) - } - - pub fn window_for_download(&self, download_id: &str) -> Result, String> { - Ok(self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .by_download - .get(download_id) - .cloned()) - } - - pub fn mark_ready(&self, label: &str) -> Result<(), String> { - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - if !state.by_window.contains_key(label) { - return Err("Properties window is no longer registered".to_string()); - } - state.ready_windows.insert(label.to_string()); - Ok(()) - } - - pub fn register_session(&self, label: &str, session_id: &str) -> Result<(), String> { - let mut state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - if !state.by_window.contains_key(label) { - return Err("Properties window is no longer registered".to_string()); - } - if state - .sessions_by_window - .get(label) - .is_some_and(|current| current == session_id) - { - return Ok(()); - } - if state - .retired_sessions_by_window - .get(label) - .is_some_and(|retired| retired.contains(session_id)) - { - return Err("Properties window session is no longer current".to_string()); - } - if state.sessions_by_window.contains_key(label) - && state - .retired_sessions_by_window - .get(label) - .is_some_and(|retired| retired.len() >= MAX_RETIRED_PROPERTIES_SESSIONS) - { - return Err(PROPERTIES_SESSION_HISTORY_EXHAUSTED.to_string()); - } - if let Some(previous) = state - .sessions_by_window - .insert(label.to_string(), session_id.to_string()) - { - state - .retired_sessions_by_window - .entry(label.to_string()) - .or_default() - .insert(previous); - } - Ok(()) - } - - pub fn session_for_window(&self, label: &str) -> Result, String> { - Ok(self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .sessions_by_window - .get(label) - .cloned()) - } - - pub fn session_matches(&self, label: &str, session_id: &str) -> Result { - Ok(self.session_for_window(label)?.as_deref() == Some(session_id)) - } - - /// Validate a session and perform a short synchronous mutation while the - /// registry lock is held. Callers use this for cancellation flags so a - /// stale session cannot pass validation and then race a replacement - /// session before its mutation is recorded. - pub fn with_current_session( - &self, - label: &str, - session_id: &str, - mutation: impl FnOnce() -> Result, - ) -> Result { - let state = self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())?; - if state.sessions_by_window.get(label).map(String::as_str) != Some(session_id) { - return Err("Properties window session is no longer current".to_string()); - } - mutation() - } - - #[cfg(test)] - pub fn is_ready(&self, label: &str) -> Result { - Ok(self - .state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .ready_windows - .contains(label)) - } - - pub fn clear_ready(&self, label: &str) -> Result<(), String> { - self.state - .lock() - .map_err(|_| "Properties window registry is unavailable".to_string())? - .ready_windows - .remove(label); - Ok(()) - } -} - -pub fn is_properties_window_label(label: &str) -> bool { - label.starts_with(PROPERTIES_LABEL_PREFIX) - && label.len() > PROPERTIES_LABEL_PREFIX.len() - && label[PROPERTIES_LABEL_PREFIX.len()..] - .chars() - .all(|character| character.is_ascii_hexdigit()) -} - -/// Custom Tauri commands are not automatically narrowed by a capability's -/// window list. Commands that a Properties child may call must therefore -/// validate the invoking webview and its registered download explicitly. -pub fn ensure_properties_or_main( - caller: &tauri::WebviewWindow, - registry: &PropertiesWindowRegistry, - download_id: &str, -) -> Result<(), String> { - if caller.label() == MAIN_WINDOW_LABEL { - return Ok(()); - } - if !is_properties_window_label(caller.label()) - || registry.download_for_window(caller.label())?.as_deref() != Some(download_id) - { - return Err("This window is not authorized for the requested download".to_string()); - } - Ok(()) -} - -pub fn ensure_main_window(caller: &tauri::WebviewWindow) -> Result<(), String> { - (caller.label() == MAIN_WINDOW_LABEL) - .then_some(()) - .ok_or_else(|| "This command is available only to the main window".to_string()) -} - -fn emit_to_main( - app: &tauri::AppHandle, - event: &str, - payload: T, -) -> Result<(), String> { - use tauri::Emitter; - - if app.get_webview_window(MAIN_WINDOW_LABEL).is_none() { - return Err("Firelink main window is unavailable".to_string()); - } - - app.emit_to( - tauri::EventTarget::webview_window(MAIN_WINDOW_LABEL), - event, - payload, - ) - .map_err(|error| error.to_string()) -} - -fn registered_download_for_caller( - caller: &tauri::WebviewWindow, - registry: &PropertiesWindowRegistry, -) -> Result { - let label = caller.label(); - if !is_properties_window_label(label) { - return Err("This window is not a Properties window".to_string()); - } - registry - .download_for_window(label)? - .ok_or_else(|| "Properties window is no longer registered".to_string()) -} - -fn is_properties_action(action: &str) -> bool { - matches!( - action, - "apply-properties" - | "set-torrent-file-selection" - | "pause-resume" - | "verify-torrent" - | "set-download-limit" - | "set-torrent-upload-limit" - | "set-torrent-peer-options" - ) -} - -fn download_exists(db: &crate::db::DbState, download_id: &str) -> Result { - let connection = db.lock()?; - Ok(crate::db::load_downloads(&connection)?.into_iter().any(|record| { - serde_json::from_str::(&record) - .ok() - .and_then(|value| value.get("id").and_then(serde_json::Value::as_str).map(str::to_owned)) - .is_some_and(|id| id == download_id) - })) -} - -fn validate_download_id(download_id: &str) -> Result<(), String> { - let trimmed = download_id.trim(); - if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) { - return Err("Invalid download ID".to_string()); - } - Ok(()) -} - -fn validate_properties_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() - || session_id.len() > MAX_PROPERTIES_SESSION_ID_BYTES - || !session_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err("Invalid Properties window session".to_string()); - } - Ok(()) -} - -fn validate_properties_request_id(request_id: u64) -> Result<(), String> { - if request_id == 0 || request_id > MAX_PROPERTIES_REQUEST_ID { - return Err("Invalid Properties action request ID".to_string()); - } - Ok(()) -} - -#[tauri::command] -pub fn open_download_properties_window( - app: tauri::AppHandle, - caller: tauri::WebviewWindow, - db: tauri::State<'_, crate::db::DbState>, - registry: tauri::State<'_, PropertiesWindowRegistry>, - id: String, -) -> Result { - if caller.label() != MAIN_WINDOW_LABEL { - return Err("Only the main window can open Properties windows".to_string()); - } - validate_download_id(&id)?; - if !download_exists(&db, &id)? { - return Err("Download no longer exists".to_string()); - } - - let label = registry.allocate(&id)?; - if let Some(window) = app.get_webview_window(&label) { - // Visibility belongs to the native window owner, not to the renderer - // handshake. A delayed or lost snapshot must leave a usable loading - // window on screen instead of making the open request appear to do - // nothing. - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - return Ok(label); - } - - // If the native window disappeared without delivering Destroyed, discard - // the old readiness bit before constructing a fresh hidden webview. - registry.clear_ready(&label)?; - let (initial_width, initial_height) = registry - .remembered_size()? - .unwrap_or((PROPERTIES_DEFAULT_WIDTH, PROPERTIES_DEFAULT_HEIGHT)); - let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into())) - .title(PROPERTIES_WINDOW_TITLE) - .inner_size(initial_width, initial_height) - .min_inner_size(PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT) - .resizable(true) - .always_on_top(false) - // Let the child renderer paint its rounded loading shell before the - // native window becomes visible. Showing an opaque native surface - // here exposes the webview's unpainted white background. - .visible(false) - // A hidden WebView2 must not request focus during construction. The - // native reveal path focuses it after the window is visible. - .focused(false) - .transparent(true); - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] - let builder = builder.decorations(false); - let build_result = builder.build(); - if let Err(error) = build_result { - // Two rapid main-window requests can race between the native lookup - // above and builder creation. If the first request won, retain the - // registry entry and focus its window instead of treating the second - // request as a failed open. - if let Some(window) = app.get_webview_window(&label) { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - return Ok(label); - } - let _ = registry.remove_window(&label); - return Err(format!("Could not open Properties window: {error}")); - } - - Ok(label) -} - -#[tauri::command] -pub fn get_properties_window_download_id( - caller: tauri::WebviewWindow, - registry: tauri::State<'_, PropertiesWindowRegistry>, -) -> Result { - registered_download_for_caller(&caller, ®istry) -} - -#[tauri::command] -pub fn properties_window_send_ready( - caller: tauri::WebviewWindow, - app: tauri::AppHandle, - registry: tauri::State<'_, PropertiesWindowRegistry>, - session_id: String, -) -> Result<(), String> { - validate_properties_session_id(&session_id)?; - let download_id = registered_download_for_caller(&caller, ®istry)?; - if let Err(error) = registry.register_session(caller.label(), &session_id) { - if error == PROPERTIES_SESSION_HISTORY_EXHAUSTED { - let _ = registry.remove_window(caller.label()); - let _ = caller.close(); - } - return Err(error); - } - emit_to_main( - &app, - PROPERTIES_WINDOW_READY_EVENT, - PropertiesWindowReadyEvent { - window_label: caller.label().to_string(), - download_id, - session_id, - }, - ) -} - -#[tauri::command] -pub fn properties_window_reveal( - caller: tauri::WebviewWindow, - registry: tauri::State<'_, PropertiesWindowRegistry>, - session_id: Option, -) -> Result<(), String> { - registered_download_for_caller(&caller, ®istry)?; - if caller.label() != MAIN_WINDOW_LABEL { - let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; - validate_properties_session_id(&session_id)?; - if !registry.session_matches(caller.label(), &session_id)? { - return Err("Properties window session is no longer current".to_string()); - } - } - registry.mark_ready(caller.label())?; - caller.show().map_err(|error| error.to_string())?; - caller.set_focus().map_err(|error| error.to_string()) -} - -#[tauri::command] -pub fn properties_window_send_action( - caller: tauri::WebviewWindow, - app: tauri::AppHandle, - registry: tauri::State<'_, PropertiesWindowRegistry>, - session_id: String, - request_id: u64, - action: String, - payload: Option, -) -> Result<(), String> { - validate_properties_session_id(&session_id)?; - validate_properties_request_id(request_id)?; - if !is_properties_action(&action) - || action.len() > 64 - || action.chars().any(char::is_control) - { - return Err("Invalid Properties action".to_string()); - } - if let Some(payload) = payload.as_ref() { - let payload_size = serde_json::to_vec(payload) - .map_err(|_| "Invalid Properties action payload".to_string())? - .len(); - if payload_size > MAX_PROPERTIES_ACTION_PAYLOAD_BYTES { - return Err("Properties action payload is too large".to_string()); - } - } - let download_id = registered_download_for_caller(&caller, ®istry)?; - if !registry.session_matches(caller.label(), &session_id)? { - return Err("Properties window session is no longer current".to_string()); - } - emit_to_main( - &app, - PROPERTIES_WINDOW_ACTION_REQUEST_EVENT, - PropertiesWindowActionEvent { - window_label: caller.label().to_string(), - download_id, - session_id, - request_id, - action, - payload, - }, - ) -} - -#[tauri::command] -pub fn validate_properties_window_request( - caller: tauri::WebviewWindow, - registry: tauri::State<'_, PropertiesWindowRegistry>, - window_label: String, - download_id: String, - session_id: String, - request_id: Option, -) -> Result<(), String> { - if caller.label() != MAIN_WINDOW_LABEL { - return Err("Only the main window can validate Properties requests".to_string()); - } - validate_download_id(&download_id)?; - validate_properties_session_id(&session_id)?; - if let Some(request_id) = request_id { - validate_properties_request_id(request_id)?; - } - if !is_properties_window_label(&window_label) { - return Err("Invalid Properties window label".to_string()); - } - if registry.download_for_window(&window_label)?.as_deref() != Some(download_id.as_str()) { - return Err("Properties window request does not match its registered download".to_string()); - } - if !registry.session_matches(&window_label, &session_id)? { - return Err("Properties window session is no longer current".to_string()); - } - Ok(()) -} - -#[tauri::command] -pub fn close_download_properties_window( - caller: tauri::WebviewWindow, - app: tauri::AppHandle, - registry: tauri::State<'_, PropertiesWindowRegistry>, - id: String, -) -> Result<(), String> { - let label = caller.label(); - let registered_id = if label == MAIN_WINDOW_LABEL { - registry.window_for_download(&id)?.map(|_| id.clone()) - } else { - registry.download_for_window(label)? - }; - if registered_id.as_deref() != Some(id.as_str()) { - return Err("Properties window close request is not registered".to_string()); - } - if let Some(window_label) = registry.window_for_download(&id)? { - if let Some(window) = app.get_webview_window(&window_label) { - window.close().map_err(|error| error.to_string())?; - } else { - // A native window can disappear without delivering its Destroyed - // event. Only clear this stale registry entry when there is no - // window left to receive a close-request veto from the child. - let _ = registry.remove_download(&id); - } - } else { - let _ = registry.remove_download(&id); - } - Ok(()) -} - -#[tauri::command] -pub fn properties_window_registry_remove_for_download( - caller: tauri::WebviewWindow, - app: tauri::AppHandle, - registry: tauri::State<'_, PropertiesWindowRegistry>, - id: String, -) -> Result<(), String> { - if caller.label() != MAIN_WINDOW_LABEL { - return Err("Only the main window can remove a Properties window".to_string()); - } - if let Some(label) = registry.remove_download(&id)? { - if let Some(window) = app.get_webview_window(&label) { - // This command is used after the download has already been - // removed. It is a forced lifecycle teardown, so a dirty-draft - // close-request handler must not be able to leave an orphaned - // Properties window behind. - let _ = window.destroy(); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn labels_are_opaque_and_strictly_scoped() { - assert!(is_properties_window_label("properties-0123456789abcdef")); - assert!(!is_properties_window_label("properties-download-id")); - assert!(!is_properties_window_label("main")); - assert!(!is_properties_window_label("properties-")); - } - - #[test] - fn registry_reuses_one_label_per_download_and_cleans_both_indexes() { - let registry = PropertiesWindowRegistry::default(); - let first = registry.allocate("download-a").unwrap(); - assert!(!registry.is_ready(&first).unwrap()); - registry.mark_ready(&first).unwrap(); - assert!(registry.is_ready(&first).unwrap()); - registry.clear_ready(&first).unwrap(); - assert!(!registry.is_ready(&first).unwrap()); - registry.mark_ready(&first).unwrap(); - assert_eq!(registry.allocate("download-a").unwrap(), first); - assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string())); - assert_eq!(registry.remove_window(&first).unwrap(), Some("download-a".to_string())); - assert_eq!(registry.download_for_window(&first).unwrap(), None); - assert!(!registry.is_ready(&first).unwrap()); - assert_ne!(registry.allocate("download-a").unwrap(), first); - } - - #[test] - fn remembered_size_uses_logical_units_and_survives_window_cleanup() { - let registry = PropertiesWindowRegistry::default(); - let label = registry.allocate("download-a").unwrap(); - - registry.remember_size(&label, 1920, 1280, 2.0).unwrap(); - assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0))); - - registry.remove_window(&label).unwrap(); - assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0))); - } - - #[test] - fn remembered_size_clamps_below_minimum_and_ignores_invalid_scale() { - let registry = PropertiesWindowRegistry::default(); - - let label = registry.allocate("download-a").unwrap(); - registry.remember_size(&label, 1, 1, 1.0).unwrap(); - assert_eq!( - registry.remembered_size().unwrap(), - Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT)) - ); - - registry.remember_size(&label, 2000, 1600, 0.0).unwrap(); - assert_eq!( - registry.remembered_size().unwrap(), - Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT)) - ); - } - - #[test] - fn late_resize_from_unregistered_window_cannot_overwrite_session_size() { - let registry = PropertiesWindowRegistry::default(); - let label = registry.allocate("download-a").unwrap(); - - registry.remember_size(&label, 1920, 1280, 2.0).unwrap(); - registry.remove_window(&label).unwrap(); - registry - .remember_size(&label, 2560, 1600, 2.0) - .unwrap(); - - assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0))); - } - - #[test] - fn invalid_ids_are_rejected() { - assert!(validate_download_id("").is_err()); - assert!(validate_download_id("\n").is_err()); - assert!(validate_download_id("valid-id").is_ok()); - assert!(validate_properties_session_id("session-1").is_ok()); - assert!(validate_properties_session_id("").is_err()); - assert!(validate_properties_session_id("bad session").is_err()); - assert!(validate_properties_request_id(1).is_ok()); - assert!(validate_properties_request_id(0).is_err()); - } - - #[test] - fn a_late_ready_from_a_retired_session_cannot_reclaim_the_window() { - let registry = PropertiesWindowRegistry::default(); - let label = registry.allocate("download-a").unwrap(); - - registry.register_session(&label, "session-old").unwrap(); - assert!(registry.session_matches(&label, "session-old").unwrap()); - - registry.register_session(&label, "session-new").unwrap(); - assert!(!registry.session_matches(&label, "session-old").unwrap()); - assert!(registry.session_matches(&label, "session-new").unwrap()); - assert!(registry.register_session(&label, "session-old").is_err()); - assert!(registry.session_matches(&label, "session-new").unwrap()); - - for index in 0..(MAX_RETIRED_PROPERTIES_SESSIONS - 1) { - registry - .register_session(&label, &format!("session-{index}")) - .unwrap(); - } - assert!(registry.register_session(&label, "session-after-limit").is_err()); - - registry.remove_window(&label).unwrap(); - assert!(!registry.session_matches(&label, "session-new").unwrap()); - } - - #[test] - fn current_session_mutation_is_fenced_from_retired_sessions() { - let registry = PropertiesWindowRegistry::default(); - let label = registry.allocate("download-a").unwrap(); - registry.register_session(&label, "session-old").unwrap(); - - let mut mutations = 0; - let stale = registry.with_current_session(&label, "session-old", || { - mutations += 1; - Ok(()) - }); - assert!(stale.is_ok()); - - registry.register_session(&label, "session-new").unwrap(); - let rejected = registry.with_current_session(&label, "session-old", || { - mutations += 1; - Ok(()) - }); - assert!(rejected.is_err()); - assert_eq!(mutations, 1); - } - - #[test] - fn child_actions_are_allowlisted() { - assert!(is_properties_action("apply-properties")); - assert!(is_properties_action("verify-torrent")); - assert!(is_properties_action("set-torrent-peer-options")); - assert!(!is_properties_action("get_keychain_password")); - assert!(!is_properties_action("")); - } -} diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index eaf1110..480821c 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1,22 +1,15 @@ -use base64::Engine as _; -use crate::ipc::{ - DownloadAllocationEvent, DownloadStateEvent, DownloadStateProgress, DownloadStatus, - QueueDirection, -}; +use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection}; use crate::power::PowerManager; -use crate::retry::{ - aria2_error_code, backoff_and_emit, is_aria2_name_resolution_error, - is_transient_network_error, network_error_class, BackoffOutcome, MAX_RETRIES, -}; +use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES}; use log; use serde::Deserialize; use serde_json; use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use tauri::{AppHandle, Manager}; use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; use ts_rs::TS; @@ -24,656 +17,14 @@ use ts_rs::TS; /// Default capacity when no setting is read yet. pub const DEFAULT_MAX_CONCURRENT: usize = 3; pub const MAX_QUEUE_CONCURRENT: usize = 12; -const MAX_PENDING_DOWNLOAD_STARTS: usize = 1024; pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__"; pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1; pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16; -pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024; -pub const MAX_TORRENT_TRACKER_TIMEOUT: u32 = 604_800; -pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800; -pub const DEFAULT_TORRENT_MAX_OPEN_FILES: u32 = 100; -pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1; -pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096; -pub const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 10; -pub const MIN_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 1; -// Aria2 1.37.0 rejects values above 60 during option parsing. Keep this -// boundary aligned with the bundled engine so a saved setting cannot prevent -// the daemon from starting. -pub const MAX_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 60; -pub const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 2; -pub const MIN_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 1; -pub const MAX_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 64; -pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256; -pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20; -pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128; -pub const MAX_TORRENT_PIECES_FOR_PROGRESS: u64 = 10_000_000; -pub const MAX_TORRENT_AVAILABILITY_PEERS: usize = 4_096; -/// Poller gaps beyond this bounded interval are treated conservatively. In -/// particular, a suspended machine must not accrue wall-clock seed time. -pub const MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS: u64 = 5; -pub const MAX_TORRENT_WEB_SEEDS: usize = 256; -pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048; -pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024; -pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999"; -pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M"; -pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024; -pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576; -const MAX_ARIA2_MAGNET_FOLLOWED_GIDS: usize = 16; -const ARIA2_MAGNET_CHILD_HANDOFF_TIMEOUT: Duration = Duration::from_secs(60); - -/// A per-download zero is different from an empty/global limit: Aria2 uses -/// `0` to remove the item cap, which intentionally lets an item override the -/// daemon-wide limit. Keep that sentinel through payloads, retries, and live -/// GID updates instead of normalizing it to `None`. -fn normalize_download_speed_limit(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed == "0" { - Some("0".to_string()) - } else { - crate::normalize_speed_limit_for_aria2(trimmed) - } -} - -pub fn normalize_minimum_normal_download_speed_kib(value: u32) -> Result { - if value > MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB { - return Err(format!( - "minimum normal download speed must be between 0 and {MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB} KiB/s" - )); - } - Ok(value) -} - -pub fn normalize_sftp_host_key_md(value: Option<&str>) -> Result, String> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - let (kind, digest) = value - .split_once('=') - .ok_or_else(|| "SFTP host-key fingerprint must use TYPE=DIGEST form".to_string())?; - let kind = kind.trim().to_ascii_lowercase(); - let digest = digest.trim().to_ascii_lowercase(); - let expected_length = match kind.as_str() { - "md5" => 32, - "sha-1" => 40, - _ => return Err("SFTP host-key fingerprint type must be md5 or sha-1".to_string()), - }; - if digest.len() != expected_length || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(format!( - "SFTP {kind} host-key fingerprint must contain exactly {expected_length} hexadecimal characters" - )); - } - Ok(Some(format!("{kind}={digest}"))) -} - -pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result, String> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH - || value.chars().any(char::is_control) - { - return Err("Torrent bind address is too long or contains control characters".to_string()); - } - let address = value - .parse::() - .map_err(|_| "Torrent bind address must be a valid IPv4 or IPv6 address".to_string())?; - Ok(Some(address.to_string())) -} - -pub fn normalize_aria2_disk_cache(value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_ARIA2_DISK_CACHE); - if value == "0" { - return Ok("0".to_string()); - } - let (digits, multiplier, suffix) = match value.as_bytes().last().copied() { - Some(b'k' | b'K') => (&value[..value.len() - 1], 1_u64, "K"), - Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_u64, "M"), - _ => return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()), - }; - if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { - return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()); - } - let amount = digits - .parse::() - .map_err(|_| "Aria2 disk cache is too large".to_string())?; - let kib = amount - .checked_mul(multiplier) - .ok_or_else(|| "Aria2 disk cache is too large".to_string())?; - if kib == 0 || kib > MAX_ARIA2_DISK_CACHE_MIB * 1024 { - return Err(format!( - "Aria2 disk cache must be between 1K and {MAX_ARIA2_DISK_CACHE_MIB}M" - )); - } - Ok(format!("{amount}{suffix}")) -} - -pub fn normalize_torrent_file_allocation(value: Option<&str>) -> Result { - match value.map(str::trim).filter(|value| !value.is_empty()) { - None => Ok("prealloc".to_string()), - Some("prealloc") => Ok("prealloc".to_string()), - Some("none") => Ok("none".to_string()), - Some(_) => Err("Torrent file allocation must be prealloc or none".to_string()), - } -} pub fn clamp_download_connections(connections: i32) -> i32 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) } -pub fn normalize_torrent_max_open_files(value: u32) -> Result { - if !(MIN_TORRENT_MAX_OPEN_FILES..=MAX_TORRENT_MAX_OPEN_FILES).contains(&value) { - return Err(format!( - "torrent maximum open files must be between {MIN_TORRENT_MAX_OPEN_FILES} and {MAX_TORRENT_MAX_OPEN_FILES}" - )); - } - Ok(value) -} - -pub fn normalize_torrent_dht_message_timeout(value: u32) -> Result { - if !(MIN_TORRENT_DHT_MESSAGE_TIMEOUT..=MAX_TORRENT_DHT_MESSAGE_TIMEOUT).contains(&value) { - return Err(format!( - "DHT message timeout must be between {MIN_TORRENT_DHT_MESSAGE_TIMEOUT} and {MAX_TORRENT_DHT_MESSAGE_TIMEOUT} seconds" - )); - } - Ok(value) -} - -pub(crate) fn normalize_torrent_web_seed_uri(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() || value.len() > MAX_TORRENT_WEB_SEED_URI_LENGTH { - return Err(format!( - "Torrent web-seed URIs must be between 1 and {MAX_TORRENT_WEB_SEED_URI_LENGTH} bytes" - )); - } - if value.chars().any(char::is_control) { - return Err("Torrent web-seed URIs must not contain control characters".to_string()); - } - let parsed = url::Url::parse(value).map_err(|_| "Torrent web-seed URI is invalid".to_string())?; - if !matches!(parsed.scheme(), "http" | "https") { - return Err("Torrent web-seed URI must use HTTP or HTTPS".to_string()); - } - if parsed.host_str().is_none_or(str::is_empty) - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - { - return Err("Torrent web-seed URI must have a host and no credentials or fragment".to_string()); - } - Ok(parsed.to_string()) -} - -pub fn normalize_torrent_web_seeds( - seeds: Option<&[crate::ipc::TorrentWebSeed]>, - files: &[crate::ipc::TorrentFile], -) -> Result, String> { - let Some(seeds) = seeds else { - return Ok(Vec::new()); - }; - if seeds.len() > MAX_TORRENT_WEB_SEEDS { - return Err(format!("a Torrent may have at most {MAX_TORRENT_WEB_SEEDS} web seeds")); - } - let mut normalized = Vec::with_capacity(seeds.len()); - let mut seen = HashSet::new(); - for seed in seeds { - if !files.iter().any(|file| file.index == seed.file_index) { - return Err(format!("Torrent web-seed file index {} is out of range", seed.file_index)); - } - let uri = normalize_torrent_web_seed_uri(&seed.uri)?; - if seen.insert((seed.file_index, uri.clone())) { - normalized.push(crate::ipc::TorrentWebSeed { - file_index: seed.file_index, - uri, - }); - } - } - Ok(normalized) -} - -pub(crate) fn normalize_torrent_mirror_uris( - mirrors: Option<&str>, -) -> Result, String> { - let mut normalized = Vec::new(); - for uri in crate::collect_download_uris("", mirrors) { - let uri = normalize_torrent_web_seed_uri(&uri)?; - if !normalized.iter().any(|existing| existing == &uri) { - if normalized.len() >= MAX_TORRENT_WEB_SEEDS { - return Err(format!( - "a Torrent may have at most {MAX_TORRENT_WEB_SEEDS} fallback web seeds" - )); - } - normalized.push(uri); - } - } - Ok(normalized) -} - -fn expand_torrent_web_seed_uri( - seed: &crate::ipc::TorrentWebSeed, - files: &[crate::ipc::TorrentFile], -) -> Result { - let uri = normalize_torrent_web_seed_uri(&seed.uri)?; - if files.len() <= 1 { - return Ok(uri); - } - let file = files - .iter() - .find(|file| file.index == seed.file_index) - .ok_or_else(|| "Torrent web-seed file index is out of range".to_string())?; - let mut parsed = url::Url::parse(&uri).map_err(|_| "Torrent web-seed URI is invalid".to_string())?; - let base_path = parsed.path().trim_end_matches('/').to_string(); - parsed.set_path(&base_path); - { - let mut path = parsed - .path_segments_mut() - .map_err(|_| "Torrent web-seed URI cannot accept a file path".to_string())?; - for segment in file.path.replace('\\', "/").split('/') { - if !segment.is_empty() && segment != "." && segment != ".." { - path.push(segment); - } - } - } - Ok(parsed.to_string()) -} - -pub fn expand_torrent_web_seeds( - seeds: &[crate::ipc::TorrentWebSeed], - files: &[crate::ipc::TorrentFile], -) -> Result, String> { - let normalized = normalize_torrent_web_seeds(Some(seeds), files)?; - normalized - .iter() - .map(|seed| Ok((seed.file_index, expand_torrent_web_seed_uri(seed, files)?))) - .collect() -} - -type TorrentFileUriSets = HashMap>; -type TorrentWebSeedPair = (u32, String); - -fn normalize_aria2_torrent_file_uris( - entries: Vec<(u32, Vec)>, - files: &[crate::ipc::TorrentFile], -) -> Result { - let expected_indices = files.iter().map(|file| file.index).collect::>(); - if expected_indices.len() != files.len() || entries.len() != files.len() { - return Err("aria2.getFiles returned an unexpected Torrent file set".to_string()); - } - - let mut normalized = HashMap::with_capacity(entries.len()); - for (file_index, uris) in entries { - if file_index == 0 || !expected_indices.contains(&file_index) { - return Err("aria2.getFiles returned an unknown Torrent file index".to_string()); - } - if normalized.contains_key(&file_index) { - return Err("aria2.getFiles returned a duplicate Torrent file index".to_string()); - } - if uris.len() > MAX_TORRENT_WEB_SEEDS.saturating_mul(3) { - return Err( - "aria2.getFiles returned too many Torrent web seeds for one file".to_string(), - ); - } - let mut file_uris = HashSet::with_capacity(uris.len()); - for uri in uris { - file_uris.insert(normalize_torrent_web_seed_uri(&uri)?); - } - normalized.insert(file_index, file_uris); - } - - if files - .iter() - .any(|file| !normalized.contains_key(&file.index)) - { - return Err("aria2.getFiles omitted a Torrent file".to_string()); - } - Ok(normalized) -} - -fn parse_aria2_torrent_file_uris( - value: &serde_json::Value, -) -> Result)>, String> { - let entries = value - .as_array() - .ok_or_else(|| "aria2.getFiles returned a non-array result".to_string())?; - let mut file_uris = Vec::with_capacity(entries.len()); - for entry in entries { - let object = entry - .as_object() - .ok_or_else(|| "aria2.getFiles returned malformed Torrent file data".to_string())?; - let file_index = parse_aria2_decimal(object.get("index"), "file index")? - .try_into() - .map_err(|_| "aria2.getFiles returned an invalid Torrent file index".to_string())?; - let uris = object - .get("uris") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "aria2.getFiles returned malformed Torrent URI data".to_string())?; - let mut parsed_uris = Vec::with_capacity(uris.len()); - for entry in uris { - let uri = entry - .get("uri") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - "aria2.getFiles returned a malformed Torrent URI entry".to_string() - })?; - parsed_uris.push(uri.to_string()); - } - file_uris.push((file_index, parsed_uris)); - } - Ok(file_uris) -} - -fn expected_initial_torrent_web_seed_state( - current: &TorrentFileUriSets, - explicit: &[TorrentWebSeedPair], -) -> TorrentFileUriSets { - let mut expected = current.clone(); - for (file_index, uri) in explicit { - expected.entry(*file_index).or_default().insert(uri.clone()); - } - expected -} - -fn expand_aria2_torrent_web_seed_source( - source: &str, - metadata: &crate::torrent::ParsedTorrent, - file: &crate::ipc::TorrentFile, -) -> Result { - let uri = normalize_torrent_web_seed_uri(source)?; - let mut parsed = - url::Url::parse(&uri).map_err(|_| "Torrent web-seed URI is invalid".to_string())?; - if metadata.files.len() == 1 { - if parsed.path().ends_with('/') { - let mut segments = parsed - .path_segments_mut() - .map_err(|_| "Torrent web-seed URI cannot accept a file path".to_string())?; - segments.push(&metadata.name); - } - return Ok(parsed.to_string()); - } - - { - let mut segments = parsed - .path_segments_mut() - .map_err(|_| "Torrent web-seed URI cannot accept a file path".to_string())?; - segments.push(&metadata.name); - for segment in file.path.split('/') { - if !segment.is_empty() && segment != "." && segment != ".." { - segments.push(segment); - } - } - } - Ok(parsed.to_string()) -} - -fn expand_torrent_web_seed_sources( - sources: &[String], - metadata: &crate::torrent::ParsedTorrent, -) -> Result, String> { - let mut expanded = HashSet::new(); - for source in sources { - for file in &metadata.files { - expanded.insert(( - file.index, - expand_aria2_torrent_web_seed_source(source, metadata, file)?, - )); - } - } - Ok(expanded) -} - -fn plan_torrent_web_seed_change( - current: &TorrentFileUriSets, - baseline: &HashSet, - old: &[TorrentWebSeedPair], - new: &[TorrentWebSeedPair], -) -> Result<(TorrentFileUriSets, Vec<(u32, Vec, Vec)>), String> { - if old.iter().any(|(file_index, uri)| { - !current - .get(file_index) - .is_some_and(|uris| uris.contains(uri)) - }) { - return Err("Aria2 web-seed state differs from Firelink's persisted state".to_string()); - } - - let mut allowed_current = baseline.clone(); - allowed_current.extend(old.iter().cloned()); - if current.iter().any(|(file_index, uris)| { - uris.iter() - .any(|uri| !allowed_current.contains(&(*file_index, uri.clone()))) - }) { - return Err("Aria2 web-seed state differs from Firelink's persisted state".to_string()); - } - - let mut expected = current.clone(); - for (file_index, uri) in old { - if !baseline.contains(&(*file_index, uri.clone())) { - let Some(file_uris) = expected.get_mut(file_index) else { - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - file_uris.remove(uri); - } - } - for (file_index, uri) in new { - let Some(file_uris) = expected.get_mut(file_index) else { - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - file_uris.insert(uri.clone()); - } - - let mut file_indices = current.keys().copied().collect::>(); - file_indices.extend(expected.keys().copied()); - let mut file_indices = file_indices.into_iter().collect::>(); - file_indices.sort_unstable(); - let mut changes = Vec::new(); - for file_index in file_indices { - let Some(current_uris) = current.get(&file_index) else { - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - let Some(expected_uris) = expected.get(&file_index) else { - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - let mut delete = current_uris - .difference(expected_uris) - .cloned() - .collect::>(); - let mut add = expected_uris - .difference(current_uris) - .cloned() - .collect::>(); - delete.sort(); - add.sort(); - if !delete.is_empty() || !add.is_empty() { - changes.push((file_index, delete, add)); - } - } - Ok((expected, changes)) -} - -pub fn normalize_torrent_max_concurrent_seeds(value: u32) -> Result { - if !(MIN_TORRENT_MAX_CONCURRENT_SEEDS..=MAX_TORRENT_MAX_CONCURRENT_SEEDS).contains(&value) { - return Err(format!( - "maximum concurrent Torrent seeds must be between {MIN_TORRENT_MAX_CONCURRENT_SEEDS} and {MAX_TORRENT_MAX_CONCURRENT_SEEDS}" - )); - } - Ok(value) -} - -fn normalize_optional_torrent_network_value( - value: Option<&str>, - field: &str, -) -> Result, String> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH - || value.chars().any(|character| character.is_control()) - { - return Err(format!("{field} is too long or contains control characters")); - } - Ok(Some(value.to_string())) -} - -fn normalize_torrent_port(value: &str, field: &str) -> Result { - let port = value - .trim() - .parse::() - .map_err(|_| format!("{field} contains an invalid port"))?; - if !(1..=u16::MAX as u32).contains(&port) { - return Err(format!("{field} ports must be between 1 and 65535")); - } - Ok(port as u16) -} - -fn normalize_torrent_listen_port(value: &str, field: &str) -> Result { - let port = normalize_torrent_port(value, field)?; - if port < MIN_TORRENT_LISTEN_PORT { - return Err(format!( - "{field} ports must be between {MIN_TORRENT_LISTEN_PORT} and 65535" - )); - } - Ok(port) -} - -pub(crate) fn normalize_torrent_port_spec( - value: Option<&str>, - field: &str, -) -> Result, String> { - let Some(value) = normalize_optional_torrent_network_value(value, field)? else { - return Ok(None); - }; - let mut normalized = Vec::new(); - for entry in value.split(',') { - let entry = entry.trim(); - if entry.is_empty() { - return Err(format!("{field} contains an empty port entry")); - } - if let Some((start, end)) = entry.split_once('-') { - let start = normalize_torrent_listen_port(start, field)?; - let end = normalize_torrent_listen_port(end, field)?; - if start > end { - return Err(format!("{field} contains a reversed port range")); - } - normalized.push(format!("{start}-{end}")); - } else { - normalized.push(normalize_torrent_listen_port(entry, field)?.to_string()); - } - } - Ok(Some(normalized.join(","))) -} - -pub(crate) fn torrent_port_spec_contains(spec: &str, port: u16) -> bool { - spec.split(',').any(|entry| { - let entry = entry.trim(); - if let Some((start, end)) = entry.split_once('-') { - matches!((start.parse::(), end.parse::()), (Ok(start), Ok(end)) if start <= port && port <= end) - } else { - entry.parse::() == Ok(port) - } - }) -} - -pub(crate) fn normalize_torrent_external_ip( - value: Option<&str>, -) -> Result, String> { - let Some(value) = normalize_optional_torrent_network_value(value, "Torrent external IP")? - else { - return Ok(None); - }; - let address = value - .parse::() - .map_err(|_| "Torrent external IP must be a valid IPv4 or IPv6 address".to_string())?; - Ok(Some(address.to_string())) -} - -pub(crate) fn normalize_torrent_dht_entry_point( - value: Option<&str>, - ipv6: bool, -) -> Result, String> { - let field = if ipv6 { - "IPv6 DHT entry point" - } else { - "IPv4 DHT entry point" - }; - let Some(value) = normalize_optional_torrent_network_value(value, field)? else { - return Ok(None); - }; - let (host, port) = if let Some(rest) = value.strip_prefix('[') { - let (host, port) = rest - .split_once(']') - .and_then(|(host, suffix)| suffix.strip_prefix(':').map(|port| (host, port))) - .ok_or_else(|| format!("{field} must use host:port syntax"))?; - if host.parse::().is_err() { - return Err(format!("{field} has an invalid IPv6 host")); - } - (format!("[{host}]"), normalize_torrent_port(port, field)?) - } else { - let (host, port) = value - .rsplit_once(':') - .ok_or_else(|| format!("{field} must use host:port syntax"))?; - if host.is_empty() || host.contains(':') || host.contains(['/', '\\', '@']) { - return Err(format!("{field} has an invalid host")); - } - if url::Host::parse(host).is_err() { - return Err(format!("{field} has an invalid host")); - } - (host.to_ascii_lowercase(), normalize_torrent_port(port, field)?) - }; - if ipv6 && !host.starts_with('[') { - return Err(format!("{field} must use an IPv6 host")); - } - if !ipv6 && host.starts_with('[') { - return Err(format!("{field} must use an IPv4 or hostname host")); - } - Ok(Some(format!("{host}:{port}"))) -} - -pub(crate) fn normalize_torrent_dht_listen_addr6( - value: Option<&str>, -) -> Result, String> { - let Some(value) = normalize_optional_torrent_network_value(value, "IPv6 DHT listen address")? - else { - return Ok(None); - }; - let address = value - .parse::() - .map_err(|_| "IPv6 DHT listen address must be a valid IPv6 address".to_string())?; - Ok(Some(address.to_string())) -} - -pub(crate) fn normalize_torrent_lpd_interface( - value: Option<&str>, -) -> Result, String> { - normalize_optional_torrent_network_value(value, "Torrent LPD interface") -} - -pub(crate) fn normalize_torrent_peer_id_prefix( - value: Option<&str>, -) -> Result, String> { - let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer ID prefix")? - else { - return Ok(None); - }; - if !value.is_ascii() || value.len() > MAX_TORRENT_PEER_ID_PREFIX_BYTES { - return Err(format!( - "Torrent peer ID prefix must be printable ASCII and at most {MAX_TORRENT_PEER_ID_PREFIX_BYTES} bytes" - )); - } - Ok(Some(value)) -} - -pub(crate) fn normalize_torrent_peer_agent( - value: Option<&str>, -) -> Result, String> { - let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer agent")? - else { - return Ok(None); - }; - if value.len() > MAX_TORRENT_PEER_AGENT_LENGTH { - return Err(format!( - "Torrent peer agent must be at most {MAX_TORRENT_PEER_AGENT_LENGTH} bytes" - )); - } - Ok(Some(value)) -} - fn reorder_selected_queue_tasks( queue_tasks: &[QueuedTask], ids: &[String], @@ -740,134 +91,6 @@ pub struct Aria2GidMapping { pub epoch: u64, } -#[derive(Debug, Clone)] -struct Aria2MagnetHandoffState { - parent_gid: String, - epoch: u64, - started_at: Instant, -} - -#[derive(Debug, Clone)] -struct Aria2MagnetChildHandoff { - gid: String, - status: String, - error: Option, - pending: Option, -} - -#[derive(Debug, Clone)] -enum Aria2MagnetCompletionDisposition { - /// This GID is a normal Torrent GID, or a magnet whose final GID already - /// owns real files. The normal terminal path remains authoritative. - NotMetadataParent, - /// Aria2 has completed metadata acquisition but has not exposed the - /// payload child yet. Keep the permit and mapping alive for reconciliation. - Deferred, - /// The payload child was validated and atomically adopted by Firelink. - Adopted(Aria2MagnetChildHandoff), - /// Aria2 exposed a metadata parent but never exposed a valid payload child - /// before the bounded handoff deadline. Fail the download instead of - /// claiming that metadata bytes are the requested payload. - Failed(String), -} - -#[derive(Clone, Copy, Debug)] -struct Aria2ConnectionOptions { - epoch: u64, - effective: i32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TorrentTelemetrySnapshot { - pub uploaded_bytes: u64, - pub seeded_seconds: u64, -} - -#[derive(Debug, Clone)] -struct TorrentTelemetryState { - gid: String, - epoch: u64, - last_upload_length: Option, - uploaded_bytes: u64, - seeded_seconds: u64, - last_observed_at: Option, - last_was_seeding: bool, -} - -impl TorrentTelemetryState { - fn new(gid: &str, epoch: u64, now: Instant) -> Self { - Self { - gid: gid.to_string(), - epoch, - last_upload_length: None, - uploaded_bytes: 0, - seeded_seconds: 0, - last_observed_at: Some(now), - last_was_seeding: false, - } - } - - fn snapshot(&self) -> TorrentTelemetrySnapshot { - TorrentTelemetrySnapshot { - uploaded_bytes: self.uploaded_bytes, - seeded_seconds: self.seeded_seconds, - } - } - - fn observe( - &mut self, - gid: &str, - epoch: u64, - upload_length: Option, - is_seeding: bool, - now: Instant, - ) -> TorrentTelemetrySnapshot { - if self.gid != gid || self.epoch != epoch { - // A new GID or control epoch is a new daemon counter lifecycle. - // Preserve Firelink totals, but never interpret the new counter - // as a continuation of the old one. - if self.last_was_seeding { - if let Some(previous) = self.last_observed_at { - let seconds = now - .saturating_duration_since(previous) - .min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS)) - .as_secs(); - self.seeded_seconds = self.seeded_seconds.saturating_add(seconds); - } - } - self.gid = gid.to_string(); - self.epoch = epoch; - self.last_upload_length = None; - self.last_observed_at = Some(now); - self.last_was_seeding = false; - } else if self.last_was_seeding { - if let Some(previous) = self.last_observed_at { - let seconds = now - .saturating_duration_since(previous) - .min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS)) - .as_secs(); - self.seeded_seconds = self.seeded_seconds.saturating_add(seconds); - } - } - - if let Some(current) = upload_length { - if let Some(previous) = self.last_upload_length { - if current >= previous { - self.uploaded_bytes = self - .uploaded_bytes - .saturating_add(current.saturating_sub(previous)); - } - // A decreased Aria2 counter is a daemon/lifecycle reset. The - // current value becomes the new baseline, without adding it. - } - self.last_upload_length = Some(current); - } - self.last_observed_at = Some(now); - self.last_was_seeding = is_seeding; - self.snapshot() - } -} - /// Owns one per-download control lock and removes its idle map entry when the /// last operation for that download finishes. pub struct Aria2ControlGuard { @@ -898,122 +121,9 @@ impl Drop for Aria2ControlGuard { #[derive(Debug, Clone)] pub enum PendingOutcome { Complete, - /// BitTorrent payload is complete, but Aria2 is still seeding. Keep the - /// GID, ownership record, and queue permit until the final complete - /// notification arrives. - Seeding, Error(String), } -#[derive(Debug, Clone)] -pub struct PendingAria2Outcome { - pub outcome: PendingOutcome, - pub progress: Option, -} - -impl PendingAria2Outcome { - pub fn new(outcome: PendingOutcome) -> Self { - Self { - outcome, - progress: None, - } - } -} - -fn aria2_magnet_parent_has_metadata_file(status: &serde_json::Value) -> bool { - status - .get("files") - .and_then(serde_json::Value::as_array) - .is_some_and(|files| { - files.iter().any(|file| { - file.get("path") - .and_then(serde_json::Value::as_str) - .is_some_and(|path| path.starts_with("[METADATA]")) - }) - }) -} - -fn aria2_magnet_parent_has_payload_file(status: &serde_json::Value) -> bool { - status - .get("files") - .and_then(serde_json::Value::as_array) - .is_some_and(|files| { - files.iter().any(|file| { - file.get("path") - .and_then(serde_json::Value::as_str) - .is_some_and(|path| !path.is_empty() && !path.starts_with("[METADATA]")) - }) - }) -} - -fn aria2_magnet_followed_gids( - status: &serde_json::Value, - parent_gid: &str, -) -> Vec { - status - .get("followedBy") - .and_then(serde_json::Value::as_array) - .into_iter() - .flat_map(|gids| gids.iter()) - .filter_map(serde_json::Value::as_str) - .filter(|gid| { - !gid.is_empty() - && *gid != parent_gid - && gid.len() <= 64 - && gid.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) - .take(MAX_ARIA2_MAGNET_FOLLOWED_GIDS) - .map(str::to_string) - .collect() -} - -fn aria2_magnet_status_error(status: &serde_json::Value) -> String { - let error_code = status - .get("errorCode") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()); - let error_message = status - .get("errorMessage") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .unwrap_or("aria2 magnet payload child ended unexpectedly"); - let message = match error_code { - Some(code) => format!("aria2 error code {code}: {error_message}"), - None => error_message.to_string(), - }; - crate::redact_sensitive_text(&message) -} - -fn is_direct_magnet_payload(payload: &SpawnPayload) -> bool { - payload.is_torrent - && !payload.torrent_verify_only - && payload.torrent_path.is_none() - && payload - .url - .trim_start() - .get(..7) - .is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:")) -} - -fn torrent_uses_direct_network(payload: &SpawnPayload) -> bool { - payload.is_torrent - && !payload.torrent_verify_only - && payload.proxy.as_deref().is_none_or(|proxy| { - let proxy = proxy.trim(); - proxy.is_empty() || proxy.eq_ignore_ascii_case("none") - }) -} - -fn aria2_effective_resolver_mode(payload: &SpawnPayload) -> &'static str { - if payload.aria2_resolver_mode == Aria2ResolverMode::System - || torrent_uses_direct_network(payload) - { - "system" - } else { - "automatic" - } -} - /// Result of recycling an aria2 transfer's connections. A refresh can race /// with daemon completion or leave the transfer paused after an ambiguous /// unpause failure, so callers must handle the verified daemon outcome. @@ -1024,13 +134,6 @@ pub enum Aria2RefreshOutcome { Complete, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Aria2SeedControlOutcome { - Resumed, - Paused, - Complete, -} - /// Result of rebuilding an aria2 job while retaining its partial file and /// queue permit. `Refresh` is the compatibility path for test/alternate /// spawners that do not own aria2's addUri options. @@ -1077,52 +180,8 @@ struct QueuePermitOwnership { active: bool, } -#[derive(Debug, Clone)] -struct SeedWaiter { - id: String, - queue_id: String, - lifecycle_generation: u64, -} - -#[derive(Debug, Default)] -struct SeedCapacityState { - enabled: bool, - max_concurrent: usize, - owners: HashSet, - waiting: VecDeque, - starting: HashSet, -} - -#[derive(Debug, Clone, Copy)] -struct SeedBudget { - remaining_minutes: Option, - started_at: std::time::Instant, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SeedAdmissionOutcome { - Seeding, - Waiting, - Complete, -} - /// Args mirroring start_download / start_media_download. Kept untyped-loose /// (String/Option) to match the existing command signatures exactly. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Aria2ResolverMode { - /// Use Aria2's normal resolver configuration. This is the initial mode - /// and deliberately leaves daemon-wide behavior unchanged. - Automatic, - /// Use the host operating system resolver for this transfer. - System, -} - -impl Default for Aria2ResolverMode { - fn default() -> Self { - Self::Automatic - } -} - #[derive(Debug, Clone, Default)] pub struct SpawnPayload { pub url: String, @@ -1132,47 +191,16 @@ pub struct SpawnPayload { pub speed_limit: Option, pub username: Option, pub password: Option, - pub sftp_host_key_md: Option, pub headers: Option, pub checksum: Option, pub cookies: Option, pub mirrors: Option, pub user_agent: Option, pub max_tries: Option, - pub minimum_normal_download_speed_kib: u32, - pub retry_not_found_errors: bool, - pub adaptive_mirror_selection: bool, pub proxy: Option, - /// Runtime-only resolver selection. This is never part of an enqueue or - /// persisted download payload. - pub aria2_resolver_mode: Aria2ResolverMode, pub format_selector: Option, pub cookie_source: Option, pub is_media: bool, - pub is_torrent: bool, - pub torrent_path: Option, - pub torrent_file_indices: Option>, - pub torrent_seed_time: Option, - pub torrent_seed_ratio: Option, - pub torrent_seed_remaining: Option, - pub torrent_web_seeds: Option>, - pub torrent_upload_limit: Option, - pub torrent_max_peers: Option, - pub torrent_peer_speed_limit: Option, - pub torrent_check_integrity: bool, - pub torrent_trackers: Option, - pub torrent_exclude_trackers: Option, - pub torrent_tracker_connect_timeout: Option, - pub torrent_tracker_timeout: Option, - pub torrent_tracker_interval: Option, - pub torrent_stop_timeout: Option, - pub torrent_prioritize_piece: Option, - pub torrent_remove_unselected_file: bool, - pub torrent_encryption_policy: Option, - pub torrent_file_allocation: Option, - pub torrent_verify_only: bool, - pub torrent_verify_restore_status: Option, - pub torrent_verified_length: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -1219,52 +247,6 @@ pub trait SidecarSpawner: Send + Sync + 'static { Err("live aria2 speed limits are unavailable".to_string()) } - /// Change one active BitTorrent transfer's runtime upload cap without - /// replacing its GID or queue permit. - async fn set_torrent_upload_limit( - &self, - _gid: &str, - _limit: Option<&str>, - ) -> Result<(), String> { - Err("live torrent upload limits are unavailable".to_string()) - } - - /// Change the peer cap and low-speed peer expansion threshold without - /// replacing the Torrent GID or queue permit. - async fn set_torrent_peer_options( - &self, - _gid: &str, - _max_peers: u32, - _peer_speed_limit: &str, - ) -> Result<(), String> { - Err("live torrent peer options are unavailable".to_string()) - } - - /// Pause a completed Torrent before releasing its download permit to the - /// Firelink-owned seed-slot pool. - async fn pause_for_seed(&self, _gid: &str) -> Result { - Err("live Torrent seed-slot pausing is unavailable".to_string()) - } - - /// Resume a Torrent after a Firelink seed slot has been reserved. - async fn resume_for_seed(&self, _gid: &str) -> Result { - Err("live Torrent seed-slot resuming is unavailable".to_string()) - } - - async fn get_torrent_file_uris(&self, _gid: &str) -> Result)>, String> { - Err("live Torrent web-seed inspection is unavailable".to_string()) - } - - async fn change_torrent_uris( - &self, - _gid: &str, - _file_index: u32, - _delete: &[String], - _add: &[String], - ) -> Result<(), String> { - Err("live Torrent web-seed changes are unavailable".to_string()) - } - /// Run a media download to completion. The permit is parked for the full /// duration; release is handled by QueueManager on the runner's exit. async fn run_media( @@ -1296,63 +278,28 @@ pub struct QueueManager { /// Serializes queue-slot selection with global permit acquisition and /// ownership transitions. admission_gate: Mutex<()>, - /// Prevents new enqueue/admission work after a system action has passed - /// its final safety check. The flag is set while holding admission_gate so - /// the check and the fence are one state transition. - system_action_pending: AtomicBool, /// Last queue selected by the dispatcher. Selection starts after this /// queue when multiple queues have eligible work. dispatch_cursor: Mutex>, target_capacity: AtomicUsize, slots_to_retire: AtomicUsize, notify: Notify, - /// Firelink-owned seed capacity. Seeders keep the Aria2 GID and mapping - /// alive, but release the download semaphore while they own a seed slot. - seed_capacity: StdMutex, - seed_budgets: StdMutex>, - /// Firelink lifetime Torrent upload/seed accounting. Raw Aria2 counters - /// are scoped to the current GID and control epoch and never leave this - /// process as durable state. - torrent_telemetry: Mutex>, - torrent_moves: StdMutex>, - torrent_move_cancellations: StdMutex>, /// aria2 gid -> download id map (shared with the WS poller). pub aria2_gids: Arc>>, /// gid -> buffered (id_placeholder, outcome) for completions that arrived /// before the gid was stored. Drained by `remember_gid`. - pub pending_completion: Arc>>, - /// Aria2 can emit onDownloadStart before addUri's response has been - /// mapped to the Firelink download. Buffer that start marker until the - /// current GID mapping is installed. - pending_download_starts: Arc>>, - /// 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>, + pub pending_completion: Arc>>, /// download id -> spawn payload for aria2 transient-error re-addUri retries. aria2_payloads: Mutex>, - /// Attempt-scoped Aria2 connection options. The persisted payload keeps - /// the user's requested count; this map records the effective count after - /// range probing for the currently admitted attempt. - aria2_connection_options: Mutex>, - /// Initial aria2 addUri handoffs that have not yet either published a GID - /// or removed a stale late GID. Removal waits for these handoffs before - /// deleting owned assets so a magnet cannot leave an orphaned output. - aria2_dispatch_inflight: Mutex>>, - aria2_dispatch_notify: Notify, /// The daemon-wide download cap currently applied to aria2. This mirrors /// successful RPC changes so the poller can avoid treating an intentional /// cap as a degraded connection pool. aria2_global_speed_limit: Arc>>, - /// Capability reported by aria2.getVersion. Keep the fallback disabled - /// until the daemon explicitly advertises Async DNS; an unknown - /// capability must not be treated as permission to change resolver mode. - aria2_async_dns_supported: AtomicBool, - /// 0-based transient-error strike counter per aria2 download id. aria2_retry_strikes: Mutex>, @@ -1366,14 +313,6 @@ pub struct QueueManager { /// Gids whose terminal events must be ignored after a lifecycle transition. /// This is bounded so a long-lived daemon cannot grow the set indefinitely. aria2_ignored_gids: Mutex>, - /// Direct magnets have a short Aria2 metadata-parent handoff before the - /// payload child becomes visible. Keep that observation bounded and - /// lifecycle-scoped so a missing child cannot leak a permit forever. - aria2_magnet_handoffs: Mutex>, - /// Once a direct magnet's payload child is adopted, remember that child - /// identity so terminal reconciliation does not request the full Aria2 - /// file list again for a potentially very large Torrent. - aria2_magnet_payload_gids: Mutex>, /// Wakes retry backoff workers when a pause/remove action cancels them. aria2_retry_cancel_notify: Notify, @@ -1426,37 +365,19 @@ impl QueueManager { queue_limits: Mutex::new(HashMap::new()), queue_permit_ownership: Mutex::new(HashMap::new()), admission_gate: Mutex::new(()), - system_action_pending: AtomicBool::new(false), dispatch_cursor: Mutex::new(None), target_capacity: AtomicUsize::new(capacity), slots_to_retire: AtomicUsize::new(0), notify: Notify::new(), - seed_capacity: StdMutex::new(SeedCapacityState { - enabled: false, - max_concurrent: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS as usize, - ..SeedCapacityState::default() - }), - seed_budgets: StdMutex::new(HashMap::new()), - torrent_telemetry: Mutex::new(HashMap::new()), - torrent_moves: StdMutex::new(HashSet::new()), - torrent_move_cancellations: StdMutex::new(HashSet::new()), aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), - pending_download_starts: Arc::new(Mutex::new(HashSet::new())), - aria2_allocation_pending: Mutex::new(HashMap::new()), aria2_payloads: Mutex::new(HashMap::new()), - aria2_connection_options: Mutex::new(HashMap::new()), - aria2_dispatch_inflight: Mutex::new(HashMap::new()), - aria2_dispatch_notify: Notify::new(), aria2_global_speed_limit: Arc::new(StdMutex::new(None)), - aria2_async_dns_supported: AtomicBool::new(false), aria2_retry_strikes: Mutex::new(HashMap::new()), aria2_retry_cancelled: Mutex::new(HashSet::new()), aria2_retry_inflight: Mutex::new(HashMap::new()), aria2_retrying_gids: Mutex::new(HashSet::new()), aria2_ignored_gids: Mutex::new(VecDeque::new()), - aria2_magnet_handoffs: Mutex::new(HashMap::new()), - aria2_magnet_payload_gids: Mutex::new(HashMap::new()), aria2_retry_cancel_notify: Notify::new(), aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())), aria2_gid_state: Mutex::new(()), @@ -1471,129 +392,6 @@ impl QueueManager { Arc::clone(&self.power_manager) } - pub fn set_aria2_async_dns_supported(&self, supported: bool) { - self.aria2_async_dns_supported - .store(supported, Ordering::Relaxed); - } - - fn aria2_system_resolver_fallback_available(&self) -> bool { - self.aria2_async_dns_supported.load(Ordering::Relaxed) - } - - /// Accept one lifecycle-fenced Aria2 status sample and return Firelink's - /// monotonic lifetime counters. Poller callers must already have checked - /// the mapping; the key and epoch checks here provide a second fence at - /// the accounting owner itself. - pub async fn observe_torrent_telemetry( - &self, - id: &str, - gid: &str, - epoch: u64, - upload_length: Option, - is_seeding: bool, - now: Instant, - ) -> TorrentTelemetrySnapshot { - let mut telemetry = self.torrent_telemetry.lock().await; - let state = telemetry - .entry(id.to_string()) - .or_insert_with(|| TorrentTelemetryState::new(gid, epoch, now)); - state.observe(gid, epoch, upload_length, is_seeding, now) - } - - /// Restore the durable Firelink totals before the first raw Aria2 sample - /// for a download. Raw upload counters are intentionally not restored; - /// the next observation establishes a fresh GID/epoch baseline. - pub async fn hydrate_torrent_telemetry( - &self, - id: &str, - uploaded_bytes: u64, - seeded_seconds: u64, - ) { - let mut telemetry = self.torrent_telemetry.lock().await; - let state = telemetry - .entry(id.to_string()) - .or_insert_with(|| TorrentTelemetryState::new("", 0, Instant::now())); - state.uploaded_bytes = state.uploaded_bytes.max(uploaded_bytes); - state.seeded_seconds = state.seeded_seconds.max(seeded_seconds); - } - - /// Clear the one-shot integrity override only for the still-current - /// lifecycle. A normal user integrity preference is never changed here. - pub async fn clear_torrent_relocation_check(&self, id: &str, epoch: u64) -> bool { - if !self.is_aria2_control_epoch_current(id, epoch).await { - return false; - } - let mut payloads = self.aria2_payloads.lock().await; - let Some(payload) = payloads.get_mut(id) else { - return false; - }; - if !payload.is_torrent { - return false; - } - payload.torrent_check_integrity = false; - true - } - - pub async fn begin_torrent_move(&self, id: &str) -> Result<(), String> { - let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - return Err("System action is already being performed".to_string()); - } - self.torrent_moves - .lock() - .expect("Torrent move lock poisoned") - .insert(id.to_string()); - self.torrent_move_cancellations - .lock() - .expect("Torrent move cancellation lock poisoned") - .remove(id); - Ok(()) - } - - pub fn cancel_torrent_move(&self, id: &str) { - self.torrent_move_cancellations - .lock() - .expect("Torrent move cancellation lock poisoned") - .insert(id.to_string()); - } - - pub fn torrent_move_cancelled(&self, id: &str) -> bool { - self.torrent_move_cancellations - .lock() - .expect("Torrent move cancellation lock poisoned") - .contains(id) - } - - pub fn finish_torrent_move(&self, id: &str) { - self.torrent_moves - .lock() - .expect("Torrent move lock poisoned") - .remove(id); - self.torrent_move_cancellations - .lock() - .expect("Torrent move cancellation lock poisoned") - .remove(id); - } - - pub fn has_torrent_moves(&self) -> bool { - !self - .torrent_moves - .lock() - .expect("Torrent move lock poisoned") - .is_empty() - } - - /// Drop counters after terminal cleanup/removal. Persisted lifetime - /// totals remain owned by the DownloadItem row; this only removes raw - /// process-local lifecycle state. - pub async fn forget_torrent_telemetry(&self, id: &str) { - self.torrent_telemetry.lock().await.remove(id); - } - - pub fn app_handle(&self) -> AppHandle { - self.app_handle.clone() - } - pub fn activate_power_management(&self) -> Result<(), String> { self.power_manager.activate() } @@ -1611,474 +409,6 @@ impl QueueManager { self.power_manager.set_system_prevention(enabled) } - /// Apply the persisted seed policy to the live queue manager. Existing - /// seeders are not paused when the setting changes; the new limit applies - /// to the next seed admission and to waiting seeders. - pub fn configure_seed_capacity(&self, enabled: bool, max_concurrent: u32) { - let max_concurrent = normalize_torrent_max_concurrent_seeds(max_concurrent) - .unwrap_or(DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS) as usize; - if let Ok(mut state) = self.seed_capacity.lock() { - state.enabled = enabled; - state.max_concurrent = max_concurrent; - if enabled { - if let Ok(budgets) = self.seed_budgets.lock() { - state.owners.extend(budgets.keys().cloned()); - } - } - } - self.notify.notify_waiters(); - } - - fn seed_capacity_enabled(&self) -> bool { - self.seed_capacity - .lock() - .map(|state| state.enabled) - .unwrap_or(false) - } - - fn seed_owner(&self, id: &str) -> bool { - self.seed_capacity - .lock() - .map(|state| state.owners.contains(id)) - .unwrap_or(false) - } - - pub fn is_seed_owner(&self, id: &str) -> bool { - self.seed_owner(id) - } - - fn seed_waiting(&self, id: &str) -> bool { - self.seed_capacity.lock().map_or(false, |state| { - state.waiting.iter().any(|waiter| waiter.id == id) - || state.starting.contains(id) - }) - } - - fn seed_starting(&self, id: &str) -> bool { - self.seed_capacity - .lock() - .map_or(false, |state| state.starting.contains(id)) - } - - fn add_seed_waiter(&self, waiter: SeedWaiter) { - if let Ok(mut state) = self.seed_capacity.lock() { - if !state.owners.contains(&waiter.id) - && !state.starting.contains(&waiter.id) - && !state.waiting.iter().any(|candidate| candidate.id == waiter.id) - { - state.waiting.push_back(waiter); - } - } - self.notify.notify_waiters(); - } - - fn reserve_seed_slot(&self, id: &str) -> bool { - let Ok(mut state) = self.seed_capacity.lock() else { - return false; - }; - if state.owners.contains(id) { - return true; - } - if !state.enabled || state.owners.len() >= state.max_concurrent { - return false; - } - state.owners.insert(id.to_string()); - true - } - - fn reserve_seed_waiter(&self) -> Option { - let Ok(mut state) = self.seed_capacity.lock() else { - return None; - }; - if state.enabled && state.owners.len() >= state.max_concurrent { - return None; - } - let waiter = state.waiting.pop_front()?; - state.owners.insert(waiter.id.clone()); - state.starting.insert(waiter.id.clone()); - Some(waiter) - } - - fn finish_seed_start(&self, id: &str) { - if let Ok(mut state) = self.seed_capacity.lock() { - state.starting.remove(id); - } - } - - fn abandon_seed_start(&self, id: &str) { - if let Ok(mut state) = self.seed_capacity.lock() { - state.starting.remove(id); - state.owners.remove(id); - } - self.notify.notify_waiters(); - } - - /// Stop tracking a seed lifecycle. This is called for terminal, manual - /// pause, and remove paths; releasing a download permit while a seed slot - /// is merely changing hands deliberately does not call it. - pub fn release_seed_tracking(&self, id: &str) { - if let Ok(mut state) = self.seed_capacity.lock() { - state.owners.remove(id); - state.starting.remove(id); - state.waiting.retain(|waiter| waiter.id != id); - } - if let Ok(mut budgets) = self.seed_budgets.lock() { - budgets.remove(id); - } - self.notify.notify_waiters(); - } - - pub fn is_waiting_to_seed(&self, id: &str) -> bool { - self.seed_waiting(id) - } - - pub fn wake_seed_waiters(&self) { - self.notify.notify_waiters(); - } - - async fn record_seed_started(&self, id: &str) { - let remaining_minutes = self - .aria2_payloads - .lock() - .await - .get(id) - .and_then(|payload| { - payload - .torrent_seed_remaining - .or(payload.torrent_seed_time) - .filter(|minutes| minutes.is_finite() && *minutes > 0.0) - }); - if let Ok(mut budgets) = self.seed_budgets.lock() { - budgets.insert( - id.to_string(), - SeedBudget { - remaining_minutes, - started_at: std::time::Instant::now(), - }, - ); - } - } - - /// Persist the remaining time in the in-memory payload before a seeder is - /// parked or manually paused. The frontend persistence path receives the - /// same value through the WaitingToSeed event and can restore it after a - /// restart. - pub async fn capture_seed_remaining(&self, id: &str) -> Option { - let remaining = self.seed_budgets.lock().ok().and_then(|mut budgets| { - let budget = budgets.get_mut(id)?; - let elapsed = budget.started_at.elapsed().as_secs_f64() / 60.0; - budget.remaining_minutes.map(|minutes| (minutes - elapsed).max(0.0)) - }); - if let Some(remaining) = remaining { - if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) { - payload.torrent_seed_time = Some(remaining); - payload.torrent_seed_remaining = Some(remaining); - } - } - remaining - } - - async fn release_download_permit_for_seed(&self, id: &str) { - let _admission_gate = self.admission_gate.lock().await; - let removed = self.active_permits.lock().await.remove(id).is_some(); - self.active_permit_generations.lock().await.remove(id); - self.queue_permit_ownership.lock().await.remove(id); - if removed { - self.notify.notify_waiters(); - } - drop(_admission_gate); - if removed { - self.sync_power_activity().await; - } - } - - async fn admit_seed_after_completion(&self, id: &str) -> SeedAdmissionOutcome { - if self.seed_owner(id) { - return SeedAdmissionOutcome::Seeding; - } - if self.seed_waiting(id) { - return SeedAdmissionOutcome::Waiting; - } - if self.reserve_seed_slot(id) { - self.release_download_permit_for_seed(id).await; - self.record_seed_started(id).await; - return SeedAdmissionOutcome::Seeding; - } - - let Some(gid) = self.aria2_gid_for_download(id) else { - return SeedAdmissionOutcome::Seeding; - }; - match self.spawner.pause_for_seed(&gid).await { - Ok(Aria2SeedControlOutcome::Paused) => { - self.record_seed_started(id).await; - let remaining = self.capture_seed_remaining(id).await; - let ownership = self.queue_permit_ownership.lock().await.get(id).cloned(); - let lifecycle_generation = ownership - .as_ref() - .map(|ownership| ownership.lifecycle_generation) - .or(self.registered_lifecycle_generation(id).await) - .unwrap_or_default(); - self.release_download_permit_for_seed(id).await; - self.add_seed_waiter(SeedWaiter { - id: id.to_string(), - queue_id: ownership - .as_ref() - .map(|ownership| ownership.queue_id.clone()) - .unwrap_or_else(|| "main".to_string()), - lifecycle_generation, - }); - let _ = remaining; - SeedAdmissionOutcome::Waiting - } - Ok(Aria2SeedControlOutcome::Complete) => SeedAdmissionOutcome::Complete, - Ok(Aria2SeedControlOutcome::Resumed) | Err(_) => { - // An ambiguous pause must retain the download permit and GID; - // treating the transfer as a live seeder is the conservative - // choice until the next daemon status event resolves it. - self.reserve_seed_slot_even_if_full(id); - self.record_seed_started(id).await; - SeedAdmissionOutcome::Seeding - } - } - } - - fn reserve_seed_slot_even_if_full(&self, id: &str) { - if let Ok(mut state) = self.seed_capacity.lock() { - state.owners.insert(id.to_string()); - } - } - - fn emit_waiting_to_seed(&self, id: &str, remaining: Option) { - use tauri::Emitter; - let _ = self.app_handle.emit( - "download-state", - DownloadStateEvent::waiting_to_seed(id, remaining), - ); - } - - async fn try_start_waiting_seed(self: &Arc) -> bool { - let Some(waiter) = self.reserve_seed_waiter() else { - return false; - }; - let manager = Arc::clone(self); - tauri::async_runtime::spawn(async move { - manager.resume_waiting_seed(waiter).await; - }); - true - } - - async fn resume_waiting_seed(self: Arc, waiter: SeedWaiter) { - let id = waiter.id.clone(); - let Some(gid) = self.aria2_gid_for_download(&id) else { - self.abandon_seed_start(&id); - return; - }; - if !self - .is_registered_generation_or_legacy(&id, waiter.lifecycle_generation) - .await - || !self.seed_waiting(&id) - || !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) - { - self.abandon_seed_start(&id); - return; - } - let control_epoch = self.current_aria2_control_epoch(&id).await; - let Some(permit_candidate) = self - .acquire_aria2_permit_candidate_for_queue( - &id, - &waiter.queue_id, - waiter.lifecycle_generation, - control_epoch, - ) - .await - else { - self.abandon_seed_start(&id); - return; - }; - let _control_guard = self.acquire_aria2_control(&id).await; - if !self - .is_registered_generation_or_legacy(&id, waiter.lifecycle_generation) - .await - || !self.seed_waiting(&id) - || !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.release_aria2_permit_candidate(&id, waiter.lifecycle_generation) - .await; - self.abandon_seed_start(&id); - return; - } - let epoch = self.next_aria2_control_epoch(&id).await; - if !self.rebind_aria2_gid_epoch(&id, &gid, epoch).await { - self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation) - .await; - self.abandon_seed_start(&id); - return; - } - if !self - .park_aria2_permit_if_missing_for_queue( - &id, - &waiter.queue_id, - waiter.lifecycle_generation, - permit_candidate, - ) - .await - { - self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation) - .await; - self.abandon_seed_start(&id); - return; - } - match self.spawner.resume_for_seed(&gid).await { - Ok(Aria2SeedControlOutcome::Resumed) => { - if !self.is_aria2_control_epoch_current(&id, epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &Aria2GidMapping { id: id.clone(), epoch }) - { - let _ = self.spawner.pause_for_seed(&gid).await; - self.release_download_permit_for_seed(&id).await; - self.abandon_seed_start(&id); - return; - } - self.record_seed_started(&id).await; - // Keep the lifecycle visibly in the starting state until its - // download permit has been released. Otherwise observers can - // see a resumed seeder before the permit transition finishes. - self.release_download_permit_for_seed(&id).await; - self.finish_seed_start(&id); - self.emit_state(&id, DownloadStatus::Seeding); - } - Ok(Aria2SeedControlOutcome::Complete) => { - self.finish_seed_start(&id); - self.apply_completion_locked(&id, PendingOutcome::Complete).await; - } - Ok(Aria2SeedControlOutcome::Paused) => { - let remaining = self.capture_seed_remaining(&id).await; - self.release_download_permit_for_seed(&id).await; - self.finish_seed_start(&id); - self.abandon_seed_start(&id); - self.add_seed_waiter(SeedWaiter { - id: id.clone(), - queue_id: waiter.queue_id, - lifecycle_generation: waiter.lifecycle_generation, - }); - self.emit_waiting_to_seed(&id, remaining); - } - Err(error) => { - // An unverified unpause must not release either the seed - // owner or its download permit: the daemon may already be - // active even though the RPC/status check was unavailable. - // Keep the item fenced in the starting state and retry the - // status-verified control until the daemon or a newer - // lifecycle resolves it. - log::warn!( - "Torrent seed resume [{}] could not be verified; retaining seed ownership and permit: {}", - id, - error - ); - self.emit_waiting_to_seed(&id, None); - let retry_waiter = SeedWaiter { - id: id.clone(), - queue_id: waiter.queue_id.clone(), - lifecycle_generation: waiter.lifecycle_generation, - }; - let manager = Arc::clone(&self); - tauri::async_runtime::spawn(async move { - manager - .retry_ambiguous_seed_resume(retry_waiter, gid, epoch) - .await; - }); - } - } - } - - async fn retry_ambiguous_seed_resume( - self: Arc, - waiter: SeedWaiter, - gid: String, - epoch: u64, - ) { - let mut delay = Duration::from_millis(250); - loop { - tokio::select! { - _ = tokio::time::sleep(delay) => {} - _ = self.notify.notified() => {} - } - - let _control_guard = self.acquire_aria2_control(&waiter.id).await; - let lifecycle_current = self - .is_registered_generation_or_legacy(&waiter.id, waiter.lifecycle_generation) - .await - && self.seed_starting(&waiter.id) - && matches!(self.active_kind(&waiter.id).await, Some(TaskKind::Aria2)) - && self.has_active_permit(&waiter.id).await - && self.aria2_gid_for_download(&waiter.id).as_deref() == Some(gid.as_str()) - && self.is_aria2_control_epoch_current(&waiter.id, epoch).await - && self.is_current_aria2_gid_mapping( - &gid, - &Aria2GidMapping { - id: waiter.id.clone(), - epoch, - }, - ); - if !lifecycle_current { - return; - } - - match self.spawner.resume_for_seed(&gid).await { - Ok(Aria2SeedControlOutcome::Resumed) => { - if !self.is_aria2_control_epoch_current(&waiter.id, epoch).await - || !self.is_current_aria2_gid_mapping( - &gid, - &Aria2GidMapping { - id: waiter.id.clone(), - epoch, - }, - ) - { - let _ = self.spawner.pause_for_seed(&gid).await; - let _ = self.spawner.pause_for_seed(&gid).await; - self.release_download_permit_for_seed(&waiter.id).await; - self.abandon_seed_start(&waiter.id); - return; - } - self.record_seed_started(&waiter.id).await; - self.release_download_permit_for_seed(&waiter.id).await; - self.finish_seed_start(&waiter.id); - self.emit_state(&waiter.id, DownloadStatus::Seeding); - return; - } - Ok(Aria2SeedControlOutcome::Complete) => { - self.finish_seed_start(&waiter.id); - self.apply_completion_locked(&waiter.id, PendingOutcome::Complete) - .await; - return; - } - Ok(Aria2SeedControlOutcome::Paused) => { - let remaining = self.capture_seed_remaining(&waiter.id).await; - self.release_download_permit_for_seed(&waiter.id).await; - self.finish_seed_start(&waiter.id); - self.abandon_seed_start(&waiter.id); - self.add_seed_waiter(SeedWaiter { - id: waiter.id.clone(), - queue_id: waiter.queue_id.clone(), - lifecycle_generation: waiter.lifecycle_generation, - }); - self.emit_waiting_to_seed(&waiter.id, remaining); - return; - } - Err(error) => { - log::warn!( - "Torrent seed resume [{}] remains unverified; retaining seed ownership and permit: {}", - waiter.id, - error - ); - delay = (delay * 2).min(Duration::from_secs(5)); - } - } - } - } - async fn sync_power_activity(&self) { let active_transfers = self.active_permits.lock().await.len(); self.power_manager @@ -2096,27 +426,6 @@ impl QueueManager { .collect() } - /// Temporarily remove one not-yet-admitted task while a caller performs a - /// lifecycle-safe reconfiguration. The admission gate prevents the - /// dispatcher from popping the same task between the caller's validation - /// and mutation of its payload. - pub async fn take_pending_task(&self, id: &str) -> Option<(usize, QueuedTask)> { - let _admission_gate = self.admission_gate.lock().await; - let mut pending = self.pending.lock().await; - let index = pending.iter().position(|task| task.id == id)?; - pending.remove(index).map(|task| (index, task)) - } - - /// Restore a task removed by `take_pending_task`, preserving its queue - /// position even when another queue's work was admitted meanwhile. - pub async fn restore_pending_task(&self, index: usize, task: QueuedTask) { - let _admission_gate = self.admission_gate.lock().await; - let mut pending = self.pending.lock().await; - let insert_at = index.min(pending.len()); - pending.insert(insert_at, task); - self.notify.notify_one(); - } - /// 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.registered_ids.lock().await.remove(id); @@ -2125,7 +434,6 @@ impl QueueManager { // Epoch checks remain the authoritative guard; removing this marker // prevents terminal downloads from accumulating cancellation entries. self.aria2_retry_cancelled.lock().await.remove(id); - self.release_seed_tracking(id); self.notify.notify_waiters(); } @@ -2150,13 +458,6 @@ impl QueueManager { == Some(generation) } - async fn is_registered_generation_or_legacy(&self, id: &str, generation: u64) -> bool { - self.is_registered_generation(id, generation).await - || (generation == 0 - && self.registered_lifecycle_generation(id).await.is_none() - && self.is_registered(id).await) - } - pub(crate) async fn release_registered_id_for_generation(&self, id: &str, generation: u64) { let released = { let mut registered = self.registered_ids.lock().await; @@ -2171,7 +472,6 @@ impl QueueManager { }; if released { self.aria2_retry_cancelled.lock().await.remove(id); - self.release_seed_tracking(id); self.notify.notify_waiters(); } } @@ -2185,21 +485,6 @@ impl QueueManager { .or_insert(generation); } - /// Return a generation newer than every observed or cancelled enqueue for - /// an internally-created lifecycle. The caller still passes this value to - /// `reserve_enqueue_generation`, which performs the atomic ownership and - /// cancellation checks before the enqueue is committed. - pub async fn next_enqueue_generation(&self, id: &str) -> Result { - let cancellations = self.enqueue_cancellations.lock().await; - let generations = self.enqueue_generations.lock().await; - let previous_generation = generations.get(id).copied().unwrap_or_default(); - let cancelled_generation = cancellations.get(id).copied().unwrap_or_default(); - previous_generation - .max(cancelled_generation) - .checked_add(1) - .ok_or_else(|| "Download lifecycle generation exhausted".to_string()) - } - /// Atomically reserve an ID after rejecting cancelled or replayed generations. /// The returned watermark must be passed to `rollback_enqueue_reservation` /// if ownership registration fails before the task is committed. @@ -2258,93 +543,20 @@ impl QueueManager { } pub async fn commit_reserved_enqueue( - &self, - task: QueuedTask, - generation: u64, - previous_generation: Option, - ) -> Result<(), String> { - self.commit_reserved_enqueue_with_finalizer(task, generation, previous_generation, || async { - Ok(()) - }) - .await - } - - /// Commit an enqueue and its final durable admission marker as one - /// dispatcher-visible boundary. The task is placed in the pending list - /// before the finalizer runs, but the admission gate stays held so the - /// dispatcher cannot pop it until the finalizer succeeds. If the - /// finalizer fails, the task is removed before any worker can observe it. - pub async fn commit_reserved_enqueue_with_finalizer( &self, mut task: QueuedTask, generation: u64, - previous_generation: Option, - finalizer: F, - ) -> Result<(), String> - where - F: FnOnce() -> Fut, - Fut: std::future::Future>, - { + ) -> Result<(), String> { let id = task.id.clone(); - let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - self.rollback_enqueue_reservation(&id, generation, previous_generation) - .await; - return Err("System action is already being performed".to_string()); - } - if self - .registered_lifecycle_generation(&id) - .await - .is_none_or(|registered| registered != generation) + let cancellations = self.enqueue_cancellations.lock().await; + if cancellations + .get(&id) + .is_some_and(|cancelled| *cancelled >= generation) { - self.rollback_enqueue_reservation(&id, generation, previous_generation) - .await; - return Err("Download enqueue reservation is no longer current".to_string()); - } - { - let cancellations = self.enqueue_cancellations.lock().await; - if cancellations - .get(&id) - .is_some_and(|cancelled| *cancelled >= generation) - { - self.rollback_enqueue_reservation(&id, generation, previous_generation) - .await; - return Err("Download enqueue was superseded by a newer user action".to_string()); - } + return Err("Download enqueue was superseded by a newer user action".to_string()); } task.lifecycle_generation = generation; self.pending.lock().await.push_back(task); - - if let Err(error) = finalizer().await { - let mut pending = self.pending.lock().await; - pending.retain(|candidate| { - !(candidate.id == id && candidate.lifecycle_generation == generation) - }); - self.rollback_enqueue_reservation(&id, generation, previous_generation) - .await; - return Err(error); - } - - // Cancellation can arrive while the durable admission marker is being - // written. Recheck it before making the task visible to the rest of - // the lifecycle; the admission gate prevents a dispatcher or queue - // mutation from observing a half-committed replacement. - { - let cancellations = self.enqueue_cancellations.lock().await; - if cancellations - .get(&id) - .is_some_and(|cancelled| *cancelled >= generation) - { - let mut pending = self.pending.lock().await; - pending.retain(|candidate| { - !(candidate.id == id && candidate.lifecycle_generation == generation) - }); - self.rollback_enqueue_reservation(&id, generation, previous_generation) - .await; - return Err("Download enqueue was superseded by a newer user action".to_string()); - } - } - self.emit_state(id, DownloadStatus::Queued); self.notify.notify_one(); Ok(()) @@ -2358,10 +570,7 @@ impl QueueManager { ) -> Result<(), String> { let id = task.id.clone(); let previous_generation = self.reserve_enqueue_generation(&id, generation).await?; - if let Err(error) = self - .commit_reserved_enqueue(task, generation, previous_generation) - .await - { + if let Err(error) = self.commit_reserved_enqueue(task, generation).await { self.rollback_enqueue_reservation(&id, generation, previous_generation) .await; return Err(error); @@ -2465,666 +674,15 @@ impl QueueManager { self.aria2_retry_strikes.lock().await.contains_key(id) } - pub async fn aria2_retry_strike(&self, id: &str) -> usize { - self.aria2_retry_strikes - .lock() - .await - .get(id) - .copied() - .unwrap_or_default() - } - pub async fn aria2_requested_connections(&self, id: &str) -> Option { self.aria2_payloads .lock() .await .get(id) - .and_then(|payload| { - if payload.is_torrent { - None - } else { - payload.connections - } - }) + .and_then(|payload| payload.connections) .map(clamp_download_connections) } - pub async fn set_aria2_connection_options( - &self, - id: &str, - epoch: u64, - effective: i32, - ) { - let mut options = self.aria2_connection_options.lock().await; - if options - .get(id) - .is_some_and(|current| current.epoch > epoch) - { - return; - } - options.insert( - id.to_string(), - Aria2ConnectionOptions { - epoch, - effective: clamp_download_connections(effective), - }, - ); - } - - pub async fn aria2_effective_connections(&self, id: &str, epoch: u64) -> Option { - self.aria2_connection_options - .lock() - .await - .get(id) - .filter(|options| options.epoch == epoch) - .map(|options| options.effective) - } - - pub async fn aria2_torrent_seeding_requested(&self, id: &str) -> bool { - self.aria2_payloads - .lock() - .await - .get(id) - .is_some_and(torrent_seeding_requested) - } - - /// Whether a currently seeding Torrent owns the Firelink permit that - /// allows seed-time accounting. Separate seed slots require explicit - /// ownership; legacy single-pool mode keeps the transfer permit live. - pub async fn aria2_torrent_seed_permit_owned(&self, id: &str) -> bool { - if self.seed_capacity_enabled() { - self.seed_owner(id) - } else { - self.aria2_torrent_seeding_requested(id).await - } - } - - pub async fn aria2_is_torrent(&self, id: &str) -> bool { - self.aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent) - } - - /// Return only the bounded number of tracker endpoints associated with a - /// live Torrent. The endpoint values and managed metadata path never leave - /// this method; callers use the count for redacted diagnostics only. - pub async fn aria2_torrent_tracker_count(&self, id: &str) -> Option { - let payload = self.aria2_payloads.lock().await.get(id).cloned()?; - if !payload.is_torrent { - return None; - } - - let mut count = payload - .torrent_trackers - .as_deref() - .map(|trackers| { - trackers - .split(',') - .filter(|tracker| !tracker.trim().is_empty()) - .take(256) - .count() - }); - - if payload - .url - .trim_start() - .to_ascii_lowercase() - .starts_with("magnet:") - { - let magnet_count = crate::torrent::magnet_tracker_count(&payload.url).ok()?; - count = Some(count.unwrap_or_default().saturating_add(magnet_count)); - } - - if let Some(torrent_path) = payload.torrent_path.as_deref() { - let path = crate::torrent::validate_managed_torrent_path( - &self.app_handle, - id, - torrent_path, - ) - .ok()?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path).await.ok()?; - let metadata = crate::torrent::torrent_details_from_bytes(&bytes).ok()?; - count = Some(count.unwrap_or_default().saturating_add(metadata.trackers.len())); - } - - count.map(|value| value.min(256)) - } - - pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool { - self.aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.torrent_verify_only) - } - - async fn capture_torrent_verification_evidence(&self, id: &str) { - if !self.aria2_is_torrent_verification(id).await { - return; - } - let Some(gid) = self.aria2_gid_for_download(id) else { - return; - }; - let Some(mapping) = self.aria2_gid_mapping(&gid) else { - return; - }; - let Some(state) = self.app_handle.try_state::() else { - return; - }; - let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); - let secret = state.aria2_secret.clone(); - drop(state); - let Ok(status) = crate::rpc_call( - port, - &secret, - "aria2.tellStatus", - serde_json::json!([gid, ["status", "totalLength", "verifiedLength", "verifyIntegrityPending"]]), - ) - .await else { - return; - }; - if !self.is_current_aria2_gid_mapping(&gid, &mapping) - || !self - .is_aria2_control_epoch_current(id, mapping.epoch) - .await - { - return; - } - let status_name = status.get("status").and_then(|value| value.as_str()); - let total = status - .get("totalLength") - .and_then(|value| value.as_str().and_then(|value| value.parse::().ok()).or_else(|| value.as_u64())); - let verified = status - .get("verifiedLength") - .and_then(|value| value.as_str().and_then(|value| value.parse::().ok()).or_else(|| value.as_u64())); - let pending = status - .get("verifyIntegrityPending") - .is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true")); - if let Some(total) = Self::complete_torrent_verification_length( - status_name, - pending, - total, - verified, - ) { - self.record_torrent_verified_length(id, mapping.epoch, total) - .await; - } - } - - fn complete_torrent_verification_length( - status: Option<&str>, - verify_pending: bool, - total: Option, - verified: Option, - ) -> Option { - if matches!(status, Some("complete" | "active" | "waiting")) - && !verify_pending - { - return verified - .zip(total) - .filter(|(verified, total)| verified >= total) - .map(|(_, total)| total); - } - None - } - - pub async fn record_torrent_verified_length(&self, id: &str, epoch: u64, length: u64) { - if !self.is_aria2_control_epoch_current(id, epoch).await { - return; - } - if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) { - if payload.torrent_verify_only { - let current = payload.torrent_verified_length.unwrap_or(0); - payload.torrent_verified_length = Some(current.max(length)); - } - } - } - - async fn torrent_metadata_for_payload( - &self, - id: &str, - payload: &SpawnPayload, - ) -> Result { - let path = payload - .torrent_path - .as_deref() - .ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?; - let path = crate::torrent::validate_managed_torrent_path(&self.app_handle, id, path)?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached Torrent metadata: {error}"))?; - crate::torrent::parse_torrent_bytes(&bytes) - } - - async fn torrent_files_for_payload( - &self, - id: &str, - payload: &SpawnPayload, - ) -> Result, String> { - Ok(self.torrent_metadata_for_payload(id, payload).await?.files) - } - - fn torrent_baseline_web_seed_pairs( - &self, - payload: &SpawnPayload, - metadata: &crate::torrent::ParsedTorrent, - ) -> Result, String> { - let mut sources = metadata.web_seeds.clone(); - sources.extend(normalize_torrent_mirror_uris(payload.mirrors.as_deref())?); - expand_torrent_web_seed_sources(&sources, metadata) - } - - async fn current_torrent_mapping( - &self, - id: &str, - ) -> Result<(String, Aria2GidMapping), String> { - if !self.is_registered(id).await || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) { - return Err("download is not an active Aria2 Torrent".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "active Torrent transfer has no GID".to_string())?; - let mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "active Torrent transfer has no current GID mapping".to_string())?; - if mapping.id != id || !self.is_aria2_control_epoch_current(id, mapping.epoch).await { - return Err("Torrent lifecycle changed before web-seed inspection".to_string()); - } - Ok((gid, mapping)) - } - - pub async fn get_aria2_torrent_web_seeds( - &self, - id: &str, - ) -> Result, String> { - let control_guard = self.acquire_aria2_control(id).await; - self.get_aria2_torrent_web_seeds_locked(id, &control_guard) - .await - } - - pub async fn get_aria2_torrent_web_seeds_locked( - &self, - id: &str, - _control_guard: &Aria2ControlGuard, - ) -> Result, String> { - let payload = self - .aria2_payloads - .lock() - .await - .get(id) - .cloned() - .ok_or_else(|| "Torrent retry payload is unavailable".to_string())?; - let files = self.torrent_files_for_payload(id, &payload).await?; - let desired = normalize_torrent_web_seeds(payload.torrent_web_seeds.as_deref(), &files)?; - let (gid, mapping) = self.current_torrent_mapping(id).await?; - let current = normalize_aria2_torrent_file_uris( - self.spawner.get_torrent_file_uris(&gid).await?, - &files, - )?; - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - return Err("Torrent lifecycle changed while reading web seeds".to_string()); - } - let expected = expand_torrent_web_seeds(&desired, &files)?; - if expected.iter().any(|(file_index, uri)| { - !current - .get(file_index) - .is_some_and(|uris| uris.contains(uri)) - }) { - return Err("Aria2 web-seed state differs from Firelink's persisted state".to_string()); - } - Ok(desired - .into_iter() - .filter(|seed| { - expand_torrent_web_seed_uri(seed, &files).ok().is_some_and(|uri| { - current - .get(&seed.file_index) - .is_some_and(|uris| uris.contains(&uri)) - }) - }) - .collect()) - } - - pub async fn normalize_aria2_torrent_web_seeds( - &self, - id: &str, - seeds: &[crate::ipc::TorrentWebSeed], - ) -> Result, String> { - let control_guard = self.acquire_aria2_control(id).await; - self.normalize_aria2_torrent_web_seeds_locked(id, seeds, &control_guard) - .await - } - - pub async fn normalize_aria2_torrent_web_seeds_locked( - &self, - id: &str, - seeds: &[crate::ipc::TorrentWebSeed], - _control_guard: &Aria2ControlGuard, - ) -> Result, String> { - let payload = self - .aria2_payloads - .lock() - .await - .get(id) - .cloned() - .ok_or_else(|| "Torrent retry payload is unavailable".to_string())?; - let files = self.torrent_files_for_payload(id, &payload).await?; - normalize_torrent_web_seeds(Some(seeds), &files) - } - - async fn rollback_torrent_web_seed_changes( - &self, - id: &str, - gid: &str, - mapping: &Aria2GidMapping, - changes: &[(u32, Vec, Vec)], - ) -> bool { - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(gid, mapping) - { - return false; - } - let mut restored = true; - for (file_index, delete, add) in changes.iter().rev() { - if let Err(error) = self - .spawner - .change_torrent_uris(gid, *file_index, add, delete) - .await - { - restored = false; - log::warn!( - "Torrent web-seed rollback [{}] failed for gid {} file {}: {}", - id, - gid, - file_index, - error - ); - } - } - restored - } - - async fn restore_torrent_web_seed_payload_if_current( - &self, - id: &str, - gid: &str, - mapping: &Aria2GidMapping, - old_payload: &SpawnPayload, - ) { - if self.is_aria2_control_epoch_current(id, mapping.epoch).await - && self.is_current_aria2_gid_mapping(gid, mapping) - { - if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) { - payload.torrent_web_seeds = old_payload.torrent_web_seeds.clone(); - } - } - } - - pub async fn set_aria2_torrent_web_seeds( - &self, - id: &str, - seeds: Vec, - ) -> Result< - ( - Vec, - Vec, - ), - String, - > { - let control_guard = self.acquire_aria2_control(id).await; - self.set_aria2_torrent_web_seeds_locked(id, seeds, &control_guard) - .await - } - - pub async fn set_aria2_torrent_web_seeds_locked( - &self, - id: &str, - seeds: Vec, - _control_guard: &Aria2ControlGuard, - ) -> Result< - ( - Vec, - Vec, - ), - String, - > { - let old_payload = self - .aria2_payloads - .lock() - .await - .get(id) - .cloned() - .ok_or_else(|| "Torrent retry payload is unavailable".to_string())?; - let metadata = self.torrent_metadata_for_payload(id, &old_payload).await?; - let files = metadata.files.clone(); - let desired = normalize_torrent_web_seeds(Some(&seeds), &files)?; - let old = normalize_torrent_web_seeds(old_payload.torrent_web_seeds.as_deref(), &files)?; - let (gid, mapping) = self.current_torrent_mapping(id).await?; - let mut current = normalize_aria2_torrent_file_uris( - self.spawner.get_torrent_file_uris(&gid).await?, - &files, - )?; - let old_expanded = expand_torrent_web_seeds(&old, &files)?; - let new_expanded = expand_torrent_web_seeds(&desired, &files)?; - let baseline = self.torrent_baseline_web_seed_pairs(&old_payload, &metadata)?; - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - return Err("Torrent lifecycle changed while reading web seeds".to_string()); - } - let (expected, planned_changes) = - plan_torrent_web_seed_change(¤t, &baseline, &old_expanded, &new_expanded)?; - - if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) { - payload.torrent_web_seeds = Some(desired.clone()); - } - let mut changes = Vec::<(u32, Vec, Vec)>::new(); - for (file_index, delete, add) in planned_changes { - changes.push((file_index, delete.clone(), add.clone())); - if let Err(error) = self.spawner.change_torrent_uris(&gid, file_index, &delete, &add).await { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err(error); - } - let Some(current_uris) = current.get_mut(&file_index) else { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - for uri in &delete { - current_uris.remove(uri); - } - current_uris.extend(add.iter().cloned()); - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err("Torrent lifecycle changed while changing web seeds".to_string()); - } - } - let readback = match self.spawner.get_torrent_file_uris(&gid).await { - Ok(readback) => match normalize_aria2_torrent_file_uris(readback, &files) { - Ok(readback) => readback, - Err(error) => { - self.rollback_torrent_web_seed_changes(id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err(error); - } - }, - Err(error) => { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err(error); - } - }; - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err("Torrent lifecycle changed while reading back web seeds".to_string()); - } - if readback != expected { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - self.restore_torrent_web_seed_payload_if_current( - id, - &gid, - &mapping, - &old_payload, - ) - .await; - return Err("Aria2 web-seed readback did not match the requested state".to_string()); - } - Ok((desired, old)) - } - - /// Attach the persisted typed seed set after addTorrent returns its GID. - /// The addTorrent URI parameter is intentionally left unscoped; this - /// method is the only path that supplies the required Aria2 file index. - pub async fn install_initial_torrent_web_seeds(&self, id: &str) -> Result<(), String> { - let _control_guard = self.acquire_aria2_control(id).await; - let payload = self - .aria2_payloads - .lock() - .await - .get(id) - .cloned() - .ok_or_else(|| "Torrent retry payload is unavailable".to_string())?; - let desired = payload.torrent_web_seeds.clone().unwrap_or_default(); - if desired.is_empty() { - return Ok(()); - } - let files = self.torrent_files_for_payload(id, &payload).await?; - let (gid, mapping) = self.current_torrent_mapping(id).await?; - let mut current = normalize_aria2_torrent_file_uris( - self.spawner.get_torrent_file_uris(&gid).await?, - &files, - )?; - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - return Err("Torrent lifecycle changed while reading initial web seeds".to_string()); - } - let expanded = expand_torrent_web_seeds(&desired, &files)?; - let expected = expected_initial_torrent_web_seed_state(¤t, &expanded); - let mut changes = Vec::<(u32, Vec, Vec)>::new(); - for (file_index, uri) in &expanded { - if current - .get(file_index) - .is_some_and(|uris| uris.contains(uri)) - { - continue; - } - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err("Torrent lifecycle changed while attaching web seeds".to_string()); - } - let add = vec![uri.clone()]; - changes.push((*file_index, Vec::new(), add.clone())); - if let Err(error) = self - .spawner - .change_torrent_uris(&gid, *file_index, &[], &add) - .await - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err(error); - } - let Some(current_uris) = current.get_mut(file_index) else { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err("Aria2 web-seed state contains an unknown Torrent file index".to_string()); - }; - current_uris.insert(uri.clone()); - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err("Torrent lifecycle changed while attaching web seeds".to_string()); - } - } - let readback = match self.spawner.get_torrent_file_uris(&gid).await { - Ok(readback) => readback, - Err(error) => { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err(error); - } - }; - if !self.is_aria2_control_epoch_current(id, mapping.epoch).await - || !self.is_current_aria2_gid_mapping(&gid, &mapping) - { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err("Torrent lifecycle changed while reading initial web seeds".to_string()); - } - let readback = match normalize_aria2_torrent_file_uris(readback, &files) { - Ok(readback) => readback, - Err(error) => { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err(error); - } - }; - if readback != expected { - self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes) - .await; - return Err("Aria2 did not retain the persisted Torrent web-seed set".to_string()); - } - Ok(()) - } - pub fn set_aria2_global_speed_limit(&self, limit: Option) { *self .aria2_global_speed_limit @@ -3133,22 +691,22 @@ impl QueueManager { } pub async fn aria2_speed_limited(&self, id: &str) -> bool { - let item_limit = self - .aria2_payloads + if self + .aria2_global_speed_limit + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() + { + return true; + } + + self.aria2_payloads .lock() .await .get(id) .and_then(|payload| payload.speed_limit.as_deref()) - .and_then(normalize_download_speed_limit); - if item_limit.as_deref() == Some("0") { - return false; - } - item_limit.is_some() - || self - .aria2_global_speed_limit - .lock() - .unwrap_or_else(|error| error.into_inner()) - .is_some() + .and_then(crate::normalize_speed_limit_for_aria2) + .is_some() } /// Change an active aria2 transfer's speed cap without replacing its GID @@ -3163,7 +721,7 @@ impl QueueManager { let normalized_limit = match limit.as_deref().map(str::trim) { None | Some("") => None, Some(raw) => Some( - normalize_download_speed_limit(raw) + crate::normalize_speed_limit_for_aria2(raw) .ok_or_else(|| "invalid download speed limit".to_string())?, ), }; @@ -3213,443 +771,8 @@ impl QueueManager { Ok(()) } - /// Change an active Torrent's upload cap without replacing its GID or - /// queue permit. The control lock and post-RPC ownership check fence a - /// late response from a terminal or replaced lifecycle. - pub async fn set_aria2_torrent_upload_limit( - &self, - id: &str, - limit: Option, - ) -> Result<(), String> { - let normalized_limit = match limit.as_deref().map(str::trim) { - None | Some("") => None, - Some(raw) => Some( - crate::normalize_speed_limit_for_aria2(raw) - .ok_or_else(|| "invalid torrent upload limit".to_string())?, - ), - }; - let _control_guard = self.acquire_aria2_control(id).await; - - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { - return Err("download is not an active aria2 transfer".to_string()); - } - let is_torrent = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent); - if !is_torrent { - return Err("download is not a Torrent transfer".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "active Torrent transfer has no gid".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?; - if expected_mapping.id != id { - return Err("aria2 gid belongs to another download".to_string()); - } - if !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("active Torrent transfer has a stale control epoch".to_string()); - } - - self.spawner - .set_torrent_upload_limit(&gid, normalized_limit.as_deref()) - .await?; - - let still_current = self.is_registered(id).await - && matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - && self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); - if !still_current { - return Err("Torrent lifecycle changed while setting upload limit".to_string()); - } - - let mut payloads = self.aria2_payloads.lock().await; - let payload = payloads - .get_mut(id) - .ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?; - payload.torrent_upload_limit = normalized_limit; - Ok(()) - } - - /// Change active Torrent peer settings without replacing the GID or queue - /// permit. Clearing a setting restores Aria2's documented default while - /// keeping the persisted payload override-free for future retries. - pub async fn set_aria2_torrent_peer_options( - &self, - id: &str, - max_peers: Option, - peer_speed_limit: Option, - ) -> Result<(), String> { - let normalized_max_peers = normalize_torrent_max_peers(max_peers)?; - let normalized_peer_speed_limit = - normalize_torrent_peer_speed_limit(peer_speed_limit.as_deref())?; - let rpc_max_peers = normalized_max_peers.unwrap_or(ARIA2_DEFAULT_TORRENT_MAX_PEERS); - let rpc_peer_speed_limit = normalized_peer_speed_limit - .as_deref() - .unwrap_or(ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT); - let _control_guard = self.acquire_aria2_control(id).await; - - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { - return Err("download is not an active aria2 transfer".to_string()); - } - let is_torrent = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent); - if !is_torrent { - return Err("download is not a Torrent transfer".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "active Torrent transfer has no gid".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?; - if expected_mapping.id != id { - return Err("aria2 gid belongs to another download".to_string()); - } - if !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("active Torrent transfer has a stale control epoch".to_string()); - } - - self.spawner - .set_torrent_peer_options(&gid, rpc_max_peers, rpc_peer_speed_limit) - .await?; - - let still_current = self.is_registered(id).await - && matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - && self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); - if !still_current { - return Err("Torrent lifecycle changed while setting peer options".to_string()); - } - - let mut payloads = self.aria2_payloads.lock().await; - let payload = payloads - .get_mut(id) - .ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?; - payload.torrent_max_peers = normalized_max_peers; - payload.torrent_peer_speed_limit = normalized_peer_speed_limit; - Ok(()) - } - - async fn get_aria2_torrent_peer_result( - &self, - id: &str, - result_kind: &str, - ) -> Result { - let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await { - return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string()); - } - let is_torrent = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent); - if !is_torrent { - return Err("download is not a Torrent transfer".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "active Torrent transfer has no gid".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?; - if expected_mapping.id != id - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("active Torrent transfer has a stale control epoch".to_string()); - } - - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getPeers", - serde_json::json!([gid]), - ) - .await - .map_err(|error| { - format!( - "aria2.getPeers failed: {}", - crate::redact_sensitive_text(&error) - ) - })?; - let still_current = self.is_registered(id).await - && self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); - if !still_current { - return Err(format!("Torrent lifecycle changed while reading {result_kind}")); - } - - Ok(result) - } - - /// Return bounded peer diagnostics for the current Torrent GID. Endpoint - /// and peer-id fields live only in this response; they are not persisted. - /// The control lock and post-RPC mapping check prevent a late response from - /// being attributed to a replaced or terminal lifecycle. - pub async fn get_aria2_torrent_peers( - &self, - id: &str, - ) -> Result { - let result = self - .get_aria2_torrent_peer_result(id, "peer diagnostics") - .await?; - parse_torrent_peer_diagnostics(result) - } - - /// Compute bounded, anonymized swarm availability for the current - /// Torrent lifecycle. The raw local/peer bitfields are consumed in native - /// memory and never returned to the frontend. - pub async fn get_aria2_torrent_availability( - &self, - id: &str, - ) -> Result { - let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await { - return Err("Torrent availability is unavailable for this lifecycle".to_string()); - } - if !self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent) - { - return Err("download is not a Torrent transfer".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "active Torrent has no gid".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "active Torrent has no current gid mapping".to_string())?; - if expected_mapping.id != id - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("active Torrent has a stale control epoch".to_string()); - } - let state = self.app_handle.state::(); - let status = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.tellStatus", - serde_json::json!([gid, ["bitfield", "numPieces"]]), - ) - .await - .map_err(|error| { - format!( - "aria2.tellStatus failed: {}", - crate::redact_sensitive_text(&error) - ) - })?; - if !self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("Torrent lifecycle changed while reading availability".to_string()); - } - let peers = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getPeers", - serde_json::json!([gid]), - ) - .await - .map_err(|error| { - format!( - "aria2.getPeers failed: {}", - crate::redact_sensitive_text(&error) - ) - })?; - let snapshot = parse_torrent_availability(status, peers)?; - if !self.is_registered(id).await - || !self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("Torrent lifecycle changed while reading availability".to_string()); - } - Ok(snapshot) - } - - /// Return a lifecycle-fenced, metadata-derived projection of Aria2's - /// per-file progress. The daemon's absolute paths and URI lists are never - /// copied across the boundary. - pub async fn get_aria2_torrent_file_progress( - &self, - id: &str, - ) -> Result { - let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await { - return Err("live Torrent file progress is unavailable".to_string()); - } - let payload = self - .aria2_payloads - .lock() - .await - .get(id) - .cloned() - .ok_or_else(|| "live Torrent file progress is unavailable".to_string())?; - if !payload.is_torrent { - return Err("download is not a Torrent transfer".to_string()); - } - let torrent_path = payload - .torrent_path - .as_deref() - .ok_or_else(|| "live Torrent file progress is unavailable".to_string())?; - let torrent_path = crate::torrent::validate_managed_torrent_path( - &self.app_handle, - id, - torrent_path, - )?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&torrent_path) - .await - .map_err(|_| "live Torrent file progress is unavailable".to_string())?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "live Torrent file progress is unavailable".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "live Torrent file progress is unavailable".to_string())?; - if expected_mapping.id != id - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("live Torrent file progress is unavailable".to_string()); - } - - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getFiles", - serde_json::json!([gid]), - ) - .await - .map_err(|error| { - format!( - "aria2.getFiles failed: {}", - crate::redact_sensitive_text(&error) - ) - })?; - let snapshot = parse_torrent_file_progress(result, &metadata.files)?; - - let still_current = self.is_registered(id).await - && self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); - if !still_current { - return Err("Torrent lifecycle changed while reading file progress".to_string()); - } - - Ok(snapshot) - } - - /// Return a bounded, lifecycle-fenced projection of Aria2's piece - /// bitfield. The raw bitfield never crosses the native boundary; the UI - /// receives only exact counts and balanced percentage buckets. - pub async fn get_aria2_torrent_piece_progress( - &self, - id: &str, - ) -> Result { - let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await { - return Err("live Torrent piece progress is unavailable".to_string()); - } - let is_torrent = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent); - if !is_torrent { - return Err("download is not a Torrent transfer".to_string()); - } - let gid = self - .aria2_gid_for_download(id) - .ok_or_else(|| "live Torrent piece progress is unavailable".to_string())?; - let expected_mapping = self - .aria2_gid_mapping(&gid) - .ok_or_else(|| "live Torrent piece progress is unavailable".to_string())?; - if expected_mapping.id != id - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Err("live Torrent piece progress is unavailable".to_string()); - } - - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.tellStatus", - serde_json::json!([gid, ["bitfield", "pieceLength", "numPieces"]]), - ) - .await - .map_err(|error| { - format!( - "aria2.tellStatus failed: {}", - crate::redact_sensitive_text(&error) - ) - })?; - let snapshot = parse_torrent_piece_progress(result)?; - - let still_current = self.is_registered(id).await - && self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) - && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); - if !still_current { - return Err("Torrent lifecycle changed while reading piece progress".to_string()); - } - - Ok(snapshot) - } - /// Pop the next task, or None if empty. pub async fn pop_front(&self) -> Option { - let _admission_gate = self.admission_gate.lock().await; self.pending.lock().await.pop_front() } @@ -3699,9 +822,7 @@ impl QueueManager { control_epoch: u64, ) -> Option { loop { - if !self - .is_registered_generation_or_legacy(id, lifecycle_generation) - .await + if !self.is_registered_generation(id, lifecycle_generation).await || self.is_aria2_retry_cancelled(id).await || !self.is_aria2_control_epoch_current(id, control_epoch).await { @@ -3714,9 +835,7 @@ impl QueueManager { .try_reserve_queue_slot(id, queue_id, lifecycle_generation) .await { - if self - .is_registered_generation_or_legacy(id, lifecycle_generation) - .await + if self.is_registered_generation(id, lifecycle_generation).await && !self.is_aria2_retry_cancelled(id).await && self .is_aria2_control_epoch_current(id, control_epoch) @@ -3756,9 +875,6 @@ impl QueueManager { lifecycle_generation: u64, ) -> Option { let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - return None; - } let mut ownership = self.queue_permit_ownership.lock().await; if ownership.contains_key(id) || self.active_permits.lock().await.contains_key(id) @@ -3818,21 +934,6 @@ impl QueueManager { permit: OwnedSemaphorePermit, ) -> bool { let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - let removed = self - .queue_permit_ownership - .lock() - .await - .get(id) - .is_some_and(|entry| { - entry.lifecycle_generation == lifecycle_generation && !entry.active - }); - if removed { - self.queue_permit_ownership.lock().await.remove(id); - self.notify.notify_waiters(); - } - return false; - } let mut ownership = self.queue_permit_ownership.lock().await; let owned = ownership .get(id) @@ -3860,9 +961,6 @@ impl QueueManager { async fn try_admit_next_task(&self) -> Option<(OwnedSemaphorePermit, QueuedTask)> { let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - return None; - } let mut pending = self.pending.lock().await; if pending.is_empty() { return None; @@ -3933,9 +1031,6 @@ impl QueueManager { permit: OwnedSemaphorePermit, ) { let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - return; - } self.active_permits .lock() .await @@ -3979,18 +1074,6 @@ impl QueueManager { .get(id) .copied(); let mut ownership = self.queue_permit_ownership.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - let remove_reservation = ownership.get(id).is_some_and(|entry| { - entry.queue_id == queue_id - && entry.lifecycle_generation == lifecycle_generation - && !entry.active - }); - if remove_reservation { - ownership.remove(id); - self.notify.notify_waiters(); - } - return false; - } let has_matching_reservation = ownership.get(id).is_some_and(|entry| { entry.queue_id == queue_id && entry.lifecycle_generation == lifecycle_generation @@ -4116,7 +1199,7 @@ impl QueueManager { } } - pub(crate) async fn release_permit_for_generation(&self, id: &str, generation: u64) { + async fn release_permit_for_generation(&self, id: &str, generation: u64) { let _admission_gate = self.admission_gate.lock().await; let removed = { let mut permits = self.active_permits.lock().await; @@ -4150,31 +1233,6 @@ impl QueueManager { self.active_permits.lock().await.contains_key(id) } - /// Atomically fence new transfer admission after checking all backend-owned - /// work. The frontend performs the same check for a useful user message, - /// but this backend transition closes the check-to-action race. - pub async fn begin_system_action(&self, force: bool) -> Result<(), String> { - let _admission_gate = self.admission_gate.lock().await; - if self.system_action_pending.load(Ordering::Acquire) { - return Err("Another system action is already pending".to_string()); - } - if !force - && (!self.pending.lock().await.is_empty() - || !self.queue_permit_ownership.lock().await.is_empty() - || !self.active_permits.lock().await.is_empty() - || self.has_torrent_moves()) - { - return Err("System action was skipped because downloads are still active or queued".to_string()); - } - self.system_action_pending.store(true, Ordering::Release); - Ok(()) - } - - pub fn end_system_action(&self) { - self.system_action_pending.store(false, Ordering::Release); - self.notify.notify_waiters(); - } - /// Clear all permits belonging to aria2. Useful when aria2 WS connection drops. pub async fn clear_aria2_permits(&self) { let ids_to_fail: Vec = { @@ -4196,11 +1254,6 @@ impl QueueManager { .await; } } - // Unknown start notifications belong to the current Aria2 daemon - // session. A reconnect/restart invalidates that association; the - // poller will provide a fresh positive-progress fallback after the - // next GID mapping instead of letting an old GID clear a new phase. - self.pending_download_starts.lock().await.clear(); } /// Number of un-acquired permits currently in the semaphore pool. @@ -4208,162 +1261,13 @@ impl QueueManager { self.semaphore.available_permits() } - pub(crate) fn emit_state(&self, id: impl Into, status: DownloadStatus) { + fn emit_state(&self, id: impl Into, status: DownloadStatus) { use tauri::Emitter; let _ = self .app_handle .emit("download-state", DownloadStateEvent::new(id, status)); } - fn emit_allocation_event(&self, id: &str, pending: bool, lifecycle_generation: u64) { - use tauri::Emitter; - let _ = self.app_handle.emit( - "download-allocation", - DownloadAllocationEvent { - id: id.to_string(), - pending, - lifecycle_generation: lifecycle_generation.to_string(), - }, - ); - } - - pub fn aria2_allocation_phase_eligible(payload: &SpawnPayload) -> bool { - if payload.is_media || payload.is_torrent || payload.torrent_verify_only { - return false; - } - true - } - - async fn begin_aria2_allocation( - &self, - id: &str, - control_epoch: u64, - lifecycle_generation: u64, - payload: &SpawnPayload, - ) { - if !Self::aria2_allocation_phase_eligible(payload) { - return; - } - self.aria2_allocation_pending - .lock() - .await - .insert(id.to_string(), (control_epoch, lifecycle_generation)); - self.emit_allocation_event(id, true, lifecycle_generation); - } - - pub async fn clear_aria2_allocation_for_lifecycle_generation( - &self, - id: &str, - lifecycle_generation: u64, - ) { - let cleared_generation = { - let mut pending = self.aria2_allocation_pending.lock().await; - let Some((_, pending_generation)) = pending.get(id).copied() else { - return; - }; - if pending_generation != lifecycle_generation { - return; - } - pending.remove(id).map(|(_, generation)| generation) - }; - if let Some(cleared_generation) = cleared_generation { - self.emit_allocation_event(id, false, cleared_generation); - } - } - - pub async fn clear_aria2_allocation_for_epoch(&self, id: &str, control_epoch: u64) { - let lifecycle_generation = { - let mut pending = self.aria2_allocation_pending.lock().await; - let Some((pending_epoch, lifecycle_generation)) = pending.get(id).copied() else { - return; - }; - if pending_epoch != control_epoch { - return; - } - pending.remove(id); - lifecycle_generation - }; - self.emit_allocation_event(id, false, lifecycle_generation); - } - - pub async fn clear_aria2_allocation(&self, id: &str) { - let lifecycle_generation = self - .aria2_allocation_pending - .lock() - .await - .remove(id) - .map(|(_, lifecycle_generation)| lifecycle_generation); - if let Some(lifecycle_generation) = lifecycle_generation { - self.emit_allocation_event(id, false, lifecycle_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 - // transfer progress and must not hide the allocation phase. - if downloaded_bytes == 0 { - return; - } - let mapping = { - let _gid_state = self.aria2_gid_state.lock().await; - if self.is_aria2_gid_ignored_locked(gid).await { - return; - } - let Some(mapping) = self.aria2_gid_mapping(gid) else { - let mut starts = self.pending_download_starts.lock().await; - if starts.len() < MAX_PENDING_DOWNLOAD_STARTS { - starts.insert(gid.to_string()); - } - return; - }; - mapping - }; - if !self - .is_current_aria2_gid_mapping(gid, &mapping) - || !self - .is_aria2_control_epoch_current(&mapping.id, mapping.epoch) - .await - { - return; - } - self.clear_aria2_allocation_for_epoch(&mapping.id, mapping.epoch) - .await; - } - - pub async fn handle_aria2_download_start(&self, gid: &str, downloaded_bytes: u64) { - // Aria2 can publish onDownloadStart before it finishes creating - // preallocated files. A zero-byte start is therefore not native - // transfer progress and must leave the allocation phase visible. - if downloaded_bytes == 0 { - return; - } - let mapping = { - let _gid_state = self.aria2_gid_state.lock().await; - if self.is_aria2_gid_ignored_locked(gid).await { - return; - } - let Some(mapping) = self.aria2_gid_mapping(gid) else { - let mut starts = self.pending_download_starts.lock().await; - if starts.len() < MAX_PENDING_DOWNLOAD_STARTS { - starts.insert(gid.to_string()); - } - return; - }; - mapping - }; - if !self - .is_current_aria2_gid_mapping(gid, &mapping) - || !self - .is_aria2_control_epoch_current(&mapping.id, mapping.epoch) - .await - { - return; - } - self.clear_aria2_allocation_for_epoch(&mapping.id, mapping.epoch) - .await; - } - /// Resize the global concurrency limit. Grow adds permits immediately; /// shrink records a retirement debt honored lazily by the dispatcher. pub fn set_capacity(&self, new_target: usize) { @@ -4414,9 +1318,6 @@ impl QueueManager { let notified = self.notify.notified(); tokio::pin!(notified); notified.as_mut().enable(); - if self.try_start_waiting_seed().await { - continue; - } if let Some((permit, task)) = self.try_admit_next_task().await { // Admission owns the global and per-queue reservation before // this task is spawned. Keep the dispatcher free to admit @@ -4487,35 +1388,14 @@ impl QueueManager { } else { None }; - if let Some(epoch) = aria2_lifecycle_epoch { - self.begin_aria2_dispatch(&id, epoch).await; - // Register the native allocation phase before releasing the - // lifecycle lock. A pause/remove can otherwise invalidate this - // dispatch in the gap and leave a stale pending marker behind - // when the asynchronous addUri call starts. - self.begin_aria2_allocation( - &id, - epoch, - lifecycle_generation, - &task.payload, - ) - .await; - } + self.emit_state(&id, DownloadStatus::Downloading); drop(control_guard); - // Media runners do not receive an Aria2 GID. Their permit is already - // active at this point, so publish their live state before spawning - // the runner; Aria2 tasks publish only after remember_gid below. - if matches!(&task.kind, TaskKind::Media) { - self.emit_state(&id, DownloadStatus::Downloading); - } - match task.kind { TaskKind::Aria2 => { let lifecycle_epoch = aria2_lifecycle_epoch .expect("aria2 dispatch must initialize a control epoch"); - let add_result = self.spawner.add_uri(&id, &task.payload).await; - match add_result { + match self.spawner.add_uri(&id, &task.payload).await { Ok(gid) => { let control_guard = self.acquire_aria2_control(&id).await; let cancelled = self.aria2_retry_cancelled.lock().await.contains(&id); @@ -4530,23 +1410,6 @@ impl QueueManager { id, gid ); - if task.payload.is_torrent { - if let Err(error) = self - .reconcile_aria2_torrent_ownership_for_payload( - &id, - &gid, - &task.payload, - ) - .await - { - log::debug!( - "aria2 dispatch cancellation [{}]: could not resolve torrent output paths for gid {}: {}", - id, - gid, - error - ); - } - } if let Err(error) = self.spawner.remove_uri(&gid).await { log::warn!( "aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}", @@ -4556,85 +1419,19 @@ impl QueueManager { ); } self.ignore_aria2_gid(&gid).await; - self.finish_aria2_dispatch(&id, lifecycle_epoch).await; if current_lifecycle { self.clear_aria2_retry_state(&id).await; self.release_permit(&id).await; } - self.clear_aria2_allocation_for_epoch(&id, lifecycle_epoch) - .await; return; } - // A queued task is not a live transfer until aria2 has - // accepted it and Firelink has installed the GID - // mapping. Emitting Downloading before this point - // lets the UI (and a concurrent Properties pause) - // act on a lifecycle that does not yet exist. let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await; - if buffered_outcome.is_none() { - self.emit_state(&id, DownloadStatus::Downloading); - } - let install_web_seeds = buffered_outcome.is_none() - && task.payload.is_torrent - && !task.payload.torrent_verify_only - && task.payload.torrent_web_seeds.is_some(); - self.finish_aria2_dispatch(&id, lifecycle_epoch).await; drop(control_guard); - if install_web_seeds { - if let Err(error) = self.install_initial_torrent_web_seeds(&id).await { - // Initial web-seed installation is a - // post-GID lifecycle step. Reacquire the - // control lock and fence the failure against - // the dispatch epoch before retiring the GID; - // an old install error must never fail a - // newer retry/resume lifecycle. - let _control_guard = self.acquire_aria2_control(&id).await; - let current_gid = self.aria2_gid_for_download(&id); - let current = self - .is_aria2_control_epoch_current(&id, lifecycle_epoch) - .await - && self.is_registered(&id).await - && matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) - && current_gid.as_deref().is_some_and(|gid| { - self.aria2_gid_mapping(gid).is_some_and(|mapping| { - mapping.id == id && mapping.epoch == lifecycle_epoch - }) - }); - if current { - if let Some(current_gid) = current_gid { - if let Err(remove_error) = self.spawner.remove_uri(¤t_gid).await { - log::warn!( - "aria2 dispatch web-seed failure [{}]: could not remove gid {}: {}", - id, - current_gid, - remove_error - ); - } - self.ignore_aria2_gid(¤t_gid).await; - } - self.apply_completion_locked( - &id, - PendingOutcome::Error(format!( - "could not attach Torrent web seeds: {error}" - )), - ) - .await; - } else { - log::info!( - "aria2 dispatch web-seed failure [{}]: ignoring stale install error after a newer lifecycle took ownership", - id - ); - } - return; - } - } if let Some(outcome) = buffered_outcome { - self.handle_aria2_pending_event(&gid, outcome).await; + self.handle_aria2_event(&gid, outcome).await; } } Err(error) => { - self.clear_aria2_allocation_for_epoch(&id, lifecycle_epoch) - .await; let _control_guard = self.acquire_aria2_control(&id).await; let current_lifecycle = self .is_aria2_control_epoch_current(&id, lifecycle_epoch) @@ -4653,7 +1450,6 @@ impl QueueManager { id ); } - self.finish_aria2_dispatch(&id, lifecycle_epoch).await; } } } @@ -4718,51 +1514,16 @@ impl QueueManager { } fn emit_failed(&self, id: &str, error: String) { - self.emit_failed_with_progress(id, error, None); - } - - fn emit_failed_with_progress( - &self, - id: &str, - error: String, - progress: Option, - ) { use tauri::Emitter; - let mut event = DownloadStateEvent::failed(id, error); - if let Some(progress) = progress { - event = event.with_progress(progress); - } let _ = self .app_handle - .emit("download-state", event); - } - - fn emit_paused_with_error_and_progress( - &self, - id: &str, - error: String, - progress: Option, - ) { - use tauri::Emitter; - let mut event = DownloadStateEvent::paused_with_error(id, error); - if let Some(progress) = progress { - event = event.with_progress(progress); - } - let _ = self.app_handle.emit( - "download-state", - event, - ); + .emit("download-state", DownloadStateEvent::failed(id, error)); } /// Store gid -> id and return any buffered terminal event for the caller /// to reconcile against the correct event path. In particular, buffered /// errors must still pass through transient retry classification. - pub async fn remember_gid(&self, id: String, gid: String) -> Option { - // A new GID starts a fresh Aria2 observation. In particular, a - // missing-GID recovery or retry must not inherit the handoff deadline - // of the retired magnet metadata parent. - self.clear_aria2_magnet_handoff(&id).await; - self.aria2_magnet_payload_gids.lock().await.remove(&id); + pub async fn remember_gid(&self, id: String, gid: String) -> Option { let epoch = self.current_aria2_control_epoch(&id).await; let buffered_outcome = { let _gid_state = self.aria2_gid_state.lock().await; @@ -4801,22 +1562,7 @@ impl QueueManager { } buffered.remove(&gid).map(|(_buf_id, outcome)| outcome) }; - let start_buffered = self - .pending_download_starts - .lock() - .await - .remove(&gid); - if start_buffered { - self.clear_aria2_allocation_for_epoch(&id, epoch).await; - } - let retry_strike = self.aria2_retry_strike(&id).await; - log::info!( - "aria2 gid transition [stage=gid_transition id={} gid={} epoch={} retry_strike={} action=mapped]", - id, - gid, - epoch, - retry_strike - ); + log::info!("aria2 gid transition [{}]: mapped {}", id, gid); buffered_outcome } @@ -4825,26 +1571,14 @@ impl QueueManager { /// epoch must not be reused after a pause invalidated that lifecycle. pub async fn rebind_aria2_gid_epoch(&self, id: &str, gid: &str, epoch: u64) -> bool { let _gid_state = self.aria2_gid_state.lock().await; - { - let mut gids = self.aria2_gids.write().unwrap(); - let Some(mapping) = gids.get_mut(gid) else { - return false; - }; - if mapping.id != id { - return false; - } - mapping.epoch = epoch; - } - if let Some(options) = self.aria2_connection_options.lock().await.get_mut(id) { - options.epoch = epoch; - } - if self.aria2_gid_for_download(id).as_deref() == Some(gid) { - if let Some(payload_mapping) = self.aria2_magnet_payload_gids.lock().await.get_mut(id) { - if payload_mapping.id == id { - payload_mapping.epoch = epoch; - } - } + let mut gids = self.aria2_gids.write().unwrap(); + let Some(mapping) = gids.get_mut(gid) else { + return false; + }; + if mapping.id != id { + return false; } + mapping.epoch = epoch; true } @@ -4860,241 +1594,44 @@ impl QueueManager { /// and lets commands reconcile an Aria2 terminal status without releasing /// the lock first. pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) { - self.apply_completion_locked_with_progress(id, outcome, None) - .await; - } - - pub(crate) async fn apply_completion_locked_with_progress( - &self, - id: &str, - outcome: PendingOutcome, - progress: Option, - ) { - self.clear_aria2_magnet_handoff(id).await; - self.clear_aria2_allocation(id).await; - if matches!(&outcome, PendingOutcome::Complete) { - self.capture_torrent_verification_evidence(id).await; - } - let (verification_restore_status, verification_only, verification_observed) = { - let payloads = self.aria2_payloads.lock().await; - payloads - .get(id) - .filter(|payload| payload.torrent_verify_only) - .map(|payload| { - ( - payload.torrent_verify_restore_status.clone(), - true, - payload.torrent_verified_length.is_some(), - ) - }) - .unwrap_or((None, false, false)) - }; - let outcome = match outcome { - PendingOutcome::Complete if verification_only && !verification_observed => { - PendingOutcome::Error( - "Torrent integrity verification did not produce a complete hash-check result" - .to_string(), - ) - } - PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => { - if !self.seed_capacity_enabled() { - // Keep a budget record even when the legacy single-pool - // mode is active. If the user enables separate seed - // capacity while this Torrent is already seeding, the - // existing seeder must be counted before admitting new - // seeders. - self.record_seed_started(id).await; - self.emit_state(id, DownloadStatus::Seeding); - return; - } - match self.admit_seed_after_completion(id).await { - SeedAdmissionOutcome::Seeding => { - self.emit_state(id, DownloadStatus::Seeding); - return; - } - SeedAdmissionOutcome::Waiting => { - let remaining = self - .aria2_payloads - .lock() - .await - .get(id) - .and_then(|payload| payload.torrent_seed_remaining); - self.emit_waiting_to_seed(id, remaining); - return; - } - SeedAdmissionOutcome::Complete => PendingOutcome::Complete, - } - } - // `onBtDownloadComplete` means that Aria2 is still seeding. If - // Firelink has no seeding policy for this payload, reconcile it - // as terminal so a daemon-side default or stale event cannot - // strand the GID and permit. - PendingOutcome::Seeding => PendingOutcome::Complete, - other => other, - }; - if let Some(gid) = self.aria2_gid_for_download(id) { - if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await { - log::debug!( - "aria2 torrent ownership [{}]: could not resolve files for gid {}: {}", - id, - gid, - error - ); - } - } // A terminal event invalidates every delayed retry or control worker // from the previous lifecycle before releasing its permit. self.next_aria2_control_epoch(id).await; self.cancel_aria2_retries(id).await; - let torrent_removal_requested = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file); match outcome { PendingOutcome::Complete => { - self.forget_torrent_telemetry(id).await; self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; - if torrent_removal_requested { - match crate::download_ownership::clear_torrent_removal_paths_if_absent( - &self.app_handle, - id, - ) { - Ok(true) => {} - Ok(false) => { - log::warn!( - "aria2 torrent removal reservation [{}]: keeping paths reserved because Aria2 cleanup was not observed", - id - ); - } - Err(error) => { - log::warn!( - "aria2 torrent removal reservation [{}]: could not verify cleanup: {}", - id, - error - ); - } - } - } self.release_registered_id(id).await; self.release_permit(id).await; - let restored_status = if verification_only { - if verification_restore_status.as_deref() == Some("completed") { - DownloadStatus::Completed - } else { - DownloadStatus::Paused - } - } else { - match verification_restore_status.as_deref() { - Some("paused") => DownloadStatus::Paused, - Some("failed") => DownloadStatus::Failed, - Some("ready") => DownloadStatus::Ready, - Some("staged") => DownloadStatus::Staged, - Some("completed") => DownloadStatus::Completed, - _ => DownloadStatus::Completed, - } - }; - let mut event = DownloadStateEvent::new(id, restored_status); - if let Some(progress) = progress { - event = event.with_progress(progress); - } - use tauri::Emitter; - let _ = self.app_handle.emit("download-state", event); + self.emit_state(id, DownloadStatus::Completed); } PendingOutcome::Error(error) => { - self.forget_torrent_telemetry(id).await; - let terminal_gid = self - .aria2_gid_for_download(id) - .unwrap_or_else(|| "".to_string()); - let terminal_epoch = self - .aria2_gid_for_download(id) - .and_then(|gid| self.aria2_gid_mapping(&gid).map(|mapping| mapping.epoch)) - .unwrap_or(self.current_aria2_control_epoch(id).await); - let retry_strike = self - .aria2_retry_strikes - .lock() - .await - .get(id) - .copied() - .unwrap_or_default(); - let requested_connections = self - .aria2_requested_connections(id) - .await - .unwrap_or(DOWNLOAD_CONNECTIONS_MIN); - let effective_connections = self - .aria2_effective_connections(id, terminal_epoch) - .await - .unwrap_or(requested_connections); - if !verification_only && error.to_ascii_lowercase().contains("checksum") { + if error.to_ascii_lowercase().contains("checksum") { log::warn!("Checksum error detected for {}, cleaning up assets", id); - if let Ok(paths) = - crate::download_ownership::owned_paths_for_id(&self.app_handle, id) + if let Ok(primary_path) = + crate::download_ownership::primary_path_for_id(&self.app_handle, id) { - for path in paths { - let _ = crate::remove_download_assets(&path, &self.app_handle).await; + if let Some(path) = primary_path.as_deref() { + let _ = crate::remove_download_assets(path, &self.app_handle).await; } } } - let error = if verification_only { - format!( - "Torrent integrity verification failed; resume the Torrent to repair it: {error}" - ) - } else { - error - }; + log::error!("aria2 download {} failed: {}", id, error); self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; - if torrent_removal_requested { - match crate::download_ownership::clear_torrent_removal_paths_if_absent( - &self.app_handle, - id, - ) { - Ok(true) => {} - Ok(false) => log::warn!( - "aria2 torrent removal reservation [{}]: keeping paths reserved because cleanup was not observed after failure", - id - ), - Err(clear_error) => log::warn!( - "aria2 torrent removal reservation [{}]: could not verify cleanup after terminal failure: {}", - id, - clear_error - ), - } - } self.release_registered_id(id).await; self.release_permit(id).await; - if verification_only { - self.emit_paused_with_error_and_progress(id, error, progress); - } else { - let aria2_code = aria2_error_code(&error).unwrap_or_else(|| "none".to_string()); - log::error!( - "aria2 terminal [stage=terminal id={} gid={} epoch={} retry_strike={} requested_connections={} effective_connections={} error_class={} aria2_error_code={}]", - id, - terminal_gid, - terminal_epoch, - retry_strike, - requested_connections, - effective_connections, - network_error_class(&error), - aria2_code - ); - self.emit_failed_with_progress(id, error, progress); - } + self.emit_failed(id, error); } - PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"), } } pub async fn clear_aria2_retry_state(&self, id: &str) { - self.clear_aria2_magnet_handoff(id).await; - self.aria2_magnet_payload_gids.lock().await.remove(id); self.aria2_payloads.lock().await.remove(id); self.aria2_retry_strikes.lock().await.remove(id); - self.aria2_connection_options.lock().await.remove(id); } pub async fn cancel_aria2_retries(&self, id: &str) { @@ -5117,295 +1654,6 @@ impl QueueManager { self.aria2_retry_strikes.lock().await.remove(id); } - async fn note_aria2_magnet_handoff( - &self, - id: &str, - parent_gid: &str, - epoch: u64, - ) -> bool { - let mut handoffs = self.aria2_magnet_handoffs.lock().await; - let state = handoffs - .entry(id.to_string()) - .or_insert_with(|| Aria2MagnetHandoffState { - parent_gid: parent_gid.to_string(), - epoch, - started_at: Instant::now(), - }); - if state.parent_gid != parent_gid || state.epoch != epoch { - *state = Aria2MagnetHandoffState { - parent_gid: parent_gid.to_string(), - epoch, - started_at: Instant::now(), - }; - } - state.started_at.elapsed() >= ARIA2_MAGNET_CHILD_HANDOFF_TIMEOUT - } - - async fn clear_aria2_magnet_handoff(&self, id: &str) { - self.aria2_magnet_handoffs.lock().await.remove(id); - } - - async fn remove_aria2_magnet_parent_result(&self, gid: &str) { - let Some(state) = self.app_handle.try_state::() else { - return; - }; - let port = state - .aria2_port - .load(std::sync::atomic::Ordering::Relaxed); - let secret = state.aria2_secret.clone(); - drop(state); - for attempt in 1..=3 { - match crate::rpc_call( - port, - &secret, - "aria2.removeDownloadResult", - serde_json::json!([gid]), - ) - .await - { - Ok(result) if result.as_str() == Some(gid) => return, - Ok(result) => { - log::debug!( - "aria2 magnet handoff [stage=cleanup gid={} attempt={} result=unexpected_return value={}]", - gid, - attempt, - crate::redact_sensitive_text(&result.to_string()) - ); - return; - } - Err(_error) if attempt < 3 => { - tokio::time::sleep(Duration::from_millis(50 * attempt as u64)).await; - } - Err(error) => log::debug!( - "aria2 magnet handoff [stage=cleanup gid={} attempts={} result=deferred error_class={} error_code={}]", - gid, - attempt, - network_error_class(&error), - diagnostic_error_code(&error) - ), - } - } - } - - /// Aria2 represents a direct magnet as a short metadata parent lifecycle - /// followed by a real payload child GID. Firelink owns one download ID, - /// permit, and lifecycle epoch, so completion of the metadata parent must - /// be converted into an atomic mapping transfer before terminal handling. - async fn reconcile_aria2_magnet_parent_completion( - &self, - id: &str, - parent_gid: &str, - expected_mapping: &Aria2GidMapping, - ) -> Aria2MagnetCompletionDisposition { - let payload = self.aria2_payloads.lock().await.get(id).cloned(); - let Some(_) = payload.filter(is_direct_magnet_payload) else { - return Aria2MagnetCompletionDisposition::NotMetadataParent; - }; - if !self.is_current_aria2_gid_mapping(parent_gid, expected_mapping) - || !self - .is_aria2_control_epoch_current(id, expected_mapping.epoch) - .await - { - return Aria2MagnetCompletionDisposition::Deferred; - } - - // A child adopted by this lifecycle is already known to be the real - // payload. Avoid asking Aria2 for its complete file list just to prove - // that a terminal child is not the short metadata parent. - let adopted_payload = self - .aria2_magnet_payload_gids - .lock() - .await - .get(id) - .is_some_and(|mapping| { - mapping == expected_mapping - && self.aria2_gid_for_download(id).as_deref() == Some(parent_gid) - }); - if adopted_payload { - return Aria2MagnetCompletionDisposition::NotMetadataParent; - } - - let handoff_expired = self - .note_aria2_magnet_handoff(id, parent_gid, expected_mapping.epoch) - .await; - let Some(state) = self.app_handle.try_state::() else { - return Aria2MagnetCompletionDisposition::Deferred; - }; - let port = state - .aria2_port - .load(std::sync::atomic::Ordering::Relaxed); - let secret = state.aria2_secret.clone(); - drop(state); - - let parent_status = match crate::rpc_call( - port, - &secret, - "aria2.tellStatus", - serde_json::json!([parent_gid, ["status", "followedBy", "files", "errorCode", "errorMessage"]]), - ) - .await - { - Ok(status) => status, - Err(error) => { - log::debug!( - "aria2 magnet handoff [stage=parent_status id={} gid={} epoch={} result=unavailable error_class={} error_code={}]", - id, - parent_gid, - expected_mapping.epoch, - network_error_class(&error), - diagnostic_error_code(&error) - ); - return if handoff_expired { - Aria2MagnetCompletionDisposition::Failed( - "Aria2 completed magnet metadata but did not expose the payload download" - .to_string(), - ) - } else { - Aria2MagnetCompletionDisposition::Deferred - }; - } - }; - let parent_status_name = parent_status - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let parent_has_metadata = aria2_magnet_parent_has_metadata_file(&parent_status); - let parent_has_payload = aria2_magnet_parent_has_payload_file(&parent_status); - let child_gids = aria2_magnet_followed_gids(&parent_status, parent_gid); - let child_count = child_gids.len(); - - // Aria2 can expose more than one followed GID. Query the bounded set - // concurrently so a missing or slow child cannot hold the per-download - // control lock while every 3-second RPC timeout is consumed in series. - let child_statuses = futures_util::future::join_all(child_gids.into_iter().map(|child_gid| async { - let result = crate::rpc_call( - port, - &secret, - "aria2.tellStatus", - serde_json::json!([child_gid, [ - "status", - "belongsTo", - "errorCode", - "errorMessage", - "completedLength", - "totalLength", - "uploadLength", - "seeder" - ]]), - ) - .await; - match result { - Ok(status) => (child_gid, Some(status)), - Err(error) => { - log::debug!( - "aria2 magnet handoff [stage=child_status id={} parent_gid={} child_gid={} epoch={} result=unavailable error_class={} error_code={}]", - id, - parent_gid, - child_gid, - expected_mapping.epoch, - network_error_class(&error), - diagnostic_error_code(&error) - ); - (child_gid, None) - } - } - })).await; - - for (child_gid, child_status) in child_statuses { - let Some(child_status) = child_status else { - continue; - }; - if child_status - .get("belongsTo") - .and_then(serde_json::Value::as_str) - .is_some_and(|belongs_to| belongs_to != parent_gid) - { - continue; - } - let child_status_name = child_status - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if !matches!( - child_status_name, - "active" | "waiting" | "paused" | "complete" | "error" | "removed" - ) { - continue; - } - - let pending_child = { - let _gid_state = self.aria2_gid_state.lock().await; - let current_mapping = self.aria2_gid_mapping(parent_gid); - if current_mapping.as_ref() != Some(expected_mapping) { - return Aria2MagnetCompletionDisposition::Deferred; - } - let child_owned_by_other_download = self - .aria2_gid_mapping(&child_gid) - .is_some_and(|mapping| mapping.id != id); - if child_owned_by_other_download { - continue; - } - { - let mut gids = self.aria2_gids.write().unwrap(); - gids.remove(parent_gid); - gids.insert(child_gid.clone(), expected_mapping.clone()); - } - self.aria2_magnet_payload_gids - .lock() - .await - .insert(id.to_string(), expected_mapping.clone()); - self.unignore_aria2_gid_locked(&child_gid).await; - self.ignore_aria2_gid_locked(parent_gid).await; - self.pending_download_starts.lock().await.remove(&child_gid); - let mut buffered = self.pending_completion.lock().await; - buffered.remove(parent_gid); - buffered.remove(&child_gid).map(|(_buffered_id, outcome)| outcome) - }; - - self.clear_aria2_magnet_handoff(id).await; - log::info!( - "aria2 magnet handoff [stage=gid_transition id={} parent_gid={} child_gid={} epoch={} child_status={} action=adopted]", - id, - parent_gid, - child_gid, - expected_mapping.epoch, - child_status_name - ); - return Aria2MagnetCompletionDisposition::Adopted(Aria2MagnetChildHandoff { - gid: child_gid, - status: child_status_name.to_string(), - error: matches!(child_status_name, "error" | "removed") - .then(|| aria2_magnet_status_error(&child_status)), - pending: pending_child, - }); - } - - if matches!(parent_status_name, "error" | "removed") { - self.clear_aria2_magnet_handoff(id).await; - return Aria2MagnetCompletionDisposition::Failed(aria2_magnet_status_error( - &parent_status, - )); - } - if handoff_expired { - Aria2MagnetCompletionDisposition::Failed( - "Aria2 completed magnet metadata but did not expose the payload download" - .to_string(), - ) - } else { - log::debug!( - "aria2 magnet handoff [stage=child_wait id={} gid={} epoch={} status={} child_count={} parent_metadata={} parent_payload={} action=deferred]", - id, - parent_gid, - expected_mapping.epoch, - parent_status_name, - child_count, - parent_has_metadata, - parent_has_payload - ); - Aria2MagnetCompletionDisposition::Deferred - } - } - async fn finish_aria2_retry(&self, id: &str, gid: &str, retry_epoch: u64) { self.release_aria2_retry_inflight(id, retry_epoch).await; self.aria2_retrying_gids.lock().await.remove(gid); @@ -5438,23 +1686,21 @@ impl QueueManager { } Err(error) if attempt < MAX_ATTEMPTS => { log::warn!( - "aria2 lifecycle cleanup [stage=cleanup id={} gid={} attempt={} result=retrying error_class={} error_code={}]", + "aria2 lifecycle cleanup [{}]: failed to remove stale replacement gid {} on attempt {}: {}; retrying", id, gid, attempt, - network_error_class(&error), - diagnostic_error_code(&error) + error ); tokio::time::sleep(Duration::from_millis(100)).await; } Err(error) => { log::error!( - "aria2 lifecycle cleanup [stage=cleanup id={} gid={} attempts={} result=failed error_class={} error_code={}]", + "aria2 lifecycle cleanup [{}]: stale replacement gid {} could not be removed after {} attempts: {}", id, gid, MAX_ATTEMPTS, - network_error_class(&error), - diagnostic_error_code(&error) + error ); } } @@ -5463,7 +1709,6 @@ impl QueueManager { async fn ignore_aria2_gid_locked(&self, gid: &str) { const MAX_IGNORED_GIDS: usize = 1024; - self.pending_download_starts.lock().await.remove(gid); let mut ignored = self.aria2_ignored_gids.lock().await; if !ignored.iter().any(|known| known == gid) { ignored.push_back(gid.to_string()); @@ -5496,160 +1741,6 @@ impl QueueManager { .find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone())) } - async fn begin_aria2_dispatch(&self, id: &str, epoch: u64) { - self.aria2_dispatch_inflight - .lock() - .await - .entry(id.to_string()) - .or_default() - .insert(epoch); - } - - async fn finish_aria2_dispatch(&self, id: &str, epoch: u64) { - let mut inflight = self.aria2_dispatch_inflight.lock().await; - let removed = if let Some(epochs) = inflight.get_mut(id) { - let removed = epochs.remove(&epoch); - if epochs.is_empty() { - inflight.remove(id); - } - removed - } else { - false - }; - drop(inflight); - if removed { - self.aria2_dispatch_notify.notify_waiters(); - } - } - - pub async fn wait_for_aria2_dispatch(&self, id: &str) { - loop { - let notified = self.aria2_dispatch_notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - if !self - .aria2_dispatch_inflight - .lock() - .await - .contains_key(id) - { - return; - } - notified.await; - } - } - - async fn reconcile_aria2_torrent_ownership_for_payload( - &self, - id: &str, - gid: &str, - payload: &SpawnPayload, - ) -> Result<(), String> { - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getFiles", - serde_json::json!([gid]), - ) - .await?; - self.persist_aria2_torrent_ownership(id, payload, result) - } - - fn persist_aria2_torrent_ownership( - &self, - id: &str, - payload: &SpawnPayload, - result: serde_json::Value, - ) -> Result<(), String> { - let paths = result - .as_array() - .into_iter() - .flatten() - .filter(|file| file.get("selected").and_then(|value| value.as_str()) != Some("false")) - .filter_map(|file| file.get("path").and_then(|value| value.as_str())) - .map(std::path::PathBuf::from) - .collect::>(); - if paths.is_empty() { - return Err("aria2 returned no selected torrent output paths".to_string()); - } - let destination = crate::resolve_path(&payload.destination, &self.app_handle); - let canonical_destination = crate::canonicalize_with_missing_components(&destination) - .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; - if paths.iter().any(|path| { - crate::canonicalize_with_missing_components(path) - .is_none_or(|path| !crate::platform::path_is_within(&path, &canonical_destination)) - }) { - return Err("aria2 returned a torrent output path outside its destination".to_string()); - } - let primary = if paths.len() == 1 { - paths[0].clone() - } else { - let canonical_paths = paths - .iter() - .filter_map(|path| crate::canonicalize_with_missing_components(path)) - .collect::>(); - let mut common = canonical_paths - .first() - .and_then(|path| path.parent()) - .map(std::path::Path::to_path_buf) - .ok_or_else(|| "torrent output path has no parent".to_string())?; - for path in canonical_paths.iter().skip(1) { - while !crate::platform::path_is_within(path, &common) { - let Some(parent) = common.parent() else { - return Err("torrent output paths have no common directory".to_string()); - }; - if parent == common { - return Err("torrent output paths have no common directory".to_string()); - } - common = parent.to_path_buf(); - } - } - common - }; - crate::download_ownership::set_owned_paths_with_primary( - &self.app_handle, - id, - &primary, - &paths, - ) - } - - /// Refresh ownership from Aria2's resolved file list before a torrent - /// lifecycle is forgotten. Magnet metadata is not available when the row - /// is enqueued, so the user-provided display name is not a safe ownership - /// path until Aria2 reports the actual files. - pub async fn reconcile_aria2_torrent_ownership( - &self, - id: &str, - gid: &str, - ) -> Result<(), String> { - let payload = self.aria2_payloads.lock().await.get(id).cloned(); - let Some(payload) = payload.filter(|payload| payload.is_torrent) else { - return Ok(()); - }; - let mapping = self - .aria2_gid_mapping(gid) - .filter(|mapping| mapping.id == id) - .ok_or_else(|| "aria2 torrent ownership has no current gid mapping".to_string())?; - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getFiles", - serde_json::json!([gid]), - ) - .await?; - if !self.is_current_aria2_gid_mapping(gid, &mapping) - || !self - .is_aria2_control_epoch_current(id, mapping.epoch) - .await - { - return Err("aria2 torrent lifecycle changed while resolving output paths".to_string()); - } - self.persist_aria2_torrent_ownership(id, &payload, result) - } - /// Capture the GID ownership token used by the poller. The mapping's /// epoch must still be current when an asynchronous status snapshot is /// emitted; otherwise a late response from an older GID can be attributed @@ -5666,41 +1757,6 @@ impl QueueManager { self.aria2_gid_mapping(gid).as_ref() == Some(expected) } - /// A direct magnet's metadata parent is not a terminal payload GID. The - /// parent may report `complete` before Aria2 exposes the followed child, - /// so imperative pause/resume commands must defer to the same handoff - /// reconciler as WebSocket and poller events instead of completing or - /// re-enqueueing the metadata marker. - pub(crate) async fn aria2_direct_magnet_needs_child_handoff( - &self, - id: &str, - gid: &str, - ) -> bool { - let direct_magnet = self - .aria2_payloads - .lock() - .await - .get(id) - .is_some_and(is_direct_magnet_payload); - if !direct_magnet { - return false; - } - - let Some(mapping) = self.aria2_gid_mapping(gid) else { - return false; - }; - if mapping.id != id || self.aria2_gid_for_download(id).as_deref() != Some(gid) { - return false; - } - - !self - .aria2_magnet_payload_gids - .lock() - .await - .get(id) - .is_some_and(|payload_mapping| payload_mapping == &mapping) - } - pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> { self.aria2_gids .read() @@ -5731,20 +1787,7 @@ impl QueueManager { let payload = self.aria2_payloads.lock().await.get(id).cloned(); let recreation = if let Some(payload) = payload.as_ref() { - let lifecycle_generation = self - .registered_lifecycle_generation(id) - .await - .unwrap_or_default(); - self.begin_aria2_allocation(id, observed_epoch, lifecycle_generation, payload) - .await; - match self.spawner.recreate_uri(id, gid, payload).await { - Ok(outcome) => outcome, - Err(error) => { - self.clear_aria2_allocation_for_epoch(id, observed_epoch) - .await; - return Err(error); - } - } + self.spawner.recreate_uri(id, gid, payload).await? } else { // Older persisted rows may briefly reach recovery before their // payload has been rebuilt. Keep the current lifecycle intact and @@ -5754,8 +1797,6 @@ impl QueueManager { if let Aria2RecreateOutcome::NewGid(new_gid) = recreation { if new_gid.trim().is_empty() || new_gid == gid { - self.clear_aria2_allocation_for_epoch(id, observed_epoch) - .await; return Err(format!( "aria2 connection recovery returned an invalid replacement gid for {gid}" )); @@ -5766,8 +1807,6 @@ impl QueueManager { && self.is_aria2_control_epoch_current(id, observed_epoch).await && self.aria2_gid_for_download(id).as_deref() == Some(gid); if !still_current { - self.clear_aria2_allocation_for_epoch(id, observed_epoch) - .await; self.ignore_aria2_gid(&new_gid).await; drop(_control_guard); self.remove_stale_aria2_gid(id, &new_gid).await; @@ -5788,24 +1827,15 @@ impl QueueManager { ); drop(_control_guard); if let Some(outcome) = buffered_outcome { - self.handle_aria2_pending_event(&new_gid, outcome).await; + self.handle_aria2_event(&new_gid, outcome).await; } return Ok(()); } let outcome = match recreation { Aria2RecreateOutcome::Complete => Aria2RefreshOutcome::Complete, - Aria2RecreateOutcome::Refresh => match self.spawner.refresh_uri(gid).await { - Ok(outcome) => outcome, - Err(error) => { - self.clear_aria2_allocation_for_epoch(id, observed_epoch) - .await; - return Err(error); - } - }, + Aria2RecreateOutcome::Refresh => self.spawner.refresh_uri(gid).await?, Aria2RecreateOutcome::Unavailable(error) => { - self.clear_aria2_allocation_for_epoch(id, observed_epoch) - .await; let still_current = self.is_registered(id).await && self.has_active_permit(id).await && !self.is_aria2_retry_cancelled(id).await @@ -5823,11 +1853,10 @@ impl QueueManager { self.release_permit(id).await; self.emit_state(id, DownloadStatus::Paused); log::warn!( - "aria2 connection recovery [stage=recovery id={} gid={} result=paused_unavailable error_class={} error_code={}]", + "aria2 connection recovery [{}]: replacement job unavailable; retired stale gid {} and paused the download: {}", id, gid, - network_error_class(&error), - diagnostic_error_code(&error) + error ); return Ok(()); } @@ -5876,9 +1905,6 @@ impl QueueManager { /// Remove every gid mapping for a download and discard buffered terminal /// events for those gids. Returns the most recently encountered gid. pub async fn forget_aria2_gid(&self, id: &str) -> Option { - self.clear_aria2_magnet_handoff(id).await; - self.aria2_magnet_payload_gids.lock().await.remove(id); - self.clear_aria2_allocation(id).await; let _gid_state = self.aria2_gid_state.lock().await; let removed = { let mut gids = self.aria2_gids.write().unwrap(); @@ -5916,12 +1942,10 @@ impl QueueManager { self: &Arc, gid: String, error: String, - progress: Option, ) -> Pin + Send + 'static>> { let this = Arc::clone(self); Box::pin(async move { - this.handle_aria2_download_error_inner(&gid, error, progress) - .await; + this.handle_aria2_download_error_inner(&gid, error).await; }) } @@ -5931,8 +1955,8 @@ impl QueueManager { async fn map_or_buffer_aria2_event( &self, gid: &str, - outcome: PendingAria2Outcome, - ) -> Option<(Aria2GidMapping, PendingAria2Outcome)> { + outcome: PendingOutcome, + ) -> Option<(Aria2GidMapping, PendingOutcome)> { let _gid_state = self.aria2_gid_state.lock().await; if self.is_aria2_gid_ignored_locked(gid).await { return None; @@ -5951,31 +1975,13 @@ impl QueueManager { None } - async fn handle_aria2_download_error_inner( - self: &Arc, - gid: &str, - error: String, - progress: Option, - ) { - let Some((mapping, pending)) = self - .map_or_buffer_aria2_event( - gid, - PendingAria2Outcome { - outcome: PendingOutcome::Error(error), - progress, - }, - ) + async fn handle_aria2_download_error_inner(self: &Arc, gid: &str, error: String) { + let Some((mapping, PendingOutcome::Error(error))) = self + .map_or_buffer_aria2_event(gid, PendingOutcome::Error(error)) .await else { return; }; - let PendingAria2Outcome { - outcome: PendingOutcome::Error(error), - progress, - } = pending - else { - return; - }; let _control_guard = self.acquire_aria2_control(&mapping.id).await; let current_mapping = { @@ -5992,11 +1998,6 @@ impl QueueManager { return; } let id = mapping.id; - // The failed GID is no longer allocating. Keep the native phase - // marker scoped to an actual replacement addUri rather than showing - // "Allocating files" throughout retry backoff or a failed pause. - self.clear_aria2_allocation_for_epoch(&id, mapping.epoch) - .await; if self.aria2_retry_cancelled.lock().await.contains(&id) { log::info!( "aria2 retry cancellation [{}]: ignoring error for gid {} during removal", @@ -6025,11 +2026,7 @@ impl QueueManager { let payload = self.aria2_payloads.lock().await.get(&id).cloned(); if payload.is_none() { - self.apply_completion_locked_with_progress( - &id, - PendingOutcome::Error(error), - progress, - ) + self.apply_completion_locked(&id, PendingOutcome::Error(error)) .await; return; } @@ -6041,51 +2038,10 @@ impl QueueManager { *entry }; - let retry_action = aria2_retry_action( - &payload, - &error, - strike, - self.aria2_system_resolver_fallback_available(), - ); - let resolver_fallback = retry_action == Aria2RetryAction::SystemResolverFallback; - let requested_connections = self - .aria2_requested_connections(&id) - .await - .unwrap_or(DOWNLOAD_CONNECTIONS_MIN); - let effective_connections = self - .aria2_effective_connections(&id, mapping.epoch) - .await - .unwrap_or(requested_connections); - let action = match retry_action { - Aria2RetryAction::SystemResolverFallback => "system_resolver_fallback", - Aria2RetryAction::OrdinaryRetry => "ordinary_retry", - Aria2RetryAction::Terminal => "terminal", - }; - let error_code = aria2_error_code(&error) - .unwrap_or_else(|| network_error_class(&error).to_string()); - log::warn!( - "aria2 retry [stage=retry id={} gid={} epoch={} retry_strike={} action={} resolver_mode={} error_class={} error_code={} requested_connections={} effective_connections={}]", - id, - gid, - mapping.epoch, - strike, - action, - aria2_effective_resolver_mode(&payload), - network_error_class(&error), - error_code, - requested_connections, - effective_connections - ); - // Switching resolver strategy is a bounded transfer repair, not an - // ordinary retry. It must still run when the user configured zero - // automatic retries, while every later failure follows the normal - // retry budget and never switches back to the first strategy. - if retry_action == Aria2RetryAction::Terminal { - self.apply_completion_locked_with_progress( - &id, - PendingOutcome::Error(error), - progress, - ) + let transient = is_retryable_aria2_error(&error); + let strikes_left = strike < automatic_retry_limit(payload.max_tries); + if !(transient && strikes_left) { + self.apply_completion_locked(&id, PendingOutcome::Error(error)) .await; return; } @@ -6124,18 +2080,9 @@ impl QueueManager { .insert(id.clone(), payload.clone()); } - if resolver_fallback { - payload.aria2_resolver_mode = Aria2ResolverMode::System; - self.aria2_payloads - .lock() - .await - .insert(id.clone(), payload.clone()); - } - let this = Arc::clone(self); let id_for_task = id.clone(); let error_for_emit = error.clone(); - let progress_for_retry = progress.clone(); tauri::async_runtime::spawn(async move { let retry_cancel = async { loop { @@ -6153,12 +2100,10 @@ impl QueueManager { }; let outcome = backoff_and_emit(strike, error_for_emit, retry_cancel, |reason| { use tauri::Emitter; - let event = if resolver_fallback { - DownloadStateEvent::retrying_with_resolver_fallback(&id_for_task, reason) - } else { - DownloadStateEvent::retrying(&id_for_task, reason) - }; - let _ = this.app_handle.emit("download-state", event); + let _ = this.app_handle.emit( + "download-state", + DownloadStateEvent::retrying(&id_for_task, reason), + ); }) .await; @@ -6194,17 +2139,6 @@ impl QueueManager { return; } - let lifecycle_generation = this - .registered_lifecycle_generation(&id_for_task) - .await - .unwrap_or_default(); - this.begin_aria2_allocation( - &id_for_task, - retry_epoch, - lifecycle_generation, - ¤t_payload, - ) - .await; match this .spawner .add_uri(&id_for_task, ¤t_payload) @@ -6219,16 +2153,13 @@ impl QueueManager { || this.aria2_gid_for_download(&id_for_task).as_deref() != Some(retry_gid.as_str()); if stale { - this.clear_aria2_allocation_for_epoch(&id_for_task, retry_epoch) - .await; drop(control_guard); if let Err(error) = this.spawner.remove_uri(&new_gid).await { log::error!( - "aria2 retry cancellation [stage=cleanup id={} gid={} result=failed error_class={} error_code={}]", + "aria2 retry cancellation [{}]: failed to remove late gid {}: {}", id_for_task, new_gid, - network_error_class(&error), - diagnostic_error_code(&error) + error ); } else { log::info!( @@ -6241,46 +2172,35 @@ impl QueueManager { .await; return; } - if !resolver_fallback { - this.aria2_retry_strikes - .lock() - .await - .insert(id_for_task.clone(), strike + 1); - } - let new_gid_for_event = new_gid.clone(); - let buffered_outcome = this.remember_gid(id_for_task.clone(), new_gid).await; - // Install the replacement GID before exposing the - // Downloading state. If Aria2 completed or failed the - // replacement before the mapping was installed, apply - // that buffered terminal outcome directly and never - // publish a stale transient state. - if buffered_outcome.is_none() { - this.emit_state(&id_for_task, DownloadStatus::Downloading); - } - // The old gid remains marked as retrying until the new - // mapping is installed, so a duplicate old event is - // ignored while a genuine new-gid event is accepted. + this.aria2_retry_strikes + .lock() + .await + .insert(id_for_task.clone(), strike + 1); + this.emit_state(&id_for_task, DownloadStatus::Downloading); + // Stop suppressing events for the id before exposing the + // new gid. The old gid remains marked as retrying until + // remember_gid atomically replaces its mapping, so a + // duplicate old event is still ignored while a genuine + // new-gid error is allowed through. this.release_aria2_retry_inflight(&id_for_task, retry_epoch) .await; + let new_gid_for_event = new_gid.clone(); + let buffered_outcome = this.remember_gid(id_for_task.clone(), new_gid).await; this.aria2_retrying_gids.lock().await.remove(&retry_gid); drop(control_guard); if let Some(outcome) = buffered_outcome { - this.handle_aria2_pending_event(&new_gid_for_event, outcome) - .await; + this.handle_aria2_event(&new_gid_for_event, outcome).await; } } Err(retry_error) => { - this.clear_aria2_allocation_for_epoch(&id_for_task, retry_epoch) - .await; let stale = this.is_aria2_retry_cancelled(&id_for_task).await || !this .is_aria2_control_epoch_current(&id_for_task, retry_epoch) .await; if !stale { - this.apply_completion_locked_with_progress( + this.apply_completion_locked( &id_for_task, PendingOutcome::Error(retry_error), - progress_for_retry, ) .await; } @@ -6295,44 +2215,14 @@ impl QueueManager { /// Entry point for the aria2 WS poller. Resolves gid -> id; if not yet /// stored, buffers the outcome for reconciliation by remember_gid. pub async fn handle_aria2_event(self: &Arc, gid: &str, outcome: PendingOutcome) { - self.handle_aria2_pending_event(gid, PendingAria2Outcome::new(outcome)) - .await; - } - - pub async fn handle_aria2_event_with_progress( - self: &Arc, - gid: &str, - outcome: PendingOutcome, - progress: Option, - ) { - self.handle_aria2_pending_event( - gid, - PendingAria2Outcome { outcome, progress }, - ) - .await; - } - - async fn handle_aria2_pending_event( - self: &Arc, - gid: &str, - pending: PendingAria2Outcome, - ) { - let PendingAria2Outcome { outcome, progress } = pending; if let PendingOutcome::Error(error) = outcome { - self.handle_aria2_download_error(gid.to_string(), error, progress) + self.handle_aria2_download_error(gid.to_string(), error) .await; return; } - let Some((mapping, pending)) = self - .map_or_buffer_aria2_event( - gid, - PendingAria2Outcome { outcome, progress }, - ) - .await - else { + let Some((mapping, outcome)) = self.map_or_buffer_aria2_event(gid, outcome).await else { return; }; - let PendingAria2Outcome { outcome, progress } = pending; let _control_guard = self.acquire_aria2_control(&mapping.id).await; if self.aria2_retrying_gids.lock().await.contains(gid) { @@ -6351,79 +2241,7 @@ impl QueueManager { { return; } - - if matches!(&outcome, PendingOutcome::Complete) { - match self - .reconcile_aria2_magnet_parent_completion(&mapping.id, gid, &mapping) - .await - { - Aria2MagnetCompletionDisposition::NotMetadataParent => {} - Aria2MagnetCompletionDisposition::Deferred => { - // A metadata parent is not the user's payload. Keep the - // current permit and mapping alive until the next - // poll/reconnect observation exposes the child GID. - self.emit_state(&mapping.id, DownloadStatus::Downloading); - return; - } - Aria2MagnetCompletionDisposition::Failed(error) => { - self.apply_completion_locked_with_progress( - &mapping.id, - PendingOutcome::Error(error), - progress, - ) - .await; - return; - } - Aria2MagnetCompletionDisposition::Adopted(child) => { - let id = mapping.id.clone(); - drop(_control_guard); - // Parent-result cleanup is best effort and may consume - // several bounded RPC attempts. Do it after releasing - // the per-download control lock so a stale daemon result - // cannot block pause, resume, or removal of the adopted - // payload lifecycle. - self.remove_aria2_magnet_parent_result(gid).await; - if let Some(pending) = child.pending { - Box::pin(self.handle_aria2_pending_event(&child.gid, pending)).await; - } else { - match child.status.as_str() { - "complete" => { - Box::pin(self.handle_aria2_event(&child.gid, PendingOutcome::Complete)) - .await; - } - "error" | "removed" => { - Box::pin(self.handle_aria2_event( - &child.gid, - PendingOutcome::Error(child.error.unwrap_or_else(|| { - "aria2 magnet payload child ended unexpectedly".to_string() - })), - )) - .await; - } - "paused" => { - // The child may have been paused by Aria2 - // while the metadata-parent event was in - // flight. Mirror the normal pause boundary so - // this handoff cannot strand a queue permit. - self.clear_aria2_allocation(&id).await; - self.next_aria2_control_epoch(&id).await; - self.cancel_aria2_retries(&id).await; - self.release_seed_tracking(&id); - self.release_permit(&id).await; - self.emit_state(&id, DownloadStatus::Paused); - } - "active" | "waiting" => { - self.emit_state(&id, DownloadStatus::Downloading) - } - _ => {} - } - } - return; - } - } - } - self.apply_completion_locked_with_progress(&mapping.id, outcome, progress) - .await; + self.apply_completion_locked(&mapping.id, outcome).await; } /// Reorder a pending task up or down. Returns the new pending order. @@ -6449,7 +2267,6 @@ impl QueueManager { queue_id: &str, direction: QueueDirection, ) -> Vec { - let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let queue_positions = pending .iter() @@ -6513,7 +2330,6 @@ impl QueueManager { queue_id: &str, target_index: usize, ) -> Vec { - let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let queue_positions = pending .iter() @@ -6542,7 +2358,6 @@ impl QueueManager { /// Does NOT release a permit (the caller handles active permits via /// release_permit if the task was already dispatched). pub async fn remove_from_pending(&self, id: &str) -> bool { - let _admission_gate = self.admission_gate.lock().await; let mut pending = self.pending.lock().await; let before = pending.len(); pending.retain(|t| t.id != id); @@ -6552,34 +2367,12 @@ impl QueueManager { } removed } - - pub async fn remove_from_pending_for_generation(&self, id: &str, generation: u64) -> bool { - let _admission_gate = self.admission_gate.lock().await; - let mut pending = self.pending.lock().await; - let before = pending.len(); - pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation)); - let removed = pending.len() < before; - if removed { - self.notify.notify_one(); - } - removed - } } fn automatic_retry_limit(max_tries: Option) -> usize { max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize } -fn torrent_seeding_requested(payload: &SpawnPayload) -> bool { - payload.is_torrent - && (payload - .torrent_seed_time - .is_some_and(|minutes| minutes.is_finite() && minutes > 0.0) - || payload - .torrent_seed_ratio - .is_some_and(|ratio| ratio.is_finite() && ratio >= 0.0)) -} - fn aria2_attempt_limit(max_tries: Option) -> u32 { // Firelink owns the retry budget and performs the backoff/GID rotation. // Keep each aria2 GID to one attempt so `max_tries` is not multiplied by @@ -6592,62 +2385,6 @@ fn is_retryable_aria2_error(error: &str) -> bool { is_transient_network_error(error) || is_aria2_range_mode_error(error) } -fn is_aria2_not_found_error(error: &str) -> bool { - let lower = error.to_ascii_lowercase(); - lower.contains("aria2 error code 3") || lower.contains("aria2 error code 4") -} - -fn is_aria2_low_speed_error(error: &str) -> bool { - error - .to_ascii_lowercase() - .contains("aria2 error code 5") -} - -fn is_retryable_aria2_error_for_payload(payload: &SpawnPayload, error: &str) -> bool { - is_retryable_aria2_error(error) - || (!payload.is_torrent - && payload.retry_not_found_errors - && is_aria2_not_found_error(error)) - || (!payload.is_torrent - && payload.minimum_normal_download_speed_kib > 0 - && is_aria2_low_speed_error(error)) -} - -fn should_use_aria2_system_resolver_fallback( - payload: &SpawnPayload, - error: &str, - async_dns_supported: bool, -) -> bool { - async_dns_supported - && payload.aria2_resolver_mode == Aria2ResolverMode::Automatic - && is_aria2_name_resolution_error(error) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Aria2RetryAction { - SystemResolverFallback, - OrdinaryRetry, - Terminal, -} - -fn aria2_retry_action( - payload: &SpawnPayload, - error: &str, - strike: usize, - async_dns_supported: bool, -) -> Aria2RetryAction { - if should_use_aria2_system_resolver_fallback(payload, error, async_dns_supported) { - return Aria2RetryAction::SystemResolverFallback; - } - if is_retryable_aria2_error_for_payload(payload, error) - && strike < automatic_retry_limit(payload.max_tries) - { - Aria2RetryAction::OrdinaryRetry - } else { - Aria2RetryAction::Terminal - } -} - fn is_aria2_rpc_unavailable(error: &str) -> bool { let lower = error.to_ascii_lowercase(); is_transient_network_error(error) @@ -6686,299 +2423,52 @@ enum BoundedRangeSupport { Unknown, } -impl BoundedRangeSupport { - fn as_str(self) -> &'static str { - match self { - Self::Supported => "supported", - Self::Unsupported => "unsupported", - Self::Unknown => "unknown", - } - } -} - -struct HttpTransferProbe { - final_uri: String, - range_support: BoundedRangeSupport, - credentials_allowed: bool, - redirect_count: usize, -} - -struct PreparedNormalTransfer { - uris: Vec, - requested_connections: i32, - effective_connections: i32, - credentials_allowed: bool, -} - -fn payload_has_credential_material(payload: &SpawnPayload) -> bool { - let inline_url_credentials = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) - .into_iter() - .any(|uri| { - reqwest::Url::parse(&uri) - .ok() - .is_some_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some()) - }); - if inline_url_credentials { - return true; +async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 { + let requested = clamp_download_connections( + payload + .connections + .unwrap_or(DOWNLOAD_CONNECTIONS_MIN), + ); + if requested <= 1 { + return requested; } - if payload - .username - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || payload - .password - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || payload - .cookies - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - return true; - } - - payload - .headers - .as_deref() - .into_iter() - .flat_map(str::lines) - .filter_map(|line| { - line.split_once(':') - .map(|(name, _)| name.trim().to_ascii_lowercase()) - }) - .any(|name| header_name_has_credential_material(&name)) -} - -pub(crate) fn header_name_has_credential_material(name: &str) -> bool { - let name = name.trim().to_ascii_lowercase(); - name.is_empty() || matches!( - name.as_str(), - "authorization" - | "cookie" - | "cookie2" - | "proxy-authorization" - | "set-cookie" - | "set-cookie2" - | "x-api-key" - | "x-auth-token" - | "x-access-token" - ) || [ - "auth", - "credential", - "key", - "password", - "passwd", - "secret", - "session", - "signature", - "token", - ] - .iter() - .any(|marker| name.contains(marker)) -} - -async fn prepare_normal_transfer( - id: &str, - epoch: u64, - payload: &SpawnPayload, -) -> Result { - let credential_origin = reqwest::Url::parse(&payload.url) - .map_err(|_| "normal download has an invalid primary URL".to_string())?; - let requested = - clamp_download_connections(payload.connections.unwrap_or(DOWNLOAD_CONNECTIONS_MIN)); - let mut connections = requested; - let mut uris = Vec::new(); - let mut credentials_allowed = true; - for (index, uri) in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) - .into_iter() - .enumerate() - { + for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) { if !is_http_uri(&uri) { - let parsed = - reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?; - crate::resolve_and_validate_url_host(&parsed).await?; - let uri_credentials_allowed = can_forward_payload_credentials(&credential_origin, &parsed); - if index > 0 - && payload_has_credential_material(payload) - && !uri_credentials_allowed - { - return Err( - "credentialed mirrors must use the same origin as the primary URL" - .to_string(), - ); - } - credentials_allowed &= uri_credentials_allowed; - uris.push(uri); continue; } - let probe_started = Instant::now(); - match probe_bounded_range_support(&uri, payload, &credential_origin).await { - Ok(probe) => { - if index > 0 - && payload_has_credential_material(payload) - && !probe.credentials_allowed - { - return Err( - "credentialed mirrors must use the same origin as the primary URL" - .to_string(), - ); - } - credentials_allowed &= probe.credentials_allowed; - uris.push(probe.final_uri); - log::info!( - "aria2 range probe [stage=range_probe id={} epoch={} host={} support={} redirect_count={} credentials_allowed={} requested_connections={} effective_connections={} elapsed_ms={}]", - id, - epoch, - uri_host_for_log(&uri), - probe.range_support.as_str(), - probe.redirect_count, - probe.credentials_allowed, - requested, - if probe.range_support == BoundedRangeSupport::Unsupported && requested > 1 { - 1 - } else { - connections - }, - probe_started.elapsed().as_millis() - ); - match probe.range_support { - BoundedRangeSupport::Unsupported if requested > 1 => { - log::warn!( - "aria2 range probe [stage=range_probe id={} epoch={} host={} result=unsupported action=single_connection]", - id, - epoch, - uri_host_for_log(&uri) - ); - connections = 1; - } - BoundedRangeSupport::Unknown if requested > 1 => { - log::debug!( - "aria2 range probe [stage=range_probe id={} epoch={} host={} result=unknown action=keep_requested connections={}]", - id, - epoch, - uri_host_for_log(&uri), - requested - ); - } - _ => {} - } - } - Err(error) if is_fatal_range_probe_error(&error) => { - log::error!( - "aria2 redirect [stage=redirect id={} epoch={} host={} result=rejected error_code={} requested_connections={} effective_connections={} elapsed_ms={}]", - id, - epoch, - uri_host_for_log(&uri), - range_probe_error_code(&error), - requested, - connections, - probe_started.elapsed().as_millis() - ); - return Err(format!( - "normal transfer preflight rejected for {}: {}", - uri_host_for_log(&uri), - range_probe_error_code(&error) - )); - } - Err(error) if payload_has_credential_material(payload) => { + match probe_bounded_range_support(&uri, payload).await { + Ok(BoundedRangeSupport::Unsupported) => { log::warn!( - "aria2 range probe [stage=range_probe id={} epoch={} host={} result=retryable error_code={} credentials_verified=false requested_connections={} effective_connections={} elapsed_ms={}]", + "aria2 range probe [{}]: {} does not honor bounded byte ranges; using one connection", id, - epoch, - uri_host_for_log(&uri), - range_probe_error_code(&error), - requested, - connections, - probe_started.elapsed().as_millis() + uri_host_for_log(&uri) ); - return Err(format!( - "normal transfer preflight is retryable for {}: {}", + return 1; + } + Ok(BoundedRangeSupport::Supported) => {} + Ok(BoundedRangeSupport::Unknown) => { + log::debug!( + "aria2 range probe [{}]: {} range support unknown; keeping {} connections", + id, uri_host_for_log(&uri), - range_probe_error_code(&error) - )); + requested + ); } Err(error) => { - log::warn!( - "aria2 range probe [stage=range_probe id={} epoch={} host={} result=unknown action=use_source_uri error_code={} credentials_verified=false requested_connections={} effective_connections={} elapsed_ms={}]", + log::debug!( + "aria2 range probe [{}]: {} probe failed: {}; keeping {} connections", id, - epoch, uri_host_for_log(&uri), - range_probe_error_code(&error), - requested, - connections, - probe_started.elapsed().as_millis() + error, + requested ); - uris.push(uri); } } } - uris.dedup(); - if uris.is_empty() { - return Err("normal download has no usable URI".to_string()); - } - if payload_has_credential_material(payload) && !credentials_allowed { - log::warn!( - "aria2 redirect policy [{}]: stripping credentials after a cross-origin redirect", - id - ); - } - Ok(PreparedNormalTransfer { - uris, - requested_connections: requested, - effective_connections: connections, - credentials_allowed, - }) -} -fn is_fatal_range_probe_error(error: &str) -> bool { - let lower = error.to_ascii_lowercase(); - lower.contains("private/local ip not allowed") - || lower.contains("invalid url") - || lower.contains("no host") - || lower.contains("unsupported scheme") - || lower.contains("invalid range probe redirect") - || lower.contains("range probe redirect uses an unsupported scheme") - || lower.contains("range probe redirect has no valid location") - || lower.contains("range probe redirect limit exceeded") - || lower.contains("range probe redirect loop exhausted") -} - -fn range_probe_error_code(error: &str) -> &'static str { - let lower = error.to_ascii_lowercase(); - if lower.contains("timed out") || lower.contains("timeout") { - "timeout" - } else if lower.contains("dns") || lower.contains("name resolution") { - "dns" - } else if lower.contains("private/local") { - "ssrf_private_address" - } else if lower.contains("redirect") { - "redirect" - } else if lower.contains("invalid") { - "invalid_route" - } else { - match network_error_class(error) { - "name_resolution" | "dns" => "dns", - "ssrf_policy" => "ssrf_private_address", - class => class, - } - } -} - -fn transfer_preflight_error_code(error: &str) -> &'static str { - if error - .to_ascii_lowercase() - .contains("credentialed mirrors must use the same origin") - { - "credential_policy" - } else { - range_probe_error_code(error) - } -} - -fn diagnostic_error_code(error: &str) -> String { - aria2_error_code(error).unwrap_or_else(|| network_error_class(error).to_string()) + requested } fn is_http_uri(uri: &str) -> bool { @@ -7000,7 +2490,7 @@ fn proxy_scheme(proxy: &str) -> Option { .map(|(scheme, _)| scheme.trim().to_ascii_lowercase()) } -pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result, String> { +fn aria2_all_proxy_value(proxy: &str) -> Result, String> { let proxy = proxy.trim(); if proxy.is_empty() { return Ok(None); @@ -7016,161 +2506,50 @@ pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result, Strin Ok(Some(proxy.to_string())) } -pub(crate) fn proxy_route_for_log(proxy: Option<&str>) -> &'static str { - match proxy.map(str::trim) { - None | Some("") => "none", - Some(value) if value.eq_ignore_ascii_case("none") => "disabled", - Some(_) => "configured", - } -} - async fn probe_bounded_range_support( uri: &str, payload: &SpawnPayload, - credential_origin: &reqwest::Url, -) -> Result { - probe_bounded_range_support_with_local_override(uri, payload, credential_origin, false).await -} - -#[cfg(test)] -async fn probe_bounded_range_support_local_test( - uri: &str, - payload: &SpawnPayload, - credential_origin: &reqwest::Url, -) -> Result { - probe_bounded_range_support_with_local_override(uri, payload, credential_origin, true).await -} - -async fn probe_bounded_range_support_with_local_override( - uri: &str, - payload: &SpawnPayload, - credential_origin: &reqwest::Url, - allow_localhost: bool, -) -> Result { +) -> Result { crate::ensure_reqwest_crypto_provider(); - let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?; - let mut credentials_allowed = can_forward_payload_credentials(credential_origin, ¤t); - for redirect_count in 0..=5 { - let (host, address) = if allow_localhost { - let host = current - .host_str() - .ok_or_else(|| "range probe test URL has no host".to_string())?; - let ip = host - .parse::() - .map_err(|_| "range probe test URL must use an IP host".to_string())?; - ( - host.to_string(), - std::net::SocketAddr::new( - ip, - current.port_or_known_default().unwrap_or(80), - ), - ) - } else { - crate::resolve_and_validate_url_host(¤t).await? - }; - let mut builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(10)) - .resolve(&host, address); + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::limited(5)) + .timeout(std::time::Duration::from_secs(10)); - if allow_localhost { + if let Some(proxy) = payload + .proxy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if proxy.eq_ignore_ascii_case("none") { builder = builder.no_proxy(); + } else { + builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); } - - if let Some(proxy) = payload - .proxy - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if proxy.eq_ignore_ascii_case("none") { - builder = builder.no_proxy(); - } else { - builder = - builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); - } - } - - let client = builder.build().map_err(|error| error.to_string())?; - let request = client - .get(current.clone()) - .header(reqwest::header::RANGE, "bytes=0-0") - .header(reqwest::header::ACCEPT_ENCODING, "identity"); - let response = apply_payload_headers(request, payload, credentials_allowed) - .send() - .await - .map_err(|error| error.to_string())?; - - if response.status().is_redirection() { - if redirect_count == 5 { - return Err("range probe redirect limit exceeded".to_string()); - } - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| "range probe redirect has no valid Location".to_string())?; - let next = current - .join(location) - .map_err(|error| format!("invalid range probe redirect: {error}"))?; - if !matches!(next.scheme(), "http" | "https") { - return Err("range probe redirect uses an unsupported scheme".to_string()); - } - let (next, next_credentials_allowed) = apply_redirect_credentials_policy( - credential_origin, - credentials_allowed, - next, - ); - credentials_allowed = next_credentials_allowed; - current = next; - continue; - } - - let content_range = response - .headers() - .get(reqwest::header::CONTENT_RANGE) - .and_then(|value| value.to_str().ok()); - return Ok(HttpTransferProbe { - final_uri: current.to_string(), - range_support: classify_bounded_range_response(response.status(), content_range), - credentials_allowed, - redirect_count, - }); } - Err("range probe redirect loop exhausted".to_string()) -} + let client = builder.build().map_err(|error| error.to_string())?; + let request = client + .get(uri) + .header(reqwest::header::RANGE, "bytes=0-0") + .header(reqwest::header::ACCEPT_ENCODING, "identity"); + let request = apply_payload_headers(request, payload); + let response = request.send().await.map_err(|error| error.to_string())?; + let content_range = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()); -fn can_forward_payload_credentials(original: &reqwest::Url, current: &reqwest::Url) -> bool { - original.host() == current.host() - && (original.port_or_known_default() == current.port_or_known_default() - || (original.scheme() == "http" - && current.scheme() == "https" - && original.port_or_known_default() == Some(80) - && current.port_or_known_default() == Some(443))) - && (original.scheme() == current.scheme() - || (original.scheme() == "http" && current.scheme() == "https")) -} - -fn apply_redirect_credentials_policy( - credential_origin: &reqwest::Url, - credentials_already_allowed: bool, - mut next: reqwest::Url, -) -> (reqwest::Url, bool) { - let credentials_allowed = credentials_already_allowed - && can_forward_payload_credentials(credential_origin, &next); - if !credentials_allowed { - let _ = next.set_username(""); - let _ = next.set_password(None); - } - (next, credentials_allowed) + Ok(classify_bounded_range_response( + response.status(), + content_range, + )) } fn apply_payload_headers( mut request: reqwest::RequestBuilder, payload: &SpawnPayload, - include_credentials: bool, ) -> reqwest::RequestBuilder { if let Some(user_agent) = payload .user_agent @@ -7179,151 +2558,55 @@ fn apply_payload_headers( { request = request.header(reqwest::header::USER_AGENT, user_agent); } - if include_credentials { - if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) { - request = request.header(reqwest::header::COOKIE, cookies); - } - if let Some(headers) = payload.headers.as_deref() { - for line in headers - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - if name.trim().eq_ignore_ascii_case("range") { - continue; - } - let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else { - continue; - }; - let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else { - continue; - }; - request = request.header(name, value); - } - } - if let Some(username) = payload - .username - .as_deref() - .filter(|value| !value.is_empty()) + if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) { + request = request.header(reqwest::header::COOKIE, cookies); + } + if let Some(headers) = payload.headers.as_deref() { + for line in headers + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) { - request = request.basic_auth(username, payload.password.as_deref()); + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("range") { + continue; + } + let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else { + continue; + }; + let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else { + continue; + }; + request = request.header(name, value); } } + if let Some(username) = payload + .username + .as_deref() + .filter(|value| !value.is_empty()) + { + request = request.basic_auth(username, payload.password.as_deref()); + } request } -fn apply_protocol_auth_options( - options: &mut serde_json::Map, - payload: &SpawnPayload, - uris: &[String], -) { - let has_http = uris.iter().any(|uri| is_http_uri(uri)); - let has_ftp = uris.iter().any(|uri| { - url::Url::parse(uri) - .ok() - .is_some_and(|parsed| matches!(parsed.scheme(), "ftp" | "sftp")) - }); - let has_sftp = uris.iter().any(|uri| { - url::Url::parse(uri) - .ok() - .is_some_and(|parsed| parsed.scheme() == "sftp") - }); - if has_http { - if let Some(user) = &payload.username { - options.insert("http-user".to_string(), serde_json::json!(user)); - } - if let Some(pass) = &payload.password { - options.insert("http-passwd".to_string(), serde_json::json!(pass)); - } - } - if has_ftp { - if let Some(user) = &payload.username { - options.insert("ftp-user".to_string(), serde_json::json!(user)); - } - if let Some(pass) = &payload.password { - options.insert("ftp-passwd".to_string(), serde_json::json!(pass)); - } - } - if has_sftp { - if let Some(fingerprint) = &payload.sftp_host_key_md { - options.insert("ssh-host-key-md".to_string(), serde_json::json!(fingerprint)); - } - } -} - -fn apply_checksum_options( - options: &mut serde_json::Map, - checksum: Option<&str>, -) { - if let Some(chk) = checksum { - let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') { - format!("{}={}", algo.to_ascii_lowercase(), digest) - } else { - chk.to_string() - }; - options.insert("checksum".to_string(), serde_json::json!(formatted_chk)); - options.insert("check-integrity".to_string(), serde_json::json!("true")); - } -} - -struct Aria2NetworkPolicyError { - host: String, - message: String, -} - -async fn validate_aria2_transfer_network_policy( - uris: &[String], -) -> Result<(), Aria2NetworkPolicyError> { - for uri in uris { - let parsed = reqwest::Url::parse(uri).map_err(|_| Aria2NetworkPolicyError { - host: uri_host_for_log(uri), - message: "SSRF blocked: Invalid URL".to_string(), - })?; - if !matches!(parsed.scheme(), "http" | "https" | "ftp" | "sftp") { - return Err(Aria2NetworkPolicyError { - host: uri_host_for_log(uri), - message: "Unsupported URL scheme".to_string(), - }); - } - // This is deliberately repeated immediately before addUri/addTorrent - // so admission-time DNS is not the only policy check. Aria2 still - // resolves independently later; Firelink therefore does not claim - // that this is an IP pinning boundary. - if let Err(error) = crate::resolve_and_validate_url_host(&parsed).await { - return Err(Aria2NetworkPolicyError { - host: uri_host_for_log(uri), - message: error, - }); - } - } - Ok(()) -} - fn classify_bounded_range_response( status: reqwest::StatusCode, content_range: Option<&str>, ) -> BoundedRangeSupport { - // A 416 for the deliberately tiny `bytes=0-0` request is the one - // response we can classify as an explicit range rejection. Successful - // 200 responses and larger 206 responses remain ambiguous because - // servers and proxies commonly normalize or expand bounded requests. - if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { - return BoundedRangeSupport::Unsupported; - } if status == reqwest::StatusCode::PARTIAL_CONTENT { return match content_range.and_then(parse_content_range_bounds) { Some((0, 0)) => BoundedRangeSupport::Supported, - Some((0, _)) => BoundedRangeSupport::Unknown, + Some((0, _)) => BoundedRangeSupport::Unsupported, Some(_) => BoundedRangeSupport::Unknown, None => BoundedRangeSupport::Unknown, }; } if status.is_success() { - BoundedRangeSupport::Unknown + BoundedRangeSupport::Unsupported } else { BoundedRangeSupport::Unknown } @@ -7347,14 +2630,6 @@ pub struct ProductionSpawner { const ARIA2_MIN_SPLIT_SIZE: &str = "1M"; const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder"; -const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55; -const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K"; -const MAX_TORRENT_MAX_PEERS: u32 = 1000; -pub(crate) const MAX_TORRENT_STOP_TIMEOUT: u32 = 7 * 24 * 60 * 60; -pub(crate) const MAX_TORRENT_PEER_DIAGNOSTICS: usize = 128; -const MAX_TORRENT_PEER_RESPONSE: usize = 4096; -pub(crate) const MAX_TORRENT_TRACKERS: usize = 64; -pub(crate) const MAX_TORRENT_TRACKER_BYTES: usize = 16 * 1024; fn apply_aria2_connection_options( options: &mut serde_json::Map, @@ -7386,1005 +2661,14 @@ fn apply_aria2_connection_options( ); } -fn aria2_add_uri_params( - transfer_uris: Vec, - options: serde_json::Map, -) -> serde_json::Value { - serde_json::json!([transfer_uris, options]) -} - -fn apply_aria2_normal_reliability_options( - options: &mut serde_json::Map, - payload: &SpawnPayload, - uri_count: usize, -) -> Result<(), String> { - if payload.is_torrent { - return Ok(()); - } - let minimum_speed = - normalize_minimum_normal_download_speed_kib(payload.minimum_normal_download_speed_kib)?; - if minimum_speed > 0 { - options.insert( - "lowest-speed-limit".to_string(), - serde_json::json!(format!("{minimum_speed}K")), - ); - } - if payload.adaptive_mirror_selection && uri_count > 1 { - options.insert( - "uri-selector".to_string(), - serde_json::json!("adaptive"), - ); - } - Ok(()) -} - -fn apply_aria2_resolver_options( - options: &mut serde_json::Map, - mode: Aria2ResolverMode, -) { - if mode == Aria2ResolverMode::System { - options.insert("async-dns".to_string(), serde_json::json!("false")); - } -} - -fn should_apply_aria2_connection_options(payload: &SpawnPayload) -> bool { - !payload.is_torrent -} - -fn apply_aria2_follow_options( - options: &mut serde_json::Map, - payload: &SpawnPayload, -) { - if !payload.is_torrent { - // A generic addUri can point at a .torrent or Metalink file. Aria2 - // may then create a second, followed child GID, but Firelink - // currently owns exactly one GID per download. Keep that unmanaged - // child lifecycle impossible until parent/child ownership is modeled - // end to end. - options.insert( - "follow-torrent".to_string(), - serde_json::json!("false"), - ); - options.insert( - "follow-metalink".to_string(), - serde_json::json!("false"), - ); - } -} - -fn format_aria2_torrent_number(value: f64, field: &str) -> Result { - if !value.is_finite() || value < 0.0 { - return Err(format!("torrent {field} must be a finite non-negative number")); - } - Ok(value.to_string()) -} - -fn normalize_torrent_max_peers(value: Option) -> Result, String> { - let Some(value) = value else { - return Ok(None); - }; - if !(0..=i64::from(MAX_TORRENT_MAX_PEERS)).contains(&value) { - return Err(format!( - "torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}" - )); - } - Ok(Some(value as u32)) -} - -fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result, String> { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - crate::normalize_speed_limit_for_aria2(value) - .map(Some) - .ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string()) -} - -pub(crate) fn normalize_torrent_stop_timeout(value: Option) -> Result, String> { - let Some(value) = value else { - return Ok(None); - }; - if value > MAX_TORRENT_STOP_TIMEOUT { - return Err(format!( - "torrent stall timeout must be between 0 and {MAX_TORRENT_STOP_TIMEOUT} seconds" - )); - } - Ok(Some(value)) -} - -fn normalize_torrent_piece_priority_size(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() { - return Err("torrent piece priority size must use a positive K or M value".to_string()); - } - let Some((unit_start, unit)) = value.char_indices().next_back() else { - return Err("torrent piece priority size must use a positive K or M value".to_string()); - }; - let amount = &value[..unit_start]; - let unit = unit.to_ascii_uppercase(); - if !matches!(unit, 'K' | 'M') || amount.is_empty() { - return Err("torrent piece priority size must use a positive K or M value".to_string()); - } - let amount = amount - .parse::() - .map_err(|_| "torrent piece priority size must use a positive K or M value".to_string())?; - if amount == 0 - || (unit == 'M' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB) - || (unit == 'K' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB * 1024) - { - return Err(format!( - "torrent piece priority size must be between 1K and {}M", - MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB - )); - } - Ok(format!("{amount}{unit}")) -} - -pub(crate) fn normalize_torrent_prioritize_piece( - value: Option<&str>, -) -> Result, String> { - let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if raw.len() > 64 { - return Err("torrent piece priority is too long".to_string()); - } - - let mut head = None; - let mut tail = None; - for token in raw.split(',') { - let token = token.trim(); - if token.is_empty() { - return Err("torrent piece priority contains an empty entry".to_string()); - } - let (keyword, size) = token - .split_once('=') - .map_or((token, None), |(keyword, size)| { - (keyword.trim(), Some(size)) - }); - let keyword = keyword.to_ascii_lowercase(); - let normalized_size = size - .map(normalize_torrent_piece_priority_size) - .transpose()?; - let normalized = normalized_size - .map(|size| format!("{keyword}={size}")) - .unwrap_or_else(|| keyword.clone()); - match keyword.as_str() { - "head" => { - if head.is_some() { - return Err("torrent piece priority cannot repeat head".to_string()); - } - head = Some(normalized); - } - "tail" => { - if tail.is_some() { - return Err("torrent piece priority cannot repeat tail".to_string()); - } - tail = Some(normalized); - } - _ => return Err("torrent piece priority must use head and/or tail".to_string()), - } - } - - let mut normalized = Vec::with_capacity(2); - if let Some(head) = head { - normalized.push(head); - } - if let Some(tail) = tail { - normalized.push(tail); - } - if normalized.is_empty() { - return Ok(None); - } - Ok(Some(normalized.join(","))) -} - -fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 { - match value { - Some(serde_json::Value::String(value)) => value.parse().unwrap_or_default(), - Some(serde_json::Value::Number(value)) => value.as_u64().unwrap_or_default(), - _ => 0, - } -} - -fn aria2_peer_ip(value: Option<&serde_json::Value>) -> Option { - let value = value?.as_str()?.trim(); - if value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) { - return None; - } - value.parse::().ok().map(|ip| ip.to_string()) -} - -fn aria2_peer_port(value: Option<&serde_json::Value>) -> Option { - match value { - Some(serde_json::Value::String(value)) => value.parse().ok(), - Some(serde_json::Value::Number(value)) => value.as_u64()?.try_into().ok(), - _ => None, - } -} - -fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool { - match value { - Some(serde_json::Value::Bool(value)) => *value, - Some(serde_json::Value::String(value)) => { - value.eq_ignore_ascii_case("true") || value == "1" - } - _ => false, - } -} - -fn parse_torrent_availability_decimal( - object: &serde_json::Map, - field: &str, -) -> Result { - let value = object - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?; - if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(format!("aria2.tellStatus returned an invalid {field}")); - } - value - .parse::() - .map_err(|_| format!("aria2.tellStatus returned an invalid {field}")) -} - -fn decode_torrent_availability_bitfield( - value: &str, - piece_count: u64, -) -> Result, String> { - if piece_count == 0 || piece_count > MAX_TORRENT_PIECES_FOR_PROGRESS { - return Err("Torrent availability has an unsupported piece count".to_string()); - } - let byte_count = piece_count - .checked_add(7) - .and_then(|value| value.checked_div(8)) - .ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?; - let expected_hex_length = byte_count - .checked_mul(2) - .ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?; - if value.len() != usize::try_from(expected_hex_length).unwrap_or(usize::MAX) - || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("Torrent availability bitfield is malformed".to_string()); - } - let mut bytes = Vec::with_capacity(byte_count as usize); - for pair in value.as_bytes().chunks_exact(2) { - let high = char::from(pair[0]) - .to_digit(16) - .ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?; - let low = char::from(pair[1]) - .to_digit(16) - .ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?; - bytes.push(((high << 4) | low) as u8); - } - if piece_count % 8 != 0 { - let overflow_mask = (1u8 << (8 - piece_count as u8 % 8)) - 1; - if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) { - return Err("Torrent availability bitfield has overflow bits".to_string()); - } - } - Ok(bytes) -} - -fn torrent_availability_piece_is_set(bitfield: &[u8], index: usize) -> bool { - bitfield[index / 8] & (1 << (7 - index % 8)) != 0 -} - -pub(crate) fn parse_torrent_availability( - status: serde_json::Value, - peers: serde_json::Value, -) -> Result { - let status = status - .as_object() - .ok_or_else(|| "aria2.tellStatus returned malformed Torrent availability".to_string())?; - let piece_count = parse_torrent_availability_decimal(status, "numPieces")?; - let local_bitfield = status - .get("bitfield") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "aria2.tellStatus has no Torrent availability bitfield yet".to_string())?; - let local_bitfield = decode_torrent_availability_bitfield(local_bitfield, piece_count)?; - let peers = peers - .as_array() - .ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?; - if peers.len() > MAX_TORRENT_AVAILABILITY_PEERS { - return Err("aria2.getPeers returned too many peers for availability".to_string()); - } - let mut copies = vec![0u16; piece_count as usize]; - for index in 0..piece_count as usize { - if torrent_availability_piece_is_set(&local_bitfield, index) { - copies[index] = 1; - } - } - for peer in peers { - let Some(peer) = peer.as_object() else { - // Peer projections are network-derived and may be incomplete - // while Aria2 refreshes its peer table. Preserve the connected - // count but omit an unusable contribution rather than failing - // availability for the whole swarm. - continue; - }; - let Some(bitfield_value) = peer.get("bitfield") else { - // Aria2 can report a connected peer before the handshake has - // supplied its piece bitfield. Keep that peer in the connected - // count, but do not let it make the whole availability snapshot - // unavailable. A present, non-string bitfield remains malformed. - continue; - }; - let Some(bitfield) = bitfield_value.as_str() else { - continue; - }; - let Ok(bitfield) = decode_torrent_availability_bitfield(bitfield, piece_count) else { - continue; - }; - for index in 0..piece_count as usize { - if torrent_availability_piece_is_set(&bitfield, index) { - copies[index] = copies[index].saturating_add(1); - } - } - } - - let minimum = copies.iter().copied().min().unwrap_or(0); - let above_minimum = copies.iter().filter(|count| **count > minimum).count(); - let availability = minimum as f64 + above_minimum as f64 / piece_count as f64; - let bucket_count = piece_count.min(256) as usize; - let mut buckets = Vec::with_capacity(bucket_count); - for bucket_index in 0..bucket_count { - let start = piece_count * bucket_index as u64 / bucket_count as u64; - let end = piece_count * (bucket_index as u64 + 1) / bucket_count as u64; - let minimum_copies = copies[start as usize..end as usize] - .iter() - .copied() - .min() - .unwrap_or(0); - buckets.push(crate::ipc::TorrentAvailabilityBucket { minimum_copies }); - } - Ok(crate::ipc::TorrentAvailabilitySnapshot { - piece_count, - availability, - connected_peers: peers.len().try_into().unwrap_or(u32::MAX), - buckets, - }) -} - -struct TorrentPeerCounts { - listed_peers: u32, - listed_seeders: u32, -} - -fn torrent_peer_counts_from_array( - peers: &[serde_json::Value], -) -> Result { - if peers.len() > MAX_TORRENT_PEER_RESPONSE { - return Err("aria2.getPeers returned too many peers".to_string()); - } - if peers.iter().any(|peer| !peer.is_object()) { - return Err("aria2.getPeers returned malformed peer data".to_string()); - } - let mut total_seeders = 0u32; - for peer in peers { - let peer = peer - .as_object() - .ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?; - if aria2_peer_bool(peer.get("seeder")) { - total_seeders = total_seeders.saturating_add(1); - } - } - Ok(TorrentPeerCounts { - listed_peers: u32::try_from(peers.len()).unwrap_or(u32::MAX), - listed_seeders: total_seeders, - }) -} - -pub(crate) fn parse_torrent_peer_diagnostics( - result: serde_json::Value, -) -> Result { - let peers = result - .as_array() - .ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?; - let summary = torrent_peer_counts_from_array(peers)?; - let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS)); - - for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) { - let peer = peer - .as_object() - .ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?; - let seeder = aria2_peer_bool(peer.get("seeder")); - sanitized.push(crate::ipc::TorrentPeer { - ip: aria2_peer_ip(peer.get("ip")), - port: aria2_peer_port(peer.get("port")), - download_speed: aria2_peer_number(peer.get("downloadSpeed")), - upload_speed: aria2_peer_number(peer.get("uploadSpeed")), - seeder, - am_choking: aria2_peer_bool(peer.get("amChoking")), - peer_choking: aria2_peer_bool(peer.get("peerChoking")), - }); - } - - Ok(crate::ipc::TorrentPeerDiagnostics { - listed_peers: summary.listed_peers, - listed_seeders: summary.listed_seeders, - peers: sanitized, - truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS, - }) -} - -fn parse_aria2_decimal(value: Option<&serde_json::Value>, field: &str) -> Result { - let value = value - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("aria2.getFiles returned an invalid {field}"))?; - if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(format!("aria2.getFiles returned an invalid {field}")); - } - value - .parse::() - .map_err(|_| format!("aria2.getFiles returned an invalid {field}")) -} - -fn parse_aria2_selected(value: Option<&serde_json::Value>) -> Result { - match value { - Some(serde_json::Value::String(value)) if value == "true" => Ok(true), - Some(serde_json::Value::String(value)) if value == "false" => Ok(false), - Some(serde_json::Value::Bool(value)) => Ok(*value), - _ => Err("aria2.getFiles returned an invalid selected flag".to_string()), - } -} - -pub(crate) fn parse_torrent_file_progress( - result: serde_json::Value, - metadata_files: &[crate::ipc::TorrentFile], -) -> Result { - let files = result - .as_array() - .ok_or_else(|| "aria2.getFiles returned a non-array result".to_string())?; - if files.len() != metadata_files.len() { - return Err("aria2.getFiles returned an unexpected file count".to_string()); - } - - let mut parsed = HashMap::::with_capacity(files.len()); - for file in files { - let object = file - .as_object() - .ok_or_else(|| "aria2.getFiles returned malformed file data".to_string())?; - let index = parse_aria2_decimal(object.get("index"), "file index")? - .try_into() - .map_err(|_| "aria2.getFiles returned an invalid file index".to_string())?; - let metadata = metadata_files - .iter() - .find(|metadata| metadata.index == index) - .ok_or_else(|| "aria2.getFiles returned an unknown file index".to_string())?; - if parsed.contains_key(&index) { - return Err("aria2.getFiles returned a duplicate file index".to_string()); - } - let length = parse_aria2_decimal(object.get("length"), "file length")?; - if length != metadata.length { - return Err("aria2.getFiles returned a mismatched file length".to_string()); - } - let completed_length = parse_aria2_decimal( - object.get("completedLength"), - "completed file length", - )?; - if completed_length > length { - return Err("aria2.getFiles returned over-complete file data".to_string()); - } - parsed.insert( - index, - crate::ipc::TorrentFileProgress { - index, - relative_path: metadata.path.clone(), - length, - completed_length, - selected: parse_aria2_selected(object.get("selected"))?, - }, - ); - } - - let files = metadata_files - .iter() - .map(|metadata| { - parsed - .remove(&metadata.index) - .ok_or_else(|| "aria2.getFiles returned a missing file index".to_string()) - }) - .collect::, _>>()?; - if !parsed.is_empty() { - return Err("aria2.getFiles returned unexpected file data".to_string()); - } - Ok(crate::ipc::TorrentFileProgressSnapshot { files }) -} - -fn parse_aria2_status_decimal( - object: &serde_json::Map, - field: &str, -) -> Result { - let value = object - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?; - if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(format!("aria2.tellStatus returned an invalid {field}")); - } - value - .parse::() - .map_err(|_| format!("aria2.tellStatus returned an invalid {field}")) -} - -fn decode_torrent_piece_bitfield( - value: &str, - num_pieces: u64, -) -> Result, String> { - if num_pieces == 0 { - return Err("aria2.tellStatus returned a non-positive piece count".to_string()); - } - if num_pieces > MAX_TORRENT_PIECES_FOR_PROGRESS { - return Err("aria2.tellStatus returned an unsupported piece count".to_string()); - } - let byte_count = num_pieces - .checked_add(7) - .and_then(|value| value.checked_div(8)) - .ok_or_else(|| "aria2.tellStatus returned an unsupported piece count".to_string())?; - let hex_length = byte_count - .checked_mul(2) - .ok_or_else(|| "aria2.tellStatus returned an oversized bitfield".to_string())?; - if value.len() != usize::try_from(hex_length).unwrap_or(usize::MAX) - || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("aria2.tellStatus returned an invalid piece bitfield".to_string()); - } - - let mut bytes = Vec::with_capacity(usize::try_from(byte_count).unwrap_or_default()); - for pair in value.as_bytes().chunks_exact(2) { - let high = char::from(pair[0]).to_digit(16).ok_or_else(|| { - "aria2.tellStatus returned an invalid piece bitfield".to_string() - })?; - let low = char::from(pair[1]).to_digit(16).ok_or_else(|| { - "aria2.tellStatus returned an invalid piece bitfield".to_string() - })?; - bytes.push(((high << 4) | low) as u8); - } - - let remainder = (num_pieces % 8) as u8; - if remainder != 0 { - let overflow_mask = (1u8 << (8 - remainder)) - 1; - if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) { - return Err("aria2.tellStatus returned set overflow bits".to_string()); - } - } - Ok(bytes) -} - -fn torrent_piece_is_complete(bitfield: &[u8], index: u64) -> bool { - let byte = bitfield[(index / 8) as usize]; - byte & (1 << (7 - (index % 8))) != 0 -} - -pub(crate) fn parse_torrent_piece_progress( - result: serde_json::Value, -) -> Result { - let object = result - .as_object() - .ok_or_else(|| "aria2.tellStatus returned malformed piece progress".to_string())?; - let piece_length = parse_aria2_status_decimal(object, "pieceLength")?; - let num_pieces = parse_aria2_status_decimal(object, "numPieces")?; - if piece_length == 0 { - return Err("aria2.tellStatus returned a non-positive piece length".to_string()); - } - let bitfield = object - .get("bitfield") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "aria2.tellStatus has no piece bitfield yet".to_string())?; - let bitfield = decode_torrent_piece_bitfield(bitfield, num_pieces)?; - let completed_pieces = (0..num_pieces) - .filter(|index| torrent_piece_is_complete(&bitfield, *index)) - .count() as u64; - - let bucket_count = num_pieces.min(256) as usize; - let mut buckets = Vec::with_capacity(bucket_count); - for bucket_index in 0..bucket_count { - let start = num_pieces * bucket_index as u64 / bucket_count as u64; - let end = num_pieces * (bucket_index as u64 + 1) / bucket_count as u64; - let bucket_length = end.saturating_sub(start); - let completed = (start..end) - .filter(|index| torrent_piece_is_complete(&bitfield, *index)) - .count() as u64; - let percentage = if bucket_length == 0 { - 0 - } else { - ((completed * 100) / bucket_length).min(100) as u8 - }; - buckets.push(percentage); - } - - Ok(crate::ipc::TorrentPieceProgressSnapshot { - piece_length, - num_pieces, - completed_pieces, - buckets, - }) -} - -pub(crate) fn normalize_torrent_tracker_uri(value: &str) -> Result { - let token = value.trim(); - if token.is_empty() { - return Err("torrent tracker URI is empty".to_string()); - } - if token.chars().any(char::is_control) { - return Err("torrent tracker URI contains a control character".to_string()); - } - let parsed = url::Url::parse(token).map_err(|_| "torrent tracker URI is invalid".to_string())?; - if !matches!(parsed.scheme(), "http" | "https" | "udp") { - return Err("torrent tracker URI must use http, https, or udp".to_string()); - } - if parsed.host_str().is_none_or(str::is_empty) { - return Err("torrent tracker URI must include a host".to_string()); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err("torrent tracker URI must not contain credentials".to_string()); - } - if parsed.fragment().is_some() { - return Err("torrent tracker URI must not contain a fragment".to_string()); - } - Ok(parsed.to_string()) -} - -fn normalize_torrent_tracker_list( - value: Option<&str>, - allow_wildcard: bool, -) -> Result, String> { - let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if raw.len() > MAX_TORRENT_TRACKER_BYTES { - return Err(format!( - "torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes" - )); - } - - let mut trackers = Vec::new(); - let mut wildcard = false; - let mut serialized_bytes = 0usize; - for line in raw.split(['\r', '\n']) { - let line = line.trim(); - if line.is_empty() { - continue; - } - for token in line.split(',') { - let token = token.trim(); - if token.is_empty() { - return Err("torrent tracker list contains an empty entry".to_string()); - } - if allow_wildcard && token == "*" { - if !trackers.is_empty() { - return Err( - "torrent tracker exclusion wildcard cannot be combined with tracker URLs" - .to_string(), - ); - } - wildcard = true; - continue; - } - if wildcard { - return Err( - "torrent tracker exclusion wildcard cannot be combined with tracker URLs" - .to_string(), - ); - } - let normalized = normalize_torrent_tracker_uri(token)?; - if trackers.iter().any(|tracker| tracker == &normalized) { - continue; - } - if trackers.len() >= MAX_TORRENT_TRACKERS { - return Err(format!( - "torrent tracker list must contain at most {MAX_TORRENT_TRACKERS} trackers" - )); - } - serialized_bytes = serialized_bytes - .checked_add(normalized.len()) - .and_then(|bytes| bytes.checked_add(if trackers.is_empty() { 0 } else { 1 })) - .ok_or_else(|| "torrent tracker list is too large".to_string())?; - if serialized_bytes > MAX_TORRENT_TRACKER_BYTES { - return Err(format!( - "torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes" - )); - } - trackers.push(normalized); - } - } - - if wildcard { - return Ok(Some("*".to_string())); - } - if trackers.is_empty() { - return Ok(None); - } - Ok(Some(trackers.join(","))) -} - -pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result, String> { - normalize_torrent_tracker_list(value, false) -} - -pub(crate) fn normalize_torrent_exclude_trackers( - value: Option<&str>, -) -> Result, String> { - normalize_torrent_tracker_list(value, true) -} - -fn normalize_torrent_tracker_timeout(value: Option, field: &str) -> Result, String> { - let Some(value) = value else { - return Ok(None); - }; - if !(1..=MAX_TORRENT_TRACKER_TIMEOUT).contains(&value) { - return Err(format!( - "torrent {field} must be between 1 and {MAX_TORRENT_TRACKER_TIMEOUT} seconds" - )); - } - Ok(Some(value)) -} - -pub(crate) fn normalize_torrent_tracker_connect_timeout( - value: Option, -) -> Result, String> { - normalize_torrent_tracker_timeout(value, "tracker connect timeout") -} - -pub(crate) fn normalize_torrent_tracker_request_timeout( - value: Option, -) -> Result, String> { - normalize_torrent_tracker_timeout(value, "tracker timeout") -} - -pub(crate) fn normalize_torrent_tracker_interval( - value: Option, -) -> Result, String> { - let Some(value) = value else { - return Ok(None); - }; - if value > MAX_TORRENT_TRACKER_INTERVAL { - return Err(format!( - "torrent tracker interval must be between 0 and {MAX_TORRENT_TRACKER_INTERVAL} seconds" - )); - } - Ok(Some(value)) -} - -pub(crate) fn normalize_torrent_encryption_policy( - value: Option<&str>, -) -> Result, String> { - let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - match raw { - "disabled" => Ok(None), - "require-crypto" => Ok(Some("require-crypto".to_string())), - "force-encryption" => Ok(Some("force-encryption".to_string())), - _ => Err( - "torrent encryption policy must be disabled, require-crypto, or force-encryption" - .to_string(), - ), - } -} - -fn apply_aria2_torrent_options( - options: &mut serde_json::Map, - payload: &SpawnPayload, -) -> Result<(), String> { - if !payload.is_torrent { - return Ok(()); - } - - if payload.torrent_verify_only { - options.insert("check-integrity".to_string(), serde_json::json!("true")); - options.insert("hash-check-only".to_string(), serde_json::json!("true")); - options.insert("seed-time".to_string(), serde_json::json!("0")); - options.insert("seed-ratio".to_string(), serde_json::json!("0")); - options.insert("bt-hash-check-seed".to_string(), serde_json::json!("false")); - options.insert("bt-seed-unverified".to_string(), serde_json::json!("false")); - return Ok(()); - } - - let encryption_policy = - normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?; - let (force_encryption, require_crypto, min_crypto_level) = - match encryption_policy.as_deref() { - Some("require-crypto") => (false, true, "plain"), - Some("force-encryption") => (true, true, "arc4"), - None => (false, false, "plain"), - Some(policy) => { - return Err(format!("unsupported normalized Torrent encryption policy: {policy}")); - } - }; - options.insert( - "bt-force-encryption".to_string(), - serde_json::json!(force_encryption.to_string()), - ); - options.insert( - "bt-require-crypto".to_string(), - serde_json::json!(require_crypto.to_string()), - ); - options.insert( - "bt-min-crypto-level".to_string(), - serde_json::json!(min_crypto_level), - ); - // Keep metadata probing and generic .torrent following out of the normal - // transfer policy. A direct magnet still has an Aria2 metadata parent; - // QueueManager adopts its validated payload child explicitly. - options.insert("bt-metadata-only".to_string(), serde_json::json!("false")); - options.insert("bt-save-metadata".to_string(), serde_json::json!("false")); - options.insert("follow-torrent".to_string(), serde_json::json!("false")); - if torrent_uses_direct_network(payload) { - // Aria2's async resolver is independent of the system resolver. TUN - // clients commonly install DNS interception/routing at the system - // resolver boundary, so direct Torrent discovery must use the same - // resolver path as the working HTTP/media clients. Do not override an - // explicitly configured proxy route, where local DNS could leak or - // bypass the proxy's name-resolution policy. - options.insert("async-dns".to_string(), serde_json::json!("false")); - } - - let seed_time = payload - .torrent_seed_time - .map(|value| format_aria2_torrent_number(value, "seed time")) - .transpose()?; - let seed_ratio = payload - .torrent_seed_ratio - .map(|value| format_aria2_torrent_number(value, "seed ratio")) - .transpose()?; - - // Aria2 treats seed-time=0 as an explicit disable. Omit it when a ratio - // policy exists so ratio-only and unlimited-ratio policies remain active. - // With no policy, keep the explicit zero to prevent daemon defaults from - // turning a normal Torrent download into an untracked seeding lifecycle. - if seed_ratio.is_none() || payload.torrent_seed_time.is_some_and(|value| value > 0.0) { - options.insert( - "seed-time".to_string(), - serde_json::json!(seed_time.unwrap_or_else(|| "0".to_string())), - ); - } - - if let Some(seed_ratio) = seed_ratio { - options.insert("seed-ratio".to_string(), serde_json::json!(seed_ratio)); - } - - if let Some(upload_limit) = payload.torrent_upload_limit.as_deref() { - let normalized = crate::normalize_speed_limit_for_aria2(upload_limit) - .ok_or_else(|| "torrent upload limit must be greater than zero".to_string())?; - options.insert("max-upload-limit".to_string(), serde_json::json!(normalized)); - } - - if let Some(max_peers) = payload.torrent_max_peers { - if max_peers > MAX_TORRENT_MAX_PEERS { - return Err(format!( - "torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}" - )); - } - options.insert( - "bt-max-peers".to_string(), - serde_json::json!(max_peers.to_string()), - ); - } - - if let Some(peer_speed_limit) = payload.torrent_peer_speed_limit.as_deref() { - let normalized = normalize_torrent_peer_speed_limit(Some(peer_speed_limit)) - ?.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())?; - options.insert( - "bt-request-peer-speed-limit".to_string(), - serde_json::json!(normalized), - ); - } - if let Some(trackers) = normalize_torrent_trackers(payload.torrent_trackers.as_deref())? { - options.insert("bt-tracker".to_string(), serde_json::json!(trackers)); - } - if let Some(trackers) = - normalize_torrent_exclude_trackers(payload.torrent_exclude_trackers.as_deref())? - { - options.insert("bt-exclude-tracker".to_string(), serde_json::json!(trackers)); - } - if let Some(timeout) = normalize_torrent_tracker_connect_timeout( - payload.torrent_tracker_connect_timeout, - )? { - options.insert( - "bt-tracker-connect-timeout".to_string(), - serde_json::json!(timeout.to_string()), - ); - } - if let Some(timeout) = - normalize_torrent_tracker_request_timeout(payload.torrent_tracker_timeout)? - { - options.insert( - "bt-tracker-timeout".to_string(), - serde_json::json!(timeout.to_string()), - ); - } - if let Some(interval) = normalize_torrent_tracker_interval(payload.torrent_tracker_interval)? { - options.insert( - "bt-tracker-interval".to_string(), - serde_json::json!(interval.to_string()), - ); - } - if let Some(stop_timeout) = normalize_torrent_stop_timeout(payload.torrent_stop_timeout)? { - options.insert( - "bt-stop-timeout".to_string(), - serde_json::json!(stop_timeout.to_string()), - ); - } - if let Some(piece_priority) = - normalize_torrent_prioritize_piece(payload.torrent_prioritize_piece.as_deref())? - { - options.insert( - "bt-prioritize-piece".to_string(), - serde_json::json!(piece_priority), - ); - } - if payload.torrent_remove_unselected_file { - let Some(indices) = payload.torrent_file_indices.as_deref() else { - return Err( - "removing unselected Torrent files requires selecting a subset of files" - .to_string(), - ); - }; - if indices.is_empty() { - return Err("torrent file selection is invalid".to_string()); - } - options.insert( - "bt-remove-unselected-file".to_string(), - serde_json::json!("true"), - ); - } - let allocation = normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())?; - options.insert("file-allocation".to_string(), serde_json::json!(allocation)); - if payload.torrent_check_integrity { - options.insert( - "check-integrity".to_string(), - serde_json::json!("true"), - ); - options.insert( - "bt-hash-check-seed".to_string(), - serde_json::json!(torrent_seeding_requested(payload).to_string()), - ); - // Do not let a daemon-wide bt-seed-unverified setting bypass the - // explicit per-download integrity request. - options.insert( - "bt-seed-unverified".to_string(), - serde_json::json!("false"), - ); - } - Ok(()) -} - -fn apply_aria2_header_options( - options: &mut serde_json::Map, - payload: &SpawnPayload, - credentials_allowed: bool, -) { - if !credentials_allowed { - return; - } - let mut header_list = Vec::new(); - if let Some(cookies) = &payload.cookies { - header_list.push(format!("Cookie: {cookies}")); - } - if let Some(headers) = &payload.headers { - for line in headers.lines() { - if !line.trim().is_empty() { - header_list.push(line.trim().to_string()); - } - } - } - if !header_list.is_empty() { - options.insert("header".to_string(), serde_json::json!(header_list)); - } -} - impl ProductionSpawner { pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } } - async fn add_transfer_rpc( + async fn add_uri_rpc( &self, state: &crate::AppState, - method: &str, params: &serde_json::Value, ) -> Result { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); @@ -8392,7 +2676,7 @@ impl ProductionSpawner { match crate::rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, - method, + "aria2.addUri", params.clone(), ) .await @@ -8407,65 +2691,17 @@ impl ProductionSpawner { } } } - - async fn control_seed_rpc( - &self, - gid: &str, - method: &str, - ) -> Result { - let state = self.app_handle.state::(); - let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); - let secret = &state.aria2_secret; - let rpc_error = match crate::rpc_call(port, secret, method, serde_json::json!([gid])).await { - Ok(result) => crate::ensure_aria2_gid_result(method, gid, &result) - .err() - .map(|error| error.to_string()), - Err(error) => Some(crate::redact_sensitive_text(&error.to_string())), - }; - let status = crate::aria2_download_status(port, secret, gid) - .await - .map_err(|error| { - rpc_error - .as_ref() - .map(|rpc_error| format!("{rpc_error}; status verification failed: {error}")) - .unwrap_or_else(|| format!("status verification failed: {error}")) - })?; - let outcome = match status.as_str() { - "active" | "waiting" => Aria2SeedControlOutcome::Resumed, - "paused" => Aria2SeedControlOutcome::Paused, - "complete" => Aria2SeedControlOutcome::Complete, - other => { - return Err(format!( - "aria2 {method} left gid {gid} in unhandled state {other}" - )); - } - }; - if let Some(error) = rpc_error { - log::warn!( - "aria2 {method} for seed gid {} returned an error after status verification: {}", - gid, - error - ); - } - Ok(outcome) - } } #[async_trait::async_trait] impl SidecarSpawner for ProductionSpawner { async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result { let state = self.app_handle.state::(); - let attempt_epoch = state.queue_manager.current_aria2_control_epoch(id).await; - let admission_started = Instant::now(); let mut options = serde_json::Map::new(); - let mut connection_options = None; let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle); if !crate::is_safe_path(&resolved_dest, &self.app_handle) { return Err("Path traversal blocked".to_string()); } - if payload.is_torrent { - crate::torrent::validate_output_name(&payload.filename)?; - } let proxy_value = payload .proxy .as_deref() @@ -8478,69 +2714,9 @@ impl SidecarSpawner for ProductionSpawner { ); let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename); - if !payload.is_torrent { - options.insert("out".to_string(), serde_json::json!(safe_filename)); - } - let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) = - if payload.is_torrent { - // Torrent metadata acquisition credentials are never transfer - // credentials. Torrent trackers and web seeds use their own - // validated URI/options paths below. - (Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, false) - } else { - let requested_uris = - crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); - let requested_connections = - clamp_download_connections(payload.connections.unwrap_or(DOWNLOAD_CONNECTIONS_MIN)); - if let Err(error) = validate_aria2_transfer_network_policy(&requested_uris).await { - log::error!( - "aria2 admission [stage=network_policy id={} epoch={} host={} proxy_route={} uri_count={} requested_connections={} effective_connections=not_established error_class={} error_code={} elapsed_ms={}]", - id, - attempt_epoch, - error.host, - proxy_route_for_log(payload.proxy.as_deref()), - requested_uris.len(), - requested_connections, - network_error_class(&error.message), - transfer_preflight_error_code(&error.message), - admission_started.elapsed().as_millis() - ); - return Err(error.message); - } - let prepared = match prepare_normal_transfer(id, attempt_epoch, payload).await { - Ok(prepared) => prepared, - Err(error) => { - log::error!( - "aria2 admission [stage=transfer_preflight id={} epoch={} host={} proxy_route={} requested_connections={} effective_connections=not_established error_class={} error_code={} elapsed_ms={}]", - id, - attempt_epoch, - requested_uris - .first() - .map(|uri| uri_host_for_log(uri)) - .unwrap_or_else(|| "".to_string()), - proxy_route_for_log(payload.proxy.as_deref()), - requested_connections, - network_error_class(&error), - transfer_preflight_error_code(&error), - admission_started.elapsed().as_millis() - ); - return Err(error); - } - }; - connection_options = Some(prepared.effective_connections); - ( - prepared.uris, - prepared.requested_connections, - prepared.effective_connections, - prepared.credentials_allowed, - ) - }; - if should_apply_aria2_connection_options(payload) { - apply_aria2_connection_options(&mut options, transfer_connections); - } - apply_aria2_normal_reliability_options(&mut options, payload, transfer_uris.len())?; - apply_aria2_follow_options(&mut options, payload); - apply_aria2_torrent_options(&mut options, payload)?; + options.insert("out".to_string(), serde_json::json!(safe_filename)); + let conn = effective_aria2_connections(id, payload).await; + apply_aria2_connection_options(&mut options, conn); let mt = aria2_attempt_limit(payload.max_tries); options.insert("max-tries".to_string(), serde_json::json!(mt.to_string())); options.insert("retry-wait".to_string(), serde_json::json!("2")); @@ -8552,166 +2728,61 @@ impl SidecarSpawner for ProductionSpawner { if let Some(speed) = payload .speed_limit .as_deref() - .and_then(normalize_download_speed_limit) + .and_then(crate::normalize_speed_limit_for_aria2) { options.insert("max-download-limit".to_string(), serde_json::json!(speed)); } - if !payload.is_torrent { - if credentials_allowed { - apply_protocol_auth_options(&mut options, payload, &transfer_uris); - } - apply_checksum_options(&mut options, payload.checksum.as_deref()); + if let Some(user) = &payload.username { + options.insert("http-user".to_string(), serde_json::json!(user)); + } + if let Some(pass) = &payload.password { + options.insert("http-passwd".to_string(), serde_json::json!(pass)); + } + if let Some(chk) = &payload.checksum { + let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') { + format!("{}={}", algo.to_ascii_lowercase(), digest) + } else { + chk.clone() + }; + options.insert("checksum".to_string(), serde_json::json!(formatted_chk)); } if let Some(ua) = &payload.user_agent { options.insert("user-agent".to_string(), serde_json::json!(ua)); } - apply_aria2_header_options(&mut options, payload, credentials_allowed); + let mut header_list = Vec::new(); + if let Some(cook) = &payload.cookies { + header_list.push(format!("Cookie: {}", cook)); + } + if let Some(hdrs) = &payload.headers { + for line in hdrs.lines() { + if !line.trim().is_empty() { + header_list.push(line.trim().to_string()); + } + } + } + if !header_list.is_empty() { + options.insert("header".to_string(), serde_json::json!(header_list)); + } if let Some(prox) = proxy_value { options.insert("all-proxy".to_string(), serde_json::json!(prox)); } - apply_aria2_resolver_options(&mut options, payload.aria2_resolver_mode); - let retry_strike = state.queue_manager.aria2_retry_strike(id).await; - let transfer_host = transfer_uris - .first() - .map(|uri| uri_host_for_log(uri)) - .unwrap_or_else(|| "".to_string()); + let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); + let params = serde_json::json!([uris, options]); - log::info!( - "aria2 admission [stage=admission id={} epoch={} retry_strike={} host={} requested_connections={} effective_connections={} uri_count={} resolver_mode={} proxy_route={} elapsed_ms={}]", - id, - attempt_epoch, - retry_strike, - transfer_host, - requested_connections, - transfer_connections, - transfer_uris.len(), - aria2_effective_resolver_mode(payload), - proxy_route_for_log(payload.proxy.as_deref()), - admission_started.elapsed().as_millis() - ); - - let (method, params) = if payload.is_torrent { - if let Some(path) = payload.torrent_path.as_deref() { - let path = crate::torrent::validate_managed_torrent_path( - &self.app_handle, - id, - path, - )?; - let bytes = crate::torrent::read_bounded_torrent_bytes(&path) - .await - .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let (sanitized_bytes, embedded_web_seeds) = - crate::torrent::sanitize_torrent_bytes_for_aria2(&bytes)?; - let embedded_web_seeds = - crate::filter_torrent_web_seed_destinations(&embedded_web_seeds).await?; - let metadata = crate::torrent::parse_torrent_bytes(&sanitized_bytes)?; - options.insert( - "index-out".to_string(), - serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)), - ); - let selected = crate::torrent::validate_selected_indices( - payload.torrent_file_indices.as_deref(), - metadata.files.len(), - )?; - if let Some(indices) = selected { - options.insert( - "select-file".to_string(), - serde_json::json!(indices.iter().map(u32::to_string).collect::>().join(",")), - ); - } - let encoded = base64::engine::general_purpose::STANDARD.encode(sanitized_bytes); - let fallback_web_seeds = - normalize_torrent_mirror_uris(payload.mirrors.as_deref())?; - crate::validate_torrent_web_seed_destinations(&fallback_web_seeds).await?; - let mut uris = embedded_web_seeds; - uris.extend(fallback_web_seeds); - uris.sort(); - uris.dedup(); - ("aria2.addTorrent", serde_json::json!([encoded, uris, options])) - } else { - let parsed = url::Url::parse(&payload.url) - .map_err(|_| "invalid magnet URI".to_string())?; - if parsed.scheme() != "magnet" { - return Err("torrent transfer has no magnet URI or cached metadata".to_string()); - } - let selected = crate::torrent::validate_selected_indices( - payload.torrent_file_indices.as_deref(), - usize::MAX, - )?; - if selected.is_some() { - return Err("magnet file selection requires resolved torrent metadata".to_string()); - } - let magnet = crate::torrent::sanitize_magnet_uri_for_aria2(&payload.url)?; - ("aria2.addUri", serde_json::json!([[magnet], options])) - } - } else { - ( - "aria2.addUri", - aria2_add_uri_params(transfer_uris, options), - ) - }; - - let lifecycle_current = state - .queue_manager - .is_aria2_control_epoch_current(id, attempt_epoch) - .await - && state.queue_manager.is_registered(id).await; - if !lifecycle_current { - log::info!( - "aria2 admission [stage=admission id={} epoch={} retry_strike={} host={} requested_connections={} effective_connections={} result=stale_before_rpc elapsed_ms={}]", - id, - attempt_epoch, - retry_strike, - transfer_host, - requested_connections, - transfer_connections, - admission_started.elapsed().as_millis() - ); - return Err("aria2 admission canceled before RPC".to_string()); - } - - match self.add_transfer_rpc(&state, method, ¶ms).await { + match self.add_uri_rpc(&state, ¶ms).await { Ok(result) => { let gid = result.as_str().unwrap_or("").to_string(); if gid.is_empty() { - Err(format!("{method} returned an empty gid")) + Err("aria2.addUri returned an empty gid".to_string()) } else { - if let Some(effective) = connection_options { - state - .queue_manager - .set_aria2_connection_options( - id, - attempt_epoch, - effective, - ) - .await; - } - log::info!( - "aria2 {} [stage=admission id={} gid={} epoch={} retry_strike={} elapsed_ms={} result=created]", - method, - id, - gid, - attempt_epoch, - retry_strike, - admission_started.elapsed().as_millis() - ); + log::info!("aria2 addUri [{}]: created gid {}", id, gid); Ok(gid) } } Err(e) => { - let error_code = aria2_error_code(&e) - .unwrap_or_else(|| network_error_class(&e).to_string()); - log::error!( - "aria2 admission [stage=admission id={} epoch={} method={} error_class={} error_code={} elapsed_ms={}]", - id, - attempt_epoch, - method, - network_error_class(&e), - error_code, - admission_started.elapsed().as_millis() - ); let safe_error = crate::redact_sensitive_text(&e); - Err(format!("aria2 {method} failed: {safe_error}")) + log::error!("aria2 addUri [{}] failed: {}", id, safe_error); + Err(format!("aria2 addUri failed: {safe_error}")) } } } @@ -8726,14 +2797,7 @@ impl SidecarSpawner for ProductionSpawner { ) .await?; match result.as_str() { - Some(returned_gid) if returned_gid == gid => { - crate::wait_for_aria2_stopped( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - gid, - ) - .await - } + Some(returned_gid) if returned_gid == gid => Ok(()), Some(returned_gid) => Err(format!( "aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}" )), @@ -8765,107 +2829,6 @@ impl SidecarSpawner for ProductionSpawner { } } - async fn set_torrent_upload_limit( - &self, - gid: &str, - limit: Option<&str>, - ) -> Result<(), String> { - let state = self.app_handle.state::(); - let limit = limit.unwrap_or("0"); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.changeOption", - serde_json::json!([gid, {"max-upload-limit": limit}]), - ) - .await - .map_err(|error| format!("aria2 changeOption failed for gid {gid}: {error}"))?; - match result.as_str() { - Some("OK") => Ok(()), - Some(value) => Err(format!( - "aria2.changeOption returned unexpected result {value} for gid {gid}" - )), - None => Err("aria2.changeOption returned a non-string result".to_string()), - } - } - - async fn set_torrent_peer_options( - &self, - gid: &str, - max_peers: u32, - peer_speed_limit: &str, - ) -> Result<(), String> { - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.changeOption", - serde_json::json!([gid, { - "bt-max-peers": max_peers.to_string(), - "bt-request-peer-speed-limit": peer_speed_limit, - }]), - ) - .await - .map_err(|error| format!("aria2.changeOption failed for gid {gid}: {error}"))?; - match result.as_str() { - Some("OK") => Ok(()), - Some(value) => Err(format!( - "aria2.changeOption returned unexpected result {value} for gid {gid}" - )), - None => Err("aria2.changeOption returned a non-string result".to_string()), - } - } - - async fn pause_for_seed( - &self, - gid: &str, - ) -> Result { - self.control_seed_rpc(gid, "aria2.forcePause").await - } - - async fn resume_for_seed( - &self, - gid: &str, - ) -> Result { - self.control_seed_rpc(gid, "aria2.unpause").await - } - - async fn get_torrent_file_uris(&self, gid: &str) -> Result)>, String> { - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.getFiles", - serde_json::json!([gid]), - ) - .await - .map_err(|error| format!("aria2.getFiles failed for gid {gid}: {}", crate::redact_sensitive_text(&error.to_string())))?; - parse_aria2_torrent_file_uris(&result) - } - - async fn change_torrent_uris( - &self, - gid: &str, - file_index: u32, - delete: &[String], - add: &[String], - ) -> Result<(), String> { - let state = self.app_handle.state::(); - let result = crate::rpc_call( - state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), - &state.aria2_secret, - "aria2.changeUri", - serde_json::json!([gid, file_index, delete, add]), - ) - .await - .map_err(|error| format!("aria2.changeUri failed for gid {gid}: {}", crate::redact_sensitive_text(&error.to_string())))?; - match result.as_str() { - Some("OK") => Ok(()), - Some(value) => Err(format!("aria2.changeUri returned unexpected result {value}")), - None => Err("aria2.changeUri returned a non-string result".to_string()), - } - } - async fn recreate_uri( &self, id: &str, @@ -8904,11 +2867,10 @@ impl SidecarSpawner for ProductionSpawner { match crate::aria2_download_status(port, secret, gid).await { Ok(status) if status == "paused" => { log::warn!( - "aria2 connection recovery [stage=recovery id={} gid={} operation=force_pause result=verified_paused error_class={} error_code={}]", + "aria2 connection recovery [{}]: forcePause for gid {} returned an error after the daemon paused it: {}", id, gid, - network_error_class(&error), - diagnostic_error_code(&error) + error ); } Ok(status) if status == "complete" => { @@ -8918,11 +2880,10 @@ impl SidecarSpawner for ProductionSpawner { if aria2_recovery_should_rebuild_after_pause_error(&status) => { log::warn!( - "aria2 connection recovery [stage=recovery id={} gid={} operation=force_pause result=gid_missing_rebuild error_class={} error_code={}]", + "aria2 connection recovery [{}]: gid {} disappeared after forcePause failed; rebuilding from the saved payload: {}", id, gid, - network_error_class(&error), - diagnostic_error_code(&error) + error ); } Ok(status) => { @@ -9123,7 +3084,7 @@ impl SidecarSpawner for ProductionSpawner { } } -#[derive(Debug, Clone, Default, Deserialize, TS)] +#[derive(Debug, Clone, Deserialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct EnqueueItem { pub id: String, @@ -9135,188 +3096,57 @@ pub struct EnqueueItem { pub speed_limit: Option, pub username: Option, pub password: Option, - #[serde(default)] - #[ts(optional)] - pub sftp_host_key_md: Option, pub headers: Option, pub checksum: Option, pub cookies: Option, pub mirrors: Option, pub user_agent: Option, pub max_tries: Option, - #[serde(default)] - #[ts(optional)] - pub minimum_normal_download_speed_kib: Option, - #[serde(default)] - #[ts(optional)] - pub retry_not_found_errors: Option, - #[serde(default)] - #[ts(optional)] - pub adaptive_mirror_selection: Option, pub proxy: Option, pub format_selector: Option, pub cookie_source: Option, pub is_media: Option, #[serde(default)] #[ts(optional)] - pub is_torrent: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_path: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_file_indices: Option>, - #[serde(default)] - #[ts(optional)] - pub torrent_info_hash: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_time: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_ratio: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_seed_remaining: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_web_seeds: Option>, - #[serde(default)] - #[ts(optional)] - pub torrent_upload_limit: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_max_peers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_peer_speed_limit: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_check_integrity: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_trackers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_exclude_trackers: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_connect_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_tracker_interval: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_stop_timeout: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_prioritize_piece: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_remove_unselected_file: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_encryption_policy: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_file_allocation: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_verify_only: Option, - #[serde(default)] - #[ts(optional)] - pub torrent_verify_restore_status: Option, - #[serde(default)] - #[ts(optional)] pub lifecycle_generation: Option, - #[serde(default)] - #[ts(optional)] - pub replace_existing_fingerprint: Option, } impl EnqueueItem { - pub fn strip_torrent_credentials(&mut self) { - if self.is_torrent.unwrap_or(false) { - self.username = None; - self.password = None; - self.headers = None; - self.cookies = None; - } - } - pub fn into_task(self) -> QueuedTask { - let mut item = self; - item.strip_torrent_credentials(); - let media = item.is_media.unwrap_or(false); + let media = self.is_media.unwrap_or(false); let kind = if media { TaskKind::Media } else { TaskKind::Aria2 }; - let id = item.id.clone(); + let id = self.id.clone(); QueuedTask { id, - queue_id: item.queue_id, + queue_id: self.queue_id, kind, - lifecycle_generation: item + lifecycle_generation: self .lifecycle_generation .as_deref() .and_then(|generation| generation.parse().ok()) .unwrap_or_default(), payload: SpawnPayload { - url: item.url, - destination: item.destination, - filename: item.filename, - connections: item.connections, - speed_limit: item.speed_limit, - username: item.username, - password: item.password, - sftp_host_key_md: item.sftp_host_key_md, - headers: item.headers, - checksum: item.checksum, - cookies: item.cookies, - mirrors: item.mirrors, - user_agent: item.user_agent, - max_tries: item.max_tries, - minimum_normal_download_speed_kib: item - .minimum_normal_download_speed_kib - .unwrap_or_default(), - retry_not_found_errors: item.retry_not_found_errors.unwrap_or(false), - adaptive_mirror_selection: item.adaptive_mirror_selection.unwrap_or(true), - proxy: item.proxy, - aria2_resolver_mode: Aria2ResolverMode::Automatic, - format_selector: item.format_selector, - cookie_source: item.cookie_source, + url: self.url, + destination: self.destination, + filename: self.filename, + connections: self.connections, + speed_limit: self.speed_limit, + username: self.username, + password: self.password, + headers: self.headers, + checksum: self.checksum, + cookies: self.cookies, + mirrors: self.mirrors, + user_agent: self.user_agent, + max_tries: self.max_tries, + proxy: self.proxy, + format_selector: self.format_selector, + cookie_source: self.cookie_source, is_media: media, - is_torrent: item.is_torrent.unwrap_or(false), - torrent_path: item.torrent_path, - torrent_file_indices: item.torrent_file_indices, - torrent_seed_time: item.torrent_seed_remaining.or(item.torrent_seed_time), - torrent_seed_ratio: item.torrent_seed_ratio, - torrent_seed_remaining: item.torrent_seed_remaining, - torrent_web_seeds: item.torrent_web_seeds, - torrent_upload_limit: item.torrent_upload_limit, - torrent_max_peers: item.torrent_max_peers, - torrent_peer_speed_limit: item.torrent_peer_speed_limit, - torrent_check_integrity: item.torrent_check_integrity.unwrap_or(false), - torrent_trackers: item.torrent_trackers, - torrent_exclude_trackers: item.torrent_exclude_trackers, - torrent_tracker_connect_timeout: item.torrent_tracker_connect_timeout, - torrent_tracker_timeout: item.torrent_tracker_timeout, - torrent_tracker_interval: item.torrent_tracker_interval, - torrent_stop_timeout: item.torrent_stop_timeout, - torrent_prioritize_piece: item.torrent_prioritize_piece, - torrent_remove_unselected_file: item - .torrent_remove_unselected_file - .unwrap_or(false), - torrent_encryption_policy: item.torrent_encryption_policy, - torrent_file_allocation: item.torrent_file_allocation, - torrent_verify_only: item.torrent_verify_only.unwrap_or(false), - torrent_verify_restore_status: item.torrent_verify_restore_status, - torrent_verified_length: None, }, } } @@ -9326,504 +3156,6 @@ impl EnqueueItem { mod tests { use super::*; - struct TestSpawner; - - #[async_trait::async_trait] - impl SidecarSpawner for TestSpawner { - async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { - Ok("test-gid".to_string()) - } - - async fn remove_uri(&self, _gid: &str) -> Result<(), String> { - Ok(()) - } - - async fn run_media( - &self, - _id: &str, - _payload: &SpawnPayload, - _lifecycle_generation: u64, - ) -> Result<(), String> { - Ok(()) - } - } - - struct BlockingSpawner { - started: Arc, - release: Arc, - } - - #[async_trait::async_trait] - impl SidecarSpawner for BlockingSpawner { - async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { - self.started.notify_one(); - self.release.notified().await; - Ok("blocking-gid".to_string()) - } - - async fn remove_uri(&self, _gid: &str) -> Result<(), String> { - Ok(()) - } - - async fn run_media( - &self, - _id: &str, - _payload: &SpawnPayload, - _lifecycle_generation: u64, - ) -> Result<(), String> { - Ok(()) - } - } - - fn allocation_pending_epoch( - manager: &QueueManager, - id: &str, - ) -> Option<(u64, u64)> { - manager - .aria2_allocation_pending - .try_lock() - .ok() - .and_then(|pending| pending.get(id).copied()) - } - - #[test] - fn aria2_allocation_eligibility_matches_download_type_and_torrent_policy() { - assert!(QueueManager::::aria2_allocation_phase_eligible( - &SpawnPayload::default() - )); - assert!(!QueueManager::::aria2_allocation_phase_eligible( - &SpawnPayload { - is_media: true, - ..SpawnPayload::default() - } - )); - assert!(!QueueManager::::aria2_allocation_phase_eligible( - &SpawnPayload { - is_torrent: true, - torrent_file_allocation: Some("prealloc".to_string()), - ..SpawnPayload::default() - } - )); - assert!(!QueueManager::::aria2_allocation_phase_eligible( - &SpawnPayload { - is_torrent: true, - torrent_file_allocation: Some("none".to_string()), - ..SpawnPayload::default() - } - )); - assert!(!QueueManager::::aria2_allocation_phase_eligible( - &SpawnPayload { - is_torrent: true, - torrent_verify_only: true, - ..SpawnPayload::default() - } - )); - } - - #[tokio::test] - async fn allocation_stays_pending_while_async_add_uri_is_in_flight() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let started = Arc::new(tokio::sync::Notify::new()); - let release = Arc::new(tokio::sync::Notify::new()); - let manager = Arc::new(QueueManager::test_new( - app.handle().clone(), - 1, - Arc::new(BlockingSpawner { - started: Arc::clone(&started), - release: Arc::clone(&release), - }), - )); - let previous_generation = manager - .reserve_enqueue_generation("allocation", 7) - .await - .expect("lifecycle reservation"); - manager - .commit_reserved_enqueue( - QueuedTask { - id: "allocation".to_string(), - queue_id: "main".to_string(), - kind: TaskKind::Aria2, - payload: SpawnPayload::default(), - lifecycle_generation: 7, - }, - 7, - previous_generation, - ) - .await - .expect("queued task"); - - let dispatcher = tokio::spawn(Arc::clone(&manager).run_dispatcher()); - tokio::time::timeout(Duration::from_secs(1), started.notified()) - .await - .expect("addUri should begin"); - assert_eq!(allocation_pending_epoch(&manager, "allocation"), Some((1, 7))); - - release.notify_one(); - tokio::time::timeout(Duration::from_secs(1), async { - loop { - if manager.aria2_gid_for_download("allocation").as_deref() == Some("blocking-gid") { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("async addUri should install its GID"); - manager.handle_aria2_download_start("blocking-gid", 1).await; - assert_eq!(allocation_pending_epoch(&manager, "allocation"), None); - dispatcher.abort(); - } - - #[tokio::test] - async fn enqueue_finalizer_failure_removes_pending_task_before_dispatch() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let id = "finalizer-failure"; - let generation = 3; - let previous_generation = manager - .reserve_enqueue_generation(id, generation) - .await - .expect("lifecycle reservation"); - - let error = manager - .commit_reserved_enqueue_with_finalizer( - QueuedTask { - id: id.to_string(), - queue_id: "main".to_string(), - kind: TaskKind::Aria2, - payload: SpawnPayload::default(), - lifecycle_generation: generation, - }, - generation, - previous_generation, - || async { Err("journal commit failed".to_string()) }, - ) - .await - .expect_err("a failed finalizer must reject admission"); - - assert_eq!(error, "journal commit failed"); - assert!(manager.pending_order(None).await.is_empty()); - assert_eq!(manager.registered_lifecycle_generation(id).await, None); - assert!(!manager.is_registered(id).await); - } - - #[tokio::test] - async fn enqueue_cancellation_during_finalizer_rejects_admission() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = Arc::new(QueueManager::test_new( - app.handle().clone(), - 1, - Arc::new(TestSpawner), - )); - let started = Arc::new(tokio::sync::Notify::new()); - let release = Arc::new(tokio::sync::Notify::new()); - let finalizer_started = Arc::clone(&started); - let finalizer_release = Arc::clone(&release); - let id = "finalizer-cancelled".to_string(); - let generation = 4; - let previous_generation = manager - .reserve_enqueue_generation(&id, generation) - .await - .expect("lifecycle reservation"); - - let commit_manager = Arc::clone(&manager); - let commit_id = id.clone(); - let commit = tokio::spawn(async move { - commit_manager - .commit_reserved_enqueue_with_finalizer( - QueuedTask { - id: commit_id, - queue_id: "main".to_string(), - kind: TaskKind::Aria2, - payload: SpawnPayload::default(), - lifecycle_generation: generation, - }, - generation, - previous_generation, - { - move || async move { - finalizer_started.notify_one(); - finalizer_release.notified().await; - Ok(()) - } - }, - ) - .await - }); - - tokio::time::timeout(Duration::from_secs(1), started.notified()) - .await - .expect("finalizer should begin"); - manager.cancel_enqueue_generation(&id, generation).await; - release.notify_one(); - - let error = tokio::time::timeout(Duration::from_secs(1), commit) - .await - .expect("enqueue should finish") - .expect("enqueue task should not panic") - .expect_err("cancellation must reject the in-flight admission"); - assert_eq!( - error, - "Download enqueue was superseded by a newer user action" - ); - assert!(manager.pending_order(None).await.is_empty()); - assert!(!manager.is_registered(&id).await); - } - - #[tokio::test] - async fn download_start_before_gid_registration_is_buffered_and_consumed() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let epoch = manager.next_aria2_control_epoch("buffered-start").await; - manager - .begin_aria2_allocation( - "buffered-start", - epoch, - 9, - &SpawnPayload::default(), - ) - .await; - manager.handle_aria2_download_start("early-gid", 1).await; - assert!(manager - .pending_download_starts - .lock() - .await - .contains("early-gid")); - - manager - .remember_gid("buffered-start".to_string(), "early-gid".to_string()) - .await; - assert_eq!(allocation_pending_epoch(&manager, "buffered-start"), None); - assert!(!manager - .pending_download_starts - .lock() - .await - .contains("early-gid")); - } - - #[tokio::test] - async fn allocation_fallback_and_stale_epochs_cannot_clear_a_new_lifecycle() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let first_epoch = manager.next_aria2_control_epoch("stale-allocation").await; - manager - .begin_aria2_allocation( - "stale-allocation", - first_epoch, - 10, - &SpawnPayload::default(), - ) - .await; - let second_epoch = manager.next_aria2_control_epoch("stale-allocation").await; - manager - .begin_aria2_allocation( - "stale-allocation", - second_epoch, - 11, - &SpawnPayload::default(), - ) - .await; - manager - .clear_aria2_allocation_for_epoch("stale-allocation", first_epoch) - .await; - manager - .clear_aria2_allocation_for_lifecycle_generation("stale-allocation", 10) - .await; - assert_eq!( - allocation_pending_epoch(&manager, "stale-allocation"), - Some((second_epoch, 11)) - ); - - manager - .remember_gid("stale-allocation".to_string(), "fallback-gid".to_string()) - .await; - manager - .handle_aria2_download_start("fallback-gid", 0) - .await; - assert_eq!( - allocation_pending_epoch(&manager, "stale-allocation"), - Some((second_epoch, 11)) - ); - manager - .complete_aria2_allocation_for_gid("fallback-gid", 0) - .await; - assert_eq!( - allocation_pending_epoch(&manager, "stale-allocation"), - Some((second_epoch, 11)) - ); - manager - .complete_aria2_allocation_for_gid("fallback-gid", 1) - .await; - assert_eq!(allocation_pending_epoch(&manager, "stale-allocation"), None); - - manager - .begin_aria2_allocation( - "stale-allocation", - second_epoch, - 11, - &SpawnPayload::default(), - ) - .await; - manager.handle_aria2_download_start("fallback-gid", 1).await; - assert_eq!(allocation_pending_epoch(&manager, "stale-allocation"), None); - } - - #[tokio::test] - async fn ignored_gid_start_markers_cannot_clear_a_new_lifecycle() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let old_epoch = manager.next_aria2_control_epoch("ignored-start").await; - manager - .begin_aria2_allocation( - "ignored-start", - old_epoch, - 20, - &SpawnPayload::default(), - ) - .await; - manager - .remember_gid("ignored-start".to_string(), "reused-gid".to_string()) - .await; - manager.forget_aria2_gid("ignored-start").await; - - manager.handle_aria2_download_start("reused-gid", 1).await; - assert!(!manager - .pending_download_starts - .lock() - .await - .contains("reused-gid")); - - let new_epoch = manager.next_aria2_control_epoch("ignored-start").await; - manager - .begin_aria2_allocation( - "ignored-start", - new_epoch, - 21, - &SpawnPayload::default(), - ) - .await; - manager - .remember_gid("ignored-start".to_string(), "new-gid".to_string()) - .await; - assert_eq!( - allocation_pending_epoch(&manager, "ignored-start"), - Some((new_epoch, 21)) - ); - manager.handle_aria2_download_start("new-gid", 1).await; - assert_eq!(allocation_pending_epoch(&manager, "ignored-start"), None); - } - - #[tokio::test] - async fn allocation_is_cleared_by_terminal_reconciliation_and_cancellation() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let epoch = manager.next_aria2_control_epoch("terminal-allocation").await; - manager - .begin_aria2_allocation( - "terminal-allocation", - epoch, - 12, - &SpawnPayload::default(), - ) - .await; - manager - .apply_completion("terminal-allocation", PendingOutcome::Error("disk full".to_string())) - .await; - assert_eq!(allocation_pending_epoch(&manager, "terminal-allocation"), None); - - let next_epoch = manager.next_aria2_control_epoch("terminal-allocation").await; - manager - .begin_aria2_allocation( - "terminal-allocation", - next_epoch, - 13, - &SpawnPayload::default(), - ) - .await; - manager.clear_aria2_allocation("terminal-allocation").await; - assert_eq!(allocation_pending_epoch(&manager, "terminal-allocation"), None); - } - - struct SeedSpawner; - - #[async_trait::async_trait] - impl SidecarSpawner for SeedSpawner { - async fn add_uri(&self, id: &str, _payload: &SpawnPayload) -> Result { - Ok(format!("gid-{id}")) - } - - async fn remove_uri(&self, _gid: &str) -> Result<(), String> { - Ok(()) - } - - async fn pause_for_seed(&self, _gid: &str) -> Result { - Ok(Aria2SeedControlOutcome::Paused) - } - - async fn resume_for_seed(&self, _gid: &str) -> Result { - Ok(Aria2SeedControlOutcome::Resumed) - } - - async fn run_media( - &self, - _id: &str, - _payload: &SpawnPayload, - _lifecycle_generation: u64, - ) -> Result<(), String> { - Ok(()) - } - } - - struct AmbiguousResumeSeedSpawner { - resume_calls: Arc, - } - - #[async_trait::async_trait] - impl SidecarSpawner for AmbiguousResumeSeedSpawner { - async fn add_uri(&self, id: &str, _payload: &SpawnPayload) -> Result { - Ok(format!("gid-{id}")) - } - - async fn remove_uri(&self, _gid: &str) -> Result<(), String> { - Ok(()) - } - - async fn pause_for_seed(&self, _gid: &str) -> Result { - Ok(Aria2SeedControlOutcome::Paused) - } - - async fn resume_for_seed(&self, _gid: &str) -> Result { - if self.resume_calls.fetch_add(1, Ordering::SeqCst) == 0 { - Err("seed resume status verification unavailable".to_string()) - } else { - Ok(Aria2SeedControlOutcome::Resumed) - } - } - - async fn run_media( - &self, - _id: &str, - _payload: &SpawnPayload, - _lifecycle_generation: u64, - ) -> Result<(), String> { - Ok(()) - } - } - #[test] fn aria2_connection_options_enable_requested_ranges_for_small_release_assets() { let mut options = serde_json::Map::new(); @@ -9845,2198 +3177,6 @@ mod tests { ); } - #[test] - fn normal_reliability_options_are_bounded_and_do_not_apply_to_torrents() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - minimum_normal_download_speed_kib: 64, - adaptive_mirror_selection: true, - ..SpawnPayload::default() - }; - apply_aria2_normal_reliability_options(&mut options, &payload, 2).unwrap(); - assert_eq!( - options.get("lowest-speed-limit"), - Some(&serde_json::json!("64K")) - ); - assert_eq!( - options.get("uri-selector"), - Some(&serde_json::json!("adaptive")) - ); - - options.clear(); - apply_aria2_normal_reliability_options(&mut options, &payload, 1).unwrap(); - assert!(!options.contains_key("uri-selector")); - - options.clear(); - apply_aria2_normal_reliability_options( - &mut options, - &SpawnPayload { - is_torrent: true, - minimum_normal_download_speed_kib: 64, - adaptive_mirror_selection: true, - ..SpawnPayload::default() - }, - 2, - ) - .unwrap(); - assert!(options.is_empty()); - assert!(normalize_minimum_normal_download_speed_kib( - MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB + 1 - ) - .is_err()); - } - - #[test] - fn aria2_system_resolver_mode_is_per_transfer_and_preserves_automatic_default() { - let mut automatic = serde_json::Map::new(); - apply_aria2_resolver_options(&mut automatic, Aria2ResolverMode::Automatic); - assert!(!automatic.contains_key("async-dns")); - - let mut system = serde_json::Map::new(); - apply_aria2_resolver_options(&mut system, Aria2ResolverMode::System); - assert_eq!(system.get("async-dns"), Some(&serde_json::json!("false"))); - } - - #[test] - fn resolver_state_events_are_typed_and_redacted() { - let event = DownloadStateEvent::failed( - "dns-event", - "aria2 error code 19: Name resolution failed for https://example.test/file?token=secret", - ); - assert_eq!(event.error_kind, Some(crate::ipc::DownloadErrorKind::NameResolution)); - assert!(event.error.as_deref().is_some_and(|error| { - error.contains("[redacted]") && !error.contains("token=secret") - })); - - let retry = DownloadStateEvent::retrying_with_resolver_fallback( - "dns-event", - "aria2 error code 19: Name resolution failed", - ); - assert_eq!(retry.error_kind, Some(crate::ipc::DownloadErrorKind::NameResolution)); - assert_eq!(retry.resolver_fallback, Some(true)); - } - - #[test] - fn torrent_payloads_do_not_use_generic_connection_options() { - let torrent = SpawnPayload { - is_torrent: true, - connections: Some(16), - ..Default::default() - }; - let normal = SpawnPayload { - is_torrent: false, - connections: Some(16), - ..Default::default() - }; - let mut torrent_options = serde_json::Map::new(); - if should_apply_aria2_connection_options(&torrent) { - apply_aria2_connection_options(&mut torrent_options, 16); - } - assert!(!torrent_options.contains_key("split")); - assert!(!torrent_options.contains_key("max-connection-per-server")); - - let mut normal_options = serde_json::Map::new(); - if should_apply_aria2_connection_options(&normal) { - apply_aria2_connection_options(&mut normal_options, 16); - } - assert_eq!(normal_options.get("split"), Some(&serde_json::json!("16"))); - assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16"))); - } - - #[test] - fn torrent_payloads_do_not_emit_generic_headers_or_cookies() { - let payload = SpawnPayload { - is_torrent: true, - headers: Some("User-Agent: browser\nAuthorization: secret".to_string()), - cookies: Some("session=secret".to_string()), - ..Default::default() - }; - let mut options = serde_json::Map::new(); - - apply_aria2_header_options(&mut options, &payload, false); - - assert!(!options.contains_key("header")); - } - - #[test] - fn torrent_enqueue_items_strip_generic_credentials_before_task_creation() { - let mut item = EnqueueItem { - is_torrent: Some(true), - username: Some("browser-user".to_string()), - password: Some("secret".to_string()), - headers: Some("Authorization: secret".to_string()), - cookies: Some("session=secret".to_string()), - ..Default::default() - }; - - item.strip_torrent_credentials(); - let task = item.into_task(); - - assert!(task.payload.username.is_none()); - assert!(task.payload.password.is_none()); - assert!(task.payload.headers.is_none()); - assert!(task.payload.cookies.is_none()); - } - - #[test] - fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() { - assert_eq!( - normalize_torrent_bind_address(Some(" 2001:db8::1 ")).unwrap(), - Some("2001:db8::1".to_string()) - ); - assert_eq!(normalize_torrent_bind_address(Some(" ")).unwrap(), None); - assert!(normalize_torrent_bind_address(Some("localhost")).is_err()); - assert!(normalize_torrent_bind_address(Some("127.0.0.1\n--bad")).is_err()); - - assert_eq!(normalize_aria2_disk_cache(None).unwrap(), "16M"); - assert_eq!(normalize_aria2_disk_cache(Some(" 256m ")).unwrap(), "256M"); - assert_eq!(normalize_aria2_disk_cache(Some("1024K")).unwrap(), "1024K"); - assert_eq!(normalize_aria2_disk_cache(Some("0")).unwrap(), "0"); - assert!(normalize_aria2_disk_cache(Some("1025M")).is_err()); - assert!(normalize_aria2_disk_cache(Some("16")).is_err()); - - assert_eq!(normalize_torrent_file_allocation(None).unwrap(), "prealloc"); - assert_eq!(normalize_torrent_file_allocation(Some(" none ")).unwrap(), "none"); - assert!(normalize_torrent_file_allocation(Some("truncate")).is_err()); - } - - #[test] - fn torrent_verification_evidence_requires_matching_lengths_but_accepts_empty_data() { - assert_eq!( - QueueManager::::complete_torrent_verification_length( - Some("complete"), - false, - Some(0), - Some(0), - ), - Some(0) - ); - assert_eq!( - QueueManager::::complete_torrent_verification_length( - Some("complete"), - false, - Some(100), - Some(99), - ), - None - ); - assert_eq!( - QueueManager::::complete_torrent_verification_length( - Some("complete"), - true, - Some(100), - Some(100), - ), - None - ); - } - - #[test] - fn torrent_options_disable_seeding_when_no_policy_is_saved() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); - assert!(!options.contains_key("max-upload-limit")); - assert_eq!( - options.get("bt-force-encryption"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("bt-require-crypto"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("bt-min-crypto-level"), - Some(&serde_json::json!("plain")) - ); - } - - #[test] - fn direct_magnet_parent_detection_requires_a_metadata_file() { - let parent = serde_json::json!({ - "status": "complete", - "followedBy": ["0123456789abcdef"], - "files": [{"path": "[METADATA]firelink-torrent-runtime"}], - }); - assert!(aria2_magnet_parent_has_metadata_file(&parent)); - assert!(!aria2_magnet_parent_has_payload_file(&parent)); - assert_eq!( - aria2_magnet_followed_gids(&parent, "fedcba9876543210"), - vec!["0123456789abcdef".to_string()] - ); - - let malformed = serde_json::json!({ - "followedBy": [ - "not-a-gid", - "0123456789abcdef", - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef1" - ], - "files": [{"path": "payload.bin"}], - }); - assert!(!aria2_magnet_parent_has_metadata_file(&malformed)); - assert!(aria2_magnet_parent_has_payload_file(&malformed)); - assert_eq!( - aria2_magnet_followed_gids(&malformed, "parent"), - vec!["0123456789abcdef".to_string()] - ); - - let final_payload = serde_json::json!({ - "status": "complete", - "files": [{"path": "/downloads/payload.bin"}], - }); - assert!(aria2_magnet_parent_has_payload_file(&final_payload)); - } - - #[tokio::test] - async fn direct_magnet_control_status_cannot_treat_parent_as_payload() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let id = "direct-magnet-control"; - let gid = "0123456789abcdef"; - let payload = SpawnPayload { - url: "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567" - .to_string(), - is_torrent: true, - ..Default::default() - }; - assert!(is_direct_magnet_payload(&payload)); - manager.aria2_payloads.lock().await.insert( - id.to_string(), - payload, - ); - manager.remember_gid(id.to_string(), gid.to_string()).await; - - assert_eq!(manager.aria2_gid_for_download(id).as_deref(), Some(gid)); - assert!( - manager - .aria2_direct_magnet_needs_child_handoff(id, gid) - .await - ); - - manager - .aria2_magnet_payload_gids - .lock() - .await - .insert( - id.to_string(), - Aria2GidMapping { - id: id.to_string(), - epoch: 0, - }, - ); - assert!(!manager - .aria2_direct_magnet_needs_child_handoff(id, gid) - .await); - } - - #[test] - fn normal_torrent_options_make_metadata_and_following_policy_explicit() { - let mut options = serde_json::Map::new(); - apply_aria2_torrent_options( - &mut options, - &SpawnPayload { - is_torrent: true, - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!( - options.get("bt-metadata-only"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("bt-save-metadata"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("follow-torrent"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("async-dns"), - Some(&serde_json::json!("false")) - ); - - let mut proxied_options = serde_json::Map::new(); - apply_aria2_torrent_options( - &mut proxied_options, - &SpawnPayload { - is_torrent: true, - proxy: Some("http://127.0.0.1:8080".to_string()), - ..Default::default() - }, - ) - .unwrap(); - assert!(!proxied_options.contains_key("async-dns")); - assert_eq!( - aria2_effective_resolver_mode(&SpawnPayload { - is_torrent: true, - ..Default::default() - }), - "system" - ); - assert_eq!( - aria2_effective_resolver_mode(&SpawnPayload { - is_torrent: true, - proxy: Some("http://127.0.0.1:8080".to_string()), - ..Default::default() - }), - "automatic" - ); - } - - #[test] - fn generic_aria2_downloads_disable_followed_child_gids() { - let mut options = serde_json::Map::new(); - apply_aria2_follow_options(&mut options, &SpawnPayload::default()); - - assert_eq!( - options.get("follow-torrent"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("follow-metalink"), - Some(&serde_json::json!("false")) - ); - } - - #[test] - fn explicit_torrent_downloads_do_not_override_follow_policy() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - ..Default::default() - }; - - apply_aria2_follow_options(&mut options, &payload); - - assert!(!options.contains_key("follow-torrent")); - assert!(!options.contains_key("follow-metalink")); - } - - #[test] - fn torrent_encryption_policy_maps_to_one_consistent_aria2_policy() { - let cases = [ - ( - Some("require-crypto"), - ("false", "true", "plain"), - ), - ( - Some("force-encryption"), - ("true", "true", "arc4"), - ), - (None, ("false", "false", "plain")), - ]; - - for (policy, expected) in cases { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_encryption_policy: policy.map(str::to_string), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!( - options.get("bt-force-encryption"), - Some(&serde_json::json!(expected.0)) - ); - assert_eq!( - options.get("bt-require-crypto"), - Some(&serde_json::json!(expected.1)) - ); - assert_eq!( - options.get("bt-min-crypto-level"), - Some(&serde_json::json!(expected.2)) - ); - } - } - - #[test] - fn torrent_encryption_policy_rejects_unknown_values() { - assert_eq!(normalize_torrent_encryption_policy(None).unwrap(), None); - assert_eq!( - normalize_torrent_encryption_policy(Some(" disabled ")).unwrap(), - None - ); - assert_eq!( - normalize_torrent_encryption_policy(Some("require-crypto")).unwrap(), - Some("require-crypto".to_string()) - ); - assert!(normalize_torrent_encryption_policy(Some("arc4")).is_err()); - assert!(normalize_torrent_encryption_policy(Some("true")).is_err()); - } - - #[test] - fn torrent_encryption_policy_is_not_applied_to_non_torrent_payloads() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - torrent_encryption_policy: Some("force-encryption".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert!(!options.contains_key("bt-force-encryption")); - assert!(!options.contains_key("bt-require-crypto")); - assert!(!options.contains_key("bt-min-crypto-level")); - } - - #[test] - fn torrent_options_preserve_seed_policy_and_upload_limit() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(30.0), - torrent_seed_ratio: Some(1.5), - torrent_upload_limit: Some("2 MiB/s".to_string()), - torrent_max_peers: Some(120), - torrent_peer_speed_limit: Some("2M".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!(options.get("seed-time"), Some(&serde_json::json!("30"))); - assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5"))); - assert_eq!( - options.get("max-upload-limit"), - Some(&serde_json::json!("2M")) - ); - assert_eq!( - options.get("bt-max-peers"), - Some(&serde_json::json!("120")) - ); - assert_eq!( - options.get("bt-request-peer-speed-limit"), - Some(&serde_json::json!("2M")) - ); - assert!(torrent_seeding_requested(&payload)); - } - - #[test] - fn torrent_options_support_ratio_only_seeding_without_disabling_seed_time() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_seed_ratio: Some(1.5), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert!(!options.contains_key("seed-time")); - assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5"))); - assert!(torrent_seeding_requested(&payload)); - } - - #[test] - fn torrent_integrity_check_does_not_enter_seeding_without_a_seed_policy() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_check_integrity: true, - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); - assert_eq!( - options.get("bt-hash-check-seed"), - Some(&serde_json::json!("false")) - ); - assert_eq!( - options.get("bt-seed-unverified"), - Some(&serde_json::json!("false")) - ); - } - - #[test] - fn torrent_verification_uses_hash_only_options_and_ignores_transfer_policy() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_verify_only: true, - torrent_trackers: Some("https://tracker.example/announce".to_string()), - torrent_seed_ratio: Some(1.0), - torrent_file_allocation: Some("none".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); - assert_eq!(options.get("hash-check-only"), Some(&serde_json::json!("true"))); - assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); - assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("0"))); - assert!(!options.contains_key("bt-tracker")); - assert!(!options.contains_key("file-allocation")); - } - - #[test] - fn torrent_integrity_check_preserves_an_explicit_seeding_policy() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_check_integrity: true, - torrent_seed_ratio: Some(1.0), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); - assert_eq!( - options.get("bt-hash-check-seed"), - Some(&serde_json::json!("true")) - ); - assert_eq!( - options.get("bt-seed-unverified"), - Some(&serde_json::json!("false")) - ); - } - - #[test] - fn torrent_integrity_options_are_not_emitted_when_disabled_or_non_torrent() { - for payload in [ - SpawnPayload::default(), - SpawnPayload { - is_torrent: true, - ..Default::default() - }, - ] { - let mut options = serde_json::Map::new(); - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - assert!(!options.contains_key("check-integrity")); - assert!(!options.contains_key("bt-hash-check-seed")); - assert!(!options.contains_key("bt-seed-unverified")); - } - } - - #[test] - fn enqueue_item_deserializes_integrity_policy_from_frontend_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-integrity", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_check_integrity": true - })) - .expect("frontend enqueue payload should deserialize"); - - assert!(item.into_task().payload.torrent_check_integrity); - } - - #[test] - fn enqueue_item_preserves_torrent_encryption_policy() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-encryption", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_encryption_policy": "force-encryption" - })) - .expect("frontend enqueue payload should deserialize"); - - assert_eq!( - item.into_task().payload.torrent_encryption_policy.as_deref(), - Some("force-encryption") - ); - } - - #[test] - fn enqueue_item_preserves_torrent_tracker_timing() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-tracker-timing", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_tracker_connect_timeout": 11, - "torrent_tracker_timeout": 22, - "torrent_tracker_interval": 33 - })) - .expect("frontend enqueue payload should deserialize"); - - let payload = item.into_task().payload; - assert_eq!(payload.torrent_tracker_connect_timeout, Some(11)); - assert_eq!(payload.torrent_tracker_timeout, Some(22)); - assert_eq!(payload.torrent_tracker_interval, Some(33)); - } - - #[test] - fn torrent_network_settings_are_normalized_and_bounded() { - assert_eq!( - normalize_torrent_port_spec(Some(" 6881-6999, 7000 "), "TCP listen ports").unwrap(), - Some("6881-6999,7000".to_string()) - ); - assert_eq!( - normalize_torrent_external_ip(Some(" 2001:db8::1 ")).unwrap(), - Some("2001:db8::1".to_string()) - ); - assert_eq!( - normalize_torrent_dht_entry_point(Some("Bootstrap.Example:6881"), false).unwrap(), - Some("bootstrap.example:6881".to_string()) - ); - assert_eq!( - normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), true).unwrap(), - Some("[2001:db8::1]:6881".to_string()) - ); - assert_eq!( - normalize_torrent_dht_listen_addr6(Some("2001:db8::2")).unwrap(), - Some("2001:db8::2".to_string()) - ); - assert_eq!( - normalize_torrent_lpd_interface(Some("en0")).unwrap(), - Some("en0".to_string()) - ); - assert_eq!( - normalize_torrent_peer_id_prefix(Some("-FL-1-3-1-")).unwrap(), - Some("-FL-1-3-1-".to_string()) - ); - assert_eq!( - normalize_torrent_peer_agent(Some("Firelink/1.3.1")).unwrap(), - Some("Firelink/1.3.1".to_string()) - ); - assert!(torrent_port_spec_contains("6800-6802,6881", 6801)); - assert!(!torrent_port_spec_contains("6800-6802,6881", 6803)); - assert_eq!(normalize_torrent_port_spec(Some(" "), "TCP listen ports").unwrap(), None); - assert_eq!( - normalize_torrent_port_spec(Some("1024"), "TCP listen ports").unwrap(), - Some("1024".to_string()) - ); - } - - #[test] - fn torrent_network_settings_reject_unsafe_or_malformed_values() { - for value in ["0", "1023", "65536", "7000-6999", "6881,", "6881-"] { - assert!( - normalize_torrent_port_spec(Some(value), "TCP listen ports").is_err(), - "{value}" - ); - } - assert!(normalize_torrent_external_ip(Some("example.com")).is_err()); - assert!(normalize_torrent_dht_entry_point(Some("example.com"), false).is_err()); - assert!(normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), false).is_err()); - assert!(normalize_torrent_dht_entry_point(Some("2001:db8::1:6881"), true).is_err()); - assert!(normalize_torrent_dht_listen_addr6(Some("127.0.0.1")).is_err()); - assert!(normalize_torrent_lpd_interface(Some("en0\n--bad")).is_err()); - assert!(normalize_torrent_peer_id_prefix(Some("é")).is_err()); - assert!(normalize_torrent_peer_id_prefix(Some("123456789012345678901")).is_err()); - assert!(normalize_torrent_peer_agent(Some("agent\nname")).is_err()); - assert!(normalize_torrent_peer_agent(Some(&"a".repeat(MAX_TORRENT_PEER_AGENT_LENGTH + 1))).is_err()); - assert!( - normalize_torrent_port_spec( - Some(&"1".repeat(MAX_TORRENT_NETWORK_VALUE_LENGTH + 1)), - "TCP listen ports" - ) - .is_err() - ); - } - - #[test] - fn torrent_trackers_are_normalized_and_deduplicated() { - assert_eq!( - normalize_torrent_trackers(Some( - " https://tracker.example/announce\nudp://tracker.example:6969/announce\nhttps://tracker.example/announce " - )) - .unwrap(), - Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string()) - ); - assert_eq!(normalize_torrent_trackers(Some(" \n\n ")).unwrap(), None); - } - - #[test] - fn torrent_trackers_reject_unsafe_or_unbounded_values() { - for value in [ - "ftp://tracker.example/announce", - "https://user:pass@tracker.example/announce", - "https://tracker.example/announce#fragment", - "https://tracker.example/announce,", - "https://", - ] { - assert!(normalize_torrent_trackers(Some(value)).is_err(), "{value}"); - } - let too_many = (0..=MAX_TORRENT_TRACKERS) - .map(|index| format!("https://tracker{index}.example/announce")) - .collect::>() - .join("\n"); - assert!(normalize_torrent_trackers(Some(&too_many)).is_err()); - } - - #[test] - fn torrent_fallback_mirrors_use_the_dedicated_http_policy() { - assert_eq!( - normalize_torrent_mirror_uris(Some( - " https://mirror.example/one\nhttps://mirror.example/one\nhttps://mirror.example/two " - )) - .unwrap(), - vec![ - "https://mirror.example/one".to_string(), - "https://mirror.example/two".to_string() - ] - ); - for value in [ - "ftp://mirror.example/file", - "sftp://mirror.example/file", - "https://user:pass@mirror.example/file", - "https://mirror.example/file#fragment", - ] { - assert!( - normalize_torrent_mirror_uris(Some(value)).is_err(), - "{value}" - ); - } - } - - #[test] - fn initial_torrent_web_seed_readback_keeps_existing_and_explicit_uris() { - let current = HashMap::from([ - ( - 1, - HashSet::from(["https://embedded.example/file".to_string()]), - ), - ( - 2, - HashSet::from(["https://legacy.example/file".to_string()]), - ), - ]); - let explicit = vec![(1, "https://explicit.example/file".to_string())]; - let mut expected = current.clone(); - expected - .get_mut(&1) - .expect("file 1 should be present") - .insert("https://explicit.example/file".to_string()); - - assert_eq!( - expected_initial_torrent_web_seed_state(¤t, &explicit), - expected - ); - } - - #[test] - fn aria2_web_seed_readback_keeps_file_ownership_separate() { - let files = vec![ - crate::ipc::TorrentFile { - index: 1, - path: "one.bin".to_string(), - length: 1, - }, - crate::ipc::TorrentFile { - index: 2, - path: "two.bin".to_string(), - length: 1, - }, - ]; - let result = serde_json::json!([ - { - "index": "1", - "uris": [{"uri": "https://mirror.example/bundle/one.bin"}] - }, - { - "index": "2", - "uris": [{"uri": "https://mirror.example/bundle/two.bin"}] - } - ]); - let state = normalize_aria2_torrent_file_uris( - parse_aria2_torrent_file_uris(&result).unwrap(), - &files, - ) - .unwrap(); - - assert!(state[&1].contains("https://mirror.example/bundle/one.bin")); - assert!(!state[&1].contains("https://mirror.example/bundle/two.bin")); - assert!(state[&2].contains("https://mirror.example/bundle/two.bin")); - assert!(normalize_aria2_torrent_file_uris( - parse_aria2_torrent_file_uris(&serde_json::json!([ - {"index": "0", "uris": []}, - {"index": "2", "uris": []} - ])) - .unwrap(), - &files, - ) - .is_err()); - } - - #[test] - fn aria2_expands_unscoped_web_seed_baselines_per_torrent_file() { - let metadata = crate::torrent::ParsedTorrent { - name: "bundle".to_string(), - total_bytes: 2, - files: vec![ - crate::ipc::TorrentFile { - index: 1, - path: "folder/one.bin".to_string(), - length: 1, - }, - crate::ipc::TorrentFile { - index: 2, - path: "two.bin".to_string(), - length: 1, - }, - ], - info_hash: "0123456789abcdef0123456789abcdef01234567".to_string(), - web_seeds: Vec::new(), - }; - let expanded = expand_torrent_web_seed_sources( - &["https://mirror.example/base".to_string()], - &metadata, - ) - .unwrap(); - - assert_eq!( - expanded, - HashSet::from([ - ( - 1, - "https://mirror.example/base/bundle/folder/one.bin".to_string() - ), - (2, "https://mirror.example/base/bundle/two.bin".to_string()), - ]) - ); - } - - #[test] - fn torrent_web_seed_change_preserves_baseline_and_removes_only_explicit_pairs() { - let current = HashMap::from([ - ( - 1, - HashSet::from([ - "https://mirror.example/bundle/one.bin".to_string(), - "https://explicit.example/one.bin".to_string(), - ]), - ), - ( - 2, - HashSet::from(["https://mirror.example/bundle/two.bin".to_string()]), - ), - ]); - let baseline = HashSet::from([ - (1, "https://mirror.example/bundle/one.bin".to_string()), - (2, "https://mirror.example/bundle/two.bin".to_string()), - ]); - let old = vec![ - (1, "https://mirror.example/bundle/one.bin".to_string()), - (1, "https://explicit.example/one.bin".to_string()), - ]; - let new = vec![(2, "https://explicit.example/two.bin".to_string())]; - - let (expected, changes) = - plan_torrent_web_seed_change(¤t, &baseline, &old, &new).unwrap(); - assert_eq!( - changes, - vec![ - ( - 1, - vec!["https://explicit.example/one.bin".to_string()], - Vec::new(), - ), - ( - 2, - Vec::new(), - vec!["https://explicit.example/two.bin".to_string()], - ), - ] - ); - assert_eq!( - expected[&1], - HashSet::from(["https://mirror.example/bundle/one.bin".to_string()]) - ); - assert_eq!( - expected[&2], - HashSet::from([ - "https://mirror.example/bundle/two.bin".to_string(), - "https://explicit.example/two.bin".to_string(), - ]) - ); - } - - #[test] - fn torrent_web_seed_change_rejects_unknown_file_indices_without_panicking() { - let current = HashMap::from([ - (1, HashSet::from(["https://mirror.example/one".to_string()])), - (2, HashSet::from(["https://mirror.example/two".to_string()])), - ]); - - let result = plan_torrent_web_seed_change( - ¤t, - &HashSet::new(), - &[], - &[(999, "https://explicit.example/unknown".to_string())], - ); - - assert!(result.is_err()); - } - - #[test] - fn torrent_exclude_trackers_support_wildcard_and_normalized_uris() { - assert_eq!(normalize_torrent_exclude_trackers(Some("*")).unwrap(), Some("*".to_string())); - assert_eq!( - normalize_torrent_exclude_trackers(Some( - " https://tracker.example/announce\nudp://tracker.example:6969/announce " - )) - .unwrap(), - Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string()) - ); - assert_eq!(normalize_torrent_exclude_trackers(Some(" \n\n ")).unwrap(), None); - } - - #[test] - fn torrent_exclude_trackers_reject_unsafe_or_ambiguous_values() { - for value in [ - "ftp://tracker.example/announce", - "https://user:pass@tracker.example/announce", - "https://tracker.example/announce#fragment", - "https://tracker.example/announce,*", - "*,https://tracker.example/announce", - "https://tracker.example/announce,", - ] { - assert!(normalize_torrent_exclude_trackers(Some(value)).is_err(), "{value}"); - } - } - - #[test] - fn torrent_tracker_timing_is_bounded_and_preserves_aria2_defaults() { - assert_eq!(normalize_torrent_tracker_connect_timeout(None).unwrap(), None); - assert_eq!( - normalize_torrent_tracker_connect_timeout(Some(1)).unwrap(), - Some(1) - ); - assert_eq!( - normalize_torrent_tracker_request_timeout(Some(MAX_TORRENT_TRACKER_TIMEOUT)).unwrap(), - Some(MAX_TORRENT_TRACKER_TIMEOUT) - ); - assert_eq!(normalize_torrent_tracker_interval(Some(0)).unwrap(), Some(0)); - assert!(normalize_torrent_tracker_connect_timeout(Some(0)).is_err()); - assert!(normalize_torrent_tracker_request_timeout(Some(0)).is_err()); - assert!(normalize_torrent_tracker_interval(Some(MAX_TORRENT_TRACKER_INTERVAL + 1)).is_err()); - - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_tracker_connect_timeout: Some(11), - torrent_tracker_timeout: Some(22), - torrent_tracker_interval: Some(33), - ..Default::default() - }; - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - assert_eq!( - options.get("bt-tracker-connect-timeout"), - Some(&serde_json::json!("11")) - ); - assert_eq!( - options.get("bt-tracker-timeout"), - Some(&serde_json::json!("22")) - ); - assert_eq!( - options.get("bt-tracker-interval"), - Some(&serde_json::json!("33")) - ); - } - - #[test] - fn torrent_tracker_timing_is_not_applied_to_non_torrent_payloads() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - torrent_tracker_connect_timeout: Some(11), - torrent_tracker_timeout: Some(22), - torrent_tracker_interval: Some(33), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert!(!options.contains_key("bt-tracker-connect-timeout")); - assert!(!options.contains_key("bt-tracker-timeout")); - assert!(!options.contains_key("bt-tracker-interval")); - } - - #[test] - fn torrent_max_open_files_is_bounded() { - assert_eq!(normalize_torrent_max_open_files(1).unwrap(), 1); - assert_eq!( - normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES).unwrap(), - MAX_TORRENT_MAX_OPEN_FILES - ); - assert!(normalize_torrent_max_open_files(0).is_err()); - assert!(normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES + 1).is_err()); - } - - #[test] - fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_trackers: Some("https://tracker.example/announce".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!( - options.get("bt-tracker"), - Some(&serde_json::json!("https://tracker.example/announce")) - ); - } - - #[test] - fn torrent_exclude_trackers_are_emitted_as_the_aria2_option() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_exclude_trackers: Some("*".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!( - options.get("bt-exclude-tracker"), - Some(&serde_json::json!("*")) - ); - } - - #[test] - fn torrent_stop_timeout_is_normalized_and_emitted() { - assert_eq!(normalize_torrent_stop_timeout(None).unwrap(), None); - assert_eq!(normalize_torrent_stop_timeout(Some(0)).unwrap(), Some(0)); - assert_eq!( - normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT)).unwrap(), - Some(MAX_TORRENT_STOP_TIMEOUT) - ); - assert!(normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT + 1)).is_err()); - - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_stop_timeout: Some(300), - ..Default::default() - }; - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - assert_eq!( - options.get("bt-stop-timeout"), - Some(&serde_json::json!("300")) - ); - - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_stop_timeout: Some(0), - ..Default::default() - }; - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - assert_eq!( - options.get("bt-stop-timeout"), - Some(&serde_json::json!("0")) - ); - } - - #[test] - fn torrent_stop_timeout_is_not_applied_to_non_torrent_payloads() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - torrent_stop_timeout: Some(300), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert!(!options.contains_key("bt-stop-timeout")); - } - - #[test] - fn torrent_piece_priority_is_normalized_and_bounded() { - assert_eq!( - normalize_torrent_prioritize_piece(Some(" tail = 64k, HEAD ")).unwrap(), - Some("head,tail=64K".to_string()) - ); - assert_eq!( - normalize_torrent_prioritize_piece(Some("head=1m,tail=1024M")).unwrap(), - Some("head=1M,tail=1024M".to_string()) - ); - assert_eq!(normalize_torrent_prioritize_piece(Some(" ")).unwrap(), None); - - for value in [ - "head,head", - "tail,tail", - "middle", - "head=0K", - "tail=1G", - "head=1🙂", - "head=1K,", - ] { - assert!( - normalize_torrent_prioritize_piece(Some(value)).is_err(), - "{value}" - ); - } - assert!(normalize_torrent_prioritize_piece(Some("head=1025M")).is_err()); - } - - #[test] - fn torrent_piece_priority_is_emitted_as_the_aria2_option() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_prioritize_piece: Some("tail=2m,head".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!( - options.get("bt-prioritize-piece"), - Some(&serde_json::json!("head,tail=2M")) - ); - } - - #[test] - fn torrent_piece_priority_is_not_applied_to_non_torrent_payloads() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - torrent_prioritize_piece: Some("head".to_string()), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert!(!options.contains_key("bt-prioritize-piece")); - } - - #[test] - fn torrent_unselected_file_removal_requires_a_non_empty_file_selection() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_remove_unselected_file: true, - torrent_file_indices: Some(vec![]), - ..Default::default() - }; - - let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); - assert!(error.contains("file selection")); - assert!(!options.contains_key("bt-remove-unselected-file")); - } - - #[test] - fn torrent_unselected_file_removal_is_emitted_only_for_selected_torrent_files() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_remove_unselected_file: true, - torrent_file_indices: Some(vec![1]), - ..Default::default() - }; - - apply_aria2_torrent_options(&mut options, &payload).unwrap(); - - assert_eq!( - options.get("bt-remove-unselected-file"), - Some(&serde_json::json!("true")) - ); - } - - #[test] - fn torrent_unselected_file_removal_is_not_applied_without_torrent_selection() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_remove_unselected_file: true, - ..Default::default() - }; - - let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); - assert!(error.contains("subset")); - } - - #[test] - fn torrent_peer_diagnostics_are_bounded_and_omit_identity_and_bitfields() { - let mut result = vec![serde_json::json!({ - "peerId": "secret-peer-id", - "ip": "192.0.2.10", - "port": "6881", - "bitfield": "ffffffff", - "downloadSpeed": "10602", - "uploadSpeed": "6890", - "seeder": "true", - "amChoking": "false", - "peerChoking": "true" - })]; - result.extend((1..MAX_TORRENT_PEER_DIAGNOSTICS + 2).map(|index| { - serde_json::json!({ - "peerId": format!("peer-{index}"), - "ip": format!("192.0.2.{index}"), - "downloadSpeed": index.to_string(), - "uploadSpeed": 0, - "seeder": index == MAX_TORRENT_PEER_DIAGNOSTICS + 1, - "amChoking": false, - "peerChoking": false - }) - })); - - let diagnostics = parse_torrent_peer_diagnostics(serde_json::Value::Array(result)).unwrap(); - assert_eq!(diagnostics.listed_peers, (MAX_TORRENT_PEER_DIAGNOSTICS + 2) as u32); - assert_eq!(diagnostics.listed_seeders, 2); - assert_eq!(diagnostics.peers.len(), MAX_TORRENT_PEER_DIAGNOSTICS); - assert!(diagnostics.truncated); - assert_eq!(diagnostics.peers[0].ip.as_deref(), Some("192.0.2.10")); - assert_eq!(diagnostics.peers[0].port, Some(6881)); - let serialized = serde_json::to_string(&diagnostics).unwrap(); - assert!(serialized.contains("192.0.2.")); - assert!(!serialized.contains("\"port\":null")); - assert!(!serialized.contains("peerId")); - assert!(!serialized.contains("secret-peer-id")); - assert!(!serialized.contains("bitfield")); - } - - #[test] - fn torrent_peer_diagnostics_reject_non_array_results() { - let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err(); - assert!(error.contains("non-array")); - } - - #[test] - fn torrent_peer_diagnostics_reject_malformed_peer_entries() { - let error = parse_torrent_peer_diagnostics(serde_json::json!([{ - "seeder": true - }, "not-a-peer"])) - .unwrap_err(); - assert!(error.contains("malformed")); - } - - fn test_torrent_progress_metadata() -> Vec { - vec![ - crate::ipc::TorrentFile { - index: 1, - path: "folder/one.bin".to_string(), - length: 12, - }, - crate::ipc::TorrentFile { - index: 2, - path: "folder/two.bin".to_string(), - length: 7, - }, - ] - } - - #[test] - fn torrent_file_progress_uses_validated_metadata_and_discards_daemon_paths() { - let result = serde_json::json!([ - { - "index": "2", - "path": "/private/secret/two.bin", - "uris": [{"uri": "https://secret.example/file"}], - "length": "7", - "completedLength": "3", - "selected": "false" - }, - { - "index": "1", - "path": "/private/secret/one.bin", - "length": "12", - "completedLength": "12", - "selected": "true" - } - ]); - - let snapshot = parse_torrent_file_progress(result, &test_torrent_progress_metadata()).unwrap(); - assert_eq!(snapshot.files[0].relative_path, "folder/one.bin"); - assert_eq!(snapshot.files[0].completed_length, 12); - assert!(snapshot.files[0].selected); - assert_eq!(snapshot.files[1].relative_path, "folder/two.bin"); - assert_eq!(snapshot.files[1].completed_length, 3); - assert!(!snapshot.files[1].selected); - let serialized = serde_json::to_string(&snapshot).unwrap(); - assert!(!serialized.contains("/private/secret")); - assert!(!serialized.contains("secret.example")); - } - - #[test] - fn torrent_file_progress_rejects_malformed_and_inconsistent_rows() { - let metadata = test_torrent_progress_metadata(); - let cases = [ - ( - serde_json::json!([{ - "index": "1", - "length": "not-a-number", - "completedLength": "0", - "selected": "true" - }, { - "index": "2", - "length": "7", - "completedLength": "0", - "selected": "true" - }]), - "invalid file length", - ), - ( - serde_json::json!([{ - "index": "1", - "length": "12", - "completedLength": "13", - "selected": "true" - }, { - "index": "2", - "length": "7", - "completedLength": "0", - "selected": "true" - }]), - "over-complete", - ), - ( - serde_json::json!([{ - "index": "1", - "length": "12", - "completedLength": "0", - "selected": "true" - }, { - "index": "1", - "length": "12", - "completedLength": "0", - "selected": "true" - }]), - "duplicate", - ), - ( - serde_json::json!([{ - "index": "3", - "length": "12", - "completedLength": "0", - "selected": "true" - }, { - "index": "2", - "length": "7", - "completedLength": "0", - "selected": "true" - }]), - "unknown", - ), - ]; - - for (result, expected) in cases { - let error = parse_torrent_file_progress(result, &metadata).unwrap_err(); - assert!(error.contains(expected), "{error}"); - } - let error = parse_torrent_file_progress(serde_json::json!([]), &metadata).unwrap_err(); - assert!(error.contains("file count")); - } - - #[test] - fn torrent_piece_progress_decodes_high_bit_first_and_buckets_small_torrents() { - let snapshot = parse_torrent_piece_progress(serde_json::json!({ - "pieceLength": "16384", - "numPieces": "10", - "bitfield": "9040" - })) - .unwrap(); - - assert_eq!(snapshot.piece_length, 16_384); - assert_eq!(snapshot.num_pieces, 10); - assert_eq!(snapshot.completed_pieces, 3); - assert_eq!(snapshot.buckets, vec![100, 0, 0, 100, 0, 0, 0, 0, 0, 100]); - } - - #[test] - fn torrent_piece_progress_aggregates_to_at_most_256_balanced_buckets() { - let bitfield = "ff".repeat(64); - let snapshot = parse_torrent_piece_progress(serde_json::json!({ - "pieceLength": "1", - "numPieces": "512", - "bitfield": bitfield - })) - .unwrap(); - - assert_eq!(snapshot.completed_pieces, 512); - assert_eq!(snapshot.buckets.len(), 256); - assert!(snapshot.buckets.iter().all(|value| *value == 100)); - } - - #[test] - fn torrent_piece_progress_rejects_missing_fields_malformed_hex_and_overflow_bits() { - let cases = [ - (serde_json::json!({}), "invalid pieceLength"), - ( - serde_json::json!({ - "pieceLength": "16384", - "numPieces": "0", - "bitfield": "" - }), - "non-positive", - ), - ( - serde_json::json!({ - "pieceLength": "16384", - "numPieces": "8", - "bitfield": "xz" - }), - "invalid piece bitfield", - ), - ( - serde_json::json!({ - "pieceLength": "16384", - "numPieces": "10", - "bitfield": "904f" - }), - "overflow", - ), - ]; - - for (result, expected) in cases { - let error = parse_torrent_piece_progress(result).unwrap_err(); - assert!(error.contains(expected), "{error}"); - } - } - - #[test] - fn torrent_network_limits_and_web_seed_normalization_are_bounded() { - assert_eq!(normalize_torrent_dht_message_timeout(1).unwrap(), 1); - assert_eq!(normalize_torrent_dht_message_timeout(60).unwrap(), 60); - assert!(normalize_torrent_dht_message_timeout(0).is_err()); - assert!(normalize_torrent_dht_message_timeout(61).is_err()); - assert_eq!(normalize_torrent_max_concurrent_seeds(2).unwrap(), 2); - assert!(normalize_torrent_max_concurrent_seeds(65).is_err()); - - let files = vec![ - crate::ipc::TorrentFile { - index: 1, - path: "folder/file.bin".to_string(), - length: 3, - }, - crate::ipc::TorrentFile { - index: 2, - path: "other.txt".to_string(), - length: 4, - }, - ]; - let seeds = normalize_torrent_web_seeds( - Some(&[ - crate::ipc::TorrentWebSeed { - file_index: 1, - uri: " https://mirror.example/base/ ".to_string(), - }, - crate::ipc::TorrentWebSeed { - file_index: 1, - uri: "https://mirror.example/base/".to_string(), - }, - ]), - &files, - ) - .unwrap(); - assert_eq!(seeds.len(), 1); - assert_eq!( - expand_torrent_web_seeds(&seeds, &files).unwrap()[0].1, - "https://mirror.example/base/folder/file.bin" - ); - assert!(normalize_torrent_web_seeds( - Some(&[crate::ipc::TorrentWebSeed { - file_index: 1, - uri: "ftp://mirror.example/file".to_string(), - }]), - &files, - ) - .is_err()); - assert!(normalize_torrent_web_seeds( - Some(&[crate::ipc::TorrentWebSeed { - file_index: 9, - uri: "https://mirror.example/file".to_string(), - }]), - &files, - ) - .is_err()); - } - - #[test] - fn normal_protocol_options_use_the_protocol_specific_aria2_keys() { - let payload = SpawnPayload { - username: Some("alice".to_string()), - password: Some("secret".to_string()), - sftp_host_key_md: Some( - "sha-1=0123456789abcdef0123456789abcdef01234567".to_string(), - ), - ..SpawnPayload::default() - }; - let mut options = serde_json::Map::new(); - apply_protocol_auth_options( - &mut options, - &payload, - &["ftp://example.test/file.bin".to_string()], - ); - assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice"))); - assert_eq!(options.get("ftp-passwd"), Some(&serde_json::json!("secret"))); - assert!(!options.contains_key("http-user")); - assert!(!options.contains_key("ssh-host-key-md")); - - options.clear(); - apply_protocol_auth_options( - &mut options, - &payload, - &["sftp://example.test/file.bin".to_string()], - ); - assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice"))); - assert_eq!( - options.get("ssh-host-key-md"), - Some(&serde_json::json!("sha-1=0123456789abcdef0123456789abcdef01234567")) - ); - assert!(!options.contains_key("http-user")); - } - - #[test] - fn inline_url_credentials_are_treated_as_credential_material() { - assert!(payload_has_credential_material(&SpawnPayload { - url: "https://alice:secret@example.test/file".to_string(), - ..SpawnPayload::default() - })); - assert!(payload_has_credential_material(&SpawnPayload { - url: "https://example.test/file".to_string(), - mirrors: Some("https://alice:secret@mirror.example/file".to_string()), - ..SpawnPayload::default() - })); - assert!(header_name_has_credential_material("X-Request-Signature")); - assert!(!header_name_has_credential_material("Referer")); - } - - #[test] - fn normal_checksum_options_enable_aria2_integrity_checks() { - let mut options = serde_json::Map::new(); - apply_checksum_options(&mut options, Some("SHA-256=ABCDEF")); - assert_eq!(options.get("checksum"), Some(&serde_json::json!("sha-256=ABCDEF"))); - assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); - - options.clear(); - apply_checksum_options(&mut options, None); - assert!(options.is_empty()); - } - - #[test] - fn sftp_host_key_fingerprints_are_normalized_and_bounded() { - assert_eq!( - normalize_sftp_host_key_md(Some( - " SHA-1=0123456789ABCDEF0123456789ABCDEF01234567 ", - )) - .unwrap() - .as_deref(), - Some("sha-1=0123456789abcdef0123456789abcdef01234567") - ); - assert!(normalize_sftp_host_key_md(Some("sha-256=abcd")).is_err()); - assert!(normalize_sftp_host_key_md(Some("md5=abcd")).is_err()); - assert!(normalize_sftp_host_key_md(Some("sha-1=xyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzx")).is_err()); - assert_eq!(normalize_sftp_host_key_md(Some(" ")).unwrap(), None); - } - - #[test] - fn range_probe_does_not_forward_credentials_across_origins() { - crate::ensure_reqwest_crypto_provider(); - let payload = SpawnPayload { - username: Some("alice".to_string()), - password: Some("secret".to_string()), - cookies: Some("session=secret".to_string()), - headers: Some("Authorization: Bearer secret\nX-Test: yes".to_string()), - ..SpawnPayload::default() - }; - let client = reqwest::Client::builder().build().unwrap(); - let same_origin = apply_payload_headers( - client.get("https://example.test/file"), - &payload, - true, - ) - .build() - .unwrap(); - assert!(same_origin.headers().contains_key(reqwest::header::COOKIE)); - assert!(same_origin.headers().contains_key(reqwest::header::AUTHORIZATION)); - assert_eq!(same_origin.headers().get("x-test").unwrap(), "yes"); - - let cross_origin = apply_payload_headers( - client.get("https://cdn.example.test/file"), - &payload, - false, - ) - .build() - .unwrap(); - assert!(!cross_origin.headers().contains_key(reqwest::header::COOKIE)); - assert!(!cross_origin.headers().contains_key(reqwest::header::AUTHORIZATION)); - assert!(!cross_origin.headers().contains_key("x-test")); - } - - #[test] - fn range_probe_allows_http_to_https_upgrade_but_not_cross_origin_return() { - let original = reqwest::Url::parse("http://example.test/file").unwrap(); - let upgrade = reqwest::Url::parse("https://example.test/file").unwrap(); - let unrelated = reqwest::Url::parse("https://cdn.example.test/file").unwrap(); - - assert!(can_forward_payload_credentials(&original, &original)); - assert!(can_forward_payload_credentials(&original, &upgrade)); - assert!(!can_forward_payload_credentials(&original, &unrelated)); - assert!(!can_forward_payload_credentials( - &reqwest::Url::parse("https://example.test/file").unwrap(), - &reqwest::Url::parse("http://example.test/file").unwrap() - )); - - let ftp_origin = reqwest::Url::parse("ftp://example.test/file").unwrap(); - assert!(can_forward_payload_credentials( - &ftp_origin, - &reqwest::Url::parse("ftp://example.test/other").unwrap(), - )); - assert!(!can_forward_payload_credentials( - &ftp_origin, - &reqwest::Url::parse("ftp://mirror.example.test/file").unwrap(), - )); - - let redirected_with_credentials = reqwest::Url::parse( - "https://alice:secret@cdn.example.test/file", - ) - .unwrap(); - let (sanitized, credentials_allowed) = apply_redirect_credentials_policy( - &original, - true, - redirected_with_credentials, - ); - assert!(!credentials_allowed); - assert!(sanitized.username().is_empty()); - assert!(sanitized.password().is_none()); - } - - #[tokio::test] - async fn range_probe_fixture_follows_redirect_and_forwards_same_origin_credentials() { - use axum::{ - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Redirect}, - routing::get, - Router, - }; - - async fn source() -> Redirect { - Redirect::temporary("/final") - } - - async fn final_resource(headers: HeaderMap) -> impl IntoResponse { - let range = headers - .get("range") - .and_then(|value| value.to_str().ok()); - let cookie = headers - .get("cookie") - .and_then(|value| value.to_str().ok()); - if range == Some("bytes=0-0") && cookie == Some("session=secret") { - ( - StatusCode::PARTIAL_CONTENT, - [("content-range", "bytes 0-0/8")], - ) - .into_response() - } else { - StatusCode::UNAUTHORIZED.into_response() - } - } - - let app = Router::new() - .route("/source", get(source)) - .route("/final", get(final_resource)); - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .expect("fixture listener"); - let port = listener.local_addr().expect("fixture address").port(); - let server = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - let source_url = format!("http://127.0.0.1:{port}/source"); - let payload = SpawnPayload { - url: source_url.clone(), - cookies: Some("session=secret".to_string()), - connections: Some(16), - ..SpawnPayload::default() - }; - let origin = reqwest::Url::parse(&source_url).expect("fixture URL"); - let probe = probe_bounded_range_support_local_test(&source_url, &payload, &origin) - .await - .expect("range probe"); - - assert_eq!( - probe.final_uri, - format!("http://127.0.0.1:{port}/final") - ); - assert_eq!(probe.range_support, BoundedRangeSupport::Supported); - assert!(probe.credentials_allowed); - server.abort(); - } - - #[test] - fn public_probe_transport_failures_are_advisory_but_security_failures_are_fatal() { - assert!(!is_fatal_range_probe_error("error sending request")); - assert_eq!(range_probe_error_code("error sending request"), "transport"); - assert_eq!( - range_probe_error_code("error sending request for https://example.test/file"), - "transport" - ); - assert!(is_fatal_range_probe_error("SSRF blocked: Private/local IP not allowed")); - assert_eq!( - range_probe_error_code("SSRF blocked: DNS resolution timed out"), - "timeout" - ); - } - - #[test] - fn proxy_route_diagnostics_never_include_proxy_details() { - assert_eq!(proxy_route_for_log(None), "none"); - assert_eq!(proxy_route_for_log(Some("none")), "disabled"); - assert_eq!( - proxy_route_for_log(Some("http://user:secret@example.test:8080")), - "configured" - ); - } - - #[test] - fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-trackers", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_trackers": "https://tracker.example/announce", - "torrent_exclude_trackers": "*" - })) - .expect("frontend enqueue payload should deserialize"); - - let payload = item.into_task().payload; - assert_eq!( - payload.torrent_trackers.as_deref(), - Some("https://tracker.example/announce") - ); - assert_eq!(payload.torrent_exclude_trackers.as_deref(), Some("*")); - } - - #[test] - fn enqueue_item_carries_normal_reliability_policy_into_the_spawn_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "normal-reliability", - "queue_id": "main", - "url": "https://example.test/file.bin", - "destination": "/tmp/downloads", - "filename": "file.bin", - "minimum_normal_download_speed_kib": 64, - "retry_not_found_errors": true, - "adaptive_mirror_selection": false - })) - .expect("frontend enqueue payload should deserialize"); - - let payload = item.into_task().payload; - assert_eq!(payload.minimum_normal_download_speed_kib, 64); - assert!(payload.retry_not_found_errors); - assert!(!payload.adaptive_mirror_selection); - } - - #[test] - fn enqueue_item_carries_torrent_stop_timeout_into_the_spawn_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-stop-timeout", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_stop_timeout": 300 - })) - .expect("frontend enqueue payload should deserialize"); - - assert_eq!( - item.into_task().payload.torrent_stop_timeout, - Some(300) - ); - } - - #[test] - fn enqueue_item_carries_torrent_piece_priority_into_the_spawn_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-piece-priority", - "queue_id": "main", - "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_prioritize_piece": "head=2M,tail=1M" - })) - .expect("frontend enqueue payload should deserialize"); - - assert_eq!( - item.into_task().payload.torrent_prioritize_piece.as_deref(), - Some("head=2M,tail=1M") - ); - } - - #[test] - fn enqueue_item_carries_torrent_unselected_file_removal_into_the_spawn_payload() { - let item: EnqueueItem = serde_json::from_value(serde_json::json!({ - "id": "torrent-remove-unselected", - "queue_id": "main", - "url": "file:///tmp/payload.torrent", - "destination": "/tmp/downloads", - "filename": "payload", - "is_media": false, - "is_torrent": true, - "torrent_file_indices": [1], - "torrent_remove_unselected_file": true - })) - .expect("frontend enqueue payload should deserialize"); - - assert!(item.into_task().payload.torrent_remove_unselected_file); - } - - #[tokio::test] - async fn internally_created_enqueue_advances_past_cancelled_generation() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - - manager.cancel_enqueue_generation("torrent", 4).await; - let generation = manager - .next_enqueue_generation("torrent") - .await - .expect("a fresh generation should be available"); - - assert_eq!(generation, 5); - manager - .reserve_enqueue_generation("torrent", generation) - .await - .expect("the fresh generation should not be rejected as stale"); - assert_eq!(manager.registered_lifecycle_generation("torrent").await, Some(5)); - } - - #[test] - fn torrent_options_reject_invalid_seed_values() { - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(f64::NAN), - ..Default::default() - }; - - let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); - assert!(error.contains("seed time")); - } - - #[test] - fn torrent_options_reject_invalid_peer_values() { - assert!(normalize_torrent_max_peers(Some(-1)).is_err()); - - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_max_peers: Some(MAX_TORRENT_MAX_PEERS + 1), - ..Default::default() - }; - let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); - assert!(error.contains("maximum peers")); - - let mut options = serde_json::Map::new(); - let payload = SpawnPayload { - is_torrent: true, - torrent_peer_speed_limit: Some("0".to_string()), - ..Default::default() - }; - let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); - assert!(error.contains("peer speed limit")); - } - - #[tokio::test] - async fn seeding_outcome_keeps_torrent_ownership_and_permit_live() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new( - app.handle().clone(), - 1, - Arc::new(TestSpawner), - ); - let payload = SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(30.0), - ..Default::default() - }; - - assert!(manager.ensure_aria2_permit("torrent").await); - manager - .aria2_payloads - .lock() - .await - .insert("torrent".to_string(), payload); - manager - .remember_gid("torrent".to_string(), "test-gid".to_string()) - .await; - manager - .apply_completion("torrent", PendingOutcome::Seeding) - .await; - - assert_eq!(manager.aria2_gid_for_download("torrent").as_deref(), Some("test-gid")); - assert_eq!(manager.available_permits(), 0); - assert!(manager - .capture_seed_remaining("torrent") - .await - .is_some()); - } - - #[tokio::test] - async fn pending_torrent_reconfiguration_restores_position_and_payload() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - for id in ["first", "target", "last"] { - manager - .push(QueuedTask { - id: id.to_string(), - queue_id: "queue".to_string(), - kind: TaskKind::Aria2, - lifecycle_generation: 0, - payload: SpawnPayload { - is_torrent: true, - ..Default::default() - }, - }) - .await - .unwrap(); - } - - let (index, mut task) = manager - .take_pending_task("target") - .await - .expect("target remains pending"); - assert_eq!(manager.pending_order(None).await, ["first", "last"]); - task.payload.torrent_file_indices = Some(vec![2]); - manager.restore_pending_task(index, task).await; - - assert_eq!( - manager.pending_order(None).await, - ["first", "target", "last"] - ); - let (_, restored) = manager - .take_pending_task("target") - .await - .expect("target was restored"); - assert_eq!(restored.payload.torrent_file_indices, Some(vec![2])); - } - - #[tokio::test] - async fn enabling_separate_seed_capacity_counts_existing_seeders() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(SeedSpawner)); - assert!(manager.ensure_aria2_permit("existing").await); - manager - .aria2_payloads - .lock() - .await - .insert( - "existing".to_string(), - SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(5.0), - ..Default::default() - }, - ); - manager - .remember_gid("existing".to_string(), "gid-existing".to_string()) - .await; - manager - .apply_completion("existing", PendingOutcome::Seeding) - .await; - - assert!(!manager.is_seed_owner("existing")); - manager.configure_seed_capacity(true, 1); - assert!(manager.is_seed_owner("existing")); - assert!(manager.has_active_permit("existing").await); - } - - #[tokio::test] - async fn separate_seed_capacity_pauses_fairly_and_rebinds_before_resume() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = Arc::new(QueueManager::test_new( - app.handle().clone(), - 1, - Arc::new(SeedSpawner), - )); - manager.configure_seed_capacity(true, 1); - for id in ["first", "second"] { - manager.reserve_enqueue_generation(id, 0).await.unwrap(); - assert!(manager.ensure_aria2_permit(id).await); - manager - .aria2_payloads - .lock() - .await - .insert( - id.to_string(), - SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(5.0), - ..Default::default() - }, - ); - manager - .remember_gid(id.to_string(), format!("gid-{id}")) - .await; - if id == "first" { - manager - .apply_completion(id, PendingOutcome::Seeding) - .await; - assert!(manager.is_seed_owner(id)); - assert!(!manager.has_active_permit(id).await); - } else { - manager - .apply_completion(id, PendingOutcome::Seeding) - .await; - assert!(manager.is_waiting_to_seed(id)); - assert!(!manager.has_active_permit(id).await); - } - } - - manager.release_registered_id("first").await; - assert!(manager.try_start_waiting_seed().await); - let result = tokio::time::timeout(Duration::from_secs(1), async { - loop { - if manager.is_seed_owner("second") && !manager.is_waiting_to_seed("second") { - break; - } - tokio::task::yield_now().await; - } - }) - .await; - assert!(result.is_ok(), "waiting seed did not resume after capacity was released"); - assert!(!manager.has_active_permit("second").await); - assert_eq!(manager.current_aria2_control_epoch("second").await, 1); - } - - #[tokio::test] - async fn ambiguous_seed_resume_retries_without_stranding_the_seed_permit() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let spawner = Arc::new(AmbiguousResumeSeedSpawner { - resume_calls: Arc::new(AtomicUsize::new(0)), - }); - let manager = Arc::new(QueueManager::test_new( - app.handle().clone(), - 1, - spawner.clone(), - )); - manager.configure_seed_capacity(true, 1); - for id in ["first", "second"] { - manager.reserve_enqueue_generation(id, 0).await.unwrap(); - assert!(manager.ensure_aria2_permit(id).await); - manager - .aria2_payloads - .lock() - .await - .insert( - id.to_string(), - SpawnPayload { - is_torrent: true, - torrent_seed_time: Some(5.0), - ..Default::default() - }, - ); - manager - .remember_gid(id.to_string(), format!("gid-{id}")) - .await; - manager - .apply_completion(id, PendingOutcome::Seeding) - .await; - } - - assert!(manager.is_waiting_to_seed("second")); - manager.release_registered_id("first").await; - assert!(manager.try_start_waiting_seed().await); - - let first_attempt = tokio::time::timeout(Duration::from_secs(1), async { - loop { - if spawner.resume_calls.load(Ordering::SeqCst) >= 1 - && manager.has_active_permit("second").await - && manager.is_waiting_to_seed("second") - { - break; - } - tokio::task::yield_now().await; - } - }) - .await; - assert!(first_attempt.is_ok(), "ambiguous seed resume was not retained safely"); - - manager.wake_seed_waiters(); - let recovered = tokio::time::timeout(Duration::from_secs(1), async { - loop { - if spawner.resume_calls.load(Ordering::SeqCst) >= 2 - && manager.is_seed_owner("second") - && !manager.is_waiting_to_seed("second") - && !manager.has_active_permit("second").await - { - break; - } - tokio::task::yield_now().await; - } - }) - .await; - assert!( - recovered.is_ok(), - "ambiguous seed resume did not reconcile after the daemon became reachable" - ); - assert_eq!(manager.current_aria2_control_epoch("second").await, 1); - } - - #[tokio::test] - async fn unexpected_seeding_outcome_without_policy_is_reconciled_as_complete() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new( - app.handle().clone(), - 1, - Arc::new(TestSpawner), - ); - - assert!(manager.ensure_aria2_permit("torrent").await); - manager - .aria2_payloads - .lock() - .await - .insert( - "torrent".to_string(), - SpawnPayload { - is_torrent: true, - ..Default::default() - }, - ); - manager - .apply_completion("torrent", PendingOutcome::Seeding) - .await; - - assert_eq!(manager.aria2_gid_for_download("torrent"), None); - assert_eq!(manager.available_permits(), 1); - } - #[test] fn aria2_connection_options_never_emit_zero_connections() { let mut options = serde_json::Map::new(); @@ -12070,55 +3210,6 @@ mod tests { ); } - #[tokio::test] - async fn stale_aria2_connection_options_cannot_overwrite_a_newer_epoch() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); - let first_epoch = manager.next_aria2_control_epoch("download").await; - - manager - .set_aria2_connection_options("download", first_epoch, 1) - .await; - assert_eq!( - manager - .aria2_effective_connections("download", first_epoch) - .await, - Some(1) - ); - - let second_epoch = manager.next_aria2_control_epoch("download").await; - assert_eq!( - manager - .aria2_effective_connections("download", second_epoch) - .await, - None - ); - manager - .set_aria2_connection_options("download", second_epoch, 4) - .await; - manager - .set_aria2_connection_options("download", first_epoch, 1) - .await; - assert_eq!( - manager - .aria2_effective_connections("download", second_epoch) - .await, - Some(4) - ); - } - - #[test] - fn aria2_add_uri_uses_the_prepared_attempt_uris() { - let prepared = vec!["https://cdn.example.test/signed-attempt".to_string()]; - let source = "https://downloads.example.test/stable-source"; - let params = aria2_add_uri_params(prepared.clone(), serde_json::Map::new()); - - assert_eq!(params[0], serde_json::json!(prepared)); - assert_ne!(params[0], serde_json::json!([source])); - } - #[test] fn bounded_range_probe_accepts_exact_requested_byte() { assert_eq!( @@ -12156,28 +3247,20 @@ mod tests { } #[test] - fn bounded_range_probe_treats_expanded_response_as_unknown() { + fn bounded_range_probe_rejects_server_that_expands_to_end() { assert_eq!( classify_bounded_range_response( reqwest::StatusCode::PARTIAL_CONTENT, Some("bytes 0-383882117/383882118"), ), - BoundedRangeSupport::Unknown + BoundedRangeSupport::Unsupported ); } #[test] - fn bounded_range_probe_treats_ignored_range_request_as_unknown() { + fn bounded_range_probe_rejects_ignored_range_request() { assert_eq!( classify_bounded_range_response(reqwest::StatusCode::OK, None), - BoundedRangeSupport::Unknown - ); - } - - #[test] - fn bounded_range_probe_classifies_explicit_range_rejection() { - assert_eq!( - classify_bounded_range_response(reqwest::StatusCode::RANGE_NOT_SATISFIABLE, None), BoundedRangeSupport::Unsupported ); } @@ -12190,134 +3273,6 @@ mod tests { assert!(!is_retryable_aria2_error("No URI available.")); } - #[test] - fn aria2_stall_timeout_outcome_is_not_automatically_retried() { - assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download")); - } - - #[test] - fn optional_not_found_and_low_speed_retries_use_the_firelink_budget() { - let not_found = "aria2 error code 3: Resource not found"; - let low_speed = "aria2 error code 5: Download speed is too slow"; - - assert_eq!( - aria2_retry_action(&SpawnPayload::default(), not_found, 0, false), - Aria2RetryAction::Terminal - ); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - retry_not_found_errors: true, - max_tries: Some(1), - ..SpawnPayload::default() - }, - not_found, - 0, - false, - ), - Aria2RetryAction::OrdinaryRetry - ); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - retry_not_found_errors: true, - max_tries: Some(1), - ..SpawnPayload::default() - }, - not_found, - 1, - false, - ), - Aria2RetryAction::Terminal - ); - assert_eq!( - aria2_retry_action(&SpawnPayload::default(), low_speed, 0, false), - Aria2RetryAction::Terminal - ); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - minimum_normal_download_speed_kib: 1, - max_tries: Some(1), - ..SpawnPayload::default() - }, - low_speed, - 0, - false, - ), - Aria2RetryAction::OrdinaryRetry - ); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - is_torrent: true, - minimum_normal_download_speed_kib: 1, - retry_not_found_errors: true, - max_tries: Some(1), - ..SpawnPayload::default() - }, - low_speed, - 0, - false, - ), - Aria2RetryAction::Terminal - ); - } - - #[test] - fn aria2_name_resolution_error_is_retryable_for_resolver_recovery() { - let error = "aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers."; - assert!(is_retryable_aria2_error(error)); - assert!(should_use_aria2_system_resolver_fallback( - &SpawnPayload::default(), - error, - true, - )); - assert_eq!( - aria2_retry_action(&SpawnPayload::default(), error, 0, true), - Aria2RetryAction::SystemResolverFallback - ); - assert!(!should_use_aria2_system_resolver_fallback( - &SpawnPayload { - aria2_resolver_mode: Aria2ResolverMode::System, - ..Default::default() - }, - error, - true, - )); - assert!(!should_use_aria2_system_resolver_fallback( - &SpawnPayload::default(), - error, - false, - )); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - aria2_resolver_mode: Aria2ResolverMode::System, - max_tries: Some(0), - ..Default::default() - }, - error, - 0, - true, - ), - Aria2RetryAction::Terminal - ); - assert_eq!( - aria2_retry_action( - &SpawnPayload { - aria2_resolver_mode: Aria2ResolverMode::System, - max_tries: Some(1), - ..Default::default() - }, - error, - 0, - true, - ), - Aria2RetryAction::OrdinaryRetry - ); - } - #[test] fn aria2_startup_rpc_errors_are_retryable() { assert!(is_aria2_rpc_unavailable( @@ -12343,170 +3298,4 @@ mod tests { assert_eq!(automatic_retry_limit(Some(0)), 0); assert_eq!(automatic_retry_limit(Some(2)), 2); } - - #[test] - fn torrent_availability_aggregates_local_and_peer_copies_without_exposing_bitfields() { - let snapshot = parse_torrent_availability( - serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), - serde_json::json!([ - { "bitfield": "30", "ip": "192.0.2.1" } - ]), - ) - .expect("availability should parse"); - assert_eq!(snapshot.piece_count, 4); - assert_eq!(snapshot.connected_peers, 1); - assert!((snapshot.availability - 1.5).abs() < f64::EPSILON); - assert_eq!(snapshot.buckets.len(), 4); - assert_eq!(snapshot.buckets[0].minimum_copies, 1); - } - - #[test] - fn torrent_availability_rejects_malformed_and_overflow_bitfields() { - assert!(parse_torrent_availability( - serde_json::json!({ "numPieces": "4", "bitfield": "f1" }), - serde_json::json!([]), - ) - .is_err()); - let snapshot = parse_torrent_availability( - serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), - serde_json::json!([{ "bitfield": "0" }]), - ) - .expect("malformed peer data should be omitted"); - assert_eq!(snapshot.connected_peers, 1); - assert_eq!(snapshot.availability, 1.0); - } - - #[test] - fn torrent_availability_ignores_peers_before_their_bitfield_handshake() { - let snapshot = parse_torrent_availability( - serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), - serde_json::json!([ - { "ip": "192.0.2.1" }, - { "bitfield": "30" } - ]), - ) - .expect("a peer without a handshake bitfield is not malformed"); - assert_eq!(snapshot.connected_peers, 2); - assert!((snapshot.availability - 1.5).abs() < f64::EPSILON); - } - - #[test] - fn torrent_telemetry_counts_only_monotonic_upload_deltas() { - let start = Instant::now(); - let mut state = TorrentTelemetryState::new("gid-1", 7, start); - assert_eq!( - state.observe("gid-1", 7, Some(100), false, start), - TorrentTelemetrySnapshot { - uploaded_bytes: 0, - seeded_seconds: 0 - } - ); - assert_eq!( - state.observe( - "gid-1", - 7, - Some(180), - false, - start + Duration::from_secs(1) - ) - .uploaded_bytes, - 80 - ); - // A daemon counter reset establishes a new baseline and contributes - // no bytes from the reset itself. - assert_eq!( - state.observe( - "gid-1", - 7, - Some(12), - false, - start + Duration::from_secs(2) - ) - .uploaded_bytes, - 80 - ); - assert_eq!( - state.observe( - "gid-1", - 7, - Some(20), - false, - start + Duration::from_secs(3) - ) - .uploaded_bytes, - 88 - ); - } - - #[test] - fn torrent_telemetry_restarts_baseline_on_gid_or_epoch_replacement() { - let start = Instant::now(); - let mut state = TorrentTelemetryState::new("gid-1", 1, start); - state.observe("gid-1", 1, Some(500), false, start); - state.observe("gid-1", 1, Some(525), false, start + Duration::from_secs(1)); - let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(2)); - assert_eq!(snapshot.uploaded_bytes, 25); - assert_eq!( - state.observe("gid-2", 2, Some(9), false, start + Duration::from_secs(3)) - .uploaded_bytes, - 30 - ); - } - - #[test] - fn torrent_telemetry_closes_the_previous_seed_interval_on_lifecycle_replacement() { - let start = Instant::now(); - let mut state = TorrentTelemetryState::new("gid-1", 1, start); - state.observe("gid-1", 1, Some(0), true, start); - let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(3)); - assert_eq!(snapshot.seeded_seconds, 3); - assert_eq!(snapshot.uploaded_bytes, 0); - } - - #[test] - fn torrent_telemetry_counts_seed_seconds_only_for_the_previous_seed_interval() { - let start = Instant::now(); - let mut state = TorrentTelemetryState::new("gid-1", 1, start); - state.observe("gid-1", 1, Some(0), true, start); - assert_eq!( - state.observe( - "gid-1", - 1, - Some(10), - true, - start + Duration::from_secs(4) - ) - .seeded_seconds, - 4 - ); - assert_eq!( - state.observe( - "gid-1", - 1, - Some(10), - false, - start + Duration::from_secs(9) - ) - .seeded_seconds, - 9 - ); - } - - #[test] - fn torrent_telemetry_caps_long_observer_gaps() { - let start = Instant::now(); - let mut state = TorrentTelemetryState::new("gid-1", 1, start); - state.observe("gid-1", 1, Some(0), true, start); - let snapshot = state.observe( - "gid-1", - 1, - Some(1), - true, - start + Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS + 3600), - ); - assert_eq!( - snapshot.seeded_seconds, - MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS - ); - } } diff --git a/src-tauri/src/retry.rs b/src-tauri/src/retry.rs index 007850d..f7425b4 100644 --- a/src-tauri/src/retry.rs +++ b/src-tauri/src/retry.rs @@ -50,121 +50,6 @@ pub const BACKOFF_SCHEDULE_429: [Duration; 3] = [ /// fall through to a hard `Failed`. Three strikes matches the schedule length. pub const MAX_RETRIES: usize = BACKOFF_SCHEDULE.len(); -/// Detect Aria2's name-resolution failure without treating arbitrary DNS-like -/// text as a resolver failure. The numeric code is the authoritative signal; -/// the message forms cover older/alternate Aria2 wrappers that omit it. -pub fn is_aria2_name_resolution_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - aria2_error_code(message).as_deref() == Some("19") - || (lower.contains("name resolution") - && lower.contains("failed") - && lower.contains("could not contact dns")) - || lower.contains("could not contact dns server") -} - -/// Extract Aria2's numeric error code without retaining the rest of its -/// message. Aria2 error messages can include the request URI, so diagnostics -/// should record this code rather than the raw text. -pub fn aria2_error_code(message: &str) -> Option { - let lower = message.to_ascii_lowercase(); - let marker = "aria2 error code"; - let start = lower.find(marker)? + marker.len(); - let remainder = lower[start..].trim_start_matches(|character: char| { - character.is_ascii_whitespace() - || matches!(character, ':' | '=' | '(' | ')' | '[' | ']') - }); - let digits: String = remainder - .chars() - .take_while(|character| character.is_ascii_digit()) - .collect(); - (!digits.is_empty()).then_some(digits) -} - -/// Coarse, secret-free classification for retry diagnostics. The returned -/// value is intentionally stable and contains no provider or request text. -pub fn network_error_class(message: &str) -> &'static str { - if is_aria2_name_resolution_error(message) { - return "name_resolution"; - } - let lower = message.to_ascii_lowercase(); - if lower.contains("private/local ip") || lower.contains("ssrf") { - return "ssrf_policy"; - } - if lower.contains("permission denied") || lower.contains("operation not permitted") { - return "permission"; - } - if lower.contains("timed out") || lower.contains("timeout") { - return "timeout"; - } - if lower.contains("connection refused") { - return "connection_refused"; - } - if lower.contains("connection reset") || lower.contains("connection aborted") { - return "connection_reset"; - } - if [ - "invalid range", - "range not satisfiable", - "range request", - "range support", - "accept-ranges", - "bounded range", - "byte range", - "does not support range", - ] - .iter() - .any(|marker| lower.contains(marker)) - { - return "range"; - } - if lower.contains("dns") || lower.contains("name resolution") { - return "dns"; - } - let has_http_version_token = lower.split_whitespace().any(|token| { - let token = token.trim_start_matches(|character: char| { - matches!(character, '(' | '[' | '{') - }); - token.starts_with("http/") - && token - .chars() - .nth(5) - .is_some_and(|character| character.is_ascii_digit()) - }); - if lower.contains("http error") - || has_http_version_token - || lower.contains("http status") - || lower.contains("response status") - || lower.contains("status code") - || [ - "status=400", - "status=401", - "status=403", - "status=404", - "status=408", - "status=410", - "status=429", - "status=451", - "status=500", - "status=502", - "status=503", - "status=504", - ] - .iter() - .any(|marker| { - lower.split_whitespace().any(|token| { - token - .trim_matches(|character: char| { - !character.is_ascii_alphanumeric() && character != '=' - }) - == *marker - }) - }) - { - return "http"; - } - "transport" -} - /// Resolve the backoff delay for a 0-based strike. Strikes at or beyond the /// schedule length clamp to the longest slot (10s) rather than panicking, so a /// mis-sized loop degrades gracefully instead of aborting the worker. @@ -179,7 +64,7 @@ pub fn backoff_for(strike: usize) -> Duration { /// Classify an error string as a transient network condition worth retrying. /// /// Returns `true` for socket drops, connect/read timeouts, connection resets, -/// and transient HTTP status conditions across both download paths: +/// and HTTP 408 / request-timeout conditions across both download paths: /// /// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`, /// `HTTP Error 408`. @@ -237,13 +122,9 @@ pub fn is_transient_network_error(message: &str) -> bool { return false; } - if is_aria2_name_resolution_error(message) { - return true; - } - let m = message.to_ascii_lowercase(); - const TRANSIENT: [&str; 36] = [ + const TRANSIENT: [&str; 34] = [ // socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp "timed out", "timeout", @@ -259,8 +140,6 @@ pub fn is_transient_network_error(message: &str) -> bool { "connection aborted", "error sending request", // reqwest wrapper for connect/send failures "dns error", // transient resolver failures - "name resolution", // aria2 name-resolution failures - "could not contact dns", // aria2 c-ares resolver failures "protocol error", // aria2 read/protocol failures after a link drop "tls handshake failure", "ssl/tls handshake failure", @@ -285,12 +164,9 @@ pub fn is_transient_network_error(message: &str) -> bool { "timeout.", "invalid range header", ]; - const TRANSIENT_HTTP_STATUS: [&str; 11] = [ - "408", "429", "500", "502", "503", "504", "520", "521", "522", "523", "524", - ]; - TRANSIENT_HTTP_STATUS - .iter() - .any(|status| contains_http_status(&m, status)) + contains_http_status(&m, "408") + || contains_http_status(&m, "429") + || contains_http_status(&m, "503") || TRANSIENT.iter().any(|t| m.contains(t)) } @@ -366,44 +242,6 @@ mod tests { assert_eq!(MAX_RETRIES, 3); } - #[test] - fn extracts_aria2_error_code_without_message_material() { - for error in [ - "aria2 error code 19: Could not contact DNS servers", - "aria2 error code: 19: Could not contact DNS servers", - "aria2 error code (19): Could not contact DNS servers", - "aria2 error code=19: Could not contact DNS servers", - ] { - assert_eq!(aria2_error_code(error).as_deref(), Some("19")); - } - let error = - "aria2 error code 19: Could not contact DNS servers for https://example.test/file?token=secret"; - assert_eq!(network_error_class(error), "name_resolution"); - assert_eq!(aria2_error_code("aria2 error code: unknown 19"), None); - assert!(is_aria2_name_resolution_error("aria2 error code: 19")); - } - - #[test] - fn classifies_diagnostic_errors_without_echoing_private_details() { - assert_eq!(network_error_class("operation not permitted"), "permission"); - assert_eq!(network_error_class("connect timed out"), "timeout"); - assert_eq!(network_error_class("invalid range header"), "range"); - assert_eq!( - network_error_class("error sending request for https://example.test/file"), - "transport" - ); - assert_eq!( - network_error_class("error sending request for http://example.test/file"), - "transport" - ); - assert_eq!( - network_error_class("error sending request for https://example.test/file?status=503"), - "transport" - ); - assert_eq!(network_error_class("ranged GET fallback failed"), "transport"); - assert_eq!(network_error_class("HTTP Error 503"), "http"); - } - #[test] fn backoff_for_indexes_then_clamps() { assert_eq!(backoff_for(0), Duration::from_secs(2)); @@ -454,16 +292,6 @@ mod tests { assert!(is_transient_network_error("The response status is not successful. status=429")); } - #[test] - fn classifies_rpc_http_gateway_errors_as_transient() { - for status in [500, 502, 503, 504, 520, 521, 522, 523, 524] { - assert!( - is_transient_network_error(&format!("HTTP {status} gateway failure")), - "HTTP {status} should be retryable" - ); - } - } - #[test] fn classifies_ytdlp_and_aria2_phrasing_as_transient() { assert!(is_transient_network_error( @@ -485,22 +313,6 @@ mod tests { )); } - #[test] - fn classifies_aria2_name_resolution_failures_precisely() { - assert!(is_aria2_name_resolution_error( - "aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers." - )); - assert!(is_aria2_name_resolution_error( - "Name resolution for example.test failed: Could not contact DNS server" - )); - assert!(is_aria2_name_resolution_error( - "aria2 error code 19: connection refused" - )); - assert!(!is_aria2_name_resolution_error( - "aria2 error code 8: No URI available" - )); - } - // --- transient classification: negative cases ------------------------- #[test] diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 871b902..f003867 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -16,14 +16,13 @@ fn stop_is_due( stop_minute: Option, current_minute: u32, last_start_key: &str, - triggered_start_key: &str, start_key: &str, last_stop_key: &str, stop_key: &str, ) -> bool { stop_time_enabled && stop_minute.is_some_and(|stop| current_minute >= stop) - && (last_start_key == start_key || triggered_start_key == start_key) + && last_start_key == start_key && last_stop_key != stop_key } @@ -34,7 +33,6 @@ struct OvernightStopCheck<'a> { current_minute: u32, previous_day_allowed: bool, last_start_key: &'a str, - triggered_start_key: &'a str, previous_start_key: &'a str, last_stop_key: &'a str, stop_key: &'a str, @@ -48,7 +46,6 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool { current_minute, previous_day_allowed, last_start_key, - triggered_start_key, previous_start_key, last_stop_key, stop_key, @@ -58,31 +55,10 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool { && start_minute.zip(stop_minute).is_some_and(|(start, stop)| { stop < start && current_minute >= stop && current_minute < start }) - && (last_start_key == previous_start_key || triggered_start_key == previous_start_key) + && last_start_key == previous_start_key && last_stop_key != stop_key } -fn persist_scheduler_start_trigger( - app_handle: &tauri::AppHandle, - settings_cache: &Arc>>, - key: &str, -) { - if let Err(error) = crate::settings::update_settings_state(app_handle, |state| { - state.insert( - "schedulerTriggeredStartKey".to_string(), - serde_json::json!(key), - ); - }) { - log::warn!("Failed to persist scheduler start trigger: {error}"); - } - - if let Ok(mut settings) = settings_cache.write() { - if let Some(settings) = settings.as_mut() { - settings.scheduler_triggered_start_key = Some(key.to_string()); - } - } -} - pub fn spawn_scheduler( app_handle: tauri::AppHandle, settings_cache: Arc>>, @@ -90,11 +66,6 @@ pub fn spawn_scheduler( tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(1)); let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new(); - // Renderer acknowledgement remains the durable completion record, but - // a native dispatch marker also survives a closed/unmounted webview so - // an overnight stop does not become permanently ineligible. The - // process-local start key also covers the same-loop event/stop check. - let mut triggered_start_key = String::new(); loop { interval.tick().await; @@ -103,21 +74,11 @@ pub fn spawn_scheduler( ( settings.scheduler.clone(), settings.scheduler_last_start_key.clone(), - settings - .scheduler_triggered_start_key - .clone() - .unwrap_or_default(), settings.scheduler_last_stop_key.clone(), ) }) }); - if let Some(( - scheduler, - scheduler_last_start_key, - persisted_triggered_start_key, - scheduler_last_stop_key, - )) = settings - { + if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings { if !scheduler.enabled { continue; } @@ -147,29 +108,13 @@ pub fn spawn_scheduler( .get("start") .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) { - if persisted_triggered_start_key != start_key - && triggered_start_key != start_key - { - // Record the dispatch intent before emitting so a - // crash between the native event and renderer ack - // still makes an overnight stop eligible. Start - // events remain retryable until the renderer acks - // them, which covers startup/listener races. - persist_scheduler_start_trigger( - &app_handle, - &settings_cache, - &start_key, - ); - } - if app_handle.emit( + let _ = app_handle.emit( "schedule-trigger", serde_json::json!({ "action": "start", "key": start_key }), - ).is_ok() { - triggered_start_key = start_key.clone(); - } + ); last_emit.insert("start", std::time::Instant::now()); } @@ -180,13 +125,6 @@ pub fn spawn_scheduler( stop_minute, current_minute, &scheduler_last_start_key, - if triggered_start_key == start_key { - start_key.as_str() - } else if persisted_triggered_start_key == start_key { - start_key.as_str() - } else { - "" - }, &start_key, &scheduler_last_stop_key, &stop_key, @@ -208,13 +146,6 @@ pub fn spawn_scheduler( current_minute, previous_day_allowed, last_start_key: &scheduler_last_start_key, - triggered_start_key: if triggered_start_key == previous_start_key { - previous_start_key.as_str() - } else if persisted_triggered_start_key == previous_start_key { - previous_start_key.as_str() - } else { - "" - }, previous_start_key: &previous_start_key, last_stop_key: &scheduler_last_stop_key, stop_key: &stop_key, @@ -264,7 +195,6 @@ mod tests { Some(480), 600, "", - "", "2026-06-22-start", "", "2026-06-22-stop", @@ -274,43 +204,12 @@ mod tests { Some(480), 600, "2026-06-22-start", - "", "2026-06-22-start", "", "2026-06-22-stop", )); } - #[test] - fn stop_accepts_process_local_start_when_renderer_ack_is_missing() { - assert!(stop_is_due( - true, - Some(480), - 600, - "", - "2026-06-22-start", - "2026-06-22-start", - "", - "2026-06-22-stop", - )); - } - - #[test] - fn overnight_stop_accepts_persisted_start_trigger_when_app_restarts() { - assert!(overnight_stop_is_due(OvernightStopCheck { - stop_time_enabled: true, - start_minute: Some(1320), - stop_minute: Some(360), - current_minute: 420, - previous_day_allowed: true, - last_start_key: "", - triggered_start_key: "2026-06-22-start", - previous_start_key: "2026-06-22-start", - last_stop_key: "", - stop_key: "2026-06-23-stop", - })); - } - #[test] fn overnight_stop_uses_the_previous_day_start() { assert!(overnight_stop_is_due(OvernightStopCheck { @@ -320,7 +219,6 @@ mod tests { current_minute: 420, previous_day_allowed: true, last_start_key: "2026-06-22-start", - triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-23-stop", @@ -332,7 +230,6 @@ mod tests { current_minute: 1380, previous_day_allowed: true, last_start_key: "2026-06-22-start", - triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-22-stop", @@ -344,7 +241,6 @@ mod tests { current_minute: 420, previous_day_allowed: false, last_start_key: "2026-06-22-start", - triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-23-stop", diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ad9f19d..ec6a52c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,118 +7,6 @@ use serde_json::{Map, Value}; use std::collections::HashMap; use tauri::{AppHandle, Manager}; -#[derive(Clone, Debug, Default)] -pub struct TorrentStartupSettings { - pub listen_port: String, - pub dht_listen_port: String, - pub external_ip: String, - pub dht_entry_point: String, - pub dht_entry_point6: String, - pub dht_listen_addr6: String, - pub lpd_interface: String, - pub peer_id_prefix: String, - pub peer_agent: String, - pub dht_message_timeout: u32, - pub ipv6_enabled: bool, - pub bind_address: String, - pub disk_cache: String, -} - -fn normalize_torrent_startup_value( - field: &str, - value: &str, - normalize: impl Fn(Option<&str>) -> Result, String>, -) -> String { - match normalize(Some(value)) { - Ok(Some(value)) => value, - Ok(None) => String::new(), - Err(error) => { - log::error!("invalid persisted {field}; using Aria2 default: {error}"); - String::new() - } - } -} - -pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> TorrentStartupSettings { - let Some(settings) = settings else { - return TorrentStartupSettings::default(); - }; - let bind_address = normalize_torrent_startup_value( - "Torrent bind address", - &settings.torrent_bind_address, - crate::queue::normalize_torrent_bind_address, - ); - let bind_address = if !settings.torrent_ipv6_enabled - && bind_address - .parse::() - .is_ok_and(|address| address.is_ipv6()) - { - log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled"); - String::new() - } else { - bind_address - }; - - TorrentStartupSettings { - listen_port: normalize_torrent_startup_value( - "TCP listen ports", - &settings.torrent_listen_port, - |value| crate::queue::normalize_torrent_port_spec(value, "TCP listen ports"), - ), - dht_listen_port: normalize_torrent_startup_value( - "UDP listen ports", - &settings.torrent_dht_listen_port, - |value| crate::queue::normalize_torrent_port_spec(value, "UDP listen ports"), - ), - external_ip: normalize_torrent_startup_value( - "Torrent external IP", - &settings.torrent_external_ip, - crate::queue::normalize_torrent_external_ip, - ), - dht_entry_point: normalize_torrent_startup_value( - "IPv4 DHT entry point", - &settings.torrent_dht_entry_point, - |value| crate::queue::normalize_torrent_dht_entry_point(value, false), - ), - dht_entry_point6: normalize_torrent_startup_value( - "IPv6 DHT entry point", - &settings.torrent_dht_entry_point6, - |value| crate::queue::normalize_torrent_dht_entry_point(value, true), - ), - dht_listen_addr6: normalize_torrent_startup_value( - "IPv6 DHT listen address", - &settings.torrent_dht_listen_addr6, - crate::queue::normalize_torrent_dht_listen_addr6, - ), - lpd_interface: normalize_torrent_startup_value( - "Torrent LPD interface", - &settings.torrent_lpd_interface, - crate::queue::normalize_torrent_lpd_interface, - ), - peer_id_prefix: normalize_torrent_startup_value( - "Torrent peer ID prefix", - &settings.torrent_peer_id_prefix, - crate::queue::normalize_torrent_peer_id_prefix, - ), - peer_agent: normalize_torrent_startup_value( - "Torrent peer agent", - &settings.torrent_peer_agent, - crate::queue::normalize_torrent_peer_agent, - ), - dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout( - settings.torrent_dht_message_timeout, - ) - .unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT), - ipv6_enabled: settings.torrent_ipv6_enabled, - bind_address, - disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache)) - .unwrap_or_else(|error| { - log::error!("invalid persisted Aria2 disk cache; using default: {error}"); - crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string() - }), - } -} - pub fn load_settings( app_handle: &AppHandle, ) -> Result { @@ -144,139 +32,6 @@ pub fn decode_stored_settings(stored: &Value) -> Result, - key: &str, - normalize: impl Fn(Option<&str>) -> Result, String>, -) { - let Some(value) = state.get(key).and_then(Value::as_str) else { - return; - }; - let normalized = normalize(Some(value)).ok().flatten().unwrap_or_default(); - state.insert(key.to_string(), Value::String(normalized)); -} - -pub fn canonicalize_torrent_network_settings(stored: &str) -> Result { - let mut document = decode_document(&Value::String(stored.to_string()))?; - let state = settings_state_mut(&mut document)?; - canonicalize_torrent_network_value(state, "torrentListenPort", |value| { - crate::queue::normalize_torrent_port_spec(value, "TCP listen ports") - }); - canonicalize_torrent_network_value(state, "torrentDhtListenPort", |value| { - crate::queue::normalize_torrent_port_spec(value, "UDP listen ports") - }); - canonicalize_torrent_network_value(state, "torrentExternalIp", crate::queue::normalize_torrent_external_ip); - canonicalize_torrent_network_value(state, "torrentDhtEntryPoint", |value| { - crate::queue::normalize_torrent_dht_entry_point(value, false) - }); - canonicalize_torrent_network_value(state, "torrentDhtEntryPoint6", |value| { - crate::queue::normalize_torrent_dht_entry_point(value, true) - }); - canonicalize_torrent_network_value(state, "torrentDhtListenAddr6", crate::queue::normalize_torrent_dht_listen_addr6); - canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface); - canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix); - canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent); - canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address); - let disk_cache = state - .get("aria2DiskCache") - .and_then(Value::as_str) - .and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok()) - .unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()); - state.insert("aria2DiskCache".to_string(), Value::String(disk_cache)); - let dht_message_timeout = state - .get("torrentDhtMessageTimeout") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok()) - .unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT); - state.insert( - "torrentDhtMessageTimeout".to_string(), - Value::Number(serde_json::Number::from(dht_message_timeout)), - ); - let max_concurrent_seeds = state - .get("torrentMaxConcurrentSeeds") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok()) - .unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS); - state.insert( - "torrentMaxConcurrentSeeds".to_string(), - Value::Number(serde_json::Number::from(max_concurrent_seeds)), - ); - if !state - .get("torrentSeparateSeedSlots") - .is_some_and(Value::is_boolean) - { - state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false)); - } - if !state - .get("torrentIpv6Enabled") - .is_some_and(Value::is_boolean) - { - state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true)); - } - if state - .get("torrentIpv6Enabled") - .and_then(Value::as_bool) - == Some(false) - && state - .get("torrentBindAddress") - .and_then(Value::as_str) - .and_then(|value| value.parse::().ok()) - .is_some_and(|address| address.is_ipv6()) - { - return Err( - "IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(), - ); - } - // Renderer snapshots are also a persistence boundary. Remove malformed - // scalar values before the document is written so a recoverable default - // is not hidden behind a hostile value that will fail on the next save or - // restart. Keep the network canonicalization above first so invalid text - // fields retain their established empty-string representation. - let state_value = if document.get("state").is_some() { - document - .get_mut("state") - .ok_or_else(|| "persisted settings state is missing".to_string())? - } else { - &mut document - }; - sanitize_persisted_setting_values(state_value); - serde_json::to_string(&document) - .map_err(|error| format!("failed to encode canonical settings: {error}")) -} - -/// Normalize one text setting before the frontend commits it to durable state. -/// Keep this on the native boundary so interactive validation and persisted -/// settings use exactly the same Aria2-compatible rules. -pub fn canonicalize_torrent_network_setting(field: &str, value: &str) -> Result { - let normalized = match field { - "torrentListenPort" => { - crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports")? - } - "torrentDhtListenPort" => { - crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports")? - } - "torrentExternalIp" => crate::queue::normalize_torrent_external_ip(Some(value))?, - "torrentDhtEntryPoint" => { - crate::queue::normalize_torrent_dht_entry_point(Some(value), false)? - } - "torrentDhtEntryPoint6" => { - crate::queue::normalize_torrent_dht_entry_point(Some(value), true)? - } - "torrentDhtListenAddr6" => { - crate::queue::normalize_torrent_dht_listen_addr6(Some(value))? - } - "torrentLpdInterface" => crate::queue::normalize_torrent_lpd_interface(Some(value))?, - "torrentPeerIdPrefix" => crate::queue::normalize_torrent_peer_id_prefix(Some(value))?, - "torrentPeerAgent" => crate::queue::normalize_torrent_peer_agent(Some(value))?, - "torrentBindAddress" => crate::queue::normalize_torrent_bind_address(Some(value))?, - "aria2DiskCache" => return crate::queue::normalize_aria2_disk_cache(Some(value)), - _ => return Err("unknown Torrent network setting".to_string()), - }; - Ok(normalized.unwrap_or_default()) -} - pub fn update_settings_state( app_handle: &AppHandle, update: impl FnOnce(&mut Map), @@ -315,11 +70,7 @@ pub fn preserve_scheduler_runtime_keys( }; let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?; let incoming_state = settings_state_mut(&mut incoming_document)?; - for key in [ - "schedulerLastStartKey", - "schedulerTriggeredStartKey", - "schedulerLastStopKey", - ] { + for key in ["schedulerLastStartKey", "schedulerLastStopKey"] { if let Some(value) = existing_state.get(key) { incoming_state.insert(key.to_string(), value.clone()); } @@ -434,101 +185,9 @@ fn sanitize_persisted_setting_values(state: &mut Value) { return; }; - let main_window_size = state - .get("mainWindowSize") - .cloned() - .and_then(|value| serde_json::from_value::(value).ok()) - .and_then(|size| crate::window_geometry::normalize_main_window_size(Some(&size))); - match main_window_size { - Some(size) => { - state.insert( - "mainWindowSize".to_string(), - serde_json::to_value(size).expect("main window size is serializable"), - ); - } - None => { - state.remove("mainWindowSize"); - } - } - sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some()); sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some()); sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some()); - sanitize_integer_setting(state, "proxyPort", |value| { - value - .as_u64() - .is_some_and(|value| (1..=u16::MAX as u64).contains(&value)) - }); - sanitize_integer_setting(state, "torrentMaxOpenFiles", |value| { - value - .as_u64() - .is_some_and(|value| { - (crate::queue::MIN_TORRENT_MAX_OPEN_FILES as u64..= - crate::queue::MAX_TORRENT_MAX_OPEN_FILES as u64) - .contains(&value) - }) - }); - sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| { - value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| { - (crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT - ..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT) - .contains(&value) - }) - }); - sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| { - value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| { - (crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS - ..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS) - .contains(&value) - }) - }); - for key in [ - "isSidebarVisible", - "torrentEnableDht", - "torrentEnableDht6", - "torrentEnablePex", - "torrentEnableLpd", - "torrentSeparateSeedSlots", - "torrentIpv6Enabled", - ] { - sanitize_boolean_setting(state, key); - } - for key in ["proxyHost", "customUserAgent"] { - sanitize_string_setting(state, key); - } - sanitize_torrent_network_string(state, "torrentListenPort", |value| { - crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok() - }); - sanitize_torrent_network_string(state, "torrentDhtListenPort", |value| { - crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports").is_ok() - }); - sanitize_torrent_network_string(state, "torrentExternalIp", |value| { - crate::queue::normalize_torrent_external_ip(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "torrentDhtEntryPoint", |value| { - crate::queue::normalize_torrent_dht_entry_point(Some(value), false).is_ok() - }); - sanitize_torrent_network_string(state, "torrentDhtEntryPoint6", |value| { - crate::queue::normalize_torrent_dht_entry_point(Some(value), true).is_ok() - }); - sanitize_torrent_network_string(state, "torrentDhtListenAddr6", |value| { - crate::queue::normalize_torrent_dht_listen_addr6(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "torrentLpdInterface", |value| { - crate::queue::normalize_torrent_lpd_interface(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "torrentPeerIdPrefix", |value| { - crate::queue::normalize_torrent_peer_id_prefix(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "torrentPeerAgent", |value| { - crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "torrentBindAddress", |value| { - crate::queue::normalize_torrent_bind_address(Some(value)).is_ok() - }); - sanitize_torrent_network_string(state, "aria2DiskCache", |value| { - crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok() - }); sanitize_allowed_string( state, "theme", @@ -614,32 +273,6 @@ fn sanitize_integer_setting( } } -fn sanitize_boolean_setting(state: &mut serde_json::Map, key: &str) { - if state.get(key).is_some_and(|value| !value.is_boolean()) { - state.remove(key); - } -} - -fn sanitize_string_setting(state: &mut serde_json::Map, key: &str) { - if state.get(key).is_some_and(|value| !value.is_string()) { - state.remove(key); - } -} - -fn sanitize_torrent_network_string( - state: &mut serde_json::Map, - key: &str, - is_valid: impl Fn(&str) -> bool, -) { - if state - .get(key) - .and_then(Value::as_str) - .is_some_and(|value| !is_valid(value)) - { - state.remove(key); - } -} - fn sanitize_allowed_string( state: &mut serde_json::Map, key: &str, @@ -655,113 +288,12 @@ fn sanitize_allowed_string( } fn validate_settings(settings: &mut PersistedSettings) { - settings.main_window_size = crate::window_geometry::normalize_main_window_size( - settings.main_window_size.as_ref(), - ); if settings.max_concurrent_downloads == 0 { settings.max_concurrent_downloads = default_settings().max_concurrent_downloads; } settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12); settings.per_server_connections = settings.per_server_connections.clamp(1, 16); settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10); - settings.minimum_normal_download_speed_ki_b = - crate::queue::normalize_minimum_normal_download_speed_kib( - settings.minimum_normal_download_speed_ki_b, - ) - .unwrap_or_default(); - settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit) - .unwrap_or_default(); - settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2( - &settings.torrent_overall_upload_limit, - ) - .unwrap_or_default(); - settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files( - settings.torrent_max_open_files, - ) - .unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES); - settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout( - settings.torrent_dht_message_timeout, - ) - .unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT); - settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds( - settings.torrent_max_concurrent_seeds, - ) - .unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS); - settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address( - Some(&settings.torrent_bind_address), - ) - .ok() - .flatten() - .unwrap_or_default(); - if !settings.torrent_ipv6_enabled - && settings - .torrent_bind_address - .parse::() - .is_ok_and(|address| address.is_ipv6()) - { - log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled"); - settings.torrent_bind_address.clear(); - } - settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache)) - .unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()); - settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec( - Some(&settings.torrent_listen_port), - "TCP listen ports", - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_dht_listen_port = crate::queue::normalize_torrent_port_spec( - Some(&settings.torrent_dht_listen_port), - "UDP listen ports", - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_external_ip = crate::queue::normalize_torrent_external_ip( - Some(&settings.torrent_external_ip), - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_dht_entry_point = crate::queue::normalize_torrent_dht_entry_point( - Some(&settings.torrent_dht_entry_point), - false, - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_dht_entry_point6 = crate::queue::normalize_torrent_dht_entry_point( - Some(&settings.torrent_dht_entry_point6), - true, - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_dht_listen_addr6 = crate::queue::normalize_torrent_dht_listen_addr6( - Some(&settings.torrent_dht_listen_addr6), - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_lpd_interface = crate::queue::normalize_torrent_lpd_interface( - Some(&settings.torrent_lpd_interface), - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_peer_id_prefix = crate::queue::normalize_torrent_peer_id_prefix( - Some(&settings.torrent_peer_id_prefix), - ) - .ok() - .flatten() - .unwrap_or_default(); - settings.torrent_peer_agent = crate::queue::normalize_torrent_peer_agent( - Some(&settings.torrent_peer_agent), - ) - .ok() - .flatten() - .unwrap_or_default(); if !matches!( settings.last_custom_speed_limit_unit.as_str(), "KB/s" | "MB/s" @@ -778,7 +310,6 @@ fn default_category_subfolders() -> HashMap { ("Documents", "Documents"), ("Pictures", "Pictures"), ("Applications", "Applications"), - ("Torrents", "Torrents"), ("Other", "Other"), ] .into_iter() @@ -848,7 +379,6 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> { ("Documents", "Documents"), ("Pictures", "Images"), ("Applications", "Apps"), - ("Torrents", "Torrents"), ("Other", "Other"), ]; for (category, alias) in aliases { @@ -919,12 +449,9 @@ fn default_settings() -> PersistedSettings { approved_download_roots: Vec::new(), max_concurrent_downloads: 3, global_speed_limit: String::new(), - torrent_overall_upload_limit: String::new(), speed_limit_preset_values: vec![1.0, 5.0, 10.0], logs_enabled: false, is_sidebar_visible: true, - is_folders_collapsed: false, - main_window_size: None, sidebar_position: "auto".to_string(), active_settings_tab: SettingsTab::Downloads, scheduler: SchedulerSettings { @@ -940,15 +467,11 @@ fn default_settings() -> PersistedSettings { scheduler_running: false, scheduler_active_download_ids: Vec::new(), scheduler_last_start_key: String::new(), - scheduler_triggered_start_key: None, scheduler_last_stop_key: String::new(), last_custom_speed_limit_ki_b: 1024, last_custom_speed_limit_unit: "MB/s".to_string(), per_server_connections: 16, max_automatic_retries: 3, - minimum_normal_download_speed_ki_b: 0, - retry_not_found_errors: false, - adaptive_mirror_selection: true, show_notifications: true, play_completion_sound: false, auto_add_clipboard_links: false, @@ -959,26 +482,6 @@ fn default_settings() -> PersistedSettings { proxy_mode: ProxyMode::None, proxy_host: String::new(), proxy_port: 8080, - torrent_enable_dht: true, - torrent_enable_dht6: false, - torrent_enable_pex: true, - torrent_enable_lpd: false, - torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES, - torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT, - torrent_separate_seed_slots: false, - torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS, - torrent_ipv6_enabled: true, - torrent_listen_port: String::new(), - torrent_dht_listen_port: String::new(), - torrent_external_ip: String::new(), - torrent_dht_entry_point: String::new(), - torrent_dht_entry_point6: String::new(), - torrent_dht_listen_addr6: String::new(), - torrent_lpd_interface: String::new(), - torrent_peer_id_prefix: String::new(), - torrent_peer_agent: String::new(), - torrent_bind_address: String::new(), - aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(), custom_user_agent: String::new(), ask_where_to_save_each_file: false, remember_last_used_download_directory: false, @@ -995,10 +498,8 @@ fn default_settings() -> PersistedSettings { mod tests { use crate::ipc::{FontFamily, WindowControlStyle}; use super::{ - canonicalize_torrent_network_setting, canonicalize_torrent_network_settings, - decode_stored_settings, default_settings, - preserve_portable_pairing_token, preserve_scheduler_runtime_keys, - torrent_startup_settings, + decode_stored_settings, default_settings, preserve_portable_pairing_token, + preserve_scheduler_runtime_keys, }; use serde_json::{json, Value}; @@ -1007,7 +508,6 @@ mod tests { let existing = json!({ "state": { "schedulerLastStartKey": "2026-06-22-start", - "schedulerTriggeredStartKey": "2026-06-22-start", "schedulerLastStopKey": "2026-06-22-stop" }, "version": 3 @@ -1016,7 +516,6 @@ mod tests { let incoming = json!({ "state": { "schedulerLastStartKey": "", - "schedulerTriggeredStartKey": "", "schedulerLastStopKey": "", "theme": "system" }, @@ -1027,10 +526,6 @@ mod tests { let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap(); let merged: Value = serde_json::from_str(&merged).unwrap(); assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start"); - assert_eq!( - merged["state"]["schedulerTriggeredStartKey"], - "2026-06-22-start" - ); assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop"); } @@ -1040,7 +535,6 @@ mod tests { "state": { "maxConcurrentDownloads": 7, "globalSpeedLimit": "2M", - "torrentOverallUploadLimit": "1.5M", "sidebarPosition": "right", "scheduler": { "enabled": true, @@ -1059,7 +553,6 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 7); assert_eq!(settings.global_speed_limit, "2M"); - assert_eq!(settings.torrent_overall_upload_limit, "1.5M"); assert_eq!(settings.sidebar_position, "right"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); @@ -1085,7 +578,6 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 5); assert_eq!(settings.global_speed_limit, "512K"); - assert!(settings.torrent_overall_upload_limit.is_empty()); assert_eq!(settings.last_custom_speed_limit_unit, "MB/s"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); @@ -1108,33 +600,6 @@ mod tests { assert!(settings.logs_enabled); assert!(!settings.scheduler.enabled); assert!(settings.global_speed_limit.is_empty()); - assert!(settings.torrent_overall_upload_limit.is_empty()); - } - - #[test] - fn normalizes_invalid_torrent_overall_upload_limit_to_unlimited() { - let stored = json!({ - "state": { - "torrentOverallUploadLimit": "not-a-rate" - } - }); - - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - - assert!(settings.torrent_overall_upload_limit.is_empty()); - } - - #[test] - fn normalizes_invalid_global_speed_limit_to_unlimited() { - let stored = json!({ - "state": { - "globalSpeedLimit": "not-a-rate" - } - }); - - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - - assert!(settings.global_speed_limit.is_empty()); } #[test] @@ -1154,7 +619,6 @@ mod tests { assert_eq!(settings.base_download_folder, "/Users/test/Downloads"); assert_eq!(settings.category_subfolders["Movies"], "Movies"); - assert_eq!(settings.category_subfolders["Torrents"], "Torrents"); assert!(!settings.category_directory_overrides.contains_key("Movies")); assert_eq!( settings.category_directory_overrides["Documents"], @@ -1286,8 +750,7 @@ mod tests { "state": { "maxConcurrentDownloads": 99, "perServerConnections": -4, - "maxAutomaticRetries": 99, - "minimumNormalDownloadSpeedKiB": 2000000 + "maxAutomaticRetries": 99 }, "version": 3 }); @@ -1297,17 +760,6 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 12); assert_eq!(settings.per_server_connections, 1); assert_eq!(settings.max_automatic_retries, 10); - assert_eq!(settings.minimum_normal_download_speed_ki_b, 0); - } - - #[test] - fn normal_reliability_defaults_are_migration_safe() { - let stored = json!({ "state": {}, "version": 5 }); - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - - assert_eq!(settings.minimum_normal_download_speed_ki_b, 0); - assert!(!settings.retry_not_found_errors); - assert!(settings.adaptive_mirror_selection); } #[test] @@ -1318,20 +770,6 @@ mod tests { "maxConcurrentDownloads": "not-a-number", "perServerConnections": 5, "showNotifications": "yes", - "torrentEnableDht": "yes", - "torrentEnableDht6": 1, - "torrentEnablePex": null, - "torrentEnableLpd": [], - "torrentMaxOpenFiles": 0, - "torrentListenPort": "7000-6999", - "torrentDhtListenPort": "6881,\n", - "torrentExternalIp": "not-an-ip", - "torrentDhtEntryPoint": "bootstrap.example", - "torrentDhtEntryPoint6": "2001:db8::1:6881", - "torrentDhtListenAddr6": "127.0.0.1", - "torrentLpdInterface": "en0\n--bad", - "torrentPeerIdPrefix": "123456789012345678901", - "torrentPeerAgent": "agent\nname", "theme": "not-a-theme", "calendarPreference": "lunar", "siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}] @@ -1345,27 +783,6 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 3); assert_eq!(settings.per_server_connections, 5); assert!(settings.show_notifications); - assert!(settings.torrent_enable_dht); - assert!(!settings.torrent_enable_dht6); - assert!(settings.torrent_enable_pex); - assert!(!settings.torrent_enable_lpd); - assert_eq!( - settings.torrent_max_open_files, - crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES - ); - assert_eq!( - settings.torrent_dht_message_timeout, - crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT - ); - assert!(settings.torrent_listen_port.is_empty()); - assert!(settings.torrent_dht_listen_port.is_empty()); - assert!(settings.torrent_external_ip.is_empty()); - assert!(settings.torrent_dht_entry_point.is_empty()); - assert!(settings.torrent_dht_entry_point6.is_empty()); - assert!(settings.torrent_dht_listen_addr6.is_empty()); - assert!(settings.torrent_lpd_interface.is_empty()); - assert!(settings.torrent_peer_id_prefix.is_empty()); - assert!(settings.torrent_peer_agent.is_empty()); assert!(matches!(settings.theme, crate::ipc::Theme::System)); assert!(matches!( settings.calendar_preference, @@ -1375,160 +792,10 @@ mod tests { assert_eq!(settings.site_logins[0].id, "valid"); } - #[test] - fn malformed_proxy_and_user_agent_values_fall_back_to_safe_defaults() { - let stored = json!({ - "state": { - "proxyMode": "custom", - "proxyHost": 123, - "proxyPort": 70000, - "customUserAgent": ["not-a-string"], - "isSidebarVisible": "yes" - } - }); - - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - - assert!(matches!(settings.proxy_mode, crate::ipc::ProxyMode::Custom)); - assert!(settings.proxy_host.is_empty()); - assert_eq!(settings.proxy_port, 8080); - assert!(settings.custom_user_agent.is_empty()); - assert!(settings.is_sidebar_visible); - } - - #[test] - fn preserves_valid_torrent_network_settings() { - let stored = json!({ - "state": { - "torrentListenPort": " 6881-6999 ", - "torrentDhtListenPort": "6881", - "torrentExternalIp": "203.0.113.7", - "torrentDhtEntryPoint": "Bootstrap.Example:6881", - "torrentDhtEntryPoint6": "[2001:db8::1]:6881", - "torrentDhtListenAddr6": "2001:db8::2", - "torrentLpdInterface": "en0", - "torrentPeerIdPrefix": "-FL-1-3-1-", - "torrentPeerAgent": "Firelink/1.3.1" - } - }); - - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - - assert_eq!(settings.torrent_listen_port, "6881-6999"); - assert_eq!(settings.torrent_dht_listen_port, "6881"); - assert_eq!(settings.torrent_external_ip, "203.0.113.7"); - assert_eq!(settings.torrent_dht_entry_point, "bootstrap.example:6881"); - assert_eq!(settings.torrent_dht_entry_point6, "[2001:db8::1]:6881"); - assert_eq!(settings.torrent_dht_listen_addr6, "2001:db8::2"); - assert_eq!(settings.torrent_lpd_interface, "en0"); - assert_eq!(settings.torrent_peer_id_prefix, "-FL-1-3-1-"); - assert_eq!(settings.torrent_peer_agent, "Firelink/1.3.1"); - } - - #[test] - fn canonicalizes_torrent_network_settings_for_frontend_hydration() { - let stored = json!({ - "state": { - "torrentListenPort": " 6881-6999 ", - "torrentExternalIp": "not-an-ip", - "torrentPeerIdPrefix": "123456789012345678901", - "torrentPeerAgent": " Firelink/1.3.1 ", - "torrentDhtMessageTimeout": 601, - "torrentMaxConcurrentSeeds": 65, - "torrentSeparateSeedSlots": "yes", - "proxyPort": 70000, - "proxyHost": 123, - "customUserAgent": ["not-a-string"] - }, - "version": 6 - }); - - let canonical = canonicalize_torrent_network_settings(&stored.to_string()).unwrap(); - let canonical: Value = serde_json::from_str(&canonical).unwrap(); - assert_eq!(canonical["state"]["torrentListenPort"], "6881-6999"); - assert_eq!(canonical["state"]["torrentExternalIp"], ""); - assert_eq!(canonical["state"]["torrentPeerIdPrefix"], ""); - assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1"); - assert_eq!( - canonical["state"]["torrentDhtMessageTimeout"], - crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT - ); - assert_eq!( - canonical["state"]["torrentMaxConcurrentSeeds"], - crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS - ); - assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false); - assert!(canonical["state"].get("proxyPort").is_none()); - assert!(canonical["state"].get("proxyHost").is_none()); - assert!(canonical["state"].get("customUserAgent").is_none()); - } - - #[test] - fn canonicalizes_individual_torrent_network_inputs_with_shared_rules() { - assert_eq!( - canonicalize_torrent_network_setting("torrentListenPort", " 6881-6999 ").unwrap(), - "6881-6999" - ); - assert_eq!( - canonicalize_torrent_network_setting("torrentDhtEntryPoint6", "[2001:db8::1]:6881") - .unwrap(), - "[2001:db8::1]:6881" - ); - assert_eq!( - canonicalize_torrent_network_setting("aria2DiskCache", " 256m ").unwrap(), - "256M" - ); - assert_eq!( - canonicalize_torrent_network_setting("torrentBindAddress", " ").unwrap(), - "" - ); - assert!(canonicalize_torrent_network_setting("torrentListenPort", "61").is_err()); - assert!(canonicalize_torrent_network_setting("unknown", "value").is_err()); - } - - #[test] - fn rejects_ipv6_bind_address_when_transport_is_disabled() { - let stored = json!({ - "state": { - "torrentIpv6Enabled": false, - "torrentBindAddress": "2001:db8::10" - } - }); - - let error = canonicalize_torrent_network_settings(&stored.to_string()) - .expect_err("IPv6 bind must not be accepted with IPv6 transport disabled"); - assert!(error.contains("IPv6 Torrent bind address")); - } - - #[test] - fn startup_settings_revalidate_values_at_the_aria2_boundary() { - let stored = json!({ - "state": { - "torrentListenPort": "not-a-port", - "torrentPeerIdPrefix": "123456789012345678901", - "torrentPeerAgent": "Firelink/1.3.1" - } - }); - let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); - let startup = torrent_startup_settings(Some(&settings)); - assert!(startup.listen_port.is_empty()); - assert!(startup.peer_id_prefix.is_empty()); - assert_eq!(startup.peer_agent, "Firelink/1.3.1"); - assert_eq!( - startup.dht_message_timeout, - crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT - ); - } - #[test] fn opt_in_defaults_match_the_frontend_defaults() { assert!(!default_settings().play_completion_sound); assert!(!default_settings().auto_add_clipboard_links); - assert!(!default_settings().torrent_separate_seed_slots); - assert_eq!( - default_settings().torrent_max_concurrent_seeds, - crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS - ); } #[test] @@ -1536,51 +803,6 @@ mod tests { assert!(!default_settings().remember_last_used_download_directory); } - #[test] - fn legacy_settings_without_geometry_use_no_persisted_size() { - let settings = decode_stored_settings(&Value::String( - json!({ "state": { "theme": "system" }, "version": 0 }).to_string(), - )) - .unwrap(); - - assert!(settings.main_window_size.is_none()); - } - - #[test] - fn valid_main_window_geometry_round_trips() { - let settings = decode_stored_settings(&Value::String( - json!({ - "state": { "mainWindowSize": { "width": 1440, "height": 900 } }, - "version": 6 - }) - .to_string(), - )) - .unwrap(); - - assert_eq!( - settings - .main_window_size - .as_ref() - .map(|size| (size.width, size.height)), - Some((1440, 900)) - ); - } - - #[test] - fn malformed_and_out_of_range_geometry_is_dropped() { - for geometry in [ - json!({ "width": "1440", "height": 900 }), - json!({ "width": 959, "height": 900 }), - json!({ "width": 1440, "height": 16_385 }), - ] { - let settings = decode_stored_settings(&Value::String( - json!({ "state": { "mainWindowSize": geometry }, "version": 6 }).to_string(), - )) - .unwrap(); - assert!(settings.main_window_size.is_none()); - } - } - #[test] fn decodes_disabled_last_used_download_directory_setting() { let stored = json!({ diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 5e24c45..b918d30 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -5,11 +5,6 @@ pub const PORTABLE_MARKER: &str = "portable.flag"; const PORTABLE_DATA_DIR: &str = "data"; const PORTABLE_LOG_DIR: &str = "logs"; const PORTABLE_WEBVIEW_DIR: &str = "webview"; -const ARIA2_DATA_DIR: &str = "aria2"; -const ARIA2_DHT_FILE: &str = "dht.dat"; -const ARIA2_DHT6_FILE: &str = "dht6.dat"; -const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt"; -const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub enum StorageMode { @@ -109,149 +104,10 @@ impl StorageLayout { pub fn webview_dir(&self) -> &Path { &self.webview_dir } - - pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) { - let directory = self.data_dir.join(ARIA2_DATA_DIR); - ( - directory.join(ARIA2_DHT_FILE), - directory.join(ARIA2_DHT6_FILE), - ) - } - - pub fn aria2_server_stat_path(&self) -> PathBuf { - self.data_dir - .join(ARIA2_DATA_DIR) - .join(ARIA2_SERVER_STAT_FILE) - } - - /// Create and validate only Firelink's Aria2 state directory. Aria2 owns - /// the table contents; Firelink owns this exact location and must never - /// fall back to a user-global default when it cannot establish it. - pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> { - let directory = self.data_dir.join(ARIA2_DATA_DIR); - if crate::path_has_symlink_component(&directory) { - return Err(format!( - "Aria2 state directory contains a symlink: '{}'", - directory.display() - )); - } - - match std::fs::symlink_metadata(&directory) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "Aria2 state directory is a symlink: '{}'", - directory.display() - )); - } - Ok(metadata) if !metadata.is_dir() => { - return Err(format!( - "Aria2 state path is not a directory: '{}'", - directory.display() - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(&directory).map_err(|error| { - format!( - "failed to create Aria2 state directory '{}': {error}", - directory.display() - ) - })?; - } - Err(error) => { - return Err(format!( - "failed to inspect Aria2 state directory '{}': {error}", - directory.display() - )); - } - } - - Ok(self.aria2_dht_paths()) - } - - /// Prepare the exact cache file used by Aria2's adaptive URI selector. - /// The cache is non-authoritative: malformed or oversized contents are - /// reset to empty, while symlinks and non-files disable the cache instead - /// of allowing Aria2 to write outside Firelink's storage boundary. - pub fn prepare_aria2_server_stat_path(&self) -> Result { - let directory = self.data_dir.join(ARIA2_DATA_DIR); - if crate::path_has_symlink_component(&directory) { - return Err("Aria2 server-stat directory contains a symlink".to_string()); - } - std::fs::create_dir_all(&directory) - .map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?; - - let path = self.aria2_server_stat_path(); - match std::fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err("Aria2 server-stat cache is a symlink".to_string()); - } - Ok(metadata) if !metadata.is_file() => { - return Err("Aria2 server-stat cache is not a regular file".to_string()); - } - Ok(metadata) => { - let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES - && std::fs::read_to_string(&path) - .ok() - .is_some_and(|contents| aria2_server_stat_is_valid(&contents)); - if !valid { - std::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(&path) - .map_err(|error| { - format!("failed to reset Aria2 server-stat cache: {error}") - })?; - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - .map_err(|error| { - format!("failed to create Aria2 server-stat cache: {error}") - })?; - } - Err(error) => { - return Err(format!( - "failed to inspect Aria2 server-stat cache: {error}" - )); - } - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?; - } - Ok(path) - } -} - -fn aria2_server_stat_is_valid(contents: &str) -> bool { - contents.lines().all(|line| { - let line = line.trim(); - if line.is_empty() { - return true; - } - if line.chars().any(char::is_control) { - return false; - } - let fields = line - .split(',') - .filter_map(|field| field.trim().split_once('=')) - .map(|(name, value)| (name.trim(), value.trim())) - .collect::>(); - ["host", "protocol", "dl_speed", "last_updated", "status"] - .iter() - .all(|name| fields.get(name).is_some_and(|value| !value.is_empty())) - }) } fn canonicalize_storage_path(path: &Path) -> Result { - if crate::path_has_symbolic_link_component(path) { + if crate::path_has_symlink_component(path) { return Err(format!( "storage path contains a symlinked component: '{}'", path.display() @@ -298,7 +154,7 @@ fn canonicalize_storage_path(path: &Path) -> Result { #[cfg(test)] mod tests { - use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER}; + use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER}; use std::fs; use std::path::Path; use tempfile::TempDir; @@ -326,109 +182,6 @@ mod tests { ); } - fn test_layout(data_dir: &Path) -> StorageLayout { - let data_dir = fs::canonicalize(data_dir).unwrap(); - StorageLayout { - mode: StorageMode::Standard, - data_dir: data_dir.clone(), - log_dir: data_dir.join("logs"), - webview_dir: data_dir.join("webview"), - } - } - - #[test] - fn aria2_dht_paths_are_owned_by_the_selected_data_directory() { - let root = TempDir::new().unwrap(); - let layout = test_layout(root.path()); - let root_path = fs::canonicalize(root.path()).unwrap(); - - assert_eq!( - layout.aria2_dht_paths(), - ( - root_path.join("aria2/dht.dat"), - root_path.join("aria2/dht6.dat") - ) - ); - let prepared = layout.prepare_aria2_dht_paths().unwrap(); - assert_eq!(prepared, layout.aria2_dht_paths()); - assert!(root_path.join("aria2").is_dir()); - } - - #[test] - fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() { - let root = TempDir::new().unwrap(); - let root_path = fs::canonicalize(root.path()).unwrap(); - fs::write(root_path.join("aria2"), b"not a directory").unwrap(); - - let error = test_layout(root.path()) - .prepare_aria2_dht_paths() - .unwrap_err(); - assert!(error.contains("not a directory")); - } - - #[test] - fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() { - let root = TempDir::new().unwrap(); - let layout = test_layout(root.path()); - layout.prepare_aria2_dht_paths().unwrap(); - let path = layout.prepare_aria2_server_stat_path().unwrap(); - assert_eq!(path, layout.aria2_server_stat_path()); - assert_eq!(fs::read_to_string(&path).unwrap(), ""); - - fs::write(&path, "not an aria2 server profile\n").unwrap(); - layout.prepare_aria2_server_stat_path().unwrap(); - assert_eq!(fs::read_to_string(&path).unwrap(), ""); - - let valid = - "host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n"; - fs::write(&path, valid).unwrap(); - layout.prepare_aria2_server_stat_path().unwrap(); - assert_eq!(fs::read_to_string(&path).unwrap(), valid); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } - - #[cfg(unix)] - #[test] - fn aria2_server_stat_cache_rejects_symlink_output() { - use std::os::unix::fs::symlink; - - let root = TempDir::new().unwrap(); - let target = TempDir::new().unwrap(); - let layout = test_layout(root.path()); - layout.prepare_aria2_dht_paths().unwrap(); - symlink( - target.path().join("outside"), - layout.aria2_server_stat_path(), - ) - .unwrap(); - - assert!(layout.prepare_aria2_server_stat_path().is_err()); - } - - #[cfg(unix)] - #[test] - fn aria2_dht_preparation_rejects_a_symlinked_directory() { - use std::os::unix::fs::symlink; - - let root = TempDir::new().unwrap(); - let target = TempDir::new().unwrap(); - let root_path = fs::canonicalize(root.path()).unwrap(); - symlink(target.path(), root_path.join("aria2")).unwrap(); - - let error = test_layout(root.path()) - .prepare_aria2_dht_paths() - .unwrap_err(); - assert!(error.contains("symlink")); - } - #[cfg(unix)] #[test] fn rejects_symlinked_storage_directories() { @@ -455,32 +208,4 @@ mod tests { assert!(canonicalize_storage_path(Path::new(&redirected)).is_err()); } - - #[cfg(windows)] - #[test] - fn accepts_windows_junctions_for_redirected_storage_paths() { - use std::process::Command; - - let parent = TempDir::new().unwrap(); - let spaced_parent = parent.path().join("firelink test data"); - fs::create_dir(&spaced_parent).unwrap(); - let root = TempDir::new_in(&spaced_parent).unwrap(); - let target = TempDir::new_in(&spaced_parent).unwrap(); - let redirected = root.path().join("redirected"); - let target_storage = target.path().join("firelink"); - fs::create_dir(&target_storage).unwrap(); - - let status = Command::new("cmd") - .args(["/D", "/C", "mklink", "/J"]) - .arg(&redirected) - .arg(target.path()) - .status() - .expect("Windows junction creation command should start"); - assert!(status.success(), "mklink /J failed with status {status}"); - - assert_eq!( - canonicalize_storage_path(&redirected.join("firelink")).unwrap(), - fs::canonicalize(target_storage).unwrap() - ); - } } diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs deleted file mode 100644 index 93b87dc..0000000 --- a/src-tauri/src/torrent.rs +++ /dev/null @@ -1,1855 +0,0 @@ -use sha1::{Digest, Sha1}; -use std::collections::{BTreeMap, HashSet}; -use std::io::Read; -use std::path::{Path, PathBuf}; - -use tauri::Manager; -use tokio::io::AsyncReadExt; - -use crate::ipc::{TorrentFile, TorrentMetadata}; - -pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024; - -#[derive(Debug, Clone)] -pub struct ParsedTorrent { - pub name: String, - pub total_bytes: u64, - pub files: Vec, - pub info_hash: String, - /// URL-list entries from the torrent metainfo. These are validated again - /// at enqueue time because Aria2 consumes the original torrent bytes. - pub web_seeds: Vec, -} - -#[derive(Debug, Clone)] -enum BencodeValue { - Integer(i64), - Bytes(Vec), - List(Vec), - Dict(BTreeMap, BencodeValue>), -} - -struct Parser<'a> { - input: &'a [u8], - position: usize, - depth: usize, -} - -impl<'a> Parser<'a> { - fn new(input: &'a [u8]) -> Self { - Self { input, position: 0, depth: 0 } - } - - fn parse(mut self) -> Result { - let value = self.parse_value()?; - if self.position != self.input.len() { - return Err("torrent metadata has trailing data".to_string()); - } - Ok(value) - } - - fn parse_value(&mut self) -> Result { - if self.depth >= 128 { - return Err("torrent metadata nesting is too deep".to_string()); - } - let byte = *self - .input - .get(self.position) - .ok_or_else(|| "torrent metadata is truncated".to_string())?; - match byte { - b'i' => self.parse_integer(), - b'l' => self.parse_list(), - b'd' => self.parse_dict(), - b'0'..=b'9' => self.parse_bytes(), - _ => Err("torrent metadata contains an invalid bencode value".to_string()), - } - } - - fn parse_integer(&mut self) -> Result { - self.position += 1; - let start = self.position; - while let Some(byte) = self.input.get(self.position) { - if *byte == b'e' { - break; - } - self.position += 1; - } - if self.input.get(self.position) != Some(&b'e') { - return Err("torrent integer is truncated".to_string()); - } - let raw = std::str::from_utf8(&self.input[start..self.position]) - .map_err(|_| "torrent integer is not valid UTF-8".to_string())?; - if raw.is_empty() || (raw.starts_with('0') && raw.len() > 1) || raw == "-0" { - return Err("torrent integer has invalid encoding".to_string()); - } - let value = raw - .parse::() - .map_err(|_| "torrent integer is outside the supported range".to_string())?; - self.position += 1; - Ok(BencodeValue::Integer(value)) - } - - fn parse_bytes(&mut self) -> Result { - let start = self.position; - while let Some(byte) = self.input.get(self.position) { - if *byte == b':' { - break; - } - if !byte.is_ascii_digit() { - return Err("torrent byte string has an invalid length".to_string()); - } - self.position += 1; - } - if self.input.get(self.position) != Some(&b':') { - return Err("torrent byte string is truncated".to_string()); - } - let length = std::str::from_utf8(&self.input[start..self.position]) - .map_err(|_| "torrent byte string length is invalid".to_string())? - .to_owned(); - if (length.starts_with('0') && length.len() > 1) || length.is_empty() { - return Err("torrent byte string has invalid length encoding".to_string()); - } - let length = length - .parse::() - .map_err(|_| "torrent byte string length is too large".to_string())?; - self.position += 1; - let end = self - .position - .checked_add(length) - .ok_or_else(|| "torrent byte string length overflowed".to_string())?; - if end > self.input.len() { - return Err("torrent byte string is truncated".to_string()); - } - let value = self.input[self.position..end].to_vec(); - self.position = end; - Ok(BencodeValue::Bytes(value)) - } - - fn parse_list(&mut self) -> Result { - self.position += 1; - self.depth += 1; - let mut values = Vec::new(); - while self.input.get(self.position) != Some(&b'e') { - if self.input.get(self.position).is_none() { - self.depth -= 1; - return Err("torrent list is truncated".to_string()); - } - values.push(self.parse_value()?); - } - self.position += 1; - self.depth -= 1; - Ok(BencodeValue::List(values)) - } - - fn parse_dict(&mut self) -> Result { - self.position += 1; - self.depth += 1; - let mut values = BTreeMap::new(); - let mut previous_key: Option> = None; - while self.input.get(self.position) != Some(&b'e') { - if self.input.get(self.position).is_none() { - self.depth -= 1; - return Err("torrent dictionary is truncated".to_string()); - } - let key = match self.parse_bytes()? { - BencodeValue::Bytes(key) => key, - _ => unreachable!(), - }; - if previous_key.as_ref().is_some_and(|previous| previous >= &key) { - self.depth -= 1; - return Err("torrent dictionary keys are not sorted".to_string()); - } - previous_key = Some(key.clone()); - values.insert(key, self.parse_value()?); - } - self.position += 1; - self.depth -= 1; - Ok(BencodeValue::Dict(values)) - } -} - -fn encode(value: &BencodeValue, output: &mut Vec) { - match value { - BencodeValue::Integer(value) => output.extend_from_slice(format!("i{value}e").as_bytes()), - BencodeValue::Bytes(value) => { - output.extend_from_slice(value.len().to_string().as_bytes()); - output.push(b':'); - output.extend_from_slice(value); - } - BencodeValue::List(values) => { - output.push(b'l'); - for value in values { - encode(value, output); - } - output.push(b'e'); - } - BencodeValue::Dict(values) => { - output.push(b'd'); - for (key, value) in values { - encode(&BencodeValue::Bytes(key.clone()), output); - encode(value, output); - } - output.push(b'e'); - } - } -} - -fn bytes_text(value: Option<&BencodeValue>, field: &str) -> Result { - let bytes = match value { - Some(BencodeValue::Bytes(bytes)) => bytes, - _ => return Err(format!("torrent metadata is missing {field}")), - }; - String::from_utf8(bytes.clone()).map_err(|_| format!("torrent {field} is not valid UTF-8")) -} - -fn positive_length(value: Option<&BencodeValue>, field: &str) -> Result { - match value { - Some(BencodeValue::Integer(value)) if *value >= 0 => Ok(*value as u64), - _ => Err(format!("torrent {field} is invalid")), - } -} - -fn safe_path_component(value: &str, field: &str) -> Result { - let value = value.trim(); - if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) { - return Err(format!("torrent {field} contains an unsafe path")); - } - Ok(crate::download_ownership::canonical_download_filename(value)) -} - -fn parse_info(info: &BencodeValue) -> Result { - let info_dict = match info { - BencodeValue::Dict(value) => value, - _ => return Err("torrent info dictionary is invalid".to_string()), - }; - let name = info_dict - .get(b"name.utf-8".as_slice()) - .or_else(|| info_dict.get(b"name".as_slice())) - .map(|value| bytes_text(Some(value), "name")) - .transpose()? - .ok_or_else(|| "torrent metadata is missing name".to_string())?; - let name = crate::download_ownership::canonical_download_filename(&safe_path_component(&name, "name")?); - - let mut files = Vec::new(); - let total_bytes = if let Some(files_value) = info_dict.get(b"files".as_slice()) { - let entries = match files_value { - BencodeValue::List(entries) => entries, - _ => return Err("torrent files field is invalid".to_string()), - }; - let mut total = 0u64; - let mut paths = HashSet::new(); - for (position, entry) in entries.iter().enumerate() { - let entry = match entry { - BencodeValue::Dict(value) => value, - _ => return Err(format!("torrent file entry {} is invalid", position + 1)), - }; - let path_value = entry - .get(b"path.utf-8".as_slice()) - .or_else(|| entry.get(b"path".as_slice())) - .ok_or_else(|| format!("torrent file entry {} has no path", position + 1))?; - let path_parts = match path_value { - BencodeValue::List(parts) => parts - .iter() - .map(|part| bytes_text(Some(part), "file path")) - .collect::, _>>()?, - _ => return Err(format!("torrent file entry {} path is invalid", position + 1)), - }; - if path_parts.is_empty() { - return Err(format!("torrent file entry {} path is empty", position + 1)); - } - let path = path_parts - .iter() - .map(|part| safe_path_component(part, "file path")) - .collect::, _>>()? - .join("/"); - if !paths.insert(path.clone()) { - return Err("torrent contains duplicate output paths".to_string()); - } - let length = positive_length(entry.get(b"length".as_slice()), "file length")?; - total = total - .checked_add(length) - .ok_or_else(|| "torrent total size is too large".to_string())?; - files.push(TorrentFile { - index: (position + 1) as u32, - path, - length, - }); - } - if files.is_empty() { - return Err("torrent has no files".to_string()); - } - total - } else { - let length = positive_length(info_dict.get(b"length".as_slice()), "length")?; - files.push(TorrentFile { - index: 1, - path: name.clone(), - length, - }); - length - }; - - let mut encoded = Vec::new(); - encode(info, &mut encoded); - let digest = Sha1::digest(encoded); - let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect(); - - Ok(ParsedTorrent { name, total_bytes, files, info_hash, web_seeds: Vec::new() }) -} - -fn canonical_btih(value: &str) -> Option { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Some(normalized); - } - if normalized.len() != 32 { - return None; - } - - let mut decoded = Vec::with_capacity(20); - let mut accumulator = 0u64; - let mut bits = 0u8; - for byte in normalized.bytes() { - let value = match byte { - b'a'..=b'z' => byte - b'a', - b'2'..=b'7' => byte - b'2' + 26, - _ => return None, - } as u64; - accumulator = (accumulator << 5) | value; - bits += 5; - while bits >= 8 { - bits -= 8; - decoded.push(((accumulator >> bits) & 0xff) as u8); - } - if bits == 0 { - accumulator = 0; - } else { - accumulator &= (1u64 << bits) - 1; - } - } - if bits != 0 || decoded.len() != 20 { - return None; - } - - Some(decoded.iter().map(|byte| format!("{byte:02x}")).collect()) -} - -pub fn canonical_info_hash(value: &str) -> Option { - canonical_btih(value) -} - -pub fn validate_info_hash(expected: Option<&str>, actual: &str) -> Result<(), String> { - let Some(expected) = expected else { - return Ok(()); - }; - let expected = canonical_btih(expected) - .ok_or_else(|| "torrent metadata has an invalid expected info hash".to_string())?; - if expected != actual { - return Err("torrent metadata changed before it was queued".to_string()); - } - Ok(()) -} - -pub fn parse_torrent_bytes(bytes: &[u8]) -> Result { - if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { - return Err(format!("torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes")); - } - let root = Parser::new(bytes).parse()?; - let root = match root { - BencodeValue::Dict(value) => value, - _ => return Err("torrent root is not a dictionary".to_string()), - }; - let info = root - .get(b"info".as_slice()) - .ok_or_else(|| "torrent metadata is missing info".to_string())?; - let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?; - let mut parsed = parse_info(info)?; - parsed.web_seeds = web_seeds; - Ok(parsed) -} - -/// Remove the untrusted metainfo URL-list before handing a torrent to Aria2. -/// The info dictionary, and therefore the info hash, remains unchanged. The -/// caller can pass the parsed seeds back through the explicit web-seed policy -/// and add only the destinations that were accepted there. -pub fn sanitize_torrent_bytes_for_aria2(bytes: &[u8]) -> Result<(Vec, Vec), String> { - if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { - return Err(format!( - "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" - )); - } - let root = match Parser::new(bytes).parse()? { - BencodeValue::Dict(value) => value, - _ => return Err("torrent root is not a dictionary".to_string()), - }; - let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?; - let mut sanitized = root; - sanitized.remove(b"url-list".as_slice()); - let mut encoded = Vec::with_capacity(bytes.len()); - encode(&BencodeValue::Dict(sanitized), &mut encoded); - Ok((encoded, web_seeds)) -} - -fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option { - let BencodeValue::Bytes(bytes) = value? else { - return None; - }; - if bytes.len() > limit { - return None; - } - String::from_utf8(bytes.clone()) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn bounded_uri(value: &str, schemes: &[&str]) -> Option { - if value.len() > 2_048 || value.chars().any(char::is_control) { - return None; - } - let parsed = url::Url::parse(value).ok()?; - if !schemes.contains(&parsed.scheme()) - || parsed.host_str().is_none_or(str::is_empty) - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - { - return None; - } - Some(parsed.to_string()) -} - -fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec { - let mut values = Vec::new(); - let mut append = |value: &BencodeValue| { - if let BencodeValue::Bytes(bytes) = value { - if let Ok(value) = String::from_utf8(bytes.clone()) { - if let Some(uri) = bounded_uri(value.trim(), schemes) { - if !values.contains(&uri) && values.len() < 256 { - values.push(uri); - } - } - } - } - }; - match value { - Some(BencodeValue::Bytes(_)) => append(value.unwrap()), - Some(BencodeValue::List(entries)) => { - for entry in entries { - append(entry); - } - } - _ => {} - } - values -} - -fn torrent_tracker_uri_is_safe(value: &BencodeValue) -> bool { - let BencodeValue::Bytes(bytes) = value else { - return false; - }; - let Ok(value) = String::from_utf8(bytes.clone()) else { - return false; - }; - bounded_uri(value.trim(), &["http", "https", "udp"]).is_some() -} - -fn torrent_tracker_metadata_is_safe(root: &BTreeMap, BencodeValue>) -> bool { - let mut tracker_count = 0usize; - let mut count_tracker = |value: &BencodeValue| { - tracker_count = tracker_count.saturating_add(1); - tracker_count <= 256 && torrent_tracker_uri_is_safe(value) - }; - - if let Some(announce) = root.get(b"announce".as_slice()) { - if !count_tracker(announce) { - return false; - } - } - - let Some(announce_list) = root.get(b"announce-list".as_slice()) else { - return true; - }; - let BencodeValue::List(tiers) = announce_list else { - return false; - }; - for tier in tiers { - let BencodeValue::List(trackers) = tier else { - return false; - }; - for tracker in trackers { - if !count_tracker(tracker) { - return false; - } - } - } - true -} - -fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result, String> { - let Some(value) = value else { - return Ok(Vec::new()); - }; - let entries = match value { - BencodeValue::Bytes(_) => std::slice::from_ref(value), - BencodeValue::List(entries) => entries.as_slice(), - _ => return Err("torrent url-list field is invalid".to_string()), - }; - if entries.len() > 256 { - return Err("torrent url-list contains too many web seeds".to_string()); - } - let mut normalized = Vec::with_capacity(entries.len()); - for entry in entries { - let BencodeValue::Bytes(bytes) = entry else { - return Err("torrent url-list contains a non-text web seed".to_string()); - }; - let value = String::from_utf8(bytes.clone()) - .map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?; - let value = value.trim(); - let uri = bounded_uri(value, &["http", "https"]) - .ok_or_else(|| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?; - if !normalized.contains(&uri) { - normalized.push(uri); - } - } - Ok(normalized) -} - -pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result { - if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { - return Err(format!( - "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" - )); - } - let root = match Parser::new(bytes).parse()? { - BencodeValue::Dict(value) => value, - _ => return Err("torrent root is not a dictionary".to_string()), - }; - let info = root - .get(b"info".as_slice()) - .ok_or_else(|| "torrent metadata is missing info".to_string())?; - let parsed = parse_info(info)?; - let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?; - let info_dict = match info { - BencodeValue::Dict(value) => value, - _ => return Err("torrent info dictionary is invalid".to_string()), - }; - let piece_length = positive_length(info_dict.get(b"piece length".as_slice()), "piece length")?; - let piece_count = match info_dict.get(b"pieces".as_slice()) { - Some(BencodeValue::Bytes(pieces)) if pieces.len() % 20 == 0 => (pieces.len() / 20) as u64, - _ => return Err("torrent pieces field is invalid".to_string()), - }; - let creation_date = root - .get(b"creation date".as_slice()) - .and_then(|value| match value { - BencodeValue::Integer(value) if *value >= 0 => chrono::DateTime::::from_timestamp(*value, 0) - .map(|date| date.to_rfc3339()), - _ => None, - }); - let trackers = collect_torrent_uris(root.get(b"announce".as_slice()), &["http", "https", "udp"]) - .into_iter() - .chain(root.get(b"announce-list".as_slice()).into_iter().flat_map(|value| { - let mut trackers = Vec::new(); - if let BencodeValue::List(tiers) = value { - for tier in tiers { - if let BencodeValue::List(entries) = tier { - for entry in entries { - trackers.extend(collect_torrent_uris(Some(entry), &["http", "https", "udp"])); - } - } - } - } - trackers - })) - .fold(Vec::new(), |mut result, tracker| { - if !result.contains(&tracker) && result.len() < 256 { - result.push(tracker); - } - result - }); - Ok(crate::ipc::TorrentDetails { - info_hash: parsed.info_hash, - display_name: parsed.name, - total_bytes: parsed.total_bytes, - file_count: parsed.files.len() as u32, - piece_length, - piece_count, - private: matches!(info_dict.get(b"private".as_slice()), Some(BencodeValue::Integer(1))), - creation_date, - creator: bounded_optional_text( - root.get(b"created by.utf-8".as_slice()).or_else(|| root.get(b"created by".as_slice())), - 256, - ), - comment: bounded_optional_text( - root.get(b"comment.utf-8".as_slice()).or_else(|| root.get(b"comment".as_slice())), - 4_096, - ), - trackers, - web_seeds, - }) -} - -/// Return whether validated metainfo may be reused for a Magnet identified by -/// its info hash. Tracker lists are safe to retain because they are peer -/// discovery metadata, while direct web-seed/source fields are deliberately -/// excluded so a later Magnet cannot inherit arbitrary HTTP resources. -pub fn torrent_metadata_is_safe_for_magnet_reuse(bytes: &[u8]) -> Result { - if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { - return Err(format!( - "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" - )); - } - let root = Parser::new(bytes).parse()?; - let root = match root { - BencodeValue::Dict(value) => value, - _ => return Err("torrent root is not a dictionary".to_string()), - }; - let info = root - .get(b"info".as_slice()) - .ok_or_else(|| "torrent metadata is missing info".to_string())?; - parse_info(info)?; - if !torrent_tracker_metadata_is_safe(&root) { - return Ok(false); - } - - Ok(root.keys().all(|key| { - matches!( - key.as_slice(), - b"info" - | b"announce" - | b"announce-list" - | b"comment" - | b"comment.utf-8" - | b"created by" - | b"created by.utf-8" - | b"creation date" - | b"encoding" - | b"publisher" - | b"publisher-url" - | b"publisher-url.utf-8" - ) - })) -} - -fn magnet_metadata(source: &str) -> Result { - let parsed = url::Url::parse(source).map_err(|_| "invalid magnet URI".to_string())?; - validate_magnet_authority(&parsed)?; - let _ = normalized_magnet_trackers(&parsed)?; - let info_hash = parsed - .query_pairs() - .find_map(|(key, value)| { - if key != "xt" { - return None; - } - let value = value.strip_prefix("urn:btih:")?; - canonical_btih(value) - }) - .ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?; - let name = parsed - .query_pairs() - .find_map(|(key, value)| (key == "dn").then_some(value.into_owned())) - .filter(|value| !value.trim().is_empty()) - .map(|value| crate::download_ownership::canonical_download_filename(&value)) - .unwrap_or_else(|| format!("torrent-{info_hash}")); - Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash, web_seeds: Vec::new() }) -} - -fn validate_magnet_authority(parsed: &url::Url) -> Result<(), String> { - if parsed.scheme() != "magnet" { - return Err("unsupported torrent source".to_string()); - } - if !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.fragment().is_some() - || parsed.host_str().is_some() - || parsed.port().is_some() - { - return Err("magnet URI contains an invalid authority or fragment".to_string()); - } - Ok(()) -} - -fn normalized_magnet_trackers(parsed: &url::Url) -> Result, String> { - let mut trackers = Vec::new(); - let mut serialized_bytes = 0usize; - for (key, value) in parsed.query_pairs() { - if key != "tr" { - continue; - } - if trackers.len() >= crate::queue::MAX_TORRENT_TRACKERS { - return Err(format!( - "magnet tracker list must contain at most {} trackers", - crate::queue::MAX_TORRENT_TRACKERS - )); - } - let normalized = crate::queue::normalize_torrent_tracker_uri(&value)?; - serialized_bytes = serialized_bytes - .checked_add(normalized.len()) - .ok_or_else(|| "magnet tracker list is too large".to_string())?; - if serialized_bytes > crate::queue::MAX_TORRENT_TRACKER_BYTES { - return Err(format!( - "magnet tracker list must be at most {} bytes", - crate::queue::MAX_TORRENT_TRACKER_BYTES - )); - } - trackers.push(normalized); - } - Ok(trackers) -} - -/// Return the bounded tracker count from a Magnet without exposing the -/// tracker values or the source URI to diagnostics. -pub fn magnet_tracker_count(source: &str) -> Result { - let parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?; - validate_magnet_authority(&parsed)?; - Ok(normalized_magnet_trackers(&parsed)?.len()) -} - -/// Return the Magnet URI form that Firelink may hand to Aria2. Direct source -/// parameters can make Aria2 fetch arbitrary HTTP/FTP/SFTP resources during -/// metadata resolution, so keep the peer/tracker identity parameters but -/// remove `ws`, `as`, and `xs` sources. Users can add validated web seeds after -/// metadata is available through the transactional per-file path. -pub fn sanitize_magnet_uri_for_aria2(source: &str) -> Result { - let mut parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?; - validate_magnet_authority(&parsed)?; - let normalized_trackers = normalized_magnet_trackers(&parsed)?; - let mut tracker_index = 0; - - let mut has_info_hash = false; - let mut query = url::form_urlencoded::Serializer::new(String::new()); - for (key, value) in parsed.query_pairs() { - if matches!(key.as_ref(), "ws" | "as" | "xs") { - continue; - } - if key == "xt" { - let hash = value - .strip_prefix("urn:btih:") - .and_then(canonical_btih) - .ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?; - query.append_pair("xt", &format!("urn:btih:{hash}")); - has_info_hash = true; - } else if key == "tr" { - let tracker = normalized_trackers - .get(tracker_index) - .ok_or_else(|| "magnet tracker normalization lost an entry".to_string())?; - tracker_index += 1; - query.append_pair("tr", tracker); - } else { - query.append_pair(&key, &value); - } - } - if !has_info_hash { - return Err("magnet URI has no valid BitTorrent info hash".to_string()); - } - let query = query.finish(); - parsed.set_query(Some(&query)); - Ok(parsed.to_string()) -} - -pub fn magnet_allows_cached_metadata(source: &str) -> bool { - let Ok(parsed) = url::Url::parse(source.trim()) else { - return false; - }; - if validate_magnet_authority(&parsed).is_err() || normalized_magnet_trackers(&parsed).is_err() { - return false; - } - - let mut has_info_hash = false; - for (key, value) in parsed.query_pairs() { - match key.as_ref() { - "xt" => { - let Some(info_hash) = value.strip_prefix("urn:btih:") else { - return false; - }; - if canonical_btih(info_hash).is_none() { - return false; - } - has_info_hash = true; - } - "dn" | "tr" => {} - _ => return false, - } - } - has_info_hash -} - -fn local_torrent_path(source: &str) -> Result { - let path = match url::Url::parse(source) { - Ok(parsed) if parsed.scheme() == "file" => parsed - .to_file_path() - .map_err(|_| "invalid local torrent path".to_string())?, - _ => PathBuf::from(source), - }; - if !path.is_absolute() { - return Err("torrent source is not an absolute local file".to_string()); - } - let path = std::fs::canonicalize(&path) - .map_err(|error| format!("could not resolve torrent file: {error}"))?; - if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("torrent")) != Some(true) { - return Err("torrent files must use the .torrent extension".to_string()); - } - Ok(path) -} - -pub fn inspect_source(source: &str) -> Result { - if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { - return magnet_metadata(source.trim()); - } - let path = local_torrent_path(source)?; - let bytes = read_bounded_torrent_bytes_sync(&path) - .map_err(|error| format!("could not read torrent file: {error}"))?; - parse_torrent_bytes(&bytes) -} - -/// Remote torrent metadata is fetched and cached before enqueue so it follows -/// the same validated `addTorrent`, ownership, retry, and restart path as -/// local metadata and magnets. -pub fn is_remote_torrent_url(source: &str) -> bool { - let Ok(parsed) = url::Url::parse(source.trim()) else { - return false; - }; - matches!(parsed.scheme(), "http" | "https") - && parsed - .path_segments() - .and_then(|segments| segments.last()) - .is_some_and(|name| name.to_ascii_lowercase().ends_with(".torrent")) -} - -pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option) -> TorrentMetadata { - TorrentMetadata { - name: parsed.name, - total_bytes: parsed.total_bytes, - files: parsed.files, - info_hash: parsed.info_hash, - torrent_path, - } -} - -/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep -/// these values derived from the validated, canonical paths so the daemon's -/// actual files stay aligned with Firelink's ownership registry. -pub fn validate_output_name(name: &str) -> Result<(), String> { - if name.is_empty() - || name != name.trim() - || name == "." - || name == ".." - || name.ends_with(['.', ' ']) - || name.chars().any(|character| { - character.is_control() - || matches!(character, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') - }) - || crate::platform::is_windows_reserved_filename(name) - || crate::download_ownership::canonical_download_filename(name) != name - { - return Err("Torrent output name is not a safe single path component".to_string()); - } - Ok(()) -} - -pub fn aria2_index_outputs(parsed: &ParsedTorrent, output_name: &str) -> Vec { - parsed - .files - .iter() - .map(|file| { - let output = if parsed.files.len() == 1 { - output_name.to_string() - } else { - format!("{output_name}/{}", file.path) - }; - format!("{}={output}", file.index) - }) - .collect() -} - -pub fn aria2_output_paths( - parsed: &ParsedTorrent, - selected: Option<&[u32]>, - output_name: &str, -) -> Vec { - parsed - .files - .iter() - .filter(|file| selected.is_none_or(|indices| indices.contains(&file.index))) - .map(|file| { - if parsed.files.len() == 1 { - output_name.to_string() - } else { - format!("{output_name}/{}", file.path) - } - }) - .collect() -} - -pub fn managed_torrent_path( - app_handle: &tauri::AppHandle, - id: &str, -) -> Result { - if id.is_empty() - || !id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err("invalid torrent download id".to_string()); - } - let root = managed_torrent_storage_root(app_handle)?; - Ok(root.join(format!("{id}.torrent"))) -} - -pub fn managed_torrent_info_hash_path( - app_handle: &tauri::AppHandle, - info_hash: &str, -) -> Result { - let root = managed_torrent_storage_root(app_handle)?; - managed_torrent_info_hash_path_at(&root, info_hash) -} - -fn managed_torrent_info_hash_path_at(root: &Path, info_hash: &str) -> Result { - let info_hash = canonical_btih(info_hash) - .ok_or_else(|| "invalid torrent info hash cache key".to_string())?; - Ok(root.join(format!(".info-{info_hash}.torrent"))) -} - -pub fn managed_torrent_storage_root( - app_handle: &tauri::AppHandle, -) -> Result { - let root = app_handle - .path() - .app_data_dir() - .map_err(|error| format!("could not resolve torrent storage: {error}"))? - .join("torrents"); - Ok(root) -} - -pub fn remove_orphaned_probe_dirs( - app_handle: &tauri::AppHandle, -) -> Result { - let root = managed_torrent_storage_root(app_handle)?; - remove_orphaned_probe_dirs_at(&root) -} - -fn remove_orphaned_probe_dirs_at(root: &Path) -> Result { - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(error) => return Err(format!("could not inspect torrent probe storage: {error}")), - }; - let mut removed = 0; - for entry in entries { - let entry = entry.map_err(|error| format!("could not inspect torrent probe storage: {error}"))?; - let is_probe = entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with(".probe-")); - if !is_probe || !entry - .file_type() - .map_err(|error| format!("could not inspect torrent probe entry: {error}"))? - .is_dir() - { - continue; - } - match std::fs::remove_dir_all(entry.path()) { - Ok(()) => removed += 1, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(format!("could not remove orphaned torrent probe: {error}")), - } - } - Ok(removed) -} - -pub fn remove_orphaned_cached_torrents( - app_handle: &tauri::AppHandle, - retained_ids: &HashSet, - retained_info_hashes: &HashSet, -) -> Result { - let root = managed_torrent_storage_root(app_handle)?; - remove_orphaned_cached_torrents_at(&root, retained_ids, retained_info_hashes) -} - -fn remove_orphaned_cached_torrents_at( - root: &Path, - retained_ids: &HashSet, - retained_info_hashes: &HashSet, -) -> Result { - let entries = match std::fs::read_dir(&root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(error) => return Err(format!("could not inspect torrent metadata storage: {error}")), - }; - let mut removed = 0; - for entry in entries { - let entry = entry - .map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?; - let file_type = entry - .file_type() - .map_err(|error| format!("could not inspect torrent metadata entry: {error}"))?; - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - if crate::platform::is_atomic_temp_file_name(&name) { - if file_type.is_file() || file_type.is_symlink() { - if remove_cached_torrent_entry( - &entry.path(), - "could not remove orphaned torrent metadata temporary file", - )? { - removed += 1; - } - } - continue; - } - if (file_type.is_file() || file_type.is_symlink()) - && is_canonical_torrent_temp_file(&name) - { - if remove_cached_torrent_entry( - &entry.path(), - "could not remove orphaned torrent metadata temporary file", - )? { - removed += 1; - } - continue; - } - if file_type.is_file() && name.starts_with(".info-") && name.ends_with(".torrent") { - let retained = name - .strip_prefix(".info-") - .and_then(|name| name.strip_suffix(".torrent")) - .and_then(canonical_btih) - .is_some_and(|info_hash| { - name == format!(".info-{info_hash}.torrent") - && retained_info_hashes.contains(&info_hash) - }); - if retained { - continue; - } - if remove_cached_torrent_entry(&entry.path(), "could not remove orphaned torrent metadata")? { - removed += 1; - } - continue; - } - if entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent") { - continue; - } - let path = entry.path(); - let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - if retained_ids.contains(id) { - if file_type.is_symlink() { - if remove_cached_torrent_entry( - &path, - "could not remove invalid retained torrent metadata link", - )? { - removed += 1; - } - } - continue; - } - if !file_type.is_file() && !file_type.is_symlink() { - continue; - } - if remove_cached_torrent_entry(&path, "could not remove orphaned torrent metadata")? { - removed += 1; - } - } - Ok(removed) -} - -fn remove_cached_torrent_entry(path: &Path, message: &str) -> Result { - match std::fs::remove_file(path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => { - let is_link = std::fs::symlink_metadata(path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false); - if is_link { - match std::fs::remove_dir_all(path) { - Ok(()) => Ok(true), - Err(dir_error) if dir_error.kind() == std::io::ErrorKind::NotFound => { - Ok(false) - } - Err(_) => Err(format!("{message}: {error}")), - } - } else { - Err(format!("{message}: {error}")) - } - } - } -} - -pub fn validate_selected_indices( - selected: Option<&[u32]>, - file_count: usize, -) -> Result>, String> { - let Some(selected) = selected else { - return Ok(None); - }; - if selected.is_empty() || selected.iter().any(|index| *index == 0 || *index as usize > file_count) { - return Err("torrent file selection is invalid".to_string()); - } - let mut normalized = selected.to_vec(); - normalized.sort_unstable(); - normalized.dedup(); - if normalized.len() == file_count { - Ok(None) - } else { - Ok(Some(normalized)) - } -} - -pub async fn prepare_local_torrent( - app_handle: &tauri::AppHandle, - source: &str, - id: &str, -) -> Result<(ParsedTorrent, String), String> { - let source_path = local_torrent_path(source)?; - let file_metadata = tokio::fs::metadata(&source_path) - .await - .map_err(|error| format!("could not inspect torrent file: {error}"))?; - if file_metadata.len() == 0 || file_metadata.len() > MAX_TORRENT_BYTES as u64 { - return Err(format!( - "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" - )); - } - let bytes = read_bounded_torrent_bytes(&source_path) - .await - .map_err(|error| format!("could not read torrent file: {error}"))?; - let parsed = parse_torrent_bytes(&bytes)?; - let destination = cache_torrent_bytes(app_handle, id, &bytes).await?; - if let Err(error) = cache_torrent_info_hash(app_handle, &bytes).await { - log::warn!("could not cache canonical torrent metadata: {error}"); - } - Ok((parsed, destination)) -} - -pub async fn cache_torrent_bytes( - app_handle: &tauri::AppHandle, - id: &str, - bytes: &[u8], -) -> Result { - if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { - return Err(format!( - "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" - )); - } - let destination = managed_torrent_path(app_handle, id)?; - if let Some(parent) = destination.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| format!("could not create torrent storage: {error}"))?; - } - crate::platform::atomic_write_replace(&destination, bytes) - .await - .map_err(|error| format!("could not cache torrent metadata: {error}"))?; - Ok(destination.to_string_lossy().to_string()) -} - -fn is_canonical_torrent_temp_file(name: &str) -> bool { - let Some(rest) = name.strip_prefix(".cache-") else { - return false; - }; - let Some((info_hash, temporary_id)) = rest.split_once(".torrent.") else { - return false; - }; - let Some(temporary_id) = temporary_id.strip_suffix(".tmp") else { - return false; - }; - canonical_btih(info_hash).as_deref() == Some(info_hash) - && temporary_id.len() == 32 - && temporary_id.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -pub(crate) fn read_bounded_torrent_bytes_sync(path: &Path) -> std::io::Result> { - let file = std::fs::File::open(path)?; - let mut bytes = Vec::with_capacity(std::cmp::min(MAX_TORRENT_BYTES, 64 * 1024)); - file.take((MAX_TORRENT_BYTES + 1) as u64) - .read_to_end(&mut bytes)?; - if bytes.len() > MAX_TORRENT_BYTES { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("torrent metadata exceeds {MAX_TORRENT_BYTES} bytes"), - )); - } - Ok(bytes) -} - -pub(crate) async fn read_bounded_torrent_bytes(path: &Path) -> std::io::Result> { - let file = tokio::fs::File::open(path).await?; - let mut bytes = Vec::with_capacity(std::cmp::min(MAX_TORRENT_BYTES, 64 * 1024)); - file.take((MAX_TORRENT_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .await?; - if bytes.len() > MAX_TORRENT_BYTES { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("torrent metadata exceeds {MAX_TORRENT_BYTES} bytes"), - )); - } - Ok(bytes) -} - -static CANONICAL_TORRENT_CACHE_LOCK: std::sync::OnceLock> = - std::sync::OnceLock::new(); - -fn canonical_torrent_cache_lock() -> &'static tokio::sync::Mutex<()> { - CANONICAL_TORRENT_CACHE_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) -} - -async fn read_cached_torrent_by_info_hash_unlocked( - root: &Path, - info_hash: &str, -) -> Result>, String> { - let info_hash = canonical_btih(info_hash) - .ok_or_else(|| "invalid torrent info hash cache key".to_string())?; - let path = managed_torrent_info_hash_path_at(root, &info_hash)?; - let metadata = match tokio::fs::symlink_metadata(&path).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("could not inspect cached torrent metadata: {error}")), - }; - if !metadata.file_type().is_file() && !metadata.file_type().is_symlink() { - return Ok(None); - } - - let validated_path = match validate_managed_torrent_path_against_expected( - &path, - &path.to_string_lossy(), - ) { - Ok(path) => path, - Err(error) => { - let _ = tokio::fs::remove_file(&path).await; - log::warn!("discarding invalid cached torrent metadata: {error}"); - return Ok(None); - } - }; - let bytes = match read_bounded_torrent_bytes(&validated_path).await { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { - let _ = tokio::fs::remove_file(&path).await; - return Ok(None); - } - Err(error) => return Err(format!("could not read cached torrent metadata: {error}")), - }; - let reusable = torrent_metadata_is_safe_for_magnet_reuse(&bytes).is_ok_and(|safe| safe); - match parse_torrent_bytes(&bytes) { - Ok(parsed) if reusable && parsed.info_hash == info_hash => {} - Ok(_) | Err(_) => { - let _ = tokio::fs::remove_file(&path).await; - return Ok(None); - } - } - Ok(Some(bytes)) -} - -pub async fn read_cached_torrent_by_info_hash( - app_handle: &tauri::AppHandle, - info_hash: &str, -) -> Result>, String> { - let root = managed_torrent_storage_root(app_handle)?; - read_cached_torrent_by_info_hash_at(&root, info_hash).await -} - -#[doc(hidden)] -pub async fn read_cached_torrent_by_info_hash_at( - root: &Path, - info_hash: &str, -) -> Result>, String> { - let _guard = canonical_torrent_cache_lock().lock().await; - read_cached_torrent_by_info_hash_unlocked(root, info_hash).await -} - -pub async fn cache_torrent_info_hash( - app_handle: &tauri::AppHandle, - bytes: &[u8], -) -> Result, String> { - let root = managed_torrent_storage_root(app_handle)?; - cache_torrent_info_hash_at(&root, bytes).await -} - -#[doc(hidden)] -pub async fn cache_torrent_info_hash_at( - root: &Path, - bytes: &[u8], -) -> Result, String> { - let _guard = canonical_torrent_cache_lock().lock().await; - let parsed = parse_torrent_bytes(bytes)?; - if !torrent_metadata_is_safe_for_magnet_reuse(bytes)? { - return Ok(None); - } - let info_hash = parsed.info_hash; - let destination = managed_torrent_info_hash_path_at(root, &info_hash)?; - if read_cached_torrent_by_info_hash_unlocked(root, &info_hash) - .await? - .is_some() - { - return Ok(Some(destination.to_string_lossy().to_string())); - } - - let parent = destination - .parent() - .ok_or_else(|| "torrent storage has no parent directory".to_string())?; - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| format!("could not create torrent storage: {error}"))?; - crate::platform::atomic_write_replace(&destination, bytes) - .await - .map_err(|error| format!("could not commit canonical torrent metadata: {error}"))?; - Ok(Some(destination.to_string_lossy().to_string())) -} - -pub fn validate_managed_torrent_path( - app_handle: &tauri::AppHandle, - id: &str, - path: &str, -) -> Result { - let expected = managed_torrent_path(app_handle, id)?; - validate_managed_torrent_path_against_expected(&expected, path) -} - -pub fn validate_managed_torrent_info_hash_path( - app_handle: &tauri::AppHandle, - info_hash: &str, - path: &str, -) -> Result { - let expected = managed_torrent_info_hash_path(app_handle, info_hash)?; - validate_managed_torrent_path_against_expected(&expected, path) -} - -fn validate_managed_torrent_path_against_expected( - expected: &Path, - path: &str, -) -> Result { - let candidate = std::fs::canonicalize(path) - .map_err(|error| format!("could not access cached torrent metadata: {error}"))?; - let expected_parent = expected - .parent() - .and_then(|parent| std::fs::canonicalize(parent).ok()) - .ok_or_else(|| "cached torrent storage is unavailable".to_string())?; - if candidate.parent() != Some(expected_parent.as_path()) - || candidate.file_name() != expected.file_name() - { - return Err("cached torrent metadata path is invalid".to_string()); - } - Ok(candidate) -} - -pub async fn remove_managed_torrent( - app_handle: &tauri::AppHandle, - id: &str, -) { - if let Ok(path) = managed_torrent_path(app_handle, id) { - let _ = tokio::fs::remove_file(path).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_single_file_torrent_and_hashes_info_dictionary() { - let parsed = parse_torrent_bytes(b"d4:infod6:lengthi5e4:name4:testee") - .expect("single-file torrent should parse"); - assert_eq!(parsed.name, "test"); - assert_eq!(parsed.total_bytes, 5); - assert_eq!(parsed.files[0].index, 1); - assert_eq!(parsed.files[0].path, "test"); - assert_eq!(parsed.info_hash.len(), 40); - } - - #[test] - fn retains_only_strict_http_web_seeds_for_enqueue_policy_validation() { - let parsed = parse_torrent_bytes( - b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae", - ) - .expect("torrent with an HTTP web seed should parse"); - assert_eq!(parsed.web_seeds, vec!["https://example.test/a"]); - - let credentials = parse_torrent_bytes( - b"d4:infod6:lengthi5e4:name4:teste8:url-list32:https://user:pass@example.test/aee", - ); - assert!(credentials.is_err()); - } - - #[test] - fn sanitizing_torrent_web_seeds_preserves_the_info_hash() { - let bytes = b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae"; - let original = parse_torrent_bytes(bytes).expect("torrent should parse"); - let (sanitized, web_seeds) = sanitize_torrent_bytes_for_aria2(bytes) - .expect("torrent should be sanitized"); - let parsed = parse_torrent_bytes(&sanitized).expect("sanitized torrent should parse"); - - assert_eq!(web_seeds, vec!["https://example.test/a"]); - assert!(parsed.web_seeds.is_empty()); - assert_eq!(parsed.info_hash, original.info_hash); - } - - #[test] - fn magnet_sanitization_removes_direct_sources_but_preserves_identity() { - let sanitized = sanitize_magnet_uri_for_aria2( - "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&dn=demo&tr=udp%3A%2F%2Ftracker.example%3A80&ws=https%3A%2F%2Flocal.example%2Ffile&as=https%3A%2F%2Fother.example%2Ffile", - ) - .expect("magnet should sanitize"); - assert!(sanitized.contains("xt=urn%3Abtih%3A0123456789012345678901234567890123456789")); - assert!(sanitized.contains("dn=demo")); - assert!(sanitized.contains("tr=udp%3A%2F%2Ftracker.example%3A80")); - assert!(!sanitized.contains("ws=")); - assert!(!sanitized.contains("as=")); - } - - #[test] - fn magnet_trackers_use_the_shared_bounded_tracker_policy() { - let valid = "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&tr=https%3A%2F%2Ftracker.example%2Fannounce"; - assert!(magnet_allows_cached_metadata(valid)); - assert!(sanitize_magnet_uri_for_aria2(valid).is_ok()); - assert_eq!(magnet_tracker_count(valid).unwrap(), 1); - - for suffix in [ - "&tr=ftp%3A%2F%2Ftracker.example%2Fannounce", - "&tr=https%3A%2F%2Fuser%3Apass%40tracker.example%2Fannounce", - "&tr=https%3A%2F%2Ftracker.example%2Fannounce%23fragment", - "&tr=https%3A%2F%2F", - ] { - let magnet = format!( - "magnet:?xt=urn:btih:0123456789012345678901234567890123456789{suffix}" - ); - assert!(!magnet_allows_cached_metadata(&magnet), "{magnet}"); - assert!(sanitize_magnet_uri_for_aria2(&magnet).is_err(), "{magnet}"); - } - - assert!(!magnet_allows_cached_metadata( - "magnet://tracker.example/?xt=urn:btih:0123456789012345678901234567890123456789" - )); - assert!(sanitize_magnet_uri_for_aria2( - "magnet://tracker.example/?xt=urn:btih:0123456789012345678901234567890123456789" - ) - .is_err()); - - let too_many = (0..=crate::queue::MAX_TORRENT_TRACKERS) - .map(|index| format!("tr=https%3A%2F%2Ftracker{index}.example%2Fannounce")) - .collect::>() - .join("&"); - let magnet = format!( - "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&{too_many}" - ); - assert!(sanitize_magnet_uri_for_aria2(&magnet).is_err()); - assert!(!magnet_allows_cached_metadata(&magnet)); - - let oversized = format!( - "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&tr=https://tracker.example/{}", - "a".repeat(crate::queue::MAX_TORRENT_TRACKER_BYTES) - ); - assert!(sanitize_magnet_uri_for_aria2(&oversized).is_err()); - assert!(!magnet_allows_cached_metadata(&oversized)); - } - - #[test] - fn validates_torrent_output_names_as_single_safe_components() { - for name in ["test", "My Torrent (1)", "archive.tar"] { - validate_output_name(name).expect("ordinary output names should be accepted"); - } - for name in ["", " test", "test ", ".", "..", "a/b", "a\\b", "CON", "a?.bin"] { - assert!(validate_output_name(name).is_err(), "{name:?}"); - } - } - - #[test] - fn exposes_bounded_torrent_details_from_metadata() { - let mut bytes = b"d4:infod6:lengthi5e4:name4:test12:piece lengthi2e6:pieces20:".to_vec(); - bytes.extend([0_u8; 20]); - bytes.extend_from_slice(b"ee"); - - let details = torrent_details_from_bytes(&bytes).expect("details should parse"); - - assert_eq!(details.display_name, "test"); - assert_eq!(details.total_bytes, 5); - assert_eq!(details.file_count, 1); - assert_eq!(details.piece_length, 2); - assert_eq!(details.piece_count, 1); - assert!(!details.private); - } - - #[test] - fn parses_multi_file_torrent_and_rejects_traversal() { - let parsed = parse_torrent_bytes( - b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee", - ) - .expect("multi-file torrent should parse"); - assert_eq!(parsed.total_bytes, 5); - assert_eq!(parsed.files.len(), 2); - assert_eq!(parsed.files[1].path, "root/b.bin"); - - let error = parse_torrent_bytes( - b"d4:infod5:filesld6:lengthi1e4:pathl2:..4:evileee4:name4:rootee", - ) - .expect_err("path traversal must be rejected"); - assert!(error.contains("unsafe path")); - } - - #[test] - fn parses_magnet_identity_without_logging_or_persisting_tracker_data() { - let parsed = inspect_source( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret", - ) - .expect("magnet should parse"); - assert_eq!(parsed.name, "Example Torrent"); - assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567"); - assert!(parsed.files.is_empty()); - } - - #[test] - fn magnet_reuse_rejects_web_seed_metadata_but_retains_tracker_metadata() { - assert!(torrent_metadata_is_safe_for_magnet_reuse( - b"d4:infod6:lengthi5e4:name4:testee" - ) - .expect("plain torrent metadata should parse")); - assert!(torrent_metadata_is_safe_for_magnet_reuse( - b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee" - ) - .expect("tracker-bearing torrent metadata should parse")); - assert!(torrent_metadata_is_safe_for_magnet_reuse( - b"d13:announce-listll32:https://tracker.example/announceee4:infod6:lengthi5e4:name4:testee" - ) - .expect("tracker-list-bearing torrent metadata should parse")); - assert!(!torrent_metadata_is_safe_for_magnet_reuse( - b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee" - ) - .expect("unsupported tracker metadata should parse")); - assert!(!torrent_metadata_is_safe_for_magnet_reuse( - b"d8:announce42:https://user:pass@tracker.example/announce4:infod6:lengthi5e4:name4:testee" - ) - .expect("credential-bearing tracker metadata should parse")); - assert!(!torrent_metadata_is_safe_for_magnet_reuse( - b"d13:announce-listl1:xe4:infod6:lengthi5e4:name4:testee" - ) - .expect("malformed tracker-list metadata should parse")); - assert!(!torrent_metadata_is_safe_for_magnet_reuse( - b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe" - ) - .expect("web-seed-bearing torrent metadata should parse")); - } - - #[test] - fn canonical_cache_temporary_names_are_strictly_recognized() { - assert!(is_canonical_torrent_temp_file( - ".cache-0123456789abcdef0123456789abcdef01234567.torrent.0123456789abcdef0123456789abcdef.tmp" - )); - assert!(!is_canonical_torrent_temp_file( - ".cache-orphan.torrent.temporary.tmp" - )); - assert!(!is_canonical_torrent_temp_file( - ".cache-0123456789abcdef0123456789abcdef01234567.torrent.tmp" - )); - } - - #[tokio::test] - async fn canonical_cache_round_trip_rejects_invalid_bytes_and_source_metadata() { - let app = tauri::test::mock_builder() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app"); - let bytes = b"d4:infod6:lengthi5e4:name4:testee"; - let parsed = parse_torrent_bytes(bytes).expect("test torrent should parse"); - let path = managed_torrent_info_hash_path(app.handle(), &parsed.info_hash) - .expect("canonical cache path should resolve"); - let _ = tokio::fs::remove_file(&path).await; - - assert!( - cache_torrent_info_hash(app.handle(), bytes) - .await - .expect("canonical cache write should succeed") - .is_some() - ); - assert_eq!( - read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) - .await - .expect("canonical cache read should succeed"), - Some(bytes.to_vec()) - ); - - tokio::fs::write(&path, b"not a torrent") - .await - .expect("invalid cache fixture should be writable"); - assert!( - read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash) - .await - .expect("invalid cache should be handled") - .is_none() - ); - assert!(!path.exists()); - - let tracker_bytes = - b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"; - let tracker_hash = parse_torrent_bytes(tracker_bytes) - .expect("tracker-bearing torrent should parse") - .info_hash; - assert!( - cache_torrent_info_hash(app.handle(), tracker_bytes) - .await - .expect("tracker metadata should be reusable") - .is_some() - ); - assert_eq!( - read_cached_torrent_by_info_hash(app.handle(), &tracker_hash) - .await - .expect("tracker cache should be readable"), - Some(tracker_bytes.to_vec()) - ); - assert!( - cache_torrent_info_hash( - app.handle(), - b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae" - ) - .await - .expect("web-seed metadata should be handled") - .is_none() - ); - } - - #[test] - fn canonicalizes_base32_magnet_hashes_to_hex() { - let parsed = inspect_source( - "magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=Base32", - ) - .expect("a valid Base32 hash should parse"); - assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567"); - } - - #[test] - fn validates_expected_hashes_across_hex_and_base32_encodings() { - validate_info_hash( - Some("AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH"), - "0123456789abcdef0123456789abcdef01234567", - ) - .expect("equivalent Base32 and hexadecimal hashes should match"); - assert!(validate_info_hash( - Some("0123456789abcdef0123456789abcdef01234567"), - "fedcba9876543210fedcba9876543210fedcba98", - ) - .is_err()); - validate_info_hash(None, "not-used").expect("missing legacy identity should remain compatible"); - assert_eq!( - canonical_info_hash("AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH").as_deref(), - Some("0123456789abcdef0123456789abcdef01234567") - ); - } - - #[test] - fn magnets_with_safe_parameters_can_reuse_hash_keyed_metadata() { - assert!(magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567" - )); - assert!(magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent" - )); - assert!(magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce" - )); - assert!(magnet_allows_cached_metadata( - "magnet:?tr=udp%3A%2F%2Ftracker.invalid%3A1337%2Fannounce&xt=urn:btih:0123456789abcdef0123456789abcdef01234567" - )); - assert!(!magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile" - )); - assert!(!magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&as=https%3A%2F%2Fexample.invalid%2Ffile" - )); - assert!(!magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&unknown=value" - )); - assert!(!magnet_allows_cached_metadata( - "magnet:?xt=urn:btih:not-a-valid-hash" - )); - } - - #[test] - fn rejects_malformed_base32_magnet_hashes() { - let error = inspect_source( - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcde!&dn=Invalid", - ) - .expect_err("a 32-character hash with non-base32 characters must be rejected"); - assert!(error.contains("valid BitTorrent info hash")); - - let error = inspect_source( - "magnet:?xt=urn:btih:00000000000000000000000000000000&dn=Invalid", - ) - .expect_err("characters outside RFC 4648 Base32 must be rejected"); - assert!(error.contains("valid BitTorrent info hash")); - } - - #[test] - fn normalizes_and_bounds_selected_file_indices() { - assert_eq!( - validate_selected_indices(Some(&[3, 1, 3]), 3).unwrap(), - Some(vec![1, 3]) - ); - assert!(validate_selected_indices(Some(&[0]), 3).is_err()); - assert!(validate_selected_indices(Some(&[4]), 3).is_err()); - } - - #[test] - fn maps_validated_torrent_files_to_aria2_index_outputs() { - let parsed = parse_torrent_bytes( - b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee", - ) - .expect("multi-file torrent should parse"); - assert_eq!( - aria2_index_outputs(&parsed, "custom-name"), - vec![ - "1=custom-name/root/a.txt".to_string(), - "2=custom-name/root/b.bin".to_string(), - ] - ); - } - - #[test] - fn recognizes_only_http_torrent_metadata_urls() { - assert!(is_remote_torrent_url("https://example.com/files/sample.torrent")); - assert!(is_remote_torrent_url("http://example.com/sample.TORRENT?download=1")); - assert!(!is_remote_torrent_url("https://example.com/files/sample.zip")); - assert!(!is_remote_torrent_url("ftp://example.com/files/sample.torrent")); - assert!(!is_remote_torrent_url( - "magnet:?xt=urn:btih:0123456789012345678901234567890123456789" - )); - } - - #[test] - fn bounded_local_torrent_reads_reject_oversized_files() { - let temporary = tempfile::tempdir().expect("temporary torrent directory should exist"); - let path = temporary.path().join("oversized.torrent"); - let file = std::fs::File::create(&path).expect("oversized torrent fixture should exist"); - file.set_len((MAX_TORRENT_BYTES + 1) as u64) - .expect("oversized torrent fixture should be sparse-writable"); - - let error = read_bounded_torrent_bytes_sync(&path) - .expect_err("oversized torrent metadata must be rejected before parsing"); - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - } - - #[test] - fn rejects_noncanonical_lengths_and_invalid_files_field() { - assert!(parse_torrent_bytes(b"d4:infod6:lengthi5e4:name04:testee").is_err()); - assert!(parse_torrent_bytes(b"d4:infod5:filesi1e6:lengthi5e4:name4:testee").is_err()); - } - - #[test] - fn removes_only_orphaned_probe_directories() { - let temporary = tempfile::tempdir().expect("temporary torrent storage should exist"); - let root = temporary.path(); - std::fs::create_dir(root.join(".probe-stale")).expect("probe directory should exist"); - std::fs::write(root.join(".probe-file"), b"not a directory") - .expect("probe marker file should exist"); - std::fs::create_dir(root.join("retained-dir")).expect("unrelated directory should exist"); - - assert_eq!(remove_orphaned_probe_dirs_at(root).unwrap(), 1); - assert!(!root.join(".probe-stale").exists()); - assert!(root.join(".probe-file").is_file()); - assert!(root.join("retained-dir").is_dir()); - } - - #[test] - fn removes_unretained_torrent_files_but_preserves_retained_and_unrelated_entries() { - let temporary = tempfile::tempdir().expect("temporary torrent storage should exist"); - let root = temporary.path(); - let retained_hash = "0123456789abcdef0123456789abcdef01234567"; - std::fs::write(root.join("keep-id.torrent"), b"retained") - .expect("retained metadata should exist"); - std::fs::write(root.join("orphan-id.torrent"), b"orphan") - .expect("orphan metadata should exist"); - std::fs::write(root.join(format!(".info-{retained_hash}.torrent")), b"retained hash") - .expect("retained hash metadata should exist"); - std::fs::write(root.join(format!("{retained_hash}.torrent")), b"legacy hash") - .expect("legacy hash metadata should exist"); - std::fs::write( - root.join(".info-fedcba9876543210fedcba9876543210fedcba98.torrent"), - b"orphan hash", - ) - .expect("orphan hash metadata should exist"); - std::fs::write( - root.join(format!( - ".cache-{retained_hash}.torrent.0123456789abcdef0123456789abcdef.tmp" - )), - b"orphan temporary", - ) - .expect("orphan temporary metadata should exist"); - std::fs::write( - root.join(".firelink-atomic-0123456789abcdef0123456789abcdef.tmp"), - b"interrupted atomic staging", - ) - .expect("interrupted atomic staging should exist"); - std::fs::write(root.join("notes.txt"), b"unrelated") - .expect("unrelated file should exist"); - let retained = HashSet::from(["keep-id".to_string()]); - let retained_hashes = HashSet::from([retained_hash.to_string()]); - - assert_eq!( - remove_orphaned_cached_torrents_at(root, &retained, &retained_hashes).unwrap(), - 5 - ); - assert!(root.join("keep-id.torrent").is_file()); - assert!(!root.join("orphan-id.torrent").exists()); - assert!(root.join(format!(".info-{retained_hash}.torrent")).is_file()); - assert!(!root.join(format!("{retained_hash}.torrent")).exists()); - assert!(!root - .join(".info-fedcba9876543210fedcba9876543210fedcba98.torrent") - .exists()); - assert!(!root - .join(format!( - ".cache-{retained_hash}.torrent.0123456789abcdef0123456789abcdef.tmp" - )) - .exists()); - assert!(!root - .join(".firelink-atomic-0123456789abcdef0123456789abcdef.tmp") - .exists()); - assert!(root.join("notes.txt").is_file()); - } - - #[cfg(unix)] - #[test] - fn removes_unretained_torrent_symlinks_without_following_targets() { - use std::os::unix::fs::symlink; - - let temporary = tempfile::tempdir().expect("temporary torrent storage should exist"); - let root = temporary.path(); - let target = root.join("target.bin"); - let link = root.join("orphan-link.torrent"); - let temporary_link = root.join( - ".cache-0123456789abcdef0123456789abcdef01234567.torrent.0123456789abcdef0123456789abcdef.tmp", - ); - std::fs::write(&target, b"target should remain").expect("target should exist"); - symlink(&target, &link).expect("orphan symlink should exist"); - symlink(&target, &temporary_link).expect("orphan temporary symlink should exist"); - - assert_eq!( - remove_orphaned_cached_torrents_at(root, &HashSet::new(), &HashSet::new()).unwrap(), - 2 - ); - assert!(!link.exists()); - assert!(!temporary_link.exists()); - assert!(target.is_file()); - } - - #[cfg(unix)] - #[test] - fn removes_retained_torrent_symlinks_without_following_targets() { - use std::os::unix::fs::symlink; - - let temporary = tempfile::tempdir().expect("temporary torrent storage should exist"); - let root = temporary.path(); - let target = root.join("target.bin"); - let link = root.join("retained-id.torrent"); - std::fs::write(&target, b"target should remain").expect("target should exist"); - symlink(&target, &link).expect("retained symbolic link should be created"); - - assert_eq!( - remove_orphaned_cached_torrents_at( - root, - &HashSet::from(["retained-id".to_string()]), - &HashSet::new(), - ) - .unwrap(), - 1 - ); - assert!(!link.exists()); - assert!(target.is_file()); - } -} diff --git a/src-tauri/src/torrent_probe.rs b/src-tauri/src/torrent_probe.rs deleted file mode 100644 index 5f453fe..0000000 --- a/src-tauri/src/torrent_probe.rs +++ /dev/null @@ -1,1438 +0,0 @@ -use async_trait::async_trait; -use serde_json::{json, Map, Value}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -const STOP_POLL_ATTEMPTS: usize = 30; -const STOP_POLL_INTERVAL: Duration = Duration::from_millis(100); -const CANCELLATION_CLEANUP_ATTEMPTS: usize = 3; -const CANCELLATION_CLEANUP_INTERVAL: Duration = Duration::from_millis(250); - -#[async_trait] -pub(crate) trait RpcClient: Send + Sync { - async fn call(&self, method: &str, params: Value) -> Result; -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum ProbeFailure { - Metadata(String), - Cleanup(String), -} - -pub(crate) async fn run_metadata_probe( - client: Arc, - source: &str, - mut options: Map, - metadata_path: &Path, - timeout: Duration, - poll_interval: Duration, -) -> Result, ProbeFailure> { - // This probe only resolves magnet metadata. It must never allow Aria2 to - // interpret a downloaded metadata file as another child download because - // the probe cleanup guard owns exactly one GID. - options.insert("follow-torrent".to_string(), json!("false")); - options.insert("follow-metalink".to_string(), json!("false")); - let mut cleanup_guard = ProbeCleanupGuard::new(Arc::clone(&client), metadata_path); - let result = match client - .call("aria2.addUri", json!([[source], options])) - .await - { - Ok(result) => result, - Err(error) => { - cleanup_guard.disarm(); - return Err(ProbeFailure::Metadata(format!( - "Aria2 could not start magnet metadata resolution: {}", - crate::redact_sensitive_text(&error) - ))); - } - }; - let gid = match result.as_str().filter(|value| !value.is_empty()) { - Some(gid) => gid.to_string(), - None => { - cleanup_guard.disarm(); - return Err(ProbeFailure::Metadata( - "Aria2 returned an empty metadata probe GID".to_string(), - )); - } - }; - cleanup_guard.set_gid(gid.clone()); - - let metadata_result = async { - let deadline = Instant::now() + timeout; - loop { - let status = match client - .call( - "aria2.tellStatus", - json!([&gid, ["status", "errorCode", "errorMessage"]]), - ) - .await - { - Ok(status) => status, - Err(error) if crate::aria2_gid_not_found(&error) => { - return Err(ProbeFailure::Metadata( - "Aria2 removed the magnet metadata probe before metadata was saved" - .to_string(), - )); - } - Err(error) if crate::retry::is_transient_network_error(&error) => { - if Instant::now() >= deadline { - return Err(ProbeFailure::Metadata(format!( - "Aria2 metadata resolution status failed: {}", - crate::redact_sensitive_text(&error) - ))); - } - tokio::time::sleep(poll_interval).await; - continue; - } - Err(error) => { - return Err(ProbeFailure::Metadata(format!( - "Aria2 metadata resolution status failed: {}", - crate::redact_sensitive_text(&error) - ))); - } - }; - match status.get("status").and_then(Value::as_str) { - Some("complete") => break, - Some("error") | Some("removed") => { - let error_code = status - .get("errorCode") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()); - let error_message = status - .get("errorMessage") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .unwrap_or("metadata probe ended without a torrent file"); - let detail = match error_code { - Some(code) => format!("aria2 error code {code}: {error_message}"), - None => error_message.to_string(), - }; - return Err(ProbeFailure::Metadata(format!( - "Aria2 could not resolve magnet metadata: {}", - crate::redact_sensitive_text(&detail) - ))); - } - Some("active") | Some("waiting") | Some("paused") => {} - None => { - return Err(ProbeFailure::Metadata( - "Aria2 returned an invalid metadata probe status".to_string(), - )); - } - Some(status) => { - return Err(ProbeFailure::Metadata(format!( - "Aria2 returned an unsupported metadata probe status: {status}" - ))); - } - } - if Instant::now() >= deadline { - return Err(ProbeFailure::Metadata( - "Aria2 magnet metadata resolution timed out".to_string(), - )); - } - tokio::time::sleep(poll_interval).await; - } - - tokio::fs::read(metadata_path).await.map_err(|error| { - ProbeFailure::Metadata(format!( - "Aria2 did not save magnet metadata ({:?})", - error.kind() - )) - }) - } - .await; - - let cleanup_result = cleanup_metadata_probe(client.as_ref(), &gid).await; - if let Err(error) = cleanup_result { - return Err(ProbeFailure::Cleanup(error)); - } - cleanup_guard.disarm(); - metadata_result -} - -struct ProbeCleanupGuard { - client: Arc, - gid: Option, - probe_dir: Option, -} - -impl ProbeCleanupGuard { - fn new(client: Arc, metadata_path: &Path) -> Self { - Self { - client, - gid: None, - probe_dir: metadata_path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .map(Path::to_path_buf), - } - } - - fn set_gid(&mut self, gid: String) { - self.gid = Some(gid); - } - - fn disarm(&mut self) { - self.gid = None; - self.probe_dir = None; - } -} - -impl Drop for ProbeCleanupGuard { - fn drop(&mut self) { - let gid = self.gid.take(); - let probe_dir = self.probe_dir.take(); - if gid.is_none() && probe_dir.is_none() { - return; - } - - let Some(runtime) = tokio::runtime::Handle::try_current().ok() else { - log::warn!("magnet metadata probe was canceled without an active Tokio runtime"); - return; - }; - let client = Arc::clone(&self.client); - runtime.spawn(async move { - let cleanup_result = if let Some(gid) = gid { - let mut last_error = None; - for attempt in 0..CANCELLATION_CLEANUP_ATTEMPTS { - match cleanup_metadata_probe(client.as_ref(), &gid).await { - Ok(()) => { - last_error = None; - break; - } - Err(error) => { - last_error = Some(error); - if attempt + 1 < CANCELLATION_CLEANUP_ATTEMPTS { - tokio::time::sleep(CANCELLATION_CLEANUP_INTERVAL).await; - } - } - } - } - last_error - } else { - None - }; - if let Some(error) = cleanup_result { - log::warn!( - "canceled magnet metadata probe cleanup failed: {}", - crate::redact_sensitive_text(&error) - ); - // Do not delete a directory while Aria2 may still own the - // GID. The startup reaper removes this orphan before the - // next daemon launch. - return; - } - if let Some(probe_dir) = probe_dir { - if let Err(error) = tokio::fs::remove_dir_all(&probe_dir).await { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "canceled magnet metadata probe directory cleanup failed ({:?})", - error.kind() - ); - } - } - } - }); - } -} - -async fn cleanup_metadata_probe(client: &C, gid: &str) -> Result<(), String> { - match client.call("aria2.forceRemove", json!([gid])).await { - Ok(result) => { - crate::ensure_aria2_gid_result("forceRemove", gid, &result)?; - wait_for_stopped(client, gid).await - } - Err(error) if crate::aria2_gid_not_found(&error) => Ok(()), - Err(error) => match aria2_status(client, gid).await { - Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => Ok(()), - Err(status_error) if crate::aria2_gid_not_found(&status_error) => Ok(()), - _ => Err(format!( - "failed to remove aria2 gid {gid}: {}", - crate::redact_sensitive_text(&error) - )), - }, - } -} - -async fn aria2_status(client: &C, gid: &str) -> Result { - let result = client - .call("aria2.tellStatus", json!([gid, ["status"]])) - .await - .map_err(|error| { - format!( - "failed to query aria2 gid {gid}: {}", - crate::redact_sensitive_text(&error) - ) - })?; - result - .get("status") - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}")) -} - -async fn wait_for_stopped(client: &C, gid: &str) -> Result<(), String> { - let mut last_transient_error = None; - for _ in 0..STOP_POLL_ATTEMPTS { - match aria2_status(client, gid).await { - Ok(status) - if matches!(status.as_str(), "paused" | "complete" | "error" | "removed") => - { - return Ok(()) - } - Ok(_) => {} - Err(error) if crate::aria2_gid_not_found(&error) => return Ok(()), - Err(error) if crate::retry::is_transient_network_error(&error) => { - last_transient_error = Some(error); - } - Err(error) => return Err(error), - } - tokio::time::sleep(STOP_POLL_INTERVAL).await; - } - match last_transient_error { - Some(error) => Err(format!( - "aria2 gid {gid} did not stop within 3 seconds after forceRemove: {}", - crate::redact_sensitive_text(&error) - )), - None => Err(format!( - "aria2 gid {gid} did not stop within 3 seconds after forceRemove" - )), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::Body, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - routing::post, - Json, Router, - }; - use std::collections::HashMap; - use std::collections::VecDeque; - use std::net::SocketAddr; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Mutex; - use tokio::sync::{oneshot, watch, Notify}; - - enum ScriptedReply { - Result(Value), - RpcError(String), - HttpError(StatusCode, String), - Malformed(String), - Delay(Duration, Box), - Hang, - } - - struct RecordedCall { - method: String, - params: Value, - } - - struct ScriptedRpcState { - secret: String, - scripts: Mutex>>, - calls: Mutex>, - call_notification: Notify, - termination: watch::Sender, - } - - struct ScriptedRpcServer { - address: SocketAddr, - state: Arc, - shutdown: Option>, - task: Option>, - } - - impl ScriptedRpcServer { - async fn start(scripts: impl IntoIterator)>) -> Self { - let secret = "torrent-probe-test-secret".to_string(); - let (termination, _) = watch::channel(false); - let state = Arc::new(ScriptedRpcState { - secret: secret.clone(), - scripts: Mutex::new( - scripts - .into_iter() - .map(|(method, replies)| (method, replies.into_iter().collect())) - .collect(), - ), - calls: Mutex::new(Vec::new()), - call_notification: Notify::new(), - termination, - }); - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .expect("scripted RPC listener should bind"); - let address = listener - .local_addr() - .expect("scripted RPC listener should have an address"); - let app = Router::new() - .route("/jsonrpc", post(scripted_rpc_handler)) - .with_state(Arc::clone(&state)); - let (shutdown, shutdown_signal) = oneshot::channel(); - let task = tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = shutdown_signal.await; - }) - .await; - }); - Self { - address, - state, - shutdown: Some(shutdown), - task: Some(task), - } - } - - fn client(&self) -> Arc { - Arc::new(crate::Aria2RpcClient { - port: self.address.port(), - secret: self.state.secret.clone(), - }) - } - - fn calls(&self) -> Vec<(String, Value)> { - self.state - .calls - .lock() - .expect("scripted RPC call log lock should work") - .iter() - .map(|call| (call.method.clone(), call.params.clone())) - .collect() - } - - async fn wait_for_method(&self, method: &str, occurrence: usize) { - loop { - let count = self - .state - .calls - .lock() - .expect("scripted RPC call log lock should work") - .iter() - .filter(|call| call.method == method) - .count(); - if count >= occurrence { - return; - } - self.state.call_notification.notified().await; - } - } - - async fn shutdown(mut self) { - self.state.termination.send_replace(true); - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(task) = self.task.take() { - task.await.expect("scripted RPC server should stop cleanly"); - } - } - - async fn terminate(mut self) { - self.state.termination.send_replace(true); - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(mut task) = self.task.take() { - match tokio::time::timeout(Duration::from_secs(1), &mut task).await { - Ok(result) => { - let _ = result; - } - Err(_) => { - task.abort(); - let _ = task.await; - } - } - } - } - } - - impl Drop for ScriptedRpcServer { - fn drop(&mut self) { - self.state.termination.send_replace(true); - if let Some(task) = self.task.take() { - task.abort(); - } - } - } - - async fn scripted_rpc_handler( - State(state): State>, - Json(request): Json, - ) -> Response { - let id = request.get("id").cloned().unwrap_or(Value::Null); - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let params = request.get("params").cloned().unwrap_or(Value::Null); - state - .calls - .lock() - .expect("scripted RPC call log lock should work") - .push(RecordedCall { - method: method.clone(), - params: params.clone(), - }); - state.call_notification.notify_waiters(); - - let token = params - .as_array() - .and_then(|params| params.first()) - .and_then(Value::as_str); - let expected_token = format!("token:{}", state.secret); - if token != Some(expected_token.as_str()) { - return rpc_error_response( - id, - StatusCode::UNAUTHORIZED, - "invalid RPC token".to_string(), - ); - } - - let reply = state - .scripts - .lock() - .expect("scripted RPC scripts lock should work") - .get_mut(&method) - .and_then(VecDeque::pop_front) - .unwrap_or_else(|| { - ScriptedReply::RpcError(format!("unexpected scripted RPC method {method}")) - }); - scripted_reply_response(id, reply, state.termination.subscribe()).await - } - - async fn scripted_reply_response( - id: Value, - mut reply: ScriptedReply, - mut termination: watch::Receiver, - ) -> Response { - loop { - if *termination.borrow() { - return terminated_response(); - } - match reply { - ScriptedReply::Delay(delay, next) => { - tokio::select! { - _ = tokio::time::sleep(delay) => reply = *next, - _ = termination.changed() => return terminated_response(), - } - } - ScriptedReply::Hang => { - let _ = termination.changed().await; - return terminated_response(); - } - ScriptedReply::Result(result) => { - return Json(json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - })) - .into_response(); - } - ScriptedReply::RpcError(message) => { - return rpc_error_response(id, StatusCode::OK, message); - } - ScriptedReply::HttpError(status, message) => { - return rpc_error_response(id, status, message); - } - ScriptedReply::Malformed(body) => { - return (StatusCode::OK, Body::from(body)).into_response(); - } - } - } - } - - fn terminated_response() -> Response { - (StatusCode::OK, Body::empty()).into_response() - } - - fn rpc_error_response(id: Value, status: StatusCode, message: String) -> Response { - ( - status, - Json(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": 1, "message": message }, - })), - ) - .into_response() - } - - fn scripts( - entries: impl IntoIterator)>, - ) -> Vec<(String, Vec)> { - entries - .into_iter() - .map(|(method, replies)| (method.to_string(), replies)) - .collect() - } - - async fn probe_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { - let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); - let probe_dir = temporary.path().join("probe"); - tokio::fs::create_dir(&probe_dir) - .await - .expect("probe directory should be created"); - let metadata_path = probe_dir.join("metadata.torrent"); - tokio::fs::write(&metadata_path, b"torrent metadata") - .await - .expect("metadata fixture should be writable"); - (temporary, probe_dir, metadata_path) - } - - async fn wait_for_path(path: &Path, should_exist: bool) { - let deadline = Instant::now() + Duration::from_secs(3); - while Instant::now() < deadline { - if path.exists() == should_exist { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - assert_eq!( - path.exists(), - should_exist, - "path state did not become {}: {}", - should_exist, - path.display() - ); - } - - struct FakeRpc { - statuses: Mutex>>, - force_remove: Mutex>, - removed: AtomicBool, - calls: Mutex>, - call_notification: tokio::sync::Notify, - } - - impl FakeRpc { - fn new( - statuses: impl IntoIterator>, - force_remove: Result, - ) -> Self { - Self { - statuses: Mutex::new(statuses.into_iter().collect()), - force_remove: Mutex::new(force_remove), - removed: AtomicBool::new(false), - calls: Mutex::new(Vec::new()), - call_notification: tokio::sync::Notify::new(), - } - } - - fn status(name: &str) -> Result { - Ok(json!({ "status": name })) - } - - fn call_names(&self) -> Vec { - self.calls - .lock() - .expect("call log lock should work") - .clone() - } - - async fn wait_for_call(&self, method: &str) { - loop { - if self.call_names().iter().any(|call| call == method) { - return; - } - self.call_notification.notified().await; - } - } - } - - #[async_trait] - impl RpcClient for FakeRpc { - async fn call(&self, method: &str, _params: Value) -> Result { - self.calls - .lock() - .expect("call log lock should work") - .push(method.to_string()); - self.call_notification.notify_one(); - match method { - "aria2.addUri" => Ok(json!("gid-1")), - "aria2.tellStatus" => { - if let Some(status) = self - .statuses - .lock() - .expect("status queue lock should work") - .pop_front() - { - status - } else if self.removed.load(Ordering::Acquire) { - Self::status("removed") - } else { - Self::status("active") - } - } - "aria2.forceRemove" => { - let result = self - .force_remove - .lock() - .expect("forceRemove lock should work"); - if result.is_ok() { - self.removed.store(true, Ordering::Release); - } - match &*result { - Ok(value) => Ok(value.clone()), - Err(error) => Err(error.clone()), - } - } - _ => Err(format!("unexpected RPC method {method}")), - } - } - } - - async fn run_fake_probe( - rpc: Arc, - timeout: Duration, - poll_interval: Duration, - ) -> Result, ProbeFailure> { - let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); - let metadata_path = temporary.path().join("metadata.torrent"); - tokio::fs::write(&metadata_path, b"torrent metadata") - .await - .expect("metadata fixture should be writable"); - run_metadata_probe( - rpc, - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - timeout, - poll_interval, - ) - .await - } - - #[tokio::test(flavor = "current_thread")] - async fn resolves_active_waiting_complete_and_cleans_the_probe() { - let rpc = Arc::new(FakeRpc::new( - [ - FakeRpc::status("active"), - FakeRpc::status("waiting"), - FakeRpc::status("complete"), - FakeRpc::status("removed"), - ], - Ok(json!("gid-1")), - )); - let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect("complete probe should return metadata"); - assert_eq!(bytes, b"torrent metadata"); - assert_eq!( - rpc.call_names(), - vec![ - "aria2.addUri", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.forceRemove", - "aria2.tellStatus", - ] - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn returns_metadata_error_for_failed_status_and_cleans_the_probe() { - let rpc = Arc::new(FakeRpc::new( - [FakeRpc::status("error"), FakeRpc::status("removed")], - Ok(json!("gid-1")), - )); - let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect_err("error status should fail the probe"); - assert!( - matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve")) - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn retries_transient_status_errors_during_metadata_polling() { - let rpc = Arc::new(FakeRpc::new( - [ - Err("connection reset by peer".to_string()), - FakeRpc::status("complete"), - FakeRpc::status("removed"), - ], - Ok(json!("gid-1")), - )); - let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect("a transient status error should be retried"); - assert_eq!(bytes, b"torrent metadata"); - } - - #[tokio::test(flavor = "current_thread")] - async fn rejects_a_malformed_status_response_instead_of_waiting_for_timeout() { - let rpc = Arc::new(FakeRpc::new([Ok(json!({}))], Ok(json!("gid-1")))); - let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect_err("a missing status field should fail immediately"); - assert!(matches!( - error, - ProbeFailure::Metadata(message) - if message.contains("invalid metadata probe status") - )); - } - - #[tokio::test(flavor = "current_thread")] - async fn does_not_expose_metadata_path_when_reading_fails() { - let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); - let metadata_path = temporary.path().join("missing-metadata.torrent"); - let rpc = Arc::new(FakeRpc::new( - [FakeRpc::status("complete"), FakeRpc::status("removed")], - Ok(json!("gid-1")), - )); - let error = run_metadata_probe( - Arc::clone(&rpc), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("missing metadata should fail"); - let ProbeFailure::Metadata(message) = error else { - panic!("metadata read failure should remain a metadata error"); - }; - assert!(!message.contains(&*temporary.path().to_string_lossy())); - assert!(message.contains("NotFound")); - } - - #[tokio::test(flavor = "current_thread")] - async fn redacts_status_rpc_errors_during_cleanup() { - let rpc = Arc::new(FakeRpc::new( - [ - FakeRpc::status("complete"), - Err("token=super-secret-value".to_string()), - ], - Ok(json!("gid-1")), - )); - let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect_err("cleanup status failure should be reported"); - let ProbeFailure::Cleanup(message) = error else { - panic!("cleanup status failure should remain a cleanup error"); - }; - assert!(!message.contains("super-secret-value")); - assert!(message.contains("[redacted]")); - } - - #[tokio::test(flavor = "current_thread")] - async fn retries_transient_status_errors_after_force_remove() { - let rpc = Arc::new(FakeRpc::new( - [ - FakeRpc::status("complete"), - Err("connection reset by peer".to_string()), - FakeRpc::status("removed"), - ], - Ok(json!("gid-1")), - )); - run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO) - .await - .expect("a transient cleanup status error should be retried"); - } - - #[tokio::test(flavor = "current_thread")] - async fn handles_missing_gid_and_timeout_without_leaking_cleanup() { - let missing = Arc::new(FakeRpc::new( - [ - Err("aria2 gid gid-1 not found".to_string()), - Err("aria2 gid gid-1 not found".to_string()), - ], - Err("temporary forceRemove transport failure".to_string()), - )); - let missing_error = - run_fake_probe(Arc::clone(&missing), Duration::from_secs(1), Duration::ZERO) - .await - .expect_err("missing gid should fail the probe"); - assert!( - matches!(missing_error, ProbeFailure::Metadata(message) if message.contains("removed")) - ); - - let timeout = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1")))); - let timeout_error = run_fake_probe( - Arc::clone(&timeout), - Duration::from_millis(5), - Duration::from_millis(1), - ) - .await - .expect_err("active probe should time out"); - assert!( - matches!(timeout_error, ProbeFailure::Metadata(message) if message.contains("timed out")) - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn accepts_force_remove_race_only_after_terminal_status() { - let terminal_race = Arc::new(FakeRpc::new( - [FakeRpc::status("error"), FakeRpc::status("complete")], - Err("temporary forceRemove transport failure".to_string()), - )); - let error = run_fake_probe( - Arc::clone(&terminal_race), - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("metadata error should still be returned"); - assert!( - matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve")) - ); - - let active_race = Arc::new(FakeRpc::new( - [FakeRpc::status("error"), FakeRpc::status("active")], - Err("temporary forceRemove transport failure".to_string()), - )); - let cleanup_error = run_fake_probe( - Arc::clone(&active_race), - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("active race must report cleanup failure"); - assert!( - matches!(cleanup_error, ProbeFailure::Cleanup(message) if message.contains("failed to remove")) - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn cancellation_still_removes_the_remote_gid_and_probe_directory() { - let temporary = tempfile::tempdir().expect("temporary probe storage should exist"); - let metadata_path = temporary.path().join("metadata.torrent"); - tokio::fs::write(&metadata_path, b"torrent metadata") - .await - .expect("metadata fixture should be writable"); - let rpc = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1")))); - let probe = run_metadata_probe( - Arc::clone(&rpc), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(60), - Duration::from_secs(60), - ); - assert!( - tokio::time::timeout(Duration::from_millis(20), probe) - .await - .is_err(), - "the active probe should be canceled while polling" - ); - - tokio::time::timeout( - Duration::from_secs(1), - rpc.wait_for_call("aria2.forceRemove"), - ) - .await - .expect("cancellation cleanup should force-remove the GID"); - tokio::time::timeout(Duration::from_secs(1), async { - while temporary.path().exists() { - tokio::task::yield_now().await; - } - }) - .await - .expect("cancellation cleanup should remove the probe directory"); - } - - fn status_reply(status: &str) -> ScriptedReply { - ScriptedReply::Result(json!({ "status": status })) - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_exercises_production_client_and_status_order() { - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ - status_reply("active"), - status_reply("waiting"), - status_reply("paused"), - status_reply("complete"), - status_reply("removed"), - ], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::Result(json!("gid-1"))], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let mut options = Map::new(); - options.insert("bt-metadata-only".to_string(), json!("true")); - options.insert("bt-save-metadata".to_string(), json!("true")); - let bytes = run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - options, - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect("HTTP-backed metadata probe should resolve"); - - assert_eq!(bytes, b"torrent metadata"); - let calls = server.calls(); - assert_eq!( - calls - .iter() - .map(|(method, _)| method.as_str()) - .collect::>(), - vec![ - "aria2.addUri", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.forceRemove", - "aria2.tellStatus", - ] - ); - assert!(calls.iter().all(|(_, params)| { - params - .as_array() - .and_then(|params| params.first()) - .and_then(Value::as_str) - == Some("token:torrent-probe-test-secret") - })); - let add_params = calls[0] - .1 - .as_array() - .expect("addUri params should be an array"); - assert_eq!( - add_params[1], - json!(["magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"]) - ); - let options = add_params[2] - .as_object() - .expect("addUri options should be an object"); - assert_eq!( - options.get("bt-metadata-only"), - Some(&json!("true")), - "recorded addUri params: {add_params:?}" - ); - assert_eq!( - options.get("bt-save-metadata"), - Some(&json!("true")), - "recorded addUri params: {add_params:?}" - ); - assert_eq!( - options.get("follow-torrent"), - Some(&json!("false")), - "recorded addUri params: {add_params:?}" - ); - assert_eq!( - options.get("follow-metalink"), - Some(&json!("false")), - "recorded addUri params: {add_params:?}" - ); - tokio::fs::remove_dir_all(&probe_dir) - .await - .expect("successful probe fixture should be removable"); - server.shutdown().await; - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_reports_add_uri_failure_without_fabricating_cleanup() { - let server = ScriptedRpcServer::start(scripts([( - "aria2.addUri", - vec![ScriptedReply::HttpError( - StatusCode::SERVICE_UNAVAILABLE, - "daemon is starting".to_string(), - )], - )])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let error = run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("addUri failure should fail metadata resolution"); - assert!(matches!( - error, - ProbeFailure::Metadata(message) if message.contains("could not start") - )); - assert_eq!( - server - .calls() - .iter() - .map(|(method, _)| method.as_str()) - .collect::>(), - vec!["aria2.addUri"] - ); - assert!(probe_dir.exists()); - server.shutdown().await; - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_covers_terminal_malformed_and_missing_gid_statuses() { - for (initial_status, expected_message) in [ - ( - ScriptedReply::Result(json!({ - "status": "error", - "errorCode": "19", - "errorMessage": "tracker rejected metadata", - })), - "could not resolve", - ), - (status_reply("removed"), "could not resolve"), - ( - ScriptedReply::Result(json!({ "unexpected": "shape" })), - "invalid metadata probe status", - ), - (ScriptedReply::Malformed("{".to_string()), "status failed"), - ] { - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![initial_status, status_reply("removed")], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::Result(json!("gid-1"))], - ), - ])) - .await; - let (_temporary, _probe_dir, metadata_path) = probe_fixture().await; - let error = run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("terminal or malformed status should fail the probe"); - assert!(matches!( - error, - ProbeFailure::Metadata(message) if message.contains(expected_message) - )); - server.shutdown().await; - } - - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ScriptedReply::RpcError("gid gid-1 not found".to_string())], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::RpcError("gid gid-1 not found".to_string())], - ), - ])) - .await; - let (_temporary, _probe_dir, metadata_path) = probe_fixture().await; - let error = run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("a missing GID should fail metadata resolution"); - assert!(matches!( - error, - ProbeFailure::Metadata(message) if message.contains("removed") - )); - assert_eq!( - server - .calls() - .iter() - .map(|(method, _)| method.as_str()) - .collect::>(), - vec!["aria2.addUri", "aria2.tellStatus", "aria2.forceRemove"] - ); - server.shutdown().await; - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_retries_polling_and_force_remove_outages() { - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ - ScriptedReply::HttpError( - StatusCode::SERVICE_UNAVAILABLE, - "temporary polling outage".to_string(), - ), - status_reply("complete"), - status_reply("removed"), - ], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::Result(json!("gid-1"))], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect("a transient polling outage should be retried"); - assert_eq!( - server - .calls() - .iter() - .map(|(method, _)| method.as_str()) - .collect::>(), - vec![ - "aria2.addUri", - "aria2.tellStatus", - "aria2.tellStatus", - "aria2.forceRemove", - "aria2.tellStatus", - ] - ); - tokio::fs::remove_dir_all(&probe_dir) - .await - .expect("successful probe fixture should be removable"); - server.shutdown().await; - - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![status_reply("complete"), status_reply("complete")], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::HttpError( - StatusCode::SERVICE_UNAVAILABLE, - "temporary cleanup outage".to_string(), - )], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect("a completion race after forceRemove should be accepted"); - assert_eq!( - server - .calls() - .iter() - .map(|(method, _)| method.as_str()) - .collect::>(), - vec![ - "aria2.addUri", - "aria2.tellStatus", - "aria2.forceRemove", - "aria2.tellStatus", - ] - ); - tokio::fs::remove_dir_all(&probe_dir) - .await - .expect("successful probe fixture should be removable"); - server.shutdown().await; - - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ - status_reply("complete"), - status_reply("active"), - status_reply("active"), - status_reply("removed"), - ], - ), - ( - "aria2.forceRemove", - vec![ - ScriptedReply::HttpError( - StatusCode::BAD_GATEWAY, - "temporary gateway outage".to_string(), - ), - ScriptedReply::RpcError("connection reset by peer".to_string()), - ScriptedReply::Result(json!("gid-1")), - ], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let error = run_metadata_probe( - server.client(), - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &metadata_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - .expect_err("cleanup must report an unverified active GID"); - assert!(matches!(error, ProbeFailure::Cleanup(_))); - server.wait_for_method("aria2.forceRemove", 3).await; - wait_for_path(&probe_dir, false).await; - server.shutdown().await; - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_covers_cancellation_before_and_after_gid_assignment() { - let server = ScriptedRpcServer::start(scripts([( - "aria2.addUri", - vec![ScriptedReply::Delay( - Duration::from_millis(250), - Box::new(ScriptedReply::Result(json!("gid-1"))), - )], - )])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let probe_path = metadata_path.clone(); - let client = server.client(); - let task = tokio::spawn(async move { - run_metadata_probe( - client, - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &probe_path, - Duration::from_secs(60), - Duration::from_secs(60), - ) - .await - }); - server.wait_for_method("aria2.addUri", 1).await; - task.abort(); - let _ = task.await; - wait_for_path(&probe_dir, false).await; - server.shutdown().await; - - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ScriptedReply::Hang, status_reply("removed")], - ), - ( - "aria2.forceRemove", - vec![ScriptedReply::Result(json!("gid-1"))], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let probe_path = metadata_path.clone(); - let client = server.client(); - let task = tokio::spawn(async move { - run_metadata_probe( - client, - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &probe_path, - Duration::from_secs(60), - Duration::from_secs(60), - ) - .await - }); - server.wait_for_method("aria2.tellStatus", 1).await; - task.abort(); - let _ = task.await; - server.wait_for_method("aria2.forceRemove", 1).await; - wait_for_path(&probe_dir, false).await; - server.terminate().await; - } - - #[tokio::test(flavor = "current_thread")] - async fn http_rpc_harness_retains_probe_when_daemon_shuts_down_during_resolution_or_cleanup() { - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ( - "aria2.tellStatus", - vec![ScriptedReply::Delay( - Duration::from_millis(250), - Box::new(status_reply("active")), - )], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let probe_path = metadata_path.clone(); - let client = server.client(); - let task = tokio::spawn(async move { - run_metadata_probe( - client, - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &probe_path, - Duration::from_millis(40), - Duration::from_millis(5), - ) - .await - }); - server.wait_for_method("aria2.tellStatus", 1).await; - server.terminate().await; - let result = tokio::time::timeout(Duration::from_secs(2), task) - .await - .expect("probe should finish after daemon shutdown") - .expect("probe task should not panic") - .expect_err("daemon shutdown should not report metadata success"); - assert!(matches!(result, ProbeFailure::Cleanup(_))); - assert!(probe_dir.exists()); - - let server = ScriptedRpcServer::start(scripts([ - ("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]), - ("aria2.tellStatus", vec![status_reply("complete")]), - ( - "aria2.forceRemove", - vec![ScriptedReply::Delay( - Duration::from_secs(1), - Box::new(ScriptedReply::Result(json!("gid-1"))), - )], - ), - ])) - .await; - let (_temporary, probe_dir, metadata_path) = probe_fixture().await; - let probe_path = metadata_path.clone(); - let client = server.client(); - let task = tokio::spawn(async move { - run_metadata_probe( - client, - "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - Map::new(), - &probe_path, - Duration::from_secs(1), - Duration::ZERO, - ) - .await - }); - server.wait_for_method("aria2.forceRemove", 1).await; - server.terminate().await; - let result = tokio::time::timeout(Duration::from_secs(2), task) - .await - .expect("cleanup should finish after daemon shutdown") - .expect("cleanup task should not panic") - .expect_err("daemon shutdown should report cleanup uncertainty"); - assert!(matches!(result, ProbeFailure::Cleanup(_))); - assert!(probe_dir.exists()); - } -} diff --git a/src-tauri/src/window_geometry.rs b/src-tauri/src/window_geometry.rs deleted file mode 100644 index 6a4de37..0000000 --- a/src-tauri/src/window_geometry.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crate::ipc::MainWindowSize; - -pub const MAIN_WINDOW_DEFAULT_WIDTH: u32 = 1280; -pub const MAIN_WINDOW_DEFAULT_HEIGHT: u32 = 800; -pub const MAIN_WINDOW_MIN_WIDTH: u32 = 960; -pub const MAIN_WINDOW_MIN_HEIGHT: u32 = 640; -pub const MAIN_WINDOW_MAX_WIDTH: u32 = 16_384; -pub const MAIN_WINDOW_MAX_HEIGHT: u32 = 16_384; - -pub fn default_main_window_size() -> MainWindowSize { - MainWindowSize { - width: MAIN_WINDOW_DEFAULT_WIDTH, - height: MAIN_WINDOW_DEFAULT_HEIGHT, - } -} - -pub fn normalize_main_window_size(size: Option<&MainWindowSize>) -> Option { - let size = size?; - if size.width < MAIN_WINDOW_MIN_WIDTH - || size.height < MAIN_WINDOW_MIN_HEIGHT - || size.width > MAIN_WINDOW_MAX_WIDTH - || size.height > MAIN_WINDOW_MAX_HEIGHT - { - return None; - } - Some(size.clone()) -} - -pub fn clamp_main_window_size( - size: MainWindowSize, - work_area_width: u32, - work_area_height: u32, -) -> MainWindowSize { - let width_limit = work_area_width.max(MAIN_WINDOW_MIN_WIDTH); - let height_limit = work_area_height.max(MAIN_WINDOW_MIN_HEIGHT); - MainWindowSize { - width: size.width.min(width_limit), - height: size.height.min(height_limit), - } -} - -#[cfg(test)] -mod tests { - use super::{ - clamp_main_window_size, default_main_window_size, normalize_main_window_size, - MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH, - }; - use crate::ipc::MainWindowSize; - - #[test] - fn default_size_matches_the_main_window_configuration() { - assert_eq!(default_main_window_size().width, 1280); - assert_eq!(default_main_window_size().height, 800); - } - - #[test] - fn rejects_sizes_outside_the_persisted_bounds() { - assert!(normalize_main_window_size(Some(&MainWindowSize { - width: MAIN_WINDOW_MIN_WIDTH - 1, - height: 800, - })) - .is_none()); - assert!(normalize_main_window_size(Some(&MainWindowSize { - width: 1280, - height: 16_385, - })) - .is_none()); - } - - #[test] - fn caps_a_valid_size_to_the_available_work_area() { - let clamped = clamp_main_window_size( - MainWindowSize { - width: 1600, - height: 1000, - }, - 1280, - 720, - ); - assert_eq!(clamped.width, 1280); - assert_eq!(clamped.height, 720); - } - - #[test] - fn keeps_the_minimum_when_the_work_area_is_shorter_than_the_minimum() { - let clamped = clamp_main_window_size( - MainWindowSize { - width: 1280, - height: 800, - }, - 800, - 500, - ); - assert_eq!(clamped.width, MAIN_WINDOW_MIN_WIDTH); - assert_eq!(clamped.height, MAIN_WINDOW_MIN_HEIGHT); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4246703..dc251ea 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 800, + "height": 760, "minWidth": 960, "minHeight": 640, "transparent": false @@ -38,26 +38,12 @@ "resources": { "engine-dist/": "engine-dist/", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" - }, - "fileAssociations": [ - { - "ext": ["torrent"], - "mimeType": "application/x-bittorrent", - "name": "BitTorrent file", - "description": "BitTorrent metadata file", - "role": "Viewer", - "rank": "Alternate", - "exportedType": { - "identifier": "org.bittorrent.torrent", - "conformsTo": ["public.data", "public.item"] - } - } - ] + } }, "plugins": { "deep-link": { "desktop": { - "schemes": ["firelink", "magnet"] + "schemes": ["firelink"] } } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 6e72015..f56a439 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 800, + "height": 760, "minWidth": 960, "minHeight": 640, "transparent": false, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 08616c6..13081c2 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 800, + "height": 760, "minWidth": 960, "minHeight": 640, "transparent": true, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index ccfdc66..652302c 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 800, + "height": 760, "minWidth": 960, "minHeight": 640, "transparent": true, diff --git a/src/App.tsx b/src/App.tsx index cade729..a65014f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,17 +10,11 @@ import { KeychainPermissionModal } from './components/KeychainPermissionModal'; import { extractValidDownloadUrls } from './utils/url'; import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; -import { flushDownloadPersistence, initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; -import { - subscribeToSettingsPersistenceErrors, - useSettingsStore, - waitForSettingsPersistence -} from "./store/useSettingsStore"; +import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import { WindowControls } from "./components/WindowControls"; -import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { updateDockBadge } from './utils/dockBadge'; @@ -39,17 +33,6 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n'; import { useTranslation } from 'react-i18next'; import { formatDownloadBytes } from './utils/downloadProgress'; -import { synchronizeDocumentAppearance } from './utils/documentAppearance'; -import { createMainWindowSizePersistence } from './utils/mainWindowState'; -import { createSidebarResizeSession } from './utils/sidebarResize'; -import type { MainWindowSize } from './bindings/MainWindowSize'; -import { - beginSchedulerControl, - consumeSchedulerHandoffIds, - handoffSupersededSchedulerIds, - isSchedulerControlCurrent -} from './utils/schedulerControl'; -import { createSerialTaskQueue } from './utils/serialTaskQueue'; const loadSettingsView = () => import('./components/SettingsView'); const loadSchedulerView = () => import('./components/SchedulerView'); @@ -66,6 +49,9 @@ const SettingsView = lazy(loadSettingsView); const SchedulerView = lazy(loadSchedulerView); const SpeedLimiterView = lazy(loadSpeedLimiterView); const LogsView = lazy(loadLogsView); +const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({ + default: module.PropertiesModal, +}))); const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({ default: module.DeleteConfirmationModal, }))); @@ -114,6 +100,7 @@ const PageLoadingFallback = () => { }; let automaticUpdateCheckStarted = false; +const processingScheduleKeys = new Set(); let powerPreferencesSync: Promise = Promise.resolve(); const waitForSettingsHydration = (): Promise => { @@ -200,9 +187,6 @@ function App() { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220; }); - const sidebarResizeCleanupRef = useRef<(() => void) | null>(null); - const sidebarRevealRef = useRef(null); - const restoreSidebarFocusRef = useRef(false); const theme = useSettingsStore(state => state.theme); const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle); @@ -242,6 +226,7 @@ function App() { const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const showKeychainModal = useSettingsStore(state => state.showKeychainModal); const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen); + const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; @@ -264,7 +249,6 @@ function App() { const pendingPostActionTimer = useRef(null); const startupResumeStarted = useRef(false); const startupInputReady = useRef(false); - const extensionProcessing = useRef(createSerialTaskQueue()); const frontendReadyUpdate = useRef>(Promise.resolve()); const pendingStartupInputs = useRef state.preventsDisplaySleepWhileDownloading ); + const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const { addToast, removeToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); @@ -318,79 +303,12 @@ function App() { return update; }, []); - const acknowledgeExtensionDownload = useCallback(async (requestId?: string) => { - if (!requestId) return; - try { - await invoke('ack_extension_download', { requestId }); - } catch (error) { - console.error('Failed to acknowledge browser extension download:', error); - } - }, []); - - const processExtensionDownload = useCallback(async (payload: ExtensionDownloadRequest) => { - await useDownloadStore.getState().handleExtensionDownload(payload); - await acknowledgeExtensionDownload(payload.request_id); - }, [acknowledgeExtensionDownload]); - - const enqueueAddInput = useCallback((task: () => void | Promise) => { - return extensionProcessing.current(task); - }, []); - const schedulePostQueueAction = useCallback((action: Exclude) => { clearPendingPostActionTimer(); const actionLabel = t($ => $.scheduler.postActions[action]); let timerId: number | null = null; let toastId: string | null = null; - const showForceActionToast = () => { - let forceToastId: string | null = null; - const proceed = () => { - if (forceToastId !== null) { - removeToast(forceToastId); - forceToastId = null; - } - invoke('perform_system_action', { action, force: true }).catch(error => { - console.error('Forced scheduled post action failed:', error); - addToast({ - message: t($ => $.app.systemActionFailed, { detail: String(error) }), - variant: 'error', - isActionable: true - }); - }); - }; - forceToastId = addToast({ - variant: 'warning', - isActionable: true, - duration: 0, - message: ( -
- {t($ => $.app.systemActionCancelled)} - -
- ) - }); - }; - const perform = (force: boolean) => { - invoke('perform_system_action', { action, force }).catch(error => { - const detail = String(error); - if (!force && detail.includes('active or queued')) { - showForceActionToast(); - return; - } - console.error('Scheduled post action failed:', error); - addToast({ - message: t($ => $.app.systemActionFailed, { detail }), - variant: 'error', - isActionable: true - }); - }); - }; const cancel = () => { clearPendingPostActionTimer(); timerId = null; @@ -432,117 +350,67 @@ function App() { isActiveDownloadStatus(download.status) ); if (activeTransfers) { - showForceActionToast(); + addToast({ + message: t($ => $.app.systemActionCancelled), + variant: 'warning', + isActionable: true + }); return; } - perform(false); + invoke('perform_system_action', { action }).catch(error => { + console.error('Scheduled post action failed:', error); + addToast({ + message: t($ => $.app.systemActionFailed, { detail: String(error) }), + variant: 'error', + isActionable: true + }); + }); }, 10_000); pendingPostActionTimer.current = timerId; - }, [addToast, clearPendingPostActionTimer, removeToast, t]); + }, [addToast, clearPendingPostActionTimer, removeToast]); const startSidebarResize = (event: React.PointerEvent) => { - sidebarResizeCleanupRef.current?.(); event.preventDefault(); - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - // Pointer capture is best-effort; the session still listens on window. - } - const cleanup = createSidebarResizeSession({ - windowTarget: window, - body: document.body, - captureTarget: event.currentTarget, - pointerId: event.pointerId, - startX: event.clientX, - startWidth: sidebarWidth, - isRight: isSidebarOnRight, - onWidth: setSidebarWidth, - }); - sidebarResizeCleanupRef.current = cleanup; - }; + const startX = event.clientX; + const startWidth = sidebarWidth; - useEffect(() => () => { - sidebarResizeCleanupRef.current?.(); - }, []); + const handlePointerMove = (moveEvent: PointerEvent) => { + const delta = isSidebarOnRight + ? startX - moveEvent.clientX + : moveEvent.clientX - startX; + const nextWidth = Math.min(260, Math.max(190, startWidth + delta)); + setSidebarWidth(nextWidth); + }; - useEffect(() => { - if (isSidebarVisible) return; - if (restoreSidebarFocusRef.current) { - restoreSidebarFocusRef.current = false; - sidebarRevealRef.current?.focus({ preventScroll: true }); - } - }, [isSidebarVisible]); + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + document.body.classList.remove('is-resizing'); + }; - const handleSidebarToggle = () => { - if (isSidebarVisible) { - const activeElement = document.activeElement; - restoreSidebarFocusRef.current = activeElement instanceof HTMLElement - && Boolean(activeElement.closest('.app-sidebar-shell')); - } - toggleSidebar(); + document.body.classList.add('is-resizing'); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); }; useEffect(() => { return clearPendingPostActionTimer; }, [clearPendingPostActionTimer]); + useEffect(() => { + if (activeTransferCount > 0) { + clearPendingPostActionTimer(); + } + }, [activeTransferCount, clearPendingPostActionTimer]); + useEffect(() => { initMediaDomains(); window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { - const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label); let active = true; - let exitRequested = false; - let exiting = false; - let settingsHydrated = useSettingsStore.persist.hasHydrated(); - let latestSizeBeforeHydration: MainWindowSize | null = null; - const unlistenSettingsHydration = settingsHydrated - ? null - : useSettingsStore.persist.onFinishHydration(() => { - settingsHydrated = true; - const size = latestSizeBeforeHydration; - latestSizeBeforeHydration = null; - if (size && active && !exitRequested && !exiting) { - useSettingsStore.getState().setMainWindowSize(size); - } - }); - const mainWindowSizePersistence = createMainWindowSizePersistence({ - appWindow: getCurrentWindow(), - onSize: size => { - if (!active || exiting) return; - if (!settingsHydrated) { - latestSizeBeforeHydration = size; - return; - } - useSettingsStore.getState().setMainWindowSize(size); - } - }); let cleanupListeners: (() => void) | null = null; - let unlistenExit: (() => void) | null = null; - const exitListener = listen('app-exit-requested', async () => { - exitRequested = true; - try { - await mainWindowSizePersistence.flush(); - await waitForSettingsPersistence(); - await flushDownloadPersistence(); - } catch (error) { - console.error('Failed to flush download state before exit:', error); - } finally { - exiting = true; - latestSizeBeforeHydration = null; - await invoke('ack_frontend_exit').catch(error => { - console.error('Failed to acknowledge frontend exit flush:', error); - }); - } - }); - void exitListener.then(unlisten => { - if (active) unlistenExit = unlisten; - else unlisten(); - }).catch(error => { - console.error('Failed to listen for frontend exit flush:', error); - }); const initialize = async () => { let unlistenDownload: (() => void) | null = null; let unlistenTerminalState: (() => void) | null = null; @@ -550,9 +418,6 @@ function App() { let unlistenDeepLink: (() => void) | null = null; const disposeListeners = () => { void queueFrontendReadyUpdate(false).catch(() => {}); - mainWindowSizePersistence.dispose(); - unlistenExit?.(); - unlistenExit = null; unlistenTerminalState?.(); unlistenTerminalState = null; unlistenExtension?.(); @@ -693,11 +558,16 @@ function App() { } }); unlistenExtension = await listen('extension-add-download', (event) => { + if (event.payload.request_id) { + void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => { + console.error('Failed to acknowledge browser extension download:', error); + }); + } if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) { pendingStartupInputs.current.push({ type: 'extension', payload: event.payload }); return; } - enqueueAddInput(() => processExtensionDownload(event.payload)).catch(error => { + useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { console.error('Failed to handle browser extension download:', error); }); }); @@ -706,7 +576,7 @@ function App() { pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload }); return; } - enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(event.payload)); + useDownloadStore.getState().openAddModalWithUrls(event.payload); }); cleanupListeners = disposeListeners; @@ -754,13 +624,8 @@ function App() { pendingStartupInputs.current = []; cleanupListeners?.(); cleanupListeners = null; - unlistenExit?.(); - unlistenExit = null; - unlistenSettingsHydration?.(); - mainWindowSizePersistence.dispose(); - disposePersistence(); }; - }, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]); + }, [addToast, queueFrontendReadyUpdate]); useEffect(() => { if (!coreReady) return; @@ -782,14 +647,14 @@ function App() { const pendingInputs = pendingStartupInputs.current.splice(0); for (const input of pendingInputs) { if (input.type === 'extension') { - enqueueAddInput(() => processExtensionDownload(input.payload)).catch(error => { + useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => { console.error('Failed to handle queued browser extension download:', error); }); } else { - enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(input.payload)); + useDownloadStore.getState().openAddModalWithUrls(input.payload); } } - }, [coreReady, enqueueAddInput, processExtensionDownload, showKeychainModal]); + }, [coreReady, showKeychainModal]); useEffect(() => { if (!coreReady || showKeychainModal || startupResumeStarted.current) return; @@ -804,13 +669,17 @@ function App() { }); }, [addToast, coreReady, showKeychainModal]); - useEffect(() => synchronizeDocumentAppearance(window, { - theme, - fontFamily, - appFontSize, - listRowDensity, - locale: resolveAppLocale(i18n.language), - }), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]); + useEffect(() => { + window.document.documentElement.setAttribute('data-font-family', fontFamily); + }, [fontFamily]); + + useEffect(() => { + window.document.documentElement.setAttribute('data-font-size', appFontSize); + }, [appFontSize]); + + useEffect(() => { + window.document.documentElement.setAttribute('data-list-density', listRowDensity); + }, [listRowDensity]); useEffect(() => { const checkForUpdate = () => { @@ -914,11 +783,6 @@ function App() { useEffect(() => { if (!coreReady) return; - // Scope duplicate suppression to this listener instance. A module-level - // set can retain a key across a webview/listener restart while the old - // async handler is still unwinding, causing the replacement listener to - // drop the only retry for that scheduled action. - const processingScheduleKeys = new Set(); const unlisten = listen('schedule-trigger', async (event) => { const state = useSettingsStore.getState(); const payload = event.payload; @@ -928,7 +792,6 @@ function App() { if (payload.action === 'start') { clearPendingPostActionTimer(); const scheduledQueueIds = getScheduledQueueIds(); - const generation = beginSchedulerControl(scheduledQueueIds); if (scheduledQueueIds.length === 0) { state.setSchedulerActiveDownloadIds([]); state.setSchedulerRunning(false); @@ -945,24 +808,10 @@ function App() { scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = startedResults.flat(); - if (!isSchedulerControlCurrent(generation)) { - const handoffIds = handoffSupersededSchedulerIds( - acceptedIds, - id => useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID - ); - await Promise.allSettled( - acceptedIds - .filter(id => !handoffIds.has(id)) - .map(id => useDownloadStore.getState().pauseDownload(id)) - ); - await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); - return; - } const scheduledQueueSet = new Set(scheduledQueueIds); - const handoffIds = consumeSchedulerHandoffIds(generation); const trackedIds = useDownloadStore.getState().downloads .filter(download => - (previouslyTrackedIds.has(download.id) || handoffIds.has(download.id)) && + previouslyTrackedIds.has(download.id) && scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) && isActiveDownloadStatus(download.status) ) @@ -972,18 +821,14 @@ function App() { state.setSchedulerRunning(activeIds.length > 0); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); } else if (payload.action === 'stop') { - const generation = beginSchedulerControl(); - // A stop event can race with the completion effect's post-action - // countdown after it has already cleared the tracked IDs. Always - // cancel that pending action before applying the stop transition. - clearPendingPostActionTimer(); const trackedIds = state.schedulerActiveDownloadIds; if (trackedIds.length > 0) { + clearPendingPostActionTimer(); const pauseResults = await Promise.allSettled( trackedIds.map(id => useDownloadStore.getState().pauseDownload(id)) ); const failedPauses = pauseResults.filter(result => result.status === 'rejected').length; - if (failedPauses > 0 && isSchedulerControlCurrent(generation)) { + if (failedPauses > 0) { addToast({ message: failedPauses === 1 ? t($ => $.app.schedulerPauseOneFailed) @@ -993,10 +838,8 @@ function App() { }); } } - if (isSchedulerControlCurrent(generation)) { - state.setSchedulerActiveDownloadIds([]); - state.setSchedulerRunning(false); - } + state.setSchedulerActiveDownloadIds([]); + state.setSchedulerRunning(false); await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key }); } } finally { @@ -1005,7 +848,6 @@ function App() { }); return () => { - beginSchedulerControl(); unlisten.then(f => f()).catch(console.error); }; }, [addToast, clearPendingPostActionTimer, coreReady]); @@ -1029,7 +871,15 @@ function App() { isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { - schedulePostQueueAction(settings.scheduler.postQueueAction); + if (downloads.some(download => isActiveDownloadStatus(download.status))) { + addToast({ + message: t($ => $.app.scheduledActionSkippedActive), + variant: 'warning', + isActionable: true + }); + } else { + schedulePostQueueAction(settings.scheduler.postQueueAction); + } } }, [ addToast, @@ -1175,6 +1025,39 @@ function App() { }; }, [autoAddClipboardLinks, coreReady, showKeychainModal]); + useEffect(() => { + const root = window.document.documentElement; + + const applyTheme = () => { + // Remove all theme classes first + root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'); + + if (theme === 'system') { + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + root.classList.add(systemDark ? 'theme-dark' : 'theme-light'); + root.dataset.resolvedTheme = systemDark ? 'dark' : 'light'; + root.style.colorScheme = systemDark ? 'dark' : 'light'; + if (systemDark) root.classList.add('dark'); + } else { + root.classList.add(`theme-${theme}`); + if (['dark', 'dracula', 'nord'].includes(theme)) { + root.classList.add('dark'); + } + root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; + root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; + } + }; + + applyTheme(); + + if (theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const listener = () => applyTheme(); + mediaQuery.addEventListener('change', listener); + return () => mediaQuery.removeEventListener('change', listener); + } + }, [theme]); + return (
{ setFilter(f); useSettingsStore.getState().setActiveView('downloads'); @@ -1233,7 +1113,6 @@ function App() { {!isSidebarVisible && (
+ + +
+ + +
+ + )} + + + + +
+ + {/* Footer */} +
+
+ {errorMessage} +
+
+ + {pauseResumeAction && ( + + )} + +
+
+ + + + ); +}; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx deleted file mode 100644 index 9c2a8a3..0000000 --- a/src/components/PropertiesWindowApp.tsx +++ /dev/null @@ -1,1730 +0,0 @@ -import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; -import { open, save } from '@tauri-apps/plugin-dialog'; -import { Activity, Copy, Download, FileDown, FileText, FolderOpen, Gauge, Info, List, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, SlidersHorizontal, Timer, Upload, Users, Wrench, X, ChevronDown, ChevronUp } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; -import type { TorrentDetails } from '../bindings/TorrentDetails'; -import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; -import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; -import { invokeCommand as invoke } from '../ipc'; -import { - PROPERTIES_WINDOW_ACTION_RESULT, - PROPERTIES_WINDOW_REMOVED, - PROPERTIES_WINDOW_SNAPSHOT, - attachAsyncPropertiesListener, - DEFAULT_PROPERTIES_WINDOW_CHROME, - encodePropertiesPatchValue, - formatPropertiesQueuePlacement, - getPropertiesLifecycleAction, - propertiesDiagnosticRequestState, - sendPropertiesActionRequest, - sendPropertiesReady, - isExpectedPropertiesDiagnosticUnavailable, - nextPropertiesRequestId, - propertiesDiagnosticPhase, - propertiesActionRequestKey, - propertiesWindowEventTarget, - redactPropertiesError, - resetPropertiesActionState, - type PropertiesAction, - type PropertiesActionRequest, - type PropertiesActionResult, - type PropertiesPatch, - type PropertiesDiagnosticPhase, - type PropertiesSnapshot, - type PropertiesSnapshotEvent, -} from '../propertiesBridge'; -import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; -import { changeAppLocale } from '../i18n'; -import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; -import { getWindowControlRailWidth } from '../utils/windowControlStyle'; -import { getPropertiesFooterActions } from '../utils/propertiesFooter'; -import { - formatPropertiesAvailability, - formatPropertiesDiagnosticCount, - getPropertiesAvailabilityDiagnosticState, - getPropertiesPeerDiagnosticState, - hasTorrentPeerCountDifference, - hasLiveTorrentPeerWithoutDetails, -} from '../utils/propertiesDiagnostics'; -import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl'; -import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; -import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation'; -import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle'; -import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; -import { WindowControls } from './WindowControls'; -import { - TORRENT_ENCRYPTION_POLICY_DISABLED, - TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, - TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, - isAllocationPhaseVisible, - type TorrentEncryptionPolicy, - type TorrentFileAllocation, -} from '../utils/downloads'; - -type SecretName = 'username' | 'password' | 'cookies' | 'headers'; -type SecretDraft = { value: string; touched: boolean; clear: boolean }; -const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers']; -const isTorrentDiagnosticsStatus = (status: string) => - ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); - -const isTorrentPollingStatus = isTorrentLiveStatus; - -const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status); -const isLiveNormalSpeedStatus = (status: string) => ['downloading', 'retrying'].includes(status); -const isLiveTorrentControlStatus = (status: string) => ['downloading', 'seeding', 'retrying'].includes(status); - -const isTorrentFileSelectionEditable = (status: string) => - ['ready', 'staged', 'queued', 'paused', 'failed'].includes(status); - -const safeTitle = (name: string) => { - const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160); - return `${bounded || 'Download'} - Properties - Firelink`; -}; - -const errorText = redactPropertiesError; - -const PropertiesHelp = ({ text }: { text: string }) => ( - -); - -const PropertiesField = ({ - label, - controlId, - hint, - meta, - format, - children, - className = '', -}: { - label: ReactNode; - controlId: string; - hint?: string; - meta?: ReactNode; - format?: ReactNode; - children: ReactNode; - className?: string; -}) => { - const hintId = hint ? `${controlId}-hint` : undefined; - const describedChildren = hintId && isValidElement(children) - ? cloneElement(children, { - 'aria-describedby': [ - (children.props as { 'aria-describedby'?: string })['aria-describedby'], - hintId, - ].filter(Boolean).join(' '), - } as Record) - : children; - return ( -
-
- - {hint && <>{hint}} - {meta && {meta}} -
- {describedChildren} - {format && {format}} -
- ); -}; - -const PropertiesOptionToggle = ({ - label, - hint, - checked, - disabled, - onChange, -}: { - label: ReactNode; - hint: string; - checked: boolean; - disabled: boolean; - onChange: (checked: boolean) => void; -}) => { - const controlId = useId(); - return ( -
- onChange(event.target.checked)} disabled={disabled} /> - - - - - {hint} -
- ); -}; - -const propertiesStatusTone = (status: string) => { - if (status === 'paused') return 'paused'; - if (status === 'seeding') return 'seeding'; - if (status === 'failed') return 'failed'; - if (status === 'processing' || status === 'verifying' || status === 'moving') return 'processing'; - if (status === 'queued' || status === 'staged') return 'queued'; - if (status === 'retrying') return 'retrying'; - if (status === 'completed') return 'completed'; - return 'downloading'; -}; - -const propertiesTabIcon = (tab: PropertiesTab) => { - const props = { className: 'properties-window-tab-icon', size: 15, strokeWidth: 2, 'aria-hidden': true } as const; - switch (tab) { - case 'overview': return ; - case 'files': return ; - case 'trackers': return ; - case 'peers': return ; - case 'options': return ; - case 'transfer': return ; - case 'advanced': return ; - } -}; - -const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [ - snapshot.id, - snapshot.status, - snapshot.lastTry ?? '', - snapshot.hasBeenDispatched === true, - snapshot.destination ?? '', - snapshot.torrentInfoHash ?? '', - snapshot.torrentFileIndices?.join(',') ?? '', - snapshot.torrentMoveDestination ?? '', - snapshot.torrentMoveRestoreStatus ?? '', - snapshot.torrentVerifyOnly === true, - snapshot.torrentRelocationCheckPending === true, -].join('\u0000'); - -export const PropertiesWindowApp = () => { - const { t } = useTranslation(); - const translationRef = useRef(t); - translationRef.current = t; - const currentWindow = useMemo(() => getCurrentWindow(), []); - const windowLabel = currentWindow.label; - const sessionId = useMemo(() => crypto.randomUUID(), []); - const [downloadId, setDownloadId] = useState(null); - const [snapshot, setSnapshot] = useState(null); - const [isUrlExpanded, setIsUrlExpanded] = useState(false); - const [urlHasOverflow, setUrlHasOverflow] = useState(false); - const [useTabOverflow, setUseTabOverflow] = useState(false); - const [activeTab, setActiveTab] = useState('overview'); - const [pendingTab, setPendingTab] = useState(null); - const [closePrompt, setClosePrompt] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [notice, setNotice] = useState(''); - const [pendingAction, setPendingAction] = useState(null); - const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null); - const [fileProgress, setFileProgress] = useState(null); - const [peers, setPeers] = useState(null); - const [peerDetailsUnavailable, setPeerDetailsUnavailable] = useState(false); - const [availability, setAvailability] = useState(null); - const [details, setDetails] = useState(null); - const [diagnosticError, setDiagnosticError] = useState(''); - const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); - const [diagnosticsRefreshing, setDiagnosticsRefreshing] = useState(false); - const [diagnosticPhase, setDiagnosticPhase] = useState('idle'); - const [peerDiagnosticPhase, setPeerDiagnosticPhase] = useState('idle'); - const [availabilityDiagnosticPhase, setAvailabilityDiagnosticPhase] = useState('idle'); - // null means the Files tab has no local selection draft yet; [] is an - // explicit user choice to clear every file and must remain visually empty. - const [selectedFiles, setSelectedFiles] = useState(null); - const [fileName, setFileName] = useState(''); - const [destination, setDestination] = useState(''); - const [connections, setConnections] = useState(''); - const [sftpHostKeyMd, setSftpHostKeyMd] = useState(''); - const [trackers, setTrackers] = useState(''); - const [excludedTrackers, setExcludedTrackers] = useState(''); - const [downloadLimit, setDownloadLimit] = useState(''); - const [uploadLimit, setUploadLimit] = useState(''); - const [maxPeers, setMaxPeers] = useState(''); - const [peerSpeedLimit, setPeerSpeedLimit] = useState(''); - const [seedTime, setSeedTime] = useState(''); - const [seedRatio, setSeedRatio] = useState(''); - const [checkIntegrity, setCheckIntegrity] = useState(false); - const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false); - const [stopTimeout, setStopTimeout] = useState(''); - const [prioritizePiece, setPrioritizePiece] = useState(''); - const [encryptionPolicy, setEncryptionPolicy] = useState(''); - const [fileAllocation, setFileAllocation] = useState(''); - const [trackerConnectTimeout, setTrackerConnectTimeout] = useState(''); - const [trackerTimeout, setTrackerTimeout] = useState(''); - const [trackerInterval, setTrackerInterval] = useState(''); - const [secretDrafts, setSecretDrafts] = useState>({ - username: { value: '', touched: false, clear: false }, - password: { value: '', touched: false, clear: false }, - cookies: { value: '', touched: false, clear: false }, - headers: { value: '', touched: false, clear: false }, - }); - const [draftTab, setDraftTab] = useState(null); - const draftTabRef = useRef(null); - const closeAfterSaveRef = useRef(false); - const switchAfterSaveRef = useRef(null); - const requestIdRef = useRef(0); - const pendingActionRequestRef = useRef(null); - const pendingActionRef = useRef(null); - const pendingActionSendKeyRef = useRef(null); - const latestSnapshotRevisionRef = useRef(0); - const latestBridgeGenerationRef = useRef(null); - const appearanceCleanupRef = useRef<(() => void) | null>(null); - const hasRevealedWindowRef = useRef(false); - const revealInFlightRef = useRef(false); - const readyRetryTimerRef = useRef(undefined); - const diagnosticsInFlightRef = useRef(new Set()); - const snapshotRef = useRef(snapshot); - const activeTabRef = useRef(activeTab); - const downloadIdRef = useRef(downloadId); - const fileProgressRef = useRef(fileProgress); - const peersRef = useRef(peers); - const availabilityRef = useRef(availability); - const detailsRef = useRef(details); - const diagnosticAttemptsRef = useRef(new Set()); - const diagnosticLifecycleKeyRef = useRef(''); - const diagnosticLifecycleEpochRef = useRef(0); - const allowWindowCloseRef = useRef(false); - const isDirtyRef = useRef(false); - const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME); - const previousUrlDownloadIdRef = useRef(downloadId); - const previousUrlRef = useRef(null); - const urlCardRef = useRef(null); - const urlValueRef = useRef(null); - snapshotRef.current = snapshot; - activeTabRef.current = activeTab; - downloadIdRef.current = downloadId; - fileProgressRef.current = fileProgress; - peersRef.current = peers; - availabilityRef.current = availability; - detailsRef.current = details; - - const isTorrent = snapshot?.isTorrent === true; - const isSftp = Boolean(snapshot?.url.trim().toLowerCase().startsWith('sftp:')); - const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]); - const peerDiagnosticState = getPropertiesPeerDiagnosticState(peers, diagnosticsLoading, peerDiagnosticPhase); - const availabilityDiagnosticState = getPropertiesAvailabilityDiagnosticState(availability, diagnosticsLoading, availabilityDiagnosticPhase); - const urlCanExpand = Boolean(snapshot && (shouldOfferPropertiesUrlExpansion(snapshot.url) || urlHasOverflow)); - const isDirty = draftTab !== null; - isDirtyRef.current = isDirty; - if (isDirty && allowWindowCloseRef.current) { - // A programmatic close is only authorized for the close request it - // immediately follows. If the native close was vetoed by another owner - // and the user starts editing again, restore the dirty-state guard. - allowWindowCloseRef.current = false; - } - - useEffect(() => { - const nextUrl = snapshot?.url ?? null; - if (shouldResetPropertiesUrlExpansion( - previousUrlDownloadIdRef.current, - downloadId, - previousUrlRef.current, - nextUrl, - )) { - setIsUrlExpanded(false); - setUrlHasOverflow(false); - } - previousUrlDownloadIdRef.current = downloadId; - previousUrlRef.current = nextUrl; - }, [downloadId, snapshot?.url]); - - useEffect(() => { - const updateOverflowState = () => setUseTabOverflow(shouldUsePropertiesTabOverflow(window.innerWidth)); - updateOverflowState(); - const media = typeof window.matchMedia === 'function' - ? window.matchMedia(`(max-width: ${PROPERTIES_TABS_OVERFLOW_BREAKPOINT}px)`) - : null; - if (media && typeof media.addEventListener === 'function') { - media.addEventListener('change', updateOverflowState); - return () => media.removeEventListener('change', updateOverflowState); - } - if (media && typeof media.addListener === 'function') { - media.addListener(updateOverflowState); - return () => media.removeListener(updateOverflowState); - } - window.addEventListener('resize', updateOverflowState); - return () => window.removeEventListener('resize', updateOverflowState); - }, []); - - useLayoutEffect(() => { - if (isUrlExpanded) return; - const card = urlCardRef.current; - const value = urlValueRef.current; - if (!card || !value) { - setUrlHasOverflow(false); - return; - } - const measureOverflow = () => { - setUrlHasOverflow(value.scrollHeight > value.clientHeight + 1); - }; - measureOverflow(); - if (typeof ResizeObserver === 'function') { - const observer = new ResizeObserver(measureOverflow); - observer.observe(card); - return () => observer.disconnect(); - } - window.addEventListener('resize', measureOverflow); - return () => window.removeEventListener('resize', measureOverflow); - }, [activeTab, isUrlExpanded, snapshot?.url, useTabOverflow]); - - const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => { - allowWindowCloseRef.current = allowDirtyClose; - try { - await currentWindow.close(); - } catch (error) { - setErrorMessage(errorText(error)); - allowWindowCloseRef.current = false; - } - }, [currentWindow]); - - const revealWindow = useCallback(async () => { - // The native reveal command is session-bound. Before the first snapshot, - // the child has not completed its ready handshake yet, so revealing here - // would produce a false startup error and leave stale error copy in the - // footer. The snapshot path calls reveal again after registration. - if (latestSnapshotRevisionRef.current === 0) return; - if (hasRevealedWindowRef.current) { - if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) { - window.clearInterval(readyRetryTimerRef.current); - readyRetryTimerRef.current = undefined; - } - return; - } - if (revealInFlightRef.current) return; - revealInFlightRef.current = true; - try { - await invoke('properties_window_reveal', { sessionId }); - hasRevealedWindowRef.current = true; - if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) { - window.clearInterval(readyRetryTimerRef.current); - readyRetryTimerRef.current = undefined; - } - } catch (error) { - setErrorMessage(errorText(error)); - } finally { - revealInFlightRef.current = false; - } - }, []); - - useEffect(() => { - // Native creation is hidden. Reveal after React has committed the loading - // shell so the first visible frame is styled and clipped by its radius, - // independent of the bridge snapshot timing. - void revealWindow(); - }, [revealWindow]); - - useEffect(() => { - draftTabRef.current = draftTab; - }, [draftTab]); - - const hydrateDraft = useCallback((next: PropertiesSnapshot) => { - setFileName(next.fileName); - setDestination(next.destination ?? ''); - setConnections(next.connections === undefined ? '' : String(next.connections)); - setSftpHostKeyMd(next.sftpHostKeyMd ?? ''); - setTrackers(next.torrentTrackers ?? ''); - setExcludedTrackers(next.torrentExcludeTrackers ?? ''); - setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null); - setDownloadLimit(next.speedLimit ?? ''); - setUploadLimit(next.torrentUploadLimit ?? ''); - setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers)); - setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? ''); - setSeedTime(next.torrentSeedTime === undefined ? '' : String(next.torrentSeedTime)); - setSeedRatio(next.torrentSeedRatio === undefined ? '' : String(next.torrentSeedRatio)); - setCheckIntegrity(next.torrentCheckIntegrity === true); - setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true); - setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout)); - setPrioritizePiece(next.torrentPrioritizePiece ?? ''); - setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? ''); - setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? ''); - setTrackerConnectTimeout(next.torrentTrackerConnectTimeout === undefined ? '' : String(next.torrentTrackerConnectTimeout)); - setTrackerTimeout(next.torrentTrackerTimeout === undefined ? '' : String(next.torrentTrackerTimeout)); - setTrackerInterval(next.torrentTrackerInterval === undefined ? '' : String(next.torrentTrackerInterval)); - setSecretDrafts({ - username: { value: '', touched: false, clear: false }, - password: { value: '', touched: false, clear: false }, - cookies: { value: '', touched: false, clear: false }, - headers: { value: '', touched: false, clear: false }, - }); - }, []); - - const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string, manual = false) => { - if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return; - const diagnosticTabKey = `${id}:${tab}`; - const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current; - const requestKey = `${diagnosticTabKey}:${requestLifecycleEpoch}`; - if (diagnosticsInFlightRef.current.has(requestKey)) return; - diagnosticsInFlightRef.current.add(requestKey); - const isCurrent = () => downloadIdRef.current === id - && activeTabRef.current === tab - && isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '') - && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch; - const hasCachedResult = () => tab === 'files' - ? fileProgressRef.current !== null - : tab === 'peers' - ? peersRef.current !== null || availabilityRef.current !== null - : detailsRef.current !== null; - const hasPreviousAttempt = diagnosticAttemptsRef.current.has(diagnosticTabKey); - diagnosticAttemptsRef.current.add(diagnosticTabKey); - if (isCurrent()) { - const cached = hasCachedResult(); - const requestState = propertiesDiagnosticRequestState(cached, hasPreviousAttempt, manual); - setDiagnosticsLoading(requestState.loading); - setDiagnosticsRefreshing(requestState.refreshing); - if (tab === 'peers') { - setPeerDiagnosticPhase(propertiesDiagnosticPhase( - peersRef.current !== null, - 'request-start', - hasPreviousAttempt, - )); - setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase( - availabilityRef.current !== null, - 'request-start', - hasPreviousAttempt, - )); - } - // A silent refresh with no cached result must not replace a stable - // unavailable/error message between polling requests. Cached results - // remain visible while their request is refreshed in the background. - if (requestState.resetMessage) { - setDiagnosticError(''); - setDiagnosticPhase(requestState.phase); - } - } - try { - if (tab === 'overview') { - const nextDetails = await invoke('get_torrent_details', { id }); - if (isCurrent()) { - setDetails(nextDetails); - setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); - } - } else if (tab === 'files') { - const nextProgress = await invoke('get_torrent_file_progress', { id }); - if (isCurrent()) { - setFileProgress(nextProgress); - setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); - } - } else if (tab === 'peers') { - const hadCachedPeers = peersRef.current !== null; - const hadCachedAvailability = availabilityRef.current !== null; - const [peerResult, availabilityResult] = await Promise.allSettled([ - invoke('get_torrent_peers', { id }), - invoke('get_torrent_availability', { id }), - ]); - if (isCurrent()) { - if (peerResult.status === 'fulfilled') { - setPeers(peerResult.value); - setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails( - snapshotRef.current?.torrentConnectedPeers, - peerResult.value.listedPeers, - )); - } else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) { - setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails( - snapshotRef.current?.torrentConnectedPeers, - 0, - )); - } - if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); - const peerOutcome = peerResult.status === 'fulfilled' - ? 'success' - : isExpectedPropertiesDiagnosticUnavailable(peerResult.reason) - ? 'expected-unavailable' - : 'unexpected-error'; - const availabilityOutcome = availabilityResult.status === 'fulfilled' - ? 'success' - : isExpectedPropertiesDiagnosticUnavailable(availabilityResult.reason) - ? 'expected-unavailable' - : 'unexpected-error'; - setPeerDiagnosticPhase(propertiesDiagnosticPhase(hadCachedPeers, peerOutcome)); - setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase(hadCachedAvailability, availabilityOutcome)); - const rejectedResults = [peerResult, availabilityResult] - .filter((result): result is PromiseRejectedResult => result.status === 'rejected') - .map(result => result.reason); - const unexpectedErrors = rejectedResults - .filter(error => !isExpectedPropertiesDiagnosticUnavailable(error)); - setDiagnosticError(unexpectedErrors.length > 0 ? errorText(unexpectedErrors[0]) : ''); - if (rejectedResults.length > 0) { - const hasPeerResult = hasCachedResult() - || peerResult.status === 'fulfilled' - || availabilityResult.status === 'fulfilled'; - setDiagnosticPhase(propertiesDiagnosticPhase( - hasPeerResult, - unexpectedErrors.length > 0 ? 'unexpected-error' : 'expected-unavailable', - )); - } else setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); - } - } - } catch (error) { - if (isCurrent()) { - const message = errorText(error); - // A paused row may not have a retained Aria2 GID (for example when it - // was paused before its first dispatch). That is an expected absence, - // not a diagnostic failure, and must not flash a raw backend error. - if (isExpectedPropertiesDiagnosticUnavailable(error)) { - setDiagnosticError(''); - setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'expected-unavailable')); - } else { - setDiagnosticError(message); - setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'unexpected-error')); - } - if (tab === 'peers') { - const hasCachedPeers = peersRef.current !== null; - const hasCachedAvailability = availabilityRef.current !== null; - const outcome = isExpectedPropertiesDiagnosticUnavailable(error) - ? 'expected-unavailable' - : 'unexpected-error'; - setPeerDiagnosticPhase(propertiesDiagnosticPhase(hasCachedPeers, outcome)); - setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase(hasCachedAvailability, outcome)); - } - } - } finally { - diagnosticsInFlightRef.current.delete(requestKey); - if (downloadIdRef.current === id - && activeTabRef.current === tab - && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch) { - setDiagnosticsLoading(false); - setDiagnosticsRefreshing(false); - } - } - }, []); - - useEffect(() => { - let cancelled = false; - let readyHeartbeatTimer: number | undefined; - let unlistenSnapshot: UnlistenFn | undefined; - let unlistenResult: UnlistenFn | undefined; - let unlistenRemoved: UnlistenFn | undefined; - const start = async () => { - try { - const id = await invoke('get_properties_window_download_id'); - if (cancelled) return; - setDownloadId(id); - const snapshotListener = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { - if (cancelled) return; - if (event.payload.windowLabel !== windowLabel - || event.payload.downloadId !== id - || event.payload.sessionId !== sessionId) return; - if (latestBridgeGenerationRef.current !== null - && event.payload.bridgeGeneration < latestBridgeGenerationRef.current) return; - if (latestBridgeGenerationRef.current !== event.payload.bridgeGeneration) { - latestBridgeGenerationRef.current = event.payload.bridgeGeneration; - latestSnapshotRevisionRef.current = 0; - diagnosticLifecycleEpochRef.current += 1; - diagnosticLifecycleKeyRef.current = ''; - diagnosticAttemptsRef.current.clear(); - const lostAction = pendingActionRef.current; - const lostDraftAction = lostAction === 'apply-properties' - || lostAction === 'set-torrent-file-selection'; - // A main-webview restart can lose both the action-result event and - // the store transition event while the child Properties window - // remains alive. No result from the dead bridge can be correlated - // safely after this point, so release the UI lock after adopting - // the next snapshot and require any retry to be an explicit user - // action. This never replays a possibly completed lifecycle - // request, and also recovers when the native request failed while - // its failure event was lost. - const resetActionState = resetPropertiesActionState(requestIdRef.current); - requestIdRef.current = resetActionState.requestId; - pendingActionRequestRef.current = resetActionState.request; - pendingActionRef.current = resetActionState.pendingAction; - setPendingAction(null); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - if (lostDraftAction) { - // A completed property action is represented by the fresh - // snapshot, not by the stale draft that produced the request. - draftTabRef.current = null; - setDraftTab(null); - setPendingTab(null); - setClosePrompt(false); - } - setPendingTorrentCommand(null); - } - if (event.payload.revision <= latestSnapshotRevisionRef.current) return; - latestSnapshotRevisionRef.current = event.payload.revision; - const nextDiagnosticLifecycleKey = propertiesDiagnosticLifecycleKey(event.payload.snapshot); - if (diagnosticLifecycleKeyRef.current !== nextDiagnosticLifecycleKey) { - diagnosticLifecycleKeyRef.current = nextDiagnosticLifecycleKey; - diagnosticLifecycleEpochRef.current += 1; - diagnosticAttemptsRef.current.clear(); - // A changed Torrent lifecycle invalidates every diagnostic snapshot - // from the previous lifecycle. Keep the old response out of the - // loading and stale states while the new lifecycle is queried. - setDetails(null); - setFileProgress(null); - setPeers(null); - setPeerDetailsUnavailable(false); - setAvailability(null); - setDiagnosticError(''); - setDiagnosticsLoading(false); - setDiagnosticsRefreshing(false); - setDiagnosticPhase('idle'); - setPeerDiagnosticPhase('idle'); - setAvailabilityDiagnosticPhase('idle'); - } - await changeAppLocale(event.payload.snapshot.appearance.locale); - if (cancelled - || event.payload.bridgeGeneration !== latestBridgeGenerationRef.current - || event.payload.revision !== latestSnapshotRevisionRef.current) return; - appearanceCleanupRef.current?.(); - appearanceCleanupRef.current = synchronizeDocumentAppearance( - window, - event.payload.snapshot.appearance, - ); - windowChromeRef.current = event.payload.snapshot.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME; - setSnapshot(event.payload.snapshot); - if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot); - void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); - await revealWindow(); - }, { target: propertiesWindowEventTarget(windowLabel) }); - if (cancelled) { - snapshotListener(); - return; - } - unlistenSnapshot = snapshotListener; - const resultListener = await listen(PROPERTIES_WINDOW_ACTION_RESULT, event => { - if (cancelled) return; - if (event.payload.windowLabel !== windowLabel - || event.payload.downloadId !== id - || event.payload.sessionId !== sessionId) return; - if (event.payload.requestId !== requestIdRef.current) return; - if (pendingActionRef.current === null) return; - const completedAction = pendingActionRef.current; - pendingActionRef.current = null; - pendingActionRequestRef.current = null; - setPendingAction(null); - if (!event.payload.ok) { - setErrorMessage(event.payload.error ?? 'The action failed'); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - } else { - const commitsDraft = completedAction === 'apply-properties' - || completedAction === 'set-torrent-file-selection'; - const nextTab = switchAfterSaveRef.current; - const shouldClose = closeAfterSaveRef.current; - switchAfterSaveRef.current = null; - closeAfterSaveRef.current = false; - setErrorMessage(''); - setNotice(completedAction === 'apply-properties' - ? translationRef.current($ => $.properties.saved) - : ''); - if (commitsDraft) { - draftTabRef.current = null; - setDraftTab(null); - setClosePrompt(false); - if (nextTab) { - setActiveTab(nextTab); - setPendingTab(null); - } - if (shouldClose) { - void closeCurrentWindow(true); - } - } - } - }, { target: propertiesWindowEventTarget(windowLabel) }); - if (cancelled) { - resultListener(); - return; - } - unlistenResult = resultListener; - const removedListener = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => { - if (cancelled) return; - if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) { - if (readyRetryTimerRef.current !== undefined) { - window.clearInterval(readyRetryTimerRef.current); - readyRetryTimerRef.current = undefined; - } - if (readyHeartbeatTimer !== undefined) { - window.clearInterval(readyHeartbeatTimer); - readyHeartbeatTimer = undefined; - } - diagnosticLifecycleEpochRef.current += 1; - diagnosticLifecycleKeyRef.current = ''; - diagnosticAttemptsRef.current.clear(); - setSnapshot(null); - draftTabRef.current = null; - isDirtyRef.current = false; - setDraftTab(null); - setPendingTab(null); - setClosePrompt(false); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - pendingActionRequestRef.current = null; - pendingActionRef.current = null; - pendingActionSendKeyRef.current = null; - allowWindowCloseRef.current = false; - setPendingAction(null); - setNotice(translationRef.current($ => $.downloadTable.noDownloads)); - } - }, { target: propertiesWindowEventTarget(windowLabel) }); - if (cancelled) { - removedListener(); - return; - } - unlistenRemoved = removedListener; - if (cancelled) return; - // Tauri event listeners are registered asynchronously. If the main - // bridge was still installing its listener, the first ready event can - // legitimately be missed; retry until the first snapshot confirms - // the handshake. Install this before the first attempt so an IPC - // rejection is recoverable as well as a missed event. - readyRetryTimerRef.current = window.setInterval(() => { - if (cancelled || latestSnapshotRevisionRef.current > 0) return; - void revealWindow(); - void sendPropertiesReady(sessionId).catch(() => undefined); - }, 500); - // The main webview can restart independently of this child window. - // Keep the registration alive so a fresh Properties bridge can send a - // new snapshot and recover action state without replaying a request. - readyHeartbeatTimer = window.setInterval(() => { - if (cancelled) return; - void sendPropertiesReady(sessionId).catch(() => undefined); - }, 2000); - void sendPropertiesReady(sessionId).catch(() => undefined); - } catch (error) { - if (!cancelled) setErrorMessage(errorText(error)); - } - }; - void start(); - return () => { - cancelled = true; - if (readyRetryTimerRef.current !== undefined) { - window.clearInterval(readyRetryTimerRef.current); - readyRetryTimerRef.current = undefined; - } - if (readyHeartbeatTimer !== undefined) window.clearInterval(readyHeartbeatTimer); - unlistenSnapshot?.(); - unlistenResult?.(); - unlistenRemoved?.(); - pendingActionRequestRef.current = null; - pendingActionSendKeyRef.current = null; - allowWindowCloseRef.current = false; - appearanceCleanupRef.current?.(); - appearanceCleanupRef.current = null; - }; - }, [closeCurrentWindow, currentWindow, hydrateDraft, revealWindow, sessionId, windowLabel]); - - useEffect(() => { - if (!snapshot || draftTab !== null) return; - hydrateDraft(snapshot); - }, [draftTab, hydrateDraft, snapshot]); - - useEffect(() => { - if (!downloadId || !snapshot || !isTorrent || !isTorrentDiagnosticsStatus(snapshot.status)) { - setDetails(null); - setFileProgress(null); - setPeers(null); - setPeerDetailsUnavailable(false); - setAvailability(null); - setDiagnosticError(''); - setDiagnosticsLoading(false); - setDiagnosticsRefreshing(false); - diagnosticLifecycleEpochRef.current += 1; - diagnosticLifecycleKeyRef.current = ''; - diagnosticAttemptsRef.current.clear(); - setDiagnosticPhase('idle'); - setPeerDiagnosticPhase('idle'); - setAvailabilityDiagnosticPhase('idle'); - return; - } - if (!isTorrentPollingStatus(snapshot.status)) { - setFileProgress(null); - setPeers(null); - setPeerDetailsUnavailable(false); - setAvailability(null); - diagnosticLifecycleEpochRef.current += 1; - diagnosticAttemptsRef.current.clear(); - setDiagnosticPhase('idle'); - setPeerDiagnosticPhase('idle'); - setAvailabilityDiagnosticPhase('idle'); - } - void refreshDiagnostics(activeTab, downloadId); - const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab); - if (!isTorrentPollingStatus(snapshot.status) || !shouldPollDiagnostics) return; - // Match the 1-second cadence of the normal Aria2 progress poll. The - // diagnostics request itself is still single-flight, so a slow RPC cannot - // create overlapping refreshes. - const interval = window.setInterval(() => { - if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId); - }, 1000); - return () => window.clearInterval(interval); - }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); - - useEffect(() => { - let disposed = false; - let unlisten: UnlistenFn | undefined; - attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => { - if (allowWindowCloseRef.current) { - allowWindowCloseRef.current = false; - return; - } - if (!isDirtyRef.current) return; - event.preventDefault(); - setClosePrompt(true); - }), () => disposed, value => { unlisten = value; }); - return () => { - disposed = true; - unlisten?.(); - }; - }, [currentWindow]); - - const sendPendingAction = useCallback(async (reportError: boolean) => { - const request = pendingActionRequestRef.current; - if (!request || pendingActionRef.current === null) return; - const requestKey = propertiesActionRequestKey(request); - if (pendingActionSendKeyRef.current === requestKey) return; - pendingActionSendKeyRef.current = requestKey; - try { - await sendPropertiesActionRequest(request); - } catch (error) { - // A rejected IPC promise does not prove that the host did not receive - // the request. Keep the exact request pending so the idempotent host can - // replay its result on the next attempt. - if (reportError) setErrorMessage(errorText(error)); - } finally { - if (pendingActionSendKeyRef.current === requestKey) { - pendingActionSendKeyRef.current = null; - } - } - }, []); - - const requestAction = useCallback(async ( - action: PropertiesAction, - payload?: PropertiesActionRequest['payload'], - ) => { - if (!downloadId || pendingActionRef.current !== null) return; - const requestId = nextPropertiesRequestId(requestIdRef.current); - requestIdRef.current = requestId; - pendingActionRef.current = action; - setPendingAction(action); - const request: PropertiesActionRequest = { - windowLabel, - downloadId, - sessionId, - requestId, - action, - payload, - }; - pendingActionRequestRef.current = request; - await sendPendingAction(true); - }, [downloadId, sendPendingAction, sessionId, windowLabel]); - - useEffect(() => { - if (pendingAction === null) return; - const retryTimer = window.setInterval(() => { - void sendPendingAction(false); - }, 2500); - return () => window.clearInterval(retryTimer); - }, [pendingAction, sendPendingAction]); - - const updateSecretDraft = (name: SecretName, value: string) => { - setSecretDrafts(current => ({ - ...current, - [name]: { value, touched: true, clear: false }, - })); - setDraftTab('advanced'); - }; - - const clearSecretDraft = (name: SecretName) => { - setSecretDrafts(current => ({ - ...current, - [name]: { value: '', touched: true, clear: true }, - })); - setDraftTab('advanced'); - }; - - const applyActiveTab = useCallback(async () => { - if (!snapshot) return; - const canApplyLiveNormalSpeed = snapshot.isMedia !== true - && snapshot.isTorrent !== true - && isLiveNormalSpeedStatus(snapshot.status); - const canApplyLiveTorrentOptions = snapshot.isTorrent === true - && isLiveTorrentControlStatus(snapshot.status); - if (!isEditableStatus(snapshot.status) - && !((activeTab === 'transfer' && canApplyLiveNormalSpeed) - || (activeTab === 'options' && canApplyLiveTorrentOptions))) { - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - setErrorMessage(t($ => $.properties.editingUnavailable)); - return; - } - const patch: PropertiesPatch = {}; - if (activeTab === 'overview') { - if (fileName !== snapshot.fileName) patch.fileName = fileName; - const nextDestination = destination.trim() ? destination : undefined; - const currentDestination = snapshot.destination?.trim() ? snapshot.destination : undefined; - if (nextDestination !== currentDestination) patch.destination = encodePropertiesPatchValue(nextDestination); - if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) { - patch.connections = Number(connections); - } - } else if (activeTab === 'files' && isTorrent) { - const nextSelectedFiles = selectedFiles - ?? fileProgress?.files.filter(file => file.selected).map(file => file.index) - ?? []; - if (!isTorrentFileSelectionEditable(snapshot.status)) { - setErrorMessage(t($ => $.properties.editingUnavailable)); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - return; - } - if (nextSelectedFiles.length === 0) { - setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - return; - } - await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles }); - return; - } else if (activeTab === 'trackers') { - const nextTrackers = trackers.trim(); - const currentTrackers = (snapshot.torrentTrackers ?? '').trim(); - if (nextTrackers !== currentTrackers) patch.torrentTrackers = encodePropertiesPatchValue(nextTrackers || undefined); - const nextExcludedTrackers = excludedTrackers.trim(); - const currentExcludedTrackers = (snapshot.torrentExcludeTrackers ?? '').trim(); - if (nextExcludedTrackers !== currentExcludedTrackers) patch.torrentExcludeTrackers = encodePropertiesPatchValue(nextExcludedTrackers || undefined); - if (trackerConnectTimeout !== String(snapshot.torrentTrackerConnectTimeout ?? '')) { - patch.torrentTrackerConnectTimeout = encodePropertiesPatchValue(trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined); - } - if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) { - patch.torrentTrackerTimeout = encodePropertiesPatchValue(trackerTimeout.trim() ? Number(trackerTimeout) : undefined); - } - if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) { - patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined); - } - } else if (activeTab === 'options' || activeTab === 'transfer') { - const canApplyLiveTorrentSpeed = !isTorrent || isLiveNormalSpeedStatus(snapshot.status); - if (downloadLimit !== (snapshot.speedLimit ?? '') && canApplyLiveTorrentSpeed) { - patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined); - } - if (isEditableStatus(snapshot.status) && activeTab === 'transfer' && !isTorrent && connections.trim()) { - const nextConnections = Number(connections); - if (nextConnections !== snapshot.connections) patch.connections = nextConnections; - } - if (isTorrent) { - if (isEditableStatus(snapshot.status) && removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) { - setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - return; - } - if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = encodePropertiesPatchValue(uploadLimit.trim() ? uploadLimit : undefined); - if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) { - patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined); - } - if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined); - if (isEditableStatus(snapshot.status)) { - if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { - patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined); - } - if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { - patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined); - } - if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity; - if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile; - if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) { - patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined); - } - if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined); - const nextEncryptionPolicy = encryptionPolicy || undefined; - if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy); - const nextFileAllocation = fileAllocation || undefined; - if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation); - } - } - } else if (activeTab === 'advanced') { - if (isSftp && sftpHostKeyMd !== (snapshot.sftpHostKeyMd ?? '')) { - patch.sftpHostKeyMd = encodePropertiesPatchValue(sftpHostKeyMd.trim() || undefined); - } - for (const name of SECRET_NAMES) { - const draft = secretDrafts[name]; - if (!draft.touched) continue; - patch[name] = draft.clear ? { kind: 'clear' } : { kind: 'replace', value: draft.value }; - } - } - await requestAction('apply-properties', patch); - }, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isSftp, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, sftpHostKeyMd, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]); - - const chooseTab = (tab: PropertiesTab) => { - if (tab === activeTab) { - if (pendingTab !== null) setPendingTab(null); - return false; - } - if (isDirty) { - setPendingTab(tab); - return false; - } - setActiveTab(tab); - return true; - }; - - const discardDraft = () => { - if (pendingActionRef.current !== null) return; - const shouldClose = closePrompt; - if (snapshot) hydrateDraft(snapshot); - draftTabRef.current = null; - setDraftTab(null); - if (pendingTab) setActiveTab(pendingTab); - setPendingTab(null); - setClosePrompt(false); - if (shouldClose) void closeCurrentWindow(true); - }; - - const closeWindow = async () => { - if (isDirtyRef.current) { - setClosePrompt(true); - return; - } - await closeCurrentWindow(); - }; - - const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => { - if (!downloadId) return; - if (action === 'move' && !['paused', 'completed', 'failed'].includes(snapshot?.status ?? '')) return; - if (action === 'verify') { - if (pendingTorrentCommand !== null || snapshot?.status === 'moving') return; - await requestAction('verify-torrent'); - return; - } - if (pendingTorrentCommand !== null || snapshot?.status === 'moving') return; - setPendingTorrentCommand(action); - try { - if (action === 'magnet') { - await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId })); - setNotice(t($ => $.properties.torrentMagnetCopied)); - } else if (action === 'export') { - const destinationPath = await save({ defaultPath: `${snapshot?.fileName || 'download'}.torrent` }); - if (destinationPath) { - await invoke('export_torrent_metadata', { id: downloadId, destination: destinationPath }); - setNotice(t($ => $.properties.torrentMetadataExported)); - } - } else if (action === 'move') { - const selected = await open({ directory: true, multiple: false }); - if (selected && typeof selected === 'string' && window.confirm(t($ => $.properties.torrentMoveConfirm))) { - await invoke('move_torrent_data', { id: downloadId, destination: selected, sessionId }); - setNotice(t($ => $.properties.torrentMoveCompleted)); - } - } - } catch (error) { - setErrorMessage(errorText(error)); - } finally { - setPendingTorrentCommand(null); - } - }; - - const cancelTorrentMove = async () => { - if (!downloadId || snapshot?.status !== 'moving' || pendingTorrentCommand === 'cancel') return; - setPendingTorrentCommand('cancel'); - try { - await invoke('cancel_torrent_move_data', { id: downloadId, sessionId }); - setNotice(t($ => $.properties.torrentMoveCancelRequested)); - } catch (error) { - setErrorMessage(errorText(error)); - } finally { - setPendingTorrentCommand(null); - } - }; - - const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current; - const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle); - const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`; - const windowShellStyle = { '--properties-window-control-rail-width': `${windowControlRailWidth}px` } as CSSProperties; - if (!downloadId || !snapshot) { - return ( -
- -
- {t($ => $.downloadTable.properties)} - Firelink -
-
- {errorMessage || t($ => $.app.loading)} -
-
- ); - } - - const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status); - const liveNormalSpeedEnabled = pendingAction === null - && snapshot.isMedia !== true - && snapshot.isTorrent !== true - && isLiveNormalSpeedStatus(snapshot.status); - const liveTorrentOptionsEnabled = pendingAction === null - && snapshot.isTorrent === true - && isLiveTorrentControlStatus(snapshot.status); - const liveTorrentSpeedEnabled = liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status); - const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status); - const torrentMoveAvailable = ['paused', 'completed', 'failed'].includes(snapshot.status); - const progress = getPropertiesProgress(snapshot); - const lifecycleAction = getPropertiesLifecycleAction(snapshot.status); - const footerActions = getPropertiesFooterActions({ - isDirty, - hasUnsavedNavigation: pendingTab !== null || closePrompt, - }); - const isPromptFooter = footerActions.includes('keepEditing'); - const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status); - const waitingForPeers = isTorrentWaitingForPeers({ - isTorrent: snapshot.isTorrent, - status: snapshot.status, - downloadedBytes: snapshot.downloadedBytes, - fraction: snapshot.fraction, - connectedPeers: snapshot.torrentConnectedPeers, - connectedSeeders: snapshot.torrentConnectedSeeders, - }); - const allocationPending = snapshot.isTorrent !== true - && isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status); - const total = snapshot.size || (snapshot.totalBytes === undefined - ? t($ => $.addDownloads.unknownSize) - : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); - const statusLabel = allocationPending - ? t($ => $.downloads.status.allocatingFiles) - : waitingForPeers - ? t($ => $.downloads.status.waitingForPeers) - : t($ => $.downloads.status[snapshot.status]); - const connectionPresentation = getPropertiesConnectionPresentation(snapshot); - const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency' - ? t($ => $.properties.fragmentConcurrency) - : connectionPresentation.labelKey === 'torrentPeersSeeders' - ? t($ => $.properties.torrentPeersSeeders) - : t($ => $.properties.connections); - const connectionControlLabel = snapshot.isTorrent === true - ? t($ => $.properties.torrentConnectedPeers) - : connectionHeaderLabel; - const connectionValue: ReactNode = connectionPresentation.torrentPeerCounts - ? (() => { - const peersValue = formatPropertiesDiagnosticCount( - connectionPresentation.torrentPeerCounts.connectedPeers ?? Number.NaN, - snapshot.appearance.locale, - ); - const seedersValue = formatPropertiesDiagnosticCount( - connectionPresentation.torrentPeerCounts.connectedSeeders ?? Number.NaN, - snapshot.appearance.locale, - ); - return $.properties.torrentConnectedPeerMetric, { - peers: peersValue, - seeders: seedersValue, - })} - > - {peersValue} - - {seedersValue} - ; - })() - : {connectionPresentation.value}; - const peerDetailsNotice = peerDetailsUnavailable - && (snapshot.torrentConnectedPeers ?? 0) > 0; - const queuePlacement = formatPropertiesQueuePlacement( - snapshot.queueName, - snapshot.queuePosition, - position => t($ => $.properties.queuePosition, { position }), - ); - const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`; - const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status); - const lifecycleLabel = snapshot.credentialsRequired === true - ? t($ => $.properties.retryWithoutCredentials) - : lifecycleAction === 'pause' - ? t($ => $.downloads.actions.pause) - : lifecycleAction === 'resume' - ? t($ => $.downloads.actions.resume) - : lifecycleAction === 'retry' - ? t($ => $.downloads.actions.retry) - : t($ => $.downloads.actions.start); - const tabLabel = (tab: PropertiesTab) => { - switch (tab) { - case 'overview': return t($ => $.properties.tabs.overview); - case 'files': return t($ => $.properties.tabs.files); - case 'trackers': return t($ => $.properties.tabs.trackers); - case 'peers': return t($ => $.properties.tabs.peers); - case 'options': return t($ => $.properties.tabs.options); - case 'transfer': return t($ => $.properties.tabs.transfer); - case 'advanced': return t($ => $.properties.tabs.advanced); - } - }; - - return ( -
- -
- {snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink -
-
-
-
-
-

{snapshot.fileName}

- {statusLabel} -
-

{queuePlacement}

-
-
$.actions.continue)}> - {lifecycleAction && } - {isTorrent && <> -
- - - {snapshot.status === 'moving' ? : } -
-
- $.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}> -
- - - {snapshot.status === 'moving' ? : } -
-
- } -
-
-
-
$.properties.progress)} - aria-busy={allocationPending} - aria-valuetext={allocationPending ? statusLabel : undefined} - role="progressbar" - aria-valuemin={allocationPending ? undefined : 0} - aria-valuemax={allocationPending ? undefined : 100} - aria-valuenow={allocationPending ? undefined : Math.round(progress * 100)} - > -
-
- {progressPercent} -
-
-
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
-
{t($ => $.properties.speed)}{allocationPending ? '—' : snapshot.speed || '—'}
-
{t($ => $.properties.eta)}{allocationPending ? '—' : snapshot.eta || '—'}
- {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} - {isTorrent && <> -
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
-
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
- } -
-
{snapshot.destination || '—'}
-
- -
- {tabLabel(activeTab)} - - -
- -
- {activeTab === 'overview' &&
-
- - -
- {!identityEditingEnabled &&

{t($ => $.properties.identityReadOnly)}

} -
-
-
- {t($ => $.properties.url)} - {urlCanExpand && } -
-

{snapshot.url}

-
-
{t($ => $.properties.category)}

{snapshot.category}

-
-
- {t($ => $.properties.dateAdded)}{snapshot.dateAdded || '—'} - {t($ => $.properties.lastTry)}{snapshot.lastTry || '—'} - {t($ => $.properties.queueId)}{queuePlacement} - {t($ => $.properties.resumable)}{snapshot.resumable === false ? '—' : '✓'} - {snapshot.lastError && <> - {t($ => $.properties.lastError)} -
- {snapshot.lastErrorKind === 'nameResolution' && ( -

- {snapshot.status === 'retrying' - ? snapshot.lastResolverFallback === true - ? t($ => $.downloads.errors.nameResolutionRetrying) - : t($ => $.downloads.status.retrying) - : t($ => $.downloads.errors.nameResolutionFailed)} -

- )} - {snapshot.lastError} -
- } -
- {snapshot.isMedia === true &&
- {t($ => $.addDownloads.format)}{snapshot.mediaFormatSelector || '—'} - {t($ => $.addDownloads.quality)}{snapshot.mediaQuality || '—'} - {t($ => $.properties.fragmentConcurrency)}{snapshot.connections ?? '—'} -
} - {isTorrent && details &&
- {t($ => $.properties.torrentDetailsDisplayName)}{details.displayName || '—'} - {t($ => $.properties.torrentDetailsInfoHash)}{details.infoHash} - {t($ => $.properties.torrentDetailsSize)}{formatDownloadBytes(details.totalBytes)} - {t($ => $.properties.torrentDetailsFiles)}{details.fileCount} - {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} - {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)} - {t($ => $.properties.torrentDetailsCreated)}{details.creationDate || '—'} - {t($ => $.properties.torrentDetailsCreator)}{details.creator || '—'} - {t($ => $.properties.torrentDetailsComment)}{details.comment || '—'} -
} - {isTorrent &&
} -
} - - {activeTab === 'files' && isTorrent &&
-
-
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} />{file.index}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
- {diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

{t($ => $.properties.torrentFileProgressLoading)}

} - {diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError &&

{t($ => $.properties.torrentFileProgressUnavailable)}

} - {diagnosticError &&

{diagnosticError}

} -
} - - {activeTab === 'trackers' && isTorrent &&
-