feat(torrents): harden network identity settings

This commit is contained in:
NimBold
2026-08-02 22:39:05 +03:30
parent d171f736c5
commit 7da67c15b5
15 changed files with 1118 additions and 23 deletions
+12 -12
View File
@@ -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.
+18
View File
@@ -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,
+189 -8
View File
@@ -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<Optio
let connection = state.lock()?;
let settings = crate::db::load_settings(&connection)?;
if state.is_portable() {
return Ok(settings);
return settings
.map(|data| crate::settings::canonicalize_torrent_network_settings(&data))
.transpose();
}
settings
.map(|data| crate::db::strip_pairing_token_from_settings(&data))
.transpose()?
.map(|data| crate::settings::canonicalize_torrent_network_settings(&data))
.transpose()
}
@@ -7745,7 +7796,10 @@ mod tests {
metadata_headers, metadata_response_error,
normalize_speed_limit_for_aria2,
apply_aria2_torrent_global_options,
apply_aria2_torrent_network_options,
apply_aria2_torrent_peer_identity_options,
apply_aria2_torrent_peer_discovery_options,
aria2_rpc_port_is_occupied,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
has_resumable_download_assets, is_media_artifact_name,
@@ -7821,6 +7875,74 @@ mod tests {
);
}
#[test]
fn aria2_torrent_network_options_are_explicit_and_omit_defaults() {
let mut command = std::process::Command::new("aria2c");
apply_aria2_torrent_network_options(
&mut command,
"6881-6999",
"6881",
"203.0.113.7",
"router.example:6881",
"[2001:db8::1]:6881",
"2001:db8::2",
"en0",
);
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
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::<Vec<_>>();
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::<Aria2DaemonGuard>();
*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::<Aria2DaemonGuard>();
*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) => {
+253
View File
@@ -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<u32, String> {
Ok(value)
}
fn normalize_optional_torrent_network_value(
value: Option<&str>,
field: &str,
) -> Result<Option<String>, 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<u16, String> {
let port = value
.trim()
.parse::<u32>()
.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<u16, String> {
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<Option<String>, 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::<u16>(), end.parse::<u16>()), (Ok(start), Ok(end)) if start <= port && port <= end)
} else {
entry.parse::<u16>() == Ok(port)
}
})
}
pub(crate) fn normalize_torrent_external_ip(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent external IP")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::IpAddr>()
.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<Option<String>, 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::<std::net::Ipv6Addr>().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<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "IPv6 DHT listen address")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::Ipv6Addr>()
.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<Option<String>, String> {
normalize_optional_torrent_network_value(value, "Torrent LPD interface")
}
pub(crate) fn normalize_torrent_peer_id_prefix(
value: Option<&str>,
) -> Result<Option<String>, 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<Option<String>, 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!(
+311 -2
View File
@@ -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<Option<String>, 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<R: tauri::Runtime>(
app_handle: &AppHandle<R>,
) -> Result<PersistedSettings, String> {
@@ -32,6 +113,42 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
Ok(settings)
}
fn canonicalize_torrent_network_value(
state: &mut Map<String, Value>,
key: &str,
normalize: impl Fn(Option<&str>) -> Result<Option<String>, 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<String, String> {
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<String, Value>),
@@ -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<String, Value>, key: &st
}
}
fn sanitize_torrent_network_string(
state: &mut serde_json::Map<String, Value>,
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<String, Value>,
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);
+1 -1
View File
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+135
View File
@@ -1241,6 +1241,141 @@ runEngineChecks(false);
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentNetwork)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentListenPort)}</span>
<small>{t($ => $.settings.network.torrentListenPortDescription)}</small>
</div>
<input
type="text"
value={settings.torrentListenPort}
onChange={(event) => settings.setTorrentListenPort(event.target.value)}
placeholder="6881-6999"
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentListenPort)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtListenPort)}</span>
<small>{t($ => $.settings.network.torrentDhtListenPortDescription)}</small>
</div>
<input
type="text"
value={settings.torrentDhtListenPort}
onChange={(event) => settings.setTorrentDhtListenPort(event.target.value)}
placeholder="6881-6999"
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentDhtListenPort)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentExternalIp)}</span>
<small>{t($ => $.settings.network.torrentExternalIpDescription)}</small>
</div>
<input
type="text"
value={settings.torrentExternalIp}
onChange={(event) => settings.setTorrentExternalIp(event.target.value)}
placeholder={t($ => $.settings.network.torrentExternalIpPlaceholder)}
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentExternalIp)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtEntryPoint)}</span>
<small>{t($ => $.settings.network.torrentDhtEntryPointDescription)}</small>
</div>
<input
type="text"
value={settings.torrentDhtEntryPoint}
onChange={(event) => settings.setTorrentDhtEntryPoint(event.target.value)}
placeholder="router.example:6881"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentDhtEntryPoint)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtEntryPoint6)}</span>
<small>{t($ => $.settings.network.torrentDhtEntryPoint6Description)}</small>
</div>
<input
type="text"
value={settings.torrentDhtEntryPoint6}
onChange={(event) => settings.setTorrentDhtEntryPoint6(event.target.value)}
placeholder="[2001:db8::1]:6881"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentDhtEntryPoint6)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtListenAddr6)}</span>
<small>{t($ => $.settings.network.torrentDhtListenAddr6Description)}</small>
</div>
<input
type="text"
value={settings.torrentDhtListenAddr6}
onChange={(event) => settings.setTorrentDhtListenAddr6(event.target.value)}
placeholder="2001:db8::2"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentDhtListenAddr6)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentLpdInterface)}</span>
<small>{t($ => $.settings.network.torrentLpdInterfaceDescription)}</small>
</div>
<input
type="text"
value={settings.torrentLpdInterface}
onChange={(event) => settings.setTorrentLpdInterface(event.target.value)}
placeholder="en0"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentLpdInterface)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentPeerIdPrefix)}</span>
<small>{t($ => $.settings.network.torrentPeerIdPrefixDescription)}</small>
</div>
<input
type="text"
value={settings.torrentPeerIdPrefix}
onChange={(event) => settings.setTorrentPeerIdPrefix(event.target.value)}
placeholder="-FL-1-3-1-"
maxLength={20}
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentPeerIdPrefix)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentPeerAgent)}</span>
<small>{t($ => $.settings.network.torrentPeerAgentDescription)}</small>
</div>
<input
type="text"
value={settings.torrentPeerAgent}
onChange={(event) => settings.setTorrentPeerAgent(event.target.value)}
placeholder="Firelink/1.3.1"
maxLength={128}
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentPeerAgent)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentResourceLimits)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
+21
View File
@@ -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 Aria2s default range.',
torrentDhtListenPort: 'UDP/DHT ports',
torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2s 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 Aria2s 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 Aria2s 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.',
+21
View File
@@ -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 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.',
+21
View File
@@ -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, ואינם מגדילים את מגבלת מערכת ההפעלה.',
+21
View File
@@ -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 и не повышают лимит операционной системы.',
+21
View File
@@ -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 і не підвищують обмеження операційної системи.',
+21
View File
@@ -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,且不会提高操作系统的限制。',
+1
View File
@@ -111,6 +111,7 @@ describe('translation catalogs', () => {
'settings.network.firefoxWindows',
'settings.network.firefoxMacos',
'settings.network.safariMacos',
'settings.network.torrentExternalIpPlaceholder',
]);
const unexpectedDuplicates = duplicates
+72
View File
@@ -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<void>;
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<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
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,