From 7da67c15b5a9e1c3c5a77440483a2cd59ab37328 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 2 Aug 2026 22:39:05 +0330 Subject: [PATCH] feat(torrents): harden network identity settings --- TORRENT_FEATURES.md | 24 +-- src-tauri/src/ipc.rs | 18 ++ src-tauri/src/lib.rs | 197 ++++++++++++++++++- src-tauri/src/queue.rs | 253 ++++++++++++++++++++++++ src-tauri/src/settings.rs | 313 +++++++++++++++++++++++++++++- src/bindings/PersistedSettings.ts | 2 +- src/components/SettingsView.tsx | 135 +++++++++++++ src/i18n/catalogs/en.ts | 21 ++ src/i18n/catalogs/fa.ts | 21 ++ src/i18n/catalogs/he.ts | 21 ++ src/i18n/catalogs/ru.ts | 21 ++ src/i18n/catalogs/uk.ts | 21 ++ src/i18n/catalogs/zh-CN.ts | 21 ++ src/i18n/resources.test.ts | 1 + src/store/useSettingsStore.ts | 72 +++++++ 15 files changed, 1118 insertions(+), 23 deletions(-) diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 37f8954..3558235 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -23,6 +23,13 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar the selected-Torrent detail view exposes only operational speeds, seeder, and choking flags, with a bounded display count. - Global DHT, IPv6 DHT, PEX, and Local Peer Discovery toggles. +- Configurable TCP and UDP listen-port ranges, external BitTorrent IP, + IPv4/IPv6 DHT entry points, IPv6 DHT listen address, and LPD interface. + Values are validated, persisted, applied at Aria2 startup, and accompanied + by platform and firewall/port-forwarding warnings. +- Optional peer-ID prefix and BitTorrent peer-agent controls. Values are + bounded and validated, remain disabled by default, and include explicit + privacy, protocol-identity, and compatibility warnings. - Optional piece-integrity verification, including the explicit policy that disables unverified seeding when verification is requested. - Optional stall timeout through `bt-stop-timeout`, persisted with each @@ -69,24 +76,17 @@ No remaining Tier 0 items. ### Tier 1 — transfer policy and storage behavior -1. **File priority beyond selection** — Aria2 exposes only the binary - `selected` file state through its Torrent file API and has no supported - per-file priority option. Firelink therefore does not pretend that - `select-file` is file priority; this remains pending an engine capability or - a safe product-level model. +No remaining Tier 1 items. ### Tier 2 — advanced networking and daemon tuning -1. Configurable TCP/UDP listen ports, external IP, DHT entry points, IPv6 DHT - listen address, and LPD interface, with platform/firewall warnings. -2. Peer identity/agent controls, with explicit privacy and protocol-identity - warnings. -3. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only +1. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only if the resulting child-GID ownership model can be represented safely; the current explicit metadata path intentionally avoids unmapped child jobs. The first implementation in this task was remote `.torrent` metadata intake; follow-up implementations add stall-timeout control, bounded peer diagnostics, persisted tracker exclusion, piece-preview priority, safe unselected-file -removal, the validated encryption policy, tracker timing controls, and the -global Torrent open-file limit. +removal, the validated encryption policy, tracker timing controls, the global +Torrent open-file limit, launch-scoped Torrent network binding controls, and +peer identity/agent controls. diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index f06a604..36b4eae 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -499,6 +499,24 @@ pub struct PersistedSettings { pub torrent_enable_lpd: bool, #[serde(default = "default_torrent_max_open_files")] pub torrent_max_open_files: u32, + #[serde(default)] + pub torrent_listen_port: String, + #[serde(default)] + pub torrent_dht_listen_port: String, + #[serde(default)] + pub torrent_external_ip: String, + #[serde(default)] + pub torrent_dht_entry_point: String, + #[serde(default)] + pub torrent_dht_entry_point6: String, + #[serde(default)] + pub torrent_dht_listen_addr6: String, + #[serde(default)] + pub torrent_lpd_interface: String, + #[serde(default)] + pub torrent_peer_id_prefix: String, + #[serde(default)] + pub torrent_peer_agent: 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 eb88a5c..da0a5c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6626,6 +6626,52 @@ fn apply_aria2_torrent_peer_discovery_options( .arg(format!("--bt-enable-lpd={enable_lpd}")); } +fn apply_aria2_torrent_network_options( + command: &mut std::process::Command, + listen_port: &str, + dht_listen_port: &str, + external_ip: &str, + dht_entry_point: &str, + dht_entry_point6: &str, + dht_listen_addr6: &str, + lpd_interface: &str, +) { + for (option, value) in [ + ("--listen-port", listen_port), + ("--dht-listen-port", dht_listen_port), + ("--bt-external-ip", external_ip), + ("--dht-entry-point", dht_entry_point), + ("--dht-entry-point6", dht_entry_point6), + ("--dht-listen-addr6", dht_listen_addr6), + ("--bt-lpd-interface", lpd_interface), + ] { + let value = value.trim(); + if !value.is_empty() { + command.arg(format!("{option}={value}")); + } + } +} + +fn apply_aria2_torrent_peer_identity_options( + command: &mut std::process::Command, + peer_id_prefix: &str, + peer_agent: &str, +) { + for (option, value) in [ + ("--peer-id-prefix", peer_id_prefix), + ("--peer-agent", peer_agent), + ] { + let value = value.trim(); + if !value.is_empty() { + command.arg(format!("{option}={value}")); + } + } +} + +fn aria2_rpc_port_is_occupied(port: u16) -> bool { + std::net::TcpListener::bind(("127.0.0.1", port)).is_err() +} + fn apply_aria2_torrent_global_options( command: &mut std::process::Command, max_open_files: u32, @@ -7265,6 +7311,7 @@ fn db_save_settings( let sanitized = crate::db::strip_pairing_token_from_settings(&merged)?; crate::db::preserve_legacy_pairing_token(existing.as_deref(), &sanitized)? }; + let merged = crate::settings::canonicalize_torrent_network_settings(&merged)?; crate::db::save_settings(&connection, &merged)?; let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; let prevent_system_sleep = decoded.prevents_sleep_while_downloading; @@ -7286,10 +7333,14 @@ fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result>(), + vec![ + "--listen-port=6881-6999", + "--dht-listen-port=6881", + "--bt-external-ip=203.0.113.7", + "--dht-entry-point=router.example:6881", + "--dht-entry-point6=[2001:db8::1]:6881", + "--dht-listen-addr6=2001:db8::2", + "--bt-lpd-interface=en0", + ] + ); + + let mut defaults = std::process::Command::new("aria2c"); + apply_aria2_torrent_network_options(&mut defaults, "", "", "", "", "", "", ""); + assert!(defaults.get_args().next().is_none()); + } + + #[test] + fn aria2_torrent_peer_identity_options_are_explicit_and_omit_defaults() { + let mut command = std::process::Command::new("aria2c"); + apply_aria2_torrent_peer_identity_options( + &mut command, + "-FL-1-3-1-", + "Firelink/1.3.1", + ); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + args, + vec![ + "--peer-id-prefix=-FL-1-3-1-", + "--peer-agent=Firelink/1.3.1", + ] + ); + + let mut defaults = std::process::Command::new("aria2c"); + apply_aria2_torrent_peer_identity_options(&mut defaults, "", ""); + assert!(defaults.get_args().next().is_none()); + } + + #[test] + fn aria2_rpc_port_occupancy_is_detected_without_claiming_free_ports() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(aria2_rpc_port_is_occupied(port)); + drop(listener); + assert!(!aria2_rpc_port_is_occupied(port)); + } + #[test] fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() { let record = json!({ @@ -10487,6 +10609,8 @@ pub fn run() { queue::DEFAULT_TORRENT_MAX_OPEN_FILES } }; + let torrent_startup_settings = + crate::settings::torrent_startup_settings(persisted_settings.as_ref()); let aria2_secret_clone = aria2_secret.clone(); let app_handle_bg = app.handle().clone(); @@ -10496,7 +10620,20 @@ pub fn run() { match resolve_bundled_binary_path(&app_handle_bg, "aria2c") { Ok(binary_path) => { let mut success = false; + let mut attempted_rpc_port = false; + let mut startup_failure = None; for attempt_port in 6800..6900 { + if queue::torrent_port_spec_contains( + if torrent_startup_settings.listen_port.is_empty() { + queue::DEFAULT_TORRENT_LISTEN_PORT_SPEC + } else { + &torrent_startup_settings.listen_port + }, + attempt_port, + ) { + continue; + } + attempted_rpc_port = true; let mut cmd = std::process::Command::new(&binary_path); crate::platform::hide_child_console(&mut cmd); crate::engines::apply_aria2_environment(&mut cmd, &binary_path); @@ -10530,6 +10667,21 @@ pub fn run() { torrent_peer_discovery.2, torrent_peer_discovery.3, ); + apply_aria2_torrent_network_options( + &mut cmd, + &torrent_startup_settings.listen_port, + &torrent_startup_settings.dht_listen_port, + &torrent_startup_settings.external_ip, + &torrent_startup_settings.dht_entry_point, + &torrent_startup_settings.dht_entry_point6, + &torrent_startup_settings.dht_listen_addr6, + &torrent_startup_settings.lpd_interface, + ); + apply_aria2_torrent_peer_identity_options( + &mut cmd, + &torrent_startup_settings.peer_id_prefix, + &torrent_startup_settings.peer_agent, + ); if let Some(limit) = normalize_speed_limit_for_aria2(&global_speed_limit) { cmd.arg(format!("--max-overall-download-limit={}", limit)); @@ -10542,9 +10694,34 @@ pub fn run() { Ok(mut child) => { // Give it a moment to fail if port is in use tokio::time::sleep(std::time::Duration::from_millis(200)).await; - if let Ok(Some(_)) = child.try_wait() { - // Process exited, likely port collision, try next - continue; + if let Ok(Some(status)) = child.try_wait() { + use std::io::Read; + let mut stderr = String::new(); + if let Some(pipe) = child.stderr.take() { + // A failed child is not trusted to produce a bounded + // diagnostic. Keep startup error handling bounded so a + // pathological stderr stream cannot stall or exhaust + // the launcher while it is deciding whether to retry. + let _ = pipe.take(4096).read_to_string(&mut stderr); + } + let stderr = stderr.trim().to_string(); + if aria2_rpc_port_is_occupied(attempt_port) { + // The RPC port is occupied by another process; retry + // with the next candidate. A configured Torrent port + // cannot cause this branch because overlapping RPC + // candidates are skipped above. + continue; + } + startup_failure = Some(if stderr.is_empty() { + format!( + "aria2 exited before startup on RPC port {attempt_port} with status {status}" + ) + } else { + format!( + "aria2 exited before startup on RPC port {attempt_port}: {stderr}" + ) + }); + break; } log::info!("aria2c spawned successfully on port {}", attempt_port); @@ -10605,16 +10782,20 @@ pub fn run() { break; } Err(e) => { - log::error!("Failed to spawn aria2c: {}", e); - let guard = app_handle_bg.state::(); - *guard.startup_error.lock().unwrap() = Some(format!("Failed to spawn aria2c: {e}")); + startup_failure = Some(format!("Failed to spawn aria2c: {e}")); break; } } } if !success { let guard = app_handle_bg.state::(); - *guard.startup_error.lock().unwrap() = Some("Failed to find open port for aria2c".to_string()); + *guard.startup_error.lock().unwrap() = Some(startup_failure.unwrap_or_else(|| { + if attempted_rpc_port { + "Failed to find open RPC port for aria2c".to_string() + } else { + "No Aria2 RPC port is available outside the configured Torrent TCP listen ports".to_string() + } + })); } } Err(e) => { diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 7d8487e..39782be 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -27,6 +27,11 @@ pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800; pub const DEFAULT_TORRENT_MAX_OPEN_FILES: u32 = 100; pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1; pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096; +pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256; +pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20; +pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128; +pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024; +pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999"; pub fn clamp_download_connections(connections: i32) -> i32 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) @@ -41,6 +46,184 @@ pub fn normalize_torrent_max_open_files(value: u32) -> Result { Ok(value) } +fn normalize_optional_torrent_network_value( + value: Option<&str>, + field: &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(|character| character.is_control()) + { + return Err(format!("{field} is too long or contains control characters")); + } + Ok(Some(value.to_string())) +} + +fn normalize_torrent_port(value: &str, field: &str) -> Result { + let port = value + .trim() + .parse::() + .map_err(|_| format!("{field} contains an invalid port"))?; + if !(1..=u16::MAX as u32).contains(&port) { + return Err(format!("{field} ports must be between 1 and 65535")); + } + Ok(port as u16) +} + +fn normalize_torrent_listen_port(value: &str, field: &str) -> Result { + let port = normalize_torrent_port(value, field)?; + if port < MIN_TORRENT_LISTEN_PORT { + return Err(format!( + "{field} ports must be between {MIN_TORRENT_LISTEN_PORT} and 65535" + )); + } + Ok(port) +} + +pub(crate) fn normalize_torrent_port_spec( + value: Option<&str>, + field: &str, +) -> Result, String> { + let Some(value) = normalize_optional_torrent_network_value(value, field)? else { + return Ok(None); + }; + let mut normalized = Vec::new(); + for entry in value.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + return Err(format!("{field} contains an empty port entry")); + } + if let Some((start, end)) = entry.split_once('-') { + let start = normalize_torrent_listen_port(start, field)?; + let end = normalize_torrent_listen_port(end, field)?; + if start > end { + return Err(format!("{field} contains a reversed port range")); + } + normalized.push(format!("{start}-{end}")); + } else { + normalized.push(normalize_torrent_listen_port(entry, field)?.to_string()); + } + } + Ok(Some(normalized.join(","))) +} + +pub(crate) fn torrent_port_spec_contains(spec: &str, port: u16) -> bool { + spec.split(',').any(|entry| { + let entry = entry.trim(); + if let Some((start, end)) = entry.split_once('-') { + matches!((start.parse::(), end.parse::()), (Ok(start), Ok(end)) if start <= port && port <= end) + } else { + entry.parse::() == Ok(port) + } + }) +} + +pub(crate) fn normalize_torrent_external_ip( + value: Option<&str>, +) -> Result, String> { + let Some(value) = normalize_optional_torrent_network_value(value, "Torrent external IP")? + else { + return Ok(None); + }; + let address = value + .parse::() + .map_err(|_| "Torrent external IP must be a valid IPv4 or IPv6 address".to_string())?; + Ok(Some(address.to_string())) +} + +pub(crate) fn normalize_torrent_dht_entry_point( + value: Option<&str>, + ipv6: bool, +) -> Result, String> { + let field = if ipv6 { + "IPv6 DHT entry point" + } else { + "IPv4 DHT entry point" + }; + let Some(value) = normalize_optional_torrent_network_value(value, field)? else { + return Ok(None); + }; + let (host, port) = if let Some(rest) = value.strip_prefix('[') { + let (host, port) = rest + .split_once(']') + .and_then(|(host, suffix)| suffix.strip_prefix(':').map(|port| (host, port))) + .ok_or_else(|| format!("{field} must use host:port syntax"))?; + if host.parse::().is_err() { + return Err(format!("{field} has an invalid IPv6 host")); + } + (format!("[{host}]"), normalize_torrent_port(port, field)?) + } else { + let (host, port) = value + .rsplit_once(':') + .ok_or_else(|| format!("{field} must use host:port syntax"))?; + if host.is_empty() || host.contains(':') || host.contains(['/', '\\', '@']) { + return Err(format!("{field} has an invalid host")); + } + if url::Host::parse(host).is_err() { + return Err(format!("{field} has an invalid host")); + } + (host.to_ascii_lowercase(), normalize_torrent_port(port, field)?) + }; + if ipv6 && !host.starts_with('[') { + return Err(format!("{field} must use an IPv6 host")); + } + if !ipv6 && host.starts_with('[') { + return Err(format!("{field} must use an IPv4 or hostname host")); + } + Ok(Some(format!("{host}:{port}"))) +} + +pub(crate) fn normalize_torrent_dht_listen_addr6( + value: Option<&str>, +) -> Result, String> { + let Some(value) = normalize_optional_torrent_network_value(value, "IPv6 DHT listen address")? + else { + return Ok(None); + }; + let address = value + .parse::() + .map_err(|_| "IPv6 DHT listen address must be a valid IPv6 address".to_string())?; + Ok(Some(address.to_string())) +} + +pub(crate) fn normalize_torrent_lpd_interface( + value: Option<&str>, +) -> Result, String> { + normalize_optional_torrent_network_value(value, "Torrent LPD interface") +} + +pub(crate) fn normalize_torrent_peer_id_prefix( + value: Option<&str>, +) -> Result, String> { + let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer ID prefix")? + else { + return Ok(None); + }; + if !value.is_ascii() || value.len() > MAX_TORRENT_PEER_ID_PREFIX_BYTES { + return Err(format!( + "Torrent peer ID prefix must be printable ASCII and at most {MAX_TORRENT_PEER_ID_PREFIX_BYTES} bytes" + )); + } + Ok(Some(value)) +} + +pub(crate) fn normalize_torrent_peer_agent( + value: Option<&str>, +) -> Result, String> { + let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer agent")? + else { + return Ok(None); + }; + if value.len() > MAX_TORRENT_PEER_AGENT_LENGTH { + return Err(format!( + "Torrent peer agent must be at most {MAX_TORRENT_PEER_AGENT_LENGTH} bytes" + )); + } + Ok(Some(value)) +} + fn reorder_selected_queue_tasks( queue_tasks: &[QueuedTask], ids: &[String], @@ -4730,6 +4913,76 @@ mod tests { assert_eq!(payload.torrent_tracker_interval, Some(33)); } + #[test] + fn torrent_network_settings_are_normalized_and_bounded() { + assert_eq!( + normalize_torrent_port_spec(Some(" 6881-6999, 7000 "), "TCP listen ports").unwrap(), + Some("6881-6999,7000".to_string()) + ); + assert_eq!( + normalize_torrent_external_ip(Some(" 2001:db8::1 ")).unwrap(), + Some("2001:db8::1".to_string()) + ); + assert_eq!( + normalize_torrent_dht_entry_point(Some("Bootstrap.Example:6881"), false).unwrap(), + Some("bootstrap.example:6881".to_string()) + ); + assert_eq!( + normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), true).unwrap(), + Some("[2001:db8::1]:6881".to_string()) + ); + assert_eq!( + normalize_torrent_dht_listen_addr6(Some("2001:db8::2")).unwrap(), + Some("2001:db8::2".to_string()) + ); + assert_eq!( + normalize_torrent_lpd_interface(Some("en0")).unwrap(), + Some("en0".to_string()) + ); + assert_eq!( + normalize_torrent_peer_id_prefix(Some("-FL-1-3-1-")).unwrap(), + Some("-FL-1-3-1-".to_string()) + ); + assert_eq!( + normalize_torrent_peer_agent(Some("Firelink/1.3.1")).unwrap(), + Some("Firelink/1.3.1".to_string()) + ); + assert!(torrent_port_spec_contains("6800-6802,6881", 6801)); + assert!(!torrent_port_spec_contains("6800-6802,6881", 6803)); + assert_eq!(normalize_torrent_port_spec(Some(" "), "TCP listen ports").unwrap(), None); + assert_eq!( + normalize_torrent_port_spec(Some("1024"), "TCP listen ports").unwrap(), + Some("1024".to_string()) + ); + } + + #[test] + fn torrent_network_settings_reject_unsafe_or_malformed_values() { + for value in ["0", "1023", "65536", "7000-6999", "6881,", "6881-"] { + assert!( + normalize_torrent_port_spec(Some(value), "TCP listen ports").is_err(), + "{value}" + ); + } + assert!(normalize_torrent_external_ip(Some("example.com")).is_err()); + assert!(normalize_torrent_dht_entry_point(Some("example.com"), false).is_err()); + assert!(normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), false).is_err()); + assert!(normalize_torrent_dht_entry_point(Some("2001:db8::1:6881"), true).is_err()); + assert!(normalize_torrent_dht_listen_addr6(Some("127.0.0.1")).is_err()); + assert!(normalize_torrent_lpd_interface(Some("en0\n--bad")).is_err()); + assert!(normalize_torrent_peer_id_prefix(Some("é")).is_err()); + assert!(normalize_torrent_peer_id_prefix(Some("123456789012345678901")).is_err()); + assert!(normalize_torrent_peer_agent(Some("agent\nname")).is_err()); + assert!(normalize_torrent_peer_agent(Some(&"a".repeat(MAX_TORRENT_PEER_AGENT_LENGTH + 1))).is_err()); + assert!( + normalize_torrent_port_spec( + Some(&"1".repeat(MAX_TORRENT_NETWORK_VALUE_LENGTH + 1)), + "TCP listen ports" + ) + .is_err() + ); + } + #[test] fn torrent_trackers_are_normalized_and_deduplicated() { assert_eq!( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index dbadb56..a06dfeb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,6 +7,87 @@ use serde_json::{Map, Value}; use std::collections::HashMap; use tauri::{AppHandle, Manager}; +#[derive(Clone, Debug, Default)] +pub struct TorrentStartupSettings { + pub listen_port: String, + pub dht_listen_port: String, + pub external_ip: String, + pub dht_entry_point: String, + pub dht_entry_point6: String, + pub dht_listen_addr6: String, + pub lpd_interface: String, + pub peer_id_prefix: String, + pub peer_agent: String, +} + +fn normalize_torrent_startup_value( + field: &str, + value: &str, + normalize: impl Fn(Option<&str>) -> Result, String>, +) -> String { + match normalize(Some(value)) { + Ok(Some(value)) => value, + Ok(None) => String::new(), + Err(error) => { + log::error!("invalid persisted {field}; using Aria2 default: {error}"); + String::new() + } + } +} + +pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> TorrentStartupSettings { + let Some(settings) = settings else { + return TorrentStartupSettings::default(); + }; + TorrentStartupSettings { + listen_port: normalize_torrent_startup_value( + "TCP listen ports", + &settings.torrent_listen_port, + |value| crate::queue::normalize_torrent_port_spec(value, "TCP listen ports"), + ), + dht_listen_port: normalize_torrent_startup_value( + "UDP listen ports", + &settings.torrent_dht_listen_port, + |value| crate::queue::normalize_torrent_port_spec(value, "UDP listen ports"), + ), + external_ip: normalize_torrent_startup_value( + "Torrent external IP", + &settings.torrent_external_ip, + crate::queue::normalize_torrent_external_ip, + ), + dht_entry_point: normalize_torrent_startup_value( + "IPv4 DHT entry point", + &settings.torrent_dht_entry_point, + |value| crate::queue::normalize_torrent_dht_entry_point(value, false), + ), + dht_entry_point6: normalize_torrent_startup_value( + "IPv6 DHT entry point", + &settings.torrent_dht_entry_point6, + |value| crate::queue::normalize_torrent_dht_entry_point(value, true), + ), + dht_listen_addr6: normalize_torrent_startup_value( + "IPv6 DHT listen address", + &settings.torrent_dht_listen_addr6, + crate::queue::normalize_torrent_dht_listen_addr6, + ), + lpd_interface: normalize_torrent_startup_value( + "Torrent LPD interface", + &settings.torrent_lpd_interface, + crate::queue::normalize_torrent_lpd_interface, + ), + peer_id_prefix: normalize_torrent_startup_value( + "Torrent peer ID prefix", + &settings.torrent_peer_id_prefix, + crate::queue::normalize_torrent_peer_id_prefix, + ), + peer_agent: normalize_torrent_startup_value( + "Torrent peer agent", + &settings.torrent_peer_agent, + crate::queue::normalize_torrent_peer_agent, + ), + } +} + pub fn load_settings( app_handle: &AppHandle, ) -> Result { @@ -32,6 +113,42 @@ pub fn decode_stored_settings(stored: &Value) -> Result, + key: &str, + normalize: impl Fn(Option<&str>) -> Result, String>, +) { + let Some(value) = state.get(key).and_then(Value::as_str) else { + return; + }; + let normalized = normalize(Some(value)).ok().flatten().unwrap_or_default(); + state.insert(key.to_string(), Value::String(normalized)); +} + +pub fn canonicalize_torrent_network_settings(stored: &str) -> Result { + let mut document = decode_document(&Value::String(stored.to_string()))?; + let state = settings_state_mut(&mut document)?; + canonicalize_torrent_network_value(state, "torrentListenPort", |value| { + crate::queue::normalize_torrent_port_spec(value, "TCP listen ports") + }); + canonicalize_torrent_network_value(state, "torrentDhtListenPort", |value| { + crate::queue::normalize_torrent_port_spec(value, "UDP listen ports") + }); + canonicalize_torrent_network_value(state, "torrentExternalIp", crate::queue::normalize_torrent_external_ip); + canonicalize_torrent_network_value(state, "torrentDhtEntryPoint", |value| { + crate::queue::normalize_torrent_dht_entry_point(value, false) + }); + canonicalize_torrent_network_value(state, "torrentDhtEntryPoint6", |value| { + crate::queue::normalize_torrent_dht_entry_point(value, true) + }); + canonicalize_torrent_network_value(state, "torrentDhtListenAddr6", crate::queue::normalize_torrent_dht_listen_addr6); + canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface); + canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix); + canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent); + serde_json::to_string(&document) + .map_err(|error| format!("failed to encode canonical settings: {error}")) +} + pub fn update_settings_state( app_handle: &AppHandle, update: impl FnOnce(&mut Map), @@ -205,6 +322,33 @@ fn sanitize_persisted_setting_values(state: &mut Value) { ] { sanitize_boolean_setting(state, key); } + sanitize_torrent_network_string(state, "torrentListenPort", |value| { + crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok() + }); + sanitize_torrent_network_string(state, "torrentDhtListenPort", |value| { + crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports").is_ok() + }); + sanitize_torrent_network_string(state, "torrentExternalIp", |value| { + crate::queue::normalize_torrent_external_ip(Some(value)).is_ok() + }); + sanitize_torrent_network_string(state, "torrentDhtEntryPoint", |value| { + crate::queue::normalize_torrent_dht_entry_point(Some(value), false).is_ok() + }); + sanitize_torrent_network_string(state, "torrentDhtEntryPoint6", |value| { + crate::queue::normalize_torrent_dht_entry_point(Some(value), true).is_ok() + }); + sanitize_torrent_network_string(state, "torrentDhtListenAddr6", |value| { + crate::queue::normalize_torrent_dht_listen_addr6(Some(value)).is_ok() + }); + sanitize_torrent_network_string(state, "torrentLpdInterface", |value| { + crate::queue::normalize_torrent_lpd_interface(Some(value)).is_ok() + }); + sanitize_torrent_network_string(state, "torrentPeerIdPrefix", |value| { + crate::queue::normalize_torrent_peer_id_prefix(Some(value)).is_ok() + }); + sanitize_torrent_network_string(state, "torrentPeerAgent", |value| { + crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok() + }); sanitize_allowed_string( state, "theme", @@ -296,6 +440,20 @@ fn sanitize_boolean_setting(state: &mut serde_json::Map, key: &st } } +fn sanitize_torrent_network_string( + state: &mut serde_json::Map, + key: &str, + is_valid: impl Fn(&str) -> bool, +) { + if state + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !is_valid(value)) + { + state.remove(key); + } +} + fn sanitize_allowed_string( state: &mut serde_json::Map, key: &str, @@ -321,6 +479,64 @@ fn validate_settings(settings: &mut PersistedSettings) { settings.torrent_max_open_files, ) .unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES); + settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec( + Some(&settings.torrent_listen_port), + "TCP listen ports", + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_dht_listen_port = crate::queue::normalize_torrent_port_spec( + Some(&settings.torrent_dht_listen_port), + "UDP listen ports", + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_external_ip = crate::queue::normalize_torrent_external_ip( + Some(&settings.torrent_external_ip), + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_dht_entry_point = crate::queue::normalize_torrent_dht_entry_point( + Some(&settings.torrent_dht_entry_point), + false, + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_dht_entry_point6 = crate::queue::normalize_torrent_dht_entry_point( + Some(&settings.torrent_dht_entry_point6), + true, + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_dht_listen_addr6 = crate::queue::normalize_torrent_dht_listen_addr6( + Some(&settings.torrent_dht_listen_addr6), + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_lpd_interface = crate::queue::normalize_torrent_lpd_interface( + Some(&settings.torrent_lpd_interface), + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_peer_id_prefix = crate::queue::normalize_torrent_peer_id_prefix( + Some(&settings.torrent_peer_id_prefix), + ) + .ok() + .flatten() + .unwrap_or_default(); + settings.torrent_peer_agent = crate::queue::normalize_torrent_peer_agent( + Some(&settings.torrent_peer_agent), + ) + .ok() + .flatten() + .unwrap_or_default(); if !matches!( settings.last_custom_speed_limit_unit.as_str(), "KB/s" | "MB/s" @@ -514,6 +730,15 @@ fn default_settings() -> PersistedSettings { torrent_enable_pex: true, torrent_enable_lpd: false, torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES, + torrent_listen_port: String::new(), + torrent_dht_listen_port: String::new(), + torrent_external_ip: String::new(), + torrent_dht_entry_point: String::new(), + torrent_dht_entry_point6: String::new(), + torrent_dht_listen_addr6: String::new(), + torrent_lpd_interface: String::new(), + torrent_peer_id_prefix: String::new(), + torrent_peer_agent: String::new(), custom_user_agent: String::new(), ask_where_to_save_each_file: false, remember_last_used_download_directory: false, @@ -530,8 +755,9 @@ fn default_settings() -> PersistedSettings { mod tests { use crate::ipc::{FontFamily, WindowControlStyle}; use super::{ - decode_stored_settings, default_settings, preserve_portable_pairing_token, - preserve_scheduler_runtime_keys, + canonicalize_torrent_network_settings, decode_stored_settings, default_settings, + preserve_portable_pairing_token, preserve_scheduler_runtime_keys, + torrent_startup_settings, }; use serde_json::{json, Value}; @@ -807,6 +1033,15 @@ mod tests { "torrentEnablePex": null, "torrentEnableLpd": [], "torrentMaxOpenFiles": 0, + "torrentListenPort": "7000-6999", + "torrentDhtListenPort": "6881,\n", + "torrentExternalIp": "not-an-ip", + "torrentDhtEntryPoint": "bootstrap.example", + "torrentDhtEntryPoint6": "2001:db8::1:6881", + "torrentDhtListenAddr6": "127.0.0.1", + "torrentLpdInterface": "en0\n--bad", + "torrentPeerIdPrefix": "123456789012345678901", + "torrentPeerAgent": "agent\nname", "theme": "not-a-theme", "calendarPreference": "lunar", "siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}] @@ -828,6 +1063,15 @@ mod tests { settings.torrent_max_open_files, crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES ); + assert!(settings.torrent_listen_port.is_empty()); + assert!(settings.torrent_dht_listen_port.is_empty()); + assert!(settings.torrent_external_ip.is_empty()); + assert!(settings.torrent_dht_entry_point.is_empty()); + assert!(settings.torrent_dht_entry_point6.is_empty()); + assert!(settings.torrent_dht_listen_addr6.is_empty()); + assert!(settings.torrent_lpd_interface.is_empty()); + assert!(settings.torrent_peer_id_prefix.is_empty()); + assert!(settings.torrent_peer_agent.is_empty()); assert!(matches!(settings.theme, crate::ipc::Theme::System)); assert!(matches!( settings.calendar_preference, @@ -837,6 +1081,71 @@ mod tests { assert_eq!(settings.site_logins[0].id, "valid"); } + #[test] + fn preserves_valid_torrent_network_settings() { + let stored = json!({ + "state": { + "torrentListenPort": " 6881-6999 ", + "torrentDhtListenPort": "6881", + "torrentExternalIp": "203.0.113.7", + "torrentDhtEntryPoint": "Bootstrap.Example:6881", + "torrentDhtEntryPoint6": "[2001:db8::1]:6881", + "torrentDhtListenAddr6": "2001:db8::2", + "torrentLpdInterface": "en0", + "torrentPeerIdPrefix": "-FL-1-3-1-", + "torrentPeerAgent": "Firelink/1.3.1" + } + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert_eq!(settings.torrent_listen_port, "6881-6999"); + assert_eq!(settings.torrent_dht_listen_port, "6881"); + assert_eq!(settings.torrent_external_ip, "203.0.113.7"); + assert_eq!(settings.torrent_dht_entry_point, "bootstrap.example:6881"); + assert_eq!(settings.torrent_dht_entry_point6, "[2001:db8::1]:6881"); + assert_eq!(settings.torrent_dht_listen_addr6, "2001:db8::2"); + assert_eq!(settings.torrent_lpd_interface, "en0"); + assert_eq!(settings.torrent_peer_id_prefix, "-FL-1-3-1-"); + assert_eq!(settings.torrent_peer_agent, "Firelink/1.3.1"); + } + + #[test] + fn canonicalizes_torrent_network_settings_for_frontend_hydration() { + let stored = json!({ + "state": { + "torrentListenPort": " 6881-6999 ", + "torrentExternalIp": "not-an-ip", + "torrentPeerIdPrefix": "123456789012345678901", + "torrentPeerAgent": " Firelink/1.3.1 " + }, + "version": 6 + }); + + let canonical = canonicalize_torrent_network_settings(&stored.to_string()).unwrap(); + let canonical: Value = serde_json::from_str(&canonical).unwrap(); + assert_eq!(canonical["state"]["torrentListenPort"], "6881-6999"); + assert_eq!(canonical["state"]["torrentExternalIp"], ""); + assert_eq!(canonical["state"]["torrentPeerIdPrefix"], ""); + assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1"); + } + + #[test] + fn startup_settings_revalidate_values_at_the_aria2_boundary() { + let stored = json!({ + "state": { + "torrentListenPort": "not-a-port", + "torrentPeerIdPrefix": "123456789012345678901", + "torrentPeerAgent": "Firelink/1.3.1" + } + }); + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + let startup = torrent_startup_settings(Some(&settings)); + assert!(startup.listen_port.is_empty()); + assert!(startup.peer_id_prefix.is_empty()); + assert_eq!(startup.peer_agent, "Firelink/1.3.1"); + } + #[test] fn opt_in_defaults_match_the_frontend_defaults() { assert!(!default_settings().play_completion_sound); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 3becb8c..e1351e4 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, 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, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 507d205..e6716e3 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1241,6 +1241,141 @@ runEngineChecks(false);

