From d21649c60ea91c0fbdc3c0766b0308459eacacd3 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Thu, 27 Aug 2026 18:25:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B0=83=E6=95=B4=E3=80=81BT?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=EF=BC=88=E6=9C=89bug=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src-tauri/Cargo.lock | 1379 ++++++++++++++++++- src-tauri/Cargo.toml | 18 +- src-tauri/capabilities/download-window.json | 26 + src-tauri/src/constants.rs | 2 +- src-tauri/src/download_engine/commands.rs | 55 +- src-tauri/src/download_engine/engine.rs | 964 ++++++++++++- src-tauri/src/download_engine/http_dl.rs | 57 +- src-tauri/src/download_engine/mod.rs | 5 +- src-tauri/src/download_engine/server.rs | 13 +- src-tauri/src/download_engine/task.rs | 57 +- src-tauri/src/download_engine/torrent.rs | 469 +++++++ src-tauri/src/lib.rs | 18 +- src-tauri/src/mihomo_manager/commands.rs | 10 +- src-tauri/src/mihomo_manager/mod.rs | 81 +- src-tauri/src/mihomo_manager/types.rs | 16 + src-tauri/src/osd_window.rs | 8 +- src-tauri/tauri.conf.json | 2 +- src/App.vue | 66 +- src/components/layout/ModuleContainer.vue | 2 +- src/components/ui/input/Input.vue | 10 + src/lib/bindings.ts | 85 +- src/lib/constants.ts | 6 +- src/main.ts | 10 +- src/modules/downloader/DownloadWindow.vue | 495 +++++++ src/modules/downloader/DownloaderModule.vue | 480 ++++++- src/modules/monitor/MonitorModule.vue | 43 + src/modules/monitor/OsdWindow.vue | 20 + src/modules/proxy/ProxyModule.vue | 360 ++++- src/modules/tray/TrayMenu.vue | 20 +- src/stores/downloaderStore.ts | 89 +- src/stores/monitorStore.ts | 232 +++- src/stores/proxyStore.ts | 73 +- vite.config.ts | 16 +- 34 files changed, 4991 insertions(+), 198 deletions(-) create mode 100644 src-tauri/capabilities/download-window.json create mode 100644 src-tauri/src/download_engine/torrent.rs create mode 100644 src/modules/downloader/DownloadWindow.vue diff --git a/package.json b/package.json index 9387469..511cfcf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "thing", "private": true, - "version": "26.8.2", + "version": "26.8.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e139eaf..eef8eca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -22,7 +22,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -61,6 +61,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -85,6 +91,30 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "assert_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e2651f366b7ee3f97729fded1441539b49d5f39eeb05b842689e11e84501b2" +dependencies = [ + "const_panic", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -109,6 +139,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -199,6 +241,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-task" version = "4.7.1" @@ -239,6 +303,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -262,6 +335,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -269,7 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.5", "bytes", "futures-util", "http", @@ -278,7 +374,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -296,6 +392,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -317,6 +442,61 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum 0.8.9", + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base64" version = "0.21.7" @@ -329,6 +509,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -359,13 +545,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -420,6 +618,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -584,6 +793,34 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chardetng" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.45" @@ -608,6 +845,15 @@ dependencies = [ "inout", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -618,6 +864,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "commoncrypto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d056a8586ba25a1e4d61cb090900e495952c7886786fc55f909ab2f819b69007" +dependencies = [ + "commoncrypto-sys", +] + +[[package]] +name = "commoncrypto-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fed34f46747aa73dfaa578069fd8279d2818ade2b55f38f22a9401c7f4083e2" +dependencies = [ + "libc", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -627,6 +908,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -702,6 +992,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -766,10 +1065,22 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] +[[package]] +name = "crypto-hash" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a77162240fd97248d19a564a565eb563a3f592b386e4136fb300909e67dddca" +dependencies = [ + "commoncrypto", + "hex 0.3.2", + "openssl", + "winapi", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -843,6 +1154,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -912,6 +1244,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs" version = "4.0.0" @@ -1035,6 +1376,18 @@ dependencies = [ "tendril", ] +[[package]] +name = "dontfrag" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117949f5a8b25ba471b4dfea927a07a2f8730c585532a2eb4294e18f5155397" +dependencies = [ + "cfg-if", + "libc", + "tokio", + "windows-sys 0.52.0", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1322,6 +1675,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1331,6 +1690,27 @@ dependencies = [ "libc", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1338,6 +1718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1399,12 +1780,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1514,6 +1902,15 @@ dependencies = [ "x11", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1541,8 +1938,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1566,8 +1965,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1673,6 +2075,18 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1684,6 +2098,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.5", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1770,6 +2207,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1803,6 +2251,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" + [[package]] name = "hex" version = "0.4.3" @@ -2165,7 +2619,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "intervaltree" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270bc34e57047cab801a8c871c124d9dc7132f6473c6401f645524f4e6edd111" +dependencies = [ + "smallvec", ] [[package]] @@ -2193,6 +2656,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2238,6 +2710,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2340,6 +2842,23 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leaky-bucket" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a396bb213c2d09ed6c5495fd082c991b6ab39c9daf4fff59e6727f85c73e4c5" +dependencies = [ + "parking_lot", + "pin-project-lite", + "tokio", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2398,6 +2917,311 @@ dependencies = [ "libc", ] +[[package]] +name = "librqbit" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4349c0f35ede576a87cc2881dc6eb286ff7a1c8fc4d1f7fef95b5c5a530a7174" +dependencies = [ + "anyhow", + "arc-swap", + "async-compression", + "async-stream", + "async-trait", + "axum-extra", + "backon", + "base64 0.23.1", + "bitvec", + "byteorder", + "bytes", + "dashmap", + "futures", + "governor", + "hex 0.4.3", + "http", + "intervaltree", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "librqbit-dht", + "librqbit-dualstack-sockets", + "librqbit-lsd", + "librqbit-peer-protocol", + "librqbit-sha1-wrapper", + "librqbit-tracker-comms", + "librqbit-upnp", + "librqbit-utp", + "memmap2", + "mime_guess", + "nix", + "parking_lot", + "rand 0.10.2", + "regex", + "reqwest 0.13.4", + "rlimit", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "serde_with", + "size_format", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-socks", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "uuid", + "walkdir", + "windows 0.62.2", +] + +[[package]] +name = "librqbit-bencode" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be6410a7434e6c2b148931b6d109b57939cfed04cefa86451197c5f5198edf91" +dependencies = [ + "anyhow", + "arrayvec", + "atoi", + "bytes", + "librqbit-buffers", + "librqbit-clone-to-owned", + "serde", + "serde_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "librqbit-buffers" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00e4b6780a30d3c204f778ad64b3c917b842f5c54df38455c361761e7d12ce2" +dependencies = [ + "bytes", + "librqbit-clone-to-owned", + "serde", + "serde_derive", +] + +[[package]] +name = "librqbit-clone-to-owned" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929ff2b22b1596c3f3689a79c0b84ee8a01a650a2dd0b6eb7322d6a3b9be0e0e" +dependencies = [ + "bytes", +] + +[[package]] +name = "librqbit-core" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c33158d5c47ac68771dcf7cca13eb2fa2ee2980efbb6bb5645273e2f08c69b" +dependencies = [ + "anyhow", + "bytes", + "chardetng", + "data-encoding", + "directories", + "encoding_rs", + "hex 0.4.3", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-sha1-wrapper", + "memchr", + "parking_lot", + "rand 0.10.2", + "serde", + "serde_derive", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "librqbit-dht" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f94f543c16de3088f8aa84175203d65437c8886dc24d383941f408dc26488" +dependencies = [ + "anyhow", + "backon", + "bytes", + "chrono", + "dashmap", + "futures", + "indexmap 2.14.0", + "leaky-bucket", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "librqbit-dualstack-sockets", + "parking_lot", + "rand 0.10.2", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "librqbit-dualstack-sockets" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7189f1ff66ca75055137a38b062ba7df9691d071b7172e5090997c62eb4b4de" +dependencies = [ + "axum 0.8.9", + "backon", + "futures", + "libc", + "network-interface", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "librqbit-lsd" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1d2699a46a9dc83693c5fbbf288a17b77a24b9924815a69bfc97853a47af42" +dependencies = [ + "anyhow", + "atoi", + "bstr", + "futures", + "httparse", + "librqbit-core", + "librqbit-dualstack-sockets", + "parking_lot", + "rand 0.10.2", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "librqbit-peer-protocol" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e4b11349070274a1f77fb432d5e764ed38b310e7cdae8d2c37e0568ceb950e" +dependencies = [ + "anyhow", + "bitvec", + "byteorder", + "bytes", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "serde", + "serde_derive", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "librqbit-sha1-wrapper" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf23eeda5a8999809c0be52e36b711d062f1d52e2f3cae42d71b7b012a466c7" +dependencies = [ + "assert_cfg", + "crypto-hash", +] + +[[package]] +name = "librqbit-tracker-comms" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b561cb29abec85eee031c998f9e0c987f82d3e193b087f4d2bdc8f00d825796c" +dependencies = [ + "anyhow", + "async-stream", + "backon", + "byteorder", + "futures", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-core", + "librqbit-dualstack-sockets", + "parking_lot", + "rand 0.10.2", + "reqwest 0.13.4", + "serde", + "serde_derive", + "serde_with", + "tokio", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "librqbit-upnp" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b950b4dfe2102d3047f3fac70763c494b63a50f04a00d3dd36102d740e9bf33" +dependencies = [ + "anyhow", + "bstr", + "futures", + "httparse", + "librqbit-dualstack-sockets", + "network-interface", + "quick-xml", + "reqwest 0.13.4", + "serde", + "serde_derive", + "socket2", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "librqbit-utp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3bfdc73944bc76cab24d5690a98816770040a654c449edf5ff2b9ba22626aa" +dependencies = [ + "bitvec", + "dontfrag", + "lazy_static", + "libc", + "librqbit-dualstack-sockets", + "metrics", + "parking_lot", + "rand 0.9.5", + "ringbuf", + "rustc-hash", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2436,6 +3260,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -2488,12 +3318,27 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2503,12 +3348,32 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2614,12 +3479,42 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "network-interface" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.18", + "winapi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "notify" version = "6.1.1" @@ -2662,12 +3557,65 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3176,6 +4124,21 @@ 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.5" @@ -3274,6 +4237,21 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -3281,6 +4259,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -3304,6 +4340,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.9.5" @@ -3311,7 +4353,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3321,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3333,6 +4386,39 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3496,15 +4582,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3554,6 +4646,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3602,6 +4714,7 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -3609,21 +4722,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3821,6 +4975,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -3895,7 +5062,7 @@ dependencies = [ "base64 0.22.1", "bs58", "chrono", - "hex", + "hex 0.4.3", "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", @@ -3969,7 +5136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3980,7 +5147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4006,12 +5173,38 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "size_format" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed5f6ab2122c6dec69dca18c72fa4590a27e581ad20d44960fe74c032a0b23b" +dependencies = [ + "generic-array 0.12.4", + "num", +] + [[package]] name = "slab" version = "0.4.12" @@ -4134,6 +5327,15 @@ dependencies = [ "specta", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4294,7 +5496,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4327,6 +5529,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4350,7 +5558,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4542,7 +5750,7 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ "log", "notify-rust", - "rand", + "rand 0.9.5", "serde", "serde_json", "serde_repr", @@ -4601,7 +5809,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4624,7 +5832,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4743,14 +5951,16 @@ dependencies = [ [[package]] name = "thing" -version = "26.8.2" +version = "26.8.3" dependencies = [ - "axum", + "axum 0.7.9", "base64 0.22.1", + "bytes", "chrono", "dirs 5.0.1", "futures-util", "image", + "librqbit", "notify", "raw-window-handle", "regex", @@ -4921,6 +6131,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5172,6 +6406,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5224,6 +6464,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5261,6 +6507,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5454,6 +6706,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -5510,6 +6772,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5618,11 +6889,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5634,6 +6917,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5689,7 +6981,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5758,6 +7061,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5940,6 +7253,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -6212,7 +7534,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -6238,6 +7560,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -6333,7 +7664,7 @@ dependencies = [ "event-listener", "futures-core", "futures-lite", - "hex", + "hex 0.4.3", "libc", "ordered-stream", "rustix", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5e7a998..9e8aa12 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thing" -version = "26.8.2" +version = "26.8.3" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -45,6 +45,8 @@ base64 = "0.22" image = { version = "0.25", default-features = false, features = ["png"] } walkdir = "2" notify = { version = "6", features = [] } +librqbit = "9" +bytes = "1" [target.'cfg(windows)'.dependencies] winreg = "0.52" @@ -67,7 +69,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Storage_Xps", "Win32_Storage_FileSystem", ] } -# Explorer 蜑榊床逶ョ蠖墓」€豬具シ・ShellWindows COM・会シ壻サ・シ募・逕ィ蛻ー逧・feature・梧而蛻カ郛冶ッ台ス鍋ァッ +# Explorer 陷第ヲ雁コ企€カ・ョ陟門「難ス」ツ€雎ャ蜈キ・シ繝サShellWindows COM繝サ莨夲スシ螢サ・サ繝サ・シ蜍溘・騾包スィ陋サ・ー騾ァ繝サfeature繝サ譴ァ閠瑚崕・カ驛帛・・ッ蜿ー・ス骰具スァ・ッ windows = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Com", @@ -80,3 +82,15 @@ windows = { version = "0.52", features = [ [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" +# ===== 构建性能 ===== +# dev 增量编译加速:仅保留行号级调试信息(panic 仍显示 文件:行号), +# 显著减少本 crate 的代码生成量与链接体积,加快 `tauri dev` 的 Rust 重编。 +[profile.dev] +incremental = true +debug = "line-tables-only" + +# release 构建瘦身:去符号 + LTO,减小安装包体积 +[profile.release] +strip = true +lto = true + diff --git a/src-tauri/capabilities/download-window.json b/src-tauri/capabilities/download-window.json new file mode 100644 index 0000000..389f84f --- /dev/null +++ b/src-tauri/capabilities/download-window.json @@ -0,0 +1,26 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "download-window", + "description": "Capability for the per-download one-time window", + "windows": ["download-window-*"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-minimize", + "core:window:allow-unminimize", + "core:window:allow-set-title", + "core:window:allow-set-size", + "core:window:allow-start-dragging", + "core:window:allow-set-always-on-top", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-theme", + "core:window:allow-set-effects", + "core:window:allow-set-background-color", + "core:window:allow-close", + "core:event:allow-emit", + "core:event:allow-listen", + "snap-layout:default" + ] +} \ No newline at end of file diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 291e80c..6a782c2 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -60,6 +60,6 @@ pub mod events { pub const DOWNLOAD_ADDED: &str = "download-added"; /// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表) pub const DOWNLOAD_REMOVED: &str = "download-removed"; - /// 浏览器扩展通过 HTTP API 新增下载(前端需置前主窗口并跳到下载画面) + /// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口) pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added"; } diff --git a/src-tauri/src/download_engine/commands.rs b/src-tauri/src/download_engine/commands.rs index 9ae2794..3595836 100644 --- a/src-tauri/src/download_engine/commands.rs +++ b/src-tauri/src/download_engine/commands.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; use super::engine::{CheckUrlResult, DownloadEngine}; use super::task::{DownloadTask, DownloaderSettings}; +use super::torrent::TorrentInfo; /// 获取所有任务 #[tauri::command] @@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec, input: String) -> Result { + engine.inspect(&input).await +} + +/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载 +#[tauri::command] +#[specta::specta] +pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec) -> Result<(), String> { + engine.select_bt_files(&id, only_files).await +} + /// 检查 URL 重复性并探测文件信息(添加下载前调用) #[tauri::command] #[specta::specta] @@ -57,8 +72,9 @@ pub async fn downloader_add_task( dir: Option, headers: Option>, auto_rename: Option, + only_files: Option>, ) -> Result { - engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await + engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await } /// 暂停任务 @@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) -> engine.resume_task(&id) } +/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录) +#[tauri::command] +#[specta::specta] +pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> { + engine.cancel_task(&id) +} + +/// 重新下载已取消/出错的任务 +#[tauri::command] +#[specta::specta] +pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> { + engine.redownload(&id).await +} + /// 移除任务 #[tauri::command] #[specta::specta] @@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> { .open_url(url, None::<&str>) .map_err(|e| format!("打开链接失败: {}", e)) } + +/// 将指定 label 的下载窗口显示并强制置为前台。 +/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、 +/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop +/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。 +#[tauri::command] +#[specta::specta] +pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> { + let Some(window) = app.get_webview_window(&label) else { + return Ok(()); // 窗口已关闭则忽略 + }; + window.show().map_err(|e| e.to_string())?; + window.unminimize().map_err(|e| e.to_string())?; + match window.hwnd() { + Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize), + Err(_) => { + window.set_focus().ok(); + } + } + Ok(()) +} diff --git a/src-tauri/src/download_engine/engine.rs b/src-tauri/src/download_engine/engine.rs index a83303f..e649b7a 100644 --- a/src-tauri/src/download_engine/engine.rs +++ b/src-tauri/src/download_engine/engine.rs @@ -4,12 +4,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use tauri::{AppHandle, Emitter}; +use librqbit::ManagedTorrent; +use tauri::{AppHandle, Emitter, Manager}; use super::http_dl::{HttpDownloader, split_segments}; use super::rate_limit::RateLimiter; use super::storage::{EngineState, Storage}; -use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskStatus}; +use super::task::{DownloadTask, DownloaderSettings, ProbeResult, Segment, TaskProtocol, TaskStatus}; +use super::torrent::{TorrentDownloader, hex_encode}; use specta::Type; /// 重复类型 @@ -84,10 +86,12 @@ struct TaskHandle { /// 旧代际任务完成时不删除新任务句柄、不覆盖新任务状态(防 pause→resume 竞态) gen: u64, cancel: Arc, - /// 每个分段的已下载字节(与 segments 一一对应) + /// 每个分段的已下载字节(与 segments 一一对应;BT 任务为空) progress: Vec>, /// 下载任务的 JoinHandle(None = 已完成/已取消) join: Mutex>>, + /// BT 任务的 librqbit 句柄(协议=BitTorrent 时存在;add_task 时创建) + bt: Option>, } /// 下载引擎(进程内运行,通过 Arc 共享状态) @@ -109,6 +113,8 @@ struct EngineInner { global_limiter: Arc, /// HTTP 下载器 http: HttpDownloader, + /// BitTorrent 下载器(封装 librqbit 全局会话) + torrent: TorrentDownloader, /// 下载是否使用代理(true=尊重系统代理,false=强制直连) /// 独立于 settings 存储,避免下载过程中反复锁 settings;save_settings 时同步更新 use_proxy: AtomicBool, @@ -125,6 +131,8 @@ struct EngineInner { impl DownloadEngine { /// 创建引擎并从磁盘恢复状态 pub fn new(data_dir: PathBuf, app_handle: AppHandle) -> Self { + // tracker 同步目录与引擎存储同目录(Storage::new 会移动 data_dir,先 clone) + let tracker_data_dir = data_dir.clone(); let storage = Storage::new(data_dir); let state = storage.load(); @@ -151,6 +159,12 @@ impl DownloadEngine { let global_limiter = Arc::new(RateLimiter::new(global_limit)); // 在 settings 移入 Mutex 前读取代理开关 let use_proxy = settings.use_proxy; + // BT 会话默认目录(下载目录,每个任务用 output_folder 覆盖) + // 解析 BT 代理地址(若开启且代理模块可用) + let bt_socks = bt_proxy_addr(&app_handle, settings.bt_use_proxy); + let torrent = TorrentDownloader::new(PathBuf::from(&settings.download_dir)); + // 同步 BT 专属设置(上传限速 / 监听端口 / 代理) + torrent.set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks); let engine = Self { inner: Arc::new(EngineInner { @@ -160,6 +174,7 @@ impl DownloadEngine { settings: Mutex::new(settings), global_limiter, http: HttpDownloader::new(), + torrent, use_proxy: AtomicBool::new(use_proxy), app_handle, started: AtomicBool::new(false), @@ -170,6 +185,16 @@ impl DownloadEngine { // 启动后台持久化定时器 engine.start_save_timer(); + + // 后台:每日同步动态 tracker(打开软件检测上次同步日期,非今天则更新) + { + let torrent = engine.inner.torrent.clone(); + let tr_dir = tracker_data_dir; + tauri::async_runtime::spawn(async move { + torrent.sync_dynamic_trackers(&tr_dir).await; + }); + } + engine.inner.started.store(true, Ordering::SeqCst); engine } @@ -196,6 +221,11 @@ impl DownloadEngine { dir: Option<&str>, headers: &HashMap, ) -> (Result, DuplicateKind, Option) { + // 磁力链 / 本地 .torrent → BitTorrent 检查(按 infohash 去重) + if detect_protocol(url) == TaskProtocol::BitTorrent { + return self.check_bt_url(url, dir).await; + } + let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); let probe = self.inner.http.probe(url, headers, use_proxy).await; let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone(); @@ -258,6 +288,97 @@ impl DownloadEngine { (probe, duplicate, existing) } + /// BitTorrent 链接检查:按 infohash 去重,磁力链立即返回不阻塞等待元数据。 + /// librqbit 的 add_torrent 会阻塞等待元数据解析,对冷门种子可能超时。 + /// 因此磁力链跳过 inspect,直接返回占位信息,元数据由 add_bt_task 后台异步解析。 + async fn check_bt_url( + &self, + url: &str, + dir: Option<&str>, + ) -> (Result, DuplicateKind, Option) { + let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let task_dir = dir.map(|d| d.to_string()).unwrap_or_else(|| settings.download_dir.clone()); + + // 磁力链:提取 infohash 做去重,不阻塞等待元数据 + if url.trim().to_ascii_lowercase().starts_with("magnet:") { + let placeholder = bt_placeholder_name(url); + // 从磁力链提取 infohash(用于去重) + let info_hash = extract_magnet_hash(url); + let mut duplicate = DuplicateKind::None; + let mut existing: Option = None; + { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + for t in tasks.values() { + if let Some(ref hash) = info_hash { + if t.info_hash.as_deref() == Some(hash.as_str()) { + duplicate = DuplicateKind::Url; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + } + if t.filename == placeholder && t.dir == task_dir { + duplicate = DuplicateKind::Filename; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + } + } + // 立即返回占位信息,不等待元数据解析 + let probe = Ok(ProbeResult { + total_size: None, + supports_resume: true, + filename: Some(placeholder), + }); + return (probe, duplicate, existing); + } + + // .torrent 文件:可以立即解析元数据 + match self.inner.torrent.inspect(url).await { + Ok(info) => { + let mut duplicate = DuplicateKind::None; + let mut existing: Option = None; + { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + for t in tasks.values() { + if t.info_hash.as_deref() == Some(info.info_hash.as_str()) { + duplicate = DuplicateKind::Url; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + if t.filename == info.name && t.dir == task_dir { + duplicate = DuplicateKind::Filename; + existing = Some(ExistingTaskInfo { + id: t.id.clone(), + filename: t.filename.clone(), + status: t.status.clone(), + }); + break; + } + } + } + let probe = Ok(ProbeResult { + total_size: Some(info.total_size), + supports_resume: true, + filename: Some(info.name), + }); + (probe, duplicate, existing) + } + Err(e) => (Err(e), DuplicateKind::None, None), + } + } + /// 生成不冲突的文件名(同名时追加 (1)、(2)...) /// 冲突来源:磁盘已有同名最终文件、已存在同名临时文件(其他任务正在下载该名)、 /// 以及任务列表中已有任务占用同名(即使最终文件尚未落盘,避免共用同一 .thingdl 临时文件) @@ -303,6 +424,7 @@ impl DownloadEngine { /// 添加下载任务 /// auto_rename: 同名时自动重命名(追加 (1)、(2)...),否则覆盖 + /// only_files: BitTorrent 任务指定要下载的种子文件索引(None=全部文件) pub async fn add_task( &self, url: String, @@ -310,7 +432,13 @@ impl DownloadEngine { dir: Option, headers: HashMap, auto_rename: bool, + only_files: Option>, ) -> Result { + // 磁力链 / 本地 .torrent → BitTorrent 流程(独立处理) + if detect_protocol(&url) == TaskProtocol::BitTorrent { + return self.add_bt_task(url, dir, only_files).await; + } + // 探测资源信息 let use_proxy = self.inner.use_proxy.load(Ordering::SeqCst); let probe = self.inner.http.probe(&url, &headers, use_proxy).await; @@ -360,6 +488,10 @@ impl DownloadEngine { url: url.clone(), filename: task_filename, dir: task_dir, + protocol: TaskProtocol::Http, + info_hash: None, + bt_files: Vec::new(), + bt_metadata_ready: false, status: TaskStatus::Queued, total_size, completed_size: 0, @@ -402,8 +534,254 @@ impl DownloadEngine { Ok(id) } + /// 添加 BitTorrent 任务(磁力链 / 本地 .torrent)— 全异步添加。 + /// 立即创建占位任务并**立即返回** id(不阻塞);加入会话与元数据解析全在后台进行。 + /// 注意:librqbit 的 add_torrent 对磁力会在元数据就绪前阻塞返回,因此绝不能在此 await。 + async fn add_bt_task(&self, url: String, dir: Option, _only_files: Option>) -> Result { + let settings = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let task_dir = dir.unwrap_or_else(|| settings.download_dir.clone()); + + let id = self.inner.storage.next_task_id(); + let placeholder = bt_placeholder_name(&url); + + // 1. 立即创建占位任务(元数据未就绪,bt_metadata_ready=false → 调度器跳过) + let task = DownloadTask { + id: id.clone(), + url: url.clone(), + filename: placeholder, + dir: task_dir.clone(), + protocol: TaskProtocol::BitTorrent, + info_hash: None, + bt_files: Vec::new(), + bt_metadata_ready: false, + status: TaskStatus::Queued, + total_size: 0, + completed_size: 0, + speed: 0, + supports_resume: true, + segments: Vec::new(), + error: None, + created_at: chrono::Utc::now().timestamp_millis(), + headers: HashMap::new(), + }; + + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.insert(id.clone(), task); + } + self.persist_now(); + + // 通知前端有新任务加入(任务已在列表中) + let _ = self.inner.app_handle.emit(crate::constants::events::DOWNLOAD_ADDED, serde_json::json!({ "id": id })); + + // 2. 后台执行:加入会话(可能等待元数据)→ 解析元数据 → 就绪通知 / 失败置 Error + let engine = self.clone(); + let id_for_task = id.clone(); + tauri::async_runtime::spawn(async move { + engine.bt_resolve(id_for_task, url, task_dir).await; + }); + + // 立即返回,命令不等待任何网络操作 + Ok(id) + } + + /// BT 后台解析流程(独立 task 执行,不阻塞任何命令) + /// add_torrent 对磁力会等待元数据;后续 wait_initialized 亦可能等待,均带超时兜底。 + async fn bt_resolve(&self, id: String, url: String, task_dir: String) { + // 若任务在添加过程中已被取消/删除,不再执行 + let alive = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.get(&id).map(|t| t.status != TaskStatus::Cancelled).unwrap_or(false) + }; + if !alive { + return; + } + + // 加入会话并取得句柄(磁力元数据获取带超时) + let added = tokio::time::timeout( + std::time::Duration::from_secs(crate::download_engine::torrent::INSPECT_TIMEOUT_SECS), + self.inner.torrent.add_async(&url, &task_dir), + ).await; + let (_, handle) = match added { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + Self::fail_bt_resolve(self, &id, e); + return; + } + Err(_) => { + Self::fail_bt_resolve(self, &id, "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息".to_string()); + return; + } + }; + + // 存入句柄(供调度 / 暂停 / 删除使用) + { + let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(h) = handles.get_mut(&id) { + h.bt = Some(handle.clone()); + } else { + handles.insert( + id.clone(), + TaskHandle { + gen: self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1, + cancel: Arc::new(AtomicBool::new(false)), + progress: Vec::new(), + join: Mutex::new(None), + bt: Some(handle.clone()), + }, + ); + } + } + + // 等待元数据就绪 + if let Err(e) = self.inner.torrent.wait_initialized(&handle).await { + Self::fail_bt_resolve(self, &id, e); + return; + } + // 元数据已在会话缓存,重新解析结构化信息(此时秒回) + match self.inner.torrent.inspect(&url).await { + Ok(info) => { + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(&id) { + t.filename = info.name.clone(); + t.info_hash = Some(info.info_hash.clone()); + t.bt_files = info.files.clone(); + t.total_size = info.total_size; + t.bt_metadata_ready = true; + t.error = None; + } + } + self.persist_now(); + // 通知前端弹文件勾选对话框 + let _ = self.inner.app_handle.emit( + "download-inspect-ready", + serde_json::json!({ "id": id }), + ); + } + Err(e) => Self::fail_bt_resolve(self, &id, e), + } + } + + /// BT 元数据解析失败:任务置为 Error 并通知前端刷新 + fn fail_bt_resolve(engine: &DownloadEngine, id: &str, error: String) { + { + let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(id) { + t.status = TaskStatus::Error; + t.error = Some(error); + t.speed = 0; + } + } + engine.persist_now(); + // 复用 complete 事件通道让前端刷新错误状态 + let task = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned(); + if let Some(task) = task { + let _ = engine.inner.app_handle.emit( + "download-complete", + CompletePayload { + id: id.to_string(), + filename: task.filename.clone(), + status: task.status.clone(), + error: task.error.clone(), + }, + ); + } + } + + /// select_bt_files 命令:用户勾选文件后设置 only_files 并开始下载 + pub async fn select_bt_files(&self, id: &str, only_files: Vec) -> Result<(), String> { + let handle = { + let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.get(id).and_then(|h| h.bt.clone()) + }; + let Some(handle) = handle else { + return Err("任务句柄不存在".to_string()); + }; + // 空选择兜底:前端异常/竞态导致只传了空数组时,默认全选。 + // 否则 set_only_files(空) 会让 librqbit 认为"无需下载任何文件",wait_until_completed 立即返回, + // 任务被秒判为"已完成 0%"。 + let only_files = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + match tasks.get(id) { + Some(t) if only_files.is_empty() && !t.bt_files.is_empty() => { + t.bt_files.iter().map(|f| f.index).collect() + } + _ => only_files, + } + }; + // 应用勾选(全选也要 set,确保覆盖 add 时的默认全选) + self.inner.torrent.set_only_files(&handle, &only_files).await?; + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(id) { + t.bt_metadata_ready = true; + t.error = None; + // 总大小改为勾选文件之和 + t.total_size = only_files + .iter() + .filter_map(|fi| t.bt_files.iter().find(|f| f.index == *fi)) + .map(|f| f.size) + .sum(); + t.status = TaskStatus::Queued; + } else { + return Err("任务不存在".to_string()); + } + } + self.persist_now(); + self.schedule(); + Ok(()) + } + + /// inspect 命令入口:解析磁力链 / 本地 .torrent / 远程 .torrent URL,返回种子信息供前端勾选文件 + pub async fn inspect(&self, input: &str) -> Result { + self.inner.torrent.inspect(input).await + } + /// 暂停任务 pub fn pause_task(&self, id: &str) -> Result<(), String> { + // 判断是否为 BT 任务(决定暂停方式) + let is_bt = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.get(id).map(|t| t.protocol == TaskProtocol::BitTorrent).unwrap_or(false) + }; + + if is_bt { + // BT:中止等待 future + 暂停种子(已下载片段保留,可继续) + let bt_handle = { + let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(handle) = handles.get(id) { + if let Ok(mut join) = handle.join.lock() { + if let Some(j) = join.take() { + j.abort(); + } + } + handle.bt.clone() + } else { + None + } + }; + if let Some(bt) = &bt_handle { + if let Err(e) = tauri::async_runtime::block_on(self.inner.torrent.pause(bt)) { + crate::logger::log_error("download", &format!("暂停 BT 种子失败: {}", e)); + } + } + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get_mut(id) else { + return Err("任务不存在".to_string()); + }; + if task.status == TaskStatus::Active || task.status == TaskStatus::Queued { + task.status = TaskStatus::Paused; + task.speed = 0; + } + } + self.persist_now(); + self.schedule(); + self.emit_task_progress(id); + return Ok(()); + } + // 1. 设置取消标志 + 读取进度(锁 handles) let progress_values: Vec = { let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); @@ -436,6 +814,7 @@ impl DownloadEngine { } self.persist_now(); self.schedule(); + self.emit_task_progress(id); Ok(()) } @@ -462,12 +841,59 @@ impl DownloadEngine { } self.persist_now(); self.schedule(); + self.emit_task_progress(id); Ok(()) } - /// 移除任务 - pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> { - // 1. 设置取消标志 + abort join handle(锁 handles) + /// 取消任务:置为 Cancelled、清空进度并删除下载文件,但保留记录(只能再次下载) + pub fn cancel_task(&self, id: &str) -> Result<(), String> { + // 判断是否为 BT 任务 + let is_bt = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.get(id).map(|t| t.protocol == TaskProtocol::BitTorrent).unwrap_or(false) + }; + + if is_bt { + // BT:中止等待 future + 删除种子(连同已下载文件),保留记录 + { + let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(handle) = handles.remove(id) { + handle.cancel.store(true, Ordering::SeqCst); + if let Ok(mut join) = handle.join.lock() { + if let Some(j) = join.take() { + j.abort(); + } + } + } + } + // 从任务表中取 infohash 用于删除 + let info_hash = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.get(id).and_then(|t| t.info_hash.clone()) + }; + if let Some(hash) = info_hash { + // delete_files=true:取消即删除已下载文件(与 HTTP 取消语义一致) + if let Err(e) = tauri::async_runtime::block_on(self.inner.torrent.delete(&hash, true)) { + crate::logger::log_error("download", &format!("取消 BT 种子删除失败: {}", e)); + } + } + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get_mut(id) else { + return Err("任务不存在".to_string()); + }; + task.status = TaskStatus::Cancelled; + task.speed = 0; + task.completed_size = 0; + task.error = None; + } + self.persist_now(); + self.schedule(); + self.emit_task_progress(id); + return Ok(()); + } + + // 1. 中止活跃下载句柄 { let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); if let Some(handle) = handles.remove(id) { @@ -480,13 +906,150 @@ impl DownloadEngine { } } + // 2. 标记已取消、清空进度/错误,并取出待删路径(不在锁内做 IO) + let paths = { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get_mut(id) else { + return Err("任务不存在".to_string()); + }; + task.status = TaskStatus::Cancelled; + task.speed = 0; + task.completed_size = 0; + task.error = None; + for seg in &mut task.segments { + seg.completed = 0; + } + Some((task.file_path(), task.temp_file_path())) + }; + + // 3. 删除下载文件(临时 + 最终),取消后不保留任何文件。 + // abort() 只是请求异步取消,下载 future 里的文件句柄可能尚未释放, + // Windows 上删除被占用文件会失败。用短延时重试保证"取消即删除"可靠生效。 + if let Some((fp, tfp)) = &paths { + for p in [tfp, fp] { + for _ in 0..5 { + if std::fs::remove_file(p).is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + } + + self.persist_now(); + self.schedule(); + self.emit_task_progress(id); + Ok(()) + } + + /// 重新下载已取消/出错的任务:清空进度与文件记录后重新排队 + pub async fn redownload(&self, id: &str) -> Result<(), String> { + let task = { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get_mut(id) else { + return Err("任务不存在".to_string()); + }; + if task.status != TaskStatus::Cancelled && task.status != TaskStatus::Error { + return Err("任务不在可重新下载状态".to_string()); + } + task.completed_size = 0; + task.speed = 0; + task.error = None; + for seg in &mut task.segments { + seg.completed = 0; + } + task.status = TaskStatus::Queued; + task.clone() + }; + + // BT:取消时已从 librqbit 会话删除种子,需重新加入。 + // 用 add_async 而非 start:start 对磁力会阻塞等待元数据(同步命令线程被挂住), + // 此处带与 bt_resolve 一致的超时,超时/失败统一回滚为 Error。 + if task.protocol == TaskProtocol::BitTorrent { + let added = tokio::time::timeout( + std::time::Duration::from_secs(crate::download_engine::torrent::INSPECT_TIMEOUT_SECS), + self.inner.torrent.add_async(&task.url, &task.dir), + ).await; + match added { + Ok(Ok((_, handle))) => { + let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.insert( + id.to_string(), + TaskHandle { + gen: self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1, + cancel: Arc::new(AtomicBool::new(false)), + progress: Vec::new(), + join: Mutex::new(None), + bt: Some(handle), + }, + ); + } + Ok(Err(e)) => { + // 重新加入失败:回滚为 Error + self.mark_bt_redownload_failed(id, e); + } + Err(_) => { + self.mark_bt_redownload_failed( + id, + "重新加入种子超时:未能获取元数据,请确认有做种源或网络可直连 BT".to_string(), + ); + } + } + } + + self.persist_now(); + self.schedule(); + self.emit_task_progress(id); + Ok(()) + } + + /// BT 重新加入会话失败:回滚为 Error 并通知前端 + fn mark_bt_redownload_failed(&self, id: &str, error: String) { + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(t) = tasks.get_mut(id) { + t.status = TaskStatus::Error; + t.error = Some(error.clone()); + } + } + crate::logger::log_error("download", &format!("BT 重新加入会话失败: {}", error)); + self.persist_now(); + self.emit_task_progress(id); + } + + /// 移除任务 + pub fn remove_task(&self, id: &str, delete_files: bool) -> Result<(), String> { + // 1. 设置取消标志 + abort join handle,并取出 BT 句柄(锁 handles) + let bt_handle = { + let mut handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(handle) = handles.remove(id) { + handle.cancel.store(true, Ordering::SeqCst); + if let Ok(mut join) = handle.join.lock() { + if let Some(j) = join.take() { + j.abort(); + } + } + handle.bt + } else { + None + } + }; + // 2. 从任务列表移除(锁 tasks) let task = { let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); tasks.remove(id) }; - // 3. 可选删除文件(临时文件 + 最终文件) + // 3. BT:从 librqbit 会话删除种子(delete_files 决定是否同时删除已下载文件) + if let Some(bt) = &bt_handle { + let hash = hex_encode(&bt.info_hash().0); + if let Err(e) = tauri::async_runtime::block_on(self.inner.torrent.delete(&hash, delete_files)) { + crate::logger::log_error("download", &format!("删除 BT 种子失败: {}", e)); + } + } + + // 4. HTTP:可选删除文件(临时文件 + 最终文件) if delete_files { if let Some(task) = &task { let _ = std::fs::remove_file(task.file_path()); @@ -494,6 +1057,15 @@ impl DownloadEngine { } } + // 5. 通知前端任务已被移除(模块任务列表/专用下载窗口据此刷新去除此条目)。 + // 此前 remove_task 不发射该事件,仅 HTTP 扩展删除路径会发,导致 UI 无法即时刷新。 + if task.is_some() { + let _ = self.inner.app_handle.emit( + crate::constants::events::DOWNLOAD_REMOVED, + serde_json::json!({ "id": id }), + ); + } + self.persist_now(); self.schedule(); Ok(()) @@ -524,6 +1096,12 @@ impl DownloadEngine { .use_proxy .store(settings.use_proxy, Ordering::SeqCst); + // 同步 BT 专属设置(上传限速 / 监听端口 / 代理;监听端口、代理首次创建会话时生效) + let bt_socks = bt_proxy_addr(&self.inner.app_handle, settings.bt_use_proxy); + self.inner + .torrent + .set_settings(settings.bt_upload_limit_kb, settings.bt_listen_port, bt_socks); + { let mut s = self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()); *s = settings; @@ -538,11 +1116,16 @@ impl DownloadEngine { /// 退出时清理:停止所有下载、保存状态 pub fn cleanup_on_exit(&self) { - // 取消所有活跃下载 + // 取消所有活跃下载(HTTP 设 cancel 标志;BT 中止等待 future) { let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); for handle in handles.values() { handle.cancel.store(true, Ordering::SeqCst); + if let Ok(mut join) = handle.join.lock() { + if let Some(j) = join.take() { + j.abort(); + } + } } } @@ -560,6 +1143,9 @@ impl DownloadEngine { // 等待短暂时间让下载任务退出 std::thread::sleep(Duration::from_millis(200)); + // 停止 BitTorrent 会话(保存 DHT 状态并退出) + tauri::async_runtime::block_on(self.inner.torrent.stop()); + // 最终保存 self.persist_now(); } @@ -575,7 +1161,11 @@ impl DownloadEngine { let active = tasks.values().filter(|t| t.status == TaskStatus::Active).count(); let mut queued: Vec<_> = tasks .values() - .filter(|t| t.status == TaskStatus::Queued) + // 跳过元数据未就绪的 BT 任务(异步添加后台解析中,等待用户勾选文件) + .filter(|t| { + t.status == TaskStatus::Queued + && (t.protocol != TaskProtocol::BitTorrent || t.bt_metadata_ready) + }) .collect(); queued.sort_by_key(|t| t.created_at); (active, queued.into_iter().map(|t| t.id.clone()).collect::>()) @@ -605,16 +1195,34 @@ impl DownloadEngine { } }; + let is_bt = task.protocol == TaskProtocol::BitTorrent; + + // 快照做种开关(BT 下载完成后是否继续做种;下载开始前取值,运行中改动不影响进行中任务) + let task_do_seed = if is_bt { + self.inner.settings.lock().unwrap_or_else(|e| e.into_inner()).bt_seed_after_download + } else { + false + }; + // 分配代际号(同一任务每次重启递增) let gen = self.inner.next_gen.fetch_add(1, Ordering::SeqCst) + 1; - // 创建取消标志和进度计数器 + // 取出已有 BT 句柄(add_task 时创建;pause→resume 时复用) + let existing_bt = { + let handles = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.get(&id).and_then(|h| h.bt.clone()) + }; + + // 创建取消标志和进度计数器(HTTP 按分段;BT 无分段) let cancel = Arc::new(AtomicBool::new(false)); - let progress: Vec> = task - .segments - .iter() - .map(|s| Arc::new(std::sync::atomic::AtomicU64::new(s.completed))) - .collect(); + let progress: Vec> = if is_bt { + Vec::new() + } else { + task.segments + .iter() + .map(|s| Arc::new(std::sync::atomic::AtomicU64::new(s.completed))) + .collect() + }; // 存储句柄:先取消旧代际(若存在),确保旧任务尽快退出,避免新旧并发写同一临时文件 { @@ -629,10 +1237,151 @@ impl DownloadEngine { cancel: cancel.clone(), progress: progress.iter().map(|p| p.clone()).collect(), join: Mutex::new(None), + bt: if is_bt { existing_bt.clone() } else { None }, }, ); } + // ===== BitTorrent 下载分支 ===== + if is_bt { + let Some(bt) = existing_bt else { + // 无 BT 句柄(异常):标记 Error + { + let mut tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(task) = tasks.get_mut(&id) { + task.status = TaskStatus::Error; + task.error = Some("BitTorrent 句柄缺失".to_string()); + } + } + self.persist_now(); + return; + }; + + let engine = self.clone(); + let id_clone = id.clone(); + let torrent = self.inner.torrent.clone(); + let bt_dl = bt.clone(); + // 快照做种开关:关闭则下载完成后停止上传(做种) + let seed_after = task_do_seed.clone(); + + // 下载 future:unpause 开始下载,并等待全部文件完成 + let join = tauri::async_runtime::spawn(async move { + let my_gen = gen; + if let Err(e) = torrent.unpause(&bt_dl).await { + Self::finalize_bt(&engine, &id_clone, my_gen, TaskStatus::Error, Some(e)); + return; + } + let result = bt_dl.wait_until_completed().await; + // 下载完成但未开启做种:暂停种子停止上传 + if result.is_ok() && !seed_after { + if let Err(e) = torrent.pause(&bt_dl).await { + crate::logger::log_error("download", &format!("下载完成停止做种失败: {}", e)); + } + } + match result { + Ok(()) => Self::finalize_bt(&engine, &id_clone, my_gen, TaskStatus::Complete, None), + Err(e) => Self::finalize_bt( + &engine, + &id_clone, + my_gen, + TaskStatus::Error, + Some(e.to_string()), + ), + } + }); + + // 存储 JoinHandle + if let Some(h) = self.inner.handles.lock().unwrap_or_else(|e| e.into_inner()).get_mut(&id) { + if let Ok(mut join_guard) = h.join.lock() { + *join_guard = Some(join); + } + } + + // 启动进度监控(每 500ms 从 librqbit stats 读取) + let engine = self.clone(); + let id_monitor = id.clone(); + let cancel_monitor = cancel; + let bt_monitor = bt; + tauri::async_runtime::spawn(async move { + let my_gen = gen; + let mut last_completed = TorrentDownloader::progress(&bt_monitor).0; + let mut last_time = Instant::now(); + let mut interval = tokio::time::interval(Duration::from_millis(500)); + interval.tick().await; // 跳过第一次立即触发 + + loop { + interval.tick().await; + // 代际守卫:pause→resume 后旧监控立即退出,避免用过期进度覆盖新任务 + let is_current = { + let handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.get(&id_monitor).map(|h| h.gen == my_gen).unwrap_or(false) + }; + if !is_current { + break; + } + let is_active = { + let tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + tasks.get(&id_monitor).map(|t| t.status == TaskStatus::Active).unwrap_or(false) + }; + if !is_active || cancel_monitor.load(Ordering::SeqCst) { + break; + } + + // 读取进度(librqbit stats;file_progress 每文件进度供详情页展示) + let (completed, total, file_progress) = TorrentDownloader::progress_full(&bt_monitor); + + // 计算速度 + let now = Instant::now(); + let elapsed = now.duration_since(last_time).as_secs_f64(); + let speed = if elapsed > 0.0 && completed >= last_completed { + ((completed - last_completed) as f64 / elapsed) as u64 + } else { + 0 + }; + last_completed = completed; + last_time = now; + + // 更新任务状态 + 发送进度事件 + let live_status = { + let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get_mut(&id_monitor) else { break }; + task.completed_size = completed; + task.total_size = total.max(task.total_size); + task.speed = speed; + // 每文件进度写入 segments(与 bt_files 一一对应,供详情页实时展示) + task.segments = file_progress + .iter() + .enumerate() + .map(|(i, v)| Segment { + index: i as u32, + start: 0, + end: task + .bt_files + .get(i) + .map(|f| f.size.saturating_sub(1)) + .unwrap_or(0), + completed: *v, + }) + .collect(); + task.status.clone() + }; + + let _ = engine.inner.app_handle.emit( + "download-progress", + ProgressPayload { + id: id_monitor.clone(), + completed_size: completed, + total_size: total, + speed, + status: live_status, + segments: file_progress, + }, + ); + } + }); + return; + } + // 生成下载 future let engine = self.clone(); let id_clone = id.clone(); @@ -834,6 +1583,104 @@ impl DownloadEngine { }); } + /// BT 任务收尾:更新状态/进度、移除句柄、发完成事件、持久化并调度下一个。 + /// 带代际守卫,仅最新代际执行(与 HTTP 分支的收尾逻辑一致)。 + fn finalize_bt( + engine: &DownloadEngine, + id: &str, + my_gen: u64, + status: TaskStatus, + error: Option, + ) { + // 代际守卫:仅最新代际的任务能更新状态/移除句柄/发完成事件 + let is_current = { + let handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.get(id).map(|h| h.gen == my_gen).unwrap_or(false) + }; + if !is_current { + return; + } + + // 同步最终进度 + let (completed, total) = { + let bt = { + let handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + handles.get(id).and_then(|h| h.bt.clone()) + }; + match bt { + Some(h) => TorrentDownloader::progress(&h), + None => (0, 0), + } + }; + + { + let mut tasks = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(task) = tasks.get_mut(id) { + task.completed_size = completed; + task.total_size = total.max(task.total_size); + if status == TaskStatus::Complete { + task.completed_size = total.max(completed); + } + task.speed = 0; + task.status = status.clone(); + if let Some(e) = error { + task.error = Some(e); + } + } + } + + // 从活跃句柄中移除(仅移除自己代际的句柄) + { + let mut handles = engine.inner.handles.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(h) = handles.get(id) { + if h.gen == my_gen { + handles.remove(id); + } + } + } + + // 发送完成事件 + let task = engine.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned(); + if let Some(task) = task { + let _ = engine.inner.app_handle.emit( + "download-complete", + CompletePayload { + id: id.to_string(), + filename: task.filename.clone(), + status: task.status.clone(), + error: task.error.clone(), + }, + ); + } + + // 持久化 + 调度下一个 + engine.persist_now(); + engine.schedule(); + } + + // ===================== 状态同步通知 ===================== + + /// 立即发送该任务的 download-progress 快照(供前端列表即时同步暂停/继续等状态变化)。 + /// 暂停/恢复这类状态切换未必能即时触发下载监控循环发事件(恢复后可能等第一批数据 + /// 才开始下载),因此在此主动补发一次,保证主界面与下载窗口状态一致。 + fn emit_task_progress(&self, id: &str) { + let payload = { + let tasks = self.inner.tasks.lock().unwrap_or_else(|e| e.into_inner()); + let Some(task) = tasks.get(id) else { + return; + }; + ProgressPayload { + id: id.to_string(), + completed_size: task.completed_size, + total_size: task.total_size, + speed: task.speed, + status: task.status.clone(), + segments: task.segments.iter().map(|s| s.completed).collect(), + } + }; + let _ = self.inner.app_handle.emit("download-progress", payload); + } + // ===================== 持久化 ===================== /// 节流持久化(至少间隔 3 秒) @@ -874,3 +1721,90 @@ impl DownloadEngine { *self.inner.last_save.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now(); } } + +/// 识别 URL 所属下载协议:磁力链 / .torrent(本地文件或 http(s) 链接) → BitTorrent,其余 → HTTP +fn detect_protocol(url: &str) -> TaskProtocol { + let trimmed = url.trim(); + if trimmed.to_ascii_lowercase().starts_with("magnet:") { + return TaskProtocol::BitTorrent; + } + let path_lower = trimmed + .split('?') + .next() + .unwrap_or(trimmed) + .to_ascii_lowercase(); + // 本地 .torrent 文件路径 + if path_lower.ends_with(".torrent") && std::path::Path::new(trimmed.split('?').next().unwrap_or(trimmed)).exists() { + return TaskProtocol::BitTorrent; + } + // HTTP(S) 链接指向 .torrent 文件(如 https://.../xxx.iso.torrent)→ BitTorrent + if (trimmed.starts_with("http://") || trimmed.starts_with("https://")) + && path_lower.ends_with(".torrent") + { + return TaskProtocol::BitTorrent; + } + TaskProtocol::Http +} + +/// 磁力/种子任务的占位文件名(元数据未解析前显示) +/// 磁力 → 取 btih=infohash 前 12 位;本地 .torrent → 取文件名 +fn bt_placeholder_name(input: &str) -> String { + let t = input.trim().to_ascii_lowercase(); + if t.starts_with("magnet:") { + if let Some(pos) = t.find("btih:") { + let rest = &t[pos + 5..]; + let hash = rest.split(['&', ':']).next().unwrap_or(rest); + if !hash.is_empty() { + let short: String = hash.chars().take(12).collect(); + return format!("磁力任务 ({})", short); + } + } + return "磁力任务".to_string(); + } + // .torrent 文件名 + if let Some(p) = input.split('?').next() { + if let Some(name) = p.rsplit(['/', '\\']).next() { + if !name.is_empty() { + return name.to_string(); + } + } + } + "BitTorrent 任务".to_string() +} + +/// 从磁力链接中提取 infohash(小写十六进制),提取失败返回 None +fn extract_magnet_hash(url: &str) -> Option { + let lower = url.trim().to_ascii_lowercase(); + let pos = lower.find("btih:")?; + let rest = &lower[pos + 5..]; + let hash = rest.split(['&', ':']).next().unwrap_or(rest); + if hash.len() == 40 { + Some(hash.to_string()) + } else if hash.len() == 32 { + // Base32 编码,转为十六进制 + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(hash.to_uppercase()) + .ok()?; + Some(super::torrent::hex_encode(&bytes)) + } else { + None + } +} + +/// 解析 BitTorrent SOCKS5 代理地址:开启代理时读取代理模块(mihomo)设置的 mixed_port, +/// 返回 socks5://127.0.0.1:{port}。代理模块未运行/无配置/无法解析时返回 None(降级直连)。 +fn bt_proxy_addr(app: &tauri::AppHandle, enabled: bool) -> Option { + if !enabled { + return None; + } + let dir = app.path().app_data_dir().ok()?; + let p = dir.join("proxy").join("settings.json"); + let s = std::fs::read_to_string(p).ok()?; + let v: serde_json::Value = serde_json::from_str(&s).ok()?; + let port = v.get("mixedPort").and_then(|x| x.as_u64())?; + if port == 0 || port > 65535 { + return None; + } + Some(format!("socks5://127.0.0.1:{}", port)) +} diff --git a/src-tauri/src/download_engine/http_dl.rs b/src-tauri/src/download_engine/http_dl.rs index 8184733..c86e717 100644 --- a/src-tauri/src/download_engine/http_dl.rs +++ b/src-tauri/src/download_engine/http_dl.rs @@ -53,14 +53,34 @@ impl HttpDownloader { } /// 探测下载资源信息(大小、是否支持 Range、文件名) - /// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD + /// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD。 + /// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次 pub async fn probe( &self, url: &str, headers: &HashMap, use_proxy: bool, ) -> Result { - let client = self.client(use_proxy); + let first = self.client(use_proxy); + match self.probe_with_client(first, url, headers).await { + Ok(r) => return Ok(r), + Err(e) if use_proxy => { + let direct = self.client(false); + self.probe_with_client(direct, url, headers) + .await + .map_err(|e2| format!("代理探测失败({}),直连重试也失败({})", e, e2)) + } + Err(e) => Err(e), + } + } + + /// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用 + async fn probe_with_client( + &self, + client: &Client, + url: &str, + headers: &HashMap, + ) -> Result { // 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小) let mut req = client .get(url) @@ -165,7 +185,36 @@ impl HttpDownloader { limiter: Arc, use_proxy: bool, ) -> Result<(), String> { - let client = self.client(use_proxy); + // 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。 + // use_proxy=false 直接用直连客户端,无需回退。 + // 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退, + // 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。 + let first = self.client(use_proxy); + match self.download_with_client(first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await { + Ok(()) => return Ok(()), + Err(e) if use_proxy && e != "已取消" => { + // 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端) + let direct = self.client(false); + self.download_with_client(direct, url, headers, segments, file_path, cancel, progress, &limiter) + .await + .map_err(|e2| format!("代理下载失败({}),直连重试也失败({})", e, e2)) + } + Err(e) => Err(e), + } + } + + /// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用 + async fn download_with_client( + &self, + client: &Client, + url: &str, + headers: &HashMap, + segments: &[Segment], + file_path: &Path, + cancel: Arc, + progress: &[Arc], + limiter: &Arc, + ) -> Result<(), String> { let total_size = segments.iter().map(|s| s.len()).sum(); // 预分配文件(若已知大小) @@ -191,7 +240,7 @@ impl HttpDownloader { // 单线程下载(不支持 Range 或文件太小) let seg = &segments[0]; let prog = &progress[0]; - self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter, client) + self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client) .await?; return Ok(()); } diff --git a/src-tauri/src/download_engine/mod.rs b/src-tauri/src/download_engine/mod.rs index 8733bb1..351fd5e 100644 --- a/src-tauri/src/download_engine/mod.rs +++ b/src-tauri/src/download_engine/mod.rs @@ -5,10 +5,11 @@ pub mod rate_limit; pub mod server; pub mod storage; pub mod task; +pub mod torrent; pub use commands::{ - downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, - downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, + downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files, + downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task, downloader_resume_task, downloader_save_settings, downloader_status, }; pub use engine::DownloadEngine; diff --git a/src-tauri/src/download_engine/server.rs b/src-tauri/src/download_engine/server.rs index 1181a8e..00c77f5 100644 --- a/src-tauri/src/download_engine/server.rs +++ b/src-tauri/src/download_engine/server.rs @@ -122,13 +122,14 @@ async fn create_download( return Ok(Json(CreateDownloadResponse { id: existing.id })); } - match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await { + match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await { Ok(id) => { - // 浏览器扩展发起下载:置前主窗口并通知前端跳到下载画面(替代原桌面通知) - crate::tray_menu::focus_main_window(&state.app_handle); - let _ = state - .app_handle - .emit(crate::constants::events::DOWNLOAD_EXTENSION_ADDED, ()); + // 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端, + // 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面) + let _ = state.app_handle.emit( + crate::constants::events::DOWNLOAD_EXTENSION_ADDED, + serde_json::json!({ "id": id }), + ); Ok(Json(CreateDownloadResponse { id })) } Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))), diff --git a/src-tauri/src/download_engine/task.rs b/src-tauri/src/download_engine/task.rs index 4b59e52..8d7d8cf 100644 --- a/src-tauri/src/download_engine/task.rs +++ b/src-tauri/src/download_engine/task.rs @@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; +/// 任务下载协议类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)] +#[serde(rename_all = "lowercase")] +pub enum TaskProtocol { + /// HTTP/HTTPS 直链 + #[default] + Http, + /// BitTorrent(磁力链 / .torrent 文件) + BitTorrent, +} + /// 任务状态 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] #[serde(rename_all = "lowercase")] @@ -16,6 +27,8 @@ pub enum TaskStatus { Complete, /// 错误 Error, + /// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载) + Cancelled, } /// 下载分段(多线程 Range 下载 / 断点续传用) @@ -52,18 +65,42 @@ impl Segment { } } +/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示) +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BtFileInfo { + /// 文件在种子内的索引 + pub index: u32, + /// 相对种子根目录的路径(如 "sub/file.mkv") + pub path: String, + /// 文件大小(字节) + pub size: u64, +} + /// 下载任务 #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct DownloadTask { /// 任务 ID(自增 hex 字符串) pub id: String, - /// 下载地址 + /// 下载地址(HTTP URL 或磁力链接) pub url: String, - /// 文件名 + /// 文件名(HTTP:目标文件名;BT:种子名称) pub filename: String, /// 保存目录(绝对路径) pub dir: String, + /// 协议类型 + #[serde(default)] + pub protocol: TaskProtocol, + /// BT 种子 infohash(协议=BitTorrent 时存在) + #[serde(default)] + pub info_hash: Option, + /// BT 种子内文件列表(协议=BitTorrent 时存在) + #[serde(default)] + pub bt_files: Vec, + /// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过) + #[serde(default)] + pub bt_metadata_ready: bool, /// 状态 pub status: TaskStatus, /// 文件总大小(字节),0=未知 @@ -141,6 +178,18 @@ pub struct DownloaderSettings { /// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连 #[serde(default = "default_true")] pub use_proxy: bool, + /// BitTorrent 上传限速 KB/s(0=不限) + #[serde(default)] + pub bt_upload_limit_kb: u64, + /// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传) + #[serde(default)] + pub bt_seed_after_download: bool, + /// BitTorrent 监听端口(0=自动选择) + #[serde(default)] + pub bt_listen_port: u16, + /// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连 + #[serde(default)] + pub bt_use_proxy: bool, } fn default_max_concurrent() -> u32 { @@ -180,6 +229,10 @@ impl Default for DownloaderSettings { delete_files_on_remove: false, check_duplicate: true, use_proxy: true, + bt_upload_limit_kb: 0, + bt_seed_after_download: false, + bt_listen_port: 0, + bt_use_proxy: false, } } } diff --git a/src-tauri/src/download_engine/torrent.rs b/src-tauri/src/download_engine/torrent.rs new file mode 100644 index 0000000..614c578 --- /dev/null +++ b/src-tauri/src/download_engine/torrent.rs @@ -0,0 +1,469 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; + +// 会话字段专用 tokio 异步互斥(与 std 互斥区分开): +// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效, +// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。 +use tokio::sync::Mutex as AsyncMutex; + +use librqbit::api::TorrentIdOrHash; +use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session}; + +use super::task::BtFileInfo; + +/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差 +pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60; + +/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。 +/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目 +/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址, +/// 国内直连通常可达,按顺序尝试、首个成功即用。 +const TRACKER_SYNC_SOURCES: &[&str] = &[ + "https://cf.trackerslist.com/best.txt", + "https://trackerslist.com/best.txt", + "https://cf.trackerslist.com/all.txt", +]; +/// 动态 tracker 持久化文件与同步元数据 +const TRACKERS_FILE: &str = "bt_trackers.txt"; +const TRACKERS_META_FILE: &str = "bt_trackers_meta.json"; + +/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。 +/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。 +pub const PUBLIC_TRACKERS: &[&str] = &[ + // UDP + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.tracker.cl:1337/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://open.stealth.si:80/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://open.demonii.com:1337/announce", + "udp://tracker.moeking.me:6969/announce", + "udp://ipv4.tracker.harry.lu:80/announce", + "udp://explodie.org:6969/announce", + "udp://tracker.birkenfeld.ru:7496/announce", + "udp://tracker.pomf.se:80/announce", + "udp://tracker.tamersunion.org:6969/announce", + "udp://retracker.lanta-net.ru:2710/announce", + // HTTP(S) + "http://tracker.opentrackr.org:1337/announce", + "http://tracker.openbittorrent.com:80/announce", + "http://tracker1.itzmx.com:8080/announce", + "http://tracker4.itzmx.com:2710/announce", + "https://tracker.gbitt.info:443/announce", + "https://tracker.nanoha.org:443/announce", + "http://tracker.bt4g.com:2095/announce", + "http://tracker.gbitt.info:80/announce", +]; + +/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选) +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TorrentInfo { + /// 种子名称 + pub name: String, + /// infohash(hex 小写字符串) + pub info_hash: String, + /// 种子内全部文件总大小(字节) + pub total_size: u64, + /// 种子内文件列表 + pub files: Vec, +} + +/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。 +/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。 +#[derive(Clone)] +pub struct TorrentDownloader { + /// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话) + session: Arc>>>, + /// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖) + base_dir: PathBuf, + /// 上传限速 bytes/s(0=不限),per-torrent 应用 + upload_limit_bps: Arc, + /// 监听端口(0=自动) + listen_port: Arc, + /// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连) + proxy_addr: Arc>>, + /// 动态公共 tracker(每日从外部列表同步,叠加到内置列表) + dynamic_trackers: Arc>>, +} + +impl TorrentDownloader { + pub fn new(base_dir: PathBuf) -> Self { + Self { + session: Arc::new(AsyncMutex::new(None)), + base_dir, + upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)), + listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)), + proxy_addr: Arc::new(Mutex::new(None)), + dynamic_trackers: Arc::new(RwLock::new(Vec::new())), + } + } + + /// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效 + pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option) { + self.upload_limit_bps + .store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst); + self.listen_port + .store(listen_port, std::sync::atomic::Ordering::SeqCst); + *self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr; + } + + /// 设置动态 tracker(合并去重) + pub fn add_dynamic_trackers(&self, list: Vec) { + if list.is_empty() { + return; + } + let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner()); + for t in list { + let t = t.trim().to_string(); + if !t.is_empty() && !g.contains(&t) { + g.push(t); + } + } + } + + /// 当前动态 tracker 数量 + pub fn dynamic_count(&self) -> usize { + self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len() + } + + /// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用 + fn build_trackers(&self) -> Vec { + let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone(); + let mut trackers: Vec = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect(); + for t in dyn_trackers { + if !trackers.contains(&t) { + trackers.push(t); + } + } + trackers + } + + /// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。 + /// 失败静默降级:保留上次成功的列表。 + pub async fn sync_dynamic_trackers(&self, data_dir: &Path) { + fn parse_list(text: &str) -> Vec { + text.lines() + .map(|l| l.trim()) + .filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://")) + .map(|l| l.to_string()) + .collect() + } + let trackers_path = data_dir.join(TRACKERS_FILE); + let meta_path = data_dir.join(TRACKERS_META_FILE); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + + // 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空") + if let Ok(s) = std::fs::read_to_string(&trackers_path) { + self.add_dynamic_trackers(parse_list(&s)); + } + + // 2. 今天已同步过则不再下载 + if let Ok(meta) = std::fs::read_to_string(&meta_path) { + if let Ok(v) = serde_json::from_str::(&meta) { + if v.get("date").and_then(|d| d.as_str()) == Some(&today) { + return; + } + } + } + + // 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时) + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .ok(); + for url in TRACKER_SYNC_SOURCES { + let Some(c) = &client else { break }; + match c.get(*url).send().await { + Err(e) => { + crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e)); + continue; + } + Ok(resp) => { + let text = match resp.text().await { + Ok(t) => t, + Err(e) => { + crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e)); + continue; + } + }; + let list = parse_list(&text); + if list.is_empty() { + crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url)); + continue; + } + // 保存列表与同步元数据 + let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n"))); + let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string()); + self.add_dynamic_trackers(list); + crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count())); + return; + } + } + } + crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)"); + } + + /// 会话级公共 tracker(HashSet,供 SessionOptions.trackers 使用)。 + /// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略 + /// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致 + /// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级 + /// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力), + /// 因此必须放到这里才能让磁力链接真正带上公共 tracker。 + fn session_trackers(&self) -> std::collections::HashSet { + self.build_trackers() + .into_iter() + .filter_map(|t| url::Url::parse(&t).ok()) + .collect() + } + + /// 获取或创建全局会话。 + /// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化, + /// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。 + /// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。 + async fn get_session(&self) -> Result, String> { + let mut guard = self.session.lock().await; + if let Some(s) = guard.as_ref() { + return Ok(s.clone()); + } + let _ = std::fs::create_dir_all(&self.base_dir); + let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst); + let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() }); + let had_proxy = connect_opt.is_some(); + + // 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项 + let mut opts = lib_session_options(); + opts.trackers = self.session_trackers(); + if connect_opt.is_some() { + opts.connect = connect_opt; + } + if port > 0 { + if let Ok(addr) = format!("0.0.0.0:{}", port).parse::() { + opts.listen = Some(librqbit::ListenerOptions { + listen_addr: addr, + ..Default::default() + }); + } else { + crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port)); + } + } + // 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试 + let s = match Session::new_with_opts(self.base_dir.clone(), opts).await { + Ok(s) => s, + Err(e) => { + if had_proxy { + crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e)); + let mut m = lib_session_options(); + m.trackers = self.session_trackers(); + if port > 0 { + if let Ok(addr) = format!("0.0.0.0:{}", port).parse::() { + m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() }); + } + } + Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))? + } else { + return Err(format!("初始化 BitTorrent 会话失败: {}", e)); + } + } + }; + *guard = Some(s.clone()); + Ok(s) + } + + /// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。 + /// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。 + /// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。 + pub async fn inspect(&self, input: &str) -> Result { + let session = self.get_session().await?; + let trackers = self.build_trackers(); + // 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性); + // 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析 + let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?; + let add_fut = session.add_torrent( + add, + Some(AddTorrentOptions { + list_only: true, + trackers: Some(trackers), + ..Default::default() + }), + ); + let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut) + .await + .map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())? + .map_err(|e| format!("解析种子失败: {}", e))?; + + let AddTorrentResponse::ListOnly(lo) = resp else { + return Err("该链接未返回有效的种子元数据".to_string()); + }; + + let info = lo.info.info(); + let name = info + .name + .as_ref() + .map(|b| String::from_utf8_lossy(b.as_ref()).into_owned()) + .or_else(|| lo.info.name().map(|c| c.to_string())) + .unwrap_or_else(|| "未命名种子".to_string()); + + let mut files = Vec::new(); + let mut total_size = 0u64; + if let Some(fs) = &info.files { + for (i, f) in fs.iter().enumerate() { + let path = f + .path + .iter() + .map(|p| String::from_utf8_lossy(p.as_ref()).into_owned()) + .collect::>() + .join("/"); + files.push(BtFileInfo { + index: i as u32, + path, + size: f.length, + }); + total_size += f.length; + } + } else if let Some(len) = info.length { + // 单文件种子 + files.push(BtFileInfo { + index: 0, + path: name.clone(), + size: len, + }); + total_size = len; + } + + Ok(TorrentInfo { + name, + info_hash: hex_encode(&lo.info_hash.0), + total_size, + files, + }) + } + + /// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。 + /// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。 + pub async fn add_async( + &self, + input: &str, + output_dir: &str, + ) -> Result<(usize, Arc), String> { + let session = self.get_session().await?; + std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?; + let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst); + let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32); + let trackers = self.build_trackers(); + let opts = AddTorrentOptions { + paused: true, + output_folder: Some(output_dir.to_string()), + overwrite: true, + trackers: Some(trackers), + ratelimits: librqbit::limits::LimitsConfig { + upload_bps, + download_bps: None, + }, + ..Default::default() + }; + let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?; + let resp = session + .add_torrent(add, Some(opts)) + .await + .map_err(|e| format!("添加种子失败: {}", e))?; + match resp { + AddTorrentResponse::Added(id, handle) => Ok((id, handle)), + AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)), + _ => Err("种子已存在或元数据无效".to_string()), + } + } + + /// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时) + pub async fn wait_initialized(&self, handle: &Arc) -> Result<(), String> { + tokio::time::timeout( + std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), + handle.wait_until_initialized(), + ) + .await + .map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())? + .map_err(|e| format!("获取种子元数据失败: {}", e)) + } + + /// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选) + pub async fn set_only_files(&self, handle: &Arc, files: &[u32]) -> Result<(), String> { + let session = self.get_session().await?; + let set: std::collections::HashSet = files.iter().map(|&i| i as usize).collect(); + session + .update_only_files(handle, &set) + .await + .map_err(|e| format!("设置下载文件失败: {}", e)) + } + + /// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节) + /// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。 + pub fn progress_full(handle: &Arc) -> (u64, u64, Vec) { + let s = handle.stats(); + (s.progress_bytes, s.total_bytes, s.file_progress.clone()) + } + + /// 暂停种子(下载中的连接会停止,已下载片段保留,可继续) + pub async fn pause(&self, handle: &Arc) -> Result<(), String> { + let session = self.get_session().await?; + session + .pause(handle) + .await + .map_err(|e| format!("暂停种子失败: {}", e)) + } + + /// 继续种子 + pub async fn unpause(&self, handle: &Arc) -> Result<(), String> { + let session = self.get_session().await?; + session + .unpause(handle) + .await + .map_err(|e| format!("继续种子失败: {}", e)) + } + + /// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件) + pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> { + let session = self.get_session().await?; + let id = TorrentIdOrHash::parse(info_hash) + .map_err(|_| format!("无效的 infohash: {}", info_hash))?; + session + .delete(id, delete_files) + .await + .map_err(|e| format!("删除种子失败: {}", e)) + } + + /// 读取句柄当前下载进度 (已下载字节, 总字节) + pub fn progress(handle: &Arc) -> (u64, u64) { + let s = handle.stats(); + (s.progress_bytes, s.total_bytes) + } + + /// 停止全局会话(应用退出时调用) + pub async fn stop(&self) { + let session = { + let mut guard = self.session.lock().await; + guard.take() + }; + if let Some(s) = session { + let _ = s.stop().await; + } + } +} + +/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display) +pub(crate) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置) +fn lib_session_options() -> librqbit::SessionOptions { + librqbit::SessionOptions::default() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d36abe5..df21afd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,8 +20,8 @@ mod win32_util; use download_engine::{ DownloadEngine, - downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, - downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task, + downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files, + downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task, downloader_resume_task, downloader_save_settings, downloader_status, }; use logger::{ @@ -31,7 +31,7 @@ use mihomo_manager::{ proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings, - proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, + proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic, proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager, }; use monitor_kernel::{ @@ -116,7 +116,7 @@ fn export_bindings() { proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_restart, proxy_save_settings, - proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, + proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic, proxy_test_delay, proxy_update_profile, // quickpanel(22) quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut, @@ -140,8 +140,8 @@ fn export_bindings() { clipboard_preview_interacted, // download_engine(10,豁免 2) downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task, - downloader_resume_task, downloader_remove_task, downloader_get_settings, - downloader_save_settings, downloader_open_dir, downloader_open_url, + downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings, + downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files, // screenshot(22,豁免 3:get_fullscreen_bmp / load_cache_raw 返回 ipc::Response、compose_copy 接收 ipc::Request) screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut, screenshot_unregister_shortcut, screenshot_register_pin_shortcut, @@ -200,6 +200,7 @@ pub fn run() { proxy_status, proxy_start, proxy_stop, + proxy_traffic, proxy_restart, proxy_version, proxy_get_proxies, @@ -243,6 +244,8 @@ pub fn run() { downloader_check_url, downloader_pause_task, downloader_resume_task, + downloader_cancel_task, + downloader_redownload, downloader_remove_task, downloader_get_settings, downloader_save_settings, @@ -250,6 +253,9 @@ pub fn run() { downloader_get_extension_info, downloader_open_dir, downloader_open_url, + downloader_focus_window, + downloader_inspect, + downloader_select_bt_files, clipboard_get_history, clipboard_get_pinned, clipboard_search, diff --git a/src-tauri/src/mihomo_manager/commands.rs b/src-tauri/src/mihomo_manager/commands.rs index c222192..a92f8c1 100644 --- a/src-tauri/src/mihomo_manager/commands.rs +++ b/src-tauri/src/mihomo_manager/commands.rs @@ -4,7 +4,7 @@ use tauri::{AppHandle, State}; use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows}; use super::{ - KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, + KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot, }; use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus}; @@ -238,6 +238,14 @@ pub async fn proxy_get_connections( state.get_connections().await } +#[tauri::command] +#[specta::specta] +pub async fn proxy_traffic( + state: State<'_, MihomoManager>, +) -> Result { + state.traffic_snapshot().await +} + #[tauri::command] #[specta::specta] pub async fn proxy_close_connection( diff --git a/src-tauri/src/mihomo_manager/mod.rs b/src-tauri/src/mihomo_manager/mod.rs index 930e384..4e2708f 100644 --- a/src-tauri/src/mihomo_manager/mod.rs +++ b/src-tauri/src/mihomo_manager/mod.rs @@ -17,11 +17,11 @@ mod types; pub use autoswitch::{pick_best, start_auto_switch_loop}; pub use pseudo::is_pseudo_node; pub use system_proxy::get_system_proxy_windows; -pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus}; +pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot}; pub use commands::{ proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, - proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, + proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic, proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version, }; @@ -45,10 +45,19 @@ struct SettingsCacheEntry { settings: ProxySettings, } +/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率 +struct TrafficBaseline { + download_total: u64, + upload_total: u64, + at: Instant, +} + pub struct MihomoManager { root: PathBuf, client: Client, settings_cache: Mutex>, + /// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率 + traffic_baseline: Mutex>, /// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止) kernel_cancel: Arc, /// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消, @@ -75,6 +84,7 @@ impl MihomoManager { .build() .unwrap_or_else(|_| Client::new()), settings_cache: Mutex::new(None), + traffic_baseline: Mutex::new(None), kernel_cancel: Arc::new(AtomicBool::new(false)), kernel_cancel_tx: tokio::sync::watch::channel(false).0, install_confirm: std::sync::Mutex::new(None), @@ -308,16 +318,23 @@ impl MihomoManager { /// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。 pub fn apply_auto_system_proxy(&self) { let settings = self.load_settings(); - if settings.auto_system_proxy && !settings.system_proxy && !system_proxy::get_system_proxy_windows() { - let addr = format!("127.0.0.1:{}", settings.mixed_port); - if let Err(e) = system_proxy::set_system_proxy_windows(&addr) { - crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e)); - return; - } - let mut s = settings; - s.system_proxy = true; - let _ = self.save_settings(&s); + if !settings.auto_system_proxy { + return; } + // 以注册表实际状态为准判断是否已开启:settings.system_proxy 是会话内标志, + // 上次退出 cleanup_on_exit 只清注册表不会回写该标志,重启后会残留 true, + // 若用它做守卫会导致「启动时自动开启系统代理」永远被短路而不生效。 + if system_proxy::get_system_proxy_windows() { + return; + } + let addr = format!("127.0.0.1:{}", settings.mixed_port); + if let Err(e) = system_proxy::set_system_proxy_windows(&addr) { + crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e)); + return; + } + let mut s = settings; + s.system_proxy = true; + let _ = self.save_settings(&s); } /// 应用启动时检查是否需要自动启动 mihomo 和系统代理 @@ -450,6 +467,48 @@ impl MihomoManager { self.api_get("/connections").await } + /// 拉取 /connections 并计算实时流量快照。 + /// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。 + pub async fn traffic_snapshot(&self) -> Result { + let conns = self.get_connections().await?; + let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0); + let download_total = conns["downloadTotal"].as_u64().unwrap_or(0); + let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0); + + let (upload_speed, download_speed) = { + let base = self.traffic_baseline.lock().unwrap(); + match base.as_ref() { + // 正常差分:总量单调递增才计算速率 + Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => { + let dt = b.at.elapsed().as_secs_f64(); + if dt > 0.0 { + let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64; + let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64; + (up, down) + } else { + (0, 0) + } + } + // 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线 + _ => (0, 0), + } + }; + + *self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline { + download_total, + upload_total, + at: Instant::now(), + }); + + Ok(TrafficSnapshot { + download_total, + upload_total, + download_speed, + upload_speed, + active_connections, + }) + } + pub async fn close_connection(&self, id: &str) -> Result<(), String> { self.api_request( reqwest::Method::DELETE, diff --git a/src-tauri/src/mihomo_manager/types.rs b/src-tauri/src/mihomo_manager/types.rs index 9c0a93b..9baecc7 100644 --- a/src-tauri/src/mihomo_manager/types.rs +++ b/src-tauri/src/mihomo_manager/types.rs @@ -124,6 +124,22 @@ pub struct ProxyStatus { pub restart_count: u32, } +/// 实时流量快照(由 /connections 的会话总量差分得出实时速率) +#[derive(Serialize, Clone, Type)] +#[serde(rename_all = "camelCase")] +pub struct TrafficSnapshot { + /// 本次会话累计下载字节数 + pub download_total: u64, + /// 本次会话累计上传字节数 + pub upload_total: u64, + /// 实时下载速率(字节/秒) + pub download_speed: u64, + /// 实时上传速率(字节/秒) + pub upload_speed: u64, + /// 当前活跃连接数 + pub active_connections: usize, +} + /// 内核安装进度事件载荷 /// - stage: downloading | extracting | replacing | done | error /// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示) diff --git a/src-tauri/src/osd_window.rs b/src-tauri/src/osd_window.rs index b6a0a07..21cdee2 100644 --- a/src-tauri/src/osd_window.rs +++ b/src-tauri/src/osd_window.rs @@ -60,7 +60,7 @@ mod win_api { GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, + SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, }; @@ -165,6 +165,10 @@ mod win_api { } else { HWND_NOTOPMOST }; + // 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。 + // 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时 + // (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD, + // 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。 SetWindowPos( hwnd, insert_after, @@ -172,7 +176,7 @@ mod win_api { 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, ); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2630260..7178c75 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "thing", - "version": "26.8.2", + "version": "26.8.3", "identifier": "thing.lfeng.me", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.vue b/src/App.vue index be4f537..34dbf13 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,8 +14,8 @@ import { useProcessStore } from '@/stores/processStore' import { TooltipProvider } from '@/components/ui/tooltip' import { moduleRegistry } from '@/modules/registry' import type { ModuleMeta } from '@/types/module' -import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents' -import { EVENTS, STORAGE_KEYS } from '@/lib/constants' +import { pendingNewDownload } from '@/lib/trayEvents' +import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' import { commands } from '@/lib/bindings' const appStore = useAppStore() @@ -104,6 +104,56 @@ const handleSearch = (moduleId: string) => { handleModuleChange(moduleId) } +// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。 +// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*" +async function openDownloadWindow(taskId: string) { + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') + const { currentMonitor } = await import('@tauri-apps/api/window') + const label = `${WINDOWS.downloadWindow}-${taskId}` + try { + const existing = await WebviewWindow.getByLabel(label) + if (existing) { + // 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台) + await commands.downloaderFocusWindow(label) + return + } + // 定位到主窗口当前所在显示器的中央偏上 + const monitor = await currentMonitor() + const scale = monitor?.scaleFactor ?? 1 + const w = 420 + const h = 176 + const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2) + const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8) + const win = new WebviewWindow(label, { + url: `index.html#download-window?task=${encodeURIComponent(taskId)}`, + title: '下载', + width: w, + height: h, + x, + y, + decorations: false, + transparent: true, + resizable: false, + maximizable: false, + minimizable: true, + shadow: true, + visible: false, + focus: false, + // 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。 + // 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁 + }) + win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e)) + // 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show, + // 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。 + // 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。 + win.once('tauri://created', () => { + window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500) + }) + } catch (e) { + console.error('创建下载窗口失败:', e) + } +} + const getFallbackModule = () => { const enabledIds = appStore.enabledModules.map(m => m.id) const fallback = moduleRegistry.getAllMetas().find( @@ -198,14 +248,12 @@ onMounted(async () => { } }) ) - // 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前) + // 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。 + // 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。 trayUnlisteners.push( - await listen(EVENTS.downloadExtensionAdded, () => { - const enabledIds = appStore.enabledModules.map(m => m.id) - if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) { - pendingShowDownloadTasks.value = true - handleModuleChange('downloader') - } + await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => { + if (!e.payload?.id) return + void openDownloadWindow(e.payload.id) }) ) trayUnlisteners.push( diff --git a/src/components/layout/ModuleContainer.vue b/src/components/layout/ModuleContainer.vue index 1aa62e2..04a6af9 100644 --- a/src/components/layout/ModuleContainer.vue +++ b/src/components/layout/ModuleContainer.vue @@ -27,7 +27,7 @@ watch(