Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 141 additions & 10 deletions openless-all/app/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current);
self.write_settings(prefs)
}
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String>;
fn refresh_dictation_hotkey(&self);
fn refresh_qa_hotkey(&self);
Expand All @@ -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)
}
Expand Down Expand Up @@ -74,6 +91,13 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
(**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)
}
Expand Down Expand Up @@ -128,16 +152,18 @@ pub(crate) fn persist_settings<T: SettingsWriter>(
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);
}
Expand Down Expand Up @@ -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,必须在主线程上跑。
Expand Down Expand Up @@ -228,14 +255,118 @@ 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);
let _ = app.emit_to("main", "prefs:changed", &prefs);
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;

#[derive(Default)]
struct RaceSettingsWriter {
reads: Mutex<Vec<UserPreferences>>,
saved: Mutex<Option<UserPreferences>>,
}

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(&current);
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:保持热键兜底归一化
Expand Down Expand Up @@ -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(())
}

Expand Down
54 changes: 53 additions & 1 deletion openless-all/app/src-tauri/src/persistence/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
10 changes: 10 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down