feat(torrents): harden lifecycle and web-seed management

- Enforce generation-safe seed admission and budget tracking.
- Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe.
- Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered.
- Ignore local TORRENT_FEATURES.md roadmap notes.
This commit is contained in:
NimBold
2026-08-03 16:56:01 +03:30
parent 79c0e48c43
commit c4d3a2be51
31 changed files with 3398 additions and 258 deletions
+2
View File
@@ -11,6 +11,8 @@ skills-lock.json
# Local agent and planning notes
AGENT.md
AGENTS.md
TORRENT_FEATURES.md
torrent_features.md
CLAUDE.md
GEMINI.md
implementation_plan.md
-230
View File
@@ -1,230 +0,0 @@
# Firelink Torrent feature matrix
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-03 at Firelink `32034e9` (`main`) plus the current working
tree, 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, 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.
- Validated metadata is also stored under a canonical lowercase hexadecimal
info-hash key. Plain magnets containing only `xt` and optional `dn` reuse
that cache before probing when the cached file has no tracker, web-seed, or
other source-specific outer metadata; tracker, web-seed, source, and unknown
query parameters conservatively force a fresh probe. Cache hits are
revalidated against bencode and the exact hash, copied into the current
draft ID, and therefore remain compatible with Add-window rekeying.
- Canonical metadata writes use a same-directory temporary file and rename;
invalid entries and abandoned canonical temporary files are removed safely.
Canonical files use a separate `.info-<hash>.torrent` namespace from
draft/final IDs, and reads are bounded before parsing. Startup retention
keeps canonical files referenced by persisted Torrent records'
`torrentInfoHash`, as well as draft/final ID-keyed files.
- `addTorrent` passes validated web-seed/mirror URIs when supplied through the
existing download input. There is no separate Torrent web-seed manager.
### Queue and lifecycle ownership
- 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.
### Transfer, seeding, and integrity controls
- 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. The selected-file
ownership and unselected-file reservation are committed atomically; Firelink
clears the reservation only after Aria2's reserved paths are absent,
including when a transfer fails. Restart recovery preserves queued/paused
and orphaned reservations while reclaiming only observed failed or completed
cleanup. Disabling the option after a detach clears the reservation before
the edited item is persisted.
### Trackers, peers, and network identity
- 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`.
### Evidence already present in the tree
- Rust unit coverage for bencode/hash/path validation, option normalization,
queue ownership, lifecycle fencing, persistence sanitization, atomic Torrent
removal reservations, conservative restart recovery, host case-insensitive
path identity, 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` | App-equivalent implemented | Firelink owns a validated, atomic, info-hash-keyed metadata cache for plain magnets, limited to metadata without source-specific outer tracker/web-seed fields, while preserving the current draft-ID/rekey contract. Source-specific magnet parameters intentionally bypass reuse; Aria2's daemon option is not exposed directly. |
| `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. **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.
+31 -1
View File
@@ -632,7 +632,37 @@ async function main() {
const cancelDir = path.join(tempRoot, 'cancel');
const removeUnselectedDir = path.join(tempRoot, 'remove-unselected');
const stallDir = path.join(tempRoot, 'stall');
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true });
const dhtStateDir = path.join(tempRoot, 'aria2-state');
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir, dhtStateDir]) fs.mkdirSync(directory, { recursive: true });
const dhtPath = path.join(dhtStateDir, 'dht.dat');
const dht6Path = path.join(dhtStateDir, 'dht6.dat');
const dhtFirst = await startDaemon({
name: 'dht-paths-first',
rpcPort: await findAvailablePort(),
listenPort: await findAvailablePort(),
directory: dhtStateDir,
extraArgs: [`--dht-file-path=${dhtPath}`, `--dht-file-path6=${dht6Path}`, '--enable-dht=true', '--enable-dht6=true', '--dht-message-timeout=42'],
});
const firstDhtOptions = await rpc(dhtFirst.rpcPort, dhtFirst.secret, 'aria2.getGlobalOption');
assert(firstDhtOptions['dht-file-path'] === dhtPath, `first daemon did not retain dht-file-path: ${JSON.stringify(firstDhtOptions['dht-file-path'])}`);
assert(firstDhtOptions['dht-file-path6'] === dht6Path, `first daemon did not retain dht-file-path6: ${JSON.stringify(firstDhtOptions['dht-file-path6'])}`);
assert(firstDhtOptions['dht-message-timeout'] === '42', `first daemon did not retain dht-message-timeout: ${JSON.stringify(firstDhtOptions['dht-message-timeout'])}`);
await stopDaemon(dhtFirst);
const dhtSecond = await startDaemon({
name: 'dht-paths-second',
rpcPort: await findAvailablePort(),
listenPort: await findAvailablePort(),
directory: dhtStateDir,
extraArgs: [`--dht-file-path=${dhtPath}`, `--dht-file-path6=${dht6Path}`, '--enable-dht=true', '--enable-dht6=true', '--dht-message-timeout=42'],
});
const secondDhtOptions = await rpc(dhtSecond.rpcPort, dhtSecond.secret, 'aria2.getGlobalOption');
assert(secondDhtOptions['dht-file-path'] === dhtPath, `second daemon did not retain dht-file-path: ${JSON.stringify(secondDhtOptions['dht-file-path'])}`);
assert(secondDhtOptions['dht-file-path6'] === dht6Path, `second daemon did not retain dht-file-path6: ${JSON.stringify(secondDhtOptions['dht-file-path6'])}`);
assert(secondDhtOptions['dht-message-timeout'] === '42', `second daemon did not retain dht-message-timeout: ${JSON.stringify(secondDhtOptions['dht-message-timeout'])}`);
await stopDaemon(dhtSecond);
console.log('[OK] Aria2 DHT routing-table paths and message timeout remained explicit across two launches');
const seederListenPort = await findAvailablePort();
const clientListenPort = await findAvailablePort();
+98
View File
@@ -34,6 +34,18 @@ fn default_torrent_max_open_files() -> u32 {
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
}
fn default_torrent_dht_message_timeout() -> u32 {
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
}
fn default_torrent_separate_seed_slots() -> bool {
false
}
fn default_torrent_max_concurrent_seeds() -> u32 {
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -49,6 +61,10 @@ pub enum DownloadStatus {
/// A BitTorrent download has all selected data and is still seeding.
/// The Aria2 GID and queue permit remain live until seeding ends.
Seeding,
/// A BitTorrent download is complete but paused while waiting for a
/// Firelink-owned seeding slot.
#[serde(rename = "waitingToSeed")]
WaitingToSeed,
Paused,
Completed,
Failed,
@@ -66,6 +82,7 @@ impl DownloadStatus {
Self::Downloading => "downloading",
Self::Processing => "processing",
Self::Seeding => "seeding",
Self::WaitingToSeed => "waitingToSeed",
Self::Paused => "paused",
Self::Completed => "completed",
Self::Failed => "failed",
@@ -188,6 +205,12 @@ pub struct DownloadItem {
pub torrent_seed_ratio: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_remaining: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
@@ -252,6 +275,48 @@ pub struct TorrentPeerDiagnostics {
pub truncated: bool,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileProgress {
pub index: u32,
pub relative_path: String,
#[ts(type = "number")]
pub length: u64,
#[ts(type = "number")]
pub completed_length: u64,
pub selected: bool,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileProgressSnapshot {
pub files: Vec<TorrentFileProgress>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentPieceProgressSnapshot {
#[ts(type = "number")]
pub piece_length: u64,
#[ts(type = "number")]
pub num_pieces: u64,
#[ts(type = "number")]
pub completed_pieces: u64,
pub buckets: Vec<u8>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentWebSeed {
#[ts(type = "number")]
pub file_index: u32,
pub uri: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -501,6 +566,12 @@ pub struct PersistedSettings {
pub torrent_enable_lpd: bool,
#[serde(default = "default_torrent_max_open_files")]
pub torrent_max_open_files: u32,
#[serde(default = "default_torrent_dht_message_timeout")]
pub torrent_dht_message_timeout: u32,
#[serde(default = "default_torrent_separate_seed_slots")]
pub torrent_separate_seed_slots: bool,
#[serde(default = "default_torrent_max_concurrent_seeds")]
pub torrent_max_concurrent_seeds: u32,
#[serde(default)]
pub torrent_listen_port: String,
#[serde(default)]
@@ -559,6 +630,8 @@ pub struct DownloadStateEvent {
pub error: Option<String>,
#[ts(optional)]
pub file_name: Option<String>,
#[ts(optional)]
pub torrent_seed_remaining: Option<f64>,
}
impl DownloadStateEvent {
@@ -568,6 +641,7 @@ impl DownloadStateEvent {
status: status.as_str().to_string(),
error: None,
file_name: None,
torrent_seed_remaining: None,
}
}
@@ -577,6 +651,7 @@ impl DownloadStateEvent {
status: DownloadStatus::Failed.as_str().to_string(),
error: Some(error.into()),
file_name: None,
torrent_seed_remaining: None,
}
}
@@ -586,6 +661,17 @@ impl DownloadStateEvent {
status: DownloadStatus::Paused.as_str().to_string(),
error: Some(error.into()),
file_name: None,
torrent_seed_remaining: None,
}
}
pub fn paused_with_seed_remaining(id: impl Into<String>, remaining: Option<f64>) -> Self {
Self {
id: id.into(),
status: DownloadStatus::Paused.as_str().to_string(),
error: None,
file_name: None,
torrent_seed_remaining: remaining,
}
}
@@ -595,6 +681,7 @@ impl DownloadStateEvent {
status: DownloadStatus::Completed.as_str().to_string(),
error: None,
file_name: Some(file_name.into()),
torrent_seed_remaining: None,
}
}
@@ -606,6 +693,17 @@ impl DownloadStateEvent {
status: DownloadStatus::Retrying.as_str().to_string(),
error: Some(reason.into()),
file_name: None,
torrent_seed_remaining: None,
}
}
pub fn waiting_to_seed(id: impl Into<String>, remaining: Option<f64>) -> Self {
Self {
id: id.into(),
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
error: None,
file_name: None,
torrent_seed_remaining: remaining,
}
}
}
+405 -4
View File
@@ -4633,11 +4633,17 @@ async fn pause_download(
}
}
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
state.queue_manager.capture_seed_remaining(&id).await
} else {
None
};
state.queue_manager.release_seed_tracking(&id);
state.queue_manager.release_permit(&id).await;
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
);
return Ok(());
}
@@ -4677,6 +4683,12 @@ async fn pause_download(
.await;
}
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
state.queue_manager.capture_seed_remaining(&id).await
} else {
None
};
state.queue_manager.release_seed_tracking(&id);
state.queue_manager.release_permit(&id).await;
if registered_lifecycle_generation.is_some()
|| removed_pending
@@ -4694,7 +4706,7 @@ async fn pause_download(
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
);
Ok(())
}
@@ -4723,6 +4735,14 @@ async fn resume_download(
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
};
if state.queue_manager.is_waiting_to_seed(&id) {
// WaitingToSeed is an intentional paused GID with no download
// permit. A resume request only wakes the Firelink seed scheduler;
// it must not bypass the seed-slot admission gate.
state.queue_manager.wake_seed_waiters();
drop(control_guard);
return Ok(true);
}
let status = aria2_download_status(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
@@ -6489,6 +6509,297 @@ async fn get_torrent_peers(
state.queue_manager.get_aria2_torrent_peers(&id).await
}
#[tauri::command]
async fn get_torrent_file_progress(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<crate::ipc::TorrentFileProgressSnapshot, String> {
state
.queue_manager
.get_aria2_torrent_file_progress(&id)
.await
}
#[tauri::command]
async fn get_torrent_piece_progress(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<crate::ipc::TorrentPieceProgressSnapshot, String> {
state
.queue_manager
.get_aria2_torrent_piece_progress(&id)
.await
}
fn replace_persisted_torrent_web_seeds(
database: &crate::db::DbState,
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
) -> Result<Option<serde_json::Value>, String> {
let mut connection = database.lock()?;
let records = crate::db::load_downloads(&connection)?;
let next_seeds = serde_json::to_value(seeds)
.map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?;
let mut previous_seeds = None;
let mut changed = false;
let mut next = Vec::with_capacity(records.len());
for record in records {
let mut value: serde_json::Value = match serde_json::from_str(&record) {
Ok(value) => value,
Err(_) => {
// Preserve unrelated legacy/corrupt rows byte-for-byte. A
// web-seed update must not fail its own transaction merely
// because another download cannot be decoded.
next.push(record);
continue;
}
};
if value
.get("id")
.and_then(serde_json::Value::as_str)
== Some(id)
{
let object = value
.as_object_mut()
.ok_or_else(|| "persisted download is not an object".to_string())?;
previous_seeds = object.get("torrentWebSeeds").cloned();
object.insert("torrentWebSeeds".to_string(), next_seeds.clone());
changed = true;
}
next.push(
serde_json::to_string(&value)
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
);
}
if !changed {
return Err("download is not persisted".to_string());
}
let next_data = serde_json::to_string(&next)
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
Ok(previous_seeds)
}
fn restore_persisted_torrent_web_seeds(
database: &crate::db::DbState,
id: &str,
expected_seeds: &[crate::ipc::TorrentWebSeed],
previous_seeds: Option<serde_json::Value>,
) -> Result<(), String> {
let mut connection = database.lock()?;
let records = crate::db::load_downloads(&connection)?;
let expected_value = serde_json::to_value(expected_seeds)
.map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?;
let mut found = false;
let mut changed = false;
let mut next = Vec::with_capacity(records.len());
for record in records {
let mut value: serde_json::Value = match serde_json::from_str(&record) {
Ok(value) => value,
Err(_) => {
next.push(record);
continue;
}
};
if value
.get("id")
.and_then(serde_json::Value::as_str)
== Some(id)
{
found = true;
let object = value
.as_object_mut()
.ok_or_else(|| "persisted download is not an object".to_string())?;
if object.get("torrentWebSeeds") == Some(&expected_value) {
match previous_seeds.clone() {
Some(previous) => {
object.insert("torrentWebSeeds".to_string(), previous);
}
None => {
object.remove("torrentWebSeeds");
}
}
changed = true;
} else {
log::warn!(
"Torrent web-seed rollback [{}] skipped because persisted state changed concurrently",
id
);
}
}
next.push(
serde_json::to_string(&value)
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
);
}
if !found {
return Err("download is no longer persisted".to_string());
}
if changed {
let next_data = serde_json::to_string(&next)
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
}
Ok(())
}
async fn normalize_persisted_torrent_web_seeds(
database: &crate::db::DbState,
app_handle: &tauri::AppHandle,
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let record = {
let connection = database.lock()?;
crate::db::load_downloads(&connection)?
.into_iter()
.find_map(|record| {
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
.ok()
.filter(|item| item.id == id)
})
.ok_or_else(|| "download is not persisted".to_string())?
};
let path = record
.torrent_path
.as_deref()
.ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?;
let path = crate::torrent::validate_managed_torrent_path(app_handle, id, path)?;
let bytes = tokio::fs::read(path)
.await
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)
}
#[tauri::command]
async fn get_torrent_web_seeds(
database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>,
id: String,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
if state.queue_manager.is_registered(&id).await
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2))
{
return state.queue_manager.get_aria2_torrent_web_seeds(&id).await;
}
let persisted_seeds = {
let connection = database.lock()?;
crate::db::load_downloads(&connection)?
.into_iter()
.find_map(|record| {
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
.ok()
.filter(|item| item.id == id)
.map(|item| item.torrent_web_seeds)
})
.ok_or_else(|| "download is not persisted".to_string())?
};
let Some(seeds) = persisted_seeds else {
return Ok(Vec::new());
};
if seeds.is_empty() {
return Ok(seeds);
}
normalize_persisted_torrent_web_seeds(
database.inner(),
&state.queue_manager.app_handle(),
&id,
&seeds,
)
.await
}
#[tauri::command]
async fn set_torrent_web_seeds(
database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>,
id: String,
seeds: Vec<crate::ipc::TorrentWebSeed>,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let active = state.queue_manager.is_registered(&id).await
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
let normalized = if active {
state
.queue_manager
.normalize_aria2_torrent_web_seeds(&id, &seeds)
.await?
} else {
normalize_persisted_torrent_web_seeds(
database.inner(),
&state.queue_manager.app_handle(),
&id,
&seeds,
)
.await?
};
let previous_seeds =
replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?;
// The download can cross the queued/active boundary while metadata is
// being normalized and persistence is updated. Recheck before returning
// so a newly active Torrent receives the live Aria2 change instead of
// waiting for a restart to apply its persisted value.
let active_now = state.queue_manager.is_registered(&id).await
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
if !active_now {
return Ok(normalized);
}
match state
.queue_manager
.set_aria2_torrent_web_seeds(&id, normalized.clone())
.await
{
Ok((result, previous_live_seeds)) => {
if let Err(persist_error) =
replace_persisted_torrent_web_seeds(database.inner(), &id, &result)
{
// The live operation succeeded, but the durable value must
// remain transactional. Try to restore the exact value that
// was persisted before this command before reporting the
// persistence failure to the caller.
if let Err(rollback_error) = state
.queue_manager
.set_aria2_torrent_web_seeds(&id, previous_live_seeds)
.await
{
log::error!(
"Torrent web-seed live rollback [{}] failed after persistence error: {}",
id,
rollback_error
);
}
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
database.inner(),
&id,
&normalized,
previous_seeds.clone(),
) {
log::error!(
"Torrent web-seed persistence rollback [{}] failed: {}",
id,
restore_error
);
}
return Err(format!(
"Torrent web seeds changed live but could not be persisted: {persist_error}"
));
}
Ok(result)
}
Err(error) => {
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
database.inner(),
&id,
&normalized,
previous_seeds,
) {
log::error!("Torrent web-seed rollback [{}] failed: {}", id, restore_error);
}
Err(error)
}
}
}
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
let trimmed = limit.trim();
if trimmed.is_empty() {
@@ -6568,6 +6879,25 @@ fn apply_aria2_torrent_network_options(
}
}
fn apply_aria2_torrent_dht_paths(
command: &mut std::process::Command,
dht_path: &std::path::Path,
dht6_path: &std::path::Path,
) {
command
.arg(format!("--dht-file-path={}", dht_path.display()))
.arg(format!("--dht-file-path6={}", dht6_path.display()));
}
fn apply_aria2_torrent_dht_options(
command: &mut std::process::Command,
message_timeout: u32,
) {
let timeout = queue::normalize_torrent_dht_message_timeout(message_timeout)
.unwrap_or(queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
command.arg(format!("--dht-message-timeout={timeout}"));
}
fn apply_aria2_torrent_peer_identity_options(
command: &mut std::process::Command,
peer_id_prefix: &str,
@@ -7255,8 +7585,12 @@ fn db_save_settings(
let prevent_system_sleep = decoded.prevents_sleep_while_downloading;
let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading;
if let Ok(mut cached) = app_state.scheduler_settings.write() {
*cached = Some(decoded);
*cached = Some(decoded.clone());
}
app_state.queue_manager.configure_seed_capacity(
decoded.torrent_separate_seed_slots,
decoded.torrent_max_concurrent_seeds,
);
if let Err(error) = app_state
.power_manager
.set_preferences(prevent_system_sleep, prevent_display_sleep)
@@ -7782,6 +8116,8 @@ mod tests {
apply_aria2_torrent_network_options,
apply_aria2_torrent_peer_identity_options,
apply_aria2_torrent_peer_discovery_options,
apply_aria2_torrent_dht_paths,
apply_aria2_torrent_dht_options,
aria2_rpc_port_is_occupied,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
@@ -7834,6 +8170,50 @@ mod tests {
);
}
#[test]
fn aria2_torrent_dht_paths_are_explicit_and_owned_by_firelink() {
let root = tempfile::tempdir().unwrap();
let dht_path = root.path().join("aria2/dht.dat");
let dht6_path = root.path().join("aria2/dht6.dat");
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_dht_paths(&mut command, &dht_path, &dht6_path);
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
format!("--dht-file-path={}", dht_path.display()),
format!("--dht-file-path6={}", dht6_path.display()),
]
);
}
#[test]
fn aria2_torrent_dht_message_timeout_is_bounded_and_launch_scoped() {
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_dht_options(&mut command, 42);
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec!["--dht-message-timeout=42"]
);
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_dht_options(&mut command, 0);
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec!["--dht-message-timeout=10"]
);
}
#[test]
fn aria2_torrent_global_options_are_bounded_and_explicit() {
let mut command = std::process::Command::new("aria2c");
@@ -10466,6 +10846,12 @@ pub fn run() {
let database = crate::db::init(&storage_layout)
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
// Establish Firelink-owned Aria2 routing-table paths after the
// existing data-root initializer has created the selected storage
// directory, but before the daemon launcher is scheduled. A
// conflict must fail startup; silently allowing Aria2 to fall back
// to a user-global dht.dat would escape the storage boundary.
let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?;
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
log::warn!("could not remove orphaned torrent probes: {error}");
}
@@ -10564,6 +10950,12 @@ pub fn run() {
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
if let Some(settings) = persisted_settings.as_ref() {
queue_manager.configure_seed_capacity(
settings.torrent_separate_seed_slots,
settings.torrent_max_concurrent_seeds,
);
}
let power_manager = queue_manager.power_manager();
if let Some(settings) = persisted_settings.as_ref() {
let _ = power_manager.set_preferences(
@@ -10723,6 +11115,15 @@ pub fn run() {
torrent_max_open_files,
Some(&torrent_overall_upload_limit),
);
apply_aria2_torrent_dht_paths(
&mut cmd,
&aria2_dht_paths.0,
&aria2_dht_paths.1,
);
apply_aria2_torrent_dht_options(
&mut cmd,
torrent_startup_settings.dht_message_timeout,
);
apply_aria2_torrent_peer_discovery_options(
&mut cmd,
@@ -11332,7 +11733,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_torrent_overall_upload_limit, 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, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
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,
+1840 -10
View File
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -18,6 +18,7 @@ pub struct TorrentStartupSettings {
pub lpd_interface: String,
pub peer_id_prefix: String,
pub peer_agent: String,
pub dht_message_timeout: u32,
}
fn normalize_torrent_startup_value(
@@ -85,6 +86,10 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
&settings.torrent_peer_agent,
crate::queue::normalize_torrent_peer_agent,
),
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
settings.torrent_dht_message_timeout,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
}
}
@@ -145,6 +150,32 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
let dht_message_timeout = state
.get("torrentDhtMessageTimeout")
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
state.insert(
"torrentDhtMessageTimeout".to_string(),
Value::Number(serde_json::Number::from(dht_message_timeout)),
);
let max_concurrent_seeds = state
.get("torrentMaxConcurrentSeeds")
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
state.insert(
"torrentMaxConcurrentSeeds".to_string(),
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
);
if !state
.get("torrentSeparateSeedSlots")
.is_some_and(Value::is_boolean)
{
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
}
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode canonical settings: {error}"))
}
@@ -314,11 +345,26 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
.contains(&value)
})
});
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
.contains(&value)
})
});
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
.contains(&value)
})
});
for key in [
"torrentEnableDht",
"torrentEnableDht6",
"torrentEnablePex",
"torrentEnableLpd",
"torrentSeparateSeedSlots",
] {
sanitize_boolean_setting(state, key);
}
@@ -483,6 +529,14 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.torrent_max_open_files,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
settings.torrent_dht_message_timeout,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
settings.torrent_max_concurrent_seeds,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
Some(&settings.torrent_listen_port),
"TCP listen ports",
@@ -735,6 +789,9 @@ fn default_settings() -> PersistedSettings {
torrent_enable_pex: true,
torrent_enable_lpd: false,
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrent_separate_seed_slots: false,
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrent_listen_port: String::new(),
torrent_dht_listen_port: String::new(),
torrent_external_ip: String::new(),
@@ -1085,6 +1142,10 @@ mod tests {
settings.torrent_max_open_files,
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
);
assert_eq!(
settings.torrent_dht_message_timeout,
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
);
assert!(settings.torrent_listen_port.is_empty());
assert!(settings.torrent_dht_listen_port.is_empty());
assert!(settings.torrent_external_ip.is_empty());
@@ -1139,7 +1200,10 @@ mod tests {
"torrentListenPort": " 6881-6999 ",
"torrentExternalIp": "not-an-ip",
"torrentPeerIdPrefix": "123456789012345678901",
"torrentPeerAgent": " Firelink/1.3.1 "
"torrentPeerAgent": " Firelink/1.3.1 ",
"torrentDhtMessageTimeout": 601,
"torrentMaxConcurrentSeeds": 65,
"torrentSeparateSeedSlots": "yes"
},
"version": 6
});
@@ -1150,6 +1214,15 @@ mod tests {
assert_eq!(canonical["state"]["torrentExternalIp"], "");
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
assert_eq!(
canonical["state"]["torrentDhtMessageTimeout"],
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
);
assert_eq!(
canonical["state"]["torrentMaxConcurrentSeeds"],
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
);
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
}
#[test]
@@ -1166,12 +1239,21 @@ mod tests {
assert!(startup.listen_port.is_empty());
assert!(startup.peer_id_prefix.is_empty());
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
assert_eq!(
startup.dht_message_timeout,
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
);
}
#[test]
fn opt_in_defaults_match_the_frontend_defaults() {
assert!(!default_settings().play_completion_sound);
assert!(!default_settings().auto_add_clipboard_links);
assert!(!default_settings().torrent_separate_seed_slots);
assert_eq!(
default_settings().torrent_max_concurrent_seeds,
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
);
}
#[test]
+113 -1
View File
@@ -5,6 +5,9 @@ pub const PORTABLE_MARKER: &str = "portable.flag";
const PORTABLE_DATA_DIR: &str = "data";
const PORTABLE_LOG_DIR: &str = "logs";
const PORTABLE_WEBVIEW_DIR: &str = "webview";
const ARIA2_DATA_DIR: &str = "aria2";
const ARIA2_DHT_FILE: &str = "dht.dat";
const ARIA2_DHT6_FILE: &str = "dht6.dat";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageMode {
@@ -104,6 +107,59 @@ impl StorageLayout {
pub fn webview_dir(&self) -> &Path {
&self.webview_dir
}
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
let directory = self.data_dir.join(ARIA2_DATA_DIR);
(
directory.join(ARIA2_DHT_FILE),
directory.join(ARIA2_DHT6_FILE),
)
}
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
/// the table contents; Firelink owns this exact location and must never
/// fall back to a user-global default when it cannot establish it.
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
let directory = self.data_dir.join(ARIA2_DATA_DIR);
if crate::path_has_symlink_component(&directory) {
return Err(format!(
"Aria2 state directory contains a symlink: '{}'",
directory.display()
));
}
match std::fs::symlink_metadata(&directory) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!(
"Aria2 state directory is a symlink: '{}'",
directory.display()
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(format!(
"Aria2 state path is not a directory: '{}'",
directory.display()
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir(&directory).map_err(|error| {
format!(
"failed to create Aria2 state directory '{}': {error}",
directory.display()
)
})?;
}
Err(error) => {
return Err(format!(
"failed to inspect Aria2 state directory '{}': {error}",
directory.display()
));
}
}
Ok(self.aria2_dht_paths())
}
}
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
@@ -154,7 +210,7 @@ fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
#[cfg(test)]
mod tests {
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
@@ -182,6 +238,62 @@ mod tests {
);
}
fn test_layout(data_dir: &Path) -> StorageLayout {
let data_dir = fs::canonicalize(data_dir).unwrap();
StorageLayout {
mode: StorageMode::Standard,
data_dir: data_dir.clone(),
log_dir: data_dir.join("logs"),
webview_dir: data_dir.join("webview"),
}
}
#[test]
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
let root = TempDir::new().unwrap();
let layout = test_layout(root.path());
let root_path = fs::canonicalize(root.path()).unwrap();
assert_eq!(
layout.aria2_dht_paths(),
(
root_path.join("aria2/dht.dat"),
root_path.join("aria2/dht6.dat")
)
);
let prepared = layout.prepare_aria2_dht_paths().unwrap();
assert_eq!(prepared, layout.aria2_dht_paths());
assert!(root_path.join("aria2").is_dir());
}
#[test]
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
let root = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
let error = test_layout(root.path())
.prepare_aria2_dht_paths()
.unwrap_err();
assert!(error.contains("not a directory"));
}
#[cfg(unix)]
#[test]
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
symlink(target.path(), root_path.join("aria2")).unwrap();
let error = test_layout(root.path())
.prepare_aria2_dht_paths()
.unwrap_err();
assert!(error.contains("symlink"));
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_storage_directories() {
+2 -1
View File
@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentWebSeeds?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, };
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, torrentSeedRemaining?: number, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying";
+2 -1
View File
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, };
+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, 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, };
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, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: 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, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentFileProgress = { index: number, relativePath: string, length: number, completedLength: number, selected: boolean, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentFileProgress } from "./TorrentFileProgress";
export type TorrentFileProgressSnapshot = { files: Array<TorrentFileProgress>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentPieceProgressSnapshot = { pieceLength: number, numPieces: number, completedPieces: number, buckets: Array<number>, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentWebSeed = { fileIndex: number, uri: string, };
+399
View File
@@ -4,6 +4,9 @@ import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow';
import { useSettingsStore } from '../store/useSettingsStore';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
@@ -42,6 +45,9 @@ const formatLastTry = (
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -99,9 +105,18 @@ export const PropertiesModal = () => {
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
@@ -119,6 +134,9 @@ export const PropertiesModal = () => {
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const torrentFileProgressRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
useEffect(() => {
@@ -132,6 +150,17 @@ export const PropertiesModal = () => {
setTorrentPeerDiagnostics(null);
setTorrentPeerDiagnosticsError(false);
setIsTorrentPeerDiagnosticsPending(false);
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -205,6 +234,9 @@ export const PropertiesModal = () => {
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
.map(seed => `${seed.fileIndex}|${seed.uri}`)
.join('\n'));
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -229,6 +261,120 @@ export const PropertiesModal = () => {
setIsTorrentPeerDiagnosticsPending(false);
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
useEffect(() => {
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
void invoke('get_torrent_file_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgressError(true);
}
})
.finally(() => {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return;
const requestId = torrentWebSeedsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentWebSeedsPending(true);
void invoke('get_torrent_web_seeds', { id: propertiesDownloadId })
.then(seeds => {
if (
requestId === torrentWebSeedsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
}
})
.catch(() => {
if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true);
})
.finally(() => {
if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false);
});
}, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
void invoke('get_torrent_piece_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgressError(true);
}
})
.finally(() => {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
setLiveTorrentMaxPeersValue(
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
@@ -321,6 +467,108 @@ export const PropertiesModal = () => {
}
};
const handleRefreshTorrentFileProgress = async () => {
if (
isTorrentFileProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
setTorrentFileProgressError(false);
try {
const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
} catch {
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentFileProgressError(true);
setTorrentFileProgress(null);
}
} finally {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
}
};
const handleRefreshTorrentPieceProgress = async () => {
if (
isTorrentPieceProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
setTorrentPieceProgressError(false);
try {
const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
} catch {
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentPieceProgressError(true);
setTorrentPieceProgress(null);
}
} finally {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
}
};
const handleTorrentWebSeedsSave = async () => {
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
const seeds: TorrentWebSeed[] = [];
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf('|');
const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : '');
const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : '';
if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) {
setTorrentWebSeedsError(true);
return;
}
seeds.push({ fileIndex, uri });
}
setIsTorrentWebSeedsPending(true);
setTorrentWebSeedsError(false);
try {
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
} catch {
setTorrentWebSeedsError(true);
} finally {
setIsTorrentWebSeedsPending(false);
}
};
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -857,6 +1105,127 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPieceProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPieceProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentPieceProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPieceProgressPending
? t($ => $.properties.torrentPieceProgressLoading)
: t($ => $.properties.torrentPieceProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressUnavailable)}
</p>
)}
{torrentPieceProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPieceProgressFailed)}
</p>
)}
{torrentPieceProgress && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentPieceProgressSummary, {
completed: torrentPieceProgress.completedPieces,
total: torrentPieceProgress.numPieces,
size: formatDownloadBytes(torrentPieceProgress.pieceLength),
})}
</div>
<div
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentPieceProgress.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentPieceProgressMap)}
>
{torrentPieceProgress.buckets.map((percentage, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-blue-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.max(0.15, percentage / 100) }}
title={`${percentage}%`}
/>
))}
</div>
</>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentFileProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentFileProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentFileProgressPending
? t($ => $.properties.torrentFileProgressLoading)
: t($ => $.properties.torrentFileProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressUnavailable)}
</p>
)}
{torrentFileProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentFileProgressFailed)}
</p>
)}
{torrentFileProgress && (
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressPath)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressCompleted)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressSelected)}</th>
</tr>
</thead>
<tbody>
{torrentFileProgress.files.map(file => {
const percentage = file.length === 0
? 100
: Math.round((file.completedLength / file.length) * 100);
return (
<tr key={file.index} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{file.index}</td>
<td className="px-2 py-1 max-w-[220px] truncate" title={file.relativePath} dir="auto">{file.relativePath}</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%)
</td>
<td className="px-2 py-1">
{file.selected
? t($ => $.properties.torrentFileProgressSelected)
: t($ => $.properties.torrentFileProgressUnselected)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
@@ -1313,6 +1682,36 @@ export const PropertiesModal = () => {
</div>
</section>
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentWebSeeds)}
</h3>
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
<textarea
value={torrentWebSeedsText}
onChange={event => setTorrentWebSeedsText(event.target.value)}
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
disabled={isTorrentWebSeedsPending}
aria-label={t($ => $.properties.torrentWebSeeds)}
className="w-full h-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
{torrentWebSeedsError ? t($ => $.properties.torrentWebSeedsFailed) : ''}
</span>
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
</button>
</div>
</section>
)}
{/* Advanced Transfer Section */}
<section>
<button
+89
View File
@@ -35,9 +35,17 @@ import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore';
import {
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
MAX_TORRENT_DHT_MESSAGE_TIMEOUT,
MIN_TORRENT_DHT_MESSAGE_TIMEOUT,
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
MAX_TORRENT_MAX_CONCURRENT_SEEDS,
MIN_TORRENT_MAX_CONCURRENT_SEEDS,
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
normalizeSpeedLimitForBackend,
normalizeTorrentDhtMessageTimeout,
normalizeTorrentMaxConcurrentSeeds,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import { useTranslation } from 'react-i18next';
@@ -327,6 +335,12 @@ const engineRunId = useRef(0);
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
() => String(settings.torrentMaxOpenFiles)
);
const [torrentDhtMessageTimeoutInput, setTorrentDhtMessageTimeoutInput] = useState(
() => String(settings.torrentDhtMessageTimeout)
);
const [torrentMaxConcurrentSeedsInput, setTorrentMaxConcurrentSeedsInput] = useState(
() => String(settings.torrentMaxConcurrentSeeds)
);
const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState(
() => settings.torrentOverallUploadLimit
);
@@ -349,6 +363,14 @@ const engineRunId = useRef(0);
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
}, [settings.torrentMaxOpenFiles]);
useEffect(() => {
setTorrentDhtMessageTimeoutInput(String(settings.torrentDhtMessageTimeout));
}, [settings.torrentDhtMessageTimeout]);
useEffect(() => {
setTorrentMaxConcurrentSeedsInput(String(settings.torrentMaxConcurrentSeeds));
}, [settings.torrentMaxConcurrentSeeds]);
useEffect(() => {
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
}, [settings.torrentOverallUploadLimit]);
@@ -384,6 +406,20 @@ const engineRunId = useRef(0);
});
});
};
const commitTorrentDhtMessageTimeout = (raw: string) => {
const next = normalizeTorrentDhtMessageTimeout(raw)
?? settings.torrentDhtMessageTimeout
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT;
setTorrentDhtMessageTimeoutInput(String(next));
settings.setTorrentDhtMessageTimeout(next);
};
const commitTorrentMaxConcurrentSeeds = (raw: string) => {
const next = normalizeTorrentMaxConcurrentSeeds(raw)
?? settings.torrentMaxConcurrentSeeds
?? DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS;
setTorrentMaxConcurrentSeedsInput(String(next));
settings.setTorrentMaxConcurrentSeeds(next);
};
const commitTorrentOverallUploadLimit = (raw: string) => {
const trimmed = raw.trim();
const normalized = trimmed ? (normalizeSpeedLimitForBackend(trimmed) ?? '') : '';
@@ -1406,6 +1442,59 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentPeerAgent)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span>
<small>{t($ => $.settings.network.torrentSeparateSeedSlotsDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentSeparateSeedSlots}
onChange={(event) => settings.setTorrentSeparateSeedSlots(event.target.checked)}
aria-label={t($ => $.settings.network.torrentSeparateSeedSlots)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentMaxConcurrentSeeds)}</span>
<small>{t($ => $.settings.network.torrentMaxConcurrentSeedsDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_MAX_CONCURRENT_SEEDS}
max={MAX_TORRENT_MAX_CONCURRENT_SEEDS}
step={1}
value={torrentMaxConcurrentSeedsInput}
onChange={(event) => setTorrentMaxConcurrentSeedsInput(event.target.value)}
onBlur={(event) => commitTorrentMaxConcurrentSeeds(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentMaxConcurrentSeeds)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
step={1}
value={torrentDhtMessageTimeoutInput}
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
+32
View File
@@ -90,6 +90,7 @@ const common = {
downloading: 'Downloading',
processing: 'Processing',
seeding: 'Seeding',
waitingToSeed: 'Waiting to seed',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
@@ -260,6 +261,30 @@ const common = {
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active.',
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.',
torrentFileProgress: 'Torrent file progress',
torrentFileProgressRefresh: 'Refresh',
torrentFileProgressLoading: 'Loading file progress…',
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
torrentFileProgressFailed: 'Could not read Torrent file progress.',
torrentFileProgressHint: 'Validated relative paths and completed bytes are shown; daemon paths and URIs are not exposed.',
torrentFileProgressPath: 'File',
torrentFileProgressCompleted: 'Completed',
torrentFileProgressSelected: 'Selected',
torrentFileProgressUnselected: 'Not selected',
torrentPieceProgress: 'Torrent piece progress',
torrentPieceProgressRefresh: 'Refresh',
torrentPieceProgressLoading: 'Loading piece progress…',
torrentPieceProgressUnavailable: 'Piece progress is available while this Torrent is active or paused.',
torrentPieceProgressFailed: 'Could not read Torrent piece progress.',
torrentPieceProgressHint: 'Each cell summarizes adjacent pieces. Raw bitfields are never exposed.',
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
torrentPieceProgressMap: 'Torrent piece completion map',
torrentWebSeeds: 'Torrent web seeds',
torrentWebSeedsHint: 'One line per seed in the form file index|HTTP(S) URI. Firelink expands multi-file paths natively.',
torrentWebSeedsPlaceholder: '0|https://mirror.example/torrent/',
torrentWebSeedsApply: 'Apply web seeds',
torrentWebSeedsLoading: 'Applying…',
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
torrentPeerDownload: 'Download',
torrentPeerUpload: 'Upload',
@@ -800,6 +825,13 @@ const common = {
torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.',
torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.',
torrentNetwork: 'BitTorrent network binding',
torrentAdvanced: 'Advanced Torrent network',
torrentDhtMessageTimeout: 'DHT message timeout',
torrentDhtMessageTimeoutDescription: 'Whole seconds for DHT and UDP message waits. This does not affect remote .torrent HTTP fetches or HTTP tracker requests. Applies after Firelink restarts.',
torrentSeparateSeedSlots: 'Separate seeding capacity',
torrentSeparateSeedSlotsDescription: 'Keep seeding outside the download limit and cap it with a Firelink-managed pool.',
torrentMaxConcurrentSeeds: 'Maximum concurrent seeds',
torrentMaxConcurrentSeedsDescription: 'Maximum number of Torrents Firelink lets seed at once when separate capacity is enabled.',
torrentListenPort: 'TCP peer ports',
torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2s default range.',
torrentDhtListenPort: 'UDP/DHT ports',
+32
View File
@@ -90,6 +90,7 @@ const fa = {
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
seeding: 'در حال اشتراک‌گذاری',
waitingToSeed: 'در انتظار اشتراک‌گذاری',
paused: 'متوقف‌شده',
completed: 'تکمیل‌شده',
failed: 'ناموفق',
@@ -260,6 +261,30 @@ const fa = {
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentFileProgress: 'پیشرفت فایل‌های تورنت',
torrentFileProgressRefresh: 'تازه‌سازی',
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایل‌ها…',
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
torrentFileProgressFailed: 'خواندن پیشرفت فایل‌های تورنت ممکن نیست.',
torrentFileProgressHint: 'مسیرهای نسبی معتبر و حجم تکمیل‌شده نمایش داده می‌شود؛ مسیرهای داخلی و URIهای daemon نمایش داده نمی‌شوند.',
torrentFileProgressPath: 'فایل',
torrentFileProgressCompleted: 'تکمیل‌شده',
torrentFileProgressSelected: 'انتخاب‌شده',
torrentFileProgressUnselected: 'انتخاب‌نشده',
torrentPieceProgress: 'پیشرفت قطعه‌های تورنت',
torrentPieceProgressRefresh: 'تازه‌سازی',
torrentPieceProgressLoading: 'در حال دریافت پیشرفت قطعه‌ها…',
torrentPieceProgressUnavailable: 'پیشرفت قطعه‌ها هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
torrentPieceProgressFailed: 'خواندن پیشرفت قطعه‌های تورنت ممکن نیست.',
torrentPieceProgressHint: 'هر خانه خلاصه‌ای از قطعه‌های مجاور است؛ bitfield خام نمایش داده نمی‌شود.',
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
torrentPieceProgressMap: 'نقشه تکمیل قطعه‌های تورنت',
torrentWebSeeds: 'وب‌سیدهای تورنت',
torrentWebSeedsHint: 'هر خط به‌شکل شماره فایل|نشانی HTTP(S). مسیر فایل‌های چندفایلی را Firelink در بخش native می‌سازد.',
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
torrentWebSeedsApply: 'اعمال وب‌سیدها',
torrentWebSeedsLoading: 'در حال اعمال…',
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وب‌سیدهای تورنت انجام نشد.',
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
@@ -800,6 +825,13 @@ const fa = {
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا می‌کند و دیده‌شدن ترافیک در شبکه محلی را افزایش می‌دهد.',
torrentPeerDiscoveryRestartNote: 'این گزینه‌ها سراسری و مربوط به Aria2 هستند و پس از راه‌اندازی مجدد Firelink اعمال می‌شوند. Aria2 همچنان کشف همتا را برای تورنت‌های خصوصی خاموش می‌کند.',
torrentNetwork: 'اتصال شبکه بیت‌تورنت',
torrentAdvanced: 'شبکه پیشرفته تورنت',
torrentDhtMessageTimeout: 'مهلت پیام DHT',
torrentDhtMessageTimeoutDescription: 'مدت انتظار پیام‌های DHT و UDP برحسب ثانیه. روی دریافت HTTP فایل .torrent یا درخواست‌های tracker از نوع HTTP اثر ندارد و پس از راه‌اندازی دوباره اعمال می‌شود.',
torrentSeparateSeedSlots: 'ظرفیت جداگانهٔ سید کردن',
torrentSeparateSeedSlotsDescription: 'سید کردن را از سقف دانلود جدا می‌کند و آن را با ظرفیت مدیریت‌شدهٔ Firelink محدود می‌کند.',
torrentMaxConcurrentSeeds: 'حداکثر سید هم‌زمان',
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنت‌هایی که Firelink هم‌زمان سید می‌کند.',
torrentListenPort: 'پورت‌های همتای TCP',
torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentDhtListenPort: 'پورت‌های UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const he = {
downloading: 'מוריד',
processing: 'מעבד',
seeding: 'משתף',
waitingToSeed: 'ממתין לשיתוף',
paused: 'מושהה',
completed: 'הושלם',
failed: 'נכשל',
@@ -260,6 +261,30 @@ const he = {
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentFileProgress: 'התקדמות קובצי הטורנט',
torrentFileProgressRefresh: 'רענון',
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
torrentFileProgressFailed: 'לא ניתן לקרוא את התקדמות קובצי הטורנט.',
torrentFileProgressHint: 'מוצגים נתיבים יחסיים מאומתים ובייטים שהושלמו; נתיבי daemon וכתובות URI אינם נחשפים.',
torrentFileProgressPath: 'קובץ',
torrentFileProgressCompleted: 'הושלם',
torrentFileProgressSelected: 'נבחר',
torrentFileProgressUnselected: 'לא נבחר',
torrentPieceProgress: 'התקדמות חלקי הטורנט',
torrentPieceProgressRefresh: 'רענון',
torrentPieceProgressLoading: 'טוען את התקדמות החלקים…',
torrentPieceProgressUnavailable: 'התקדמות החלקים זמינה כשהטורנט פעיל או מושהה.',
torrentPieceProgressFailed: 'לא ניתן לקרוא את התקדמות חלקי הטורנט.',
torrentPieceProgressHint: 'כל תא מסכם חלקים סמוכים; מפת הסיביות הגולמית אינה נחשפת.',
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
torrentWebSeeds: 'זריעות Web של טורנט',
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
torrentWebSeedsApply: 'החל זריעות Web',
torrentWebSeedsLoading: 'מיישם…',
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
@@ -800,6 +825,13 @@ const he = {
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
torrentNetwork: 'קישור רשת BitTorrent',
torrentAdvanced: 'רשת Torrent מתקדמת',
torrentDhtMessageTimeout: 'זמן קצוב להודעות DHT',
torrentDhtMessageTimeoutDescription: 'משך ההמתנה להודעות DHT ו-UDP בשניות. אינו משפיע על הורדת קובצי ‎.torrent‎ ב-HTTP או על בקשות HTTP למעקבים. חל לאחר הפעלה מחדש של Firelink.',
torrentSeparateSeedSlots: 'קיבולת זריעה נפרדת',
torrentSeparateSeedSlotsDescription: 'הפרד זריעה ממגבלת ההורדות והגבל אותה למאגר בניהול Firelink.',
torrentMaxConcurrentSeeds: 'מקסימום זריעות במקביל',
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
torrentListenPort: 'יציאות עמיתי TCP',
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentDhtListenPort: 'יציאות UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const ru = {
downloading: 'Загрузка',
processing: 'Обработка',
seeding: 'Раздача',
waitingToSeed: 'Ожидание раздачи',
paused: 'Приостановлено',
completed: 'Завершено',
failed: 'Ошибка',
@@ -260,6 +261,30 @@ const ru = {
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentFileProgress: 'Прогресс файлов торрента',
torrentFileProgressRefresh: 'Обновить',
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
torrentFileProgressFailed: 'Не удалось получить прогресс файлов торрента.',
torrentFileProgressHint: 'Показываются проверенные относительные пути и загруженные байты; пути демона и URI не раскрываются.',
torrentFileProgressPath: 'Файл',
torrentFileProgressCompleted: 'Завершено',
torrentFileProgressSelected: 'Выбран',
torrentFileProgressUnselected: 'Не выбран',
torrentPieceProgress: 'Прогресс частей торрента',
torrentPieceProgressRefresh: 'Обновить',
torrentPieceProgressLoading: 'Загрузка прогресса частей…',
torrentPieceProgressUnavailable: 'Прогресс частей доступен, пока торрент активен или приостановлен.',
torrentPieceProgressFailed: 'Не удалось получить прогресс частей торрента.',
torrentPieceProgressHint: 'Каждая ячейка объединяет соседние части; исходная битовая карта не раскрывается.',
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
torrentPieceProgressMap: 'Карта завершения частей торрента',
torrentWebSeeds: 'Веб-сиды торрента',
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
torrentWebSeedsApply: 'Применить веб-сиды',
torrentWebSeedsLoading: 'Применение…',
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
@@ -800,6 +825,13 @@ const ru = {
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
torrentNetwork: 'Сетевые параметры BitTorrent',
torrentAdvanced: 'Расширенные параметры Torrent',
torrentDhtMessageTimeout: 'Тайм-аут сообщений DHT',
torrentDhtMessageTimeoutDescription: 'Время ожидания сообщений DHT и UDP в секундах. Не влияет на загрузку .torrent по HTTP или HTTP-запросы к трекерам. Применяется после перезапуска Firelink.',
torrentSeparateSeedSlots: 'Отдельная ёмкость раздачи',
torrentSeparateSeedSlotsDescription: 'Вынести раздачу за пределы лимита загрузок и ограничить её пулом Firelink.',
torrentMaxConcurrentSeeds: 'Максимум одновременных раздач',
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
torrentListenPort: 'TCP-порты пиров',
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentDhtListenPort: 'Порты UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const uk = {
downloading: 'Завантаження',
processing: 'Обробка',
seeding: 'Роздача',
waitingToSeed: 'Очікування роздачі',
paused: 'Призупинено',
completed: 'Завершено',
failed: 'Помилка',
@@ -260,6 +261,30 @@ const uk = {
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentFileProgress: 'Прогрес файлів торрента',
torrentFileProgressRefresh: 'Оновити',
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
torrentFileProgressFailed: 'Не вдалося отримати прогрес файлів торрента.',
torrentFileProgressHint: 'Показуються перевірені відносні шляхи та завантажені байти; шляхи демона й URI не розкриваються.',
torrentFileProgressPath: 'Файл',
torrentFileProgressCompleted: 'Завершено',
torrentFileProgressSelected: 'Вибрано',
torrentFileProgressUnselected: 'Не вибрано',
torrentPieceProgress: 'Прогрес частин торрента',
torrentPieceProgressRefresh: 'Оновити',
torrentPieceProgressLoading: 'Завантаження прогресу частин…',
torrentPieceProgressUnavailable: 'Прогрес частин доступний, коли торрент активний або призупинений.',
torrentPieceProgressFailed: 'Не вдалося отримати прогрес частин торрента.',
torrentPieceProgressHint: 'Кожна клітинка узагальнює сусідні частини; необроблена бітова карта не розкривається.',
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
torrentPieceProgressMap: 'Карта завершення частин торрента',
torrentWebSeeds: 'Вебсіди торента',
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
torrentWebSeedsApply: 'Застосувати вебсіди',
torrentWebSeedsLoading: 'Застосування…',
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
@@ -800,6 +825,13 @@ const uk = {
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
torrentNetwork: 'Мережеві параметри BitTorrent',
torrentAdvanced: 'Розширені параметри Torrent',
torrentDhtMessageTimeout: 'Час очікування повідомлень DHT',
torrentDhtMessageTimeoutDescription: 'Час очікування повідомлень DHT і UDP у секундах. Не впливає на завантаження .torrent через HTTP або HTTP-запити до трекерів. Застосовується після перезапуску Firelink.',
torrentSeparateSeedSlots: 'Окрема місткість роздачі',
torrentSeparateSeedSlotsDescription: 'Винести роздачу за межі ліміту завантажень і обмежити її пулом Firelink.',
torrentMaxConcurrentSeeds: 'Максимум одночасних роздач',
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
torrentListenPort: 'TCP-порти пірів',
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentDhtListenPort: 'Порти UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const zhCN = {
downloading: '下载中',
processing: '处理中',
seeding: '做种中',
waitingToSeed: '等待做种',
paused: '已暂停',
completed: '已完成',
failed: '失败',
@@ -260,6 +261,30 @@ const zhCN = {
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。',
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
torrentFileProgress: 'Torrent 文件进度',
torrentFileProgressRefresh: '刷新',
torrentFileProgressLoading: '正在加载文件进度…',
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
torrentFileProgressFailed: '无法读取 Torrent 文件进度。',
torrentFileProgressHint: '仅显示已验证的相对路径和已完成字节数;不会暴露守护进程路径或 URI。',
torrentFileProgressPath: '文件',
torrentFileProgressCompleted: '已完成',
torrentFileProgressSelected: '已选择',
torrentFileProgressUnselected: '未选择',
torrentPieceProgress: 'Torrent 分片进度',
torrentPieceProgressRefresh: '刷新',
torrentPieceProgressLoading: '正在加载分片进度…',
torrentPieceProgressUnavailable: 'Torrent 活跃或暂停时可查看分片进度。',
torrentPieceProgressFailed: '无法读取 Torrent 分片进度。',
torrentPieceProgressHint: '每个单元格汇总相邻分片;不会暴露原始位图。',
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
torrentPieceProgressMap: 'Torrent 分片完成度地图',
torrentWebSeeds: 'Torrent Web 做种',
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
torrentWebSeedsApply: '应用 Web 做种',
torrentWebSeedsLoading: '正在应用…',
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
torrentPeerDownload: '下载',
torrentPeerUpload: '上传',
@@ -800,6 +825,13 @@ const zhCN = {
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
torrentNetwork: 'BitTorrent 网络绑定',
torrentAdvanced: '高级 Torrent 网络设置',
torrentDhtMessageTimeout: 'DHT 消息超时',
torrentDhtMessageTimeoutDescription: 'DHT 和 UDP 消息的等待时间(秒)。不影响通过 HTTP 获取 .torrent 文件,也不影响 HTTP tracker 请求。Firelink 重启后生效。',
torrentSeparateSeedSlots: '独立做种容量',
torrentSeparateSeedSlotsDescription: '将做种从下载上限中分离,并使用 Firelink 管理的容量池限制做种。',
torrentMaxConcurrentSeeds: '最大同时做种数',
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
torrentListenPort: 'TCP 节点端口',
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
torrentDhtListenPort: 'UDP/DHT 端口',
+7
View File
@@ -20,6 +20,9 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
import type { TorrentMetadata } from './bindings/TorrentMetadata';
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
type CommandMap = {
fetch_metadata: {
@@ -79,6 +82,10 @@ type CommandMap = {
result: void;
};
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
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 };
+11 -4
View File
@@ -121,7 +121,8 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'seeding' || status === 'completed' || status === 'failed') {
status === 'seeding' || status === 'waitingToSeed' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
if (status === 'paused') {
@@ -146,6 +147,7 @@ const startDownloadListeners = async () => {
}
if (current.status === 'seeding' &&
status !== 'seeding' &&
status !== 'waitingToSeed' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
@@ -153,7 +155,7 @@ const startDownloadListeners = async () => {
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const updates: Partial<DownloadItem> = {
@@ -175,6 +177,11 @@ const startDownloadListeners = async () => {
? { lastTry: new Date().toISOString() }
: {})
};
if (payload.torrentSeedRemaining != null) {
updates.torrentSeedRemaining = payload.torrentSeedRemaining;
} else if (status === 'seeding' || status === 'completed' || status === 'failed') {
updates.torrentSeedRemaining = undefined;
}
if (!payload.error && status !== 'failed' && status !== 'retrying') {
updates.lastError = undefined;
}
@@ -188,7 +195,7 @@ const startDownloadListeners = async () => {
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
}));
@@ -198,7 +205,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+40 -1
View File
@@ -347,6 +347,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_seed_remaining: item.torrentSeedRemaining,
torrent_web_seeds: item.torrentWebSeeds,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
@@ -625,6 +627,12 @@ export const hasStaleTemporaryMediaEstimate = (
};
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
const rawSeedRemaining = download.torrentSeedRemaining as unknown;
const normalizedSeedRemaining = typeof rawSeedRemaining === 'number' &&
Number.isFinite(rawSeedRemaining) &&
rawSeedRemaining >= 0
? rawSeedRemaining
: undefined;
const rawMaxPeers = download.torrentMaxPeers as unknown;
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
Number.isInteger(rawMaxPeers) &&
@@ -632,6 +640,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawMaxPeers <= 1000
? rawMaxPeers
: undefined;
const rawWebSeeds = download.torrentWebSeeds as unknown;
const normalizedWebSeeds = Array.isArray(rawWebSeeds)
? rawWebSeeds.filter((seed): seed is { fileIndex: number; uri: string } =>
!!seed && typeof seed === 'object' &&
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
(seed as { fileIndex: number }).fileIndex >= 0 &&
typeof (seed as { uri?: unknown }).uri === 'string' &&
(seed as { uri: string }).uri.length <= 2048
).slice(0, 256)
: undefined;
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
@@ -671,7 +690,9 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
: undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
rawWebSeeds !== normalizedWebSeeds ||
rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers ||
@@ -685,6 +706,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawEncryptionPolicy !== normalizedEncryptionPolicy
? {
...download,
torrentSeedRemaining: normalizedSeedRemaining,
torrentWebSeeds: normalizedWebSeeds,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity,
@@ -2171,6 +2194,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (pendingStartupResume) return pendingStartupResume;
const operation = (async () => {
// WaitingToSeed is a paused Aria2 GID owned by the previous process;
// that GID cannot survive an app restart. Reconstruct the row as a
// queued Torrent using its persisted remaining seed budget, then let
// the normal backend admission path assign a fresh lifecycle/GID.
const waitingToSeedIds = get().downloads
.filter(download => download.status === 'waitingToSeed')
.map(download => download.id);
if (waitingToSeedIds.length > 0) {
set(state => ({
downloads: state.downloads.map(download => waitingToSeedIds.includes(download.id)
? { ...download, status: 'queued' }
: download)
}));
}
const active = get().downloads
.filter(d => d.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
@@ -2236,6 +2273,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_seed_remaining: item.torrentSeedRemaining,
torrent_web_seeds: item.torrentWebSeeds,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
+40
View File
@@ -24,7 +24,10 @@ import {
DEFAULT_TORRENT_MAX_OPEN_FILES,
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
normalizeSpeedLimitForBackend,
normalizeTorrentDhtMessageTimeout,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import i18n from '../i18n';
@@ -248,6 +251,9 @@ export interface SettingsState {
torrentEnablePex: boolean;
torrentEnableLpd: boolean;
torrentMaxOpenFiles: number;
torrentDhtMessageTimeout: number;
torrentSeparateSeedSlots: boolean;
torrentMaxConcurrentSeeds: number;
torrentListenPort: string;
torrentDhtListenPort: string;
torrentExternalIp: string;
@@ -313,6 +319,9 @@ export interface SettingsState {
setTorrentEnablePex: (enabled: boolean) => void;
setTorrentEnableLpd: (enabled: boolean) => void;
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
setTorrentDhtMessageTimeout: (value: number) => void;
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
setTorrentMaxConcurrentSeeds: (value: number) => void;
setTorrentListenPort: (value: string) => void;
setTorrentDhtListenPort: (value: string) => void;
setTorrentExternalIp: (value: string) => void;
@@ -404,6 +413,9 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnablePex: true,
torrentEnableLpd: false,
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrentSeparateSeedSlots: false,
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrentListenPort: '',
torrentDhtListenPort: '',
torrentExternalIp: '',
@@ -553,6 +565,19 @@ export const useSettingsStore = create<SettingsState>()(
torrentMaxOpenFilesQueue = result.then(() => undefined, () => undefined);
return result;
},
setTorrentDhtMessageTimeout: (value) => {
const normalized = normalizeTorrentDhtMessageTimeout(value);
set({
torrentDhtMessageTimeout: normalized
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
});
},
setTorrentSeparateSeedSlots: (torrentSeparateSeedSlots) => set({ torrentSeparateSeedSlots }),
setTorrentMaxConcurrentSeeds: (value) => set({
torrentMaxConcurrentSeeds: Number.isInteger(value) && value >= 1 && value <= 64
? value
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
@@ -740,6 +765,9 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnablePex: state.torrentEnablePex,
torrentEnableLpd: state.torrentEnableLpd,
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
torrentListenPort: state.torrentListenPort,
torrentDhtListenPort: state.torrentDhtListenPort,
torrentExternalIp: state.torrentExternalIp,
@@ -800,6 +828,18 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
?? currentState.torrentMaxOpenFiles,
torrentDhtMessageTimeout: normalizeTorrentDhtMessageTimeout(persisted.torrentDhtMessageTimeout)
?? currentState.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: persistedBoolean(
persisted.torrentSeparateSeedSlots,
currentState.torrentSeparateSeedSlots
),
torrentMaxConcurrentSeeds: typeof persisted.torrentMaxConcurrentSeeds === 'number'
&& Number.isInteger(persisted.torrentMaxConcurrentSeeds)
&& persisted.torrentMaxConcurrentSeeds >= 1
&& persisted.torrentMaxConcurrentSeeds <= 64
? persisted.torrentMaxConcurrentSeeds
: currentState.torrentMaxConcurrentSeeds,
torrentListenPort: typeof persisted.torrentListenPort === 'string'
? persisted.torrentListenPort
: currentState.torrentListenPort,
+3 -1
View File
@@ -4,6 +4,7 @@ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'ready',
'staged',
'paused',
'waitingToSeed',
'failed',
]);
@@ -12,6 +13,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued',
'downloading',
'seeding',
'waitingToSeed',
'processing',
'retrying',
]);
@@ -64,7 +66,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed';
+25
View File
@@ -31,6 +31,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'downloading',
'processing',
'seeding',
'waitingToSeed',
'retrying',
]);
@@ -70,6 +71,12 @@ export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
export const MIN_TORRENT_MAX_OPEN_FILES = 1;
export const MAX_TORRENT_MAX_OPEN_FILES = 4096;
export const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT = 10;
export const MIN_TORRENT_DHT_MESSAGE_TIMEOUT = 1;
export const MAX_TORRENT_DHT_MESSAGE_TIMEOUT = 600;
export const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS = 2;
export const MIN_TORRENT_MAX_CONCURRENT_SEEDS = 1;
export const MAX_TORRENT_MAX_CONCURRENT_SEEDS = 64;
const parseIntegerOption = (value: unknown): number | undefined => {
if (typeof value === 'number') {
@@ -105,6 +112,24 @@ export const normalizeTorrentMaxOpenFiles = (value: unknown): number | undefined
: undefined;
};
export const normalizeTorrentDhtMessageTimeout = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined
&& parsed >= MIN_TORRENT_DHT_MESSAGE_TIMEOUT
&& parsed <= MAX_TORRENT_DHT_MESSAGE_TIMEOUT
? parsed
: undefined;
};
export const normalizeTorrentMaxConcurrentSeeds = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined
&& parsed >= MIN_TORRENT_MAX_CONCURRENT_SEEDS
&& parsed <= MAX_TORRENT_MAX_CONCURRENT_SEEDS
? parsed
: undefined;
};
// Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components.