diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 05ed954..c1a6eaa 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -1,103 +1,218 @@ # Firelink Torrent feature matrix -This is the current product-facing comparison for BitTorrent features exposed -by Firelink's bundled Aria2 engine. It intentionally excludes Aria2's generic -HTTP/FTP/Metalink options, shell hooks, and daemon-admin RPC methods that do not -belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.html). +This document is the source of truth for Firelink's BitTorrent scope, current +implementation status, and next work. It compares Firelink with the +BitTorrent-specific surface of the bundled Aria2 1.37.0 engine. Aria2's +generic HTTP/FTP/SFTP/Metalink options, arbitrary shell hooks, and daemon +administration RPCs are intentionally separate unless they affect Torrent +ownership or safety. + +Reference: [Aria2 1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.html). + +## Audit basis + +- Audited on 2026-08-02 at Firelink `b2c86a2` (`main`), with the cumulative + Torrent work reviewed from `edc76a7`. +- Source of truth: `src-tauri/src/torrent.rs`, `torrent_probe.rs`, `queue.rs`, + `lib.rs`, `settings.rs`, `download_ownership.rs`, `db.rs`, the IPC bindings, + frontend stores/components, and `scripts/smoke-torrent.js`. +- Reliability claims require a source postcondition or a test/harness + assertion. A passing local macOS check does not prove Windows/Linux native + behavior, public tracker/DHT reachability, or packaged-app behavior. +- The requested Agy and OpenCode review was bounded to the cumulative Torrent + diff and relevant paths. Their advice was used only after this source audit + and was verified against the live tree. ## Implemented +### Intake, metadata, and file selection + - Local `.torrent` files, magnet links, and remote HTTP(S) `.torrent` metadata. - Remote metadata is bounded, SSRF-checked, redirect-checked, parsed, and - cached before it enters the normal `addTorrent` path. -- Bencode validation, canonical info-hash checks, safe output paths, managed - metadata retention, selected-file preview, `select-file`, and `index-out`. -- Firelink queue admission, per-queue/global permits, pause/resume, cancel, - retry/GID replacement, restart recovery, terminal reconciliation, and - output ownership for Torrent lifecycles. -- Optional seeding by time and/or ratio, upload progress, upload limits, - seeders telemetry, per-Torrent maximum peers, and the Aria2 - `bt-request-peer-speed-limit` threshold. -- Bounded, read-only Torrent peer diagnostics through `aria2.getPeers`. - Firelink discards peer IPs, ports, IDs, and bitfields at the native boundary; - the selected-Torrent detail view exposes only operational speeds, seeder, - and choking flags, with a bounded display count. -- Global DHT, IPv6 DHT, PEX, and Local Peer Discovery toggles. -- Configurable TCP and UDP listen-port ranges, external BitTorrent IP, - IPv4/IPv6 DHT entry points, IPv6 DHT listen address, and LPD interface. - Values are validated, persisted, applied at Aria2 startup, and accompanied - by platform and firewall/port-forwarding warnings. -- Optional peer-ID prefix and BitTorrent peer-agent controls. Values are - bounded and validated, remain disabled by default, and include explicit - privacy, protocol-identity, and compatibility warnings. -- Optional piece-integrity verification, including the explicit policy that - disables unverified seeding when verification is requested. -- Optional stall timeout through `bt-stop-timeout`, persisted with each - Torrent and re-applied when it starts or retries. A value of zero disables - the policy; Aria2 stops the Torrent after the configured consecutive - zero-download-speed interval. -- Additional per-Torrent tracker URLs through `bt-tracker`, with bounded and - credential-free HTTP/HTTPS/UDP validation. -- Per-Torrent tracker exclusion through `bt-exclude-tracker`, including Aria2's - explicit `*` value for excluding all announce URLs. Exclusions are persisted, - normalized, reapplied on retries, and do not change DHT or PEX settings. -- Optional `bt-prioritize-piece` preview policy for the head, tail, or both - ends of every selected file. The constrained policy is validated, persisted, - normalized, and reapplied when a Torrent starts or retries. -- One validated Torrent encryption policy mapped to Aria2's - `bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`: - disabled, required obfuscated handshake, or forced ARC4 payload encryption. - The policy is persisted and reapplied when a Torrent starts or retries. -- Optional tracker timing controls through `bt-tracker-connect-timeout`, - `bt-tracker-timeout`, and `bt-tracker-interval`. Connect and request - timeouts are bounded to 1–604800 seconds; interval 0 restores Aria2's - response/progress-driven scheduling. Timing is persisted and reapplied when - a Torrent starts or retries. -- Generic Aria2 downloads explicitly disable `follow-torrent` and - `follow-metalink`, so a URL that happens to return Torrent or Metalink - metadata cannot create an unmanaged child GID. Generic follow behavior is - not exposed until parent/child GID ownership is represented across queue - admission, progress, cancellation, retry, and restart recovery. -- Global `bt-max-open-files` control for multi-file Torrents, bounded to - 1–4096 with Aria2's default of 100. The setting is persisted, applied at - daemon startup, and updateable through Aria2's global-option RPC; changes - affect newly added Torrents without restarting Aria2. -- Optional `bt-remove-unselected-file` cleanup after completion when a - selected-file subset is configured. Firelink requires explicit confirmation, - reserves the unselected paths against competing downloads, keeps those - paths separate from removable download ownership, and clears the reservation - after observing Aria2's completion cleanup (or on terminal failure, - cancellation, or reconfiguration). -- Deterministic local Aria2 smoke coverage for metadata resolution, selected - output, piece priority, encryption policy, tracker timing, pause/resume, - ownership, cancellation/removal, unavailable trackers, daemon failure, and - `bt-stop-timeout` terminal behavior; RPC-boundary coverage is separate. + Remote metadata is bounded, redirect/SSRF checked, credential-free, parsed, + and cached before enqueue. +- Strict bencode parsing, sorted-key validation, size/depth bounds, UTF-8 + validation, canonical info-hash verification, safe output components, and + managed metadata retention/rekeying. +- Selected-file preview and validated `select-file` handling. Firelink derives + the Torrent output contract with Aria2 `index-out`; it does not use the + generic `out` option for Torrent files. +- Torrent metadata probing uses Aria2 `bt-metadata-only` and `bt-save-metadata` + internally, validates the returned hash, and conservatively cleans probe + directories. It is not exposed as a separate metadata-only download mode. +- `addTorrent` passes validated web-seed/mirror URIs when supplied through the + existing download input. There is no separate Torrent web-seed manager. -## Priority tiers for remaining work +### Queue and lifecycle ownership -### Tier 0 — reliability and user-visible control +- Torrents use the existing Firelink queue admission, global/per-queue permits, + pause/resume, cancellation, retry/GID replacement, restart recovery, and + terminal reconciliation. +- A Torrent's Aria2 GID is paired with the Firelink download ID and lifecycle + epoch. Late RPC results and stale terminal events cannot revive a removed or + newer lifecycle. +- Exactly one queue permit remains parked for the complete Aria2 lifecycle, + including seeding, and release is idempotent. +- Aria2 `getFiles` reconciliation establishes output ownership for Torrent + files. Ownership and optional unselected-file removal reservations are + canonicalized, persisted, collision-checked, and kept separate. +- Generic `addUri` explicitly sets both `follow-torrent=false` and + `follow-metalink=false`. This prevents an HTTP download from creating an + unmanaged child GID outside Firelink's queue, ownership, cancellation, retry, + and restart model. -No remaining Tier 0 items. +### Transfer, seeding, and integrity controls -### Tier 1 — transfer policy and storage behavior +- Optional `seed-time` and/or `seed-ratio` policies, including ratio-only and + unlimited-ratio semantics; upload progress and seeding status are reflected + in the UI. +- Per-Torrent upload limit through Aria2 `max-upload-limit`, with a live, + lifecycle-fenced update path. +- Global Aria2 aggregate upload limit through + `max-overall-upload-limit`. It is persisted, validated, applied at daemon + startup, and changeable through `aria2.changeGlobalOption`; in Firelink it + primarily controls Torrent seeding traffic, and blank means Aria2's + unlimited value (`0`). +- Per-Torrent maximum peers (`bt-max-peers`) and low-speed peer expansion + threshold (`bt-request-peer-speed-limit`), including live updates. +- Optional piece-integrity verification through `check-integrity` and a safe + `bt-hash-check-seed`/`bt-seed-unverified=false` policy. Firelink does not + silently seed unverified data when the user requests verification. +- Optional `bt-stop-timeout` stall policy, persisted per Torrent and reapplied + on start/retry. +- Optional `bt-prioritize-piece` head/tail preview policy, normalized and + reapplied on start/retry. +- Validated encryption policies mapped consistently to + `bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`. +- Optional `bt-remove-unselected-file` cleanup after successful completion, + only with an explicit partial selection and confirmation. Cancellation, + failure, replacement, and cleanup races are conservative. -No remaining Tier 1 items. +### Trackers, peers, and network identity -### Tier 2 — advanced networking and daemon tuning +- Additional `bt-tracker` URLs and `bt-exclude-tracker`, including the explicit + `*` wildcard. URLs are bounded, normalized, credential-free, and limited to + HTTP(S)/UDP schemes. +- `bt-tracker-connect-timeout`, `bt-tracker-timeout`, and + `bt-tracker-interval`, persisted per Torrent and reapplied on start/retry. +- Bounded read-only `aria2.getPeers` diagnostics. Firelink discards peer IPs, + ports, IDs, and bitfields at the native boundary and exposes only bounded + operational speeds and choking/seeder flags. +- Global DHT, IPv6 DHT, PEX, and LPD toggles. Private-Torrent behavior remains + Aria2-controlled. +- Launch-scoped TCP/UDP listen-port ranges, external BitTorrent IP, IPv4/IPv6 + DHT entry points, IPv6 DHT listen address, and LPD interface. Settings are + validated, persisted, and applied only after Firelink restart. +- Optional bounded peer-ID prefix and peer-agent overrides. They are disabled + by default and carry identity/privacy/compatibility warnings. +- Global `bt-max-open-files`, bounded to 1–4096, applied at startup and + updateable for newly added Torrents through `aria2.changeGlobalOption`. -1. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only - if the resulting child-GID ownership model can be represented safely; the - current explicit metadata path intentionally avoids unmapped child jobs. - Generic `addUri` now forces both follow options to `false` as the safe - default; the child-GID feature remains pending until the end-to-end - ownership model is implemented. Enabling it for raw generic URLs would - also bypass Firelink's bounded remote-metadata validation and would require - re-discovering child jobs from durable request identity after an Aria2 - restart; session-scoped GID strings cannot be persisted as ownership. +### Evidence already present in the tree -The first implementation in this task was remote `.torrent` metadata intake; -follow-up implementations add stall-timeout control, bounded peer diagnostics, -persisted tracker exclusion, piece-preview priority, safe unselected-file -removal, the validated encryption policy, tracker timing controls, the global -Torrent open-file limit, launch-scoped Torrent network binding controls, and -peer identity/agent controls. +- Rust unit coverage for bencode/hash/path validation, option normalization, + queue ownership, lifecycle fencing, persistence sanitization, and native + startup argument construction. +- `src-tauri/tests/torrent_rpc.rs` covers the production authenticated JSON-RPC + HTTP boundary in a Windows-compatible integration-test target. +- `npm run smoke:torrent` and `npm run smoke:torrent:failure-paths` cover + deterministic local seeding, magnet metadata resolution, selected output, + pause/resume, ownership, cancellation/removal, unavailable trackers, + daemon failure, integrity, encryption, tracker/piece policies, open-file and + aggregate-upload limits, and stall-timeout behavior. + +## Aria2 comparison: available but not exposed or only partially represented + +| Aria2 capability | Firelink status | Reason / next step | +| --- | --- | --- | +| `bt-load-saved-metadata` | Not exposed | Firelink has managed metadata files, but a new magnet currently probes metadata instead of reusing an info-hash-keyed cache. Add hash-keyed reuse with validation and stale-cache invalidation. | +| `dht-message-timeout` | Not exposed | Global DHT/UDP timeout tuning is not yet represented in settings. Add only with bounded validation and a runtime/startup contract. | +| `dht-file-path`, `dht-file-path6` | Not explicitly controlled | Aria2 can persist DHT routing tables, but Firelink does not choose app-managed paths or report their health. Decide whether portable-mode and privacy behavior justify exposing this. | +| `bt-detach-seed-only` | Not used | Aria2's concurrent-download accounting does not replace Firelink's permit ownership. Enabling it blindly would create two competing concurrency models. Revisit only with an explicit seed-slot policy. | +| `follow-torrent=true/mem` | Intentionally disabled for generic URLs | The child GID has no durable Firelink identity, permit, output ownership, or restart recovery record. Implement only after a parent/child lifecycle model exists and remote metadata validation is preserved. | +| `bt-metadata-only` / `bt-save-metadata` as user actions | Internal probe only | The Add window resolves metadata before enqueue; a separate user-visible metadata-only job is not currently a product need. | +| `on-bt-download-complete` and other hooks | Out of scope | Aria2 executes arbitrary commands. Firelink does not expose a shell-command injection surface; any future automation should be a bounded, app-owned event system. | +| `bt-enable-hook-after-hash-check` | Out of scope with hooks | It has no useful standalone meaning while arbitrary hooks are excluded. | +| `rpc-save-upload-metadata`, `save-session`, and other daemon-admin RPC policy | Out of scope / replaced | Firelink owns metadata retention and durable download state; enabling Aria2's uploaded-metadata persistence would create a second storage contract. | +| Aria2 CLI-only `show-files` / `torrent-file` controls | Product-equivalent path exists | Firelink provides a validated Add-window preview and managed `addTorrent` path rather than exposing CLI flags. | + +The comparison intentionally does not treat Aria2 defaults as Firelink +features. For example, Aria2 defaults `follow-torrent` to true, but Firelink +must override it to false on every generic `addUri` path until child ownership +is durable. + +## Priority tiers for future work + +### Tier 0 — correctness and safety gates + +No unstarted Tier 0 feature is approved. The global aggregate upload ceiling +was the highest-impact missing control and is now implemented as a persisted, +startup, and live-RPC contract. + +Before any new Torrent feature is promoted, keep these gates mandatory: + +1. Every Aria2 GID must remain attached to one Firelink identity, lifecycle + epoch, permit, and owned-path contract. +2. Every awaited RPC must re-check lifecycle ownership before mutating UI, + persistence, or queue state. +3. Any cleanup that can delete files must prove ownership and remain + conservative after cancellation, daemon loss, restart, and missed events. +4. Generic followed child GIDs remain disabled until their full lifecycle is + modeled and tested. + +### Tier 1 — high-value user behavior + +1. **Info-hash-keyed magnet metadata reuse.** Reuse a previously validated + managed `.torrent` by info hash before probing DHT/trackers. Revalidate the + bencode and exact hash, bind the result to the current draft/download + identity, and delete only invalid or unretained cache entries. +2. **Unselected-file removal crash/restart audit.** Add post-crash tests around + the persisted removal reservation, Aria2 completion cleanup, path reuse, and + case-insensitive path equality. Do not change cleanup ordering until the + ownership postconditions are proven. +3. **DHT routing-table persistence policy.** Decide and implement app-managed + `dht-file-path`/`dht-file-path6` behavior, especially for portable mode, + permissions, reset, and privacy. This should be opt-in if it expands data + retention beyond the current download metadata contract. + +### Tier 2 — advanced tuning and ownership expansion + +1. Expose bounded `dht-message-timeout` if real tracker/DHT diagnostics show a + user-visible need; validate it at startup and document that it affects DHT + and UDP tracker waits, not HTTP metadata fetches. +2. Add an explicit seed-slot policy only if Firelink wants seeding to stop + consuming a queue permit. Aria2 `bt-detach-seed-only` alone is insufficient; + Firelink's queue and power-management semantics must agree first. +3. Model generic followed Torrent children (`true` or `mem`) with durable + parent/child IDs, admission accounting, output ownership, cancellation, + retry/GID replacement, restart discovery, and bounded metadata validation. + This remains a substantial architecture change, not a one-line option. + +## Deliberately not planned + +- Arbitrary shell hooks from Aria2. +- Direct daemon-admin/session-management controls that duplicate Firelink's + persistence and ownership system. +- Claims of public tracker/DHT readiness from local deterministic fixtures. +- A second Torrent engine. Firelink's existing Aria2 queue, permit, GID, and + recovery contracts are the intended transfer architecture. + +## Validation commands + +Run focused checks first, then the relevant broader gates: + +```sh +npm test -- --run +npm run check:i18n +npm run bindings +cd src-tauri +cargo test --test torrent_rpc -- --nocapture +cargo test --all-targets +cd .. +npm run smoke:torrent +npm run smoke:torrent:failure-paths +git diff --check +``` + +Native Windows/Linux behavior, packaged-app startup, public magnets, and +router/firewall port forwarding remain separate evidence slices and must not be +implied by these local checks. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 55fd252..4ed8daa 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -712,6 +712,21 @@ async function main() { globalOptions['bt-max-open-files'] === '256', `Aria2 did not retain the global Torrent open-file limit: ${JSON.stringify(globalOptions['bt-max-open-files'])}`, ); + const previousOverallUploadLimit = globalOptions['max-overall-upload-limit'] ?? '0'; + try { + await rpc(client.rpcPort, client.secret, 'aria2.changeGlobalOption', [ + { 'max-overall-upload-limit': '256K' }, + ]); + const uploadLimitedOptions = await rpc(client.rpcPort, client.secret, 'aria2.getGlobalOption', []); + assert( + Number(uploadLimitedOptions['max-overall-upload-limit']) === 256 * 1024, + `Aria2 did not retain the global Torrent upload limit: ${JSON.stringify(uploadLimitedOptions['max-overall-upload-limit'])}`, + ); + } finally { + await rpc(client.rpcPort, client.secret, 'aria2.changeGlobalOption', [ + { 'max-overall-upload-limit': previousOverallUploadLimit }, + ]); + } const probeRemoved = await forceRemoveIfPresent(client, probeGid); if (probeRemoved) await waitForRemoved(client, probeGid); fs.rmSync(probeDir, { recursive: true, force: true }); @@ -757,7 +772,7 @@ async function main() { const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin')); assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`); assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output'); - console.log('[OK] global open-file limit, tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed'); + console.log('[OK] global open-file/upload limits, tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed'); const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin'); fs.mkdirSync(path.dirname(integrityPath), { recursive: true }); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 36b4eae..0fff014 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -462,6 +462,8 @@ pub struct PersistedSettings { pub approved_download_roots: Vec, pub max_concurrent_downloads: usize, pub global_speed_limit: String, + #[serde(default)] + pub torrent_overall_upload_limit: String, pub speed_limit_preset_values: Vec, pub logs_enabled: bool, pub is_sidebar_visible: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da0a5c6..734d6b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6609,6 +6609,18 @@ pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { }) } +fn normalize_torrent_overall_upload_limit(limit: Option<&str>) -> Result, String> { + let Some(limit) = limit else { + return Ok(None); + }; + if limit.trim().is_empty() { + return Ok(None); + } + normalize_speed_limit_for_aria2(limit) + .map(Some) + .ok_or_else(|| "Torrent overall upload limit is invalid".to_string()) +} + fn apply_aria2_torrent_peer_discovery_options( command: &mut std::process::Command, enable_dht: bool, @@ -6675,10 +6687,14 @@ fn aria2_rpc_port_is_occupied(port: u16) -> bool { fn apply_aria2_torrent_global_options( command: &mut std::process::Command, max_open_files: u32, + overall_upload_limit: Option<&str>, ) { let max_open_files = queue::normalize_torrent_max_open_files(max_open_files) .unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES); command.arg(format!("--bt-max-open-files={max_open_files}")); + if let Some(limit) = overall_upload_limit.and_then(normalize_speed_limit_for_aria2) { + command.arg(format!("--max-overall-upload-limit={limit}")); + } } #[tauri::command(rename_all = "snake_case")] @@ -6698,6 +6714,24 @@ async fn set_torrent_max_open_files( .map_err(|error| format!("Failed to set Torrent maximum open files: {error}")) } +#[tauri::command] +async fn set_torrent_overall_upload_limit( + state: tauri::State<'_, AppState>, + limit: Option, +) -> Result<(), String> { + let normalized_limit = normalize_torrent_overall_upload_limit(limit.as_deref())?; + let limit_str = normalized_limit.as_deref().unwrap_or("0"); + rpc_call( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, + "aria2.changeGlobalOption", + serde_json::json!([{"max-overall-upload-limit": limit_str}]), + ) + .await + .map(|_| ()) + .map_err(|error| format!("Failed to set Torrent overall upload limit: {error}")) +} + #[tauri::command] async fn set_global_speed_limit( state: tauri::State<'_, AppState>, @@ -7795,6 +7829,7 @@ mod tests { cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, normalize_speed_limit_for_aria2, + normalize_torrent_overall_upload_limit, apply_aria2_torrent_global_options, apply_aria2_torrent_network_options, apply_aria2_torrent_peer_identity_options, @@ -7853,18 +7888,19 @@ mod tests { #[test] fn aria2_torrent_global_options_are_bounded_and_explicit() { let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options(&mut command, 256); + apply_aria2_torrent_global_options(&mut command, 256, Some("2M")); assert_eq!( command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(), - vec!["--bt-max-open-files=256"] + vec!["--bt-max-open-files=256", "--max-overall-upload-limit=2M"] ); let mut fallback_command = std::process::Command::new("aria2c"); apply_aria2_torrent_global_options( &mut fallback_command, queue::MAX_TORRENT_MAX_OPEN_FILES + 1, + Some("not-a-rate"), ); assert_eq!( fallback_command @@ -7873,6 +7909,32 @@ mod tests { .collect::>(), vec!["--bt-max-open-files=100"] ); + let mut unlimited_command = std::process::Command::new("aria2c"); + apply_aria2_torrent_global_options(&mut unlimited_command, 256, None); + assert_eq!( + unlimited_command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["--bt-max-open-files=256"] + ); + } + + #[test] + fn torrent_overall_upload_limit_rejects_invalid_values_but_accepts_unlimited() { + assert_eq!( + normalize_torrent_overall_upload_limit(None).unwrap(), + None + ); + assert_eq!( + normalize_torrent_overall_upload_limit(Some(" ")).unwrap(), + None + ); + assert_eq!( + normalize_torrent_overall_upload_limit(Some("1.5 MB/s")).unwrap(), + Some("1.5M".to_string()) + ); + assert!(normalize_torrent_overall_upload_limit(Some("not-a-rate")).is_err()); } #[test] @@ -10583,6 +10645,10 @@ pub fn run() { .as_ref() .map(|settings| settings.global_speed_limit.clone()) .unwrap_or_default(); + let torrent_overall_upload_limit = persisted_settings + .as_ref() + .map(|settings| settings.torrent_overall_upload_limit.clone()) + .unwrap_or_default(); let torrent_peer_discovery = persisted_settings .as_ref() .map(|settings| { @@ -10658,7 +10724,11 @@ pub fn run() { .arg("--check-certificate=true") .arg(format!("--stop-with-process={}", std::process::id())); - apply_aria2_torrent_global_options(&mut cmd, torrent_max_open_files); + apply_aria2_torrent_global_options( + &mut cmd, + torrent_max_open_files, + Some(&torrent_overall_upload_limit), + ); apply_aria2_torrent_peer_discovery_options( &mut cmd, @@ -11268,7 +11338,7 @@ pub fn run() { authorize_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, - get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_torrent_max_open_files, set_global_speed_limit, remove_download, get_download_primary_path, + get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a06dfeb..c27ffe1 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -475,6 +475,10 @@ fn validate_settings(settings: &mut PersistedSettings) { settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12); settings.per_server_connections = settings.per_server_connections.clamp(1, 16); settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10); + settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2( + &settings.torrent_overall_upload_limit, + ) + .unwrap_or_default(); settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files( settings.torrent_max_open_files, ) @@ -692,6 +696,7 @@ fn default_settings() -> PersistedSettings { approved_download_roots: Vec::new(), max_concurrent_downloads: 3, global_speed_limit: String::new(), + torrent_overall_upload_limit: String::new(), speed_limit_preset_values: vec![1.0, 5.0, 10.0], logs_enabled: false, is_sidebar_visible: true, @@ -793,6 +798,7 @@ mod tests { "state": { "maxConcurrentDownloads": 7, "globalSpeedLimit": "2M", + "torrentOverallUploadLimit": "1.5M", "sidebarPosition": "right", "scheduler": { "enabled": true, @@ -811,6 +817,7 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 7); assert_eq!(settings.global_speed_limit, "2M"); + assert_eq!(settings.torrent_overall_upload_limit, "1.5M"); assert_eq!(settings.sidebar_position, "right"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); @@ -836,6 +843,7 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 5); assert_eq!(settings.global_speed_limit, "512K"); + assert!(settings.torrent_overall_upload_limit.is_empty()); assert_eq!(settings.last_custom_speed_limit_unit, "MB/s"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); @@ -858,6 +866,20 @@ mod tests { assert!(settings.logs_enabled); assert!(!settings.scheduler.enabled); assert!(settings.global_speed_limit.is_empty()); + assert!(settings.torrent_overall_upload_limit.is_empty()); + } + + #[test] + fn normalizes_invalid_torrent_overall_upload_limit_to_unlimited() { + let stored = json!({ + "state": { + "torrentOverallUploadLimit": "not-a-rate" + } + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert!(settings.torrent_overall_upload_limit.is_empty()); } #[test] diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index e1351e4..066274e 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index e6716e3..2178b49 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -37,6 +37,7 @@ import { normalizeCustomProxy } from '../store/useDownloadStore'; import { MAX_TORRENT_MAX_OPEN_FILES, MIN_TORRENT_MAX_OPEN_FILES, + normalizeSpeedLimitForBackend, normalizeTorrentMaxOpenFiles } from '../utils/downloads'; import { useTranslation } from 'react-i18next'; @@ -326,7 +327,11 @@ const engineRunId = useRef(0); const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState( () => String(settings.torrentMaxOpenFiles) ); + const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState( + () => settings.torrentOverallUploadLimit + ); const torrentMaxOpenFilesCommitRef = useRef(0); + const torrentOverallUploadLimitCommitRef = useRef(0); useEffect(() => { setPerServerConnectionsInput(String(settings.perServerConnections)); @@ -344,6 +349,10 @@ const engineRunId = useRef(0); setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles)); }, [settings.torrentMaxOpenFiles]); + useEffect(() => { + setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit); + }, [settings.torrentOverallUploadLimit]); + // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); const [loginUser, setLoginUser] = useState(''); @@ -375,6 +384,32 @@ const engineRunId = useRef(0); }); }); }; + const commitTorrentOverallUploadLimit = (raw: string) => { + const trimmed = raw.trim(); + const normalized = trimmed ? (normalizeSpeedLimitForBackend(trimmed) ?? '') : ''; + if (trimmed && !normalized) { + setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit); + addToast({ + message: t($ => $.settings.network.torrentOverallUploadLimitInvalid), + variant: 'error', + isActionable: true + }); + return; + } + const requestId = ++torrentOverallUploadLimitCommitRef.current; + setTorrentOverallUploadLimitInput(normalized); + void settings.setTorrentOverallUploadLimit(normalized).catch(error => { + if (requestId !== torrentOverallUploadLimitCommitRef.current) return; + setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit); + addToast({ + message: t($ => $.settings.network.torrentOverallUploadLimitUpdateFailed, { + detail: error instanceof Error ? error.message : String(error) + }), + variant: 'error', + isActionable: true + }); + }); + }; const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [manualUpdateStatus, setManualUpdateStatus] = useState({ type: 'idle' }); @@ -1395,6 +1430,21 @@ runEngineChecks(false); aria-label={t($ => $.settings.network.torrentMaxOpenFiles)} /> +
+
+ {t($ => $.settings.network.torrentOverallUploadLimit)} + {t($ => $.settings.network.torrentOverallUploadLimitDescription)} +
+ setTorrentOverallUploadLimitInput(event.target.value)} + onBlur={(event) => commitTorrentOverallUploadLimit(event.target.value)} + placeholder="1M" + className="app-control settings-network-input text-center" + aria-label={t($ => $.settings.network.torrentOverallUploadLimit)} + /> +

