feat(torrents): add aggregate upload limit control

This commit is contained in:
NimBold
2026-08-02 23:36:48 +03:30
parent b2c86a2ec4
commit cba485ef44
16 changed files with 466 additions and 95 deletions
+204 -89
View File
@@ -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 1604800 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
14096 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 14096, 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.
+16 -1
View File
@@ -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 });
+2
View File
@@ -462,6 +462,8 @@ pub struct PersistedSettings {
pub approved_download_roots: Vec<String>,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
#[serde(default)]
pub torrent_overall_upload_limit: String,
pub speed_limit_preset_values: Vec<f64>,
pub logs_enabled: bool,
pub is_sidebar_visible: bool,
+74 -4
View File
@@ -6609,6 +6609,18 @@ pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
})
}
fn normalize_torrent_overall_upload_limit(limit: Option<&str>) -> Result<Option<String>, 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<String>,
) -> 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<_>>(),
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<_>>(),
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<_>>(),
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,
+22
View File
@@ -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]
+1 -1
View File
@@ -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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+50
View File
@@ -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<ManualUpdateStatus>({ type: 'idle' });
@@ -1395,6 +1430,21 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span>
<small>{t($ => $.settings.network.torrentOverallUploadLimitDescription)}</small>
</div>
<input
type="text"
value={torrentOverallUploadLimitInput}
onChange={(event) => 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)}
/>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
+4
View File
@@ -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.',
+4
View File
@@ -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: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.',
+4
View File
@@ -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: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
+4
View File
@@ -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: 'Применяется при получении метаданных и работе движков загрузки.',
+4
View File
@@ -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: 'Застосовується до запитів метаданих та рушіїв завантаження.',
+4
View File
@@ -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: '应用于元数据获取和下载引擎。',
+1
View File
@@ -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 };
+47
View File
@@ -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();
+25
View File
@@ -37,6 +37,7 @@ import {
let settingsQueue: Promise<void> = Promise.resolve();
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
let torrentOverallUploadLimitQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | 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<string>;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => Promise<void>;
setTorrentOverallUploadLimit: (limit: string) => Promise<void>;
setSpeedLimitPresetValues: (values: number[]) => void;
setLogsEnabled: (enabled: boolean) => void;
setSidebarPosition: (position: SidebarPosition) => void;
@@ -358,6 +361,7 @@ export const useSettingsStore = create<SettingsState>()(
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
torrentOverallUploadLimit: '',
speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
logsEnabled: false,
activeView: 'downloads',
@@ -464,6 +468,23 @@ export const useSettingsStore = create<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
12,
currentState.maxConcurrentDownloads
),
torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string'
? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? ''
: currentState.torrentOverallUploadLimit,
perServerConnections: clampSettingInteger(
persisted.perServerConnections,
1,