mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 17:25:42 +00:00
248b4ac460
- Shell & UI: restore focus on sidebar reveal, guard window drag regions, contain context menu Escape keys, and fallback gracefully when a filtered queue is deleted. - Download table: fix sort order for estimated sizes, descending null values, 3-state sort cycle, and desktop keyboard row navigation. - Add window: harden duplicate resolution against unmanaged disk targets, draft id keying, and media credential isolation. - Properties window: add individual Torrent file path copy, horizontal scroll protection for narrow and RTL layouts, and strict numeric bounds on property edits. - Settings & scheduler: add draft buffering for integer inputs, sanitize persisted scheduler options, synchronize post-queue action countdown cancellation, and localize token regeneration. - Engine & backend: isolate loopback JSON-RPC from environment proxies with no_proxy, normalize media cookie sources, dispatch container formats case-insensitively, and preserve snake_case arguments in torrent Tauri commands. - Verification & packaging: isolate Companion git tag resolution from sandboxed git configs, and add regression tests for workflow normalization and portable packaging.
1784 lines
67 KiB
Rust
1784 lines
67 KiB
Rust
use crate::ipc::{
|
|
AppFontSize, CalendarPreference, FontFamily, ListRowDensity, MediaCookieSource,
|
|
PersistedSettings, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme,
|
|
WindowControlStyle,
|
|
};
|
|
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,
|
|
pub dht_message_timeout: u32,
|
|
pub ipv6_enabled: bool,
|
|
pub bind_address: String,
|
|
pub disk_cache: 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();
|
|
};
|
|
let bind_address = normalize_torrent_startup_value(
|
|
"Torrent bind address",
|
|
&settings.torrent_bind_address,
|
|
crate::queue::normalize_torrent_bind_address,
|
|
);
|
|
let bind_address = if !settings.torrent_ipv6_enabled
|
|
&& bind_address
|
|
.parse::<std::net::IpAddr>()
|
|
.is_ok_and(|address| address.is_ipv6())
|
|
{
|
|
log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled");
|
|
String::new()
|
|
} else {
|
|
bind_address
|
|
};
|
|
|
|
TorrentStartupSettings {
|
|
listen_port: normalize_torrent_startup_value(
|
|
"TCP listen ports",
|
|
&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,
|
|
),
|
|
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
|
|
settings.torrent_dht_message_timeout,
|
|
)
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
|
|
ipv6_enabled: settings.torrent_ipv6_enabled,
|
|
bind_address,
|
|
disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
|
.unwrap_or_else(|error| {
|
|
log::error!("invalid persisted Aria2 disk cache; using default: {error}");
|
|
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn load_settings<R: tauri::Runtime>(
|
|
app_handle: &AppHandle<R>,
|
|
) -> Result<PersistedSettings, String> {
|
|
let database = app_handle.state::<crate::db::DbState>();
|
|
let connection = database.lock()?;
|
|
let stored = crate::db::load_settings(&connection)?
|
|
.ok_or_else(|| "settings are not persisted yet".to_string())?;
|
|
decode_stored_settings(&Value::String(stored))
|
|
}
|
|
|
|
pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, String> {
|
|
let document = decode_document(stored)?;
|
|
let mut state = settings_state(&document)?.clone();
|
|
migrate_location_settings(&mut state)?;
|
|
sanitize_persisted_setting_values(&mut state);
|
|
let mut merged = serde_json::to_value(default_settings())
|
|
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
|
|
merge_json(&mut merged, &state);
|
|
|
|
let mut settings: PersistedSettings = serde_json::from_value(merged)
|
|
.map_err(|error| format!("invalid persisted settings: {error}"))?;
|
|
validate_settings(&mut settings);
|
|
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);
|
|
canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address);
|
|
let disk_cache = state
|
|
.get("aria2DiskCache")
|
|
.and_then(Value::as_str)
|
|
.and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok())
|
|
.unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
|
state.insert("aria2DiskCache".to_string(), Value::String(disk_cache));
|
|
let dht_message_timeout = state
|
|
.get("torrentDhtMessageTimeout")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
|
state.insert(
|
|
"torrentDhtMessageTimeout".to_string(),
|
|
Value::Number(serde_json::Number::from(dht_message_timeout)),
|
|
);
|
|
let max_concurrent_seeds = state
|
|
.get("torrentMaxConcurrentSeeds")
|
|
.and_then(Value::as_u64)
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
|
state.insert(
|
|
"torrentMaxConcurrentSeeds".to_string(),
|
|
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
|
|
);
|
|
if !state
|
|
.get("torrentSeparateSeedSlots")
|
|
.is_some_and(Value::is_boolean)
|
|
{
|
|
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
|
|
}
|
|
if !state
|
|
.get("torrentIpv6Enabled")
|
|
.is_some_and(Value::is_boolean)
|
|
{
|
|
state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true));
|
|
}
|
|
if state
|
|
.get("torrentIpv6Enabled")
|
|
.and_then(Value::as_bool)
|
|
== Some(false)
|
|
&& state
|
|
.get("torrentBindAddress")
|
|
.and_then(Value::as_str)
|
|
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
|
.is_some_and(|address| address.is_ipv6())
|
|
{
|
|
return Err(
|
|
"IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(),
|
|
);
|
|
}
|
|
// Renderer snapshots are also a persistence boundary. Remove malformed
|
|
// scalar values before the document is written so a recoverable default
|
|
// is not hidden behind a hostile value that will fail on the next save or
|
|
// restart. Keep the network canonicalization above first so invalid text
|
|
// fields retain their established empty-string representation.
|
|
let state_value = if document.get("state").is_some() {
|
|
document
|
|
.get_mut("state")
|
|
.ok_or_else(|| "persisted settings state is missing".to_string())?
|
|
} else {
|
|
&mut document
|
|
};
|
|
sanitize_persisted_setting_values(state_value);
|
|
serde_json::to_string(&document)
|
|
.map_err(|error| format!("failed to encode canonical settings: {error}"))
|
|
}
|
|
|
|
/// Normalize one text setting before the frontend commits it to durable state.
|
|
/// Keep this on the native boundary so interactive validation and persisted
|
|
/// settings use exactly the same Aria2-compatible rules.
|
|
pub fn canonicalize_torrent_network_setting(field: &str, value: &str) -> Result<String, String> {
|
|
let normalized = match field {
|
|
"torrentListenPort" => {
|
|
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports")?
|
|
}
|
|
"torrentDhtListenPort" => {
|
|
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports")?
|
|
}
|
|
"torrentExternalIp" => crate::queue::normalize_torrent_external_ip(Some(value))?,
|
|
"torrentDhtEntryPoint" => {
|
|
crate::queue::normalize_torrent_dht_entry_point(Some(value), false)?
|
|
}
|
|
"torrentDhtEntryPoint6" => {
|
|
crate::queue::normalize_torrent_dht_entry_point(Some(value), true)?
|
|
}
|
|
"torrentDhtListenAddr6" => {
|
|
crate::queue::normalize_torrent_dht_listen_addr6(Some(value))?
|
|
}
|
|
"torrentLpdInterface" => crate::queue::normalize_torrent_lpd_interface(Some(value))?,
|
|
"torrentPeerIdPrefix" => crate::queue::normalize_torrent_peer_id_prefix(Some(value))?,
|
|
"torrentPeerAgent" => crate::queue::normalize_torrent_peer_agent(Some(value))?,
|
|
"torrentBindAddress" => crate::queue::normalize_torrent_bind_address(Some(value))?,
|
|
"aria2DiskCache" => return crate::queue::normalize_aria2_disk_cache(Some(value)),
|
|
_ => return Err("unknown Torrent network setting".to_string()),
|
|
};
|
|
Ok(normalized.unwrap_or_default())
|
|
}
|
|
|
|
pub fn update_settings_state(
|
|
app_handle: &AppHandle,
|
|
update: impl FnOnce(&mut Map<String, Value>),
|
|
) -> Result<(), String> {
|
|
let database = app_handle.state::<crate::db::DbState>();
|
|
let connection = database.lock()?;
|
|
let stored = crate::db::load_settings(&connection)?
|
|
.ok_or_else(|| "settings are not persisted yet".to_string())?;
|
|
let mut document = decode_document(&Value::String(stored))?;
|
|
update(settings_state_mut(&mut document)?);
|
|
let stored = serde_json::to_string(&document)
|
|
.map_err(|error| format!("failed to encode settings: {error}"))?;
|
|
crate::db::save_settings(&connection, &stored)
|
|
}
|
|
|
|
pub fn preserve_scheduler_runtime_keys(
|
|
existing: Option<&str>,
|
|
incoming: &str,
|
|
) -> Result<String, String> {
|
|
let Some(existing) = existing else {
|
|
return Ok(incoming.to_string());
|
|
};
|
|
let existing_document = match decode_document(&Value::String(existing.to_string())) {
|
|
Ok(doc) => doc,
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Failed to decode existing settings, dropping runtime keys: {}",
|
|
e
|
|
);
|
|
return Ok(incoming.to_string());
|
|
}
|
|
};
|
|
let existing_state = match settings_state(&existing_document) {
|
|
Ok(state) => state,
|
|
Err(_) => return Ok(incoming.to_string()),
|
|
};
|
|
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
|
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
|
for key in [
|
|
"schedulerLastStartKey",
|
|
"schedulerTriggeredStartKey",
|
|
"schedulerLastStopKey",
|
|
] {
|
|
if let Some(value) = existing_state.get(key) {
|
|
incoming_state.insert(key.to_string(), value.clone());
|
|
}
|
|
}
|
|
serde_json::to_string(&incoming_document)
|
|
.map_err(|error| format!("failed to encode persisted settings: {error}"))
|
|
}
|
|
|
|
pub fn preserve_portable_pairing_token(
|
|
existing: Option<&str>,
|
|
incoming: &str,
|
|
) -> Result<String, String> {
|
|
let Some(existing) = existing else {
|
|
return Ok(incoming.to_string());
|
|
};
|
|
|
|
let existing_document = decode_document(&Value::String(existing.to_string()))?;
|
|
let existing_state = settings_state(&existing_document)?;
|
|
let Some(token) = existing_state
|
|
.get("extensionPairingToken")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
else {
|
|
return Ok(incoming.to_string());
|
|
};
|
|
|
|
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
|
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
|
let incoming_token_present = incoming_state
|
|
.get("extensionPairingToken")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| !value.trim().is_empty());
|
|
if !incoming_token_present {
|
|
incoming_state.insert(
|
|
"extensionPairingToken".to_string(),
|
|
Value::String(token.to_string()),
|
|
);
|
|
}
|
|
|
|
serde_json::to_string(&incoming_document)
|
|
.map_err(|error| format!("failed to encode portable settings: {error}"))
|
|
}
|
|
|
|
fn decode_document(stored: &Value) -> Result<Value, String> {
|
|
match stored {
|
|
Value::String(text) => serde_json::from_str(text)
|
|
.map_err(|error| format!("failed to parse persisted settings: {error}")),
|
|
Value::Object(_) => Ok(stored.clone()),
|
|
_ => Err("persisted settings must be a JSON string or object".to_string()),
|
|
}
|
|
}
|
|
|
|
fn settings_state(document: &Value) -> Result<&Value, String> {
|
|
if let Some(state) = document.get("state") {
|
|
if !state.is_object() {
|
|
return Err("persisted settings state must be an object".to_string());
|
|
}
|
|
Ok(state)
|
|
} else if document.is_object() {
|
|
Ok(document)
|
|
} else {
|
|
Err("persisted settings must be an object".to_string())
|
|
}
|
|
}
|
|
|
|
fn settings_state_mut(document: &mut Value) -> Result<&mut Map<String, Value>, String> {
|
|
let has_envelope = document.get("state").is_some();
|
|
let state = if has_envelope {
|
|
document
|
|
.get_mut("state")
|
|
.ok_or_else(|| "persisted settings state is missing".to_string())?
|
|
} else {
|
|
document
|
|
};
|
|
state
|
|
.as_object_mut()
|
|
.ok_or_else(|| "persisted settings state must be an object".to_string())
|
|
}
|
|
|
|
fn merge_json(target: &mut Value, source: &Value) {
|
|
match (target, source) {
|
|
(Value::Object(target), Value::Object(source)) => {
|
|
for (key, value) in source {
|
|
if let Some(target_value) = target.get_mut(key) {
|
|
if json_value_types_match(target_value, value) {
|
|
merge_json(target_value, value);
|
|
}
|
|
} else {
|
|
target.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
}
|
|
(target, source) if json_value_types_match(target, source) => *target = source.clone(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn json_value_types_match(left: &Value, right: &Value) -> bool {
|
|
matches!(
|
|
(left, right),
|
|
(Value::Null, Value::Null)
|
|
| (Value::Bool(_), Value::Bool(_))
|
|
| (Value::Number(_), Value::Number(_))
|
|
| (Value::String(_), Value::String(_))
|
|
| (Value::Array(_), Value::Array(_))
|
|
| (Value::Object(_), Value::Object(_))
|
|
)
|
|
}
|
|
|
|
fn sanitize_persisted_setting_values(state: &mut Value) {
|
|
let Some(state) = state.as_object_mut() else {
|
|
return;
|
|
};
|
|
|
|
let main_window_size = state
|
|
.get("mainWindowSize")
|
|
.cloned()
|
|
.and_then(|value| serde_json::from_value::<crate::ipc::MainWindowSize>(value).ok())
|
|
.and_then(|size| crate::window_geometry::normalize_main_window_size(Some(&size)));
|
|
match main_window_size {
|
|
Some(size) => {
|
|
state.insert(
|
|
"mainWindowSize".to_string(),
|
|
serde_json::to_value(size).expect("main window size is serializable"),
|
|
);
|
|
}
|
|
None => {
|
|
state.remove("mainWindowSize");
|
|
}
|
|
}
|
|
|
|
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
|
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
|
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
|
sanitize_integer_setting(state, "minimumNormalDownloadSpeedKiB", |value| value.as_u64().is_some());
|
|
sanitize_integer_setting(state, "lastCustomSpeedLimitKiB", |value| value.as_u64().is_some());
|
|
sanitize_allowed_string(state, "lastCustomSpeedLimitUnit", &["KB/s", "MB/s"]);
|
|
sanitize_integer_setting(state, "proxyPort", |value| {
|
|
value
|
|
.as_u64()
|
|
.is_some_and(|value| (1..=u16::MAX as u64).contains(&value))
|
|
});
|
|
sanitize_integer_setting(state, "torrentMaxOpenFiles", |value| {
|
|
value
|
|
.as_u64()
|
|
.is_some_and(|value| {
|
|
(crate::queue::MIN_TORRENT_MAX_OPEN_FILES as u64..=
|
|
crate::queue::MAX_TORRENT_MAX_OPEN_FILES as u64)
|
|
.contains(&value)
|
|
})
|
|
});
|
|
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
|
|
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
|
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
|
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
|
|
.contains(&value)
|
|
})
|
|
});
|
|
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
|
|
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
|
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
|
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
|
|
.contains(&value)
|
|
})
|
|
});
|
|
for key in [
|
|
"categorySubfoldersEnabled",
|
|
"logsEnabled",
|
|
"isSidebarVisible",
|
|
"isFoldersCollapsed",
|
|
"schedulerRunning",
|
|
"retryNotFoundErrors",
|
|
"adaptiveMirrorSelection",
|
|
"showNotifications",
|
|
"playCompletionSound",
|
|
"autoAddClipboardLinks",
|
|
"showDockBadge",
|
|
"showMenuBarIcon",
|
|
"torrentEnableDht",
|
|
"torrentEnableDht6",
|
|
"torrentEnablePex",
|
|
"torrentEnableLpd",
|
|
"torrentSeparateSeedSlots",
|
|
"torrentIpv6Enabled",
|
|
"askWhereToSaveEachFile",
|
|
"rememberLastUsedDownloadDirectory",
|
|
"preventsSleepWhileDownloading",
|
|
"preventsDisplaySleepWhileDownloading",
|
|
"autoCheckUpdates",
|
|
"keychainAccessGranted",
|
|
] {
|
|
sanitize_boolean_setting(state, key);
|
|
}
|
|
for key in [
|
|
"proxyHost",
|
|
"customUserAgent",
|
|
"globalSpeedLimit",
|
|
"torrentOverallUploadLimit",
|
|
"baseDownloadFolder",
|
|
"schedulerLastStartKey",
|
|
"schedulerLastStopKey",
|
|
] {
|
|
sanitize_string_setting(state, key);
|
|
}
|
|
if let Some(presets) = state.get("speedLimitPresetValues") {
|
|
if !presets.is_array() {
|
|
state.remove("speedLimitPresetValues");
|
|
} else if let Some(presets_arr) = state.get_mut("speedLimitPresetValues").and_then(Value::as_array_mut) {
|
|
presets_arr.retain(|v| v.as_f64().is_some_and(|f| f.is_finite() && f > 0.0));
|
|
if presets_arr.is_empty() {
|
|
state.remove("speedLimitPresetValues");
|
|
}
|
|
}
|
|
}
|
|
if let Some(roots) = state.get("approvedDownloadRoots") {
|
|
if !roots.is_array() {
|
|
state.remove("approvedDownloadRoots");
|
|
} else if let Some(roots_arr) = state.get_mut("approvedDownloadRoots").and_then(Value::as_array_mut) {
|
|
roots_arr.retain(|v| v.as_str().is_some());
|
|
}
|
|
}
|
|
if let Some(active_ids) = state.get("schedulerActiveDownloadIds") {
|
|
if !active_ids.is_array() {
|
|
state.remove("schedulerActiveDownloadIds");
|
|
} else if let Some(ids_arr) = state.get_mut("schedulerActiveDownloadIds").and_then(Value::as_array_mut) {
|
|
ids_arr.retain(|v| v.as_str().is_some());
|
|
}
|
|
}
|
|
if let Some(overrides) = state.get("categoryDirectoryOverrides") {
|
|
if !overrides.is_object() {
|
|
state.remove("categoryDirectoryOverrides");
|
|
}
|
|
}
|
|
if let Some(subfolders) = state.get("categorySubfolders") {
|
|
if !subfolders.is_object() {
|
|
state.remove("categorySubfolders");
|
|
}
|
|
}
|
|
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_torrent_network_string(state, "torrentBindAddress", |value| {
|
|
crate::queue::normalize_torrent_bind_address(Some(value)).is_ok()
|
|
});
|
|
sanitize_torrent_network_string(state, "aria2DiskCache", |value| {
|
|
crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok()
|
|
});
|
|
sanitize_allowed_string(
|
|
state,
|
|
"theme",
|
|
&["system", "light", "dark", "dracula", "nord"],
|
|
);
|
|
sanitize_allowed_string(
|
|
state,
|
|
"fontFamily",
|
|
&[
|
|
"system",
|
|
"inter",
|
|
"outfit",
|
|
"vazirmatn",
|
|
"noto-sans-hebrew",
|
|
"noto-sans-sc",
|
|
"roboto",
|
|
"serif",
|
|
"monospace",
|
|
],
|
|
);
|
|
sanitize_allowed_string(
|
|
state,
|
|
"windowControlStyle",
|
|
&["auto", "macos", "windows", "gnome", "minimal"],
|
|
);
|
|
sanitize_allowed_string(
|
|
state,
|
|
"language",
|
|
&["system", "en", "zh-CN", "he", "fa", "uk", "ru"],
|
|
);
|
|
sanitize_allowed_string(state, "calendarPreference", &["gregorian", "persian", "hebrew"]);
|
|
sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]);
|
|
sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]);
|
|
sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]);
|
|
sanitize_allowed_string(state, "activeSettingsTab", &[
|
|
"downloads",
|
|
"lookandfeel",
|
|
"network",
|
|
"locations",
|
|
"sitelogins",
|
|
"power",
|
|
"engine",
|
|
"integrations",
|
|
"about",
|
|
]);
|
|
sanitize_allowed_string(state, "proxyMode", &["none", "system", "custom"]);
|
|
sanitize_allowed_string(
|
|
state,
|
|
"mediaCookieSource",
|
|
&[
|
|
"none", "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera",
|
|
"vivaldi", "whale",
|
|
],
|
|
);
|
|
|
|
if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) {
|
|
sanitize_allowed_string(
|
|
scheduler,
|
|
"postQueueAction",
|
|
&["none", "sleep", "restart", "shutdown"],
|
|
);
|
|
for key in ["enabled", "stopTimeEnabled", "everyday"] {
|
|
sanitize_boolean_setting(scheduler, key);
|
|
}
|
|
for key in ["startTime", "stopTime"] {
|
|
sanitize_string_setting(scheduler, key);
|
|
}
|
|
if let Some(days) = scheduler.get("selectedDays") {
|
|
if !days.is_array() {
|
|
scheduler.remove("selectedDays");
|
|
} else if let Some(days_arr) = scheduler.get_mut("selectedDays").and_then(Value::as_array_mut) {
|
|
days_arr.retain(|v| v.as_u64().is_some_and(|n| n <= 6));
|
|
if days_arr.is_empty() {
|
|
scheduler.remove("selectedDays");
|
|
}
|
|
}
|
|
}
|
|
if let Some(queue_ids) = scheduler.get("selectedQueueIds") {
|
|
if !queue_ids.is_array() {
|
|
scheduler.remove("selectedQueueIds");
|
|
} else if let Some(queue_ids_arr) = scheduler.get_mut("selectedQueueIds").and_then(Value::as_array_mut) {
|
|
queue_ids_arr.retain(|v| v.as_str().is_some_and(|s| !s.trim().is_empty()));
|
|
if queue_ids_arr.is_empty() {
|
|
scheduler.remove("selectedQueueIds");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
|
logins.retain(|login| {
|
|
let Some(login) = login.as_object() else {
|
|
return false;
|
|
};
|
|
["id", "urlPattern", "username"]
|
|
.into_iter()
|
|
.all(|key| login.get(key).and_then(Value::as_str).is_some())
|
|
});
|
|
}
|
|
}
|
|
|
|
fn sanitize_integer_setting(
|
|
state: &mut serde_json::Map<String, Value>,
|
|
key: &str,
|
|
is_valid: impl Fn(&Value) -> bool,
|
|
) {
|
|
if state.get(key).is_some_and(|value| !is_valid(value)) {
|
|
state.remove(key);
|
|
}
|
|
}
|
|
|
|
fn sanitize_boolean_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
|
if state.get(key).is_some_and(|value| !value.is_boolean()) {
|
|
state.remove(key);
|
|
}
|
|
}
|
|
|
|
fn sanitize_string_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
|
if state.get(key).is_some_and(|value| !value.is_string()) {
|
|
state.remove(key);
|
|
}
|
|
}
|
|
|
|
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,
|
|
allowed: &[&str],
|
|
) {
|
|
let valid = state
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| allowed.contains(&value));
|
|
if state.contains_key(key) && !valid {
|
|
state.remove(key);
|
|
}
|
|
}
|
|
|
|
fn validate_settings(settings: &mut PersistedSettings) {
|
|
settings.main_window_size = crate::window_geometry::normalize_main_window_size(
|
|
settings.main_window_size.as_ref(),
|
|
);
|
|
if settings.max_concurrent_downloads == 0 {
|
|
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
|
}
|
|
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
|
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
|
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
|
settings.minimum_normal_download_speed_ki_b =
|
|
crate::queue::normalize_minimum_normal_download_speed_kib(
|
|
settings.minimum_normal_download_speed_ki_b,
|
|
)
|
|
.unwrap_or_default();
|
|
settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit)
|
|
.unwrap_or_default();
|
|
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
|
|
&settings.torrent_overall_upload_limit,
|
|
)
|
|
.unwrap_or_default();
|
|
settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files(
|
|
settings.torrent_max_open_files,
|
|
)
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
|
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
|
|
settings.torrent_dht_message_timeout,
|
|
)
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
|
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
|
|
settings.torrent_max_concurrent_seeds,
|
|
)
|
|
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
|
settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address(
|
|
Some(&settings.torrent_bind_address),
|
|
)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
if !settings.torrent_ipv6_enabled
|
|
&& settings
|
|
.torrent_bind_address
|
|
.parse::<std::net::IpAddr>()
|
|
.is_ok_and(|address| address.is_ipv6())
|
|
{
|
|
log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled");
|
|
settings.torrent_bind_address.clear();
|
|
}
|
|
settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
|
.unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
|
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
|
|
Some(&settings.torrent_listen_port),
|
|
"TCP listen ports",
|
|
)
|
|
.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();
|
|
settings.last_custom_speed_limit_ki_b = settings.last_custom_speed_limit_ki_b.clamp(1, 10_485_760);
|
|
settings.speed_limit_preset_values.retain(|v| v.is_finite() && *v > 0.0);
|
|
if settings.speed_limit_preset_values.is_empty() {
|
|
settings.speed_limit_preset_values = default_settings().speed_limit_preset_values;
|
|
}
|
|
settings.scheduler.selected_days.retain(|d| (0..=6).contains(d));
|
|
if settings.scheduler.selected_days.is_empty() {
|
|
settings.scheduler.selected_days = default_settings().scheduler.selected_days;
|
|
}
|
|
settings.scheduler.selected_queue_ids.retain(|q| !q.trim().is_empty());
|
|
if settings.scheduler.selected_queue_ids.is_empty() {
|
|
settings.scheduler.selected_queue_ids = default_settings().scheduler.selected_queue_ids;
|
|
}
|
|
if !matches!(
|
|
settings.last_custom_speed_limit_unit.as_str(),
|
|
"KB/s" | "MB/s"
|
|
) {
|
|
settings.last_custom_speed_limit_unit = default_settings().last_custom_speed_limit_unit;
|
|
}
|
|
}
|
|
|
|
fn default_category_subfolders() -> HashMap<String, String> {
|
|
[
|
|
("Musics", "Musics"),
|
|
("Movies", "Movies"),
|
|
("Compressed", "Compressed"),
|
|
("Documents", "Documents"),
|
|
("Pictures", "Pictures"),
|
|
("Applications", "Applications"),
|
|
("Torrents", "Torrents"),
|
|
("Other", "Other"),
|
|
]
|
|
.into_iter()
|
|
.map(|(category, folder)| (category.to_string(), folder.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
|
|
if value.trim().is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let parts = value
|
|
.split(['/', '\\'])
|
|
.filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':'))
|
|
.collect::<Vec<_>>();
|
|
if parts.is_empty() {
|
|
fallback.to_string()
|
|
} else {
|
|
parts.join("/")
|
|
}
|
|
}
|
|
|
|
fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
|
|
let state = state
|
|
.as_object_mut()
|
|
.ok_or_else(|| "persisted settings state must be an object".to_string())?;
|
|
let base = state
|
|
.get("baseDownloadFolder")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.or_else(|| {
|
|
state
|
|
.get("defaultDownloadPath")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
})
|
|
.unwrap_or("~/Downloads")
|
|
.to_string();
|
|
|
|
let mut subfolders = default_category_subfolders();
|
|
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
|
|
for (category, value) in persisted {
|
|
if let Some(folder) = value.as_str() {
|
|
let fallback = subfolders
|
|
.get(category)
|
|
.cloned()
|
|
.unwrap_or_else(|| category.clone());
|
|
subfolders.insert(
|
|
category.clone(),
|
|
normalize_category_subfolder(folder, &fallback),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut overrides = state
|
|
.get("categoryDirectoryOverrides")
|
|
.and_then(Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
if let Some(legacy) = state.get("downloadDirectories").and_then(Value::as_object) {
|
|
let aliases = [
|
|
("Musics", "Audio"),
|
|
("Movies", "Video"),
|
|
("Compressed", "Archives"),
|
|
("Documents", "Documents"),
|
|
("Pictures", "Images"),
|
|
("Applications", "Apps"),
|
|
("Torrents", "Torrents"),
|
|
("Other", "Other"),
|
|
];
|
|
for (category, alias) in aliases {
|
|
if overrides.contains_key(category) {
|
|
continue;
|
|
}
|
|
let Some(path) = legacy
|
|
.get(category)
|
|
.or_else(|| legacy.get(alias))
|
|
.and_then(Value::as_str)
|
|
.filter(|path| !path.trim().is_empty())
|
|
else {
|
|
continue;
|
|
};
|
|
let subfolder = subfolders
|
|
.get(category)
|
|
.map(String::as_str)
|
|
.unwrap_or(category);
|
|
if normalize_location_path(path) != derived_location_path(&base, subfolder) {
|
|
overrides.insert(category.to_string(), Value::String(path.to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
state.insert("baseDownloadFolder".to_string(), Value::String(base));
|
|
state
|
|
.entry("categorySubfoldersEnabled".to_string())
|
|
.or_insert(Value::Bool(true));
|
|
state.insert(
|
|
"categorySubfolders".to_string(),
|
|
serde_json::to_value(subfolders)
|
|
.map_err(|error| format!("failed to migrate category subfolders: {error}"))?,
|
|
);
|
|
state.insert(
|
|
"categoryDirectoryOverrides".to_string(),
|
|
Value::Object(overrides),
|
|
);
|
|
state.remove("defaultDownloadPath");
|
|
state.remove("downloadDirectories");
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_location_path(path: &str) -> String {
|
|
path.replace('\\', "/").trim_end_matches('/').to_string()
|
|
}
|
|
|
|
fn derived_location_path(base: &str, subfolder: &str) -> String {
|
|
let base = normalize_location_path(base);
|
|
let subfolder = subfolder.trim_matches(|character| character == '/' || character == '\\');
|
|
if subfolder.is_empty() {
|
|
base
|
|
} else {
|
|
format!("{base}/{subfolder}")
|
|
}
|
|
}
|
|
|
|
fn default_settings() -> PersistedSettings {
|
|
PersistedSettings {
|
|
theme: Theme::System,
|
|
font_family: FontFamily::System,
|
|
window_control_style: WindowControlStyle::Auto,
|
|
calendar_preference: CalendarPreference::Gregorian,
|
|
language: "system".to_string(),
|
|
base_download_folder: "~/Downloads".to_string(),
|
|
category_subfolders_enabled: true,
|
|
category_subfolders: default_category_subfolders(),
|
|
category_directory_overrides: HashMap::new(),
|
|
approved_download_roots: Vec::new(),
|
|
max_concurrent_downloads: 3,
|
|
global_speed_limit: String::new(),
|
|
torrent_overall_upload_limit: String::new(),
|
|
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
|
logs_enabled: false,
|
|
is_sidebar_visible: true,
|
|
is_folders_collapsed: false,
|
|
main_window_size: None,
|
|
sidebar_position: "auto".to_string(),
|
|
active_settings_tab: SettingsTab::Downloads,
|
|
scheduler: SchedulerSettings {
|
|
enabled: false,
|
|
start_time: "00:00".to_string(),
|
|
stop_time_enabled: false,
|
|
stop_time: "08:00".to_string(),
|
|
everyday: true,
|
|
selected_days: vec![0, 1, 2, 3, 4, 5, 6],
|
|
selected_queue_ids: vec!["00000000-0000-0000-0000-000000000001".to_string()],
|
|
post_queue_action: PostQueueAction::None,
|
|
},
|
|
scheduler_running: false,
|
|
scheduler_active_download_ids: Vec::new(),
|
|
scheduler_last_start_key: String::new(),
|
|
scheduler_triggered_start_key: None,
|
|
scheduler_last_stop_key: String::new(),
|
|
last_custom_speed_limit_ki_b: 1024,
|
|
last_custom_speed_limit_unit: "MB/s".to_string(),
|
|
per_server_connections: 16,
|
|
max_automatic_retries: 3,
|
|
minimum_normal_download_speed_ki_b: 0,
|
|
retry_not_found_errors: false,
|
|
adaptive_mirror_selection: true,
|
|
show_notifications: true,
|
|
play_completion_sound: false,
|
|
auto_add_clipboard_links: false,
|
|
app_font_size: AppFontSize::Standard,
|
|
list_row_density: ListRowDensity::Standard,
|
|
show_dock_badge: true,
|
|
show_menu_bar_icon: true,
|
|
proxy_mode: ProxyMode::None,
|
|
proxy_host: String::new(),
|
|
proxy_port: 8080,
|
|
torrent_enable_dht: true,
|
|
torrent_enable_dht6: false,
|
|
torrent_enable_pex: true,
|
|
torrent_enable_lpd: false,
|
|
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
|
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
|
torrent_separate_seed_slots: false,
|
|
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
|
torrent_ipv6_enabled: true,
|
|
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(),
|
|
torrent_bind_address: String::new(),
|
|
aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(),
|
|
custom_user_agent: String::new(),
|
|
ask_where_to_save_each_file: false,
|
|
remember_last_used_download_directory: false,
|
|
prevents_sleep_while_downloading: true,
|
|
prevents_display_sleep_while_downloading: false,
|
|
media_cookie_source: MediaCookieSource::default(),
|
|
site_logins: Vec::new(),
|
|
auto_check_updates: true,
|
|
keychain_access_granted: false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::ipc::{FontFamily, WindowControlStyle};
|
|
use super::{
|
|
canonicalize_torrent_network_setting, 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};
|
|
|
|
#[test]
|
|
fn frontend_settings_save_preserves_backend_scheduler_keys() {
|
|
let existing = json!({
|
|
"state": {
|
|
"schedulerLastStartKey": "2026-06-22-start",
|
|
"schedulerTriggeredStartKey": "2026-06-22-start",
|
|
"schedulerLastStopKey": "2026-06-22-stop"
|
|
},
|
|
"version": 3
|
|
})
|
|
.to_string();
|
|
let incoming = json!({
|
|
"state": {
|
|
"schedulerLastStartKey": "",
|
|
"schedulerTriggeredStartKey": "",
|
|
"schedulerLastStopKey": "",
|
|
"theme": "system"
|
|
},
|
|
"version": 3
|
|
})
|
|
.to_string();
|
|
|
|
let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap();
|
|
let merged: Value = serde_json::from_str(&merged).unwrap();
|
|
assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start");
|
|
assert_eq!(
|
|
merged["state"]["schedulerTriggeredStartKey"],
|
|
"2026-06-22-start"
|
|
);
|
|
assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop");
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_zustand_envelope_and_preserves_non_default_startup_settings() {
|
|
let stored = json!({
|
|
"state": {
|
|
"maxConcurrentDownloads": 7,
|
|
"globalSpeedLimit": "2M",
|
|
"torrentOverallUploadLimit": "1.5M",
|
|
"sidebarPosition": "right",
|
|
"scheduler": {
|
|
"enabled": true,
|
|
"startTime": "06:30",
|
|
"stopTimeEnabled": true,
|
|
"stopTime": "23:15",
|
|
"everyday": false,
|
|
"selectedDays": [1, 3, 5],
|
|
"postQueueAction": "sleep"
|
|
}
|
|
},
|
|
"version": 0
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.max_concurrent_downloads, 7);
|
|
assert_eq!(settings.global_speed_limit, "2M");
|
|
assert_eq!(settings.torrent_overall_upload_limit, "1.5M");
|
|
assert_eq!(settings.sidebar_position, "right");
|
|
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
|
assert!(!settings.logs_enabled);
|
|
assert!(settings.scheduler.enabled);
|
|
assert_eq!(settings.scheduler.start_time, "06:30");
|
|
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
|
|
assert_eq!(
|
|
settings.scheduler.selected_queue_ids,
|
|
vec!["00000000-0000-0000-0000-000000000001"]
|
|
);
|
|
assert_eq!(settings.base_download_folder, "~/Downloads");
|
|
assert!(settings.category_subfolders_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_legacy_top_level_settings() {
|
|
let stored = json!({
|
|
"maxConcurrentDownloads": 5,
|
|
"globalSpeedLimit": "512K"
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.max_concurrent_downloads, 5);
|
|
assert_eq!(settings.global_speed_limit, "512K");
|
|
assert!(settings.torrent_overall_upload_limit.is_empty());
|
|
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
|
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
|
assert!(!settings.logs_enabled);
|
|
assert!(!settings.scheduler.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_persisted_log_opt_in() {
|
|
let stored = json!({
|
|
"state": {
|
|
"speedLimitPresetValues": [1, 2.5, 8],
|
|
"logsEnabled": true
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 2.5, 8.0]);
|
|
assert!(settings.logs_enabled);
|
|
assert!(!settings.scheduler.enabled);
|
|
assert!(settings.global_speed_limit.is_empty());
|
|
assert!(settings.torrent_overall_upload_limit.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_invalid_torrent_overall_upload_limit_to_unlimited() {
|
|
let stored = json!({
|
|
"state": {
|
|
"torrentOverallUploadLimit": "not-a-rate"
|
|
}
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(settings.torrent_overall_upload_limit.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_invalid_global_speed_limit_to_unlimited() {
|
|
let stored = json!({
|
|
"state": {
|
|
"globalSpeedLimit": "not-a-rate"
|
|
}
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(settings.global_speed_limit.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn migrates_legacy_location_settings_and_preserves_custom_overrides() {
|
|
let stored = json!({
|
|
"state": {
|
|
"defaultDownloadPath": "/Users/test/Downloads",
|
|
"downloadDirectories": {
|
|
"Movies": "/Users/test/Downloads/Movies",
|
|
"Documents": "/Volumes/Archive/Documents"
|
|
}
|
|
},
|
|
"version": 1
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
|
assert_eq!(settings.category_subfolders["Movies"], "Movies");
|
|
assert_eq!(settings.category_subfolders["Torrents"], "Torrents");
|
|
assert!(!settings.category_directory_overrides.contains_key("Movies"));
|
|
assert_eq!(
|
|
settings.category_directory_overrides["Documents"],
|
|
"/Volumes/Archive/Documents"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalizes_category_subfolders_as_relative_paths() {
|
|
let stored = json!({
|
|
"state": {
|
|
"baseDownloadFolder": "/Users/test/Downloads",
|
|
"categorySubfolders": {
|
|
"Movies": "../Media/./Movies",
|
|
"Documents": "../../"
|
|
}
|
|
},
|
|
"version": 2
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.category_subfolders["Movies"], "Media/Movies");
|
|
assert_eq!(settings.category_subfolders["Documents"], "Documents");
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_empty_category_subfolder_as_base_folder() {
|
|
let stored = json!({
|
|
"state": {
|
|
"baseDownloadFolder": "/Users/test/Downloads",
|
|
"categorySubfolders": {
|
|
"Movies": ""
|
|
}
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.category_subfolders["Movies"], "");
|
|
assert_eq!(settings.category_subfolders["Documents"], "Documents");
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_disabled_category_subfolders() {
|
|
let stored = json!({
|
|
"state": {
|
|
"baseDownloadFolder": "/Users/test/Downloads",
|
|
"categorySubfoldersEnabled": false,
|
|
"categorySubfolders": {
|
|
"Movies": "Movies"
|
|
}
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(!settings.category_subfolders_enabled);
|
|
assert_eq!(settings.category_subfolders["Movies"], "Movies");
|
|
}
|
|
|
|
#[test]
|
|
fn replaces_zero_concurrency_with_the_safe_default() {
|
|
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.max_concurrent_downloads, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_sidebar_position_uses_automatic_layout() {
|
|
let stored = json!({
|
|
"state": {"sidebarPosition": "diagonal"},
|
|
"version": 5
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.sidebar_position, "auto");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_font_family_uses_system_font() {
|
|
let stored = json!({
|
|
"state": {"fontFamily": "comic-sans"},
|
|
"version": 5
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(matches!(settings.font_family, FontFamily::System));
|
|
}
|
|
|
|
#[test]
|
|
fn curated_font_families_are_preserved() {
|
|
for value in ["vazirmatn", "noto-sans-hebrew", "noto-sans-sc", "roboto"] {
|
|
let stored = json!({
|
|
"state": {"fontFamily": value},
|
|
"version": 5
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(serde_json::to_value(settings.font_family).unwrap(), json!(value));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_window_control_style_uses_automatic_layout() {
|
|
let stored = json!({
|
|
"state": {"windowControlStyle": "neon"},
|
|
"version": 5
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(matches!(
|
|
settings.window_control_style,
|
|
WindowControlStyle::Auto
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn clamps_out_of_range_download_settings() {
|
|
let stored = json!({
|
|
"state": {
|
|
"maxConcurrentDownloads": 99,
|
|
"perServerConnections": -4,
|
|
"maxAutomaticRetries": 99,
|
|
"minimumNormalDownloadSpeedKiB": 2000000
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.max_concurrent_downloads, 12);
|
|
assert_eq!(settings.per_server_connections, 1);
|
|
assert_eq!(settings.max_automatic_retries, 10);
|
|
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn normal_reliability_defaults_are_migration_safe() {
|
|
let stored = json!({ "state": {}, "version": 5 });
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
|
assert!(!settings.retry_not_found_errors);
|
|
assert!(settings.adaptive_mirror_selection);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_malformed_setting_types_without_dropping_valid_values() {
|
|
let stored = json!({
|
|
"state": {
|
|
"baseDownloadFolder": "/Users/test/Downloads",
|
|
"maxConcurrentDownloads": "not-a-number",
|
|
"perServerConnections": 5,
|
|
"showNotifications": "yes",
|
|
"torrentEnableDht": "yes",
|
|
"torrentEnableDht6": 1,
|
|
"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}]
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
|
assert_eq!(settings.max_concurrent_downloads, 3);
|
|
assert_eq!(settings.per_server_connections, 5);
|
|
assert!(settings.show_notifications);
|
|
assert!(settings.torrent_enable_dht);
|
|
assert!(!settings.torrent_enable_dht6);
|
|
assert!(settings.torrent_enable_pex);
|
|
assert!(!settings.torrent_enable_lpd);
|
|
assert_eq!(
|
|
settings.torrent_max_open_files,
|
|
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
|
);
|
|
assert_eq!(
|
|
settings.torrent_dht_message_timeout,
|
|
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
|
);
|
|
assert!(settings.torrent_listen_port.is_empty());
|
|
assert!(settings.torrent_dht_listen_port.is_empty());
|
|
assert!(settings.torrent_external_ip.is_empty());
|
|
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,
|
|
crate::ipc::CalendarPreference::Gregorian
|
|
));
|
|
assert_eq!(settings.site_logins.len(), 1);
|
|
assert_eq!(settings.site_logins[0].id, "valid");
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_proxy_and_user_agent_values_fall_back_to_safe_defaults() {
|
|
let stored = json!({
|
|
"state": {
|
|
"proxyMode": "custom",
|
|
"proxyHost": 123,
|
|
"proxyPort": 70000,
|
|
"customUserAgent": ["not-a-string"],
|
|
"isSidebarVisible": "yes"
|
|
}
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(matches!(settings.proxy_mode, crate::ipc::ProxyMode::Custom));
|
|
assert!(settings.proxy_host.is_empty());
|
|
assert_eq!(settings.proxy_port, 8080);
|
|
assert!(settings.custom_user_agent.is_empty());
|
|
assert!(settings.is_sidebar_visible);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_malformed_speed_and_scheduler_settings_without_error() {
|
|
let stored = json!({
|
|
"state": {
|
|
"minimumNormalDownloadSpeedKiB": "very-fast",
|
|
"lastCustomSpeedLimitKiB": -50,
|
|
"lastCustomSpeedLimitUnit": "TB/s",
|
|
"speedLimitPresetValues": ["not-a-number", -1.0, 0.0],
|
|
"approvedDownloadRoots": 12345,
|
|
"scheduler": {
|
|
"enabled": "yes",
|
|
"postQueueAction": "explode",
|
|
"selectedDays": [99, "monday"],
|
|
"selectedQueueIds": ["", " "]
|
|
},
|
|
"schedulerRunning": "active",
|
|
"schedulerActiveDownloadIds": "none"
|
|
},
|
|
"version": 6
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
|
assert_eq!(settings.last_custom_speed_limit_ki_b, 1024);
|
|
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
|
assert_eq!(settings.speed_limit_preset_values, default_settings().speed_limit_preset_values);
|
|
assert_eq!(settings.approved_download_roots, default_settings().approved_download_roots);
|
|
assert!(!settings.scheduler.enabled);
|
|
assert_eq!(settings.scheduler.post_queue_action, crate::ipc::PostQueueAction::None);
|
|
assert_eq!(settings.scheduler.selected_days, default_settings().scheduler.selected_days);
|
|
assert_eq!(settings.scheduler.selected_queue_ids, default_settings().scheduler.selected_queue_ids);
|
|
assert!(!settings.scheduler_running);
|
|
assert!(settings.scheduler_active_download_ids.is_empty());
|
|
}
|
|
|
|
#[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 ",
|
|
"torrentDhtMessageTimeout": 601,
|
|
"torrentMaxConcurrentSeeds": 65,
|
|
"torrentSeparateSeedSlots": "yes",
|
|
"proxyPort": 70000,
|
|
"proxyHost": 123,
|
|
"customUserAgent": ["not-a-string"]
|
|
},
|
|
"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");
|
|
assert_eq!(
|
|
canonical["state"]["torrentDhtMessageTimeout"],
|
|
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
|
);
|
|
assert_eq!(
|
|
canonical["state"]["torrentMaxConcurrentSeeds"],
|
|
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
|
);
|
|
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
|
|
assert!(canonical["state"].get("proxyPort").is_none());
|
|
assert!(canonical["state"].get("proxyHost").is_none());
|
|
assert!(canonical["state"].get("customUserAgent").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalizes_individual_torrent_network_inputs_with_shared_rules() {
|
|
assert_eq!(
|
|
canonicalize_torrent_network_setting("torrentListenPort", " 6881-6999 ").unwrap(),
|
|
"6881-6999"
|
|
);
|
|
assert_eq!(
|
|
canonicalize_torrent_network_setting("torrentDhtEntryPoint6", "[2001:db8::1]:6881")
|
|
.unwrap(),
|
|
"[2001:db8::1]:6881"
|
|
);
|
|
assert_eq!(
|
|
canonicalize_torrent_network_setting("aria2DiskCache", " 256m ").unwrap(),
|
|
"256M"
|
|
);
|
|
assert_eq!(
|
|
canonicalize_torrent_network_setting("torrentBindAddress", " ").unwrap(),
|
|
""
|
|
);
|
|
assert!(canonicalize_torrent_network_setting("torrentListenPort", "61").is_err());
|
|
assert!(canonicalize_torrent_network_setting("unknown", "value").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_ipv6_bind_address_when_transport_is_disabled() {
|
|
let stored = json!({
|
|
"state": {
|
|
"torrentIpv6Enabled": false,
|
|
"torrentBindAddress": "2001:db8::10"
|
|
}
|
|
});
|
|
|
|
let error = canonicalize_torrent_network_settings(&stored.to_string())
|
|
.expect_err("IPv6 bind must not be accepted with IPv6 transport disabled");
|
|
assert!(error.contains("IPv6 Torrent bind address"));
|
|
}
|
|
|
|
#[test]
|
|
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
|
|
let stored = json!({
|
|
"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");
|
|
assert_eq!(
|
|
startup.dht_message_timeout,
|
|
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opt_in_defaults_match_the_frontend_defaults() {
|
|
assert!(!default_settings().play_completion_sound);
|
|
assert!(!default_settings().auto_add_clipboard_links);
|
|
assert!(!default_settings().torrent_separate_seed_slots);
|
|
assert_eq!(
|
|
default_settings().torrent_max_concurrent_seeds,
|
|
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_remember_last_used_download_directory_by_default() {
|
|
assert!(!default_settings().remember_last_used_download_directory);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_settings_without_geometry_use_no_persisted_size() {
|
|
let settings = decode_stored_settings(&Value::String(
|
|
json!({ "state": { "theme": "system" }, "version": 0 }).to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
assert!(settings.main_window_size.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_main_window_geometry_round_trips() {
|
|
let settings = decode_stored_settings(&Value::String(
|
|
json!({
|
|
"state": { "mainWindowSize": { "width": 1440, "height": 900 } },
|
|
"version": 6
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
settings
|
|
.main_window_size
|
|
.as_ref()
|
|
.map(|size| (size.width, size.height)),
|
|
Some((1440, 900))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_and_out_of_range_geometry_is_dropped() {
|
|
for geometry in [
|
|
json!({ "width": "1440", "height": 900 }),
|
|
json!({ "width": 959, "height": 900 }),
|
|
json!({ "width": 1440, "height": 16_385 }),
|
|
] {
|
|
let settings = decode_stored_settings(&Value::String(
|
|
json!({ "state": { "mainWindowSize": geometry }, "version": 6 }).to_string(),
|
|
))
|
|
.unwrap();
|
|
assert!(settings.main_window_size.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_disabled_last_used_download_directory_setting() {
|
|
let stored = json!({
|
|
"state": {
|
|
"rememberLastUsedDownloadDirectory": false
|
|
},
|
|
"version": 3
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert!(!settings.remember_last_used_download_directory);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_legacy_extension_pairing_token_field() {
|
|
// Older standard installs persisted `extensionPairingToken` as
|
|
// plaintext inside the settings document. It now lives in the OS
|
|
// keychain and is no longer part of PersistedSettings; portable mode
|
|
// deliberately keeps its token in the portable settings document.
|
|
// serde ignores the unknown field so existing installs decode without
|
|
// error; standard-mode migration drops the plaintext value.
|
|
let stored = json!({
|
|
"state": {
|
|
"extensionPairingToken": "plaintext-leaked-secret",
|
|
"maxConcurrentDownloads": 5
|
|
},
|
|
"version": 0
|
|
});
|
|
|
|
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
|
|
|
assert_eq!(settings.max_concurrent_downloads, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_portable_pairing_token_when_startup_save_races() {
|
|
let existing = json!({
|
|
"state": {
|
|
"extensionPairingToken": "portable-token",
|
|
"maxConcurrentDownloads": 3
|
|
},
|
|
"version": 3
|
|
})
|
|
.to_string();
|
|
let incoming = json!({
|
|
"state": {
|
|
"extensionPairingToken": "",
|
|
"maxConcurrentDownloads": 5
|
|
},
|
|
"version": 3
|
|
})
|
|
.to_string();
|
|
|
|
let merged = preserve_portable_pairing_token(Some(&existing), &incoming).unwrap();
|
|
let document: Value = serde_json::from_str(&merged).unwrap();
|
|
assert_eq!(
|
|
document["state"]["extensionPairingToken"],
|
|
"portable-token"
|
|
);
|
|
assert_eq!(document["state"]["maxConcurrentDownloads"], 5);
|
|
}
|
|
}
|