mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
869 lines
30 KiB
Rust
869 lines
30 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};
|
|
|
|
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)
|
|
}
|
|
|
|
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_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_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);
|
|
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"),
|
|
("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"),
|
|
("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);
|
|
}
|
|
}
|