Files
Firelink/src-tauri/src/settings.rs
T
2026-08-10 14:20:50 +03:30

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);
}
}