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}; pub fn load_settings( app_handle: &AppHandle, ) -> Result { let database = app_handle.state::(); 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 { 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) } pub fn update_settings_state( app_handle: &AppHandle, update: impl FnOnce(&mut Map), ) -> Result<(), String> { let database = app_handle.state::(); 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 { 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 { 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 { 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> { 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_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, key: &str, is_valid: impl Fn(&Value) -> bool, ) { if state.get(key).is_some_and(|value| !is_valid(value)) { state.remove(key); } } fn sanitize_allowed_string( state: &mut serde_json::Map, 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); 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 { [ ("Musics", "Musics"), ("Movies", "Movies"), ("Compressed", "Compressed"), ("Documents", "Documents"), ("Pictures", "Pictures"), ("Applications", "Applications"), ("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::>(); 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"), ("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(), 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, 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, 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::{ decode_stored_settings, default_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys, }; 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", "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.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_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()); } #[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!(!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 }, "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); } #[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", "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!(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 opt_in_defaults_match_the_frontend_defaults() { assert!(!default_settings().play_completion_sound); assert!(!default_settings().auto_add_clipboard_links); } #[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); } }