use serde::{Deserialize, Serialize}; use std::fs::{self, create_dir_all}; use std::path::PathBuf; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TableSortingSettings { pub column: String, // Column to sort by: "name", "browser", "status" pub direction: String, // "asc" or "desc" } impl Default for TableSortingSettings { fn default() -> Self { Self { column: "name".to_string(), direction: "asc".to_string(), } } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AppSettings { #[serde(default)] pub set_as_default_browser: bool, #[serde(default = "default_theme")] pub theme: String, // "light", "dark", or "system" #[serde(default)] pub custom_theme: Option>, // CSS var name -> value (e.g., "--background": "#1a1b26") #[serde(default)] pub api_enabled: bool, #[serde(default = "default_api_port")] pub api_port: u16, #[serde(default)] pub api_token: Option, // Displayed token for user to copy #[serde(default)] pub sync_server_url: Option, // URL of the sync server #[serde(default)] pub first_launch_timestamp: Option, // Unix epoch seconds when app was first launched #[serde(default)] pub commercial_trial_acknowledged: bool, // Has user dismissed the trial expiration modal #[serde(default)] pub mcp_enabled: bool, // Enable MCP (Model Context Protocol) server #[serde(default)] pub mcp_port: Option, // Port for MCP server (default 51080) #[serde(default)] pub mcp_token: Option, // Displayed token for user to copy (not persisted, loaded from encrypted file) /// Let Donut cloud drive this installation's MCP tools over an outbound /// bridge, so an agent on the website can control this browser. /// /// Defaults to OFF and stays off until the user says otherwise. It opens a /// long-lived socket to Donut cloud and hands the far end the ability to /// launch and drive profiles, which is not something to switch on for /// somebody by default because their plan happens to include it. #[serde(default)] pub mcp_remote_enabled: bool, /// The durable `dmk_` credential agents present to the remote MCP endpoint. /// /// Plaintext, kept in an encrypted file with the same posture as /// `mcp_token`: loaded into the struct for a frontend settings read (the fx /// client cannot take the credential from its config file, so the page /// offers the export line), and stripped by `save_settings` so the settings /// JSON never carries it. Absent from the wire when there is none, so a /// settings file written by an older build stays byte-for-byte unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_remote_key: Option, /// The server-side id of `mcp_remote_key`, so a rotation can revoke exactly /// the key it replaces. Not a secret; lives in the settings JSON. #[serde(default)] pub mcp_remote_key_id: Option, #[serde(default)] pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default #[serde(default)] pub window_resize_warning_dismissed: bool, /// Stop blocking launches whose proxy exit disagrees with the fingerprint. /// Lives here rather than in localStorage because the Rust launch path is /// what enforces the block and cannot read the frontend's storage. #[serde(default)] pub fingerprint_gate_disabled: bool, /// Stop warning about VPN/proxy extensions found in a profile. #[serde(default)] pub vpn_extension_warning_disabled: bool, #[serde(default)] pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot) #[serde(default)] pub disable_auto_updates: bool, /// When true, the decrypted in-RAM copy of a password-protected profile is /// preserved between launches for faster subsequent startups. The on-disk /// copy is always re-encrypted regardless of this flag. #[serde(default)] pub keep_decrypted_profiles_in_ram: bool, /// How long a deleted profile stays in the trash before it is purged. /// Clamped to 1..=365 on save; the sweeper reads it through /// `profile::trash::configured_retention_days`. #[serde(default = "default_trash_retention_days")] pub trash_retention_days: u32, /// Feature tips. Whether one tip the user has not seen yet may open by /// itself shortly after launch. Off is the user's choice, made in the tips /// dialog. #[serde(default = "default_tips_auto_show")] pub tips_auto_show: bool, /// Ids of the tips that have been shown, in the automatic or the browse /// flow, so the automatic flow never repeats one. #[serde(default)] pub tips_seen: Vec, /// Unix seconds of the last tip that opened by itself. Paces the automatic /// flow to one tip a day at most. #[serde(default)] pub tips_last_auto_shown_at: Option, /// Cloud user ids that have had the paid-plan welcome. #[serde(default)] pub paid_welcome_seen_for: Vec, /// The plan status last observed per cloud user id, `"free"` or `"paid"`. /// A change from free to paid is what earns the paid-plan welcome. #[serde(default)] pub cloud_plan_memory: std::collections::HashMap, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct SyncSettings { pub sync_server_url: Option, pub sync_token: Option, // Only populated when reading, not stored in JSON } fn default_theme() -> String { "system".to_string() } fn default_api_port() -> u16 { 10108 } fn default_trash_retention_days() -> u32 { crate::profile::trash::DEFAULT_RETENTION_DAYS } fn default_tips_auto_show() -> bool { true } /// How long the automatic tip flow waits between two tips, so a busy day of /// restarts does not turn into a tip on every launch. pub const TIPS_AUTO_INTERVAL_SECS: u64 = 20 * 60 * 60; /// The plan status remembered per cloud user. const PLAN_STATUS_PAID: &str = "paid"; const PLAN_STATUS_FREE: &str = "free"; impl Default for AppSettings { fn default() -> Self { Self { set_as_default_browser: false, theme: "system".to_string(), custom_theme: None, api_enabled: false, api_port: 10108, api_token: None, sync_server_url: None, first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_enabled: false, mcp_port: None, mcp_token: None, mcp_remote_enabled: false, mcp_remote_key: None, mcp_remote_key_id: None, language: None, window_resize_warning_dismissed: false, fingerprint_gate_disabled: false, vpn_extension_warning_disabled: false, onboarding_completed: false, disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS, tips_auto_show: true, tips_seen: Vec::new(), tips_last_auto_shown_at: None, paid_welcome_seen_for: Vec::new(), cloud_plan_memory: std::collections::HashMap::new(), } } } /// The remote MCP credential as it is kept on this machine. #[derive(Debug, Clone)] pub struct StoredMcpRemoteKey { /// The plaintext `dmk_` key. pub key: String, /// The server-side id, when the store that wrote the key also recorded it. pub id: Option, } pub struct SettingsManager; /// Write `content` to `path` in one step: to a sibling first, then renamed /// into place. A reader that opens the file mid-write, and there are several /// at startup, sees the old settings or the new ones, never an empty file /// that parses as the defaults. fn write_whole(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> { let staging = path.with_extension("json.tmp"); fs::write(&staging, content)?; if let Err(e) = fs::rename(&staging, path) { let _ = fs::remove_file(&staging); return Err(e); } Ok(()) } impl SettingsManager { pub(crate) fn new() -> Self { Self } pub fn instance() -> &'static SettingsManager { &SETTINGS_MANAGER } pub fn get_settings_dir(&self) -> PathBuf { crate::app_dirs::settings_dir() } pub fn get_settings_file(&self) -> PathBuf { self.get_settings_dir().join("app_settings.json") } pub fn get_table_sorting_file(&self) -> PathBuf { self.get_settings_dir().join("table_sorting.json") } pub fn load_settings(&self) -> Result> { let settings_file = self.get_settings_file(); if !settings_file.exists() { // Return default settings if file doesn't exist return Ok(AppSettings::default()); } let content = fs::read_to_string(&settings_file)?; // Parse the settings file - serde will use default values for missing fields match serde_json::from_str::(&content) { Ok(settings) => Ok(settings), Err(e) => { log::warn!("Warning: Failed to parse settings file, using defaults: {e}"); Ok(AppSettings::default()) } } } pub fn save_settings(&self, settings: &AppSettings) -> Result<(), Box> { let settings_dir = self.get_settings_dir(); create_dir_all(&settings_dir)?; // The remote MCP credential works from anywhere on the internet and has // its own encrypted file; a struct loaded for the frontend carries it, so // it is dropped at the one place the JSON gets written rather than at // every caller that happens to hold such a struct. let mut on_disk = settings.clone(); on_disk.mcp_remote_key = None; let settings_file = self.get_settings_file(); let json = serde_json::to_string_pretty(&on_disk)?; write_whole(&settings_file, json.as_bytes())?; Ok(()) } pub fn load_table_sorting(&self) -> Result> { let sorting_file = self.get_table_sorting_file(); if !sorting_file.exists() { // Return default sorting if file doesn't exist return Ok(TableSortingSettings::default()); } let content = fs::read_to_string(sorting_file)?; let sorting: TableSortingSettings = serde_json::from_str(&content)?; Ok(sorting) } pub fn save_table_sorting( &self, sorting: &TableSortingSettings, ) -> Result<(), Box> { let settings_dir = self.get_settings_dir(); create_dir_all(&settings_dir)?; let sorting_file = self.get_table_sorting_file(); let json = serde_json::to_string_pretty(sorting)?; write_whole(&sorting_file, json.as_bytes())?; Ok(()) } /// Seal `secret` into `file`. /// /// One implementation for every secret this manager keeps on disk, in /// `crate::vault`: the API, MCP and sync tokens and the remote MCP /// credential share the layout, and only the five-byte header tells them /// apart. fn encrypt_to_file( file: &std::path::Path, header: &[u8; 5], secret: &str, ) -> Result<(), Box> { crate::vault::seal(file, &Self::magic(header), secret)?; Ok(()) } /// Read back a secret written by `encrypt_to_file`. /// /// A missing file, a foreign header or a layout this version does not know /// all read as "no secret" rather than an error, so a stale or damaged file /// never blocks the feature it belongs to; the caller simply mints again. fn decrypt_from_file( file: &std::path::Path, header: &[u8; 5], ) -> Result, Box> { Ok(crate::vault::open(file, &Self::magic(header))?) } /// The header plus the layout version every file of this manager carries. fn magic(header: &[u8; 5]) -> [u8; 6] { let mut magic = [0u8; 6]; magic[..5].copy_from_slice(header); magic[5] = 2; magic } fn remove_secret_file(file: &std::path::Path) -> Result<(), Box> { if file.exists() { std::fs::remove_file(file)?; } Ok(()) } /// A fresh 256-bit token, base64url so it is safe in a URL path. fn random_token() -> String { let token_bytes: [u8; 32] = { use rand::Rng; let mut rng = rand::rng(); let mut bytes = [0u8; 32]; rng.fill_bytes(&mut bytes); bytes }; use base64::{engine::general_purpose, Engine as _}; general_purpose::URL_SAFE_NO_PAD.encode(token_bytes) } fn api_token_file(&self) -> PathBuf { self.get_settings_dir().join("api_token.dat") } fn mcp_token_file(&self) -> PathBuf { self.get_settings_dir().join("mcp_token.dat") } fn sync_token_file(&self) -> PathBuf { self.get_settings_dir().join("sync_token.dat") } fn mcp_remote_key_file(&self) -> PathBuf { self.get_settings_dir().join("mcp_remote_key.dat") } pub async fn generate_api_token( &self, app_handle: &tauri::AppHandle, ) -> Result> { let token = Self::random_token(); self.store_api_token(app_handle, &token).await?; Ok(token) } pub async fn store_api_token( &self, _app_handle: &tauri::AppHandle, token: &str, ) -> Result<(), Box> { Self::encrypt_to_file(&self.api_token_file(), b"DBAPI", token) } pub async fn get_api_token( &self, _app_handle: &tauri::AppHandle, ) -> Result, Box> { Self::decrypt_from_file(&self.api_token_file(), b"DBAPI") } pub async fn remove_api_token( &self, _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { Self::remove_secret_file(&self.api_token_file()) } pub async fn generate_mcp_token( &self, app_handle: &tauri::AppHandle, ) -> Result> { let token = Self::random_token(); self.store_mcp_token(app_handle, &token).await?; Ok(token) } pub async fn store_mcp_token( &self, _app_handle: &tauri::AppHandle, token: &str, ) -> Result<(), Box> { Self::encrypt_to_file(&self.mcp_token_file(), b"DBMCP", token) } pub async fn get_mcp_token( &self, _app_handle: &tauri::AppHandle, ) -> Result, Box> { Self::decrypt_from_file(&self.mcp_token_file(), b"DBMCP") } pub async fn remove_mcp_token( &self, _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { Self::remove_secret_file(&self.mcp_token_file()) } pub async fn store_sync_token( &self, _app_handle: &tauri::AppHandle, token: &str, ) -> Result<(), Box> { Self::encrypt_to_file(&self.sync_token_file(), b"DBSYN", token) } pub async fn get_sync_token( &self, _app_handle: &tauri::AppHandle, ) -> Result, Box> { Self::decrypt_from_file(&self.sync_token_file(), b"DBSYN") } pub async fn remove_sync_token( &self, _app_handle: &tauri::AppHandle, ) -> Result<(), Box> { Self::remove_secret_file(&self.sync_token_file()) } /// Keep the remote MCP credential: the `dmk_` key in its own encrypted file /// and the server-side key id in the settings JSON, so a later rotation can /// name the key it is retiring. /// /// The plaintext is deliberately NOT part of the settings JSON: /// `save_settings` strips it, and `get_app_settings` is the one reader that /// loads it back for the frontend, the way the local display tokens are. pub fn store_mcp_remote_key( &self, key: &str, key_id: &str, ) -> Result<(), Box> { Self::encrypt_to_file(&self.mcp_remote_key_file(), b"DBMRK", key)?; let mut settings = self.load_settings()?; settings.mcp_remote_key_id = Some(key_id.to_string()); self.save_settings(&settings) } /// The stored remote MCP credential, if any: the plaintext key and the id /// the server knows it by. /// /// Read with the id from the JSON and the key from its file, so the two /// cannot disagree: a key file without an id (an interrupted store) still /// yields the key, and an id without a key file yields nothing at all. pub fn get_mcp_remote_key( &self, ) -> Result, Box> { let Some(key) = Self::decrypt_from_file(&self.mcp_remote_key_file(), b"DBMRK")? else { return Ok(None); }; let id = self.load_settings()?.mcp_remote_key_id; Ok(Some(StoredMcpRemoteKey { key, id })) } /// Drop the remote MCP credential from this machine. Does not revoke it: /// that is the caller's job, because only the caller knows whether it still /// has a session to revoke with. pub fn remove_mcp_remote_key(&self) -> Result<(), Box> { Self::remove_secret_file(&self.mcp_remote_key_file())?; let mut settings = self.load_settings()?; if settings.mcp_remote_key_id.take().is_some() { self.save_settings(&settings)?; } Ok(()) } pub fn get_sync_settings(&self) -> Result> { let settings = self.load_settings()?; Ok(SyncSettings { sync_server_url: settings.sync_server_url, sync_token: None, // Token needs to be loaded separately via async method }) } pub fn save_sync_server_url( &self, url: Option, ) -> Result<(), Box> { let mut settings = self.load_settings()?; settings.sync_server_url = url; self.save_settings(&settings) } } #[tauri::command] pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result { let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; // Always load tokens for display purposes if they exist settings.api_token = manager .get_api_token(&app_handle) .await .map_err(|e| format!("Failed to load API token: {e}"))?; settings.mcp_token = manager .get_mcp_token(&app_handle) .await .map_err(|e| format!("Failed to load MCP token: {e}"))?; // Same posture as the local tokens: shown so the fx export line can be // copied, never persisted (see `SettingsManager::save_settings`). settings.mcp_remote_key = manager .get_mcp_remote_key() .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .map(|stored| stored.key); Ok(settings) } #[tauri::command] pub async fn save_app_settings( app_handle: tauri::AppHandle, mut settings: AppSettings, ) -> Result { let manager = SettingsManager::instance(); // The remote MCP credential is minted by `rotate_mcp_remote_credential` and // by nothing else. A settings read hands the frontend the plaintext (for the // fx export line) and the frontend echoes the whole struct back, so the // field is simply not the frontend's to write: whatever arrived is dropped // here and the stored key is what the answer below carries. settings.mcp_remote_key = None; // Handle API token if settings.api_enabled { if let Some(ref token) = settings.api_token { manager .store_api_token(&app_handle, token) .await .map_err(|e| format!("Failed to store API token: {e}"))?; } else { // Check if a token already exists on disk before generating a new one let existing = manager.get_api_token(&app_handle).await.ok().flatten(); if let Some(t) = existing { settings.api_token = Some(t); } else { let token = manager .generate_api_token(&app_handle) .await .map_err(|e| format!("Failed to generate API token: {e}"))?; settings.api_token = Some(token); } } } if !settings.api_enabled { manager .remove_api_token(&app_handle) .await .map_err(|e| format!("Failed to remove API token: {e}"))?; settings.api_token = None; } // Handle MCP token if settings.mcp_enabled { if let Some(ref token) = settings.mcp_token { manager .store_mcp_token(&app_handle, token) .await .map_err(|e| format!("Failed to store MCP token: {e}"))?; } else { // Check if a token already exists on disk before generating a new one let existing = manager.get_mcp_token(&app_handle).await.ok().flatten(); if let Some(t) = existing { settings.mcp_token = Some(t); } else { let token = manager .generate_mcp_token(&app_handle) .await .map_err(|e| format!("Failed to generate MCP token: {e}"))?; settings.mcp_token = Some(token); // A running local server now answers on a URL the installed clients // do not know, so they are rewritten. With the server off there is no // URL to write yet; `McpServer::start` does this when it comes up. if crate::mcp_server::McpServer::instance() .get_port() .is_some() { let failed = crate::reinstall_mcp_agents(&app_handle, crate::mcp_integrations::McpEndpoint::Local) .await; if !failed.is_empty() { log::warn!( "[settings] Could not refresh the clients pointing at the local server: {}", failed.join(", ") ); } } } } } if !settings.mcp_enabled { manager .remove_mcp_token(&app_handle) .await .map_err(|e| format!("Failed to remove MCP token: {e}"))?; settings.mcp_token = None; } // Preserve the fields the frontend does not own. Read directly from the // file to avoid load_settings' save-on-load behavior. // // `mcp_remote_enabled` is flipped ONLY by `start_mcp_remote_bridge` and // `stop_mcp_remote_bridge`, which also start and stop the bridge task. A // settings save that carried the flag could switch the internet-facing // bridge on for the next launch without ever going through the sign-in and // terms gates those commands enforce, or switch it off on disk while the // task kept running. The key id is bookkeeping for the rotation path and is // never the frontend's to write. if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) { if let Ok(current) = serde_json::from_str::(&content) { settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; settings.mcp_remote_enabled = current.mcp_remote_enabled; settings.mcp_remote_key_id = current.mcp_remote_key_id; } } else { settings.mcp_remote_enabled = false; settings.mcp_remote_key_id = None; } settings.trash_retention_days = crate::profile::trash::clamp_retention_days(settings.trash_retention_days); let mut persist_settings = settings.clone(); persist_settings.api_token = None; persist_settings.mcp_token = None; log::info!( "[settings] Saving settings: theme={}, custom_theme_keys={}", persist_settings.theme, persist_settings .custom_theme .as_ref() .map(|t| t.len()) .unwrap_or(0) ); manager .save_settings(&persist_settings) .map_err(|e| format!("Failed to save settings: {e}"))?; // Answer with what a fresh read would show, the stored credential included, // so a page that keeps the answer as its settings does not lose the fx // export line on every save. settings.mcp_remote_key = manager .get_mcp_remote_key() .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .map(|stored| stored.key); Ok(settings) } /// Read the most recent N log files concatenated into a single string, /// suitable for paste-into-issue-tracker. Newest entries appear LAST so the /// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to /// keep clipboard payloads sane. #[tauri::command] pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result { let dir = crate::app_dirs::log_dir(&app_handle); if !dir.exists() { return Err("Log directory does not exist yet".to_string()); } let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir) .map_err(|e| format!("Failed to read log dir: {e}"))? .filter_map(|r| r.ok()) .filter_map(|e| { let p = e.path(); let m = e.metadata().ok()?.modified().ok()?; let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); if p.is_file() && (ext == "log" || ext == "txt") { Some((p, m)) } else { None } }) .collect(); entries.sort_by_key(|(_, m)| *m); const MAX_BYTES: usize = 5 * 1024 * 1024; let mut out = String::with_capacity(64 * 1024); for (path, _) in entries.iter().rev() { let header = format!( "===== {} =====\n", path .file_name() .and_then(|name| name.to_str()) .unwrap_or("log") ); if out.len() + header.len() >= MAX_BYTES { break; } out.push_str(&header); if let Ok(content) = std::fs::read_to_string(path) { let take = MAX_BYTES.saturating_sub(out.len()); if take == 0 { break; } if content.len() > take { // Tail truncation — keep the END of older files so newest data is preserved. out.push_str("[…truncated — older content elided…]\n"); out.push_str(&content[content.len() - take + 64..]); } else { out.push_str(&content); } if !out.ends_with('\n') { out.push('\n'); } } } // Reverse the per-file order so chronological newest is at the bottom. // (We pushed newest-first above to budget the tail; flip now.) let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect(); sections.reverse(); let final_out = sections .into_iter() .map(|s| format!("===== {s}")) .collect::(); Ok(crate::log_redaction::text(&final_out)) } /// Reveal the log directory in the OS file manager. #[tauri::command] pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> { let dir = crate::app_dirs::log_dir(&app_handle); if !dir.exists() { std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?; } let path = dir.to_string_lossy().to_string(); #[cfg(target_os = "macos")] { std::process::Command::new("open") .arg(&path) .spawn() .map_err(|e| format!("Failed to open log dir: {e}"))?; } #[cfg(target_os = "windows")] { std::process::Command::new("explorer") .arg(&path) .spawn() .map_err(|e| format!("Failed to open log dir: {e}"))?; } #[cfg(target_os = "linux")] { std::process::Command::new("xdg-open") .arg(&path) .spawn() .map_err(|e| format!("Failed to open log dir: {e}"))?; } Ok(()) } #[tauri::command] pub async fn get_table_sorting_settings() -> Result { let manager = SettingsManager::instance(); manager .load_table_sorting() .map_err(|e| format!("Failed to load table sorting settings: {e}")) } #[tauri::command] pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Result<(), String> { let manager = SettingsManager::instance(); manager .save_table_sorting(&sorting) .map_err(|e| format!("Failed to save table sorting settings: {e}")) } #[tauri::command] pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result { // Cloud auth takes priority over self-hosted settings if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { let sync_token = crate::cloud_auth::CLOUD_AUTH .get_or_refresh_sync_token() .await .map_err(|e| format!("Failed to get cloud sync token: {e}"))?; return Ok(SyncSettings { sync_server_url: Some(crate::cloud_auth::CLOUD_SYNC_URL.to_string()), sync_token, }); } // Fall back to self-hosted settings let manager = SettingsManager::instance(); let mut sync_settings = manager .get_sync_settings() .map_err(|e| format!("Failed to load sync settings: {e}"))?; sync_settings.sync_token = manager .get_sync_token(&app_handle) .await .map_err(|e| format!("Failed to load sync token: {e}"))?; Ok(sync_settings) } #[tauri::command] pub async fn save_sync_settings( app_handle: tauri::AppHandle, sync_server_url: Option, sync_token: Option, ) -> Result { // Cloud login and self-hosted sync share the same sync engine and a // profile can't be sync'd to two backends at once. Block any *write* // (non-null URL or token) while the user is signed into their cloud // account — the clearing path (both `None`) is always allowed so logged- // in users can wipe a stale self-hosted config that pre-dates their // sign-in. let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some(); if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string()); } let manager = SettingsManager::instance(); manager .save_sync_server_url(sync_server_url.clone()) .map_err(|e| format!("Failed to save sync server URL: {e}"))?; if let Some(ref token) = sync_token { manager .store_sync_token(&app_handle, token) .await .map_err(|e| format!("Failed to store sync token: {e}"))?; } else { manager .remove_sync_token(&app_handle) .await .map_err(|e| format!("Failed to remove sync token: {e}"))?; } Ok(SyncSettings { sync_server_url, sync_token, }) } #[tauri::command] pub async fn dismiss_window_resize_warning() -> Result<(), String> { let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; settings.window_resize_warning_dismissed = true; manager .save_settings(&settings) .map_err(|e| format!("Failed to save settings: {e}")) } #[tauri::command] pub async fn get_window_resize_warning_dismissed() -> Result { let manager = SettingsManager::instance(); let settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; Ok(settings.window_resize_warning_dismissed) } #[tauri::command] pub async fn get_onboarding_completed() -> Result { let manager = SettingsManager::instance(); let settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; Ok(settings.onboarding_completed) } #[tauri::command] pub async fn complete_onboarding() -> Result<(), String> { let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; settings.onboarding_completed = true; manager .save_settings(&settings) .map_err(|e| format!("Failed to save settings: {e}")) } /// What the tips dialog needs to decide what to open and what to skip. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TipsState { pub auto_show: bool, pub seen: Vec, pub last_auto_shown_at: Option, /// Whether the automatic flow may open a tip right now: it is switched on /// and the last automatic tip is old enough. pub auto_due: bool, } impl TipsState { fn of(settings: &AppSettings, now: u64) -> Self { Self { auto_show: settings.tips_auto_show, seen: settings.tips_seen.clone(), last_auto_shown_at: settings.tips_last_auto_shown_at, auto_due: settings.tips_auto_show && settings .tips_last_auto_shown_at .is_none_or(|last| now.saturating_sub(last) >= TIPS_AUTO_INTERVAL_SECS), } } } /// Serialises every read-modify-write of the tips fields. Two tips shown in /// quick succession are two concurrent commands, and without this the second /// load could precede the first save and drop it. static TIPS_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Remembers a tip as shown. `auto` marks it as the tip that opened by /// itself, which restarts the daily pacing. fn record_tip_seen(settings: &mut AppSettings, tip_id: &str, auto: bool, now: u64) { if !settings.tips_seen.iter().any(|id| id == tip_id) { settings.tips_seen.push(tip_id.to_string()); } if auto { settings.tips_last_auto_shown_at = Some(now); } } /// Records the plan status seen for a cloud account and answers whether the /// paid-plan welcome is due for it. /// /// The welcome is for an account that just became paid: one this desktop last /// saw as free, or one it sees for the first time right after the user signed /// in (they bought a plan on the website and came back). An account that was /// already paid the last time anybody looked, or that turns up paid in an old /// session after an app update, is not new to its plan and is recorded as /// greeted without a dialog. fn paid_welcome_due( settings: &mut AppSettings, user_id: &str, paid: bool, fresh_login: bool, ) -> bool { let status = if paid { PLAN_STATUS_PAID } else { PLAN_STATUS_FREE }; let previous = settings .cloud_plan_memory .insert(user_id.to_string(), status.to_string()); if !paid { return false; } if settings .paid_welcome_seen_for .iter() .any(|id| id == user_id) { return false; } let due = match previous.as_deref() { Some(PLAN_STATUS_FREE) => true, Some(_) => false, None => fresh_login, }; settings.paid_welcome_seen_for.push(user_id.to_string()); due } #[tauri::command] pub async fn get_tips_state() -> Result { let manager = SettingsManager::instance(); let settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; Ok(TipsState::of(&settings, unix_now())) } #[tauri::command] pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result { let _serial = TIPS_WRITE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; let now = unix_now(); record_tip_seen(&mut settings, &tip_id, auto, now); manager .save_settings(&settings) .map_err(|e| format!("Failed to save settings: {e}"))?; Ok(TipsState::of(&settings, now)) } #[tauri::command] pub async fn set_tips_auto_show(enabled: bool) -> Result { let _serial = TIPS_WRITE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; settings.tips_auto_show = enabled; manager .save_settings(&settings) .map_err(|e| format!("Failed to save settings: {e}"))?; Ok(TipsState::of(&settings, unix_now())) } #[tauri::command] pub async fn observe_cloud_plan( user_id: String, paid: bool, fresh_login: bool, ) -> Result { let _serial = TIPS_WRITE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let manager = SettingsManager::instance(); let mut settings = manager .load_settings() .map_err(|e| format!("Failed to load settings: {e}"))?; let due = paid_welcome_due(&mut settings, &user_id, paid, fresh_login); manager .save_settings(&settings) .map_err(|e| format!("Failed to save settings: {e}"))?; Ok(due) } #[tauri::command] pub fn get_system_language() -> String { sys_locale::get_locale() .map(|locale| { // Extract just the language code (e.g., "en" from "en-US") locale .split(['-', '_']) .next() .unwrap_or("en") .to_lowercase() }) .unwrap_or_else(|| "en".to_string()) } #[derive(Debug, Serialize, Clone)] pub struct SystemInfo { pub app_version: String, pub os: String, pub arch: String, pub portable: bool, } #[tauri::command] pub fn get_system_info() -> SystemInfo { let os = if cfg!(target_os = "macos") { "macOS" } else if cfg!(target_os = "windows") { "Windows" } else if cfg!(target_os = "linux") { "Linux" } else { "Unknown" }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { "unknown" }; SystemInfo { app_version: crate::app_auto_updater::AppAutoUpdater::get_current_version(), os: os.to_string(), arch: arch.to_string(), portable: crate::app_dirs::is_portable(), } } // Global singleton instance lazy_static::lazy_static! { static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new(); } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn create_test_settings_manager() -> (SettingsManager, TempDir, crate::app_dirs::TestDirGuard) { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let guard = crate::app_dirs::set_test_data_dir(temp_dir.path().to_path_buf()); let manager = SettingsManager::new(); (manager, temp_dir, guard) } #[test] fn test_settings_manager_creation() { let (_manager, _temp_dir, _guard) = create_test_settings_manager(); } #[test] fn tips_state_defaults_to_automatic_and_due() { let settings = AppSettings::default(); let state = TipsState::of(&settings, 1_000_000); assert!(state.auto_show); assert!(state.seen.is_empty()); assert_eq!(state.last_auto_shown_at, None); assert!(state.auto_due, "a fresh install owes its first tip"); } #[test] fn tips_seen_dedupes_and_paces_the_automatic_flow() { let mut settings = AppSettings::default(); record_tip_seen(&mut settings, "dns", false, 100); record_tip_seen(&mut settings, "dns", false, 200); assert_eq!(settings.tips_seen, vec!["dns".to_string()]); assert_eq!( settings.tips_last_auto_shown_at, None, "a browsed tip must not restart the daily pacing" ); record_tip_seen(&mut settings, "proxy", true, 1_000); assert_eq!(settings.tips_last_auto_shown_at, Some(1_000)); assert!( !TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS - 1).auto_due, "the next automatic tip waits a day" ); assert!(TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS).auto_due); settings.tips_auto_show = false; assert!( !TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS * 3).auto_due, "switched off means never due" ); } #[test] fn paid_welcome_is_due_once_when_an_account_turns_paid() { let mut settings = AppSettings::default(); assert!(!paid_welcome_due(&mut settings, "u1", false, true)); assert!( settings.paid_welcome_seen_for.is_empty(), "a free account is not greeted, so nothing is recorded" ); assert!( paid_welcome_due(&mut settings, "u1", true, false), "free to paid is the upgrade the welcome exists for" ); assert!(!paid_welcome_due(&mut settings, "u1", true, true), "once"); assert_eq!(settings.paid_welcome_seen_for, vec!["u1".to_string()]); } #[test] fn paid_welcome_greets_a_fresh_sign_in_but_not_an_old_paid_session() { let mut settings = AppSettings::default(); assert!( paid_welcome_due(&mut settings, "bought-on-web", true, true), "first sight right after signing in: they came back from checkout" ); assert!( !paid_welcome_due(&mut settings, "long-paid", true, false), "an app update on a machine that was already paid is not a new plan" ); assert!( !paid_welcome_due(&mut settings, "long-paid", true, true), "and it is recorded as greeted, so it never fires later" ); assert_eq!( settings .cloud_plan_memory .get("long-paid") .map(String::as_str), Some(PLAN_STATUS_PAID) ); } #[test] fn test_default_app_settings() { let default_settings = AppSettings::default(); assert!( !default_settings.set_as_default_browser, "Default should not set as default browser" ); assert_eq!( default_settings.theme, "system", "Default theme should be system" ); } #[test] fn test_default_table_sorting_settings() { let default_sorting = TableSortingSettings::default(); assert_eq!( default_sorting.column, "name", "Default sort column should be name" ); assert_eq!( default_sorting.direction, "asc", "Default sort direction should be asc" ); } #[test] fn test_load_settings_nonexistent_file() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let result = manager.load_settings(); assert!( result.is_ok(), "Should handle nonexistent settings file gracefully" ); let settings = result.unwrap(); assert!( !settings.set_as_default_browser, "Should return default settings" ); assert_eq!(settings.theme, "system", "Should return default theme"); } #[test] fn test_save_and_load_settings() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let test_settings = AppSettings { set_as_default_browser: true, theme: "dark".to_string(), custom_theme: None, api_enabled: false, api_port: 10108, api_token: None, sync_server_url: None, first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_enabled: false, mcp_port: None, mcp_token: None, mcp_remote_enabled: false, mcp_remote_key: None, mcp_remote_key_id: None, language: None, window_resize_warning_dismissed: false, fingerprint_gate_disabled: false, vpn_extension_warning_disabled: false, onboarding_completed: false, disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, trash_retention_days: 14, tips_auto_show: true, tips_seen: Vec::new(), tips_last_auto_shown_at: None, paid_welcome_seen_for: Vec::new(), cloud_plan_memory: std::collections::HashMap::new(), }; let save_result = manager.save_settings(&test_settings); assert!(save_result.is_ok(), "Should save settings successfully"); let load_result = manager.load_settings(); assert!(load_result.is_ok(), "Should load settings successfully"); let loaded_settings = load_result.unwrap(); assert!( loaded_settings.set_as_default_browser, "Loaded settings should match saved" ); assert_eq!( loaded_settings.theme, "dark", "Loaded theme should match saved" ); assert_eq!(loaded_settings.trash_retention_days, 14); } #[test] fn trash_retention_defaults_when_the_settings_file_predates_it() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let settings_dir = manager.get_settings_dir(); create_dir_all(&settings_dir).unwrap(); fs::write(manager.get_settings_file(), r#"{"theme":"light"}"#).unwrap(); let loaded = manager.load_settings().unwrap(); assert_eq!(loaded.theme, "light"); assert_eq!( loaded.trash_retention_days, crate::profile::trash::DEFAULT_RETENTION_DAYS ); } #[test] fn the_remote_key_round_trips_and_never_reaches_the_settings_json() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); assert!(manager.get_mcp_remote_key().unwrap().is_none()); manager .store_mcp_remote_key("dmk_abcdefghijklmnop", "key-1") .unwrap(); let stored = manager.get_mcp_remote_key().unwrap().expect("stored"); assert_eq!(stored.key, "dmk_abcdefghijklmnop"); assert_eq!(stored.id.as_deref(), Some("key-1")); // The id is bookkeeping and belongs in the JSON; the key is a credential // that works from anywhere on the internet and must not. let json = std::fs::read_to_string(manager.get_settings_file()).unwrap(); assert!(json.contains("\"mcp_remote_key_id\": \"key-1\""), "{json}"); assert!(!json.contains("dmk_abcdefghijklmnop"), "{json}"); assert!(!json.contains("\"mcp_remote_key\""), "{json}"); // A struct loaded for the frontend carries the plaintext (the fx export // line needs it), so the write path is what keeps it off the disk: saving // such a struct must not plant the key in the JSON. let mut settings = manager.load_settings().unwrap(); settings.mcp_remote_key = Some("dmk_abcdefghijklmnop".to_string()); manager.save_settings(&settings).unwrap(); let json = std::fs::read_to_string(manager.get_settings_file()).unwrap(); assert!(!json.contains("dmk_"), "{json}"); assert!(!json.contains("\"mcp_remote_key\""), "{json}"); assert_eq!( manager .load_settings() .unwrap() .mcp_remote_key_id .as_deref(), Some("key-1") ); manager.remove_mcp_remote_key().unwrap(); assert!(manager.get_mcp_remote_key().unwrap().is_none()); assert!(manager.load_settings().unwrap().mcp_remote_key_id.is_none()); } #[test] fn a_key_file_without_an_id_still_yields_the_key() { // An interrupted store, or a settings file rewritten by an older build // that did not know the field: the credential is still on disk and still // valid, so it must still be usable. Only the id is missing, and the // rotation path treats a missing id as "nothing to revoke". let (manager, _temp_dir, _guard) = create_test_settings_manager(); manager .store_mcp_remote_key("dmk_zzzzzzzzzzzz", "key-2") .unwrap(); let mut settings = manager.load_settings().unwrap(); settings.mcp_remote_key_id = None; manager.save_settings(&settings).unwrap(); let stored = manager.get_mcp_remote_key().unwrap().expect("stored"); assert_eq!(stored.key, "dmk_zzzzzzzzzzzz"); assert!(stored.id.is_none()); } #[test] fn test_load_table_sorting_nonexistent_file() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let result = manager.load_table_sorting(); assert!( result.is_ok(), "Should handle nonexistent sorting file gracefully" ); let sorting = result.unwrap(); assert_eq!(sorting.column, "name", "Should return default sorting"); assert_eq!(sorting.direction, "asc", "Should return default direction"); } #[test] fn test_save_and_load_table_sorting() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let test_sorting = TableSortingSettings { column: "browser".to_string(), direction: "desc".to_string(), }; let save_result = manager.save_table_sorting(&test_sorting); assert!(save_result.is_ok(), "Should save sorting successfully"); let load_result = manager.load_table_sorting(); assert!(load_result.is_ok(), "Should load sorting successfully"); let loaded_sorting = load_result.unwrap(); assert_eq!( loaded_sorting.column, "browser", "Loaded column should match saved" ); assert_eq!( loaded_sorting.direction, "desc", "Loaded direction should match saved" ); } #[test] fn test_load_corrupted_settings_file() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let settings_dir = manager.get_settings_dir(); fs::create_dir_all(&settings_dir).expect("Should create settings directory"); let settings_file = manager.get_settings_file(); fs::write(&settings_file, "{ invalid json }").expect("Should write corrupted file"); let result = manager.load_settings(); assert!( result.is_ok(), "Should handle corrupted settings file gracefully" ); let settings = result.unwrap(); assert!( !settings.set_as_default_browser, "Should return default settings for corrupted file" ); assert_eq!( settings.theme, "system", "Should return default theme for corrupted file" ); } #[test] fn test_settings_file_paths() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); let settings_dir = manager.get_settings_dir(); let settings_file = manager.get_settings_file(); let sorting_file = manager.get_table_sorting_file(); assert!( settings_dir.to_string_lossy().contains("settings"), "Settings dir should contain 'settings'" ); assert!( settings_file .to_string_lossy() .ends_with("app_settings.json"), "Settings file should end with app_settings.json" ); assert!( sorting_file .to_string_lossy() .ends_with("table_sorting.json"), "Sorting file should end with table_sorting.json" ); } }