mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 00:16:37 +00:00
feat(torrents): add aggregate upload limit control
This commit is contained in:
+204
-89
@@ -1,103 +1,218 @@
|
|||||||
# Firelink Torrent feature matrix
|
# Firelink Torrent feature matrix
|
||||||
|
|
||||||
This is the current product-facing comparison for BitTorrent features exposed
|
This document is the source of truth for Firelink's BitTorrent scope, current
|
||||||
by Firelink's bundled Aria2 engine. It intentionally excludes Aria2's generic
|
implementation status, and next work. It compares Firelink with the
|
||||||
HTTP/FTP/Metalink options, shell hooks, and daemon-admin RPC methods that do not
|
BitTorrent-specific surface of the bundled Aria2 1.37.0 engine. Aria2's
|
||||||
belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.html).
|
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
|
## Implemented
|
||||||
|
|
||||||
|
### Intake, metadata, and file selection
|
||||||
|
|
||||||
- Local `.torrent` files, magnet links, and remote HTTP(S) `.torrent` metadata.
|
- Local `.torrent` files, magnet links, and remote HTTP(S) `.torrent` metadata.
|
||||||
Remote metadata is bounded, SSRF-checked, redirect-checked, parsed, and
|
Remote metadata is bounded, redirect/SSRF checked, credential-free, parsed,
|
||||||
cached before it enters the normal `addTorrent` path.
|
and cached before enqueue.
|
||||||
- Bencode validation, canonical info-hash checks, safe output paths, managed
|
- Strict bencode parsing, sorted-key validation, size/depth bounds, UTF-8
|
||||||
metadata retention, selected-file preview, `select-file`, and `index-out`.
|
validation, canonical info-hash verification, safe output components, and
|
||||||
- Firelink queue admission, per-queue/global permits, pause/resume, cancel,
|
managed metadata retention/rekeying.
|
||||||
retry/GID replacement, restart recovery, terminal reconciliation, and
|
- Selected-file preview and validated `select-file` handling. Firelink derives
|
||||||
output ownership for Torrent lifecycles.
|
the Torrent output contract with Aria2 `index-out`; it does not use the
|
||||||
- Optional seeding by time and/or ratio, upload progress, upload limits,
|
generic `out` option for Torrent files.
|
||||||
seeders telemetry, per-Torrent maximum peers, and the Aria2
|
- Torrent metadata probing uses Aria2 `bt-metadata-only` and `bt-save-metadata`
|
||||||
`bt-request-peer-speed-limit` threshold.
|
internally, validates the returned hash, and conservatively cleans probe
|
||||||
- Bounded, read-only Torrent peer diagnostics through `aria2.getPeers`.
|
directories. It is not exposed as a separate metadata-only download mode.
|
||||||
Firelink discards peer IPs, ports, IDs, and bitfields at the native boundary;
|
- `addTorrent` passes validated web-seed/mirror URIs when supplied through the
|
||||||
the selected-Torrent detail view exposes only operational speeds, seeder,
|
existing download input. There is no separate Torrent web-seed manager.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
### Evidence already present in the tree
|
||||||
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.
|
|
||||||
|
|
||||||
The first implementation in this task was remote `.torrent` metadata intake;
|
- Rust unit coverage for bencode/hash/path validation, option normalization,
|
||||||
follow-up implementations add stall-timeout control, bounded peer diagnostics,
|
queue ownership, lifecycle fencing, persistence sanitization, and native
|
||||||
persisted tracker exclusion, piece-preview priority, safe unselected-file
|
startup argument construction.
|
||||||
removal, the validated encryption policy, tracker timing controls, the global
|
- `src-tauri/tests/torrent_rpc.rs` covers the production authenticated JSON-RPC
|
||||||
Torrent open-file limit, launch-scoped Torrent network binding controls, and
|
HTTP boundary in a Windows-compatible integration-test target.
|
||||||
peer identity/agent controls.
|
- `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.
|
||||||
|
|||||||
@@ -712,6 +712,21 @@ async function main() {
|
|||||||
globalOptions['bt-max-open-files'] === '256',
|
globalOptions['bt-max-open-files'] === '256',
|
||||||
`Aria2 did not retain the global Torrent open-file limit: ${JSON.stringify(globalOptions['bt-max-open-files'])}`,
|
`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);
|
const probeRemoved = await forceRemoveIfPresent(client, probeGid);
|
||||||
if (probeRemoved) await waitForRemoved(client, probeGid);
|
if (probeRemoved) await waitForRemoved(client, probeGid);
|
||||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
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'));
|
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
|
||||||
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
|
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');
|
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');
|
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
|
||||||
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
|
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ pub struct PersistedSettings {
|
|||||||
pub approved_download_roots: Vec<String>,
|
pub approved_download_roots: Vec<String>,
|
||||||
pub max_concurrent_downloads: usize,
|
pub max_concurrent_downloads: usize,
|
||||||
pub global_speed_limit: String,
|
pub global_speed_limit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub torrent_overall_upload_limit: String,
|
||||||
pub speed_limit_preset_values: Vec<f64>,
|
pub speed_limit_preset_values: Vec<f64>,
|
||||||
pub logs_enabled: bool,
|
pub logs_enabled: bool,
|
||||||
pub is_sidebar_visible: bool,
|
pub is_sidebar_visible: bool,
|
||||||
|
|||||||
+74
-4
@@ -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(
|
fn apply_aria2_torrent_peer_discovery_options(
|
||||||
command: &mut std::process::Command,
|
command: &mut std::process::Command,
|
||||||
enable_dht: bool,
|
enable_dht: bool,
|
||||||
@@ -6675,10 +6687,14 @@ fn aria2_rpc_port_is_occupied(port: u16) -> bool {
|
|||||||
fn apply_aria2_torrent_global_options(
|
fn apply_aria2_torrent_global_options(
|
||||||
command: &mut std::process::Command,
|
command: &mut std::process::Command,
|
||||||
max_open_files: u32,
|
max_open_files: u32,
|
||||||
|
overall_upload_limit: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)
|
let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)
|
||||||
.unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
.unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||||
command.arg(format!("--bt-max-open-files={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")]
|
#[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}"))
|
.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]
|
#[tauri::command]
|
||||||
async fn set_global_speed_limit(
|
async fn set_global_speed_limit(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -7795,6 +7829,7 @@ mod tests {
|
|||||||
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
||||||
metadata_headers, metadata_response_error,
|
metadata_headers, metadata_response_error,
|
||||||
normalize_speed_limit_for_aria2,
|
normalize_speed_limit_for_aria2,
|
||||||
|
normalize_torrent_overall_upload_limit,
|
||||||
apply_aria2_torrent_global_options,
|
apply_aria2_torrent_global_options,
|
||||||
apply_aria2_torrent_network_options,
|
apply_aria2_torrent_network_options,
|
||||||
apply_aria2_torrent_peer_identity_options,
|
apply_aria2_torrent_peer_identity_options,
|
||||||
@@ -7853,18 +7888,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
||||||
let mut command = std::process::Command::new("aria2c");
|
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!(
|
assert_eq!(
|
||||||
command
|
command
|
||||||
.get_args()
|
.get_args()
|
||||||
.map(|arg| arg.to_string_lossy().into_owned())
|
.map(|arg| arg.to_string_lossy().into_owned())
|
||||||
.collect::<Vec<_>>(),
|
.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");
|
let mut fallback_command = std::process::Command::new("aria2c");
|
||||||
apply_aria2_torrent_global_options(
|
apply_aria2_torrent_global_options(
|
||||||
&mut fallback_command,
|
&mut fallback_command,
|
||||||
queue::MAX_TORRENT_MAX_OPEN_FILES + 1,
|
queue::MAX_TORRENT_MAX_OPEN_FILES + 1,
|
||||||
|
Some("not-a-rate"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fallback_command
|
fallback_command
|
||||||
@@ -7873,6 +7909,32 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec!["--bt-max-open-files=100"]
|
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]
|
#[test]
|
||||||
@@ -10583,6 +10645,10 @@ pub fn run() {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|settings| settings.global_speed_limit.clone())
|
.map(|settings| settings.global_speed_limit.clone())
|
||||||
.unwrap_or_default();
|
.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
|
let torrent_peer_discovery = persisted_settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|settings| {
|
.map(|settings| {
|
||||||
@@ -10658,7 +10724,11 @@ pub fn run() {
|
|||||||
.arg("--check-certificate=true")
|
.arg("--check-certificate=true")
|
||||||
.arg(format!("--stop-with-process={}", std::process::id()));
|
.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(
|
apply_aria2_torrent_peer_discovery_options(
|
||||||
&mut cmd,
|
&mut cmd,
|
||||||
@@ -11268,7 +11338,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
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,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
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,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
@@ -475,6 +475,10 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
|||||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
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 = crate::queue::normalize_torrent_max_open_files(
|
||||||
settings.torrent_max_open_files,
|
settings.torrent_max_open_files,
|
||||||
)
|
)
|
||||||
@@ -692,6 +696,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
approved_download_roots: Vec::new(),
|
approved_download_roots: Vec::new(),
|
||||||
max_concurrent_downloads: 3,
|
max_concurrent_downloads: 3,
|
||||||
global_speed_limit: String::new(),
|
global_speed_limit: String::new(),
|
||||||
|
torrent_overall_upload_limit: String::new(),
|
||||||
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
||||||
logs_enabled: false,
|
logs_enabled: false,
|
||||||
is_sidebar_visible: true,
|
is_sidebar_visible: true,
|
||||||
@@ -793,6 +798,7 @@ mod tests {
|
|||||||
"state": {
|
"state": {
|
||||||
"maxConcurrentDownloads": 7,
|
"maxConcurrentDownloads": 7,
|
||||||
"globalSpeedLimit": "2M",
|
"globalSpeedLimit": "2M",
|
||||||
|
"torrentOverallUploadLimit": "1.5M",
|
||||||
"sidebarPosition": "right",
|
"sidebarPosition": "right",
|
||||||
"scheduler": {
|
"scheduler": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -811,6 +817,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(settings.max_concurrent_downloads, 7);
|
assert_eq!(settings.max_concurrent_downloads, 7);
|
||||||
assert_eq!(settings.global_speed_limit, "2M");
|
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.sidebar_position, "right");
|
||||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||||
assert!(!settings.logs_enabled);
|
assert!(!settings.logs_enabled);
|
||||||
@@ -836,6 +843,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||||
assert_eq!(settings.global_speed_limit, "512K");
|
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.last_custom_speed_limit_unit, "MB/s");
|
||||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||||
assert!(!settings.logs_enabled);
|
assert!(!settings.logs_enabled);
|
||||||
@@ -858,6 +866,20 @@ mod tests {
|
|||||||
assert!(settings.logs_enabled);
|
assert!(settings.logs_enabled);
|
||||||
assert!(!settings.scheduler.enabled);
|
assert!(!settings.scheduler.enabled);
|
||||||
assert!(settings.global_speed_limit.is_empty());
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
|
|||||||
import type { Theme } from "./Theme";
|
import type { Theme } from "./Theme";
|
||||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
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, };
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { normalizeCustomProxy } from '../store/useDownloadStore';
|
|||||||
import {
|
import {
|
||||||
MAX_TORRENT_MAX_OPEN_FILES,
|
MAX_TORRENT_MAX_OPEN_FILES,
|
||||||
MIN_TORRENT_MAX_OPEN_FILES,
|
MIN_TORRENT_MAX_OPEN_FILES,
|
||||||
|
normalizeSpeedLimitForBackend,
|
||||||
normalizeTorrentMaxOpenFiles
|
normalizeTorrentMaxOpenFiles
|
||||||
} from '../utils/downloads';
|
} from '../utils/downloads';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -326,7 +327,11 @@ const engineRunId = useRef(0);
|
|||||||
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||||
() => String(settings.torrentMaxOpenFiles)
|
() => String(settings.torrentMaxOpenFiles)
|
||||||
);
|
);
|
||||||
|
const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState(
|
||||||
|
() => settings.torrentOverallUploadLimit
|
||||||
|
);
|
||||||
const torrentMaxOpenFilesCommitRef = useRef(0);
|
const torrentMaxOpenFilesCommitRef = useRef(0);
|
||||||
|
const torrentOverallUploadLimitCommitRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPerServerConnectionsInput(String(settings.perServerConnections));
|
setPerServerConnectionsInput(String(settings.perServerConnections));
|
||||||
@@ -344,6 +349,10 @@ const engineRunId = useRef(0);
|
|||||||
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
||||||
}, [settings.torrentMaxOpenFiles]);
|
}, [settings.torrentMaxOpenFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
|
||||||
|
}, [settings.torrentOverallUploadLimit]);
|
||||||
|
|
||||||
// Local state for adding site login
|
// Local state for adding site login
|
||||||
const [loginPattern, setLoginPattern] = useState('');
|
const [loginPattern, setLoginPattern] = useState('');
|
||||||
const [loginUser, setLoginUser] = 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 [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
||||||
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
|
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
|
||||||
|
|
||||||
@@ -1395,6 +1430,21 @@ runEngineChecks(false);
|
|||||||
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
|
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
|
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const common = {
|
|||||||
torrentMaxOpenFiles: 'Maximum open Torrent files',
|
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.',
|
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}}',
|
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',
|
identity: 'Identity',
|
||||||
customUserAgent: 'Custom User-Agent',
|
customUserAgent: 'Custom User-Agent',
|
||||||
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const fa = {
|
|||||||
torrentMaxOpenFiles: 'حداکثر فایلهای باز تورنت',
|
torrentMaxOpenFiles: 'حداکثر فایلهای باز تورنت',
|
||||||
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایلهای همزمان باز در تورنتهای چندفایلی. مقدار کمتر مصرف file descriptor را کم میکند؛ پیشفرض ۱۰۰ است. تغییرات برای تورنتهای جدید و بدون راهاندازی مجدد Aria2 اعمال میشوند و محدودیت سیستمعامل را افزایش نمیدهند.',
|
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایلهای همزمان باز در تورنتهای چندفایلی. مقدار کمتر مصرف file descriptor را کم میکند؛ پیشفرض ۱۰۰ است. تغییرات برای تورنتهای جدید و بدون راهاندازی مجدد Aria2 اعمال میشوند و محدودیت سیستمعامل را افزایش نمیدهند.',
|
||||||
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایلهای باز تورنت ممکن نشد: {{detail}}',
|
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایلهای باز تورنت ممکن نشد: {{detail}}',
|
||||||
|
torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2',
|
||||||
|
torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود میکند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنتهاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال میشود و پس از راهاندازی مجدد Firelink برمیگردد.',
|
||||||
|
torrentOverallUploadLimitInvalid: 'یک محدودیت معتبر مثل 512K یا 2M برای آپلود وارد کنید.',
|
||||||
|
torrentOverallUploadLimitUpdateFailed: 'اعمال محدودیت کلی آپلود Aria2 ممکن نشد: {{detail}}',
|
||||||
identity: 'هویت',
|
identity: 'هویت',
|
||||||
customUserAgent: 'User-Agent سفارشی',
|
customUserAgent: 'User-Agent سفارشی',
|
||||||
userAgentDescription: 'در دریافتهای متادیتا و موتورهای دانلود اعمال میشود.',
|
userAgentDescription: 'در دریافتهای متادیتا و موتورهای دانلود اعمال میشود.',
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const he = {
|
|||||||
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
|
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
|
||||||
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
|
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
|
||||||
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
|
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
|
||||||
|
torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2',
|
||||||
|
torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.',
|
||||||
|
torrentOverallUploadLimitInvalid: 'הזן מגבלת העלאה תקפה, למשל 512K או 2M.',
|
||||||
|
torrentOverallUploadLimitUpdateFailed: 'לא ניתן להחיל את מגבלת ההעלאה הכוללת של Aria2: {{detail}}',
|
||||||
identity: 'זהות',
|
identity: 'זהות',
|
||||||
customUserAgent: 'User-Agent מותאם אישית',
|
customUserAgent: 'User-Agent מותאם אישית',
|
||||||
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
|
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const ru = {
|
|||||||
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
|
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
|
||||||
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
|
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
|
||||||
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
|
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
|
||||||
|
torrentOverallUploadLimit: 'Общий лимит отдачи Aria2',
|
||||||
|
torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.',
|
||||||
|
torrentOverallUploadLimitInvalid: 'Введите корректный лимит отдачи, например 512K или 2M.',
|
||||||
|
torrentOverallUploadLimitUpdateFailed: 'Не удалось применить общий лимит отдачи Aria2: {{detail}}',
|
||||||
identity: 'Идентификация',
|
identity: 'Идентификация',
|
||||||
customUserAgent: 'Собственный User-Agent',
|
customUserAgent: 'Собственный User-Agent',
|
||||||
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
|
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const uk = {
|
|||||||
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
|
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
|
||||||
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
|
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
|
||||||
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
|
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
|
||||||
|
torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2',
|
||||||
|
torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.',
|
||||||
|
torrentOverallUploadLimitInvalid: 'Введіть коректне обмеження віддачі, наприклад 512K або 2M.',
|
||||||
|
torrentOverallUploadLimitUpdateFailed: 'Не вдалося застосувати загальне обмеження віддачі Aria2: {{detail}}',
|
||||||
identity: 'Ідентифікація',
|
identity: 'Ідентифікація',
|
||||||
customUserAgent: 'Власний User-Agent',
|
customUserAgent: 'Власний User-Agent',
|
||||||
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
|
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ const zhCN = {
|
|||||||
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
|
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
|
||||||
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
|
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
|
||||||
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
|
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
|
||||||
|
torrentOverallUploadLimit: 'Aria2 总上传限制',
|
||||||
|
torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。',
|
||||||
|
torrentOverallUploadLimitInvalid: '请输入有效的上传限制,例如 512K 或 2M。',
|
||||||
|
torrentOverallUploadLimitUpdateFailed: '无法应用 Aria2 总上传限制:{{detail}}',
|
||||||
identity: '身份',
|
identity: '身份',
|
||||||
customUserAgent: '自定义 User-Agent',
|
customUserAgent: '自定义 User-Agent',
|
||||||
userAgentDescription: '应用于元数据获取和下载引擎。',
|
userAgentDescription: '应用于元数据获取和下载引擎。',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type CommandMap = {
|
|||||||
};
|
};
|
||||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||||
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
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 };
|
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||||
request_automation_permission: { args: undefined; result: void };
|
request_automation_permission: { args: undefined; result: void };
|
||||||
check_automation_permission: { args: undefined; result: void };
|
check_automation_permission: { args: undefined; result: void };
|
||||||
|
|||||||
@@ -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', () => {
|
describe('useSettingsStore dock badge synchronization', () => {
|
||||||
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
|
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
|
|
||||||
let settingsQueue: Promise<void> = Promise.resolve();
|
let settingsQueue: Promise<void> = Promise.resolve();
|
||||||
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
|
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
|
||||||
|
let torrentOverallUploadLimitQueue: Promise<void> = Promise.resolve();
|
||||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||||
let settingsPersistenceFailed = false;
|
let settingsPersistenceFailed = false;
|
||||||
@@ -212,6 +213,7 @@ export interface SettingsState {
|
|||||||
approvedDownloadRoots: string[];
|
approvedDownloadRoots: string[];
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
globalSpeedLimit: string;
|
globalSpeedLimit: string;
|
||||||
|
torrentOverallUploadLimit: string;
|
||||||
speedLimitPresetValues: number[];
|
speedLimitPresetValues: number[];
|
||||||
logsEnabled: boolean;
|
logsEnabled: boolean;
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
@@ -279,6 +281,7 @@ export interface SettingsState {
|
|||||||
approveDownloadRoot: (path: string) => Promise<string>;
|
approveDownloadRoot: (path: string) => Promise<string>;
|
||||||
setMaxConcurrentDownloads: (count: number) => void;
|
setMaxConcurrentDownloads: (count: number) => void;
|
||||||
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
||||||
|
setTorrentOverallUploadLimit: (limit: string) => Promise<void>;
|
||||||
setSpeedLimitPresetValues: (values: number[]) => void;
|
setSpeedLimitPresetValues: (values: number[]) => void;
|
||||||
setLogsEnabled: (enabled: boolean) => void;
|
setLogsEnabled: (enabled: boolean) => void;
|
||||||
setSidebarPosition: (position: SidebarPosition) => void;
|
setSidebarPosition: (position: SidebarPosition) => void;
|
||||||
@@ -358,6 +361,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
approvedDownloadRoots: [],
|
approvedDownloadRoots: [],
|
||||||
maxConcurrentDownloads: 3,
|
maxConcurrentDownloads: 3,
|
||||||
globalSpeedLimit: '',
|
globalSpeedLimit: '',
|
||||||
|
torrentOverallUploadLimit: '',
|
||||||
speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
|
speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
|
||||||
logsEnabled: false,
|
logsEnabled: false,
|
||||||
activeView: 'downloads',
|
activeView: 'downloads',
|
||||||
@@ -464,6 +468,23 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
info('Settings updated: globalSpeedLimit');
|
info('Settings updated: globalSpeedLimit');
|
||||||
set({ globalSpeedLimit: limit });
|
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 }),
|
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
|
||||||
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
||||||
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
|
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
|
||||||
@@ -688,6 +709,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
approvedDownloadRoots: state.approvedDownloadRoots,
|
approvedDownloadRoots: state.approvedDownloadRoots,
|
||||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||||
globalSpeedLimit: state.globalSpeedLimit,
|
globalSpeedLimit: state.globalSpeedLimit,
|
||||||
|
torrentOverallUploadLimit: state.torrentOverallUploadLimit,
|
||||||
speedLimitPresetValues: state.speedLimitPresetValues,
|
speedLimitPresetValues: state.speedLimitPresetValues,
|
||||||
logsEnabled: state.logsEnabled,
|
logsEnabled: state.logsEnabled,
|
||||||
isSidebarVisible: state.isSidebarVisible,
|
isSidebarVisible: state.isSidebarVisible,
|
||||||
@@ -859,6 +881,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
12,
|
12,
|
||||||
currentState.maxConcurrentDownloads
|
currentState.maxConcurrentDownloads
|
||||||
),
|
),
|
||||||
|
torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string'
|
||||||
|
? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? ''
|
||||||
|
: currentState.torrentOverallUploadLimit,
|
||||||
perServerConnections: clampSettingInteger(
|
perServerConnections: clampSettingInteger(
|
||||||
persisted.perServerConnections,
|
persisted.perServerConnections,
|
||||||
1,
|
1,
|
||||||
|
|||||||
Reference in New Issue
Block a user