+

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

+
+
+
+ {t($ => $.settings.network.torrentListenPort)} + {t($ => $.settings.network.torrentListenPortDescription)} +
+ settings.setTorrentListenPort(event.target.value)} + placeholder="6881-6999" + className="app-control settings-port-input text-center" + aria-label={t($ => $.settings.network.torrentListenPort)} + /> +
+
+
+ {t($ => $.settings.network.torrentDhtListenPort)} + {t($ => $.settings.network.torrentDhtListenPortDescription)} +
+ settings.setTorrentDhtListenPort(event.target.value)} + placeholder="6881-6999" + className="app-control settings-port-input text-center" + aria-label={t($ => $.settings.network.torrentDhtListenPort)} + /> +
+
+
+ {t($ => $.settings.network.torrentExternalIp)} + {t($ => $.settings.network.torrentExternalIpDescription)} +
+ settings.setTorrentExternalIp(event.target.value)} + placeholder={t($ => $.settings.network.torrentExternalIpPlaceholder)} + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentExternalIp)} + /> +
+
+
+ {t($ => $.settings.network.torrentDhtEntryPoint)} + {t($ => $.settings.network.torrentDhtEntryPointDescription)} +
+ settings.setTorrentDhtEntryPoint(event.target.value)} + placeholder="router.example:6881" + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentDhtEntryPoint)} + /> +
+
+
+ {t($ => $.settings.network.torrentDhtEntryPoint6)} + {t($ => $.settings.network.torrentDhtEntryPoint6Description)} +
+ settings.setTorrentDhtEntryPoint6(event.target.value)} + placeholder="[2001:db8::1]:6881" + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentDhtEntryPoint6)} + /> +
+
+
+ {t($ => $.settings.network.torrentDhtListenAddr6)} + {t($ => $.settings.network.torrentDhtListenAddr6Description)} +
+ settings.setTorrentDhtListenAddr6(event.target.value)} + placeholder="2001:db8::2" + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentDhtListenAddr6)} + /> +
+
+
+ {t($ => $.settings.network.torrentLpdInterface)} + {t($ => $.settings.network.torrentLpdInterfaceDescription)} +
+ settings.setTorrentLpdInterface(event.target.value)} + placeholder="en0" + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentLpdInterface)} + /> +
+
+
+ {t($ => $.settings.network.torrentPeerIdPrefix)} + {t($ => $.settings.network.torrentPeerIdPrefixDescription)} +
+ settings.setTorrentPeerIdPrefix(event.target.value)} + placeholder="-FL-1-3-1-" + maxLength={20} + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentPeerIdPrefix)} + /> +
+
+
+ {t($ => $.settings.network.torrentPeerAgent)} + {t($ => $.settings.network.torrentPeerAgentDescription)} +
+ settings.setTorrentPeerAgent(event.target.value)} + placeholder="Firelink/1.3.1" + maxLength={128} + className="app-control settings-network-input" + aria-label={t($ => $.settings.network.torrentPeerAgent)} + /> +
+

