diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 0caf164..80f8a2d 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -46,6 +46,14 @@ fn default_torrent_max_concurrent_seeds() -> u32 { crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS } +fn default_torrent_ipv6_enabled() -> bool { + true +} + +fn default_aria2_disk_cache() -> String { + crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string() +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -72,6 +80,9 @@ pub enum DownloadStatus { /// Transient state: a connection-aware retry is in progress with /// exponential backoff. The download slot/permit is still held. Retrying, + /// Aria2 is verifying already-present Torrent data before transfer or + /// after an explicit integrity check. + Verifying, } impl DownloadStatus { @@ -88,6 +99,7 @@ impl DownloadStatus { Self::Failed => "failed", Self::Queued => "queued", Self::Retrying => "retrying", + Self::Verifying => "verifying", } } } @@ -248,6 +260,15 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_encryption_policy: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_file_allocation: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_verify_only: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_verify_restore_status: Option, } #[derive(Clone, Debug, Serialize, TS)] @@ -308,6 +329,49 @@ pub struct TorrentPieceProgressSnapshot { pub buckets: Vec, } +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentFileSelectionEntry { + pub index: u32, + pub relative_path: String, + #[ts(type = "number")] + pub length: u64, + pub selected: bool, + #[ts(type = "number")] + #[ts(optional)] + pub completed_length: Option, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentFileSelectionSnapshot { + pub files: Vec, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentDetails { + pub info_hash: String, + pub display_name: String, + #[ts(type = "number")] + pub total_bytes: u64, + #[ts(type = "number")] + pub file_count: u32, + #[ts(type = "number")] + pub piece_length: u64, + #[ts(type = "number")] + pub piece_count: u64, + pub private: bool, + pub creation_date: Option, + pub creator: Option, + pub comment: Option, + pub trackers: Vec, + pub web_seeds: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -572,6 +636,8 @@ pub struct PersistedSettings { pub torrent_separate_seed_slots: bool, #[serde(default = "default_torrent_max_concurrent_seeds")] pub torrent_max_concurrent_seeds: u32, + #[serde(default = "default_torrent_ipv6_enabled")] + pub torrent_ipv6_enabled: bool, #[serde(default)] pub torrent_listen_port: String, #[serde(default)] @@ -590,6 +656,10 @@ pub struct PersistedSettings { pub torrent_peer_id_prefix: String, #[serde(default)] pub torrent_peer_agent: String, + #[serde(default)] + pub torrent_bind_address: String, + #[serde(default = "default_aria2_disk_cache")] + pub aria2_disk_cache: String, pub custom_user_agent: String, pub ask_where_to_save_each_file: bool, pub remember_last_used_download_directory: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b9348b2..3d5e226 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -350,7 +350,7 @@ fn parse_media_playlist_metadata( let playlist_id = value .get("id") .and_then(|id| id.as_str()) - .map(str::trim) + .map(|value| value.trim()) .filter(|id| !id.is_empty()) .map(ToOwned::to_owned); @@ -472,7 +472,7 @@ fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value .get(key) .and_then(|v| v.as_str()) - .map(str::trim) + .map(|value| value.trim()) .filter(|v| !v.is_empty()) } @@ -5343,21 +5343,36 @@ async fn detach_download_for_reconfigure( id: String, ) -> Result<(), String> { log::info!("detach_download_for_reconfigure called for id: {}", id); - let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; - let active_kind = state.queue_manager.active_kind(&id).await; + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + detach_download_for_reconfigure_locked( + &app_handle, + state.inner(), + &id, + &control_guard, + ) + .await +} + +async fn detach_download_for_reconfigure_locked( + app_handle: &tauri::AppHandle, + state: &AppState, + id: &str, + _control_guard: &queue::Aria2ControlGuard, +) -> Result<(), String> { + let active_kind = state.queue_manager.active_kind(id).await; let media_lifecycle_generation = state .queue_manager - .registered_lifecycle_generation(&id) + .registered_lifecycle_generation(id) .await .unwrap_or_default(); - state.queue_manager.remove_from_pending(&id).await; - let gid = state.queue_manager.aria2_gid_for_download(&id); + state.queue_manager.remove_from_pending(id).await; + let gid = state.queue_manager.aria2_gid_for_download(id); if let Some(gid) = gid.as_deref() { // Do not invalidate the mapped lifecycle until the daemon confirms // the detach. A failed pause must leave terminal-event reconciliation // and permit ownership intact. - state.queue_manager.cancel_aria2_retries(&id).await; + state.queue_manager.cancel_aria2_retries(id).await; let removal_result = async { let pause_res = rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -5382,24 +5397,24 @@ async fn detach_download_for_reconfigure( } .await; if let Err(error) = removal_result { - state.queue_manager.allow_aria2_retries(&id).await; + state.queue_manager.allow_aria2_retries(id).await; return Err(error); } - state.queue_manager.next_aria2_control_epoch(&id).await; - state.queue_manager.clear_aria2_retry_state(&id).await; - state.queue_manager.forget_aria2_gid(&id).await; - state.queue_manager.release_permit(&id).await; - state.queue_manager.release_registered_id(&id).await; + state.queue_manager.next_aria2_control_epoch(id).await; + state.queue_manager.clear_aria2_retry_state(id).await; + state.queue_manager.forget_aria2_gid(id).await; + state.queue_manager.release_permit(id).await; + state.queue_manager.release_registered_id(id).await; log::info!("aria2 detach [{}]: gid {} stopped and forgotten", id, gid); } else { // Invalidate a queued/addUri lifecycle that has not published a GID. - state.queue_manager.next_aria2_control_epoch(&id).await; - state.queue_manager.cancel_aria2_retries(&id).await; + state.queue_manager.next_aria2_control_epoch(id).await; + state.queue_manager.cancel_aria2_retries(id).await; let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state .download_coordinator - .pause_media_with_ack(id.clone(), media_lifecycle_generation, tx) + .pause_media_with_ack(id.to_string(), media_lifecycle_generation, tx) .await?; } else { let _ = tx.send(false); // Fallback if no task exists @@ -5408,20 +5423,20 @@ async fn detach_download_for_reconfigure( if matches!(active_kind, Some(crate::queue::TaskKind::Media)) && !media_was_registered { state .download_coordinator - .finish_media(id.clone(), media_lifecycle_generation) + .finish_media(id.to_string(), media_lifecycle_generation) .await; } - state.queue_manager.release_permit(&id).await; - state.queue_manager.clear_aria2_retry_state(&id).await; - state.queue_manager.forget_aria2_gid(&id).await; - state.queue_manager.release_registered_id(&id).await; + state.queue_manager.release_permit(id).await; + state.queue_manager.clear_aria2_retry_state(id).await; + state.queue_manager.forget_aria2_gid(id).await; + state.queue_manager.release_registered_id(id).await; } use tauri::Emitter; let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), ); Ok(()) @@ -5819,6 +5834,7 @@ async fn validate_torrent_enqueue( if item.is_media.unwrap_or(false) { return Err("torrent transfer cannot be a media download".to_string()); } + crate::torrent::validate_output_name(&item.filename)?; item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?; item.torrent_exclude_trackers = queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?; @@ -5850,6 +5866,7 @@ async fn validate_torrent_enqueue( item.torrent_file_indices.as_deref(), metadata.files.len(), )?; + item.torrent_file_indices = selected.clone(); if item.torrent_remove_unselected_file.unwrap_or(false) { let Some(selected) = selected else { return Err( @@ -5884,61 +5901,107 @@ async fn validate_torrent_enqueue( } struct ExpectedTorrentOutputPaths { + primary: std::path::PathBuf, selected: Vec, unselected: Vec, } +struct DownloadOwnershipSnapshot { + primary: Option, + owned: Vec, + removal: Vec, +} + +fn snapshot_download_ownership( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result { + Ok(DownloadOwnershipSnapshot { + primary: crate::download_ownership::primary_path_for_id(app_handle, id)?, + owned: crate::download_ownership::owned_paths_for_id(app_handle, id)?, + removal: crate::download_ownership::torrent_removal_paths_for_id(app_handle, id)?, + }) +} + +fn restore_download_ownership( + app_handle: &tauri::AppHandle, + id: &str, + snapshot: DownloadOwnershipSnapshot, +) -> Result<(), String> { + let Some(primary) = snapshot.primary else { + return crate::download_ownership::remove(app_handle, id); + }; + let owned = if snapshot.owned.is_empty() { + vec![primary.clone()] + } else { + snapshot.owned + }; + crate::download_ownership::set_owned_paths_with_primary_and_removal( + app_handle, + id, + &primary, + &owned, + &snapshot.removal, + ) +} + fn expected_torrent_output_paths( app_handle: &tauri::AppHandle, - item: &queue::EnqueueItem, + id: &str, + destination: &str, + filename: &str, + torrent_path: Option<&str>, + torrent_file_indices: Option<&[u32]>, + torrent_remove_unselected_file: bool, ) -> Result, String> { - if !item.is_torrent.unwrap_or(false) { - return Ok(None); - } - let Some(torrent_path) = item.torrent_path.as_deref() else { + let Some(torrent_path) = torrent_path else { return Ok(None); }; - let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, torrent_path)?; + let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, id, torrent_path)?; let bytes = std::fs::read(torrent_path) .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; let selected = crate::torrent::validate_selected_indices( - item.torrent_file_indices.as_deref(), + torrent_file_indices, metadata.files.len(), )?; - let destination = crate::resolve_path(&item.destination, app_handle); + let destination = crate::resolve_path(destination, app_handle); if !crate::is_safe_path(&destination, app_handle) { return Err("Path traversal blocked".to_string()); } let canonical_destination = crate::canonicalize_with_missing_components(&destination) .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; - let selected_relative = crate::torrent::aria2_output_paths(&metadata, selected.as_deref()); + let selected_relative = crate::torrent::aria2_output_paths( + &metadata, + selected.as_deref(), + filename, + ); let resolve_paths = |relative_paths: Vec| -> Result, String> { let mut paths = Vec::new(); for relative in relative_paths { - let relative = std::path::PathBuf::from(relative); - if relative.is_absolute() - || relative.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir | std::path::Component::CurDir - ) - }) - { - return Err("torrent output path is unsafe".to_string()); - } - let path = destination.join(relative); - let canonical_path = crate::canonicalize_with_missing_components(&path) - .ok_or_else(|| "torrent output path could not be canonicalized".to_string())?; - if !crate::platform::path_is_within(&canonical_path, &canonical_destination) { - return Err("torrent output path is outside its destination".to_string()); - } - paths.push(canonical_path); + let relative = std::path::PathBuf::from(relative); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err("torrent output path is unsafe".to_string()); + } + let path = destination.join(relative); + let canonical_path = crate::canonicalize_with_missing_components(&path) + .ok_or_else(|| "torrent output path could not be canonicalized".to_string())?; + if !crate::platform::path_is_within(&canonical_path, &canonical_destination) { + return Err("torrent output path is outside its destination".to_string()); + } + paths.push(canonical_path); } Ok(paths) }; let selected_paths = resolve_paths(selected_relative)?; - let unselected_paths = if item.torrent_remove_unselected_file.unwrap_or(false) { + let unselected_paths = if torrent_remove_unselected_file { let selected_indices = selected .as_deref() .ok_or_else(|| "torrent file selection is required for unselected-file removal".to_string())?; @@ -5951,7 +6014,7 @@ fn expected_torrent_output_paths( if metadata.files.len() == 1 { file.path.clone() } else { - format!("{}/{}", metadata.name, file.path) + format!("{}/{}", filename, file.path) } }) .collect::>(); @@ -5965,7 +6028,19 @@ fn expected_torrent_output_paths( } else { Vec::new() }; + let primary = if metadata.files.len() == 1 { + selected_paths + .first() + .cloned() + .ok_or_else(|| "Torrent selection produced no output path".to_string())? + } else { + resolve_paths(vec![filename.to_string()])? + .into_iter() + .next() + .ok_or_else(|| "Torrent output name is invalid".to_string())? + }; Ok(Some(ExpectedTorrentOutputPaths { + primary, selected: selected_paths, unselected: unselected_paths, })) @@ -5979,27 +6054,85 @@ fn register_download_ownership( app_handle: &tauri::AppHandle, item: &queue::EnqueueItem, ) -> Result<(), String> { - let torrent_paths = expected_torrent_output_paths(app_handle, item)?; + if item.is_torrent.unwrap_or(false) { + return register_torrent_output_ownership( + app_handle, + &item.id, + &item.destination, + &item.filename, + item.torrent_path.as_deref(), + item.torrent_file_indices.as_deref(), + item.torrent_remove_unselected_file.unwrap_or(false), + ); + } let primary = crate::download_ownership::expected_primary_path( app_handle, &item.destination, &item.filename, )?; - let (owned_paths, removal_paths) = match torrent_paths { - Some(paths) => ( - paths.selected, - if item.torrent_remove_unselected_file.unwrap_or(false) { - paths.unselected - } else { - Vec::new() - }, - ), - None => (vec![primary.clone()], Vec::new()), - }; - - crate::download_ownership::set_owned_paths_with_primary_and_removal( + set_download_output_ownership( app_handle, &item.id, + primary.clone(), + vec![primary], + Vec::new(), + ) +} + +fn register_torrent_output_ownership( + app_handle: &tauri::AppHandle, + id: &str, + destination: &str, + filename: &str, + torrent_path: Option<&str>, + torrent_file_indices: Option<&[u32]>, + torrent_remove_unselected_file: bool, +) -> Result<(), String> { + let Some(paths) = expected_torrent_output_paths( + app_handle, + id, + destination, + filename, + torrent_path, + torrent_file_indices, + torrent_remove_unselected_file, + )? else { + let primary = crate::download_ownership::expected_primary_path( + app_handle, + destination, + filename, + )?; + return set_download_output_ownership( + app_handle, + id, + primary.clone(), + vec![primary], + Vec::new(), + ); + }; + set_download_output_ownership( + app_handle, + id, + paths.primary, + paths.selected, + if torrent_remove_unselected_file { + paths.unselected + } else { + Vec::new() + }, + ) +} + +fn set_download_output_ownership( + app_handle: &tauri::AppHandle, + id: &str, + primary: std::path::PathBuf, + owned_paths: Vec, + removal_paths: Vec, +) -> Result<(), String> { + crate::download_ownership::set_owned_paths_with_primary_and_removal( + app_handle, + id, &primary, &owned_paths, &removal_paths, @@ -6234,10 +6367,21 @@ async fn remove_torrent_metadata( async fn enqueue_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, + item: queue::EnqueueItem, +) -> Result { + let id = item.id.clone(); + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + enqueue_download_locked(&app_handle, state.inner(), item, &control_guard).await +} + +async fn enqueue_download_locked( + app_handle: &tauri::AppHandle, + state: &AppState, mut item: queue::EnqueueItem, + _control_guard: &queue::Aria2ControlGuard, ) -> Result { if item.is_torrent.unwrap_or(false) { - validate_torrent_enqueue(&app_handle, &mut item) + validate_torrent_enqueue(app_handle, &mut item) .await .map_err(AppError::Internal)?; } else { @@ -6254,8 +6398,24 @@ async fn enqueue_download( .reserve_enqueue_generation(&id, lifecycle_generation) .await .map_err(AppError::Internal)?; - if let Err(error) = register_download_ownership(&app_handle, &item) { - let _ = crate::download_ownership::remove(&app_handle, &id); + let previous_ownership = match snapshot_download_ownership(app_handle, &id) { + Ok(snapshot) => snapshot, + Err(error) => { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + }; + if let Err(error) = register_download_ownership(app_handle, &item) { + if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { + log::error!( + "download ownership [{}]: failed to restore the previous mapping after enqueue registration failed: {}", + id, + restore_error + ); + } state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -6267,7 +6427,13 @@ async fn enqueue_download( .commit_reserved_enqueue(item.into_task(), lifecycle_generation) .await { - let _ = crate::download_ownership::remove(&app_handle, &id); + if let Err(restore_error) = restore_download_ownership(app_handle, &id, previous_ownership) { + log::error!( + "download ownership [{}]: failed to restore the previous mapping after enqueue commit failed: {}", + id, + restore_error + ); + } state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -6305,6 +6471,10 @@ async fn enqueue_many( let mut results = Vec::with_capacity(items.len()); for mut item in items { let id = item.id.clone(); + // Keep validation, ownership replacement, and enqueue reservation + // serialized with pause/resume/remove for this download. The guard is + // intentionally held through every early-continue path below. + let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let validation = if item.is_torrent.unwrap_or(false) { validate_torrent_enqueue(&app_handle, &mut item).await } else { @@ -6349,8 +6519,30 @@ async fn enqueue_many( continue; } }; + let previous_ownership = match snapshot_download_ownership(&app_handle, &id) { + Ok(snapshot) => snapshot, + Err(error) => { + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + }; if let Err(error) = register_download_ownership(&app_handle, &item) { - let _ = crate::download_ownership::remove(&app_handle, &id); + if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { + log::error!( + "download ownership [{}]: failed to restore the previous mapping after batch enqueue registration failed: {}", + id, + restore_error + ); + } state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -6368,7 +6560,13 @@ async fn enqueue_many( .commit_reserved_enqueue(item.into_task(), lifecycle_generation) .await { - let _ = crate::download_ownership::remove(&app_handle, &id); + if let Err(restore_error) = restore_download_ownership(&app_handle, &id, previous_ownership) { + log::error!( + "download ownership [{}]: failed to restore the previous mapping after batch enqueue commit failed: {}", + id, + restore_error + ); + } state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) @@ -6531,6 +6729,554 @@ async fn get_torrent_piece_progress( .await } +fn torrent_file_selection_snapshot( + metadata: &crate::torrent::ParsedTorrent, + selected: Option<&[u32]>, +) -> crate::ipc::TorrentFileSelectionSnapshot { + crate::ipc::TorrentFileSelectionSnapshot { + files: metadata + .files + .iter() + .map(|file| crate::ipc::TorrentFileSelectionEntry { + index: file.index, + relative_path: file.path.clone(), + length: file.length, + selected: selected.is_none_or(|indices| indices.contains(&file.index)), + completed_length: None, + }) + .collect(), + } +} + +fn load_persisted_torrent_item( + database: &crate::db::DbState, + id: &str, +) -> Result { + let connection = database.lock()?; + crate::db::load_downloads(&connection)? + .into_iter() + .find_map(|record| { + serde_json::from_str::(&record) + .ok() + .filter(|item| item.id == id) + }) + .ok_or_else(|| "download is not persisted".to_string()) +} + +fn persist_torrent_file_selection( + database: &crate::db::DbState, + id: &str, + expected: Option<&[u32]>, + selected: Option<&[u32]>, +) -> Result<(), String> { + let mut connection = database.lock()?; + let records = crate::db::load_downloads(&connection)?; + let mut changed = false; + let next = records + .into_iter() + .map(|record| { + let mut value: serde_json::Value = match serde_json::from_str(&record) { + Ok(value) => value, + Err(_) => return Ok(record), + }; + if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { + let current = match value.get("torrentFileIndices") { + None => None, + Some(serde_json::Value::Array(indices)) => Some( + indices + .iter() + .map(serde_json::Value::as_u64) + .collect::>>() + .ok_or_else(|| { + "persisted Torrent file selection is malformed".to_string() + })? + .into_iter() + .map(|index| { + u32::try_from(index).map_err(|_| { + "persisted Torrent file selection is out of range".to_string() + }) + }) + .collect::, String>>()?, + ), + Some(_) => { + return Err("persisted Torrent file selection is malformed".to_string()) + } + }; + if current.as_deref() != expected { + return Err("Torrent file selection changed; reload before applying".to_string()); + } + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + if let Some(selected) = selected { + object.insert("torrentFileIndices".to_string(), serde_json::json!(selected)); + } else { + object.remove("torrentFileIndices"); + } + changed = true; + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}")) + } else { + Ok(record) + } + }) + .collect::, String>>()?; + if !changed { + return Err("download is no longer 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()) +} + +#[tauri::command] +async fn get_torrent_file_selection( + state: tauri::State<'_, crate::db::DbState>, + app_handle: tauri::AppHandle, + id: String, +) -> Result { + let item = load_persisted_torrent_item(state.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("file selection is available only for Torrent downloads".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".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)?; + let selected = crate::torrent::validate_selected_indices( + item.torrent_file_indices.as_deref(), + metadata.files.len(), + )?; + Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref())) +} + +#[tauri::command] +async fn set_torrent_file_selection( + state: tauri::State<'_, AppState>, + database: tauri::State<'_, crate::db::DbState>, + app_handle: tauri::AppHandle, + id: String, + selected_indices: Option>, +) -> Result { + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let item = load_persisted_torrent_item(database.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("file selection is available only for Torrent downloads".to_string()); + } + if !matches!( + item.status, + crate::ipc::DownloadStatus::Ready + | crate::ipc::DownloadStatus::Staged + | crate::ipc::DownloadStatus::Queued + | crate::ipc::DownloadStatus::Paused + | crate::ipc::DownloadStatus::Failed + ) { + return Err("pause the Torrent before changing file selection".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".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)?; + let selected = crate::torrent::validate_selected_indices( + selected_indices.as_deref(), + metadata.files.len(), + )?; + if item.torrent_remove_unselected_file == Some(true) && selected.is_none() { + return Err("removing unselected Torrent files requires selecting a subset of files".to_string()); + } + + // Queued tasks already contain the fully resolved payload (proxy, + // credentials, destination, and retry settings) that the frontend cannot + // safely reconstruct in this native command. Remove that exact task from + // the admission queue while ownership and persistence are updated, then + // restore it at its original position with only the selection changed. + let pending_task = if matches!(item.status, crate::ipc::DownloadStatus::Queued) { + let pending_task = + state + .queue_manager + .take_pending_task(&id) + .await + .ok_or_else(|| { + "Torrent lifecycle changed; retry selection after the queued task settles" + .to_string() + })?; + if pending_task + .1 + .payload + .torrent_file_indices + .as_deref() + != item.torrent_file_indices.as_deref() + { + state + .queue_manager + .restore_pending_task(pending_task.0, pending_task.1) + .await; + return Err("Torrent lifecycle changed; reload before changing file selection".to_string()); + } + Some(pending_task) + } else { + None + }; + + if matches!(item.status, crate::ipc::DownloadStatus::Paused) { + if let Err(error) = detach_download_for_reconfigure_locked( + &app_handle, + state.inner(), + &id, + &control_guard, + ) + .await + { + return Err(error); + } + } + + let (destination, filename) = pending_task + .as_ref() + .map(|(_, task)| (task.payload.destination.as_str(), task.payload.filename.as_str())) + .unwrap_or_else(|| { + let destination = item.destination.as_deref().unwrap_or(""); + (destination, item.file_name.as_str()) + }); + let destination = if destination.trim().is_empty() { + verification_destination(&app_handle, &item)? + } else { + destination.to_string() + }; + let previous_ownership = snapshot_download_ownership(&app_handle, &id)?; + if let Err(error) = register_torrent_output_ownership( + &app_handle, + &id, + &destination, + filename, + item.torrent_path.as_deref(), + selected.as_deref(), + item.torrent_remove_unselected_file.unwrap_or(false), + ) { + if let Some((index, task)) = pending_task.as_ref() { + state + .queue_manager + .restore_pending_task(*index, task.clone()) + .await; + } + return Err(error); + } + + if let Err(error) = persist_torrent_file_selection( + database.inner(), + &id, + item.torrent_file_indices.as_deref(), + selected.as_deref(), + ) { + if let Err(restore_error) = restore_download_ownership( + &app_handle, + &id, + previous_ownership, + ) { + log::error!( + "torrent selection [{}]: failed to restore ownership after persistence failure: {}", + id, + restore_error + ); + } + if let Some((index, task)) = pending_task.as_ref() { + state + .queue_manager + .restore_pending_task(*index, task.clone()) + .await; + } + return Err(error); + } + + if let Some((index, mut task)) = pending_task { + task.payload.torrent_file_indices = selected.clone(); + state.queue_manager.restore_pending_task(index, task).await; + } + Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref())) +} + +#[tauri::command] +async fn get_torrent_details( + state: tauri::State<'_, crate::db::DbState>, + app_handle: tauri::AppHandle, + id: String, +) -> Result { + let item = load_persisted_torrent_item(state.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("details are available only for Torrent downloads".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".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}"))?; + crate::torrent::torrent_details_from_bytes(&bytes) +} + +fn verification_destination( + app_handle: &tauri::AppHandle, + item: &crate::ipc::DownloadItem, +) -> Result { + if let Some(destination) = item + .destination + .as_deref() + .map(str::trim) + .filter(|destination| !destination.is_empty()) + { + return Ok(destination.to_string()); + } + + let settings = crate::settings::load_settings(app_handle)?; + let base = settings.base_download_folder.trim(); + let base = if base.is_empty() { "~/Downloads" } else { base }; + let base_path = crate::resolve_path(base, app_handle); + if !settings.category_subfolders_enabled { + return Ok(base_path.to_string_lossy().to_string()); + } + + let category = format!("{:?}", item.category); + if let Some(override_path) = settings + .category_directory_overrides + .get(&category) + .map(|value| value.trim()) + .filter(|override_path| !override_path.is_empty()) + { + return Ok(crate::resolve_path(override_path, app_handle) + .to_string_lossy() + .to_string()); + } + let subfolder = settings + .category_subfolders + .get(&category) + .map(|value| value.trim()) + .unwrap_or(""); + if subfolder.is_empty() { + Ok(base_path.to_string_lossy().to_string()) + } else { + Ok(base_path.join(subfolder).to_string_lossy().to_string()) + } +} + +#[tauri::command] +async fn verify_torrent_data( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + database: tauri::State<'_, crate::db::DbState>, + id: String, +) -> Result<(), String> { + // Verification replaces the current Aria2 lifecycle. Serialize that + // replacement with pause/resume/remove so a paused GID cannot reject the + // maintenance enqueue as a duplicate task or race it with a late event. + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let item = load_persisted_torrent_item(database.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("integrity verification is available only for Torrent downloads".to_string()); + } + if !matches!( + item.status, + crate::ipc::DownloadStatus::Completed + | crate::ipc::DownloadStatus::Paused + | crate::ipc::DownloadStatus::Failed + ) { + return Err("pause the Torrent before verifying its data".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".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::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?; + let restore_status = item.status.as_str().to_string(); + let destination = verification_destination(&app_handle, &item)?; + if matches!(item.status, crate::ipc::DownloadStatus::Paused) { + detach_download_for_reconfigure_locked( + &app_handle, + state.inner(), + &id, + &control_guard, + ) + .await?; + } else if state.queue_manager.is_registered(&id).await + || state.queue_manager.aria2_gid_for_download(&id).is_some() + { + return Err("Torrent lifecycle changed; pause it before verifying its data".to_string()); + } + let enqueue_item = queue::EnqueueItem { + id: item.id.clone(), + queue_id: item + .queue_id + .clone() + .unwrap_or_else(|| "00000000-0000-0000-0000-000000000001".to_string()), + url: item.url.clone(), + destination, + filename: item.file_name.clone(), + connections: item.connections, + speed_limit: item.speed_limit.clone(), + username: item.username.clone(), + password: item.password.clone(), + headers: item.headers.clone(), + checksum: item.checksum.clone(), + cookies: item.cookies.clone(), + mirrors: None, + user_agent: None, + max_tries: Some(0), + proxy: None, + format_selector: None, + cookie_source: None, + is_media: Some(false), + is_torrent: Some(true), + torrent_path: item.torrent_path.clone(), + torrent_file_indices: item.torrent_file_indices.clone(), + torrent_info_hash: item.torrent_info_hash.clone(), + torrent_seed_time: None, + torrent_seed_ratio: None, + torrent_seed_remaining: None, + torrent_web_seeds: None, + torrent_upload_limit: None, + torrent_max_peers: None, + torrent_peer_speed_limit: None, + torrent_check_integrity: Some(true), + torrent_trackers: None, + torrent_exclude_trackers: None, + torrent_tracker_connect_timeout: None, + torrent_tracker_timeout: None, + torrent_tracker_interval: None, + torrent_stop_timeout: None, + torrent_prioritize_piece: None, + // Preserve the existing removal reservation in the ownership record, + // but the verify-only option path returns before emitting + // bt-remove-unselected-file, so this maintenance lifecycle cannot + // delete data. + torrent_remove_unselected_file: item.torrent_remove_unselected_file, + torrent_encryption_policy: None, + torrent_file_allocation: item.torrent_file_allocation.clone(), + torrent_verify_only: Some(true), + torrent_verify_restore_status: Some(restore_status.clone()), + lifecycle_generation: None, + }; + + let original_records = { + let connection = database.lock()?; + crate::db::load_downloads(&connection)? + }; + let mut next_records = Vec::with_capacity(original_records.len()); + let mut changed = false; + for record in &original_records { + let mut value: serde_json::Value = match serde_json::from_str(record) { + Ok(value) => value, + Err(_) => { + next_records.push(record.clone()); + continue; + } + }; + if value.get("id").and_then(serde_json::Value::as_str) == Some(id.as_str()) { + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + object.insert("status".to_string(), serde_json::json!("queued")); + object.insert("hasBeenDispatched".to_string(), serde_json::json!(false)); + object.insert("torrentVerifyOnly".to_string(), serde_json::json!(true)); + object.insert( + "torrentVerifyRestoreStatus".to_string(), + serde_json::json!(restore_status), + ); + changed = true; + } + next_records.push( + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}"))?, + ); + } + if !changed { + return Err("download is no longer persisted".to_string()); + } + { + let mut connection = database.lock()?; + let next_data = serde_json::to_string(&next_records) + .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; + crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?; + } + + if let Err(error) = + enqueue_download_locked(&app_handle, state.inner(), enqueue_item, &control_guard).await + { + // Roll back only this verification marker, and only while the row is + // still the queued verification lifecycle. Never restore the stale + // full array captured before enqueue: frontend persistence or another + // command may have changed unrelated rows in the meantime. + if let Ok(mut connection) = database.lock() { + if let Ok(records) = crate::db::load_downloads(&connection) { + let mut changed = false; + let next = records + .into_iter() + .map(|record| { + let mut value: serde_json::Value = match serde_json::from_str(&record) { + Ok(value) => value, + Err(_) => return record, + }; + let is_target = value + .get("id") + .and_then(serde_json::Value::as_str) + == Some(id.as_str()); + let is_verification_marker = value + .get("status") + .and_then(serde_json::Value::as_str) + == Some("queued") + && value + .get("torrentVerifyOnly") + .and_then(serde_json::Value::as_bool) + == Some(true) + && value + .get("torrentVerifyRestoreStatus") + .and_then(serde_json::Value::as_str) + == Some(restore_status.as_str()); + if is_target && is_verification_marker { + if let Some(object) = value.as_object_mut() { + object.insert( + "status".to_string(), + serde_json::json!(restore_status), + ); + object.remove("torrentVerifyOnly"); + object.remove("torrentVerifyRestoreStatus"); + changed = true; + } + } + serde_json::to_string(&value).unwrap_or(record) + }) + .collect::>(); + if changed { + if let Ok(data) = serde_json::to_string(&next) { + let _ = crate::db::replace_downloads( + &mut connection, + &data, + database.is_portable(), + ); + } + } + } + } + return Err(error.to_string()); + } + Ok(()) +} + fn replace_persisted_torrent_web_seeds( database: &crate::db::DbState, id: &str, @@ -6862,6 +7608,8 @@ fn apply_aria2_torrent_network_options( dht_entry_point6: &str, dht_listen_addr6: &str, lpd_interface: &str, + bind_address: &str, + ipv6_enabled: bool, ) { for (option, value) in [ ("--listen-port", listen_port), @@ -6877,6 +7625,12 @@ fn apply_aria2_torrent_network_options( command.arg(format!("{option}={value}")); } } + if !bind_address.trim().is_empty() { + command.arg(format!("--interface={}", bind_address.trim())); + } + if !ipv6_enabled { + command.arg("--disable-ipv6=true"); + } } fn apply_aria2_torrent_dht_paths( @@ -6922,10 +7676,14 @@ fn apply_aria2_torrent_global_options( command: &mut std::process::Command, max_open_files: u32, overall_upload_limit: Option<&str>, + disk_cache: &str, ) { let max_open_files = queue::normalize_torrent_max_open_files(max_open_files) .unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES); command.arg(format!("--bt-max-open-files={max_open_files}")); + let disk_cache = queue::normalize_aria2_disk_cache(Some(disk_cache)) + .unwrap_or_else(|_| queue::DEFAULT_ARIA2_DISK_CACHE.to_string()); + command.arg(format!("--disk-cache={disk_cache}")); if let Some(limit) = overall_upload_limit.and_then(normalize_speed_limit_for_aria2) { command.arg(format!("--max-overall-upload-limit={limit}")); } @@ -8217,35 +8975,40 @@ mod tests { #[test] fn aria2_torrent_global_options_are_bounded_and_explicit() { let mut command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options(&mut command, 256, Some("2M")); + apply_aria2_torrent_global_options(&mut command, 256, Some("2M"), "16M"); assert_eq!( command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(), - vec!["--bt-max-open-files=256", "--max-overall-upload-limit=2M"] + vec![ + "--bt-max-open-files=256", + "--disk-cache=16M", + "--max-overall-upload-limit=2M", + ] ); let mut fallback_command = std::process::Command::new("aria2c"); apply_aria2_torrent_global_options( &mut fallback_command, queue::MAX_TORRENT_MAX_OPEN_FILES + 1, Some("not-a-rate"), + "not-a-cache", ); assert_eq!( fallback_command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(), - vec!["--bt-max-open-files=100"] + vec!["--bt-max-open-files=100", "--disk-cache=16M"] ); let mut unlimited_command = std::process::Command::new("aria2c"); - apply_aria2_torrent_global_options(&mut unlimited_command, 256, None); + apply_aria2_torrent_global_options(&mut unlimited_command, 256, None, "0"); assert_eq!( unlimited_command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(), - vec!["--bt-max-open-files=256"] + vec!["--bt-max-open-files=256", "--disk-cache=0"] ); } @@ -8278,6 +9041,8 @@ mod tests { "[2001:db8::1]:6881", "2001:db8::2", "en0", + "192.0.2.10", + true, ); assert_eq!( command @@ -8292,12 +9057,23 @@ mod tests { "--dht-entry-point6=[2001:db8::1]:6881", "--dht-listen-addr6=2001:db8::2", "--bt-lpd-interface=en0", + "--interface=192.0.2.10", ] ); let mut defaults = std::process::Command::new("aria2c"); - apply_aria2_torrent_network_options(&mut defaults, "", "", "", "", "", "", ""); + apply_aria2_torrent_network_options(&mut defaults, "", "", "", "", "", "", "", "", true); assert!(defaults.get_args().next().is_none()); + + let mut ipv4_only = std::process::Command::new("aria2c"); + apply_aria2_torrent_network_options(&mut ipv4_only, "", "", "", "", "", "", "", "192.0.2.10", false); + assert_eq!( + ipv4_only + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec!["--interface=192.0.2.10", "--disable-ipv6=true"] + ); } #[test] @@ -10482,6 +11258,7 @@ struct Aria2ConnectionObservation { peak_speed_bytes: f64, last_completed: u64, seeder: bool, + verifying: bool, } struct Aria2ConnectionSample<'a> { @@ -11040,7 +11817,7 @@ pub fn run() { .map(|settings| { ( settings.torrent_enable_dht, - settings.torrent_enable_dht6, + settings.torrent_enable_dht6 && settings.torrent_ipv6_enabled, settings.torrent_enable_pex, settings.torrent_enable_lpd, ) @@ -11114,6 +11891,7 @@ pub fn run() { &mut cmd, torrent_max_open_files, Some(&torrent_overall_upload_limit), + &torrent_startup_settings.disk_cache, ); apply_aria2_torrent_dht_paths( &mut cmd, @@ -11141,6 +11919,8 @@ pub fn run() { &torrent_startup_settings.dht_entry_point6, &torrent_startup_settings.dht_listen_addr6, &torrent_startup_settings.lpd_interface, + &torrent_startup_settings.bind_address, + torrent_startup_settings.ipv6_enabled, ); apply_aria2_torrent_peer_identity_options( &mut cmd, @@ -11396,7 +12176,9 @@ pub fn run() { "numSeeders", "seeder", "connections", - "errorMessage" + "errorMessage", + "verifiedLength", + "verifyIntegrityPending" ]]); if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await { if let Some(active_arr) = active_list.as_array() { @@ -11412,6 +12194,20 @@ pub fn run() { let status = status_info.get("status").and_then(|value| value.as_str()).unwrap_or(""); let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); + let verified_length = status_info + .get("verifiedLength") + .and_then(|value| { + value + .as_str() + .and_then(|value| value.parse::().ok()) + .or_else(|| value.as_u64()) + }); + let verify_pending = status_info + .get("verifyIntegrityPending") + .is_some_and(|value| { + value.as_bool() == Some(true) + || value.as_str() == Some("true") + }); let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0.0); let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); @@ -11463,10 +12259,83 @@ pub fn run() { let entering_seeding = is_seeder && !observation.seeder; observation.seeder = is_seeder; - let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 }; - let speed = crate::download::format_speed(speed_bytes); + let is_torrent = poll_mgr.aria2_is_torrent(&id).await; + let is_verification = + poll_mgr.aria2_is_torrent_verification(&id).await; + if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) + || !poll_mgr + .is_aria2_control_epoch_current(&id, control_epoch) + .await + { + continue; + } + let is_verifying = is_torrent + && (verify_pending + || (status == "active" + && total > 0 + && completed >= total + && verified_length.is_some_and(|value| value < total))); + let entering_verifying = is_verifying && !observation.verifying; + let leaving_verifying = !is_verifying && observation.verifying; + observation.verifying = is_verifying; + if is_verification + && leaving_verifying + && total > 0 + && verified_length.is_some_and(|value| value >= total) + { + // A verification lifecycle is considered + // observed only after Aria2 leaves its + // pending/hash-check phase for this + // same GID epoch. This evidence is used + // by terminal reconciliation instead + // of treating a bare complete event as + // proof that data was verified. + poll_mgr + .record_torrent_verified_length( + &id, + control_epoch, + total, + ) + .await; + } + + if entering_verifying { + let _ = app_handle_poll.emit( + "download-state", + crate::ipc::DownloadStateEvent::new( + &id, + crate::ipc::DownloadStatus::Verifying, + ), + ); + } else if leaving_verifying && matches!(status, "active" | "waiting") { + let _ = app_handle_poll.emit( + "download-state", + crate::ipc::DownloadStateEvent::new( + &id, + crate::ipc::DownloadStatus::Downloading, + ), + ); + } + + let fraction = if is_verifying { + if total > 0 { + (verified_length.unwrap_or(0).min(total) as f64 / total as f64) + .clamp(0.0, 1.0) + } else { + 0.0 + } + } else if total > 0 { + (completed.min(total) as f64 / total as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + let speed = if is_verifying { + "0 B/s".to_string() + } else { + crate::download::format_speed(speed_bytes) + }; let upload_speed = upload_speed_bytes.map(crate::download::format_speed); - let eta = if speed_bytes > 0.0 && total > completed { + let eta = if !is_verifying && speed_bytes > 0.0 && total > completed { crate::download::format_duration((total - completed) as f64 / speed_bytes) } else { "-".to_string() @@ -11733,7 +12602,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, 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, + 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_file_selection, set_torrent_file_selection, get_torrent_details, verify_torrent_data, 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, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index b6e34cb..0fb7d31 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -41,6 +41,59 @@ pub const MAX_TORRENT_WEB_SEEDS: usize = 256; pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048; pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024; pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999"; +pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M"; +pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024; + +pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH + || value.chars().any(char::is_control) + { + return Err("Torrent bind address is too long or contains control characters".to_string()); + } + let address = value + .parse::() + .map_err(|_| "Torrent bind address must be a valid IPv4 or IPv6 address".to_string())?; + Ok(Some(address.to_string())) +} + +pub fn normalize_aria2_disk_cache(value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_ARIA2_DISK_CACHE); + if value == "0" { + return Ok("0".to_string()); + } + let (digits, multiplier, suffix) = match value.as_bytes().last().copied() { + Some(b'k' | b'K') => (&value[..value.len() - 1], 1_u64, "K"), + Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_u64, "M"), + _ => return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()), + }; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()); + } + let amount = digits + .parse::() + .map_err(|_| "Aria2 disk cache is too large".to_string())?; + let kib = amount + .checked_mul(multiplier) + .ok_or_else(|| "Aria2 disk cache is too large".to_string())?; + if kib == 0 || kib > MAX_ARIA2_DISK_CACHE_MIB * 1024 { + return Err(format!( + "Aria2 disk cache must be between 1K and {MAX_ARIA2_DISK_CACHE_MIB}M" + )); + } + Ok(format!("{amount}{suffix}")) +} + +pub fn normalize_torrent_file_allocation(value: Option<&str>) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok("prealloc".to_string()), + Some("prealloc") => Ok("prealloc".to_string()), + Some("none") => Ok("none".to_string()), + Some(_) => Err("Torrent file allocation must be prealloc or none".to_string()), + } +} pub fn clamp_download_connections(connections: i32) -> i32 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) @@ -596,6 +649,10 @@ pub struct SpawnPayload { pub torrent_prioritize_piece: Option, pub torrent_remove_unselected_file: bool, pub torrent_encryption_policy: Option, + pub torrent_file_allocation: Option, + pub torrent_verify_only: bool, + pub torrent_verify_restore_status: Option, + pub torrent_verified_length: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -1251,6 +1308,27 @@ impl QueueManager { .collect() } + /// Temporarily remove one not-yet-admitted task while a caller performs a + /// lifecycle-safe reconfiguration. The admission gate prevents the + /// dispatcher from popping the same task between the caller's validation + /// and mutation of its payload. + pub async fn take_pending_task(&self, id: &str) -> Option<(usize, QueuedTask)> { + let _admission_gate = self.admission_gate.lock().await; + let mut pending = self.pending.lock().await; + let index = pending.iter().position(|task| task.id == id)?; + pending.remove(index).map(|task| (index, task)) + } + + /// Restore a task removed by `take_pending_task`, preserving its queue + /// position even when another queue's work was admitted meanwhile. + pub async fn restore_pending_task(&self, index: usize, task: QueuedTask) { + let _admission_gate = self.admission_gate.lock().await; + let mut pending = self.pending.lock().await; + let insert_at = index.min(pending.len()); + pending.insert(insert_at, task); + self.notify.notify_one(); + } + /// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach). pub async fn release_registered_id(&self, id: &str) { self.registered_ids.lock().await.remove(id); @@ -1525,6 +1603,104 @@ impl QueueManager { .is_some_and(torrent_seeding_requested) } + pub async fn aria2_is_torrent(&self, id: &str) -> bool { + self.aria2_payloads + .lock() + .await + .get(id) + .is_some_and(|payload| payload.is_torrent) + } + + pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool { + self.aria2_payloads + .lock() + .await + .get(id) + .is_some_and(|payload| payload.torrent_verify_only) + } + + async fn capture_torrent_verification_evidence(&self, id: &str) { + if !self.aria2_is_torrent_verification(id).await { + return; + } + let Some(gid) = self.aria2_gid_for_download(id) else { + return; + }; + let Some(mapping) = self.aria2_gid_mapping(&gid) else { + return; + }; + let Some(state) = self.app_handle.try_state::() else { + return; + }; + let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); + let secret = state.aria2_secret.clone(); + drop(state); + let Ok(status) = crate::rpc_call( + port, + &secret, + "aria2.tellStatus", + serde_json::json!([gid, ["status", "totalLength", "verifiedLength", "verifyIntegrityPending"]]), + ) + .await else { + return; + }; + if !self.is_current_aria2_gid_mapping(&gid, &mapping) + || !self + .is_aria2_control_epoch_current(id, mapping.epoch) + .await + { + return; + } + let status_name = status.get("status").and_then(|value| value.as_str()); + let total = status + .get("totalLength") + .and_then(|value| value.as_str().and_then(|value| value.parse::().ok()).or_else(|| value.as_u64())); + let verified = status + .get("verifiedLength") + .and_then(|value| value.as_str().and_then(|value| value.parse::().ok()).or_else(|| value.as_u64())); + let pending = status + .get("verifyIntegrityPending") + .is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true")); + if let Some(total) = Self::complete_torrent_verification_length( + status_name, + pending, + total, + verified, + ) { + self.record_torrent_verified_length(id, mapping.epoch, total) + .await; + } + } + + fn complete_torrent_verification_length( + status: Option<&str>, + verify_pending: bool, + total: Option, + verified: Option, + ) -> Option { + if matches!(status, Some("complete" | "active" | "waiting")) + && !verify_pending + { + return verified + .zip(total) + .filter(|(verified, total)| verified >= total) + .map(|(_, total)| total); + } + None + } + + pub async fn record_torrent_verified_length(&self, id: &str, epoch: u64, length: u64) { + if !self.is_aria2_control_epoch_current(id, epoch).await { + return; + } + if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) { + if payload.torrent_verify_only { + let current = payload.torrent_verified_length.unwrap_or(0); + payload.torrent_verified_length = Some(current.max(length)); + } + } + } + async fn torrent_files_for_payload( &self, id: &str, @@ -3005,6 +3181,7 @@ impl QueueManager { let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await; let install_web_seeds = buffered_outcome.is_none() && task.payload.is_torrent + && !task.payload.torrent_verify_only && task.payload.torrent_web_seeds.is_some(); self.finish_aria2_dispatch(&id, lifecycle_epoch).await; drop(control_guard); @@ -3150,6 +3327,14 @@ impl QueueManager { .emit("download-state", DownloadStateEvent::failed(id, error)); } + fn emit_paused_with_error(&self, id: &str, error: String) { + use tauri::Emitter; + let _ = self.app_handle.emit( + "download-state", + DownloadStateEvent::paused_with_error(id, error), + ); + } + /// Store gid -> id and return any buffered terminal event for the caller /// to reconcile against the correct event path. In particular, buffered /// errors must still pass through transient retry classification. @@ -3224,7 +3409,30 @@ impl QueueManager { /// and lets commands reconcile an Aria2 terminal status without releasing /// the lock first. pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) { + if matches!(&outcome, PendingOutcome::Complete) { + self.capture_torrent_verification_evidence(id).await; + } + let (verification_restore_status, verification_only, verification_observed) = { + let payloads = self.aria2_payloads.lock().await; + payloads + .get(id) + .filter(|payload| payload.torrent_verify_only) + .map(|payload| { + ( + payload.torrent_verify_restore_status.clone(), + true, + payload.torrent_verified_length.is_some(), + ) + }) + .unwrap_or((None, false, false)) + }; let outcome = match outcome { + PendingOutcome::Complete if verification_only && !verification_observed => { + PendingOutcome::Error( + "Torrent integrity verification did not produce a complete hash-check result" + .to_string(), + ) + } PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => { if !self.seed_capacity_enabled() { // Keep a budget record even when the legacy single-pool @@ -3308,10 +3516,26 @@ impl QueueManager { } self.release_registered_id(id).await; self.release_permit(id).await; - self.emit_state(id, DownloadStatus::Completed); + let restored_status = if verification_only { + if verification_restore_status.as_deref() == Some("completed") { + DownloadStatus::Completed + } else { + DownloadStatus::Paused + } + } else { + match verification_restore_status.as_deref() { + Some("paused") => DownloadStatus::Paused, + Some("failed") => DownloadStatus::Failed, + Some("ready") => DownloadStatus::Ready, + Some("staged") => DownloadStatus::Staged, + Some("completed") => DownloadStatus::Completed, + _ => DownloadStatus::Completed, + } + }; + self.emit_state(id, restored_status); } PendingOutcome::Error(error) => { - if error.to_ascii_lowercase().contains("checksum") { + if !verification_only && error.to_ascii_lowercase().contains("checksum") { log::warn!("Checksum error detected for {}, cleaning up assets", id); if let Ok(paths) = crate::download_ownership::owned_paths_for_id(&self.app_handle, id) @@ -3322,7 +3546,13 @@ impl QueueManager { } } - log::error!("aria2 download {} failed: {}", id, error); + let error = if verification_only { + format!( + "Torrent integrity verification failed; resume the Torrent to repair it: {error}" + ) + } else { + error + }; self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; @@ -3345,7 +3575,12 @@ impl QueueManager { } self.release_registered_id(id).await; self.release_permit(id).await; - self.emit_failed(id, error); + if verification_only { + self.emit_paused_with_error(id, error); + } else { + log::error!("aria2 download {} failed: {}", id, error); + self.emit_failed(id, error); + } } PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"), } @@ -5143,6 +5378,16 @@ fn apply_aria2_torrent_options( return Ok(()); } + if payload.torrent_verify_only { + options.insert("check-integrity".to_string(), serde_json::json!("true")); + options.insert("hash-check-only".to_string(), serde_json::json!("true")); + options.insert("seed-time".to_string(), serde_json::json!("0")); + options.insert("seed-ratio".to_string(), serde_json::json!("0")); + options.insert("bt-hash-check-seed".to_string(), serde_json::json!("false")); + options.insert("bt-seed-unverified".to_string(), serde_json::json!("false")); + return Ok(()); + } + let encryption_policy = normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?; let (force_encryption, require_crypto, min_crypto_level) = @@ -5276,6 +5521,8 @@ fn apply_aria2_torrent_options( serde_json::json!("true"), ); } + let allocation = normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())?; + options.insert("file-allocation".to_string(), serde_json::json!(allocation)); if payload.torrent_check_integrity { options.insert( "check-integrity".to_string(), @@ -5379,6 +5626,9 @@ impl SidecarSpawner for ProductionSpawner { if !crate::is_safe_path(&resolved_dest, &self.app_handle) { return Err("Path traversal blocked".to_string()); } + if payload.is_torrent { + crate::torrent::validate_output_name(&payload.filename)?; + } let proxy_value = payload .proxy .as_deref() @@ -5461,7 +5711,7 @@ impl SidecarSpawner for ProductionSpawner { let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; options.insert( "index-out".to_string(), - serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)), + serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)), ); let selected = crate::torrent::validate_selected_indices( payload.torrent_file_indices.as_deref(), @@ -6017,6 +6267,15 @@ pub struct EnqueueItem { pub torrent_encryption_policy: Option, #[serde(default)] #[ts(optional)] + pub torrent_file_allocation: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_verify_only: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_verify_restore_status: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -6078,6 +6337,10 @@ impl EnqueueItem { .torrent_remove_unselected_file .unwrap_or(false), torrent_encryption_policy: self.torrent_encryption_policy, + torrent_file_allocation: self.torrent_file_allocation, + torrent_verify_only: self.torrent_verify_only.unwrap_or(false), + torrent_verify_restore_status: self.torrent_verify_restore_status, + torrent_verified_length: None, }, } } @@ -6160,6 +6423,59 @@ mod tests { ); } + #[test] + fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() { + assert_eq!( + normalize_torrent_bind_address(Some(" 2001:db8::1 ")).unwrap(), + Some("2001:db8::1".to_string()) + ); + assert_eq!(normalize_torrent_bind_address(Some(" ")).unwrap(), None); + assert!(normalize_torrent_bind_address(Some("localhost")).is_err()); + assert!(normalize_torrent_bind_address(Some("127.0.0.1\n--bad")).is_err()); + + assert_eq!(normalize_aria2_disk_cache(None).unwrap(), "16M"); + assert_eq!(normalize_aria2_disk_cache(Some(" 256m ")).unwrap(), "256M"); + assert_eq!(normalize_aria2_disk_cache(Some("1024K")).unwrap(), "1024K"); + assert_eq!(normalize_aria2_disk_cache(Some("0")).unwrap(), "0"); + assert!(normalize_aria2_disk_cache(Some("1025M")).is_err()); + assert!(normalize_aria2_disk_cache(Some("16")).is_err()); + + assert_eq!(normalize_torrent_file_allocation(None).unwrap(), "prealloc"); + assert_eq!(normalize_torrent_file_allocation(Some(" none ")).unwrap(), "none"); + assert!(normalize_torrent_file_allocation(Some("truncate")).is_err()); + } + + #[test] + fn torrent_verification_evidence_requires_matching_lengths_but_accepts_empty_data() { + assert_eq!( + QueueManager::::complete_torrent_verification_length( + Some("complete"), + false, + Some(0), + Some(0), + ), + Some(0) + ); + assert_eq!( + QueueManager::::complete_torrent_verification_length( + Some("complete"), + false, + Some(100), + Some(99), + ), + None + ); + assert_eq!( + QueueManager::::complete_torrent_verification_length( + Some("complete"), + true, + Some(100), + Some(100), + ), + None + ); + } + #[test] fn torrent_options_disable_seeding_when_no_policy_is_saved() { let mut options = serde_json::Map::new(); @@ -6354,6 +6670,28 @@ mod tests { ); } + #[test] + fn torrent_verification_uses_hash_only_options_and_ignores_transfer_policy() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_verify_only: true, + torrent_trackers: Some("https://tracker.example/announce".to_string()), + torrent_seed_ratio: Some(1.0), + torrent_file_allocation: Some("none".to_string()), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); + assert_eq!(options.get("hash-check-only"), Some(&serde_json::json!("true"))); + assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); + assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("0"))); + assert!(!options.contains_key("bt-tracker")); + assert!(!options.contains_key("file-allocation")); + } + #[test] fn torrent_integrity_check_preserves_an_explicit_seeding_policy() { let mut options = serde_json::Map::new(); @@ -7279,6 +7617,47 @@ mod tests { .is_some()); } + #[tokio::test] + async fn pending_torrent_reconfiguration_restores_position_and_payload() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner)); + for id in ["first", "target", "last"] { + manager + .push(QueuedTask { + id: id.to_string(), + queue_id: "queue".to_string(), + kind: TaskKind::Aria2, + lifecycle_generation: 0, + payload: SpawnPayload { + is_torrent: true, + ..Default::default() + }, + }) + .await + .unwrap(); + } + + let (index, mut task) = manager + .take_pending_task("target") + .await + .expect("target remains pending"); + assert_eq!(manager.pending_order(None).await, ["first", "last"]); + task.payload.torrent_file_indices = Some(vec![2]); + manager.restore_pending_task(index, task).await; + + assert_eq!( + manager.pending_order(None).await, + ["first", "target", "last"] + ); + let (_, restored) = manager + .take_pending_task("target") + .await + .expect("target was restored"); + assert_eq!(restored.payload.torrent_file_indices, Some(vec![2])); + } + #[tokio::test] async fn enabling_separate_seed_capacity_counts_existing_seeders() { let app = tauri::test::mock_builder() diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a0e91a7..98845b6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -19,6 +19,9 @@ pub struct TorrentStartupSettings { pub peer_id_prefix: String, pub peer_agent: String, pub dht_message_timeout: u32, + pub ipv6_enabled: bool, + pub bind_address: String, + pub disk_cache: String, } fn normalize_torrent_startup_value( @@ -40,6 +43,22 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent let Some(settings) = settings else { return TorrentStartupSettings::default(); }; + let bind_address = normalize_torrent_startup_value( + "Torrent bind address", + &settings.torrent_bind_address, + crate::queue::normalize_torrent_bind_address, + ); + let bind_address = if !settings.torrent_ipv6_enabled + && bind_address + .parse::() + .is_ok_and(|address| address.is_ipv6()) + { + log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled"); + String::new() + } else { + bind_address + }; + TorrentStartupSettings { listen_port: normalize_torrent_startup_value( "TCP listen ports", @@ -90,6 +109,13 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent settings.torrent_dht_message_timeout, ) .unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT), + ipv6_enabled: settings.torrent_ipv6_enabled, + bind_address, + disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache)) + .unwrap_or_else(|error| { + log::error!("invalid persisted Aria2 disk cache; using default: {error}"); + crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string() + }), } } @@ -150,6 +176,13 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result Result().ok()) + .is_some_and(|address| address.is_ipv6()) + { + return Err( + "IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(), + ); + } serde_json::to_string(&document) .map_err(|error| format!("failed to encode canonical settings: {error}")) } @@ -365,6 +418,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) { "torrentEnablePex", "torrentEnableLpd", "torrentSeparateSeedSlots", + "torrentIpv6Enabled", ] { sanitize_boolean_setting(state, key); } @@ -395,6 +449,12 @@ fn sanitize_persisted_setting_values(state: &mut Value) { sanitize_torrent_network_string(state, "torrentPeerAgent", |value| { crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok() }); + sanitize_torrent_network_string(state, "torrentBindAddress", |value| { + crate::queue::normalize_torrent_bind_address(Some(value)).is_ok() + }); + sanitize_torrent_network_string(state, "aria2DiskCache", |value| { + crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok() + }); sanitize_allowed_string( state, "theme", @@ -536,7 +596,24 @@ fn validate_settings(settings: &mut PersistedSettings) { 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); + .unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS); + settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address( + Some(&settings.torrent_bind_address), + ) + .ok() + .flatten() + .unwrap_or_default(); + if !settings.torrent_ipv6_enabled + && settings + .torrent_bind_address + .parse::() + .is_ok_and(|address| address.is_ipv6()) + { + log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled"); + settings.torrent_bind_address.clear(); + } + settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache)) + .unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()); settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec( Some(&settings.torrent_listen_port), "TCP listen ports", @@ -792,6 +869,7 @@ fn default_settings() -> PersistedSettings { 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_ipv6_enabled: true, torrent_listen_port: String::new(), torrent_dht_listen_port: String::new(), torrent_external_ip: String::new(), @@ -801,6 +879,8 @@ fn default_settings() -> PersistedSettings { torrent_lpd_interface: String::new(), torrent_peer_id_prefix: String::new(), torrent_peer_agent: String::new(), + torrent_bind_address: String::new(), + aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(), custom_user_agent: String::new(), ask_where_to_save_each_file: false, remember_last_used_download_directory: false, @@ -1225,6 +1305,20 @@ mod tests { assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false); } + #[test] + fn rejects_ipv6_bind_address_when_transport_is_disabled() { + let stored = json!({ + "state": { + "torrentIpv6Enabled": false, + "torrentBindAddress": "2001:db8::10" + } + }); + + let error = canonicalize_torrent_network_settings(&stored.to_string()) + .expect_err("IPv6 bind must not be accepted with IPv6 transport disabled"); + assert!(error.contains("IPv6 Torrent bind address")); + } + #[test] fn startup_settings_revalidate_values_at_the_aria2_boundary() { let stored = json!({ diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index 1f3405e..902bfdf 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -361,6 +361,133 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result { parse_info(info) } +fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option { + let BencodeValue::Bytes(bytes) = value? else { + return None; + }; + if bytes.len() > limit { + return None; + } + String::from_utf8(bytes.clone()) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn bounded_uri(value: &str, schemes: &[&str]) -> Option { + if value.len() > 2_048 || value.chars().any(char::is_control) { + return None; + } + let parsed = url::Url::parse(value).ok()?; + if !schemes.contains(&parsed.scheme()) + || parsed.host_str().is_none_or(str::is_empty) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return None; + } + Some(parsed.to_string()) +} + +fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec { + let mut values = Vec::new(); + let mut append = |value: &BencodeValue| { + if let BencodeValue::Bytes(bytes) = value { + if let Ok(value) = String::from_utf8(bytes.clone()) { + if let Some(uri) = bounded_uri(value.trim(), schemes) { + if !values.contains(&uri) && values.len() < 256 { + values.push(uri); + } + } + } + } + }; + match value { + Some(BencodeValue::Bytes(_)) => append(value.unwrap()), + Some(BencodeValue::List(entries)) => { + for entry in entries { + append(entry); + } + } + _ => {} + } + values +} + +pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { + return Err(format!( + "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" + )); + } + let root = match Parser::new(bytes).parse()? { + BencodeValue::Dict(value) => value, + _ => return Err("torrent root is not a dictionary".to_string()), + }; + let info = root + .get(b"info".as_slice()) + .ok_or_else(|| "torrent metadata is missing info".to_string())?; + let parsed = parse_info(info)?; + let info_dict = match info { + BencodeValue::Dict(value) => value, + _ => return Err("torrent info dictionary is invalid".to_string()), + }; + let piece_length = positive_length(info_dict.get(b"piece length".as_slice()), "piece length")?; + let piece_count = match info_dict.get(b"pieces".as_slice()) { + Some(BencodeValue::Bytes(pieces)) if pieces.len() % 20 == 0 => (pieces.len() / 20) as u64, + _ => return Err("torrent pieces field is invalid".to_string()), + }; + let creation_date = root + .get(b"creation date".as_slice()) + .and_then(|value| match value { + BencodeValue::Integer(value) if *value >= 0 => chrono::DateTime::::from_timestamp(*value, 0) + .map(|date| date.to_rfc3339()), + _ => None, + }); + let trackers = collect_torrent_uris(root.get(b"announce".as_slice()), &["http", "https", "udp"]) + .into_iter() + .chain(root.get(b"announce-list".as_slice()).into_iter().flat_map(|value| { + let mut trackers = Vec::new(); + if let BencodeValue::List(tiers) = value { + for tier in tiers { + if let BencodeValue::List(entries) = tier { + for entry in entries { + trackers.extend(collect_torrent_uris(Some(entry), &["http", "https", "udp"])); + } + } + } + } + trackers + })) + .fold(Vec::new(), |mut result, tracker| { + if !result.contains(&tracker) && result.len() < 256 { + result.push(tracker); + } + result + }); + Ok(crate::ipc::TorrentDetails { + info_hash: parsed.info_hash, + display_name: parsed.name, + total_bytes: parsed.total_bytes, + file_count: parsed.files.len() as u32, + piece_length, + piece_count, + private: matches!(info_dict.get(b"private".as_slice()), Some(BencodeValue::Integer(1))), + creation_date, + creator: bounded_optional_text( + root.get(b"created by.utf-8".as_slice()).or_else(|| root.get(b"created by".as_slice())), + 256, + ), + comment: bounded_optional_text( + root.get(b"comment.utf-8".as_slice()).or_else(|| root.get(b"comment".as_slice())), + 4_096, + ), + trackers, + web_seeds: collect_torrent_uris(root.get(b"url-list".as_slice()), &["http", "https"]), + }) +} + pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result { if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { return Err(format!( @@ -499,31 +626,53 @@ pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option) -> Torre /// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep /// these values derived from the validated, canonical paths so the daemon's /// actual files stay aligned with Firelink's ownership registry. -pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec { +pub fn validate_output_name(name: &str) -> Result<(), String> { + if name.is_empty() + || name != name.trim() + || name == "." + || name == ".." + || name.ends_with(['.', ' ']) + || name.chars().any(|character| { + character.is_control() + || matches!(character, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) + || crate::platform::is_windows_reserved_filename(name) + || crate::download_ownership::canonical_download_filename(name) != name + { + return Err("Torrent output name is not a safe single path component".to_string()); + } + Ok(()) +} + +pub fn aria2_index_outputs(parsed: &ParsedTorrent, output_name: &str) -> Vec { parsed .files .iter() .map(|file| { let output = if parsed.files.len() == 1 { - file.path.clone() + output_name.to_string() } else { - format!("{}/{}", parsed.name, file.path) + format!("{output_name}/{}", file.path) }; format!("{}={output}", file.index) }) .collect() } -pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec { +pub fn aria2_output_paths( + parsed: &ParsedTorrent, + selected: Option<&[u32]>, + output_name: &str, +) -> Vec { parsed .files .iter() .filter(|file| selected.is_none_or(|indices| indices.contains(&file.index))) .map(|file| { if parsed.files.len() == 1 { - file.path.clone() + output_name.to_string() } else { - format!("{}/{}", parsed.name, file.path) + format!("{output_name}/{}", file.path) } }) .collect() @@ -699,7 +848,11 @@ pub fn validate_selected_indices( let mut normalized = selected.to_vec(); normalized.sort_unstable(); normalized.dedup(); - Ok(Some(normalized)) + if normalized.len() == file_count { + Ok(None) + } else { + Ok(Some(normalized)) + } } pub async fn prepare_local_torrent( @@ -954,6 +1107,32 @@ mod tests { assert_eq!(parsed.info_hash.len(), 40); } + #[test] + fn validates_torrent_output_names_as_single_safe_components() { + for name in ["test", "My Torrent (1)", "archive.tar"] { + validate_output_name(name).expect("ordinary output names should be accepted"); + } + for name in ["", " test", "test ", ".", "..", "a/b", "a\\b", "CON", "a?.bin"] { + assert!(validate_output_name(name).is_err(), "{name:?}"); + } + } + + #[test] + fn exposes_bounded_torrent_details_from_metadata() { + let mut bytes = b"d4:infod6:lengthi5e4:name4:test12:piece lengthi2e6:pieces20:".to_vec(); + bytes.extend([0_u8; 20]); + bytes.extend_from_slice(b"ee"); + + let details = torrent_details_from_bytes(&bytes).expect("details should parse"); + + assert_eq!(details.display_name, "test"); + assert_eq!(details.total_bytes, 5); + assert_eq!(details.file_count, 1); + assert_eq!(details.piece_length, 2); + assert_eq!(details.piece_count, 1); + assert!(!details.private); + } + #[test] fn parses_multi_file_torrent_and_rejects_traversal() { let parsed = parse_torrent_bytes( @@ -1142,8 +1321,11 @@ mod tests { ) .expect("multi-file torrent should parse"); assert_eq!( - aria2_index_outputs(&parsed), - vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()] + aria2_index_outputs(&parsed, "custom-name"), + vec![ + "1=custom-name/root/a.txt".to_string(), + "2=custom-name/root/b.bin".to_string(), + ] ); } diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index cb6b5bd..1cc17fe 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -3,4 +3,4 @@ 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, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentWebSeeds?: Array, 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, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentWebSeeds?: Array, 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, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; diff --git a/src/bindings/DownloadStatus.ts b/src/bindings/DownloadStatus.ts index 9234fa8..2fa3cc5 100644 --- a/src/bindings/DownloadStatus.ts +++ b/src/bindings/DownloadStatus.ts @@ -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" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying"; +export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying"; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index cd68f0c..ba2d056 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,4 +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, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, 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, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, 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, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index aaede9b..f8b1dd9 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, 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, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/bindings/TorrentDetails.ts b/src/bindings/TorrentDetails.ts new file mode 100644 index 0000000..05b24da --- /dev/null +++ b/src/bindings/TorrentDetails.ts @@ -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 TorrentDetails = { infoHash: string, displayName: string, totalBytes: number, fileCount: number, pieceLength: number, pieceCount: number, private: boolean, creationDate: string | null, creator: string | null, comment: string | null, trackers: Array, webSeeds: Array, }; diff --git a/src/bindings/TorrentFileSelectionEntry.ts b/src/bindings/TorrentFileSelectionEntry.ts new file mode 100644 index 0000000..b139941 --- /dev/null +++ b/src/bindings/TorrentFileSelectionEntry.ts @@ -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 TorrentFileSelectionEntry = { index: number, relativePath: string, length: number, selected: boolean, completedLength?: number, }; diff --git a/src/bindings/TorrentFileSelectionSnapshot.ts b/src/bindings/TorrentFileSelectionSnapshot.ts new file mode 100644 index 0000000..a9641ca --- /dev/null +++ b/src/bindings/TorrentFileSelectionSnapshot.ts @@ -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 { TorrentFileSelectionEntry } from "./TorrentFileSelectionEntry"; + +export type TorrentFileSelectionSnapshot = { files: Array, }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 2504d0c..cc8cfc5 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -178,20 +178,20 @@ export const DownloadItem = React.memo(({ }; }, [isActionVisible, updateActionPosition]); - const displayFraction = download.status === 'downloading' || download.status === 'seeding' + const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding' ? liveProgress?.fraction ?? download.fraction ?? 0 : download.fraction ?? 0; const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; const displaySpeed = download.status === 'seeding' ? liveProgress?.upload_speed ?? '-' - : download.status === 'downloading' + : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.speed ?? download.speed : download.status === 'processing' ? t($ => $.downloads.values.processing) : '-'; const displayEta = download.status === 'seeding' ? '-' - : download.status === 'downloading' + : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.eta ?? download.eta : download.status === 'processing' ? t($ => $.downloads.values.muxing) @@ -297,6 +297,7 @@ export const DownloadItem = React.memo(({ download.status === 'paused' ? 'paused' : download.status === 'seeding' ? 'seeding' : download.status === 'processing' ? 'processing' : + download.status === 'verifying' ? 'processing' : download.status === 'queued' || download.status === 'staged' ? 'queued' : download.status === 'retrying' ? 'retrying' : '' }`} @@ -319,7 +320,8 @@ export const DownloadItem = React.memo(({ download.status === 'paused' ? 'download-status-paused' : download.status === 'seeding' ? 'download-status-seeding' : download.status === 'failed' ? 'download-status-failed' : - download.status === 'processing' ? 'download-status-processing' : + download.status === 'processing' ? 'download-status-processing' : + download.status === 'verifying' ? 'download-status-processing' : download.status === 'downloading' ? 'download-status-downloading' : download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' : download.status === 'retrying' ? 'download-status-retrying' : '' @@ -332,7 +334,7 @@ export const DownloadItem = React.memo(({ {downloadStatusLabel} #{queueIndex + 1} - ) : download.status === 'downloading' ? ( + ) : download.status === 'downloading' || download.status === 'verifying' ? ( displayPercent ) : download.status === 'seeding' ? ( displayPercent diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 5214479..e6da737 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -6,6 +6,8 @@ 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 { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot'; +import type { TorrentDetails } from '../bindings/TorrentDetails'; import type { TorrentWebSeed } from '../bindings/TorrentWebSeed'; import { invokeCommand as invoke } from '../ipc'; import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react'; @@ -22,7 +24,7 @@ import { formatDownloadTotal, resolveDownloadSizeDisplay } from '../utils/downloadProgress'; -import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads'; +import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; import { useTranslation } from 'react-i18next'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; @@ -43,10 +45,10 @@ const formatLastTry = ( }; const isPeerDiagnosticsStatus = (status: string): boolean => - ['downloading', 'seeding', 'retrying'].includes(status); + ['downloading', 'verifying', 'seeding', 'retrying'].includes(status); const isTorrentFileProgressStatus = (status: string): boolean => - ['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); + ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); const formatPeerSpeed = (bytesPerSecond: number): string => `${formatDownloadBytes(bytesPerSecond)}/s`; @@ -95,6 +97,7 @@ export const PropertiesModal = () => { const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); + const [torrentFileAllocation, setTorrentFileAllocation] = useState('prealloc'); const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState(''); @@ -106,6 +109,11 @@ export const PropertiesModal = () => { const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false); const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false); const [torrentFileProgress, setTorrentFileProgress] = useState(null); + const [torrentFileSelection, setTorrentFileSelection] = useState(null); + const [torrentDetails, setTorrentDetails] = useState(null); + const [torrentDetailsError, setTorrentDetailsError] = useState(false); + const [isTorrentDetailsPending, setIsTorrentDetailsPending] = useState(false); + const [isTorrentVerifyPending, setIsTorrentVerifyPending] = useState(false); const [torrentFileProgressError, setTorrentFileProgressError] = useState(false); const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false); const [torrentPieceProgress, setTorrentPieceProgress] = useState(null); @@ -132,9 +140,14 @@ export const PropertiesModal = () => { const [errorMessage, setErrorMessage] = useState(''); const [isPauseResumePending, setIsPauseResumePending] = useState(false); + const torrentFileProgressByIndex = new Map( + (torrentFileProgress?.files ?? []).map(file => [file.index, file]) + ); const actionRequestRef = useRef(0); const peerDiagnosticsRequestRef = useRef(0); const torrentFileProgressRequestRef = useRef(0); + const torrentFileSelectionRequestRef = useRef(0); + const torrentDetailsRequestRef = useRef(0); const torrentPieceProgressRequestRef = useRef(0); const torrentWebSeedsRequestRef = useRef(0); const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item)); @@ -154,6 +167,10 @@ export const PropertiesModal = () => { setTorrentFileProgress(null); setTorrentFileProgressError(false); setIsTorrentFileProgressPending(false); + torrentDetailsRequestRef.current += 1; + setTorrentDetails(null); + setTorrentDetailsError(false); + setIsTorrentDetailsPending(false); torrentPieceProgressRequestRef.current += 1; setTorrentPieceProgress(null); setTorrentPieceProgressError(false); @@ -227,6 +244,7 @@ export const PropertiesModal = () => { setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true); setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true); setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED); + setTorrentFileAllocation(normalizeTorrentFileAllocation(activeItem.torrentFileAllocation) || 'prealloc'); setTorrentTrackers(activeItem.torrentTrackers || ''); setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || ''); setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout)); @@ -244,6 +262,58 @@ export const PropertiesModal = () => { } }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); + useEffect(() => { + torrentFileSelectionRequestRef.current += 1; + setTorrentFileSelection(null); + if (!selectedPropertiesDownloadId || !item?.isTorrent) return; + const requestId = torrentFileSelectionRequestRef.current; + const propertiesDownloadId = item.id; + void invoke('get_torrent_file_selection', { id: propertiesDownloadId }) + .then(snapshot => { + if ( + requestId === torrentFileSelectionRequestRef.current + && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId + ) { + setTorrentFileSelection(snapshot); + } + }) + .catch(() => { + if (requestId === torrentFileSelectionRequestRef.current) setTorrentFileSelection(null); + }); + }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); + + useEffect(() => { + torrentDetailsRequestRef.current += 1; + setTorrentDetails(null); + setTorrentDetailsError(false); + setIsTorrentDetailsPending(false); + if (!selectedPropertiesDownloadId || !item?.isTorrent || !item.torrentPath) return; + + const requestId = torrentDetailsRequestRef.current; + const propertiesDownloadId = item.id; + setIsTorrentDetailsPending(true); + void invoke('get_torrent_details', { id: propertiesDownloadId }) + .then(details => { + if ( + requestId === torrentDetailsRequestRef.current + && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId + ) { + setTorrentDetails(details); + } + }) + .catch(() => { + if ( + requestId === torrentDetailsRequestRef.current + && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId + ) { + setTorrentDetailsError(true); + } + }) + .finally(() => { + if (requestId === torrentDetailsRequestRef.current) setIsTorrentDetailsPending(false); + }); + }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); + useEffect(() => { const activeLimit = item?.speedLimit?.trim(); setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); @@ -569,6 +639,32 @@ export const PropertiesModal = () => { } }; + const handleVerifyTorrentData = async () => { + if (!item?.isTorrent || isTorrentVerifyPending) return; + const restoreStatus = item.status; + const previousVerifyOnly = item.torrentVerifyOnly; + const previousRestoreStatus = item.torrentVerifyRestoreStatus; + // Mark the maintenance lifecycle before invoking the command so a very + // fast queued/completed event cannot be mistaken for the normal download + // lifecycle. The backend persists the same markers before dispatching. + useDownloadStore.getState().updateDownload(item.id, { + torrentVerifyOnly: true, + torrentVerifyRestoreStatus: restoreStatus + }); + setIsTorrentVerifyPending(true); + try { + await invoke('verify_torrent_data', { id: item.id }); + } catch (error) { + useDownloadStore.getState().updateDownload(item.id, { + torrentVerifyOnly: previousVerifyOnly, + torrentVerifyRestoreStatus: previousRestoreStatus + }); + setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + setIsTorrentVerifyPending(false); + } + }; + const handleSave = async () => { if (!url.trim()) { setErrorMessage(t($ => $.properties.enterValidUrl)); @@ -638,6 +734,21 @@ export const PropertiesModal = () => { setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid)); return; } + const selectedTorrentIndices = torrentFileSelection + ? torrentFileSelection.files.filter(file => file.selected).map(file => file.index) + : []; + const allTorrentFilesSelected = Boolean( + torrentFileSelection + && selectedTorrentIndices?.length === torrentFileSelection.files.length + ); + if (torrentFileSelection && selectedTorrentIndices?.length === 0) { + setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); + return; + } + if (item.isTorrent && torrentRemoveUnselectedFile && torrentFileSelection && allTorrentFilesSelected) { + setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); + return; + } if ( item.isTorrent && torrentRemoveUnselectedFile @@ -676,12 +787,18 @@ export const PropertiesModal = () => { : undefined, torrentStopTimeout: normalizedStopTimeout, torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined, - torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined + torrentFileIndices: torrentFileSelection + ? (allTorrentFilesSelected ? undefined : selectedTorrentIndices) + : item.torrentFileIndices, + torrentRemoveUnselectedFile: (torrentFileSelection + ? !allTorrentFilesSelected + : item.torrentFileIndices !== undefined) ? torrentRemoveUnselectedFile : undefined, torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED ? torrentEncryptionPolicy : undefined, + torrentFileAllocation, } : {}), ...(connectionsDirty @@ -839,6 +956,9 @@ export const PropertiesModal = () => { const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status); + const torrentFileSelectionIsEmpty = item.isTorrent + && torrentFileSelection !== null + && !torrentFileSelection.files.some(file => file.selected); const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); const observedConnectionTotal = Math.max( 1, @@ -919,7 +1039,7 @@ export const PropertiesModal = () => { let statusColor = 'text-text-secondary'; let StatusIcon = Info; if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } - else if (item.status === 'downloading' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } + else if (item.status === 'downloading' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; } else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } @@ -1105,6 +1225,72 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentPeerOptionsSavedHint)}
+ {torrentFileSelection && ( +
+
+
+ {t($ => $.properties.torrentFileSelection)} +
+
+ + +
+
+

+ {t($ => $.properties.torrentFileSelectionHint)} +

+
+ {torrentFileSelection.files.map(file => ( + + ))} +
+
+ )}
@@ -1442,6 +1628,25 @@ export const PropertiesModal = () => { {t($ => $.properties.torrentPrioritizePieceHint)}

+ +
+ +

+ {t($ => $.properties.torrentFileAllocationHint)} +

+
@@ -1485,6 +1690,18 @@ export const PropertiesModal = () => { {t($ => $.properties.torrentVerifyIntegrityHint)} +
+ +
@@ -1682,6 +1899,73 @@ export const PropertiesModal = () => {
+ {item.isTorrent && ( +
+

+ {t($ => $.properties.torrentDetails)} +

+ {isTorrentDetailsPending && ( +

{t($ => $.properties.torrentDetailsLoading)}

+ )} + {torrentDetailsError && ( +

{t($ => $.properties.torrentDetailsUnavailable)}

+ )} + {torrentDetails && ( +
+ {t($ => $.properties.torrentDetailsDisplayName)} + {torrentDetails.displayName} + {t($ => $.properties.torrentDetailsInfoHash)} + {torrentDetails.infoHash} + {t($ => $.properties.torrentDetailsSize)} + {formatDownloadBytes(torrentDetails.totalBytes)} + {t($ => $.properties.torrentDetailsFiles)} + {torrentDetails.fileCount} + {t($ => $.properties.torrentDetailsPieces)} + {torrentDetails.pieceCount} × {formatDownloadBytes(torrentDetails.pieceLength)} + {t($ => $.properties.torrentDetailsPrivate)} + {torrentDetails.private + ? t($ => $.properties.torrentDetailsPrivateYes) + : t($ => $.properties.torrentDetailsPrivateNo)} + {torrentDetails.creationDate && ( + <> + {t($ => $.properties.torrentDetailsCreated)} + {formatDateTime(torrentDetails.creationDate, { + locale: i18n.language, + calendar: calendarPreference, + options: { dateStyle: 'medium', timeStyle: 'short' } + })} + + )} + {torrentDetails.creator && ( + <> + {t($ => $.properties.torrentDetailsCreator)} + {torrentDetails.creator} + + )} + {torrentDetails.comment && ( + <> + {t($ => $.properties.torrentDetailsComment)} + {torrentDetails.comment} + + )} + {t($ => $.properties.torrentDetailsTrackers)} + + {torrentDetails.trackers.length > 0 ? torrentDetails.trackers.join(', ') : '—'} + + {t($ => $.properties.torrentDetailsWebSeeds)} + + {torrentDetails.webSeeds.length > 0 ? torrentDetails.webSeeds.join(', ') : '—'} + + {torrentDetails.private && ( +

+ {t($ => $.properties.torrentDetailsPrivateHint)} +

+ )} +
+ )} +
+ )} + {item.isTorrent && (

@@ -1794,8 +2078,8 @@ export const PropertiesModal = () => {

+
+
+ {t($ => $.settings.network.torrentBindAddress)} + {t($ => $.settings.network.torrentBindAddressDescription)} +
+ settings.setTorrentBindAddress(event.target.value)} + placeholder="192.0.2.10 or 2001:db8::10" + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentBindAddress)} + /> +
{t($ => $.settings.network.torrentDhtListenPort)} @@ -1519,6 +1546,20 @@ runEngineChecks(false); aria-label={t($ => $.settings.network.torrentMaxOpenFiles)} />
+
+
+ {t($ => $.settings.network.aria2DiskCache)} + {t($ => $.settings.network.aria2DiskCacheDescription)} +
+ settings.setAria2DiskCache(event.target.value)} + placeholder="16M" + className="app-control settings-network-input text-center" + aria-label={t($ => $.settings.network.aria2DiskCache)} + /> +
{t($ => $.settings.network.torrentOverallUploadLimit)} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 501d5c4..e68643a 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -89,6 +89,7 @@ const common = { queued: 'Queued', downloading: 'Downloading', processing: 'Processing', + verifying: 'Verifying', seeding: 'Seeding', waitingToSeed: 'Waiting to seed', paused: 'Paused', @@ -251,6 +252,8 @@ const common = { torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds', torrentVerifyIntegrity: 'Verify Torrent integrity', torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.', + torrentVerifyNow: 'Verify now', + torrentVerifyNowLoading: 'Verifying…', torrentMaxPeers: 'Maximum Torrent peers', torrentPeerSpeedLimit: 'Peer speed threshold', torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000', @@ -262,6 +265,11 @@ const common = { 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', + torrentFileSelection: 'Torrent file selection', + torrentFileSelectionHint: 'Choose which files to download. Selecting every file removes the filter; at least one file must remain selected.', + torrentFileSelectionRequired: 'Select at least one Torrent file.', + torrentFileSelectionAll: 'Select all', + torrentFileSelectionClear: 'Clear', torrentFileProgressRefresh: 'Refresh', torrentFileProgressLoading: 'Loading file progress…', torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.', @@ -300,6 +308,27 @@ const common = { torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.', torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M', torrentEncryptionPolicy: 'Torrent encryption policy', + torrentFileAllocation: 'Torrent file allocation', + torrentFileAllocationPrealloc: 'Preallocate files', + torrentFileAllocationNone: 'Allocate as needed', + torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.', + torrentDetails: 'Torrent details', + torrentDetailsLoading: 'Loading Torrent details…', + torrentDetailsUnavailable: 'Torrent details are not available.', + torrentDetailsDisplayName: 'Display name', + torrentDetailsInfoHash: 'Info hash', + torrentDetailsSize: 'Total size', + torrentDetailsFiles: 'Files', + torrentDetailsPieces: 'Pieces', + torrentDetailsPrivate: 'Private', + torrentDetailsPrivateYes: 'Yes', + torrentDetailsPrivateNo: 'No', + torrentDetailsCreated: 'Created', + torrentDetailsCreator: 'Creator', + torrentDetailsComment: 'Comment', + torrentDetailsTrackers: 'Trackers', + torrentDetailsWebSeeds: 'Embedded web seeds', + torrentDetailsPrivateHint: 'This private Torrent disables DHT, DHT6, PEX, and LPD discovery regardless of broader settings.', torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.', torrentEncryptionDisabled: 'Disabled', torrentEncryptionRequireCrypto: 'Require obfuscated handshake', @@ -819,6 +848,8 @@ const common = { torrentDhtDescription: 'Find peers without relying only on trackers. Disabling this also disables UDP tracker support.', torrentDht6: 'IPv6 DHT', torrentDht6Description: 'Use IPv6 for distributed peer discovery when the network provides a usable IPv6 path.', + torrentIpv6Enabled: 'Enable IPv6 for Torrent networking', + torrentIpv6EnabledDescription: 'Keep IPv6 available to BitTorrent, DHT, and peer discovery. Disabling this overrides IPv6 DHT even when its preference remains enabled.', torrentPex: 'Peer Exchange (PEX)', torrentPexDescription: 'Allow connected peers to share additional peer addresses.', torrentLpd: 'Local Peer Discovery (LPD)', @@ -834,6 +865,8 @@ const common = { 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 Aria2’s default range.', + torrentBindAddress: 'Torrent bind address', + torrentBindAddressDescription: 'Optional local IPv4 or IPv6 address for Aria2 sockets. Invalid addresses are rejected; changes apply after restart.', torrentDhtListenPort: 'UDP/DHT ports', torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2’s default range.', torrentExternalIp: 'External IP address', @@ -855,6 +888,8 @@ const common = { torrentResourceLimits: 'BitTorrent resource limits', torrentMaxOpenFiles: 'Maximum open Torrent files', torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.', + aria2DiskCache: 'Aria2 disk cache', + aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.', torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}', torrentOverallUploadLimit: 'Overall Aria2 upload limit', torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 070b7f7..af8b723 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -89,6 +89,7 @@ const fa = { queued: 'در صف', downloading: 'در حال دانلود', processing: 'در حال پردازش', + verifying: 'در حال بررسی صحت', seeding: 'در حال اشتراک‌گذاری', waitingToSeed: 'در انتظار اشتراک‌گذاری', paused: 'متوقف‌شده', @@ -251,6 +252,8 @@ const fa = { torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد', torrentVerifyIntegrity: 'بررسی صحت تورنت', torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.', + torrentVerifyNow: 'بررسی صحت اکنون', + torrentVerifyNowLoading: 'در حال بررسی صحت…', torrentMaxPeers: 'حداکثر همتاهای تورنت', torrentPeerSpeedLimit: 'آستانه سرعت همتا', torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد', @@ -262,6 +265,11 @@ const fa = { torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.', torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.', torrentFileProgress: 'پیشرفت فایل‌های تورنت', + torrentFileSelection: 'انتخاب فایل‌های تورنت', + torrentFileSelectionHint: 'فایل‌های موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایل‌ها فیلتر را حذف می‌کند؛ حداقل یک فایل باید انتخاب شود.', + torrentFileSelectionRequired: 'حداقل یک فایل تورنت را انتخاب کنید.', + torrentFileSelectionAll: 'انتخاب همه', + torrentFileSelectionClear: 'پاک‌کردن', torrentFileProgressRefresh: 'تازه‌سازی', torrentFileProgressLoading: 'در حال دریافت پیشرفت فایل‌ها…', torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقف‌بودن تورنت در دسترس است.', @@ -300,6 +308,27 @@ const fa = { torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد', torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت', + torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت', + torrentFileAllocationPrealloc: 'تخصیص از پیش', + torrentFileAllocationNone: 'تخصیص هنگام نیاز', + torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.', + torrentDetails: 'جزئیات تورنت', + torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…', + torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.', + torrentDetailsDisplayName: 'نام نمایشی', + torrentDetailsInfoHash: 'هش اطلاعات', + torrentDetailsSize: 'حجم کل', + torrentDetailsFiles: 'فایل‌ها', + torrentDetailsPieces: 'قطعه‌ها', + torrentDetailsPrivate: 'خصوصی', + torrentDetailsPrivateYes: 'بله', + torrentDetailsPrivateNo: 'خیر', + torrentDetailsCreated: 'ایجادشده', + torrentDetailsCreator: 'سازنده', + torrentDetailsComment: 'توضیح', + torrentDetailsTrackers: 'ترکرها', + torrentDetailsWebSeeds: 'وب‌سیدهای داخلی', + torrentDetailsPrivateHint: 'این تورنت خصوصی، مستقل از تنظیمات کلی، کشف DHT، DHT6، PEX و LPD را غیرفعال می‌کند.', torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال می‌شود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.', torrentEncryptionDisabled: 'غیرفعال', torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده', @@ -819,6 +848,8 @@ const fa = { torrentDhtDescription: 'همتاها را بدون تکیه صرف بر ترکرها پیدا می‌کند. خاموش کردن آن پشتیبانی از ترکرهای UDP را هم خاموش می‌کند.', torrentDht6: 'DHT نسخه IPv6', torrentDht6Description: 'وقتی مسیر IPv6 قابل استفاده باشد، از آن برای کشف توزیع‌شده همتاها استفاده می‌کند.', + torrentIpv6Enabled: 'فعال‌سازی IPv6 برای تورنت', + torrentIpv6EnabledDescription: 'IPv6 را برای BitTorrent، DHT و کشف همتاها فعال نگه می‌دارد. غیرفعال‌کردن آن IPv6 DHT را خاموش می‌کند.', torrentPex: 'تبادل همتا (PEX)', torrentPexDescription: 'به همتاهای متصل اجازه می‌دهد آدرس همتاهای بیشتری را به اشتراک بگذارند.', torrentLpd: 'کشف همتای محلی (LPD)', @@ -834,6 +865,8 @@ const fa = { torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنت‌هایی که Firelink هم‌زمان سید می‌کند.', torrentListenPort: 'پورت‌های همتای TCP', torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.', + torrentBindAddress: 'نشانی اتصال تورنت', + torrentBindAddressDescription: 'نشانی محلی اختیاری IPv4 یا IPv6 برای سوکت‌های Aria2. نشانی نامعتبر رد می‌شود و تغییر پس از راه‌اندازی مجدد اعمال می‌شود.', torrentDhtListenPort: 'پورت‌های UDP/DHT', torrentDhtListenPortDescription: 'پورت‌های UDP برای DHT و ترکرهای UDP. برای محدوده پیش‌فرض Aria2 خالی بگذارید.', torrentExternalIp: 'آدرس IP خارجی', @@ -855,6 +888,8 @@ const fa = { torrentResourceLimits: 'محدودیت منابع بیت‌تورنت', torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت', torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.', + aria2DiskCache: 'کش دیسک Aria2', + aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته می‌شوند و پس از راه‌اندازی مجدد اعمال می‌شوند.', torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}', torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2', torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index f8d5682..acac76e 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -89,6 +89,7 @@ const he = { queued: 'בתור', downloading: 'מוריד', processing: 'מעבד', + verifying: 'מאמת', seeding: 'משתף', waitingToSeed: 'ממתין לשיתוף', paused: 'מושהה', @@ -251,6 +252,8 @@ const he = { torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות', torrentVerifyIntegrity: 'אימות תקינות הטורנט', torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.', + torrentVerifyNow: 'אמת עכשיו', + torrentVerifyNowLoading: 'מאמת…', torrentMaxPeers: 'מספר העמיתים המרבי בטורנט', torrentPeerSpeedLimit: 'סף מהירות עמיתים', torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000', @@ -262,6 +265,11 @@ const he = { torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.', torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.', torrentFileProgress: 'התקדמות קובצי הטורנט', + torrentFileSelection: 'בחירת קובצי טורנט', + torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.', + torrentFileSelectionRequired: 'בחר לפחות קובץ טורנט אחד.', + torrentFileSelectionAll: 'בחר הכול', + torrentFileSelectionClear: 'נקה', torrentFileProgressRefresh: 'רענון', torrentFileProgressLoading: 'טוען את התקדמות הקבצים…', torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.', @@ -300,6 +308,27 @@ const he = { torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.', torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M', torrentEncryptionPolicy: 'מדיניות הצפנת Torrent', + torrentFileAllocation: 'הקצאת קובצי Torrent', + torrentFileAllocationPrealloc: 'הקצאה מראש', + torrentFileAllocationNone: 'הקצאה לפי הצורך', + torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.', + torrentDetails: 'פרטי טורנט', + torrentDetailsLoading: 'טוען פרטי טורנט…', + torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.', + torrentDetailsDisplayName: 'שם תצוגה', + torrentDetailsInfoHash: 'גיבוב מידע', + torrentDetailsSize: 'גודל כולל', + torrentDetailsFiles: 'קבצים', + torrentDetailsPieces: 'חלקים', + torrentDetailsPrivate: 'פרטי', + torrentDetailsPrivateYes: 'כן', + torrentDetailsPrivateNo: 'לא', + torrentDetailsCreated: 'נוצר', + torrentDetailsCreator: 'יוצר', + torrentDetailsComment: 'הערה', + torrentDetailsTrackers: 'עוקבים', + torrentDetailsWebSeeds: 'זרעי Web משובצים', + torrentDetailsPrivateHint: 'טורנט פרטי זה משבית גילוי DHT, DHT6, PEX ו-LPD ללא קשר להגדרות הכלליות.', torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.', torrentEncryptionDisabled: 'מושבתת', torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה', @@ -819,6 +848,8 @@ const he = { torrentDhtDescription: 'מאתר עמיתים בלי להסתמך רק על מעקבים. השבתה מכבה גם תמיכה במעקבי UDP.', torrentDht6: 'DHT של IPv6', torrentDht6Description: 'משתמש ב-IPv6 לגילוי מבוזר של עמיתים כשיש נתיב IPv6 זמין.', + torrentIpv6Enabled: 'הפעלת IPv6 עבור טורנטים', + torrentIpv6EnabledDescription: 'משאיר את IPv6 זמין עבור BitTorrent, DHT וגילוי עמיתים. השבתה זו מכבה גם IPv6 DHT.', torrentPex: 'החלפת עמיתים (PEX)', torrentPexDescription: 'מאפשר לעמיתים מחוברים לשתף כתובות של עמיתים נוספים.', torrentLpd: 'גילוי עמיתים מקומיים (LPD)', @@ -834,6 +865,8 @@ const he = { torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.', torrentListenPort: 'יציאות עמיתי TCP', torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.', + torrentBindAddress: 'כתובת קישור לטורנטים', + torrentBindAddressDescription: 'כתובת IPv4 או IPv6 מקומית ואופציונלית לשקעי Aria2. כתובות לא תקינות נדחות; השינוי חל לאחר הפעלה מחדש.', torrentDhtListenPort: 'יציאות UDP/DHT', torrentDhtListenPortDescription: 'יציאות UDP עבור DHT ועוקבי UDP. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.', torrentExternalIp: 'כתובת IP חיצונית', @@ -855,6 +888,8 @@ const he = { torrentResourceLimits: 'מגבלות משאבי BitTorrent', torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי', torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.', + aria2DiskCache: 'מטמון דיסק של Aria2', + aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.', torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}', torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2', torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 2d0693e..563eca6 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -89,6 +89,7 @@ const ru = { queued: 'В очереди', downloading: 'Загрузка', processing: 'Обработка', + verifying: 'Проверка', seeding: 'Раздача', waitingToSeed: 'Ожидание раздачи', paused: 'Приостановлено', @@ -251,6 +252,8 @@ const ru = { torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд', torrentVerifyIntegrity: 'Проверять целостность торрента', torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.', + torrentVerifyNow: 'Проверить сейчас', + torrentVerifyNowLoading: 'Проверка…', torrentMaxPeers: 'Максимум пиров торрента', torrentPeerSpeedLimit: 'Порог скорости пиров', torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000', @@ -262,6 +265,11 @@ const ru = { torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.', torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.', torrentFileProgress: 'Прогресс файлов торрента', + torrentFileSelection: 'Выбор файлов торрента', + torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.', + torrentFileSelectionRequired: 'Выберите хотя бы один файл торрента.', + torrentFileSelectionAll: 'Выбрать все', + torrentFileSelectionClear: 'Очистить', torrentFileProgressRefresh: 'Обновить', torrentFileProgressLoading: 'Загрузка прогресса файлов…', torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.', @@ -300,7 +308,28 @@ const ru = { torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.', torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M', torrentEncryptionPolicy: 'Политика шифрования Torrent', + torrentFileAllocation: 'Выделение места для файлов Torrent', + torrentFileAllocationPrealloc: 'Предварительное выделение', + torrentFileAllocationNone: 'Выделять по мере необходимости', + torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.', torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.', + torrentDetails: 'Сведения о Torrent', + torrentDetailsLoading: 'Загрузка сведений о Torrent…', + torrentDetailsUnavailable: 'Сведения о Torrent недоступны.', + torrentDetailsDisplayName: 'Отображаемое имя', + torrentDetailsInfoHash: 'Инфохеш', + torrentDetailsSize: 'Общий размер', + torrentDetailsFiles: 'Файлы', + torrentDetailsPieces: 'Части', + torrentDetailsPrivate: 'Приватный', + torrentDetailsPrivateYes: 'Да', + torrentDetailsPrivateNo: 'Нет', + torrentDetailsCreated: 'Создан', + torrentDetailsCreator: 'Создатель', + torrentDetailsComment: 'Комментарий', + torrentDetailsTrackers: 'Трекеры', + torrentDetailsWebSeeds: 'Встроенные веб-сиды', + torrentDetailsPrivateHint: 'Этот приватный Torrent отключает обнаружение через DHT, DHT6, PEX и LPD независимо от общих настроек.', torrentEncryptionDisabled: 'Отключено', torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие', torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)', @@ -819,6 +848,8 @@ const ru = { torrentDhtDescription: 'Ищет пиры не только через трекеры. Отключение также отключает поддержку UDP-трекеров.', torrentDht6: 'DHT по IPv6', torrentDht6Description: 'Использует IPv6 для распределённого поиска пиров, если доступен рабочий IPv6-маршрут.', + torrentIpv6Enabled: 'Использовать IPv6 для торрентов', + torrentIpv6EnabledDescription: 'Оставляет IPv6 доступным для BitTorrent, DHT и поиска пиров. Отключение также выключает IPv6 DHT.', torrentPex: 'Обмен пирами (PEX)', torrentPexDescription: 'Позволяет подключённым пирам передавать адреса дополнительных пиров.', torrentLpd: 'Локальное обнаружение пиров (LPD)', @@ -834,6 +865,8 @@ const ru = { torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.', torrentListenPort: 'TCP-порты пиров', torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.', + torrentBindAddress: 'Адрес привязки торрентов', + torrentBindAddressDescription: 'Необязательный локальный IPv4- или IPv6-адрес для сокетов Aria2. Недопустимые адреса отклоняются; применяется после перезапуска.', torrentDhtListenPort: 'Порты UDP/DHT', torrentDhtListenPortDescription: 'UDP-порты для DHT и UDP-трекеров. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.', torrentExternalIp: 'Внешний IP-адрес', @@ -855,6 +888,8 @@ const ru = { torrentResourceLimits: 'Ограничения ресурсов BitTorrent', torrentMaxOpenFiles: 'Максимум открытых файлов Torrent', torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.', + aria2DiskCache: 'Дисковый кэш Aria2', + aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.', torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}', torrentOverallUploadLimit: 'Общий лимит отдачи Aria2', torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 13cf1bb..839b05a 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -89,6 +89,7 @@ const uk = { queued: 'У черзі', downloading: 'Завантаження', processing: 'Обробка', + verifying: 'Перевірка', seeding: 'Роздача', waitingToSeed: 'Очікування роздачі', paused: 'Призупинено', @@ -251,6 +252,8 @@ const uk = { torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд', torrentVerifyIntegrity: 'Перевіряти цілісність торрента', torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.', + torrentVerifyNow: 'Перевірити зараз', + torrentVerifyNowLoading: 'Перевірка…', torrentMaxPeers: 'Максимум пірів торрента', torrentPeerSpeedLimit: 'Поріг швидкості пірів', torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000', @@ -262,6 +265,11 @@ const uk = { torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.', torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.', torrentFileProgress: 'Прогрес файлів торрента', + torrentFileSelection: 'Вибір файлів торрента', + torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.', + torrentFileSelectionRequired: 'Виберіть хоча б один файл торрента.', + torrentFileSelectionAll: 'Вибрати все', + torrentFileSelectionClear: 'Очистити', torrentFileProgressRefresh: 'Оновити', torrentFileProgressLoading: 'Завантаження прогресу файлів…', torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.', @@ -300,7 +308,28 @@ const uk = { torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.', torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M', torrentEncryptionPolicy: 'Політика шифрування Torrent', + torrentFileAllocation: 'Виділення місця для файлів Torrent', + torrentFileAllocationPrealloc: 'Попереднє виділення', + torrentFileAllocationNone: 'Виділяти за потреби', + torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.', torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.', + torrentDetails: 'Відомості про Torrent', + torrentDetailsLoading: 'Завантаження відомостей про Torrent…', + torrentDetailsUnavailable: 'Відомості про Torrent недоступні.', + torrentDetailsDisplayName: 'Назва', + torrentDetailsInfoHash: 'Інфохеш', + torrentDetailsSize: 'Загальний розмір', + torrentDetailsFiles: 'Файли', + torrentDetailsPieces: 'Частини', + torrentDetailsPrivate: 'Приватний', + torrentDetailsPrivateYes: 'Так', + torrentDetailsPrivateNo: 'Ні', + torrentDetailsCreated: 'Створено', + torrentDetailsCreator: 'Автор', + torrentDetailsComment: 'Коментар', + torrentDetailsTrackers: 'Трекери', + torrentDetailsWebSeeds: 'Вбудовані веб-сиди', + torrentDetailsPrivateHint: 'Цей приватний Torrent вимикає виявлення через DHT, DHT6, PEX і LPD незалежно від загальних налаштувань.', torrentEncryptionDisabled: 'Вимкнено', torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання', torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)', @@ -819,6 +848,8 @@ const uk = { torrentDhtDescription: 'Шукає пірів не лише через трекери. Вимкнення також вимикає підтримку UDP-трекерів.', torrentDht6: 'DHT через IPv6', torrentDht6Description: 'Використовує IPv6 для розподіленого пошуку пірів, якщо доступний робочий маршрут IPv6.', + torrentIpv6Enabled: 'Використовувати IPv6 для торентів', + torrentIpv6EnabledDescription: 'Залишає IPv6 доступним для BitTorrent, DHT і пошуку пірів. Вимкнення також вимикає IPv6 DHT.', torrentPex: 'Обмін пірами (PEX)', torrentPexDescription: 'Дозволяє підключеним пірам передавати адреси додаткових пірів.', torrentLpd: 'Локальний пошук пірів (LPD)', @@ -834,6 +865,8 @@ const uk = { torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.', torrentListenPort: 'TCP-порти пірів', torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.', + torrentBindAddress: 'Адреса прив’язки торентів', + torrentBindAddressDescription: 'Необов’язкова локальна IPv4- або IPv6-адреса для сокетів Aria2. Некоректні адреси відхиляються; застосовується після перезапуску.', torrentDhtListenPort: 'Порти UDP/DHT', torrentDhtListenPortDescription: 'UDP-порти для DHT і UDP-трекерів. Залиште порожнім, щоб використати типовий діапазон Aria2.', torrentExternalIp: 'Зовнішня IP-адреса', @@ -855,6 +888,8 @@ const uk = { torrentResourceLimits: 'Обмеження ресурсів BitTorrent', torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent', torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.', + aria2DiskCache: 'Дисковий кеш Aria2', + aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.', torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}', torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2', torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index db7c60a..a05afd8 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -89,6 +89,7 @@ const zhCN = { queued: '已排队', downloading: '下载中', processing: '处理中', + verifying: '校验中', seeding: '做种中', waitingToSeed: '等待做种', paused: '已暂停', @@ -251,6 +252,8 @@ const zhCN = { torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数', torrentVerifyIntegrity: '验证 Torrent 完整性', torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。', + torrentVerifyNow: '立即验证', + torrentVerifyNowLoading: '验证中…', torrentMaxPeers: 'Torrent 最大对等节点数', torrentPeerSpeedLimit: '对等节点速度阈值', torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数', @@ -262,6 +265,11 @@ const zhCN = { torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。', torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。', torrentFileProgress: 'Torrent 文件进度', + torrentFileSelection: 'Torrent 文件选择', + torrentFileSelectionHint: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。', + torrentFileSelectionRequired: '请至少选择一个 Torrent 文件。', + torrentFileSelectionAll: '全选', + torrentFileSelectionClear: '清除', torrentFileProgressRefresh: '刷新', torrentFileProgressLoading: '正在加载文件进度…', torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。', @@ -300,7 +308,28 @@ const zhCN = { torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。', torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小', torrentEncryptionPolicy: 'Torrent 加密策略', + torrentFileAllocation: 'Torrent 文件分配', + torrentFileAllocationPrealloc: '预分配文件', + torrentFileAllocationNone: '按需分配', + torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。', torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。', + torrentDetails: 'Torrent 详细信息', + torrentDetailsLoading: '正在加载 Torrent 详细信息…', + torrentDetailsUnavailable: 'Torrent 详细信息不可用。', + torrentDetailsDisplayName: '显示名称', + torrentDetailsInfoHash: '信息哈希', + torrentDetailsSize: '总大小', + torrentDetailsFiles: '文件', + torrentDetailsPieces: '分片', + torrentDetailsPrivate: '私有', + torrentDetailsPrivateYes: '是', + torrentDetailsPrivateNo: '否', + torrentDetailsCreated: '创建时间', + torrentDetailsCreator: '创建者', + torrentDetailsComment: '备注', + torrentDetailsTrackers: 'Tracker', + torrentDetailsWebSeeds: '内嵌 Web seed', + torrentDetailsPrivateHint: '此私有 Torrent 会独立于全局设置禁用 DHT、DHT6、PEX 和 LPD 发现。', torrentEncryptionDisabled: '已禁用', torrentEncryptionRequireCrypto: '要求加密握手', torrentEncryptionForceEncryption: '强制加密 payload(ARC4)', @@ -819,6 +848,8 @@ const zhCN = { torrentDhtDescription: '不只依赖 Tracker 查找节点。关闭后也会禁用 UDP Tracker 支持。', torrentDht6: 'IPv6 分布式哈希表', torrentDht6Description: '当网络提供可用的 IPv6 路径时,使用 IPv6 进行分布式节点发现。', + torrentIpv6Enabled: '为种子网络启用 IPv6', + torrentIpv6EnabledDescription: '为 BitTorrent、DHT 和节点发现保留 IPv6。禁用后也会关闭 IPv6 DHT。', torrentPex: '节点交换(PEX)', torrentPexDescription: '允许已连接的节点共享其他节点的地址。', torrentLpd: '本地节点发现(LPD)', @@ -834,6 +865,8 @@ const zhCN = { torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。', torrentListenPort: 'TCP 节点端口', torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。', + torrentBindAddress: '种子绑定地址', + torrentBindAddressDescription: '可选的本地 IPv4 或 IPv6 地址,用于 Aria2 套接字。无效地址会被拒绝;重启后生效。', torrentDhtListenPort: 'UDP/DHT 端口', torrentDhtListenPortDescription: '用于 DHT 和 UDP 跟踪器的 UDP 端口。留空以使用 Aria2 的默认范围。', torrentExternalIp: '外部 IP 地址', @@ -855,6 +888,8 @@ const zhCN = { torrentResourceLimits: 'BitTorrent 资源限制', torrentMaxOpenFiles: 'Torrent 最大打开文件数', torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。', + aria2DiskCache: 'Aria2 磁盘缓存', + aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。', torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}', torrentOverallUploadLimit: 'Aria2 总上传限制', torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。', diff --git a/src/ipc.ts b/src/ipc.ts index c1bf722..2e2434b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -23,6 +23,8 @@ 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 type { TorrentDetails } from './bindings/TorrentDetails'; +import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot'; type CommandMap = { fetch_metadata: { @@ -84,6 +86,10 @@ type CommandMap = { 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_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot }; + set_torrent_file_selection: { args: { id: string; selected_indices: number[] | null }; result: TorrentFileSelectionSnapshot }; + get_torrent_details: { args: { id: string }; result: TorrentDetails }; + verify_torrent_data: { args: { id: string }; result: void }; 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 }; diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index fff3c23..d0d34b3 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -45,14 +45,14 @@ const startDownloadListeners = async () => { // A sidecar can flush one last progress chunk after a pause, failure, // completion, or lifecycle reset. Do not let that stale chunk repopulate // the live progress map or overwrite a later lifecycle's first frame. - if (!['downloading', 'processing', 'seeding'].includes(current.status)) { + if (!['downloading', 'processing', 'verifying', 'seeding'].includes(current.status)) { useDownloadProgressStore.getState().clearDownloadProgress(payload.id); return; } useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload); const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final)); const updates: Partial = {}; - if (current.status === 'downloading' || current.status === 'processing' || current.status === 'seeding') { + if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') { updates.fraction = payload.fraction; updates.speed = current.status === 'seeding' ? payload.upload_speed ?? '-' @@ -121,7 +121,7 @@ const startDownloadListeners = async () => { return; } if (status === 'downloading' || status === 'processing' || - status === 'seeding' || status === 'waitingToSeed' || + status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'completed' || status === 'failed') { clearDownloadControlIntent(payload.id, 'resume'); } @@ -173,7 +173,7 @@ const startDownloadListeners = async () => { : {}) } : {}), ...(payload.error ? { lastError: payload.error } : {}), - ...((status === 'downloading' || status === 'retrying') + ...((status === 'downloading' || status === 'verifying' || status === 'retrying') ? { lastTry: new Date().toISOString() } : {}) }; @@ -189,10 +189,20 @@ const startDownloadListeners = async () => { updates.fileName = payload.fileName; updates.category = categoryForFileName(payload.fileName); } - if (status !== 'downloading') { + if (status !== 'downloading' && status !== 'verifying') { updates.speed = '-'; updates.eta = '-'; } + if ( + current.torrentVerifyOnly === true && + ['ready', 'staged', 'paused', 'completed', 'failed'].includes(status) + ) { + // Verification is a maintenance lifecycle layered over the existing + // row. Clear its markers once Aria2 has reached the restored terminal + // state so restart cannot replay verification indefinitely. + updates.torrentVerifyOnly = undefined; + updates.torrentVerifyRestoreStatus = undefined; + } mainStore.updateDownload(payload.id, updates); if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') { @@ -205,7 +215,7 @@ const startDownloadListeners = async () => { : { pendingOrder: [...state.pendingOrder, payload.id] }); } - if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') { + if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') { mainStore.registerBackendIds([payload.id]); } else if (status === 'completed' || status === 'failed') { mainStore.unregisterBackendIds([payload.id]); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index a6957b7..c509bbf 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -167,6 +167,33 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().downloads[0].torrentRemoveUnselectedFile).toBe(false); }); + it('detaches a paused backend lifecycle even when the frontend registration set is stale', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'paused-stale-registration', + url: 'magnet:?xt=urn:btih:abc', + fileName: 'torrent', + status: 'paused', + category: 'Other', + dateAdded: '', + isTorrent: true, + torrentFileIndices: [1] + }] as any[], + backendRegisteredIds: new Set() + }); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never); + + await useDownloadStore.getState().applyProperties('paused-stale-registration', { + torrentFileIndices: [2] + }); + + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'detach_download_for_reconfigure', + { id: 'paused-stale-registration' } + ); + expect(useDownloadStore.getState().downloads[0].torrentFileIndices).toEqual([2]); + }); + it('replaces stale media intent when an appended handoff reuses a URL', () => { useDownloadStore.getState().openAddModalWithUrls( 'https://example.com/file.bin', '', '', '', '', true diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 6f46f2d..b2af0bb 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -362,6 +362,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, torrent_encryption_policy: item.torrentEncryptionPolicy || undefined, + torrent_file_allocation: item.torrentFileAllocation || undefined, + torrent_verify_only: item.torrentVerifyOnly, + torrent_verify_restore_status: item.torrentVerifyRestoreStatus, lifecycle_generation: lifecycleGeneration.toString(), }; @@ -690,6 +693,16 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down : undefined; const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown; const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy); + const rawFileAllocation = download.torrentFileAllocation as unknown; + const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation); + const rawVerifyOnly = download.torrentVerifyOnly as unknown; + const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined; + const rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown; + const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true + && typeof rawVerifyRestoreStatus === 'string' + && ['paused', 'failed', 'completed'].includes(rawVerifyRestoreStatus) + ? rawVerifyRestoreStatus + : undefined; const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining || rawWebSeeds !== normalizedWebSeeds || rawMaxPeers !== normalizedMaxPeers || @@ -703,7 +716,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down rawStopTimeout !== normalizedStopTimeout || rawPrioritizePiece !== normalizedPrioritizePiece || rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile || - rawEncryptionPolicy !== normalizedEncryptionPolicy + rawEncryptionPolicy !== normalizedEncryptionPolicy || + rawFileAllocation !== normalizedFileAllocation || + rawVerifyOnly !== normalizedVerifyOnly || + rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus ? { ...download, torrentSeedRemaining: normalizedSeedRemaining, @@ -719,7 +735,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down torrentStopTimeout: normalizedStopTimeout, torrentPrioritizePiece: normalizedPrioritizePiece, torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile, - torrentEncryptionPolicy: normalizedEncryptionPolicy + torrentEncryptionPolicy: normalizedEncryptionPolicy, + torrentFileAllocation: normalizedFileAllocation, + torrentVerifyOnly: normalizedVerifyOnly, + torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus } : download; @@ -940,7 +959,7 @@ export const useDownloadStore = create((set, get) => { && normalizedUpdates.torrentRemoveUnselectedFile === false && item.torrentRemoveUnselectedFile !== false; - if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') { + if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { throw new Error(i18n.t($ => $.downloadTable.transferActive)); } @@ -974,15 +993,18 @@ export const useDownloadStore = create((set, get) => { } } } else if (item.status === 'paused') { - if (isRegistered) { - try { - await invoke('detach_download_for_reconfigure', { id }); - } catch (e) { - console.error("Failed to detach for reconfigure:", e); - throw e; // Preserve old properties if detach fails - } - state.unregisterBackendIds([id]); + // The frontend deliberately removes paused rows from + // backendRegisteredIds, but the backend keeps a paused Aria2 GID and + // its old payload alive for an in-place resume. Any property change, + // especially Torrent selection/output changes, must retire that + // lifecycle or resume will silently use stale daemon options. + try { + await invoke('detach_download_for_reconfigure', { id }); + } catch (e) { + console.error("Failed to detach for reconfigure:", e); + throw e; // Preserve old properties if detach fails } + if (isRegistered) state.unregisterBackendIds([id]); if (disablingTorrentRemoval) { await invoke('clear_torrent_removal_paths', { id }); } @@ -2288,6 +2310,9 @@ export const useDownloadStore = create((set, get) => { torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, torrent_encryption_policy: item.torrentEncryptionPolicy || undefined, + torrent_file_allocation: item.torrentFileAllocation || undefined, + torrent_verify_only: item.torrentVerifyOnly, + torrent_verify_restore_status: item.torrentVerifyRestoreStatus, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index d6ee3be..8b97149 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -254,6 +254,7 @@ export interface SettingsState { torrentDhtMessageTimeout: number; torrentSeparateSeedSlots: boolean; torrentMaxConcurrentSeeds: number; + torrentIpv6Enabled: boolean; torrentListenPort: string; torrentDhtListenPort: string; torrentExternalIp: string; @@ -263,6 +264,8 @@ export interface SettingsState { torrentLpdInterface: string; torrentPeerIdPrefix: string; torrentPeerAgent: string; + torrentBindAddress: string; + aria2DiskCache: string; customUserAgent: string; askWhereToSaveEachFile: boolean; preventsSleepWhileDownloading: boolean; @@ -322,6 +325,7 @@ export interface SettingsState { setTorrentDhtMessageTimeout: (value: number) => void; setTorrentSeparateSeedSlots: (enabled: boolean) => void; setTorrentMaxConcurrentSeeds: (value: number) => void; + setTorrentIpv6Enabled: (enabled: boolean) => void; setTorrentListenPort: (value: string) => void; setTorrentDhtListenPort: (value: string) => void; setTorrentExternalIp: (value: string) => void; @@ -331,6 +335,8 @@ export interface SettingsState { setTorrentLpdInterface: (value: string) => void; setTorrentPeerIdPrefix: (value: string) => void; setTorrentPeerAgent: (value: string) => void; + setTorrentBindAddress: (value: string) => void; + setAria2DiskCache: (value: string) => void; setCustomUserAgent: (userAgent: string) => void; setAskWhereToSaveEachFile: (ask: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void; @@ -416,6 +422,7 @@ export const useSettingsStore = create()( torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT, torrentSeparateSeedSlots: false, torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS, + torrentIpv6Enabled: true, torrentListenPort: '', torrentDhtListenPort: '', torrentExternalIp: '', @@ -425,6 +432,8 @@ export const useSettingsStore = create()( torrentLpdInterface: '', torrentPeerIdPrefix: '', torrentPeerAgent: '', + torrentBindAddress: '', + aria2DiskCache: '16M', customUserAgent: '', askWhereToSaveEachFile: false, preventsSleepWhileDownloading: true, @@ -549,6 +558,8 @@ export const useSettingsStore = create()( setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }), setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }), setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }), + setTorrentBindAddress: (torrentBindAddress) => set({ torrentBindAddress }), + setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }), setTorrentMaxOpenFiles: (value) => { const normalized = normalizeTorrentMaxOpenFiles(value); if (normalized === undefined) { @@ -578,6 +589,7 @@ export const useSettingsStore = create()( ? value : DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS }), + setTorrentIpv6Enabled: (torrentIpv6Enabled) => set({ torrentIpv6Enabled }), setCustomUserAgent: (customUserAgent) => set({ customUserAgent }), setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }), setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => { @@ -768,6 +780,7 @@ export const useSettingsStore = create()( torrentDhtMessageTimeout: state.torrentDhtMessageTimeout, torrentSeparateSeedSlots: state.torrentSeparateSeedSlots, torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds, + torrentIpv6Enabled: state.torrentIpv6Enabled, torrentListenPort: state.torrentListenPort, torrentDhtListenPort: state.torrentDhtListenPort, torrentExternalIp: state.torrentExternalIp, @@ -777,6 +790,8 @@ export const useSettingsStore = create()( torrentLpdInterface: state.torrentLpdInterface, torrentPeerIdPrefix: state.torrentPeerIdPrefix, torrentPeerAgent: state.torrentPeerAgent, + torrentBindAddress: state.torrentBindAddress, + aria2DiskCache: state.aria2DiskCache, customUserAgent: state.customUserAgent, askWhereToSaveEachFile: state.askWhereToSaveEachFile, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, @@ -840,6 +855,10 @@ export const useSettingsStore = create()( && persisted.torrentMaxConcurrentSeeds <= 64 ? persisted.torrentMaxConcurrentSeeds : currentState.torrentMaxConcurrentSeeds, + torrentIpv6Enabled: persistedBoolean( + persisted.torrentIpv6Enabled, + currentState.torrentIpv6Enabled + ), torrentListenPort: typeof persisted.torrentListenPort === 'string' ? persisted.torrentListenPort : currentState.torrentListenPort, @@ -867,6 +886,12 @@ export const useSettingsStore = create()( torrentPeerAgent: typeof persisted.torrentPeerAgent === 'string' ? persisted.torrentPeerAgent : currentState.torrentPeerAgent, + torrentBindAddress: typeof persisted.torrentBindAddress === 'string' + ? persisted.torrentBindAddress + : currentState.torrentBindAddress, + aria2DiskCache: typeof persisted.aria2DiskCache === 'string' + ? persisted.aria2DiskCache + : currentState.aria2DiskCache, sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition) ? persisted.sidebarPosition : currentState.sidebarPosition, diff --git a/src/utils/downloadActions.test.ts b/src/utils/downloadActions.test.ts index 049d192..3964d14 100644 --- a/src/utils/downloadActions.test.ts +++ b/src/utils/downloadActions.test.ts @@ -20,7 +20,7 @@ describe('download action policy', () => { expect(canStartDownload(status)).toBe(true); expect(canPauseDownload(status)).toBe(false); } - for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'retrying'] as const) { + for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'verifying', 'retrying'] as const) { expect(canPauseDownload(status)).toBe(true); } for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) { @@ -40,6 +40,7 @@ describe('download action policy', () => { expect(getPauseResumeAction('queued')).toBe('pause'); expect(getPauseResumeAction('downloading')).toBe('pause'); expect(getPauseResumeAction('processing')).toBe('pause'); + expect(getPauseResumeAction('verifying')).toBe('pause'); expect(getPauseResumeAction('seeding')).toBe('pause'); expect(getPauseResumeAction('retrying')).toBe('pause'); expect(getPauseResumeAction('paused')).toBe('resume'); diff --git a/src/utils/downloadActions.ts b/src/utils/downloadActions.ts index 6768c41..6f4a249 100644 --- a/src/utils/downloadActions.ts +++ b/src/utils/downloadActions.ts @@ -15,6 +15,7 @@ const PAUSABLE_STATUSES: ReadonlySet = new Set([ 'seeding', 'waitingToSeed', 'processing', + 'verifying', 'retrying', ]); @@ -66,7 +67,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 === 'waitingToSeed' || status === 'retrying'; + status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying'; export const isIdentityLocked = (status: DownloadStatus): boolean => isTransferLocked(status) || status === 'completed'; diff --git a/src/utils/downloadProgress.ts b/src/utils/downloadProgress.ts index a3415cd..c786c7d 100644 --- a/src/utils/downloadProgress.ts +++ b/src/utils/downloadProgress.ts @@ -73,6 +73,8 @@ export const downloadProgressColorClass = (status: string): string => { return 'download-status-failed'; case 'processing': return 'download-status-processing'; + case 'verifying': + return 'download-status-processing'; case 'seeding': return 'download-status-seeding'; case 'queued': diff --git a/src/utils/downloadSummary.ts b/src/utils/downloadSummary.ts index b9940c5..4e86362 100644 --- a/src/utils/downloadSummary.ts +++ b/src/utils/downloadSummary.ts @@ -22,6 +22,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean => status === 'downloading' || status === 'seeding' || status === 'processing' || + status === 'verifying' || status === 'retrying'; const hasPositiveProgress = (download: DownloadItem): boolean => diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 9261d11..a5388ac 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -56,6 +56,14 @@ describe('download persistence progress snapshots', () => { expect(persisted.totalIsEstimate).toBe(false); } ); + + it('does not persist verification byte counters across restart', () => { + const persisted = redactDownloadForPersistence(item('verifying')); + + expect(persisted.downloadedBytes).toBeUndefined(); + expect(persisted.totalBytes).toBeUndefined(); + expect(persisted.totalIsEstimate).toBeUndefined(); + }); }); describe('Torrent tracker input validation', () => { diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index e9d9793..27d4182 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet = new Set([ 'queued', 'downloading', 'processing', + 'verifying', 'seeding', 'waitingToSeed', 'retrying', @@ -40,7 +41,7 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean => /** Transfer states that consume a worker/permit. Queued is intentionally excluded. */ export const isTransferActiveStatus = (status: DownloadStatus): boolean => - status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying'; + status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'retrying'; export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MAX = 16; @@ -66,6 +67,11 @@ export const normalizeTorrentEncryptionPolicy = ( return undefined; }; +export type TorrentFileAllocation = 'prealloc' | 'none'; + +export const normalizeTorrentFileAllocation = (value: unknown): TorrentFileAllocation | undefined => + value === 'prealloc' || value === 'none' ? value : undefined; + export const MAX_TORRENT_TRACKER_TIMEOUT = 604800; export const MAX_TORRENT_TRACKER_INTERVAL = 604800; export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100; @@ -456,6 +462,7 @@ export const isMediaUrl = (rawUrl: string): boolean => { const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; const VOLATILE_PROGRESS_STATUSES = new Set([ 'downloading', + 'verifying', 'seeding' ]);