mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-18 07:13:13 +00:00
1444 lines
53 KiB
Rust
1444 lines
53 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(),
|
|
);
|
|
}
|
|
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>),
|
|
) -> 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", "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;
|
|
};
|
|
|
|
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, "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 [
|
|
"torrentEnableDht",
|
|
"torrentEnableDht6",
|
|
"torrentEnablePex",
|
|
"torrentEnableLpd",
|
|
"torrentSeparateSeedSlots",
|
|
"torrentIpv6Enabled",
|
|
] {
|
|
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_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"],
|
|
);
|
|
}
|
|
|
|
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_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) {
|
|
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.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();
|
|
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,
|
|
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_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_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",
|
|
"schedulerLastStopKey": "2026-06-22-stop"
|
|
},
|
|
"version": 3
|
|
})
|
|
.to_string();
|
|
let incoming = json!({
|
|
"state": {
|
|
"schedulerLastStartKey": "",
|
|
"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"]["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 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 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"
|
|
},
|
|
"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);
|
|
}
|
|
|
|
#[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 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);
|
|
}
|
|
}
|