{t($ => $.settings.network.identity)}

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 91caa71..b9238df 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -824,6 +824,10 @@ const common = { torrentMaxOpenFiles: 'Maximum open Torrent files', torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.', torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}', + torrentOverallUploadLimit: 'Overall Aria2 upload limit', + torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.', + torrentOverallUploadLimitInvalid: 'Enter a valid upload limit, such as 512K or 2M.', + torrentOverallUploadLimitUpdateFailed: 'Could not apply the overall Aria2 upload limit: {{detail}}', identity: 'Identity', customUserAgent: 'Custom User-Agent', userAgentDescription: 'Applied to metadata fetches and download engines.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index f15bc64..925ab7f 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -824,6 +824,10 @@ const fa = { torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت', torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.', torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}', + torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2', + torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.', + torrentOverallUploadLimitInvalid: 'یک محدودیت معتبر مثل 512K یا 2M برای آپلود وارد کنید.', + torrentOverallUploadLimitUpdateFailed: 'اعمال محدودیت کلی آپلود Aria2 ممکن نشد: {{detail}}', identity: 'هویت', customUserAgent: 'User-Agent سفارشی', userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index a207264..e4d95d5 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -824,6 +824,10 @@ const he = { torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי', torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.', torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}', + torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2', + torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.', + torrentOverallUploadLimitInvalid: 'הזן מגבלת העלאה תקפה, למשל 512K או 2M.', + torrentOverallUploadLimitUpdateFailed: 'לא ניתן להחיל את מגבלת ההעלאה הכוללת של Aria2: {{detail}}', identity: 'זהות', customUserAgent: 'User-Agent מותאם אישית', userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 1468013..eca9d76 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -824,6 +824,10 @@ const ru = { torrentMaxOpenFiles: 'Максимум открытых файлов Torrent', torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.', torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}', + torrentOverallUploadLimit: 'Общий лимит отдачи Aria2', + torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.', + torrentOverallUploadLimitInvalid: 'Введите корректный лимит отдачи, например 512K или 2M.', + torrentOverallUploadLimitUpdateFailed: 'Не удалось применить общий лимит отдачи Aria2: {{detail}}', identity: 'Идентификация', customUserAgent: 'Собственный User-Agent', userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 8e5a9cf..edd315f 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -824,6 +824,10 @@ const uk = { torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent', torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.', torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}', + torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2', + torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.', + torrentOverallUploadLimitInvalid: 'Введіть коректне обмеження віддачі, наприклад 512K або 2M.', + torrentOverallUploadLimitUpdateFailed: 'Не вдалося застосувати загальне обмеження віддачі Aria2: {{detail}}', identity: 'Ідентифікація', customUserAgent: 'Власний User-Agent', userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 64b0276..a7a5b39 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -824,6 +824,10 @@ const zhCN = { torrentMaxOpenFiles: 'Torrent 最大打开文件数', torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。', torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}', + torrentOverallUploadLimit: 'Aria2 总上传限制', + torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。', + torrentOverallUploadLimitInvalid: '请输入有效的上传限制,例如 512K 或 2M。', + torrentOverallUploadLimitUpdateFailed: '无法应用 Aria2 总上传限制:{{detail}}', identity: '身份', customUserAgent: '自定义 User-Agent', userAgentDescription: '应用于元数据获取和下载引擎。', diff --git a/src/ipc.ts b/src/ipc.ts index deb6c1a..23805e7 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -78,6 +78,7 @@ type CommandMap = { }; get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics }; set_torrent_max_open_files: { args: { max_open_files: number }; result: void }; + set_torrent_overall_upload_limit: { args: { limit: string | null }; result: void }; set_global_speed_limit: { args: { limit: string | null }; result: void }; request_automation_permission: { args: undefined; result: void }; check_automation_permission: { args: undefined; result: void }; diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index bcd583c..51521fb 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -155,6 +155,53 @@ describe('useSettingsStore global speed limit persistence', () => { }); }); +describe('useSettingsStore Torrent overall upload limit persistence', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ torrentOverallUploadLimit: '2M' }); + }); + + it('applies a normalized limit before updating local state', async () => { + await useSettingsStore.getState().setTorrentOverallUploadLimit('1.5 MB/s'); + + expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', { + limit: '1.5M' + }); + expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('1.5M'); + }); + + it('keeps the saved value when the native global option rejects an update', async () => { + vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable')); + + await expect( + useSettingsStore.getState().setTorrentOverallUploadLimit('3M') + ).rejects.toThrow('aria2 unavailable'); + + expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M'); + }); + + it('uses null to restore Aria2 unlimited upload', async () => { + await useSettingsStore.getState().setTorrentOverallUploadLimit(''); + + expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', { + limit: null + }); + expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe(''); + }); + + it('rejects malformed limits without clearing the saved value', async () => { + await expect( + useSettingsStore.getState().setTorrentOverallUploadLimit('not-a-rate') + ).rejects.toThrow('Torrent overall upload limit is invalid'); + + expect(ipc.invokeCommand).not.toHaveBeenCalledWith( + 'set_torrent_overall_upload_limit', + expect.anything() + ); + expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M'); + }); +}); + describe('useSettingsStore dock badge synchronization', () => { it('increments the badge sync version for every toggle without issuing out-of-band clears', () => { vi.clearAllMocks(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 8d705c2..4d57b3b 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -37,6 +37,7 @@ import { let settingsQueue: Promise = Promise.resolve(); let torrentMaxOpenFilesQueue: Promise = Promise.resolve(); +let torrentOverallUploadLimitQueue: Promise = Promise.resolve(); let pairingTokenHydrationRequest: Promise | null = null; const settingsPersistenceErrorListeners = new Set<() => void>(); let settingsPersistenceFailed = false; @@ -212,6 +213,7 @@ export interface SettingsState { approvedDownloadRoots: string[]; maxConcurrentDownloads: number; globalSpeedLimit: string; + torrentOverallUploadLimit: string; speedLimitPresetValues: number[]; logsEnabled: boolean; isSidebarVisible: boolean; @@ -279,6 +281,7 @@ export interface SettingsState { approveDownloadRoot: (path: string) => Promise; setMaxConcurrentDownloads: (count: number) => void; setGlobalSpeedLimit: (limit: string) => Promise; + setTorrentOverallUploadLimit: (limit: string) => Promise; setSpeedLimitPresetValues: (values: number[]) => void; setLogsEnabled: (enabled: boolean) => void; setSidebarPosition: (position: SidebarPosition) => void; @@ -358,6 +361,7 @@ export const useSettingsStore = create()( approvedDownloadRoots: [], maxConcurrentDownloads: 3, globalSpeedLimit: '', + torrentOverallUploadLimit: '', speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES, logsEnabled: false, activeView: 'downloads', @@ -464,6 +468,23 @@ export const useSettingsStore = create()( info('Settings updated: globalSpeedLimit'); set({ globalSpeedLimit: limit }); }, + setTorrentOverallUploadLimit: (limit) => { + const normalizedLimit = normalizeSpeedLimitForBackend(limit); + if (limit.trim() && !normalizedLimit) { + return Promise.reject(new Error('Torrent overall upload limit is invalid')); + } + const normalized = normalizedLimit ?? ''; + const apply = async () => { + await invoke('set_torrent_overall_upload_limit', { + limit: normalized || null + }); + info('Settings updated: torrentOverallUploadLimit'); + set({ torrentOverallUploadLimit: normalized }); + }; + const result = torrentOverallUploadLimitQueue.then(apply, apply); + torrentOverallUploadLimitQueue = result.then(() => undefined, () => undefined); + return result; + }, setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }), setLogsEnabled: (logsEnabled) => set({ logsEnabled }), setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }), @@ -688,6 +709,7 @@ export const useSettingsStore = create()( approvedDownloadRoots: state.approvedDownloadRoots, maxConcurrentDownloads: state.maxConcurrentDownloads, globalSpeedLimit: state.globalSpeedLimit, + torrentOverallUploadLimit: state.torrentOverallUploadLimit, speedLimitPresetValues: state.speedLimitPresetValues, logsEnabled: state.logsEnabled, isSidebarVisible: state.isSidebarVisible, @@ -859,6 +881,9 @@ export const useSettingsStore = create()( 12, currentState.maxConcurrentDownloads ), + torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string' + ? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? '' + : currentState.torrentOverallUploadLimit, perServerConnections: clampSettingInteger( persisted.perServerConnections, 1,