From 5078994c3a23f45d92e2a45c2406d27d2c13cf60 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 4 Aug 2026 14:07:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(settings):=20=E7=83=AD=E9=94=AE?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E4=B8=8D=E5=86=8D=E6=8B=92=E7=BB=9D=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E2=80=94=E2=80=94=E5=8D=87=E7=BA=A7=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E5=86=B2=E7=AA=81=E9=BB=98=E8=AE=A4=E9=94=AE?= =?UTF-8?q?=20+=20=E4=BF=9D=E5=AD=98=E8=87=AA=E5=8A=A8=E5=8C=96=E8=A7=A3?= =?UTF-8?q?=EF=BC=88#904=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #904:1.3.15 新增的选区润色默认键(Windows=右 Alt)在旧配置升级时被 无条件注入。若用户录音键恰为右 Alt,则偏好文件出现持久冲突,之后任何 set_settings(含切「自动」录音方式)都被 reject_hotkey_collisions 整体 拒绝,前端又静默吞错——表现为界面显示已切换、重启后回退按住说话。 修复: - types.rs:旧文件升级时若默认注入的选区润色键与录音键相同,改为停用 新功能而非写入冲突值; - commands/settings.rs:保存兜底 reconcile_hotkey_collisions——热键 冲突/非法不再拒绝整份保存,按核心度自动恢复旧值或停用冲突的非核心键 (录音键永不参与调整),其余设置照常落盘并写日志; - HotkeySettingsContext.tsx:保存失败回滚乐观状态并弹出失败提示,杜绝 「假保存」; - 回归测试:升级迁移停用冲突键、历史冲突下切模式仍可保存、非核心键 重叠/非法自动化解;4 个旧「保存被拒」测试同步改为新兜底语义。 验证:新增测试全过;cargo test --lib 924 通过、2 失败均与改动前基线 一致(cooldown 时序抖动 + Windows 符号链接权限环境测试);前端 tsc --noEmit 与 frontend-test-runner 全过。 --- .../app/src-tauri/src/commands/mod.rs | 52 ++--- .../app/src-tauri/src/commands/settings.rs | 211 +++++++++++++++++- openless-all/app/src-tauri/src/types.rs | 39 +++- .../app/src/state/HotkeySettingsContext.tsx | 18 +- 4 files changed, 284 insertions(+), 36 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 9dc6c6e4a..86a6c6e69 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -867,7 +867,7 @@ mod tests { } #[test] - fn persist_settings_rejects_less_computer_dictation_overlap() { + fn persist_settings_reconciles_less_computer_dictation_overlap_and_saves() { let writer = FakeSettingsWriter::default(); let binding = ShortcutBinding { primary: "LeftControl".into(), @@ -879,11 +879,11 @@ mod tests { ..Default::default() }; - assert_eq!( - persist_settings(&writer, prefs), - Err("Less Computer 快捷键不能和听写快捷键相同".into()) - ); - assert!(writer.saved.lock().unwrap().is_none()); + // 兜底(#904):冲突不再拒绝整份保存,较低优先级的 Less Computer 键被停用。 + persist_settings(&writer, prefs).unwrap(); + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + assert_eq!(saved.dictation_hotkey.primary, "LeftControl"); + assert!(saved.coding_agent_voice_hotkey.is_none()); } #[test] @@ -1249,7 +1249,7 @@ mod tests { } #[test] - fn persist_settings_rejects_dictation_translation_overlap() { + fn persist_settings_reconciles_dictation_translation_overlap_and_saves() { let writer = FakeSettingsWriter::default(); let binding = ShortcutBinding { primary: "RightControl".into(), @@ -1261,15 +1261,15 @@ mod tests { ..Default::default() }; - assert_eq!( - persist_settings(&writer, prefs), - Err("翻译快捷键不能和听写快捷键相同".into()) - ); - assert!(writer.saved.lock().unwrap().is_none()); + // 兜底(#904):冲突不再拒绝整份保存,翻译键恢复为旧默认 Shift。 + persist_settings(&writer, prefs).unwrap(); + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + assert_eq!(saved.dictation_hotkey.primary, "RightControl"); + assert_eq!(saved.translation_hotkey.primary, "Shift"); } #[test] - fn persist_settings_rejects_translation_switch_style_overlap() { + fn persist_settings_reconciles_translation_switch_style_overlap_and_saves() { let writer = FakeSettingsWriter::default(); let binding = ShortcutBinding { primary: "T".into(), @@ -1281,15 +1281,16 @@ mod tests { ..Default::default() }; - assert_eq!( - persist_settings(&writer, prefs), - Err("切换风格快捷键不能和翻译快捷键相同".into()) - ); - assert!(writer.saved.lock().unwrap().is_none()); + // 兜底(#904):冲突不再拒绝整份保存,切换风格键恢复为旧默认。 + persist_settings(&writer, prefs).unwrap(); + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + let defaults = UserPreferences::default(); + assert_eq!(saved.translation_hotkey.primary, "T"); + assert_eq!(saved.switch_style_hotkey, defaults.switch_style_hotkey); } #[test] - fn persist_settings_rejects_switch_style_open_app_overlap() { + fn persist_settings_reconciles_switch_style_open_app_overlap_and_saves() { let writer = FakeSettingsWriter::default(); let binding = ShortcutBinding { primary: "K".into(), @@ -1297,15 +1298,16 @@ mod tests { }; let prefs = UserPreferences { switch_style_hotkey: Some(binding.clone()), - open_app_hotkey: Some(binding), + open_app_hotkey: Some(binding.clone()), ..Default::default() }; - assert_eq!( - persist_settings(&writer, prefs), - Err("打开应用快捷键不能和切换风格快捷键相同".into()) - ); - assert!(writer.saved.lock().unwrap().is_none()); + // 兜底(#904):冲突不再拒绝整份保存,打开应用键恢复为旧默认。 + persist_settings(&writer, prefs).unwrap(); + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + let defaults = UserPreferences::default(); + assert_eq!(saved.switch_style_hotkey, Some(binding)); + assert_eq!(saved.open_app_hotkey, defaults.open_app_hotkey); } #[test] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index d3445c50f..273d60328 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -144,6 +144,114 @@ impl SettingsWriter for Arc { } } +/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。 +#[derive(Clone, Copy, PartialEq, Eq)] +enum NonCoreHotkey { + Translation, + Qa, + SwitchStyle, + OpenApp, + SelectionPolish, + LessComputer, +} + +impl NonCoreHotkey { + fn get(&self, prefs: &UserPreferences) -> Option { + match self { + Self::Translation => Some(prefs.translation_hotkey.clone()), + Self::Qa => prefs.qa_hotkey.clone(), + Self::SwitchStyle => prefs.switch_style_hotkey.clone(), + Self::OpenApp => prefs.open_app_hotkey.clone(), + Self::SelectionPolish => prefs.selection_polish_hotkey.clone(), + Self::LessComputer => prefs.coding_agent_voice_hotkey.clone(), + } + } + + fn set(&self, prefs: &mut UserPreferences, value: Option) { + match self { + // translation 是必填键,None 表示恢复失败时保持旧值不动。 + Self::Translation => { + if let Some(value) = value { + prefs.translation_hotkey = value; + } + } + Self::Qa => prefs.qa_hotkey = value, + Self::SwitchStyle => prefs.switch_style_hotkey = value, + Self::OpenApp => prefs.open_app_hotkey = value, + Self::SelectionPolish => prefs.selection_polish_hotkey = value, + Self::LessComputer => prefs.coding_agent_voice_hotkey = value, + } + } +} + +/// 单个非核心热键是否非法。与 `reject_non_dictation_side_specific_shortcuts` +/// 的逐键校验保持精确一致,避免把非冲突键一并停用。 +fn non_core_hotkey_invalid(key: NonCoreHotkey, binding: &ShortcutBinding) -> bool { + if crate::shortcut_binding::reject_side_specific_non_dictation(binding).is_err() { + return true; + } + match key { + NonCoreHotkey::SelectionPolish => { + crate::shortcut_binding::validate_binding(binding).is_err() + || reject_bare_shift_dictation_shortcut(binding).is_err() + } + _ => false, + } +} + +/// 保存兜底(#904):热键冲突不能把整份设置挡在保存之外。 +/// +/// 按核心度从高到低处理每个非核心热键:凡与更高优先级键重叠、或本身非法 +/// (侧特定修饰键等)的,恢复为旧值;旧值仍冲突/非法(历史遗留,例如 1.3.15 +/// 升级注入的选区润色默认键与录音键重复)时停用(translation 回退默认 Shift)。 +/// 返回被调整的键数量。dictation 永远保留,不参与调整。 +pub(crate) fn reconcile_hotkey_collisions( + prefs: &mut UserPreferences, + previous: &UserPreferences, +) -> usize { + // 处理顺序 = 核心度从高到低:处理某项时,更高优先级的键已定稿。 + const ORDER: [NonCoreHotkey; 6] = [ + NonCoreHotkey::Translation, + NonCoreHotkey::Qa, + NonCoreHotkey::SwitchStyle, + NonCoreHotkey::OpenApp, + NonCoreHotkey::SelectionPolish, + NonCoreHotkey::LessComputer, + ]; + let mut higher: Vec = vec![prefs.dictation_hotkey.clone()]; + let mut adjusted = 0; + for key in ORDER { + let Some(current) = key.get(prefs) else { + continue; + }; + let collides = higher + .iter() + .any(|held| crate::shortcut_binding::bindings_overlap(held, ¤t)); + if !collides && !non_core_hotkey_invalid(key, ¤t) { + higher.push(current); + continue; + } + let fallback = key.get(previous).filter(|candidate| { + !higher + .iter() + .any(|held| crate::shortcut_binding::bindings_overlap(held, candidate)) + && !non_core_hotkey_invalid(key, candidate) + }); + // translation 不能停用:旧值仍冲突/非法时回退到默认 Shift(不会与任何键重叠)。 + let resolved = if key == NonCoreHotkey::Translation && fallback.is_none() { + Some(UserPreferences::default().translation_hotkey.clone()) + } else { + fallback + }; + key.set(prefs, resolved.clone()); + adjusted += 1; + if let Some(value) = resolved { + higher.push(value); + } + } + adjusted +} + pub(crate) fn persist_settings( coord: &T, prefs: UserPreferences, @@ -163,7 +271,17 @@ pub(crate) fn persist_settings_with_keyboard_apply( let mut previous = coord.read_settings(); sync_dictation_hotkey_legacy_fields(&mut previous); sync_dictation_hotkey_legacy_fields(&mut prefs); - reject_hotkey_collisions(&prefs)?; + if let Err(collision_error) = reject_hotkey_collisions(&prefs) { + // 兜底(#904):热键冲突(含历史遗留的重复键)不能拒绝整份设置保存。 + // 自动把冲突/非法的非核心热键恢复旧值或停用,其余设置照常落盘。 + let adjusted = reconcile_hotkey_collisions(&mut prefs, &previous); + reject_hotkey_collisions(&prefs).map_err(|leftover| { + format!("{collision_error}; 自动化解 {adjusted} 项后仍无法通过校验: {leftover}") + })?; + log::warn!( + "[settings] 热键冲突已自动化解(调整 {adjusted} 项)后保存: {collision_error}" + ); + } let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey; let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode; let qa_changed = previous.qa_hotkey != prefs.qa_hotkey; @@ -448,6 +566,97 @@ mod tests { assert_eq!(saved.default_mode, PolishMode::Light); assert_eq!(saved.microphone_device_name, "External Mic"); } + + #[test] + fn reconcile_clears_legacy_dictation_selection_polish_duplication() { + // #904 历史遗留:1.3.15 升级注入的选区润色默认键(右 Alt)与录音键相同。 + let prefs = UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: crate::types::HotkeyTrigger::RightAlt, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }, + dictation_hotkey: ShortcutBinding { + primary: "RightAlt".into(), + modifiers: vec![], + }, + selection_polish_hotkey: Some(ShortcutBinding { + primary: "RightAlt".into(), + modifiers: vec![], + }), + ..Default::default() + }; + let mut next = prefs.clone(); + + let adjusted = reconcile_hotkey_collisions(&mut next, &prefs); + + assert!(adjusted >= 1); + assert!(next.selection_polish_hotkey.is_none()); + assert!(reject_hotkey_collisions(&next).is_ok()); + } + + #[test] + fn persist_settings_reconciles_legacy_collision_and_still_saves_mode() { + // #904 复现:历史冲突存在时,用户切「自动」必须能保存成功, + // 冲突的选区润色键被停用,而不是整份设置被拒。 + let collision = UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: crate::types::HotkeyTrigger::RightAlt, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }, + dictation_hotkey: ShortcutBinding { + primary: "RightAlt".into(), + modifiers: vec![], + }, + selection_polish_hotkey: Some(ShortcutBinding { + primary: "RightAlt".into(), + modifiers: vec![], + }), + ..Default::default() + }; + let mut next = collision.clone(); + next.hotkey.mode = crate::types::HotkeyMode::Auto; + let writer = RaceSettingsWriter { + reads: Mutex::new(vec![collision]), + saved: Mutex::new(None), + }; + + persist_settings_with_keyboard_apply(&writer, next, |_| Ok(())).unwrap(); + + let saved = writer.saved.lock().unwrap().clone().expect("prefs saved"); + assert_eq!(saved.hotkey.mode, crate::types::HotkeyMode::Auto); + assert!(saved.selection_polish_hotkey.is_none()); + } + + #[test] + fn reconcile_resolves_non_core_overlap_and_invalid_side_specific_hotkey() { + // QA 与翻译键相同:较低优先级的 QA 恢复旧值,旧值仍冲突则停用。 + let previous = UserPreferences { + qa_hotkey: Some(ShortcutBinding { + primary: "E".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }), + ..Default::default() + }; + let mut next = previous.clone(); + next.qa_hotkey = Some(ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }); + // 侧特定修饰键对非 dictation 非法(SIDE_SPECIFIC_NON_DICTATION_MSG)。 + next.translation_hotkey = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + + let adjusted = reconcile_hotkey_collisions(&mut next, &previous); + + assert!(adjusted >= 2); + assert_eq!(next.qa_hotkey, previous.qa_hotkey); + assert_eq!(next.translation_hotkey, previous.translation_hotkey); + assert!(reject_hotkey_collisions(&next).is_ok()); + } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7dad6dbe9..f32029e54 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1324,15 +1324,20 @@ impl<'de> Deserialize<'de> for UserPreferences { let mut selection_polish_hotkey = wire .selection_polish_hotkey .unwrap_or_else(default_selection_polish_hotkey); - if cfg!(target_os = "windows") - && selection_polish_hotkey_was_missing - && is_right_control_modifier_shortcut(&dictation_hotkey) - { - // Old settings cannot distinguish the historic default from a - // deliberate Right Ctrl choice. Preserve the existing dictation - // workflow and leave the new action disabled rather than silently - // changing a user's shortcut. - selection_polish_hotkey = None; + if selection_polish_hotkey_was_missing { + // 1.3.15 新增的选区润色默认键(Windows = 右 Alt)不能抢占/顶掉用户已有按键: + // - 老用户从未自定义录音键(仍为历史默认 Right Control):默认关闭新功能, + // 避免升级后右 Alt 被全局热键占用影响既有使用习惯; + // - 默认键与录音键相同(如录音键自定义为右 Alt):同样关闭,否则升级后 + // 任何设置保存都会被热键冲突校验整体拒绝,改动全部丢失(#904)。 + let legacy_default_user = cfg!(target_os = "windows") + && is_right_control_modifier_shortcut(&dictation_hotkey); + let default_taken_by_dictation = selection_polish_hotkey + .as_ref() + .is_some_and(|binding| binding == &dictation_hotkey); + if legacy_default_user || default_taken_by_dictation { + selection_polish_hotkey = None; + } } let streaming_insert_default_migrated = wire.streaming_insert_default_migrated; let streaming_insert = if streaming_insert_default_migrated { @@ -3061,6 +3066,22 @@ mod tests { assert_eq!(prefs.dictation_hotkey.primary, "RightControl"); } + #[cfg(target_os = "windows")] + #[test] + fn legacy_right_alt_dictation_upgrade_disables_selection_polish_instead_of_colliding() { + // #904:录音键自定义为右 Alt 的旧配置升级时,默认注入的选区润色键(右 Alt) + // 与录音键相同会形成持久冲突,把后续所有设置保存挡死。迁移必须改为停用新功能。 + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "rightAlt", "mode": "hold", "keys": null }, + "dictationHotkey": { "primary": "RightAlt", "modifiers": [] } + }"#, + ) + .unwrap(); + assert!(prefs.selection_polish_hotkey.is_none()); + assert_eq!(prefs.dictation_hotkey.primary, "RightAlt"); + } + #[cfg(target_os = "windows")] #[test] fn new_preferences_keep_the_existing_dictation_default_and_use_right_alt_for_selection_polish() { diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index bfe635200..6527fd3cc 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -21,6 +21,7 @@ import type { } from "../lib/types" import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n" import { applyThemeFromPreference } from "../lib/themeMode" +import { emitSaved } from "../lib/savedEvent" interface HotkeySettingsContextValue { prefs: UserPreferences | null @@ -48,6 +49,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null) const persistQueueRef = useRef>(Promise.resolve()) const latestPrefsRef = useRef(null) + const persistedPrefsRef = useRef(null) const refresh = useCallback(async () => { setLoading(true) @@ -60,6 +62,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { let nextError: string | null = null if (prefsResult.status === "fulfilled") { latestPrefsRef.current = prefsResult.value + persistedPrefsRef.current = prefsResult.value setPrefs(prefsResult.value) applyThemeFromPreference(prefsResult.value.themeMode ?? "system") } else { @@ -120,6 +123,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { const nextPrefs = event.payload if (!nextPrefs) return latestPrefsRef.current = nextPrefs + persistedPrefsRef.current = nextPrefs setPrefs(nextPrefs) applyThemeFromPreference(nextPrefs.themeMode ?? "system") }, @@ -196,7 +200,19 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { if (resolved === current) return setPrefs(resolved) latestPrefsRef.current = resolved - await queueSetSettings(resolved) + try { + await queueSetSettings(resolved) + persistedPrefsRef.current = resolved + } catch (error) { + // 兜底(#904):保存失败必须回滚乐观状态并可见, + // 不能出现界面显示已切换、重启后回退的“假保存”。 + const fallback = persistedPrefsRef.current ?? current + latestPrefsRef.current = fallback + setPrefs(fallback) + console.error("[hotkey-settings] save failed, rolled back", error) + emitSaved("failed", errorMessage(error)) + throw error + } }, [queueSetSettings], ) From fd2485f7046fd0a49fa76adb654e5dc55ff1dfe5 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 4 Aug 2026 15:36:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(settings):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=8C=89=E9=87=8D=E5=8F=A0=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E5=86=B2=E7=AA=81=E7=9A=84=E9=80=89=E5=8C=BA?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E9=BB=98=E8=AE=A4=E9=94=AE=EF=BC=88#904?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/types.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f32029e54..4ca820d4a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1328,13 +1328,16 @@ impl<'de> Deserialize<'de> for UserPreferences { // 1.3.15 新增的选区润色默认键(Windows = 右 Alt)不能抢占/顶掉用户已有按键: // - 老用户从未自定义录音键(仍为历史默认 Right Control):默认关闭新功能, // 避免升级后右 Alt 被全局热键占用影响既有使用习惯; - // - 默认键与录音键相同(如录音键自定义为右 Alt):同样关闭,否则升级后 - // 任何设置保存都会被热键冲突校验整体拒绝,改动全部丢失(#904)。 + // - 默认键与录音键重叠(字符串可能不等但物理同键,如 legacy rightAlt + // 派生出 RightOption 而默认是 RightAlt):同样关闭,否则升级后任何 + // 设置保存都会被热键冲突校验整体拒绝,改动全部丢失(#904)。 let legacy_default_user = cfg!(target_os = "windows") && is_right_control_modifier_shortcut(&dictation_hotkey); let default_taken_by_dictation = selection_polish_hotkey .as_ref() - .is_some_and(|binding| binding == &dictation_hotkey); + .is_some_and(|binding| { + crate::shortcut_binding::bindings_overlap(binding, &dictation_hotkey) + }); if legacy_default_user || default_taken_by_dictation { selection_polish_hotkey = None; } @@ -3082,6 +3085,22 @@ mod tests { assert_eq!(prefs.dictation_hotkey.primary, "RightAlt"); } + #[cfg(target_os = "windows")] + #[test] + fn legacy_right_alt_trigger_upgrade_disables_selection_polish_by_overlap() { + // #904 变体:旧文件没有 dictationHotkey,只带 legacy hotkey.trigger=rightAlt, + // 派生出的录音键 primary 是 "RightOption",与默认注入的 "RightAlt" 字符串不相等 + // 但物理同键(bindings_overlap=true)。迁移必须按重叠判定,不能按 == 字符串比较。 + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "rightAlt", "mode": "hold", "keys": null } + }"#, + ) + .unwrap(); + assert!(prefs.selection_polish_hotkey.is_none()); + assert_eq!(prefs.dictation_hotkey.primary, "RightOption"); + } + #[cfg(target_os = "windows")] #[test] fn new_preferences_keep_the_existing_dictation_default_and_use_right_alt_for_selection_polish() {