diff --git a/Cargo.lock b/Cargo.lock index 636ebac..65d042e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,6 +363,7 @@ dependencies = [ "tracing-subscriber", "uuid", "walkdir", + "winreg 0.52.0", "winres", "zip", ] @@ -9643,6 +9644,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/Cargo.toml b/Cargo.toml index 8d288ca..599d377 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ sha2 = "0.10" [dev-dependencies] libc = "0.2" +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" + [package.metadata.deb] maintainer = "ashell contributors" license-file = ["LICENSE"] diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 1889b95..e014b77 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1425,9 +1425,7 @@ impl Ashell { this.recording_action = None; this.keybind_error = None; this.config.set_key_binding(&action, &new_key); - if let Err(err) = this.config.save() { - tracing::error!("failed to save key binding: {err:#}"); - } + this.save_preferences_background(); cx.notify(); }); } @@ -1600,7 +1598,7 @@ impl Ashell { .checked(current_style == crate::session::config::TitleBarStyle::Native) .on_click(window.listener_for(&view, |this, _, _, cx| { this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Native); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ) @@ -1609,7 +1607,7 @@ impl Ashell { .checked(current_style == crate::session::config::TitleBarStyle::Integrated) .on_click(window.listener_for(&view, |this, _, _, cx| { this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Integrated); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ); @@ -1844,7 +1842,7 @@ impl Ashell { .checked(view.read(cx).config.right_click_copy_paste()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_right_click_copy_paste(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .into_any_element() @@ -1863,7 +1861,7 @@ impl Ashell { .checked(view.read(cx).config.keyword_highlight()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_keyword_highlight(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .into_any_element() @@ -1882,7 +1880,7 @@ impl Ashell { .checked(view.read(cx).config.lock_layout()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_lock_layout(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .into_any_element() @@ -1919,7 +1917,7 @@ impl Ashell { .checked(pos == "Bottom") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Bottom"); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ) @@ -1928,7 +1926,7 @@ impl Ashell { .checked(pos == "Sidebar") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Sidebar"); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ) @@ -1937,7 +1935,7 @@ impl Ashell { .checked(pos == "Hidden") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Hidden"); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ); @@ -2109,7 +2107,7 @@ impl Ashell { .checked(view.read(cx).config.use_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_use_proxy(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .into_any_element() @@ -2128,7 +2126,7 @@ impl Ashell { .checked(view.read(cx).config.read_env_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_read_env_proxy(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .into_any_element() @@ -2197,7 +2195,7 @@ impl Ashell { this.config.set_global_proxy_port(port); this.config.set_global_proxy_user(user); this.config.set_global_proxy_password(password); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) ) diff --git a/src/app/mod.rs b/src/app/mod.rs index bc16a90..79ed734 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -327,6 +327,8 @@ pub(crate) struct Ashell { pub(crate) hovered_url: Option, pub(crate) cmd_ctrl_pressed: bool, pub(crate) _subscriptions: Vec, + pub(crate) save_lock: std::sync::Arc>, + pub(crate) save_latest_seq: std::sync::Arc, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -718,6 +720,8 @@ impl Ashell { hovered_url: None, cmd_ctrl_pressed: false, _subscriptions, + save_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), + save_latest_seq: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), }; this.apply_theme_preferences(window, cx); @@ -784,9 +788,29 @@ impl Ashell { cx.notify(); } + pub(crate) fn save_preferences_background(&mut self) { + let local_config = self.config.cache.clone(); + let config_store = self.config.clone(); + let latest_seq = self.save_latest_seq.clone(); + let current_seq = latest_seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let save_lock = self.save_lock.clone(); + + self.runtime.spawn(async move { + let _guard = save_lock.lock().await; + if current_seq < latest_seq.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + let _ = tokio::task::spawn_blocking(move || { + if let Err(err) = config_store.save_merged_preferences(local_config) { + tracing::error!("failed to save merged preferences in background: {err:#}"); + } + }) + .await; + }); + } + pub(crate) fn start_event_pump(&self, cx: &mut Context) { cx.spawn(async move |this, cx| { - let mut idle_frames = 0u32; let mut last_blink_time = std::time::Instant::now(); loop { cx.background_executor() @@ -808,16 +832,9 @@ impl Ashell { >= std::time::Duration::from_millis(600); if changed || system_sampled || blink_due { cx.notify(); - idle_frames = 0; if blink_due { last_blink_time = now; } - } else { - idle_frames += 1; - if idle_frames >= 60 { - cx.notify(); - idle_frames = 0; - } } }) .is_err() @@ -1331,8 +1348,9 @@ impl Ashell { }; let size = bounds.size; if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 { - tracing::info!("[ui] saving layout state..."); - let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); + self.save_latest_seq + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut config = self.config.clone(); let saved_bounds = match current_bounds { gpui::WindowBounds::Fullscreen(b) => { crate::session::config::SavedWindowBounds::Fullscreen { diff --git a/src/app/theme.rs b/src/app/theme.rs index 3d09263..0dde1b8 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -126,9 +126,7 @@ impl Ashell { } rust_i18n::set_locale(&active_locale); gpui_component::set_locale(&active_locale); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save language preferences: {err:#}"); - } + self.save_preferences_background(); window.refresh(); cx.notify(); } @@ -168,8 +166,6 @@ impl Ashell { self.light_theme_name.to_string(), self.dark_theme_name.to_string(), ); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save theme preferences: {err:#}"); - } + self.save_preferences_background(); } } diff --git a/src/app/ui.rs b/src/app/ui.rs index 86e52af..da7e34f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -111,7 +111,7 @@ impl Ashell { } self.config .set_sftp_panel_minimized(self.sftp_panel_minimized); - let _ = self.config.save(); + self.save_preferences_background(); cx.notify(); } @@ -293,7 +293,7 @@ impl Ashell { .on_click(cx.listener(|this, checked, _, cx| { this.show_hidden_files = *checked; this.config.set_show_hidden_files(*checked); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })), ) @@ -1593,7 +1593,7 @@ impl Ashell { .on_click(cx.listener(|this, _, _, cx| { this.sidebar_collapsed = true; this.config.set_sidebar_collapsed(true); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })), ) @@ -1833,7 +1833,7 @@ impl Ashell { .on_click(cx.listener(|this, _, _, cx| { this.sidebar_collapsed = false; this.config.set_sidebar_collapsed(false); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })), ), @@ -2797,7 +2797,7 @@ impl Render for Ashell { .on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| { this.sidebar_collapsed = !this.sidebar_collapsed; this.config.set_sidebar_collapsed(this.sidebar_collapsed); - let _ = this.config.save(); + this.save_preferences_background(); cx.notify(); })) .on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| { diff --git a/src/session/config.rs b/src/session/config.rs index c1e936c..5785df9 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -1,4 +1,4 @@ -use std::{fs, path::PathBuf}; +use std::{fs, path::PathBuf, sync::OnceLock}; use anyhow::{Context, Result}; use argon2::Argon2; @@ -364,9 +364,10 @@ impl Default for ConfigFile { } } +#[derive(Clone)] pub struct ConfigStore { - path: PathBuf, - cache: ConfigFile, + pub(crate) path: PathBuf, + pub(crate) cache: ConfigFile, } impl ConfigStore { @@ -828,6 +829,65 @@ impl ConfigStore { Ok(()) } + + pub fn save_merged_preferences(&self, local_config: ConfigFile) -> Result<()> { + if self.path.as_os_str().is_empty() { + return Ok(()); + } + let hardware_uuid = get_hardware_uuid(); + + let mut disk_config = if self.path.exists() { + if let Ok(raw_bytes) = fs::read(&self.path) { + match decrypt_config(&raw_bytes, &hardware_uuid) { + Ok(loaded) => loaded, + Err(_) => serde_json::from_slice::(&raw_bytes) + .unwrap_or_else(|_| self.cache.clone()), + } + } else { + self.cache.clone() + } + } else { + self.cache.clone() + }; + + // Merge UI preference fields + disk_config.follow_system_theme = local_config.follow_system_theme; + disk_config.theme_mode = local_config.theme_mode; + disk_config.light_theme_name = local_config.light_theme_name; + disk_config.dark_theme_name = local_config.dark_theme_name; + disk_config.locale = local_config.locale; + disk_config.terminal_font_size = local_config.terminal_font_size; + disk_config.ui_font_size = local_config.ui_font_size; + disk_config.right_click_copy_paste = local_config.right_click_copy_paste; + disk_config.keyword_highlight = local_config.keyword_highlight; + disk_config.ui_font_family = local_config.ui_font_family; + disk_config.terminal_font_family = local_config.terminal_font_family; + disk_config.title_bar_style = local_config.title_bar_style; + disk_config.cursor_style = local_config.cursor_style; + disk_config.window_bounds = local_config.window_bounds; + disk_config.workspace_panels = local_config.workspace_panels; + disk_config.body_panels = local_config.body_panels; + disk_config.show_hidden_files = local_config.show_hidden_files; + disk_config.lock_layout = local_config.lock_layout; + disk_config.monitoring_position = local_config.monitoring_position; + disk_config.sidebar_collapsed = local_config.sidebar_collapsed; + disk_config.sftp_panel_minimized = local_config.sftp_panel_minimized; + + let encrypted_bytes = encrypt_config(&disk_config, &hardware_uuid)?; + fs::write(&self.path, encrypted_bytes) + .with_context(|| format!("failed to write {}", self.path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) { + perms.set_mode(0o600); + let _ = fs::set_permissions(&self.path, perms); + } + } + + Ok(()) + } } pub trait ProxyStream: @@ -839,8 +899,6 @@ impl String { - #[cfg(target_os = "macos")] - { - if let Ok(output) = std::process::Command::new("ioreg") - .args(&["-rd1", "-c", "IOPlatformExpertDevice"]) - .output() - { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("IOPlatformUUID") { - if let Some(uuid) = line.split('"').nth(3) { - let uuid = uuid.trim().to_string(); - if !uuid.is_empty() { - return uuid; +static HARDWARE_UUID_CACHE: OnceLock = OnceLock::new(); + +pub fn get_hardware_uuid() -> String { + HARDWARE_UUID_CACHE + .get_or_init(|| { + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("ioreg") + .args(&["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("IOPlatformUUID") { + if let Some(uuid) = line.split('"').nth(3) { + let uuid = uuid.trim().to_string(); + if !uuid.is_empty() { + return uuid; + } + } } } } } - } - } - #[cfg(target_os = "linux")] - { - if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") { - let uuid = uuid.trim().to_string(); - if !uuid.is_empty() { - return uuid; + #[cfg(target_os = "linux")] + { + if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") { + let uuid = uuid.trim().to_string(); + if !uuid.is_empty() { + return uuid; + } + } + if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { + let id = id.trim().to_string(); + if !id.is_empty() { + return id; + } + } + if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { + let id = id.trim().to_string(); + if !id.is_empty() { + return id; + } + } } - } - if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { - let id = id.trim().to_string(); - if !id.is_empty() { - return id; - } - } - if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { - let id = id.trim().to_string(); - if !id.is_empty() { - return id; - } - } - } - #[cfg(target_os = "windows")] - { - if let Ok(output) = std::process::Command::new("reg") - .args(&[ - "query", - "HKLM\\SOFTWARE\\Microsoft\\Cryptography", - "/v", - "MachineGuid", - ]) - .output() - { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("MachineGuid") { - if let Some(guid) = line.split_whitespace().last() { + #[cfg(target_os = "windows")] + { + use winreg::RegKey; + use winreg::enums::HKEY_LOCAL_MACHINE; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + if let Ok(subkey) = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography") { + if let Ok(guid) = subkey.get_value::("MachineGuid") { let guid = guid.trim().to_string(); if !guid.is_empty() { return guid; @@ -1098,23 +1152,10 @@ fn get_hardware_uuid() -> String { } } } - } - if let Ok(output) = std::process::Command::new("wmic") - .args(&["csproduct", "get", "uuid"]) - .output() - { - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().collect(); - if lines.len() >= 2 { - let uuid = lines[1].trim().to_string(); - if !uuid.is_empty() { - return uuid; - } - } - } - } - "ashell-default-hardware-uuid-fallback".to_string() + "ashell-default-hardware-uuid-fallback".to_string() + }) + .clone() } fn encrypt_config(config: &ConfigFile, password: &str) -> Result> { @@ -1207,4 +1248,57 @@ mod tests { // Decrypt with wrong password should fail assert!(decrypt_config(&encrypted, "wrong-password").is_err()); } + + #[test] + fn test_save_merged_preferences() { + let temp_dir = std::env::temp_dir(); + let path = temp_dir.join(format!("ashell-test-config-{}.json", Uuid::new_v4())); + let mut store = ConfigStore { + path: path.clone(), + cache: ConfigFile::default(), + }; + + let session = Session { + id: "test-session-id".to_string(), + name: "Test Session".to_string(), + host: "1.2.3.4".to_string(), + port: 22, + user: "root".to_string(), + auth: AuthMethod::Password, + password: "pwd".to_string(), + private_key_path: String::new(), + private_key_inline: String::new(), + passphrase: String::new(), + last_used: None, + proxy_type: String::new(), + proxy_host: String::new(), + proxy_port: None, + proxy_user: String::new(), + proxy_password: String::new(), + protocol: "ssh".to_string(), + baud_rate: 115200, + }; + store.cache.sessions.push(session.clone()); + store.save().unwrap(); + + let mut local_config = ConfigFile::default(); + local_config.ui_font_size = 18.0; + local_config.terminal_font_size = 20.0; + local_config.show_hidden_files = true; + + store.save_merged_preferences(local_config).unwrap(); + + let loaded_bytes = fs::read(&path).unwrap(); + let decrypted = decrypt_config(&loaded_bytes, &get_hardware_uuid()).unwrap(); + + assert_eq!(decrypted.ui_font_size, 18.0); + assert_eq!(decrypted.terminal_font_size, 20.0); + assert!(decrypted.show_hidden_files); + + assert_eq!(decrypted.sessions.len(), 1); + assert_eq!(decrypted.sessions[0].name, "Test Session"); + assert_eq!(decrypted.sessions[0].host, "1.2.3.4"); + + let _ = fs::remove_file(&path); + } } diff --git a/src/session/mod.rs b/src/session/mod.rs index 6a47015..8b53de6 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -371,9 +371,7 @@ impl Ashell { pub(crate) fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0); self.config.set_terminal_font_size(self.terminal_font_size); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save terminal font size: {err:#}"); - } + self.save_preferences_background(); self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into(); cx.notify(); } @@ -381,9 +379,7 @@ impl Ashell { pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context) { self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0); self.config.set_ui_font_size(self.ui_font_size); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save UI font size: {err:#}"); - } + self.save_preferences_background(); Theme::global_mut(cx).font_size = px(self.ui_font_size); self.status = format!("UI font size: {:.0}px", self.ui_font_size).into(); cx.notify(); @@ -397,9 +393,7 @@ impl Ashell { ) { self.ui_font_family = family.into(); self.config.set_ui_font_family(family); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save UI font family: {err:#}"); - } + self.save_preferences_background(); crate::app::theme::set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family); cx.notify(); window.refresh(); @@ -408,9 +402,7 @@ impl Ashell { pub(crate) fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context) { self.terminal_font_family = family.into(); self.config.set_terminal_font_family(family); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save terminal font family: {err:#}"); - } + self.save_preferences_background(); cx.notify(); } @@ -421,15 +413,13 @@ impl Ashell { ) { self.cursor_style = style; self.config.set_cursor_style(style); - if let Err(err) = self.config.save() { - tracing::warn!("failed to save cursor style: {err:#}"); - } + self.save_preferences_background(); cx.notify(); } pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context) { self.config.set_layout_state(None, None, None); - let _ = self.config.save(); + self.save_preferences_background(); self.is_layout_reset = true; self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default());