+ {t($ => $.settings.network.torrentNetworkRestartNote)} +

+
+

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

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 5aeb929..91caa71 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -799,6 +799,27 @@ const common = { torrentLpd: 'Local Peer Discovery (LPD)', torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.', torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.', + torrentNetwork: 'BitTorrent network binding', + torrentListenPort: 'TCP peer ports', + torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2’s default range.', + torrentDhtListenPort: 'UDP/DHT ports', + torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2’s default range.', + torrentExternalIp: 'External IP address', + torrentExternalIpDescription: 'Address announced to peers and trackers when the host is behind NAT. Leave blank unless you know the reachable address.', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'IPv4 DHT entry point', + torrentDhtEntryPointDescription: 'Optional bootstrap host and port, for example router.example:6881.', + torrentDhtEntryPoint6: 'IPv6 DHT entry point', + torrentDhtEntryPoint6Description: 'Optional IPv6 bootstrap address and port in bracketed form, for example [2001:db8::1]:6881.', + torrentDhtListenAddr6: 'IPv6 DHT listen address', + torrentDhtListenAddr6Description: 'IPv6 address for the DHT socket. Leave blank to let Aria2 choose.', + torrentLpdInterface: 'LPD interface', + torrentLpdInterfaceDescription: 'Network interface name or address used for Local Peer Discovery. Leave blank for the default interface.', + torrentPeerIdPrefix: 'Peer ID prefix', + torrentPeerIdPrefixDescription: 'Overrides the BitTorrent peer-ID prefix. Use only if you understand the privacy and protocol-identity impact; leave blank for Aria2’s default.', + torrentPeerAgent: 'Peer agent', + torrentPeerAgentDescription: 'Overrides the client string sent in the BitTorrent extended handshake. This changes protocol identity and may affect compatibility; leave blank for Aria2’s default.', + torrentNetworkRestartNote: 'These settings are launch-scoped and apply after Firelink restarts. Opening ports may require router port forwarding and an operating-system firewall rule; availability depends on the platform and network.', 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.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 6708116..f15bc64 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -799,6 +799,27 @@ const fa = { torrentLpd: 'کشف همتای محلی (LPD)', torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا می‌کند و دیده‌شدن ترافیک در شبکه محلی را افزایش می‌دهد.', torrentPeerDiscoveryRestartNote: 'این گزینه‌ها سراسری و مربوط به Aria2 هستند و پس از راه‌اندازی مجدد Firelink اعمال می‌شوند. Aria2 همچنان کشف همتا را برای تورنت‌های خصوصی خاموش می‌کند.', + torrentNetwork: 'اتصال شبکه بیت‌تورنت', + torrentListenPort: 'پورت‌های همتای TCP', + torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.', + torrentDhtListenPort: 'پورت‌های UDP/DHT', + torrentDhtListenPortDescription: 'پورت‌های UDP برای DHT و ترکرهای UDP. برای محدوده پیش‌فرض Aria2 خالی بگذارید.', + torrentExternalIp: 'آدرس IP خارجی', + torrentExternalIpDescription: 'آدرسی که هنگام قرار گرفتن میزبان پشت NAT به همتاها و ترکرها اعلام می‌شود. مگر از آدرس قابل دسترس مطمئن باشید، خالی بگذارید.', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'نقطه ورود DHT نسخه IPv4', + torrentDhtEntryPointDescription: 'میزبان و پورت اختیاری برای شروع DHT؛ مانند router.example:6881.', + torrentDhtEntryPoint6: 'نقطه ورود DHT نسخه IPv6', + torrentDhtEntryPoint6Description: 'آدرس و پورت اختیاری IPv6 برای شروع؛ مانند [2001:db8::1]:6881.', + torrentDhtListenAddr6: 'آدرس شنود DHT نسخه IPv6', + torrentDhtListenAddr6Description: 'آدرس IPv6 سوکت DHT. برای انتخاب خودکار توسط Aria2 خالی بگذارید.', + torrentLpdInterface: 'رابط LPD', + torrentLpdInterfaceDescription: 'نام رابط شبکه یا آدرس مورد استفاده برای کشف همتای محلی. برای رابط پیش‌فرض خالی بگذارید.', + torrentPeerIdPrefix: 'پیشوند شناسه همتا', + torrentPeerIdPrefixDescription: 'پیشوند شناسه همتای بیت‌تورنت را تغییر می‌دهد. فقط در صورت آگاهی از پیامدهای حریم خصوصی و هویت پروتکل استفاده کنید؛ برای پیش‌فرض Aria2 خالی بگذارید.', + torrentPeerAgent: 'عامل همتا', + torrentPeerAgentDescription: 'رشته کلاینت ارسال‌شده در دست‌دهی توسعه‌یافته بیت‌تورنت را تغییر می‌دهد. این کار هویت پروتکل را تغییر می‌دهد و ممکن است بر سازگاری اثر بگذارد؛ برای پیش‌فرض Aria2 خالی بگذارید.', + torrentNetworkRestartNote: 'این تنظیمات هنگام راه‌اندازی اعمال می‌شوند و پس از راه‌اندازی مجدد Firelink اثر می‌کنند. باز کردن پورت‌ها ممکن است به port forwarding روتر و قانون فایروال سیستم‌عامل نیاز داشته باشد؛ دسترسی به آن‌ها به سیستم‌عامل و شبکه بستگی دارد.', torrentResourceLimits: 'محدودیت منابع بیت‌تورنت', torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت', torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 2e5396c..a207264 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -799,6 +799,27 @@ const he = { torrentLpd: 'גילוי עמיתים מקומיים (LPD)', torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.', torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.', + torrentNetwork: 'קישור רשת BitTorrent', + torrentListenPort: 'יציאות עמיתי TCP', + torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.', + torrentDhtListenPort: 'יציאות UDP/DHT', + torrentDhtListenPortDescription: 'יציאות UDP עבור DHT ועוקבי UDP. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.', + torrentExternalIp: 'כתובת IP חיצונית', + torrentExternalIpDescription: 'הכתובת שמוכרזת לעמיתים ולעוקבים כשהמחשב מאחורי NAT. השאר ריק אלא אם ידועה לך הכתובת הנגישה.', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'נקודת כניסה ל-DHT IPv4', + torrentDhtEntryPointDescription: 'מארח ויציאה אופציונליים לאתחול, לדוגמה router.example:6881.', + torrentDhtEntryPoint6: 'נקודת כניסה ל-DHT IPv6', + torrentDhtEntryPoint6Description: 'כתובת ויציאת IPv6 אופציונליות לאתחול בסוגריים, לדוגמה [2001:db8::1]:6881.', + torrentDhtListenAddr6: 'כתובת האזנה ל-DHT IPv6', + torrentDhtListenAddr6Description: 'כתובת IPv6 לשקע DHT. השאר ריק כדי לאפשר ל-Aria2 לבחור.', + torrentLpdInterface: 'ממשק LPD', + torrentLpdInterfaceDescription: 'שם ממשק הרשת או כתובת עבור גילוי עמיתים מקומי. השאר ריק כדי להשתמש בממשק ברירת המחדל.', + torrentPeerIdPrefix: 'קידומת מזהה עמית', + torrentPeerIdPrefixDescription: 'עוקף את קידומת מזהה העמית של BitTorrent. השתמש רק אם ברורות לך השלכות הפרטיות וזהות הפרוטוקול; השאר ריק עבור ברירת המחדל של Aria2.', + torrentPeerAgent: 'סוכן עמית', + torrentPeerAgentDescription: 'עוקף את מחרוזת הלקוח שנשלחת בלחיצת היד המורחבת של BitTorrent. הדבר משנה את זהות הפרוטוקול ועלול להשפיע על תאימות; השאר ריק עבור ברירת המחדל של Aria2.', + torrentNetworkRestartNote: 'הגדרות אלה חלות בעת הפעלת המנוע ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. פתיחת יציאות עשויה לדרוש העברת יציאות בנתב וכלל בחומת האש של מערכת ההפעלה; הזמינות תלויה בפלטפורמה וברשת.', torrentResourceLimits: 'מגבלות משאבי BitTorrent', torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי', torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 55d5410..1468013 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -799,6 +799,27 @@ const ru = { torrentLpd: 'Локальное обнаружение пиров (LPD)', torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.', torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.', + torrentNetwork: 'Сетевые параметры BitTorrent', + torrentListenPort: 'TCP-порты пиров', + torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.', + torrentDhtListenPort: 'Порты UDP/DHT', + torrentDhtListenPortDescription: 'UDP-порты для DHT и UDP-трекеров. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.', + torrentExternalIp: 'Внешний IP-адрес', + torrentExternalIpDescription: 'Адрес, объявляемый пирам и трекерам, если хост находится за NAT. Оставьте пустым, если не уверены в доступном адресе.', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'Точка входа IPv4 DHT', + torrentDhtEntryPointDescription: 'Необязательные хост и порт для начальной загрузки, например router.example:6881.', + torrentDhtEntryPoint6: 'Точка входа IPv6 DHT', + torrentDhtEntryPoint6Description: 'Необязательные адрес и порт IPv6 в скобках, например [2001:db8::1]:6881.', + torrentDhtListenAddr6: 'Адрес прослушивания IPv6 DHT', + torrentDhtListenAddr6Description: 'Адрес IPv6 для сокета DHT. Оставьте пустым, чтобы Aria2 выбрала его автоматически.', + torrentLpdInterface: 'Интерфейс LPD', + torrentLpdInterfaceDescription: 'Имя сетевого интерфейса или адрес для поиска локальных пиров. Оставьте пустым для интерфейса по умолчанию.', + torrentPeerIdPrefix: 'Префикс ID пира', + torrentPeerIdPrefixDescription: 'Переопределяет префикс ID пира BitTorrent. Используйте только понимая последствия для приватности и идентичности протокола; оставьте пустым для значения Aria2 по умолчанию.', + torrentPeerAgent: 'Агент пира', + torrentPeerAgentDescription: 'Переопределяет строку клиента в расширенном рукопожатии BitTorrent. Это меняет идентичность протокола и может повлиять на совместимость; оставьте пустым для значения Aria2 по умолчанию.', + torrentNetworkRestartNote: 'Эти параметры применяются при запуске и вступают в силу после перезапуска Firelink. Для открытия портов могут потребоваться перенаправление портов на маршрутизаторе и правило системного брандмауэра; доступность зависит от платформы и сети.', torrentResourceLimits: 'Ограничения ресурсов BitTorrent', torrentMaxOpenFiles: 'Максимум открытых файлов Torrent', torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index fa664e7..8e5a9cf 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -799,6 +799,27 @@ const uk = { torrentLpd: 'Локальний пошук пірів (LPD)', torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.', torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.', + torrentNetwork: 'Мережеві параметри BitTorrent', + torrentListenPort: 'TCP-порти пірів', + torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.', + torrentDhtListenPort: 'Порти UDP/DHT', + torrentDhtListenPortDescription: 'UDP-порти для DHT і UDP-трекерів. Залиште порожнім, щоб використати типовий діапазон Aria2.', + torrentExternalIp: 'Зовнішня IP-адреса', + torrentExternalIpDescription: 'Адреса, яку оголошують пірам і трекерам, коли хост перебуває за NAT. Залиште порожнім, якщо не знаєте доступну адресу.', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'Точка входу IPv4 DHT', + torrentDhtEntryPointDescription: 'Необов’язкові хост і порт для початкового підключення, наприклад router.example:6881.', + torrentDhtEntryPoint6: 'Точка входу IPv6 DHT', + torrentDhtEntryPoint6Description: 'Необов’язкові адреса й порт IPv6 у дужках, наприклад [2001:db8::1]:6881.', + torrentDhtListenAddr6: 'Адреса прослуховування IPv6 DHT', + torrentDhtListenAddr6Description: 'IPv6-адреса для сокета DHT. Залиште порожнім, щоб Aria2 вибрала її автоматично.', + torrentLpdInterface: 'Інтерфейс LPD', + torrentLpdInterfaceDescription: 'Назва мережевого інтерфейсу або адреса для пошуку локальних пірів. Залиште порожнім для типового інтерфейсу.', + torrentPeerIdPrefix: 'Префікс ID піра', + torrentPeerIdPrefixDescription: 'Перевизначає префікс ID піра BitTorrent. Використовуйте лише з розумінням наслідків для приватності та ідентичності протоколу; залиште порожнім для типового значення Aria2.', + torrentPeerAgent: 'Агент піра', + torrentPeerAgentDescription: 'Перевизначає рядок клієнта в розширеному рукостисканні BitTorrent. Це змінює ідентичність протоколу й може вплинути на сумісність; залиште порожнім для типового значення Aria2.', + torrentNetworkRestartNote: 'Ці параметри застосовуються під час запуску й набувають чинності після перезапуску Firelink. Для відкриття портів можуть знадобитися перенаправлення портів на маршрутизаторі та правило брандмауера ОС; доступність залежить від платформи й мережі.', torrentResourceLimits: 'Обмеження ресурсів BitTorrent', torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent', torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index b0489d2..64b0276 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -799,6 +799,27 @@ const zhCN = { torrentLpd: '本地节点发现(LPD)', torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。', torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。', + torrentNetwork: 'BitTorrent 网络绑定', + torrentListenPort: 'TCP 节点端口', + torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。', + torrentDhtListenPort: 'UDP/DHT 端口', + torrentDhtListenPortDescription: '用于 DHT 和 UDP 跟踪器的 UDP 端口。留空以使用 Aria2 的默认范围。', + torrentExternalIp: '外部 IP 地址', + torrentExternalIpDescription: '主机位于 NAT 后时向节点和跟踪器公布的地址。如果不确定可访问地址,请留空。', + torrentExternalIpPlaceholder: '203.0.113.7', + torrentDhtEntryPoint: 'IPv4 DHT 入口', + torrentDhtEntryPointDescription: '可选的引导主机和端口,例如 router.example:6881。', + torrentDhtEntryPoint6: 'IPv6 DHT 入口', + torrentDhtEntryPoint6Description: '可选的 IPv6 引导地址和端口,请使用方括号格式,例如 [2001:db8::1]:6881。', + torrentDhtListenAddr6: 'IPv6 DHT 监听地址', + torrentDhtListenAddr6Description: 'DHT 套接字使用的 IPv6 地址。留空以让 Aria2 自动选择。', + torrentLpdInterface: 'LPD 接口', + torrentLpdInterfaceDescription: '用于本地节点发现的网络接口名称或地址。留空以使用默认接口。', + torrentPeerIdPrefix: '节点 ID 前缀', + torrentPeerIdPrefixDescription: '覆盖 BitTorrent 节点 ID 前缀。只有了解其隐私和协议身份影响时才应修改;留空以使用 Aria2 默认值。', + torrentPeerAgent: '节点代理标识', + torrentPeerAgentDescription: '覆盖 BitTorrent 扩展握手中发送的客户端字符串。这会改变协议身份并可能影响兼容性;留空以使用 Aria2 默认值。', + torrentNetworkRestartNote: '这些设置在启动时应用,并在重启 Firelink 后生效。开放端口可能需要路由器端口转发和操作系统防火墙规则;可用性取决于平台和网络。', torrentResourceLimits: 'BitTorrent 资源限制', torrentMaxOpenFiles: 'Torrent 最大打开文件数', torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。', diff --git a/src/i18n/resources.test.ts b/src/i18n/resources.test.ts index 2eb8346..51d921e 100644 --- a/src/i18n/resources.test.ts +++ b/src/i18n/resources.test.ts @@ -111,6 +111,7 @@ describe('translation catalogs', () => { 'settings.network.firefoxWindows', 'settings.network.firefoxMacos', 'settings.network.safariMacos', + 'settings.network.torrentExternalIpPlaceholder', ]); const unexpectedDuplicates = duplicates diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index beb1980..8d705c2 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -246,6 +246,15 @@ export interface SettingsState { torrentEnablePex: boolean; torrentEnableLpd: boolean; torrentMaxOpenFiles: number; + torrentListenPort: string; + torrentDhtListenPort: string; + torrentExternalIp: string; + torrentDhtEntryPoint: string; + torrentDhtEntryPoint6: string; + torrentDhtListenAddr6: string; + torrentLpdInterface: string; + torrentPeerIdPrefix: string; + torrentPeerAgent: string; customUserAgent: string; askWhereToSaveEachFile: boolean; preventsSleepWhileDownloading: boolean; @@ -301,6 +310,15 @@ export interface SettingsState { setTorrentEnablePex: (enabled: boolean) => void; setTorrentEnableLpd: (enabled: boolean) => void; setTorrentMaxOpenFiles: (value: number) => Promise; + setTorrentListenPort: (value: string) => void; + setTorrentDhtListenPort: (value: string) => void; + setTorrentExternalIp: (value: string) => void; + setTorrentDhtEntryPoint: (value: string) => void; + setTorrentDhtEntryPoint6: (value: string) => void; + setTorrentDhtListenAddr6: (value: string) => void; + setTorrentLpdInterface: (value: string) => void; + setTorrentPeerIdPrefix: (value: string) => void; + setTorrentPeerAgent: (value: string) => void; setCustomUserAgent: (userAgent: string) => void; setAskWhereToSaveEachFile: (ask: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void; @@ -382,6 +400,15 @@ export const useSettingsStore = create()( torrentEnablePex: true, torrentEnableLpd: false, torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES, + torrentListenPort: '', + torrentDhtListenPort: '', + torrentExternalIp: '', + torrentDhtEntryPoint: '', + torrentDhtEntryPoint6: '', + torrentDhtListenAddr6: '', + torrentLpdInterface: '', + torrentPeerIdPrefix: '', + torrentPeerAgent: '', customUserAgent: '', askWhereToSaveEachFile: false, preventsSleepWhileDownloading: true, @@ -480,6 +507,15 @@ export const useSettingsStore = create()( setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }), setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }), setTorrentEnableLpd: (torrentEnableLpd) => set({ torrentEnableLpd }), + setTorrentListenPort: (torrentListenPort) => set({ torrentListenPort }), + setTorrentDhtListenPort: (torrentDhtListenPort) => set({ torrentDhtListenPort }), + setTorrentExternalIp: (torrentExternalIp) => set({ torrentExternalIp }), + setTorrentDhtEntryPoint: (torrentDhtEntryPoint) => set({ torrentDhtEntryPoint }), + setTorrentDhtEntryPoint6: (torrentDhtEntryPoint6) => set({ torrentDhtEntryPoint6 }), + setTorrentDhtListenAddr6: (torrentDhtListenAddr6) => set({ torrentDhtListenAddr6 }), + setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }), + setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }), + setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }), setTorrentMaxOpenFiles: (value) => { const normalized = normalizeTorrentMaxOpenFiles(value); if (normalized === undefined) { @@ -682,6 +718,15 @@ export const useSettingsStore = create()( torrentEnablePex: state.torrentEnablePex, torrentEnableLpd: state.torrentEnableLpd, torrentMaxOpenFiles: state.torrentMaxOpenFiles, + torrentListenPort: state.torrentListenPort, + torrentDhtListenPort: state.torrentDhtListenPort, + torrentExternalIp: state.torrentExternalIp, + torrentDhtEntryPoint: state.torrentDhtEntryPoint, + torrentDhtEntryPoint6: state.torrentDhtEntryPoint6, + torrentDhtListenAddr6: state.torrentDhtListenAddr6, + torrentLpdInterface: state.torrentLpdInterface, + torrentPeerIdPrefix: state.torrentPeerIdPrefix, + torrentPeerAgent: state.torrentPeerAgent, customUserAgent: state.customUserAgent, askWhereToSaveEachFile: state.askWhereToSaveEachFile, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, @@ -733,6 +778,33 @@ export const useSettingsStore = create()( torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd), torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles) ?? currentState.torrentMaxOpenFiles, + torrentListenPort: typeof persisted.torrentListenPort === 'string' + ? persisted.torrentListenPort + : currentState.torrentListenPort, + torrentDhtListenPort: typeof persisted.torrentDhtListenPort === 'string' + ? persisted.torrentDhtListenPort + : currentState.torrentDhtListenPort, + torrentExternalIp: typeof persisted.torrentExternalIp === 'string' + ? persisted.torrentExternalIp + : currentState.torrentExternalIp, + torrentDhtEntryPoint: typeof persisted.torrentDhtEntryPoint === 'string' + ? persisted.torrentDhtEntryPoint + : currentState.torrentDhtEntryPoint, + torrentDhtEntryPoint6: typeof persisted.torrentDhtEntryPoint6 === 'string' + ? persisted.torrentDhtEntryPoint6 + : currentState.torrentDhtEntryPoint6, + torrentDhtListenAddr6: typeof persisted.torrentDhtListenAddr6 === 'string' + ? persisted.torrentDhtListenAddr6 + : currentState.torrentDhtListenAddr6, + torrentLpdInterface: typeof persisted.torrentLpdInterface === 'string' + ? persisted.torrentLpdInterface + : currentState.torrentLpdInterface, + torrentPeerIdPrefix: typeof persisted.torrentPeerIdPrefix === 'string' + ? persisted.torrentPeerIdPrefix + : currentState.torrentPeerIdPrefix, + torrentPeerAgent: typeof persisted.torrentPeerAgent === 'string' + ? persisted.torrentPeerAgent + : currentState.torrentPeerAgent, sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition) ? persisted.sidebarPosition : currentState.sidebarPosition,