From 95a4c787103a359d8f1801b62ad023c3156e1768 Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Thu, 25 Jun 2026 14:13:56 +0800 Subject: [PATCH] fix: preserve active style across settings saves --- .../app/src-tauri/src/commands/settings.rs | 151 ++++++++++++++++-- .../src-tauri/src/persistence/preferences.rs | 54 ++++++- openless-all/app/src-tauri/src/types.rs | 10 ++ 3 files changed, 204 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 03d96fb0..4febefdd 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -13,6 +13,14 @@ pub fn get_default_style_system_prompts() -> StyleSystemPrompts { pub(crate) trait SettingsWriter { fn read_settings(&self) -> UserPreferences; fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>; + fn write_settings_preserving_current_style_preferences( + &self, + mut prefs: UserPreferences, + ) -> Result<(), String> { + let current = self.read_settings(); + prefs.preserve_style_preferences_from(¤t); + self.write_settings(prefs) + } fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String>; fn refresh_dictation_hotkey(&self); fn refresh_qa_hotkey(&self); @@ -32,6 +40,15 @@ impl SettingsWriter for Coordinator { self.prefs().set(prefs).map_err(|e| e.to_string()) } + fn write_settings_preserving_current_style_preferences( + &self, + prefs: UserPreferences, + ) -> Result<(), String> { + self.prefs() + .set_preserving_current_style_preferences(prefs) + .map_err(|e| e.to_string()) + } + fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> { self.sync_active_asr_provider_to_vault(provider) } @@ -74,6 +91,13 @@ impl SettingsWriter for Arc { (**self).write_settings(prefs) } + fn write_settings_preserving_current_style_preferences( + &self, + prefs: UserPreferences, + ) -> Result<(), String> { + (**self).write_settings_preserving_current_style_preferences(prefs) + } + fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> { (**self).sync_active_asr_provider(provider) } @@ -128,16 +152,18 @@ pub(crate) fn persist_settings( if active_asr_provider_changed { coord.sync_active_asr_provider(&active_asr_provider)?; } - if let Err(error) = coord.write_settings(prefs.clone()) { + if let Err(error) = coord.write_settings_preserving_current_style_preferences(prefs.clone()) { if active_asr_provider_changed { if let Err(rollback_error) = coord.sync_active_asr_provider(&previous.active_asr_provider) { - coord.write_settings(prefs).map_err(|roll_forward_error| { - format!( - "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" - ) - })?; + coord + .write_settings_preserving_current_style_preferences(prefs) + .map_err(|roll_forward_error| { + format!( + "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" + ) + })?; } else { return Err(error); } @@ -185,7 +211,8 @@ pub fn set_settings( // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 - persist_settings(&*coord, prefs.clone())?; + persist_settings(&*coord, prefs)?; + let prefs = coord.prefs().get(); #[cfg(target_os = "android")] coord.apply_android_overlay_settings_change(&remote_prev, &prefs); // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 @@ -228,7 +255,8 @@ pub fn set_settings( let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); - persist_settings(&*coord, prefs.clone())?; + persist_settings(&*coord, prefs)?; + let prefs = coord.prefs().get(); #[cfg(target_os = "android")] coord.apply_android_overlay_settings_change(&previous, &prefs); let _ = app.emit("prefs:changed", &prefs); @@ -236,6 +264,109 @@ pub fn set_settings( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[derive(Default)] + struct RaceSettingsWriter { + reads: Mutex>, + saved: Mutex>, + } + + impl SettingsWriter for RaceSettingsWriter { + fn read_settings(&self) -> UserPreferences { + let mut reads = self.reads.lock().unwrap(); + if reads.is_empty() { + return self.saved.lock().unwrap().clone().unwrap_or_default(); + } + reads.remove(0) + } + + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { + *self.saved.lock().unwrap() = Some(prefs); + Ok(()) + } + + fn sync_active_asr_provider(&self, _provider: &str) -> Result<(), String> { + Ok(()) + } + + fn refresh_dictation_hotkey(&self) {} + + fn refresh_qa_hotkey(&self) {} + + fn refresh_combo_hotkey(&self) {} + + fn refresh_translation_hotkey(&self) {} + + fn refresh_switch_style_hotkey(&self) {} + + fn refresh_open_app_hotkey(&self) {} + + fn refresh_coding_agent_hotkey(&self) {} + } + + #[test] + fn settings_save_preserves_current_style_preferences_before_write() { + let packs = crate::types::builtin_style_packs(); + let current = UserPreferences { + default_mode: PolishMode::Light, + active_style_pack_id: builtin_style_pack_id(PolishMode::Light).to_string(), + ..UserPreferences::default() + }; + let mut stale_settings_payload = UserPreferences { + default_mode: PolishMode::Formal, + active_style_pack_id: builtin_style_pack_id(PolishMode::Formal).to_string(), + ..UserPreferences::default() + }; + + stale_settings_payload.preserve_style_preferences_from(¤t); + sync_style_pack_preferences(&mut stale_settings_payload, &packs); + + assert_eq!( + stale_settings_payload.active_style_pack_id, + builtin_style_pack_id(PolishMode::Light) + ); + assert_eq!(stale_settings_payload.default_mode, PolishMode::Light); + } + + #[test] + fn persist_settings_keeps_style_change_that_lands_before_write() { + let active_before_request = UserPreferences { + default_mode: PolishMode::Formal, + active_style_pack_id: builtin_style_pack_id(PolishMode::Formal).to_string(), + ..UserPreferences::default() + }; + let active_before_write = UserPreferences { + default_mode: PolishMode::Light, + active_style_pack_id: builtin_style_pack_id(PolishMode::Light).to_string(), + ..UserPreferences::default() + }; + let stale_payload = UserPreferences { + default_mode: PolishMode::Formal, + active_style_pack_id: builtin_style_pack_id(PolishMode::Formal).to_string(), + microphone_device_name: "External Mic".to_string(), + ..UserPreferences::default() + }; + let writer = RaceSettingsWriter { + reads: Mutex::new(vec![active_before_request, active_before_write]), + saved: Mutex::new(None), + }; + + persist_settings(&writer, stale_payload).unwrap(); + + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + assert_eq!( + saved.active_style_pack_id, + builtin_style_pack_id(PolishMode::Light) + ); + assert_eq!(saved.default_mode, PolishMode::Light); + assert_eq!(saved.microphone_device_name, "External Mic"); + } +} + // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── // // 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化 @@ -265,8 +396,8 @@ pub fn set_update_channel( return Ok(()); } prefs.update_channel = channel; - persist_settings(&*coord, prefs.clone())?; - let _ = app.emit("prefs:changed", &prefs); + persist_settings(&*coord, prefs)?; + let _ = app.emit("prefs:changed", &coord.prefs().get()); Ok(()) } diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 1f3fbb4a..13a600d3 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -97,11 +97,25 @@ impl PreferencesStore { *guard = prefs; Ok(()) } + + pub fn set_preserving_current_style_preferences( + &self, + mut prefs: UserPreferences, + ) -> Result<()> { + let mut guard = self.state.lock(); + prefs.preserve_style_preferences_from(&guard); + let json = serde_json::to_vec_pretty(&prefs).context("encode prefs failed")?; + atomic_write(&self.path, &json)?; + *guard = prefs; + Ok(()) + } } #[cfg(test)] mod tests { - use super::read_preferences; + use super::{read_preferences, PreferencesStore}; + use crate::types::{builtin_style_pack_id, PolishMode, UserPreferences}; + use parking_lot::Mutex; use std::fs; use std::path::PathBuf; @@ -142,4 +156,42 @@ mod tests { let _ = fs::remove_dir_all(&tmp); } + + #[test] + fn set_preserving_current_style_preferences_keeps_store_style_fields() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + let path = tmp.join("preferences.json"); + let current = UserPreferences { + default_mode: PolishMode::Light, + active_style_pack_id: "local.light-cleanup".to_string(), + ..UserPreferences::default() + }; + let store = PreferencesStore { + path, + state: Mutex::new(current), + }; + let incoming = UserPreferences { + default_mode: PolishMode::Formal, + active_style_pack_id: builtin_style_pack_id(PolishMode::Formal).to_string(), + microphone_device_name: "External Mic".to_string(), + ..UserPreferences::default() + }; + + store + .set_preserving_current_style_preferences(incoming) + .expect("save prefs"); + + let saved = store.get(); + assert_eq!(saved.default_mode, PolishMode::Light); + assert_eq!(saved.active_style_pack_id, "local.light-cleanup"); + assert_eq!(saved.microphone_device_name, "External Mic"); + let saved_on_disk = read_preferences(&store.path).expect("read saved prefs"); + assert_eq!(saved_on_disk.default_mode, PolishMode::Light); + assert_eq!(saved_on_disk.active_style_pack_id, "local.light-cleanup"); + assert_eq!(saved_on_disk.microphone_device_name, "External Mic"); + + let _ = fs::remove_dir_all(&tmp); + } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 1d40b72c..21ce266d 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -807,6 +807,16 @@ pub struct UserPreferences { pub android_overlay_size_dp: u32, } +impl UserPreferences { + pub(crate) fn preserve_style_preferences_from(&mut self, current: &Self) { + self.default_mode = current.default_mode; + self.enabled_modes = current.enabled_modes.clone(); + self.active_style_pack_id = current.active_style_pack_id.clone(); + self.style_system_prompts = current.style_system_prompts.clone(); + self.custom_style_prompts = current.custom_style_prompts.clone(); + } +} + fn default_local_asr_model() -> String { "qwen3-asr-0.6b".into() }