diff --git a/crates/nyaterm-core/src/storage.rs b/crates/nyaterm-core/src/storage.rs index cfac10b10..e8bc58341 100644 --- a/crates/nyaterm-core/src/storage.rs +++ b/crates/nyaterm-core/src/storage.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -8,18 +7,18 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::{ - AiSettings, AliyunDriveSyncSettings, AppSettingsSummary, CloudSyncSettings, CloudSyncState, - CredentialCrypto, CredentialCryptoError, Group, KeywordHighlightConfig, - KeywordHighlightImportResult, KeywordHighlightRule, OAuthDriveSyncSettings, - PortableSnapshotError, ProxyConfig, ProxyGroup, ProxyGroupsConfig, QuickCommand, - QuickCommandCategory, QuickCommandsConfig, SavedConnection, SearchEngineConfig, SessionsConfig, - TranslationSettings, TunnelConfig, TunnelGroup, TunnelGroupsConfig, ai_settings_has_secret, - default_search_engines, merge_masked_ai_settings, merge_masked_cloud_sync_settings, + AiSettings, AliyunDriveSyncSettings, CloudSyncSettings, CloudSyncState, CredentialCrypto, + CredentialCryptoError, Group, KeywordHighlightConfig, KeywordHighlightImportResult, + KeywordHighlightRule, OAuthDriveSyncSettings, PortableSnapshotError, ProxyConfig, ProxyGroup, + ProxyGroupsConfig, QuickCommand, QuickCommandCategory, QuickCommandsConfig, SavedConnection, + SessionsConfig, TranslationSettings, TunnelConfig, TunnelGroup, TunnelGroupsConfig, + ai_settings_has_secret, merge_masked_ai_settings, merge_masked_cloud_sync_settings, merge_masked_translation_settings, normalize_ai_settings, translation_settings_has_secret, uuid, }; mod ai_history; +mod app_settings; mod command_history; mod config_backup; mod keyword_highlights; @@ -429,1252 +428,6 @@ impl ConnectionStore { Ok(()) } - pub fn load_app_settings_summary(&self) -> Result { - let value = self.load_settings_value()?; - Ok(AppSettingsSummary { - theme: json_string(&value, &["appearance", "theme"], "github-dark"), - background_image_path: json_path(&value, &["appearance", "background_image_path"]) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()), - background_image_fit: json_string( - &value, - &["appearance", "background_image_fit"], - "cover", - ), - background_image_opacity: { - let raw = json_path(&value, &["appearance", "background_image_opacity"]); - let pct = raw - .and_then(|v| v.as_f64()) - .map(|v| { - // Accept both 0..1 float (Tauri) and 0..100 percent. - if v <= 1.0 { - (v * 100.0).round() as u8 - } else { - v.round() as u8 - } - }) - .unwrap_or(45) - .clamp(0, 100); - pct - }, - background_content_opacity: { - let raw = json_path(&value, &["appearance", "background_opacity"]); - let pct = raw - .and_then(|v| v.as_f64()) - .map(|v| { - if v <= 1.0 { - (v * 100.0).round() as u8 - } else { - v.round() as u8 - } - }) - .unwrap_or(82) - .clamp(0, 100); - pct - }, - // Tauri UiConfig.language (not translation.target_language). - language: json_string(&value, &["ui", "language"], "en"), - terminal_font_family: json_string( - &value, - &["appearance", "font_family"], - "JetBrains Mono", - ), - terminal_font_size: json_u16(&value, &["appearance", "font_size"], 16), - cursor_style: { - let raw = json_string(&value, &["appearance", "cursor_style"], "block"); - match raw.as_str() { - "underline" | "bar" | "block" => raw, - _ => "block".to_string(), - } - }, - cursor_blink: json_bool(&value, &["appearance", "cursor_blink"], true), - terminal_theme: { - let raw = json_path(&value, &["appearance", "terminal_theme"]) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - raw - }, - minimum_contrast_ratio: { - let raw = json_path(&value, &["appearance", "minimum_contrast_ratio"]); - let num = raw.and_then(|v| { - v.as_f64() - .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) - }); - match num { - Some(1.0) => "1".to_string(), - Some(3.0) => "3".to_string(), - Some(4.5) => "4.5".to_string(), - Some(7.0) => "7".to_string(), - Some(21.0) => "21".to_string(), - _ => "1".to_string(), - } - }, - ui_font_family: json_string(&value, &["appearance", "ui_font_family"], "Inter"), - ui_font_size: json_u16(&value, &["appearance", "ui_font_size"], 16).clamp(12, 24), - terminal_font_weight: { - let w = json_u16(&value, &["appearance", "font_weight"], 400); - match w { - 300 | 400 | 500 | 600 | 700 | 800 => w, - _ => 400, - } - }, - terminal_font_weight_bold: { - let w = json_u16(&value, &["appearance", "font_weight_bold"], 700); - match w { - 300 | 400 | 500 | 600 | 700 | 800 => w, - _ => 700, - } - }, - x11_display: json_string(&value, &["terminal", "x11_display"], ""), - terminal_scrollback_lines: json_u32(&value, &["terminal", "scrollback_lines"], 5000) - .clamp(100, 100_000), - terminal_keep_alive_interval: json_u32( - &value, - &["terminal", "keep_alive_interval"], - 30, - ) - .min(600), - terminal_hardware_acceleration: json_bool( - &value, - &["terminal", "hardware_acceleration"], - true, - ), - terminal_show_workspace_padding: json_bool( - &value, - &["terminal", "show_workspace_padding"], - false, - ), - terminal_show_line_numbers: json_bool( - &value, - &["terminal", "show_line_numbers"], - false, - ), - terminal_show_timestamps: json_bool(&value, &["terminal", "show_timestamps"], false), - terminal_show_timestamp_milliseconds: json_bool( - &value, - &["terminal", "show_timestamp_milliseconds"], - false, - ), - terminal_show_multi_line_paste_dialog: json_bool( - &value, - &["terminal", "show_multi_line_paste_dialog"], - true, - ), - terminal_paste_image_as_path: json_bool( - &value, - &["terminal", "paste_image_as_path"], - true, - ), - terminal_low_latency_mode: json_bool(&value, &["terminal", "low_latency_mode"], false), - terminal_action_links_enabled: json_bool( - &value, - &["terminal", "action_links_enabled"], - false, - ), - terminal_action_links_matchers: load_action_links_matchers(&value), - search_custom_engines: load_search_engines(&value), - ui_show_remote_stats: json_bool(&value, &["ui", "show_remote_stats"], true), - ui_remote_stats_interval: json_u32(&value, &["ui", "remote_stats_interval"], 3) - .clamp(1, 60), - ui_show_process_manager: json_bool(&value, &["ui", "show_process_manager"], true), - ui_process_manager_interval: json_u32(&value, &["ui", "process_manager_interval"], 5) - .clamp(3, 120), - ui_show_docker_manager: json_bool(&value, &["ui", "show_docker_manager"], true), - ui_docker_manager_interval: json_u32(&value, &["ui", "docker_manager_interval"], 10) - .clamp(3, 120), - ui_quick_cmd_view_mode: normalize_quick_cmd_view_mode(&json_string( - &value, - &["ui", "quick_cmd_view_mode"], - "tile", - )), - ui_quick_cmd_sort_mode: normalize_quick_cmd_sort_mode(&json_string( - &value, - &["ui", "quick_cmd_sort_mode"], - "created", - )), - ui_saved_connections_sort_mode: normalize_saved_connections_sort_mode(&json_string( - &value, - &["ui", "saved_connections_sort_mode"], - "default", - )), - ui_file_explorer_auto_sync_cwd_connection_ids: json_string_vec( - &value, - &["ui", "file_explorer_auto_sync_cwd_connection_ids"], - 256, - ), - ui_file_explorer_favorite_dirs_by_connection_id: json_string_vec_map( - &value, - &["ui", "file_explorer_favorite_dirs_by_connection_id"], - 12, - ), - ui_left_panel_width: json_u32(&value, &["ui", "left_width"], 256).clamp(160, 720), - ui_right_panel_width: json_u32(&value, &["ui", "right_width"], 288).clamp(200, 720), - ui_transfer_height: json_u32(&value, &["ui", "transfer_height"], 180).clamp(60, 600), - ui_quick_cmd_height: json_u32(&value, &["ui", "quick_cmd_height"], 180).clamp(36, 520), - ui_quick_cmd_visible: json_bool(&value, &["ui", "show_quick_cmd_bar"], true), - ui_serial_send_height: json_u32(&value, &["ui", "serial_send_height"], 180) - .clamp(60, 520), - ui_serial_send_visible: json_bool(&value, &["ui", "show_serial_send_panel"], false), - ui_active_left_panel: json_optional_string(&value, &["ui", "active_left_panel"]), - ui_active_right_panel: json_optional_string(&value, &["ui", "active_right_panel"]), - ui_left_panel_collapsed: json_bool(&value, &["ui", "left_panel_collapsed"], false), - ui_right_panel_collapsed: json_bool(&value, &["ui", "right_panel_collapsed"], false), - ui_activity_bar_left_top: { - json_string_vec_with_default( - &value, - &["ui", "activity_bar_layout", "left_top"], - 32, - default_activity_left_top, - ) - }, - ui_activity_bar_left_bottom: { - json_string_vec_with_default( - &value, - &["ui", "activity_bar_layout", "left_bottom"], - 32, - default_activity_left_bottom, - ) - }, - ui_activity_bar_right_top: { - json_string_vec_with_default( - &value, - &["ui", "activity_bar_layout", "right_top"], - 32, - default_activity_right_top, - ) - }, - ui_activity_bar_right_bottom: { - json_string_vec_with_default( - &value, - &["ui", "activity_bar_layout", "right_bottom"], - 32, - default_activity_right_bottom, - ) - }, - ui_activity_bar_show_labels: json_bool( - &value, - &["ui", "activity_bar_layout", "show_labels"], - false, - ), - ui_panel_multi_open: json_bool(&value, &["appearance", "panel_multi_open"], false) - || json_bool(&value, &["ui", "panel_multi_open"], false), - ui_left_open_panels: json_string_vec(&value, &["ui", "left_open_panels"], 32), - ui_right_open_panels: json_string_vec(&value, &["ui", "right_open_panels"], 32), - ui_panel_stack_sizes: json_u32_map(&value, &["ui", "panel_stack_sizes"]), - interaction_copy_on_select: json_bool( - &value, - &["interaction", "copy_on_select"], - false, - ), - interaction_right_click_paste: json_bool( - &value, - &["interaction", "right_click_paste"], - false, - ), - interaction_command_suggestions_enabled: json_bool( - &value, - &["interaction", "command_suggestions_enabled"], - true, - ), - interaction_command_suggestion_min_chars: json_u32( - &value, - &["interaction", "command_suggestion_min_chars"], - 2, - ) - .clamp(1, 500), - interaction_command_suggestion_max_chars: json_u32( - &value, - &["interaction", "command_suggestion_max_chars"], - 64, - ) - .clamp(1, 500), - interaction_word_separators: json_string( - &value, - &["interaction", "word_separators"], - " \t\r\n\"'`~!@#$%^&*()-=+[{]}\\|;:,<.>/?", - ), - interaction_duplicate_session_command_delay_ms: json_u32( - &value, - &["interaction", "duplicate_session_command_delay_ms"], - 1000, - ) - .min(60_000), - interaction_alt_as_meta: json_bool(&value, &["interaction", "alt_as_meta"], false), - interaction_mac_ime_compatibility: json_bool( - &value, - &["interaction", "mac_ime_compatibility"], - true, - ), - interaction_tab_double_click_action: normalize_tab_mouse_action(&json_string( - &value, - &["interaction", "tab_double_click_action"], - "disconnect_session", - )), - interaction_tab_middle_click_action: normalize_tab_mouse_action(&json_string( - &value, - &["interaction", "tab_middle_click_action"], - "rename_tab", - )), - interaction_tab_right_click_action: normalize_tab_mouse_action(&json_string( - &value, - &["interaction", "tab_right_click_action"], - "none", - )), - interaction_default_encoding: normalize_interaction_encoding(&json_string( - &value, - &["interaction", "default_encoding"], - "UTF-8", - )), - host_key_policy: normalize_host_key_policy(&json_string( - &value, - &["security", "host_key_policy"], - "prompt", - )), - transfer_download_path: json_string(&value, &["transfer", "download_path"], ""), - transfer_ask_save_location: json_bool( - &value, - &["transfer", "ask_save_location"], - false, - ), - transfer_duplicate_strategy: normalize_transfer_duplicate_strategy(&json_string( - &value, - &["transfer", "duplicate_strategy"], - "ask", - )), - transfer_editor_type: normalize_transfer_editor_type(&json_string( - &value, - &["transfer", "editor_type"], - "external", - )), - transfer_default_editor: json_string(&value, &["transfer", "default_editor"], ""), - transfer_download_threads: json_u32(&value, &["transfer", "download_threads"], 3) - .clamp(1, 10), - transfer_upload_threads: json_u32(&value, &["transfer", "upload_threads"], 3) - .clamp(1, 10), - transfer_max_retries: json_u32(&value, &["transfer", "max_transfer_retries"], 2) - .min(10), - transfer_buffer_size: json_u32(&value, &["transfer", "transfer_buffer_size"], 32) - .clamp(8, 256), - transfer_default_file_permissions: normalize_transfer_file_permissions(&json_string( - &value, - &["transfer", "default_file_permissions"], - "644", - )), - transfer_preserve_timestamps: json_bool( - &value, - &["transfer", "preserve_timestamps"], - true, - ), - transfer_resume_broken_transfer: json_bool( - &value, - &["transfer", "resume_broken_transfer"], - true, - ), - recording_path: json_string(&value, &["transfer", "recording_path"], ""), - recording_auto_start: json_bool(&value, &["transfer", "recording_auto_start"], false), - recording_include_io_labels: json_bool( - &value, - &["transfer", "recording_include_io_labels"], - true, - ), - recording_include_timestamps: json_bool( - &value, - &["transfer", "recording_include_timestamps"], - true, - ), - recording_memory_limit_bytes: json_u64( - &value, - &["transfer", "recording_memory_limit_bytes"], - 5 * 1024 * 1024, - ), - diagnostics_level: { - let raw = json_string(&value, &["diagnostics", "level"], "info"); - match raw.as_str() { - "warn" | "debug" => raw, - _ => "info".to_string(), - } - }, - diagnostics_retention_days: { - let days = u32::from(json_u16(&value, &["diagnostics", "retention_days"], 7)); - match days { - 3 | 7 | 14 | 30 => days, - _ => 7, - } - }, - startup_restore: json_bool(&value, &["general", "startup_restore"], false), - startup_restore_window_layout: json_bool( - &value, - &["general", "startup_restore_window_layout"], - true, - ), - minimize_to_tray: json_bool(&value, &["general", "minimize_to_tray"], false), - confirm_on_close: json_bool(&value, &["general", "confirm_on_close"], true), - enable_screen_lock: json_bool(&value, &["security", "enable_screen_lock"], false), - idle_lock_minutes: u32::from(json_u16(&value, &["security", "idle_lock_minutes"], 0)), - has_master_password: value - .get("security") - .and_then(|security| security.get("master_password")) - .and_then(|master_password| master_password.as_str()) - .is_some_and(|master_password| !master_password.is_empty()), - keybindings: json_string_map(&value, &["keybindings"]), - }) - } - - pub fn verify_master_password(&self, password: &str) -> Result { - let Some(token) = self.load_encrypted_master_password()? else { - return Ok(true); - }; - let bootstrap = CredentialCrypto::new(self.portable_key_path.clone(), None); - let stored = bootstrap.decrypt_settings_secret(&token)?; - Ok(stored == password) - } - - pub fn save_master_password( - &self, - next_password: Option<&str>, - ) -> Result { - if next_password.is_some_and(str::is_empty) { - return Err(StorageError::InvalidData( - "Master password cannot be empty when enabled".to_string(), - )); - } - - let bootstrap = CredentialCrypto::new(self.portable_key_path.clone(), None); - let current_password = self - .load_encrypted_master_password()? - .map(|token| bootstrap.decrypt_settings_secret(&token)) - .transpose()?; - let rewrapped_master_key = self - .load_master_key_token()? - .map(|token| { - CredentialCrypto::new(self.portable_key_path.clone(), current_password.clone()) - .rewrap_master_key_token(&token, next_password) - }) - .transpose()?; - - let mut value = self.load_settings_value()?; - let encoded_password = next_password - .map(|password| bootstrap.encrypt_settings_secret(password)) - .transpose()?; - set_nested_json_value( - &mut value, - &["security", "master_password"], - encoded_password - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - ); - - let txn = self.db.begin_write()?; - write_json_in_txn(&txn, SETTINGS_TABLE, SETTINGS_DEFAULT, &value)?; - if let Some(token) = rewrapped_master_key.as_deref() { - txn.open_table(META_TABLE)?.insert(META_MASTER_KEY, token)?; - txn.open_table(TEXT_DOCS_TABLE)? - .insert(LEGACY_TEXT_MASTER_KEY, token)?; - } - txn.commit()?; - self.load_app_settings_summary() - } - - pub fn save_host_key_policy(&self, policy: &str) -> Result { - let policy = normalize_host_key_policy(policy); - let mut value = self.load_settings_value()?; - set_nested_json_string(&mut value, &["security", "host_key_policy"], policy); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_recording_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_string( - &mut value, - &["transfer", "recording_path"], - settings.recording_path.clone(), - ); - set_nested_json_value( - &mut value, - &["transfer", "recording_auto_start"], - serde_json::Value::Bool(settings.recording_auto_start), - ); - set_nested_json_value( - &mut value, - &["transfer", "recording_include_io_labels"], - serde_json::Value::Bool(settings.recording_include_io_labels), - ); - set_nested_json_value( - &mut value, - &["transfer", "recording_include_timestamps"], - serde_json::Value::Bool(settings.recording_include_timestamps), - ); - set_nested_json_value( - &mut value, - &["transfer", "recording_memory_limit_bytes"], - serde_json::Value::from(settings.recording_memory_limit_bytes), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_transfer_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_string( - &mut value, - &["transfer", "download_path"], - settings.transfer_download_path.clone(), - ); - set_nested_json_value( - &mut value, - &["transfer", "ask_save_location"], - serde_json::Value::Bool(settings.transfer_ask_save_location), - ); - set_nested_json_string( - &mut value, - &["transfer", "duplicate_strategy"], - normalize_transfer_duplicate_strategy(&settings.transfer_duplicate_strategy), - ); - set_nested_json_string( - &mut value, - &["transfer", "editor_type"], - normalize_transfer_editor_type(&settings.transfer_editor_type), - ); - set_nested_json_string( - &mut value, - &["transfer", "default_editor"], - settings.transfer_default_editor.clone(), - ); - set_nested_json_value( - &mut value, - &["transfer", "download_threads"], - serde_json::Value::from(settings.transfer_download_threads.clamp(1, 10)), - ); - set_nested_json_value( - &mut value, - &["transfer", "upload_threads"], - serde_json::Value::from(settings.transfer_upload_threads.clamp(1, 10)), - ); - set_nested_json_value( - &mut value, - &["transfer", "max_transfer_retries"], - serde_json::Value::from(settings.transfer_max_retries.min(10)), - ); - set_nested_json_value( - &mut value, - &["transfer", "transfer_buffer_size"], - serde_json::Value::from(settings.transfer_buffer_size.clamp(8, 256)), - ); - set_nested_json_string( - &mut value, - &["transfer", "default_file_permissions"], - normalize_transfer_file_permissions(&settings.transfer_default_file_permissions), - ); - set_nested_json_value( - &mut value, - &["transfer", "preserve_timestamps"], - serde_json::Value::Bool(settings.transfer_preserve_timestamps), - ); - set_nested_json_value( - &mut value, - &["transfer", "resume_broken_transfer"], - serde_json::Value::Bool(settings.transfer_resume_broken_transfer), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_file_explorer_favorite_dirs( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_value( - &mut value, - &["ui", "file_explorer_auto_sync_cwd_connection_ids"], - string_vec_json_value(&settings.ui_file_explorer_auto_sync_cwd_connection_ids, 256), - ); - set_nested_json_value( - &mut value, - &["ui", "file_explorer_favorite_dirs_by_connection_id"], - string_vec_map_json_value( - &settings.ui_file_explorer_favorite_dirs_by_connection_id, - 12, - ), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_quick_command_ui_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_string( - &mut value, - &["ui", "quick_cmd_view_mode"], - normalize_quick_cmd_view_mode(&settings.ui_quick_cmd_view_mode), - ); - set_nested_json_string( - &mut value, - &["ui", "quick_cmd_sort_mode"], - normalize_quick_cmd_sort_mode(&settings.ui_quick_cmd_sort_mode), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_ui_layout_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_value( - &mut value, - &["ui", "left_width"], - serde_json::Value::from(settings.ui_left_panel_width.clamp(160, 720)), - ); - set_nested_json_value( - &mut value, - &["ui", "right_width"], - serde_json::Value::from(settings.ui_right_panel_width.clamp(200, 720)), - ); - set_nested_json_value( - &mut value, - &["ui", "transfer_height"], - serde_json::Value::from(settings.ui_transfer_height.clamp(60, 600)), - ); - set_nested_json_value( - &mut value, - &["ui", "quick_cmd_height"], - serde_json::Value::from(settings.ui_quick_cmd_height.clamp(36, 520)), - ); - set_nested_json_value( - &mut value, - &["ui", "show_quick_cmd_bar"], - serde_json::Value::Bool(settings.ui_quick_cmd_visible), - ); - set_nested_json_value( - &mut value, - &["ui", "serial_send_height"], - serde_json::Value::from(settings.ui_serial_send_height.clamp(60, 520)), - ); - set_nested_json_value( - &mut value, - &["ui", "show_serial_send_panel"], - serde_json::Value::Bool(settings.ui_serial_send_visible), - ); - match &settings.ui_active_left_panel { - Some(panel) if !panel.trim().is_empty() => { - set_nested_json_string(&mut value, &["ui", "active_left_panel"], panel.clone()) - } - _ => set_nested_json_value( - &mut value, - &["ui", "active_left_panel"], - serde_json::Value::Null, - ), - } - match &settings.ui_active_right_panel { - Some(panel) if !panel.trim().is_empty() => { - set_nested_json_string(&mut value, &["ui", "active_right_panel"], panel.clone()) - } - _ => set_nested_json_value( - &mut value, - &["ui", "active_right_panel"], - serde_json::Value::Null, - ), - } - set_nested_json_value( - &mut value, - &["ui", "left_panel_collapsed"], - serde_json::Value::Bool(settings.ui_left_panel_collapsed), - ); - set_nested_json_value( - &mut value, - &["ui", "right_panel_collapsed"], - serde_json::Value::Bool(settings.ui_right_panel_collapsed), - ); - set_nested_json_value( - &mut value, - &["ui", "activity_bar_layout", "left_top"], - string_vec_json_value(&settings.ui_activity_bar_left_top, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "activity_bar_layout", "left_bottom"], - string_vec_json_value(&settings.ui_activity_bar_left_bottom, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "activity_bar_layout", "right_top"], - string_vec_json_value(&settings.ui_activity_bar_right_top, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "activity_bar_layout", "right_bottom"], - string_vec_json_value(&settings.ui_activity_bar_right_bottom, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "activity_bar_layout", "show_labels"], - serde_json::Value::Bool(settings.ui_activity_bar_show_labels), - ); - set_nested_json_value( - &mut value, - &["ui", "panel_multi_open"], - serde_json::Value::Bool(settings.ui_panel_multi_open), - ); - // Keep appearance key for Tauri compatibility. - set_nested_json_value( - &mut value, - &["appearance", "panel_multi_open"], - serde_json::Value::Bool(settings.ui_panel_multi_open), - ); - set_nested_json_value( - &mut value, - &["ui", "left_open_panels"], - string_vec_json_value(&settings.ui_left_open_panels, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "right_open_panels"], - string_vec_json_value(&settings.ui_right_open_panels, 32), - ); - set_nested_json_value( - &mut value, - &["ui", "panel_stack_sizes"], - u32_map_json_value(&settings.ui_panel_stack_sizes), - ); - set_nested_json_string( - &mut value, - &["ui", "saved_connections_sort_mode"], - normalize_saved_connections_sort_mode(&settings.ui_saved_connections_sort_mode), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_appearance_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_string(&mut value, &["appearance", "theme"], settings.theme.clone()); - match settings - .background_image_path - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - Some(path) => set_nested_json_string( - &mut value, - &["appearance", "background_image_path"], - path.to_string(), - ), - None => set_nested_json_value( - &mut value, - &["appearance", "background_image_path"], - serde_json::Value::Null, - ), - } - set_nested_json_string( - &mut value, - &["appearance", "background_image_fit"], - settings.background_image_fit.clone(), - ); - set_nested_json_value( - &mut value, - &["appearance", "background_image_opacity"], - serde_json::Value::from(settings.background_image_opacity as f64 / 100.0), - ); - set_nested_json_value( - &mut value, - &["appearance", "background_opacity"], - serde_json::Value::from(settings.background_content_opacity as f64 / 100.0), - ); - set_nested_json_string( - &mut value, - &["appearance", "font_family"], - settings.terminal_font_family.clone(), - ); - set_nested_json_value( - &mut value, - &["appearance", "font_size"], - serde_json::Value::from(settings.terminal_font_size), - ); - set_nested_json_string( - &mut value, - &["appearance", "cursor_style"], - match settings.cursor_style.as_str() { - "underline" | "bar" => settings.cursor_style.clone(), - _ => "block".to_string(), - }, - ); - set_nested_json_value( - &mut value, - &["appearance", "cursor_blink"], - serde_json::Value::from(settings.cursor_blink), - ); - match settings - .terminal_theme - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - Some(theme) => set_nested_json_string( - &mut value, - &["appearance", "terminal_theme"], - theme.to_string(), - ), - None => set_nested_json_value( - &mut value, - &["appearance", "terminal_theme"], - serde_json::Value::Null, - ), - } - let contrast = match settings.minimum_contrast_ratio.as_str() { - "3" => 3.0, - "4.5" => 4.5, - "7" => 7.0, - "21" => 21.0, - _ => 1.0, - }; - set_nested_json_value( - &mut value, - &["appearance", "minimum_contrast_ratio"], - serde_json::Value::from(contrast), - ); - set_nested_json_string( - &mut value, - &["appearance", "ui_font_family"], - if settings.ui_font_family.trim().is_empty() { - "Inter".to_string() - } else { - settings.ui_font_family.clone() - }, - ); - set_nested_json_value( - &mut value, - &["appearance", "ui_font_size"], - serde_json::Value::from(settings.ui_font_size.clamp(12, 24)), - ); - let font_weight = match settings.terminal_font_weight { - 300 | 400 | 500 | 600 | 700 | 800 => settings.terminal_font_weight, - _ => 400, - }; - let font_weight_bold = match settings.terminal_font_weight_bold { - 300 | 400 | 500 | 600 | 700 | 800 => settings.terminal_font_weight_bold, - _ => 700, - }; - set_nested_json_value( - &mut value, - &["appearance", "font_weight"], - serde_json::Value::from(font_weight), - ); - set_nested_json_value( - &mut value, - &["appearance", "font_weight_bold"], - serde_json::Value::from(font_weight_bold), - ); - set_nested_json_string( - &mut value, - &["terminal", "x11_display"], - settings.x11_display.clone(), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_terminal_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_string( - &mut value, - &["terminal", "x11_display"], - settings.x11_display.clone(), - ); - set_nested_json_value( - &mut value, - &["terminal", "scrollback_lines"], - serde_json::Value::from(settings.terminal_scrollback_lines.clamp(100, 100_000)), - ); - set_nested_json_value( - &mut value, - &["terminal", "keep_alive_interval"], - serde_json::Value::from(settings.terminal_keep_alive_interval.min(600)), - ); - set_nested_json_value( - &mut value, - &["terminal", "hardware_acceleration"], - serde_json::Value::Bool(settings.terminal_hardware_acceleration), - ); - set_nested_json_value( - &mut value, - &["terminal", "show_workspace_padding"], - serde_json::Value::Bool(settings.terminal_show_workspace_padding), - ); - set_nested_json_value( - &mut value, - &["terminal", "show_line_numbers"], - serde_json::Value::Bool(settings.terminal_show_line_numbers), - ); - set_nested_json_value( - &mut value, - &["terminal", "show_timestamps"], - serde_json::Value::Bool(settings.terminal_show_timestamps), - ); - set_nested_json_value( - &mut value, - &["terminal", "show_timestamp_milliseconds"], - serde_json::Value::Bool(settings.terminal_show_timestamp_milliseconds), - ); - set_nested_json_value( - &mut value, - &["terminal", "show_multi_line_paste_dialog"], - serde_json::Value::Bool(settings.terminal_show_multi_line_paste_dialog), - ); - set_nested_json_value( - &mut value, - &["terminal", "paste_image_as_path"], - serde_json::Value::Bool(settings.terminal_paste_image_as_path), - ); - set_nested_json_value( - &mut value, - &["terminal", "low_latency_mode"], - serde_json::Value::Bool(settings.terminal_low_latency_mode), - ); - set_nested_json_value( - &mut value, - &["terminal", "action_links_enabled"], - serde_json::Value::Bool(settings.terminal_action_links_enabled), - ); - set_nested_json_value( - &mut value, - &["terminal", "action_links_matchers"], - serde_json::json!({ - "ipv4": settings.terminal_action_links_matchers.ipv4, - "archive": settings.terminal_action_links_matchers.archive, - "host_port": settings.terminal_action_links_matchers.host_port, - }), - ); - set_nested_json_value( - &mut value, - &["search", "custom_engines"], - search_engines_to_json(&settings.search_custom_engines), - ); - set_nested_json_value( - &mut value, - &["ui", "show_remote_stats"], - serde_json::Value::Bool(settings.ui_show_remote_stats), - ); - set_nested_json_value( - &mut value, - &["ui", "remote_stats_interval"], - serde_json::Value::from(settings.ui_remote_stats_interval.clamp(1, 60)), - ); - set_nested_json_value( - &mut value, - &["ui", "show_process_manager"], - serde_json::Value::Bool(settings.ui_show_process_manager), - ); - set_nested_json_value( - &mut value, - &["ui", "process_manager_interval"], - serde_json::Value::from(settings.ui_process_manager_interval.clamp(3, 120)), - ); - set_nested_json_value( - &mut value, - &["ui", "show_docker_manager"], - serde_json::Value::Bool(settings.ui_show_docker_manager), - ); - set_nested_json_value( - &mut value, - &["ui", "docker_manager_interval"], - serde_json::Value::from(settings.ui_docker_manager_interval.clamp(3, 120)), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_interaction_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - let min_chars = settings - .interaction_command_suggestion_min_chars - .clamp(1, 500); - let max_chars = settings - .interaction_command_suggestion_max_chars - .clamp(min_chars, 500); - set_nested_json_value( - &mut value, - &["interaction", "copy_on_select"], - serde_json::Value::Bool(settings.interaction_copy_on_select), - ); - set_nested_json_value( - &mut value, - &["interaction", "right_click_paste"], - serde_json::Value::Bool(settings.interaction_right_click_paste), - ); - set_nested_json_value( - &mut value, - &["interaction", "command_suggestions_enabled"], - serde_json::Value::Bool(settings.interaction_command_suggestions_enabled), - ); - set_nested_json_value( - &mut value, - &["interaction", "command_suggestion_min_chars"], - serde_json::Value::from(min_chars), - ); - set_nested_json_value( - &mut value, - &["interaction", "command_suggestion_max_chars"], - serde_json::Value::from(max_chars), - ); - set_nested_json_string( - &mut value, - &["interaction", "word_separators"], - settings.interaction_word_separators.clone(), - ); - set_nested_json_value( - &mut value, - &["interaction", "duplicate_session_command_delay_ms"], - serde_json::Value::from( - settings - .interaction_duplicate_session_command_delay_ms - .min(60_000), - ), - ); - set_nested_json_value( - &mut value, - &["interaction", "alt_as_meta"], - serde_json::Value::Bool(settings.interaction_alt_as_meta), - ); - set_nested_json_value( - &mut value, - &["interaction", "mac_ime_compatibility"], - serde_json::Value::Bool(settings.interaction_mac_ime_compatibility), - ); - set_nested_json_string( - &mut value, - &["interaction", "tab_double_click_action"], - normalize_tab_mouse_action(&settings.interaction_tab_double_click_action), - ); - set_nested_json_string( - &mut value, - &["interaction", "tab_middle_click_action"], - normalize_tab_mouse_action(&settings.interaction_tab_middle_click_action), - ); - set_nested_json_string( - &mut value, - &["interaction", "tab_right_click_action"], - normalize_tab_mouse_action(&settings.interaction_tab_right_click_action), - ); - set_nested_json_string( - &mut value, - &["interaction", "default_encoding"], - normalize_interaction_encoding(&settings.interaction_default_encoding), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_general_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_value( - &mut value, - &["general", "startup_restore"], - serde_json::Value::Bool(settings.startup_restore), - ); - set_nested_json_value( - &mut value, - &["general", "startup_restore_window_layout"], - serde_json::Value::Bool(settings.startup_restore_window_layout), - ); - set_nested_json_value( - &mut value, - &["general", "minimize_to_tray"], - serde_json::Value::Bool(settings.minimize_to_tray), - ); - set_nested_json_value( - &mut value, - &["general", "confirm_on_close"], - serde_json::Value::Bool(settings.confirm_on_close), - ); - // UI language lives under ui.language (Tauri UiConfig.language). - let language = match settings.language.as_str() { - "zh-CN" | "zh" => "zh-CN", - "zh-TW" => "zh-TW", - "ja" => "ja", - _ => "en", - }; - set_nested_json_string(&mut value, &["ui", "language"], language.to_string()); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_diagnostics_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - let level = match settings.diagnostics_level.as_str() { - "warn" | "debug" => settings.diagnostics_level.as_str(), - _ => "info", - }; - let retention = match settings.diagnostics_retention_days { - 3 | 7 | 14 | 30 => settings.diagnostics_retention_days, - _ => 7, - }; - set_nested_json_string(&mut value, &["diagnostics", "level"], level.to_string()); - set_nested_json_value( - &mut value, - &["diagnostics", "retention_days"], - serde_json::Value::from(retention), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn load_open_tabs(&self) -> Result, StorageError> { - let value = self.load_settings_value()?; - let Some(raw) = json_path(&value, &["ui", "open_tabs"]) else { - return Ok(Vec::new()); - }; - if raw.is_null() { - return Ok(Vec::new()); - } - match serde_json::from_value::>(raw.clone()) { - Ok(tabs) => Ok(tabs), - Err(_) => Ok(Vec::new()), - } - } - - pub fn save_open_tabs( - &self, - tabs: &[crate::models::RestorableOpenTab], - ) -> Result<(), StorageError> { - let mut value = self.load_settings_value()?; - let encoded = serde_json::to_value(tabs)?; - set_nested_json_value(&mut value, &["ui", "open_tabs"], encoded); - self.save_settings_value(&value)?; - Ok(()) - } - - pub fn load_terminal_window_layout( - &self, - ) -> Result, StorageError> { - let value = self.load_settings_value()?; - let Some(raw) = json_path(&value, &["ui", "terminal_window_layout"]) else { - return Ok(None); - }; - if raw.is_null() { - return Ok(None); - } - match serde_json::from_value(raw.clone()) { - Ok(node) => Ok(Some(node)), - Err(_) => Ok(None), - } - } - - pub fn save_terminal_window_layout( - &self, - layout: Option<&crate::models::RestorableTerminalWindowNode>, - ) -> Result<(), StorageError> { - let mut value = self.load_settings_value()?; - let encoded = match layout { - Some(node) => serde_json::to_value(node)?, - None => serde_json::Value::Null, - }; - set_nested_json_value(&mut value, &["ui", "terminal_window_layout"], encoded); - self.save_settings_value(&value)?; - Ok(()) - } - - pub fn load_workspace_pane_layout( - &self, - ) -> Result, StorageError> { - let value = self.load_settings_value()?; - let Some(raw) = json_path(&value, &["ui", "workspace_pane_layout"]) else { - return Ok(None); - }; - if raw.is_null() { - return Ok(None); - } - match serde_json::from_value(raw.clone()) { - Ok(node) => Ok(Some(node)), - Err(_) => Ok(None), - } - } - - pub fn save_workspace_pane_layout( - &self, - layout: Option<&crate::models::RestorableWorkspacePaneNode>, - ) -> Result<(), StorageError> { - let mut value = self.load_settings_value()?; - let encoded = match layout { - Some(node) => serde_json::to_value(node)?, - None => serde_json::Value::Null, - }; - set_nested_json_value(&mut value, &["ui", "workspace_pane_layout"], encoded); - self.save_settings_value(&value)?; - Ok(()) - } - - pub fn save_screen_lock_settings( - &self, - settings: &AppSettingsSummary, - ) -> Result { - let mut value = self.load_settings_value()?; - set_nested_json_value( - &mut value, - &["security", "enable_screen_lock"], - serde_json::Value::Bool(settings.enable_screen_lock), - ); - set_nested_json_value( - &mut value, - &["security", "idle_lock_minutes"], - serde_json::Value::from(settings.idle_lock_minutes), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - - pub fn save_keybindings( - &self, - keybindings: &HashMap, - ) -> Result { - let mut value = self.load_settings_value()?; - let mut object = serde_json::Map::new(); - for (id, keys) in keybindings { - let id = id.trim(); - let keys = keys.trim(); - if !id.is_empty() && !keys.is_empty() { - object.insert(id.to_string(), serde_json::Value::String(keys.to_string())); - } - } - set_nested_json_value( - &mut value, - &["keybindings"], - serde_json::Value::Object(object), - ); - self.save_settings_value(&value)?; - self.load_app_settings_summary() - } - pub fn load_translation_settings(&self) -> Result { let value = self.load_settings_value()?; let mut settings = value @@ -2786,73 +1539,12 @@ fn default_settings_value() -> serde_json::Value { }) } -fn json_u32_map(value: &serde_json::Value, path: &[&str]) -> HashMap { - let mut map = HashMap::new(); - let Some(object) = json_path(value, path).and_then(|value| value.as_object()) else { - return map; - }; - for (key, raw) in object { - let number = raw - .as_u64() - .or_else(|| raw.as_f64().map(|value| value.round() as u64)); - if let Some(number) = number { - if number > 0 { - map.insert(key.clone(), number.min(u32::MAX as u64) as u32); - } - } - } - map -} - -fn u32_map_json_value(map: &HashMap) -> serde_json::Value { - let mut object = serde_json::Map::new(); - for (key, value) in map { - if *value > 0 { - object.insert(key.clone(), serde_json::json!(*value)); - } - } - serde_json::Value::Object(object) -} - -fn json_optional_string(value: &serde_json::Value, path: &[&str]) -> Option { - json_path(value, path) - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn json_string(value: &serde_json::Value, path: &[&str], fallback: &str) -> String { - json_path(value, path) - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback) - .to_string() -} - fn json_bool(value: &serde_json::Value, path: &[&str], fallback: bool) -> bool { json_path(value, path) .and_then(serde_json::Value::as_bool) .unwrap_or(fallback) } -fn json_string_map(value: &serde_json::Value, path: &[&str]) -> HashMap { - json_path(value, path) - .and_then(serde_json::Value::as_object) - .map(|object| { - object - .iter() - .filter_map(|(key, value)| { - value - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(|value| (key.clone(), value.to_string())) - }) - .collect() - }) - .unwrap_or_default() -} - fn json_string_vec(value: &serde_json::Value, path: &[&str], limit: usize) -> Vec { json_path(value, path) .and_then(serde_json::Value::as_array) @@ -2879,151 +1571,6 @@ fn json_string_vec(value: &serde_json::Value, path: &[&str], limit: usize) -> Ve .unwrap_or_default() } -fn json_string_vec_with_default( - value: &serde_json::Value, - path: &[&str], - limit: usize, - default: fn() -> Vec, -) -> Vec { - if json_path(value, path).is_some() { - json_string_vec(value, path, limit) - } else { - default() - } -} - -fn json_string_vec_map( - value: &serde_json::Value, - path: &[&str], - limit_per_key: usize, -) -> HashMap> { - json_path(value, path) - .and_then(serde_json::Value::as_object) - .map(|object| { - object - .iter() - .filter_map(|(key, value)| { - let values = value - .as_array()? - .iter() - .filter_map(|entry| { - entry - .as_str() - .map(str::trim) - .filter(|entry| !entry.is_empty()) - .map(ToOwned::to_owned) - }) - .fold(Vec::::new(), |mut values, entry| { - if !values.iter().any(|existing| existing == &entry) { - values.push(entry); - } - values - }) - .into_iter() - .take(limit_per_key) - .collect::>(); - (!values.is_empty()).then(|| (key.clone(), values)) - }) - .collect() - }) - .unwrap_or_default() -} - -fn string_vec_json_value(values: &[String], limit: usize) -> serde_json::Value { - serde_json::Value::Array( - values - .iter() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .fold(Vec::::new(), |mut values, value| { - if !values.iter().any(|existing| existing == value) { - values.push(value.to_string()); - } - values - }) - .into_iter() - .take(limit) - .map(serde_json::Value::String) - .collect(), - ) -} - -fn string_vec_map_json_value( - map: &HashMap>, - limit_per_key: usize, -) -> serde_json::Value { - let object = map - .iter() - .filter_map(|(key, values)| { - let key = key.trim(); - if key.is_empty() { - return None; - } - let values = values - .iter() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .fold(Vec::::new(), |mut values, value| { - if !values.iter().any(|existing| existing == value) { - values.push(value.to_string()); - } - values - }) - .into_iter() - .take(limit_per_key) - .map(serde_json::Value::String) - .collect::>(); - (!values.is_empty()).then(|| (key.to_string(), serde_json::Value::Array(values))) - }) - .collect(); - serde_json::Value::Object(object) -} - -fn json_u16(value: &serde_json::Value, path: &[&str], fallback: u16) -> u16 { - let Some(value) = json_path(value, path) else { - return fallback; - }; - if let Some(number) = value.as_u64() { - return number.try_into().unwrap_or(fallback); - } - if let Some(number) = value.as_f64() { - if number.is_finite() && number >= 0.0 && number <= f64::from(u16::MAX) { - return number.round() as u16; - } - } - fallback -} - -fn json_u32(value: &serde_json::Value, path: &[&str], fallback: u32) -> u32 { - let Some(value) = json_path(value, path) else { - return fallback; - }; - if let Some(number) = value.as_u64() { - return number.try_into().unwrap_or(fallback); - } - if let Some(number) = value.as_f64() { - if number.is_finite() && number >= 0.0 && number <= f64::from(u32::MAX) { - return number.round() as u32; - } - } - fallback -} - -fn json_u64(value: &serde_json::Value, path: &[&str], fallback: u64) -> u64 { - let Some(value) = json_path(value, path) else { - return fallback; - }; - if let Some(number) = value.as_u64() { - return number; - } - if let Some(number) = value.as_f64() { - if number.is_finite() && number >= 0.0 && number <= u64::MAX as f64 { - return number.round() as u64; - } - } - fallback -} - fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> { let mut current = value; for key in path { @@ -3032,89 +1579,6 @@ fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serd Some(current) } -fn set_nested_json_string(value: &mut serde_json::Value, path: &[&str], new_value: String) { - set_nested_json_value(value, path, serde_json::Value::String(new_value)); -} - -fn load_action_links_matchers(value: &serde_json::Value) -> crate::ActionLinksMatcherSettings { - let defaults = crate::ActionLinksMatcherSettings::default(); - let Some(obj) = json_path(value, &["terminal", "action_links_matchers"]) else { - return defaults; - }; - crate::ActionLinksMatcherSettings { - ipv4: obj - .get("ipv4") - .and_then(|v| v.as_bool()) - .unwrap_or(defaults.ipv4), - archive: obj - .get("archive") - .and_then(|v| v.as_bool()) - .unwrap_or(defaults.archive), - host_port: obj - .get("host_port") - .and_then(|v| v.as_bool()) - .unwrap_or(defaults.host_port), - } -} - -fn load_search_engines(value: &serde_json::Value) -> Vec { - let Some(arr) = json_path(value, &["search", "custom_engines"]).and_then(|v| v.as_array()) - else { - return default_search_engines(); - }; - let mut engines = Vec::new(); - for item in arr { - if !item.is_object() { - continue; - } - let name = item - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let url_template = item - .get("url_template") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let show_in_menu = item - .get("show_in_menu") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let icon = item - .get("icon") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - engines.push(SearchEngineConfig { - name, - url_template, - icon, - show_in_menu, - }); - } - engines -} - -fn search_engines_to_json(engines: &[SearchEngineConfig]) -> serde_json::Value { - serde_json::Value::Array( - engines - .iter() - .map(|engine| { - serde_json::json!({ - "name": engine.name, - "url_template": engine.url_template, - "icon": engine.icon, - "show_in_menu": engine.show_in_menu, - }) - }) - .collect(), - ) -} - fn set_nested_json_value( value: &mut serde_json::Value, path: &[&str], @@ -3135,112 +1599,6 @@ fn set_nested_json_value( } } -fn normalize_host_key_policy(policy: &str) -> String { - match policy { - "strict" | "accept" | "prompt" => policy.to_string(), - _ => "prompt".to_string(), - } -} - -fn normalize_transfer_duplicate_strategy(strategy: &str) -> String { - match strategy { - "ask" | "overwrite" | "skip" | "rename" => strategy.to_string(), - _ => "ask".to_string(), - } -} - -fn normalize_transfer_editor_type(editor_type: &str) -> String { - match editor_type { - "external" | "internal" => editor_type.to_string(), - _ => "external".to_string(), - } -} - -fn normalize_transfer_file_permissions(value: &str) -> String { - let trimmed = value - .trim() - .trim_start_matches("0o") - .trim_start_matches('0'); - let normalized = if trimmed.is_empty() { "0" } else { trimmed }; - if (3..=4).contains(&normalized.len()) && normalized.chars().all(|ch| matches!(ch, '0'..='7')) { - normalized.to_string() - } else { - "644".to_string() - } -} - -fn default_activity_left_top() -> Vec { - vec![ - "fileExplorer".to_string(), - "network".to_string(), - "securityAuth".to_string(), - ] -} - -fn default_activity_left_bottom() -> Vec { - vec!["syncBackupHistory".to_string(), "settings".to_string()] -} - -fn default_activity_right_top() -> Vec { - vec![ - "savedConnections".to_string(), - "aiAssistant".to_string(), - "activeSessions".to_string(), - "commandHistory".to_string(), - "resourceMonitor".to_string(), - "processManager".to_string(), - "dockerManager".to_string(), - ] -} - -fn default_activity_right_bottom() -> Vec { - vec![ - "quickCmdBar".to_string(), - "serialSend".to_string(), - "recording".to_string(), - "lock".to_string(), - ] -} - -fn normalize_quick_cmd_view_mode(value: &str) -> String { - match value.trim() { - "list" | "compact" | "tile" => value.trim().to_string(), - _ => "tile".to_string(), - } -} - -fn normalize_quick_cmd_sort_mode(value: &str) -> String { - match value.trim() { - "created" | "name" | "useCount" => value.trim().to_string(), - _ => "created".to_string(), - } -} - -fn normalize_saved_connections_sort_mode(value: &str) -> String { - match value.trim() { - "name-asc" | "name-desc" => value.trim().to_string(), - _ => "default".to_string(), - } -} - -fn normalize_tab_mouse_action(action: &str) -> String { - match action { - "none" | "rename_tab" | "copy_tab_name" | "copy_server_ip" | "duplicate_session" - | "multiplex_ssh" | "reconnect_session" | "disconnect_session" | "close_tab" => { - action.to_string() - } - _ => "none".to_string(), - } -} - -fn normalize_interaction_encoding(encoding: &str) -> String { - if encoding.eq_ignore_ascii_case("gbk") { - "GBK".to_string() - } else { - "UTF-8".to_string() - } -} - fn padded_i64(value: i64) -> String { let shifted = i128::from(value) - i128::from(i64::MIN); format!("{shifted:020}") @@ -3305,7 +1663,8 @@ fn current_time_ms() -> u64 { mod tests { use super::*; use crate::{ - AiExecutionProfile, CommandHistoryEntry, ConnectionAuth, ConnectionType, OtpEntry, SshKey, + AiExecutionProfile, CommandHistoryEntry, ConnectionAuth, ConnectionType, OtpEntry, + SearchEngineConfig, SshKey, }; use aes_gcm::{Aes256Gcm, Key, KeyInit, aead::Aead}; use base64::{Engine, engine::general_purpose::STANDARD as B64}; diff --git a/crates/nyaterm-core/src/storage/app_settings.rs b/crates/nyaterm-core/src/storage/app_settings.rs new file mode 100644 index 000000000..7c9f0e9ba --- /dev/null +++ b/crates/nyaterm-core/src/storage/app_settings.rs @@ -0,0 +1,1658 @@ +//! Application settings document persistence. +//! +//! Split out of `storage.rs` by domain. Every method here reads or writes the +//! single app settings document and returns the refreshed +//! [`AppSettingsSummary`]; document keys, field names and defaults are +//! unchanged. + +use std::collections::HashMap; + +use super::{ + ConnectionStore, LEGACY_TEXT_MASTER_KEY, META_MASTER_KEY, META_TABLE, SETTINGS_DEFAULT, + SETTINGS_TABLE, StorageError, TEXT_DOCS_TABLE, json_bool, json_path, json_string_vec, + set_nested_json_value, write_json_in_txn, +}; +use crate::{AppSettingsSummary, CredentialCrypto, SearchEngineConfig, default_search_engines}; + +impl ConnectionStore { + pub fn load_app_settings_summary(&self) -> Result { + let value = self.load_settings_value()?; + Ok(AppSettingsSummary { + theme: json_string(&value, &["appearance", "theme"], "github-dark"), + background_image_path: json_path(&value, &["appearance", "background_image_path"]) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + background_image_fit: json_string( + &value, + &["appearance", "background_image_fit"], + "cover", + ), + background_image_opacity: { + let raw = json_path(&value, &["appearance", "background_image_opacity"]); + let pct = raw + .and_then(|v| v.as_f64()) + .map(|v| { + // Accept both 0..1 float (Tauri) and 0..100 percent. + if v <= 1.0 { + (v * 100.0).round() as u8 + } else { + v.round() as u8 + } + }) + .unwrap_or(45) + .clamp(0, 100); + pct + }, + background_content_opacity: { + let raw = json_path(&value, &["appearance", "background_opacity"]); + let pct = raw + .and_then(|v| v.as_f64()) + .map(|v| { + if v <= 1.0 { + (v * 100.0).round() as u8 + } else { + v.round() as u8 + } + }) + .unwrap_or(82) + .clamp(0, 100); + pct + }, + // Tauri UiConfig.language (not translation.target_language). + language: json_string(&value, &["ui", "language"], "en"), + terminal_font_family: json_string( + &value, + &["appearance", "font_family"], + "JetBrains Mono", + ), + terminal_font_size: json_u16(&value, &["appearance", "font_size"], 16), + cursor_style: { + let raw = json_string(&value, &["appearance", "cursor_style"], "block"); + match raw.as_str() { + "underline" | "bar" | "block" => raw, + _ => "block".to_string(), + } + }, + cursor_blink: json_bool(&value, &["appearance", "cursor_blink"], true), + terminal_theme: { + let raw = json_path(&value, &["appearance", "terminal_theme"]) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + raw + }, + minimum_contrast_ratio: { + let raw = json_path(&value, &["appearance", "minimum_contrast_ratio"]); + let num = raw.and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + }); + match num { + Some(1.0) => "1".to_string(), + Some(3.0) => "3".to_string(), + Some(4.5) => "4.5".to_string(), + Some(7.0) => "7".to_string(), + Some(21.0) => "21".to_string(), + _ => "1".to_string(), + } + }, + ui_font_family: json_string(&value, &["appearance", "ui_font_family"], "Inter"), + ui_font_size: json_u16(&value, &["appearance", "ui_font_size"], 16).clamp(12, 24), + terminal_font_weight: { + let w = json_u16(&value, &["appearance", "font_weight"], 400); + match w { + 300 | 400 | 500 | 600 | 700 | 800 => w, + _ => 400, + } + }, + terminal_font_weight_bold: { + let w = json_u16(&value, &["appearance", "font_weight_bold"], 700); + match w { + 300 | 400 | 500 | 600 | 700 | 800 => w, + _ => 700, + } + }, + x11_display: json_string(&value, &["terminal", "x11_display"], ""), + terminal_scrollback_lines: json_u32(&value, &["terminal", "scrollback_lines"], 5000) + .clamp(100, 100_000), + terminal_keep_alive_interval: json_u32( + &value, + &["terminal", "keep_alive_interval"], + 30, + ) + .min(600), + terminal_hardware_acceleration: json_bool( + &value, + &["terminal", "hardware_acceleration"], + true, + ), + terminal_show_workspace_padding: json_bool( + &value, + &["terminal", "show_workspace_padding"], + false, + ), + terminal_show_line_numbers: json_bool( + &value, + &["terminal", "show_line_numbers"], + false, + ), + terminal_show_timestamps: json_bool(&value, &["terminal", "show_timestamps"], false), + terminal_show_timestamp_milliseconds: json_bool( + &value, + &["terminal", "show_timestamp_milliseconds"], + false, + ), + terminal_show_multi_line_paste_dialog: json_bool( + &value, + &["terminal", "show_multi_line_paste_dialog"], + true, + ), + terminal_paste_image_as_path: json_bool( + &value, + &["terminal", "paste_image_as_path"], + true, + ), + terminal_low_latency_mode: json_bool(&value, &["terminal", "low_latency_mode"], false), + terminal_action_links_enabled: json_bool( + &value, + &["terminal", "action_links_enabled"], + false, + ), + terminal_action_links_matchers: load_action_links_matchers(&value), + search_custom_engines: load_search_engines(&value), + ui_show_remote_stats: json_bool(&value, &["ui", "show_remote_stats"], true), + ui_remote_stats_interval: json_u32(&value, &["ui", "remote_stats_interval"], 3) + .clamp(1, 60), + ui_show_process_manager: json_bool(&value, &["ui", "show_process_manager"], true), + ui_process_manager_interval: json_u32(&value, &["ui", "process_manager_interval"], 5) + .clamp(3, 120), + ui_show_docker_manager: json_bool(&value, &["ui", "show_docker_manager"], true), + ui_docker_manager_interval: json_u32(&value, &["ui", "docker_manager_interval"], 10) + .clamp(3, 120), + ui_quick_cmd_view_mode: normalize_quick_cmd_view_mode(&json_string( + &value, + &["ui", "quick_cmd_view_mode"], + "tile", + )), + ui_quick_cmd_sort_mode: normalize_quick_cmd_sort_mode(&json_string( + &value, + &["ui", "quick_cmd_sort_mode"], + "created", + )), + ui_saved_connections_sort_mode: normalize_saved_connections_sort_mode(&json_string( + &value, + &["ui", "saved_connections_sort_mode"], + "default", + )), + ui_file_explorer_auto_sync_cwd_connection_ids: json_string_vec( + &value, + &["ui", "file_explorer_auto_sync_cwd_connection_ids"], + 256, + ), + ui_file_explorer_favorite_dirs_by_connection_id: json_string_vec_map( + &value, + &["ui", "file_explorer_favorite_dirs_by_connection_id"], + 12, + ), + ui_left_panel_width: json_u32(&value, &["ui", "left_width"], 256).clamp(160, 720), + ui_right_panel_width: json_u32(&value, &["ui", "right_width"], 288).clamp(200, 720), + ui_transfer_height: json_u32(&value, &["ui", "transfer_height"], 180).clamp(60, 600), + ui_quick_cmd_height: json_u32(&value, &["ui", "quick_cmd_height"], 180).clamp(36, 520), + ui_quick_cmd_visible: json_bool(&value, &["ui", "show_quick_cmd_bar"], true), + ui_serial_send_height: json_u32(&value, &["ui", "serial_send_height"], 180) + .clamp(60, 520), + ui_serial_send_visible: json_bool(&value, &["ui", "show_serial_send_panel"], false), + ui_active_left_panel: json_optional_string(&value, &["ui", "active_left_panel"]), + ui_active_right_panel: json_optional_string(&value, &["ui", "active_right_panel"]), + ui_left_panel_collapsed: json_bool(&value, &["ui", "left_panel_collapsed"], false), + ui_right_panel_collapsed: json_bool(&value, &["ui", "right_panel_collapsed"], false), + ui_activity_bar_left_top: { + json_string_vec_with_default( + &value, + &["ui", "activity_bar_layout", "left_top"], + 32, + default_activity_left_top, + ) + }, + ui_activity_bar_left_bottom: { + json_string_vec_with_default( + &value, + &["ui", "activity_bar_layout", "left_bottom"], + 32, + default_activity_left_bottom, + ) + }, + ui_activity_bar_right_top: { + json_string_vec_with_default( + &value, + &["ui", "activity_bar_layout", "right_top"], + 32, + default_activity_right_top, + ) + }, + ui_activity_bar_right_bottom: { + json_string_vec_with_default( + &value, + &["ui", "activity_bar_layout", "right_bottom"], + 32, + default_activity_right_bottom, + ) + }, + ui_activity_bar_show_labels: json_bool( + &value, + &["ui", "activity_bar_layout", "show_labels"], + false, + ), + ui_panel_multi_open: json_bool(&value, &["appearance", "panel_multi_open"], false) + || json_bool(&value, &["ui", "panel_multi_open"], false), + ui_left_open_panels: json_string_vec(&value, &["ui", "left_open_panels"], 32), + ui_right_open_panels: json_string_vec(&value, &["ui", "right_open_panels"], 32), + ui_panel_stack_sizes: json_u32_map(&value, &["ui", "panel_stack_sizes"]), + interaction_copy_on_select: json_bool( + &value, + &["interaction", "copy_on_select"], + false, + ), + interaction_right_click_paste: json_bool( + &value, + &["interaction", "right_click_paste"], + false, + ), + interaction_command_suggestions_enabled: json_bool( + &value, + &["interaction", "command_suggestions_enabled"], + true, + ), + interaction_command_suggestion_min_chars: json_u32( + &value, + &["interaction", "command_suggestion_min_chars"], + 2, + ) + .clamp(1, 500), + interaction_command_suggestion_max_chars: json_u32( + &value, + &["interaction", "command_suggestion_max_chars"], + 64, + ) + .clamp(1, 500), + interaction_word_separators: json_string( + &value, + &["interaction", "word_separators"], + " \t\r\n\"'`~!@#$%^&*()-=+[{]}\\|;:,<.>/?", + ), + interaction_duplicate_session_command_delay_ms: json_u32( + &value, + &["interaction", "duplicate_session_command_delay_ms"], + 1000, + ) + .min(60_000), + interaction_alt_as_meta: json_bool(&value, &["interaction", "alt_as_meta"], false), + interaction_mac_ime_compatibility: json_bool( + &value, + &["interaction", "mac_ime_compatibility"], + true, + ), + interaction_tab_double_click_action: normalize_tab_mouse_action(&json_string( + &value, + &["interaction", "tab_double_click_action"], + "disconnect_session", + )), + interaction_tab_middle_click_action: normalize_tab_mouse_action(&json_string( + &value, + &["interaction", "tab_middle_click_action"], + "rename_tab", + )), + interaction_tab_right_click_action: normalize_tab_mouse_action(&json_string( + &value, + &["interaction", "tab_right_click_action"], + "none", + )), + interaction_default_encoding: normalize_interaction_encoding(&json_string( + &value, + &["interaction", "default_encoding"], + "UTF-8", + )), + host_key_policy: normalize_host_key_policy(&json_string( + &value, + &["security", "host_key_policy"], + "prompt", + )), + transfer_download_path: json_string(&value, &["transfer", "download_path"], ""), + transfer_ask_save_location: json_bool( + &value, + &["transfer", "ask_save_location"], + false, + ), + transfer_duplicate_strategy: normalize_transfer_duplicate_strategy(&json_string( + &value, + &["transfer", "duplicate_strategy"], + "ask", + )), + transfer_editor_type: normalize_transfer_editor_type(&json_string( + &value, + &["transfer", "editor_type"], + "external", + )), + transfer_default_editor: json_string(&value, &["transfer", "default_editor"], ""), + transfer_download_threads: json_u32(&value, &["transfer", "download_threads"], 3) + .clamp(1, 10), + transfer_upload_threads: json_u32(&value, &["transfer", "upload_threads"], 3) + .clamp(1, 10), + transfer_max_retries: json_u32(&value, &["transfer", "max_transfer_retries"], 2) + .min(10), + transfer_buffer_size: json_u32(&value, &["transfer", "transfer_buffer_size"], 32) + .clamp(8, 256), + transfer_default_file_permissions: normalize_transfer_file_permissions(&json_string( + &value, + &["transfer", "default_file_permissions"], + "644", + )), + transfer_preserve_timestamps: json_bool( + &value, + &["transfer", "preserve_timestamps"], + true, + ), + transfer_resume_broken_transfer: json_bool( + &value, + &["transfer", "resume_broken_transfer"], + true, + ), + recording_path: json_string(&value, &["transfer", "recording_path"], ""), + recording_auto_start: json_bool(&value, &["transfer", "recording_auto_start"], false), + recording_include_io_labels: json_bool( + &value, + &["transfer", "recording_include_io_labels"], + true, + ), + recording_include_timestamps: json_bool( + &value, + &["transfer", "recording_include_timestamps"], + true, + ), + recording_memory_limit_bytes: json_u64( + &value, + &["transfer", "recording_memory_limit_bytes"], + 5 * 1024 * 1024, + ), + diagnostics_level: { + let raw = json_string(&value, &["diagnostics", "level"], "info"); + match raw.as_str() { + "warn" | "debug" => raw, + _ => "info".to_string(), + } + }, + diagnostics_retention_days: { + let days = u32::from(json_u16(&value, &["diagnostics", "retention_days"], 7)); + match days { + 3 | 7 | 14 | 30 => days, + _ => 7, + } + }, + startup_restore: json_bool(&value, &["general", "startup_restore"], false), + startup_restore_window_layout: json_bool( + &value, + &["general", "startup_restore_window_layout"], + true, + ), + minimize_to_tray: json_bool(&value, &["general", "minimize_to_tray"], false), + confirm_on_close: json_bool(&value, &["general", "confirm_on_close"], true), + enable_screen_lock: json_bool(&value, &["security", "enable_screen_lock"], false), + idle_lock_minutes: u32::from(json_u16(&value, &["security", "idle_lock_minutes"], 0)), + has_master_password: value + .get("security") + .and_then(|security| security.get("master_password")) + .and_then(|master_password| master_password.as_str()) + .is_some_and(|master_password| !master_password.is_empty()), + keybindings: json_string_map(&value, &["keybindings"]), + }) + } + + pub fn verify_master_password(&self, password: &str) -> Result { + let Some(token) = self.load_encrypted_master_password()? else { + return Ok(true); + }; + let bootstrap = CredentialCrypto::new(self.portable_key_path.clone(), None); + let stored = bootstrap.decrypt_settings_secret(&token)?; + Ok(stored == password) + } + + pub fn save_master_password( + &self, + next_password: Option<&str>, + ) -> Result { + if next_password.is_some_and(str::is_empty) { + return Err(StorageError::InvalidData( + "Master password cannot be empty when enabled".to_string(), + )); + } + + let bootstrap = CredentialCrypto::new(self.portable_key_path.clone(), None); + let current_password = self + .load_encrypted_master_password()? + .map(|token| bootstrap.decrypt_settings_secret(&token)) + .transpose()?; + let rewrapped_master_key = self + .load_master_key_token()? + .map(|token| { + CredentialCrypto::new(self.portable_key_path.clone(), current_password.clone()) + .rewrap_master_key_token(&token, next_password) + }) + .transpose()?; + + let mut value = self.load_settings_value()?; + let encoded_password = next_password + .map(|password| bootstrap.encrypt_settings_secret(password)) + .transpose()?; + set_nested_json_value( + &mut value, + &["security", "master_password"], + encoded_password + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + ); + + let txn = self.db.begin_write()?; + write_json_in_txn(&txn, SETTINGS_TABLE, SETTINGS_DEFAULT, &value)?; + if let Some(token) = rewrapped_master_key.as_deref() { + txn.open_table(META_TABLE)?.insert(META_MASTER_KEY, token)?; + txn.open_table(TEXT_DOCS_TABLE)? + .insert(LEGACY_TEXT_MASTER_KEY, token)?; + } + txn.commit()?; + self.load_app_settings_summary() + } + + pub fn save_host_key_policy(&self, policy: &str) -> Result { + let policy = normalize_host_key_policy(policy); + let mut value = self.load_settings_value()?; + set_nested_json_string(&mut value, &["security", "host_key_policy"], policy); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_recording_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_string( + &mut value, + &["transfer", "recording_path"], + settings.recording_path.clone(), + ); + set_nested_json_value( + &mut value, + &["transfer", "recording_auto_start"], + serde_json::Value::Bool(settings.recording_auto_start), + ); + set_nested_json_value( + &mut value, + &["transfer", "recording_include_io_labels"], + serde_json::Value::Bool(settings.recording_include_io_labels), + ); + set_nested_json_value( + &mut value, + &["transfer", "recording_include_timestamps"], + serde_json::Value::Bool(settings.recording_include_timestamps), + ); + set_nested_json_value( + &mut value, + &["transfer", "recording_memory_limit_bytes"], + serde_json::Value::from(settings.recording_memory_limit_bytes), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_transfer_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_string( + &mut value, + &["transfer", "download_path"], + settings.transfer_download_path.clone(), + ); + set_nested_json_value( + &mut value, + &["transfer", "ask_save_location"], + serde_json::Value::Bool(settings.transfer_ask_save_location), + ); + set_nested_json_string( + &mut value, + &["transfer", "duplicate_strategy"], + normalize_transfer_duplicate_strategy(&settings.transfer_duplicate_strategy), + ); + set_nested_json_string( + &mut value, + &["transfer", "editor_type"], + normalize_transfer_editor_type(&settings.transfer_editor_type), + ); + set_nested_json_string( + &mut value, + &["transfer", "default_editor"], + settings.transfer_default_editor.clone(), + ); + set_nested_json_value( + &mut value, + &["transfer", "download_threads"], + serde_json::Value::from(settings.transfer_download_threads.clamp(1, 10)), + ); + set_nested_json_value( + &mut value, + &["transfer", "upload_threads"], + serde_json::Value::from(settings.transfer_upload_threads.clamp(1, 10)), + ); + set_nested_json_value( + &mut value, + &["transfer", "max_transfer_retries"], + serde_json::Value::from(settings.transfer_max_retries.min(10)), + ); + set_nested_json_value( + &mut value, + &["transfer", "transfer_buffer_size"], + serde_json::Value::from(settings.transfer_buffer_size.clamp(8, 256)), + ); + set_nested_json_string( + &mut value, + &["transfer", "default_file_permissions"], + normalize_transfer_file_permissions(&settings.transfer_default_file_permissions), + ); + set_nested_json_value( + &mut value, + &["transfer", "preserve_timestamps"], + serde_json::Value::Bool(settings.transfer_preserve_timestamps), + ); + set_nested_json_value( + &mut value, + &["transfer", "resume_broken_transfer"], + serde_json::Value::Bool(settings.transfer_resume_broken_transfer), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_file_explorer_favorite_dirs( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_value( + &mut value, + &["ui", "file_explorer_auto_sync_cwd_connection_ids"], + string_vec_json_value(&settings.ui_file_explorer_auto_sync_cwd_connection_ids, 256), + ); + set_nested_json_value( + &mut value, + &["ui", "file_explorer_favorite_dirs_by_connection_id"], + string_vec_map_json_value( + &settings.ui_file_explorer_favorite_dirs_by_connection_id, + 12, + ), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_quick_command_ui_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_string( + &mut value, + &["ui", "quick_cmd_view_mode"], + normalize_quick_cmd_view_mode(&settings.ui_quick_cmd_view_mode), + ); + set_nested_json_string( + &mut value, + &["ui", "quick_cmd_sort_mode"], + normalize_quick_cmd_sort_mode(&settings.ui_quick_cmd_sort_mode), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_ui_layout_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_value( + &mut value, + &["ui", "left_width"], + serde_json::Value::from(settings.ui_left_panel_width.clamp(160, 720)), + ); + set_nested_json_value( + &mut value, + &["ui", "right_width"], + serde_json::Value::from(settings.ui_right_panel_width.clamp(200, 720)), + ); + set_nested_json_value( + &mut value, + &["ui", "transfer_height"], + serde_json::Value::from(settings.ui_transfer_height.clamp(60, 600)), + ); + set_nested_json_value( + &mut value, + &["ui", "quick_cmd_height"], + serde_json::Value::from(settings.ui_quick_cmd_height.clamp(36, 520)), + ); + set_nested_json_value( + &mut value, + &["ui", "show_quick_cmd_bar"], + serde_json::Value::Bool(settings.ui_quick_cmd_visible), + ); + set_nested_json_value( + &mut value, + &["ui", "serial_send_height"], + serde_json::Value::from(settings.ui_serial_send_height.clamp(60, 520)), + ); + set_nested_json_value( + &mut value, + &["ui", "show_serial_send_panel"], + serde_json::Value::Bool(settings.ui_serial_send_visible), + ); + match &settings.ui_active_left_panel { + Some(panel) if !panel.trim().is_empty() => { + set_nested_json_string(&mut value, &["ui", "active_left_panel"], panel.clone()) + } + _ => set_nested_json_value( + &mut value, + &["ui", "active_left_panel"], + serde_json::Value::Null, + ), + } + match &settings.ui_active_right_panel { + Some(panel) if !panel.trim().is_empty() => { + set_nested_json_string(&mut value, &["ui", "active_right_panel"], panel.clone()) + } + _ => set_nested_json_value( + &mut value, + &["ui", "active_right_panel"], + serde_json::Value::Null, + ), + } + set_nested_json_value( + &mut value, + &["ui", "left_panel_collapsed"], + serde_json::Value::Bool(settings.ui_left_panel_collapsed), + ); + set_nested_json_value( + &mut value, + &["ui", "right_panel_collapsed"], + serde_json::Value::Bool(settings.ui_right_panel_collapsed), + ); + set_nested_json_value( + &mut value, + &["ui", "activity_bar_layout", "left_top"], + string_vec_json_value(&settings.ui_activity_bar_left_top, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "activity_bar_layout", "left_bottom"], + string_vec_json_value(&settings.ui_activity_bar_left_bottom, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "activity_bar_layout", "right_top"], + string_vec_json_value(&settings.ui_activity_bar_right_top, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "activity_bar_layout", "right_bottom"], + string_vec_json_value(&settings.ui_activity_bar_right_bottom, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "activity_bar_layout", "show_labels"], + serde_json::Value::Bool(settings.ui_activity_bar_show_labels), + ); + set_nested_json_value( + &mut value, + &["ui", "panel_multi_open"], + serde_json::Value::Bool(settings.ui_panel_multi_open), + ); + // Keep appearance key for Tauri compatibility. + set_nested_json_value( + &mut value, + &["appearance", "panel_multi_open"], + serde_json::Value::Bool(settings.ui_panel_multi_open), + ); + set_nested_json_value( + &mut value, + &["ui", "left_open_panels"], + string_vec_json_value(&settings.ui_left_open_panels, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "right_open_panels"], + string_vec_json_value(&settings.ui_right_open_panels, 32), + ); + set_nested_json_value( + &mut value, + &["ui", "panel_stack_sizes"], + u32_map_json_value(&settings.ui_panel_stack_sizes), + ); + set_nested_json_string( + &mut value, + &["ui", "saved_connections_sort_mode"], + normalize_saved_connections_sort_mode(&settings.ui_saved_connections_sort_mode), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_appearance_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_string(&mut value, &["appearance", "theme"], settings.theme.clone()); + match settings + .background_image_path + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(path) => set_nested_json_string( + &mut value, + &["appearance", "background_image_path"], + path.to_string(), + ), + None => set_nested_json_value( + &mut value, + &["appearance", "background_image_path"], + serde_json::Value::Null, + ), + } + set_nested_json_string( + &mut value, + &["appearance", "background_image_fit"], + settings.background_image_fit.clone(), + ); + set_nested_json_value( + &mut value, + &["appearance", "background_image_opacity"], + serde_json::Value::from(settings.background_image_opacity as f64 / 100.0), + ); + set_nested_json_value( + &mut value, + &["appearance", "background_opacity"], + serde_json::Value::from(settings.background_content_opacity as f64 / 100.0), + ); + set_nested_json_string( + &mut value, + &["appearance", "font_family"], + settings.terminal_font_family.clone(), + ); + set_nested_json_value( + &mut value, + &["appearance", "font_size"], + serde_json::Value::from(settings.terminal_font_size), + ); + set_nested_json_string( + &mut value, + &["appearance", "cursor_style"], + match settings.cursor_style.as_str() { + "underline" | "bar" => settings.cursor_style.clone(), + _ => "block".to_string(), + }, + ); + set_nested_json_value( + &mut value, + &["appearance", "cursor_blink"], + serde_json::Value::from(settings.cursor_blink), + ); + match settings + .terminal_theme + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(theme) => set_nested_json_string( + &mut value, + &["appearance", "terminal_theme"], + theme.to_string(), + ), + None => set_nested_json_value( + &mut value, + &["appearance", "terminal_theme"], + serde_json::Value::Null, + ), + } + let contrast = match settings.minimum_contrast_ratio.as_str() { + "3" => 3.0, + "4.5" => 4.5, + "7" => 7.0, + "21" => 21.0, + _ => 1.0, + }; + set_nested_json_value( + &mut value, + &["appearance", "minimum_contrast_ratio"], + serde_json::Value::from(contrast), + ); + set_nested_json_string( + &mut value, + &["appearance", "ui_font_family"], + if settings.ui_font_family.trim().is_empty() { + "Inter".to_string() + } else { + settings.ui_font_family.clone() + }, + ); + set_nested_json_value( + &mut value, + &["appearance", "ui_font_size"], + serde_json::Value::from(settings.ui_font_size.clamp(12, 24)), + ); + let font_weight = match settings.terminal_font_weight { + 300 | 400 | 500 | 600 | 700 | 800 => settings.terminal_font_weight, + _ => 400, + }; + let font_weight_bold = match settings.terminal_font_weight_bold { + 300 | 400 | 500 | 600 | 700 | 800 => settings.terminal_font_weight_bold, + _ => 700, + }; + set_nested_json_value( + &mut value, + &["appearance", "font_weight"], + serde_json::Value::from(font_weight), + ); + set_nested_json_value( + &mut value, + &["appearance", "font_weight_bold"], + serde_json::Value::from(font_weight_bold), + ); + set_nested_json_string( + &mut value, + &["terminal", "x11_display"], + settings.x11_display.clone(), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_terminal_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_string( + &mut value, + &["terminal", "x11_display"], + settings.x11_display.clone(), + ); + set_nested_json_value( + &mut value, + &["terminal", "scrollback_lines"], + serde_json::Value::from(settings.terminal_scrollback_lines.clamp(100, 100_000)), + ); + set_nested_json_value( + &mut value, + &["terminal", "keep_alive_interval"], + serde_json::Value::from(settings.terminal_keep_alive_interval.min(600)), + ); + set_nested_json_value( + &mut value, + &["terminal", "hardware_acceleration"], + serde_json::Value::Bool(settings.terminal_hardware_acceleration), + ); + set_nested_json_value( + &mut value, + &["terminal", "show_workspace_padding"], + serde_json::Value::Bool(settings.terminal_show_workspace_padding), + ); + set_nested_json_value( + &mut value, + &["terminal", "show_line_numbers"], + serde_json::Value::Bool(settings.terminal_show_line_numbers), + ); + set_nested_json_value( + &mut value, + &["terminal", "show_timestamps"], + serde_json::Value::Bool(settings.terminal_show_timestamps), + ); + set_nested_json_value( + &mut value, + &["terminal", "show_timestamp_milliseconds"], + serde_json::Value::Bool(settings.terminal_show_timestamp_milliseconds), + ); + set_nested_json_value( + &mut value, + &["terminal", "show_multi_line_paste_dialog"], + serde_json::Value::Bool(settings.terminal_show_multi_line_paste_dialog), + ); + set_nested_json_value( + &mut value, + &["terminal", "paste_image_as_path"], + serde_json::Value::Bool(settings.terminal_paste_image_as_path), + ); + set_nested_json_value( + &mut value, + &["terminal", "low_latency_mode"], + serde_json::Value::Bool(settings.terminal_low_latency_mode), + ); + set_nested_json_value( + &mut value, + &["terminal", "action_links_enabled"], + serde_json::Value::Bool(settings.terminal_action_links_enabled), + ); + set_nested_json_value( + &mut value, + &["terminal", "action_links_matchers"], + serde_json::json!({ + "ipv4": settings.terminal_action_links_matchers.ipv4, + "archive": settings.terminal_action_links_matchers.archive, + "host_port": settings.terminal_action_links_matchers.host_port, + }), + ); + set_nested_json_value( + &mut value, + &["search", "custom_engines"], + search_engines_to_json(&settings.search_custom_engines), + ); + set_nested_json_value( + &mut value, + &["ui", "show_remote_stats"], + serde_json::Value::Bool(settings.ui_show_remote_stats), + ); + set_nested_json_value( + &mut value, + &["ui", "remote_stats_interval"], + serde_json::Value::from(settings.ui_remote_stats_interval.clamp(1, 60)), + ); + set_nested_json_value( + &mut value, + &["ui", "show_process_manager"], + serde_json::Value::Bool(settings.ui_show_process_manager), + ); + set_nested_json_value( + &mut value, + &["ui", "process_manager_interval"], + serde_json::Value::from(settings.ui_process_manager_interval.clamp(3, 120)), + ); + set_nested_json_value( + &mut value, + &["ui", "show_docker_manager"], + serde_json::Value::Bool(settings.ui_show_docker_manager), + ); + set_nested_json_value( + &mut value, + &["ui", "docker_manager_interval"], + serde_json::Value::from(settings.ui_docker_manager_interval.clamp(3, 120)), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_interaction_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + let min_chars = settings + .interaction_command_suggestion_min_chars + .clamp(1, 500); + let max_chars = settings + .interaction_command_suggestion_max_chars + .clamp(min_chars, 500); + set_nested_json_value( + &mut value, + &["interaction", "copy_on_select"], + serde_json::Value::Bool(settings.interaction_copy_on_select), + ); + set_nested_json_value( + &mut value, + &["interaction", "right_click_paste"], + serde_json::Value::Bool(settings.interaction_right_click_paste), + ); + set_nested_json_value( + &mut value, + &["interaction", "command_suggestions_enabled"], + serde_json::Value::Bool(settings.interaction_command_suggestions_enabled), + ); + set_nested_json_value( + &mut value, + &["interaction", "command_suggestion_min_chars"], + serde_json::Value::from(min_chars), + ); + set_nested_json_value( + &mut value, + &["interaction", "command_suggestion_max_chars"], + serde_json::Value::from(max_chars), + ); + set_nested_json_string( + &mut value, + &["interaction", "word_separators"], + settings.interaction_word_separators.clone(), + ); + set_nested_json_value( + &mut value, + &["interaction", "duplicate_session_command_delay_ms"], + serde_json::Value::from( + settings + .interaction_duplicate_session_command_delay_ms + .min(60_000), + ), + ); + set_nested_json_value( + &mut value, + &["interaction", "alt_as_meta"], + serde_json::Value::Bool(settings.interaction_alt_as_meta), + ); + set_nested_json_value( + &mut value, + &["interaction", "mac_ime_compatibility"], + serde_json::Value::Bool(settings.interaction_mac_ime_compatibility), + ); + set_nested_json_string( + &mut value, + &["interaction", "tab_double_click_action"], + normalize_tab_mouse_action(&settings.interaction_tab_double_click_action), + ); + set_nested_json_string( + &mut value, + &["interaction", "tab_middle_click_action"], + normalize_tab_mouse_action(&settings.interaction_tab_middle_click_action), + ); + set_nested_json_string( + &mut value, + &["interaction", "tab_right_click_action"], + normalize_tab_mouse_action(&settings.interaction_tab_right_click_action), + ); + set_nested_json_string( + &mut value, + &["interaction", "default_encoding"], + normalize_interaction_encoding(&settings.interaction_default_encoding), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_general_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_value( + &mut value, + &["general", "startup_restore"], + serde_json::Value::Bool(settings.startup_restore), + ); + set_nested_json_value( + &mut value, + &["general", "startup_restore_window_layout"], + serde_json::Value::Bool(settings.startup_restore_window_layout), + ); + set_nested_json_value( + &mut value, + &["general", "minimize_to_tray"], + serde_json::Value::Bool(settings.minimize_to_tray), + ); + set_nested_json_value( + &mut value, + &["general", "confirm_on_close"], + serde_json::Value::Bool(settings.confirm_on_close), + ); + // UI language lives under ui.language (Tauri UiConfig.language). + let language = match settings.language.as_str() { + "zh-CN" | "zh" => "zh-CN", + "zh-TW" => "zh-TW", + "ja" => "ja", + _ => "en", + }; + set_nested_json_string(&mut value, &["ui", "language"], language.to_string()); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_diagnostics_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + let level = match settings.diagnostics_level.as_str() { + "warn" | "debug" => settings.diagnostics_level.as_str(), + _ => "info", + }; + let retention = match settings.diagnostics_retention_days { + 3 | 7 | 14 | 30 => settings.diagnostics_retention_days, + _ => 7, + }; + set_nested_json_string(&mut value, &["diagnostics", "level"], level.to_string()); + set_nested_json_value( + &mut value, + &["diagnostics", "retention_days"], + serde_json::Value::from(retention), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn load_open_tabs(&self) -> Result, StorageError> { + let value = self.load_settings_value()?; + let Some(raw) = json_path(&value, &["ui", "open_tabs"]) else { + return Ok(Vec::new()); + }; + if raw.is_null() { + return Ok(Vec::new()); + } + match serde_json::from_value::>(raw.clone()) { + Ok(tabs) => Ok(tabs), + Err(_) => Ok(Vec::new()), + } + } + + pub fn save_open_tabs( + &self, + tabs: &[crate::models::RestorableOpenTab], + ) -> Result<(), StorageError> { + let mut value = self.load_settings_value()?; + let encoded = serde_json::to_value(tabs)?; + set_nested_json_value(&mut value, &["ui", "open_tabs"], encoded); + self.save_settings_value(&value)?; + Ok(()) + } + + pub fn load_terminal_window_layout( + &self, + ) -> Result, StorageError> { + let value = self.load_settings_value()?; + let Some(raw) = json_path(&value, &["ui", "terminal_window_layout"]) else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + match serde_json::from_value(raw.clone()) { + Ok(node) => Ok(Some(node)), + Err(_) => Ok(None), + } + } + + pub fn save_terminal_window_layout( + &self, + layout: Option<&crate::models::RestorableTerminalWindowNode>, + ) -> Result<(), StorageError> { + let mut value = self.load_settings_value()?; + let encoded = match layout { + Some(node) => serde_json::to_value(node)?, + None => serde_json::Value::Null, + }; + set_nested_json_value(&mut value, &["ui", "terminal_window_layout"], encoded); + self.save_settings_value(&value)?; + Ok(()) + } + + pub fn load_workspace_pane_layout( + &self, + ) -> Result, StorageError> { + let value = self.load_settings_value()?; + let Some(raw) = json_path(&value, &["ui", "workspace_pane_layout"]) else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + match serde_json::from_value(raw.clone()) { + Ok(node) => Ok(Some(node)), + Err(_) => Ok(None), + } + } + + pub fn save_workspace_pane_layout( + &self, + layout: Option<&crate::models::RestorableWorkspacePaneNode>, + ) -> Result<(), StorageError> { + let mut value = self.load_settings_value()?; + let encoded = match layout { + Some(node) => serde_json::to_value(node)?, + None => serde_json::Value::Null, + }; + set_nested_json_value(&mut value, &["ui", "workspace_pane_layout"], encoded); + self.save_settings_value(&value)?; + Ok(()) + } + + pub fn save_screen_lock_settings( + &self, + settings: &AppSettingsSummary, + ) -> Result { + let mut value = self.load_settings_value()?; + set_nested_json_value( + &mut value, + &["security", "enable_screen_lock"], + serde_json::Value::Bool(settings.enable_screen_lock), + ); + set_nested_json_value( + &mut value, + &["security", "idle_lock_minutes"], + serde_json::Value::from(settings.idle_lock_minutes), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } + + pub fn save_keybindings( + &self, + keybindings: &HashMap, + ) -> Result { + let mut value = self.load_settings_value()?; + let mut object = serde_json::Map::new(); + for (id, keys) in keybindings { + let id = id.trim(); + let keys = keys.trim(); + if !id.is_empty() && !keys.is_empty() { + object.insert(id.to_string(), serde_json::Value::String(keys.to_string())); + } + } + set_nested_json_value( + &mut value, + &["keybindings"], + serde_json::Value::Object(object), + ); + self.save_settings_value(&value)?; + self.load_app_settings_summary() + } +} + +fn default_activity_left_bottom() -> Vec { + vec!["syncBackupHistory".to_string(), "settings".to_string()] +} + +fn default_activity_left_top() -> Vec { + vec![ + "fileExplorer".to_string(), + "network".to_string(), + "securityAuth".to_string(), + ] +} + +fn default_activity_right_bottom() -> Vec { + vec![ + "quickCmdBar".to_string(), + "serialSend".to_string(), + "recording".to_string(), + "lock".to_string(), + ] +} + +fn default_activity_right_top() -> Vec { + vec![ + "savedConnections".to_string(), + "aiAssistant".to_string(), + "activeSessions".to_string(), + "commandHistory".to_string(), + "resourceMonitor".to_string(), + "processManager".to_string(), + "dockerManager".to_string(), + ] +} + +fn json_optional_string(value: &serde_json::Value, path: &[&str]) -> Option { + json_path(value, path) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn json_string(value: &serde_json::Value, path: &[&str], fallback: &str) -> String { + json_path(value, path) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn json_string_map(value: &serde_json::Value, path: &[&str]) -> HashMap { + json_path(value, path) + .and_then(serde_json::Value::as_object) + .map(|object| { + object + .iter() + .filter_map(|(key, value)| { + value + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(|value| (key.clone(), value.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +fn json_string_vec_map( + value: &serde_json::Value, + path: &[&str], + limit_per_key: usize, +) -> HashMap> { + json_path(value, path) + .and_then(serde_json::Value::as_object) + .map(|object| { + object + .iter() + .filter_map(|(key, value)| { + let values = value + .as_array()? + .iter() + .filter_map(|entry| { + entry + .as_str() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + }) + .fold(Vec::::new(), |mut values, entry| { + if !values.iter().any(|existing| existing == &entry) { + values.push(entry); + } + values + }) + .into_iter() + .take(limit_per_key) + .collect::>(); + (!values.is_empty()).then(|| (key.clone(), values)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn json_string_vec_with_default( + value: &serde_json::Value, + path: &[&str], + limit: usize, + default: fn() -> Vec, +) -> Vec { + if json_path(value, path).is_some() { + json_string_vec(value, path, limit) + } else { + default() + } +} + +fn json_u16(value: &serde_json::Value, path: &[&str], fallback: u16) -> u16 { + let Some(value) = json_path(value, path) else { + return fallback; + }; + if let Some(number) = value.as_u64() { + return number.try_into().unwrap_or(fallback); + } + if let Some(number) = value.as_f64() { + if number.is_finite() && number >= 0.0 && number <= f64::from(u16::MAX) { + return number.round() as u16; + } + } + fallback +} + +fn json_u32(value: &serde_json::Value, path: &[&str], fallback: u32) -> u32 { + let Some(value) = json_path(value, path) else { + return fallback; + }; + if let Some(number) = value.as_u64() { + return number.try_into().unwrap_or(fallback); + } + if let Some(number) = value.as_f64() { + if number.is_finite() && number >= 0.0 && number <= f64::from(u32::MAX) { + return number.round() as u32; + } + } + fallback +} + +fn json_u32_map(value: &serde_json::Value, path: &[&str]) -> HashMap { + let mut map = HashMap::new(); + let Some(object) = json_path(value, path).and_then(|value| value.as_object()) else { + return map; + }; + for (key, raw) in object { + let number = raw + .as_u64() + .or_else(|| raw.as_f64().map(|value| value.round() as u64)); + if let Some(number) = number { + if number > 0 { + map.insert(key.clone(), number.min(u32::MAX as u64) as u32); + } + } + } + map +} + +fn json_u64(value: &serde_json::Value, path: &[&str], fallback: u64) -> u64 { + let Some(value) = json_path(value, path) else { + return fallback; + }; + if let Some(number) = value.as_u64() { + return number; + } + if let Some(number) = value.as_f64() { + if number.is_finite() && number >= 0.0 && number <= u64::MAX as f64 { + return number.round() as u64; + } + } + fallback +} + +fn load_action_links_matchers(value: &serde_json::Value) -> crate::ActionLinksMatcherSettings { + let defaults = crate::ActionLinksMatcherSettings::default(); + let Some(obj) = json_path(value, &["terminal", "action_links_matchers"]) else { + return defaults; + }; + crate::ActionLinksMatcherSettings { + ipv4: obj + .get("ipv4") + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.ipv4), + archive: obj + .get("archive") + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.archive), + host_port: obj + .get("host_port") + .and_then(|v| v.as_bool()) + .unwrap_or(defaults.host_port), + } +} + +fn load_search_engines(value: &serde_json::Value) -> Vec { + let Some(arr) = json_path(value, &["search", "custom_engines"]).and_then(|v| v.as_array()) + else { + return default_search_engines(); + }; + let mut engines = Vec::new(); + for item in arr { + if !item.is_object() { + continue; + } + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let url_template = item + .get("url_template") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let show_in_menu = item + .get("show_in_menu") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let icon = item + .get("icon") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + engines.push(SearchEngineConfig { + name, + url_template, + icon, + show_in_menu, + }); + } + engines +} + +fn normalize_host_key_policy(policy: &str) -> String { + match policy { + "strict" | "accept" | "prompt" => policy.to_string(), + _ => "prompt".to_string(), + } +} + +fn normalize_interaction_encoding(encoding: &str) -> String { + if encoding.eq_ignore_ascii_case("gbk") { + "GBK".to_string() + } else { + "UTF-8".to_string() + } +} + +fn normalize_quick_cmd_sort_mode(value: &str) -> String { + match value.trim() { + "created" | "name" | "useCount" => value.trim().to_string(), + _ => "created".to_string(), + } +} + +fn normalize_quick_cmd_view_mode(value: &str) -> String { + match value.trim() { + "list" | "compact" | "tile" => value.trim().to_string(), + _ => "tile".to_string(), + } +} + +fn normalize_saved_connections_sort_mode(value: &str) -> String { + match value.trim() { + "name-asc" | "name-desc" => value.trim().to_string(), + _ => "default".to_string(), + } +} + +fn normalize_tab_mouse_action(action: &str) -> String { + match action { + "none" | "rename_tab" | "copy_tab_name" | "copy_server_ip" | "duplicate_session" + | "multiplex_ssh" | "reconnect_session" | "disconnect_session" | "close_tab" => { + action.to_string() + } + _ => "none".to_string(), + } +} + +fn normalize_transfer_duplicate_strategy(strategy: &str) -> String { + match strategy { + "ask" | "overwrite" | "skip" | "rename" => strategy.to_string(), + _ => "ask".to_string(), + } +} + +fn normalize_transfer_editor_type(editor_type: &str) -> String { + match editor_type { + "external" | "internal" => editor_type.to_string(), + _ => "external".to_string(), + } +} + +fn normalize_transfer_file_permissions(value: &str) -> String { + let trimmed = value + .trim() + .trim_start_matches("0o") + .trim_start_matches('0'); + let normalized = if trimmed.is_empty() { "0" } else { trimmed }; + if (3..=4).contains(&normalized.len()) && normalized.chars().all(|ch| matches!(ch, '0'..='7')) { + normalized.to_string() + } else { + "644".to_string() + } +} + +fn search_engines_to_json(engines: &[SearchEngineConfig]) -> serde_json::Value { + serde_json::Value::Array( + engines + .iter() + .map(|engine| { + serde_json::json!({ + "name": engine.name, + "url_template": engine.url_template, + "icon": engine.icon, + "show_in_menu": engine.show_in_menu, + }) + }) + .collect(), + ) +} + +fn set_nested_json_string(value: &mut serde_json::Value, path: &[&str], new_value: String) { + set_nested_json_value(value, path, serde_json::Value::String(new_value)); +} + +fn string_vec_json_value(values: &[String], limit: usize) -> serde_json::Value { + serde_json::Value::Array( + values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .fold(Vec::::new(), |mut values, value| { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } + values + }) + .into_iter() + .take(limit) + .map(serde_json::Value::String) + .collect(), + ) +} + +fn string_vec_map_json_value( + map: &HashMap>, + limit_per_key: usize, +) -> serde_json::Value { + let object = map + .iter() + .filter_map(|(key, values)| { + let key = key.trim(); + if key.is_empty() { + return None; + } + let values = values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .fold(Vec::::new(), |mut values, value| { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } + values + }) + .into_iter() + .take(limit_per_key) + .map(serde_json::Value::String) + .collect::>(); + (!values.is_empty()).then(|| (key.to_string(), serde_json::Value::Array(values))) + }) + .collect(); + serde_json::Value::Object(object) +} + +fn u32_map_json_value(map: &HashMap) -> serde_json::Value { + let mut object = serde_json::Map::new(); + for (key, value) in map { + if *value > 0 { + object.insert(key.clone(), serde_json::json!(*value)); + } + } + serde_json::Value::Object(object) +}