fix(settings): 热键冲突不再拒绝保存——升级迁移停用冲突默认键 + 保存自动化解(#904) (#907)

* fix(settings): 热键冲突不再拒绝保存——升级迁移停用冲突默认键 + 保存自动化解(#904)

#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 全过。

* fix(settings): 升级迁移按重叠判定停用冲突的选区润色默认键(#904)
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-04 17:02:24 +08:00
1 parent d0e190f20c
commit 12de016c32
4 files changed
+303 -36

No files matched your search

+27 -25
View File
@@ -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]
@@ -144,6 +144,114 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
}
}
/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。
#[derive(Clone, Copy, PartialEq, Eq)]
enum NonCoreHotkey {
Translation,
Qa,
SwitchStyle,
OpenApp,
SelectionPolish,
LessComputer,
}
impl NonCoreHotkey {
fn get(&self, prefs: &UserPreferences) -> Option<ShortcutBinding> {
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<ShortcutBinding>) {
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<ShortcutBinding> = 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, &current));
if !collides && !non_core_hotkey_invalid(key, &current) {
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<T: SettingsWriter>(
coord: &T,
prefs: UserPreferences,
@@ -163,7 +271,17 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
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) ───────────────────────────
+49 -9
View File
@@ -1324,15 +1324,23 @@ 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 被全局热键占用影响既有使用习惯;
// - 默认键与录音键重叠(字符串可能不等但物理同键,如 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| {
crate::shortcut_binding::bindings_overlap(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 +3069,38 @@ 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 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() {
@@ -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<string | null>(null)
const persistQueueRef = useRef<Promise<void>>(Promise.resolve())
const latestPrefsRef = useRef<UserPreferences | null>(null)
const persistedPrefsRef = useRef<UserPreferences | null>(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],
)