diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index bb64a38b..28e35069 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub fn invoke_handler( window::get_search_window_state, shortcut::register_global_shortcut, shortcut::get_shortcut_status, + shortcut::set_search_surface_shortcuts, clipboard::read_clipboard_payload, clipboard::consume_shortcut_auto_paste_payload, clipboard::write_clipboard_text, diff --git a/apps/desktop/src-tauri/src/commands/shortcut.rs b/apps/desktop/src-tauri/src/commands/shortcut.rs index defcc0b0..7784e139 100644 --- a/apps/desktop/src-tauri/src/commands/shortcut.rs +++ b/apps/desktop/src-tauri/src/commands/shortcut.rs @@ -15,3 +15,10 @@ pub fn register_global_shortcut( pub fn get_shortcut_status() -> (bool, Option) { crate::core::system::shortcut::get_shortcut_status() } + +#[tauri::command] +pub fn set_search_surface_shortcuts( + entries: Vec, +) -> Result<(), String> { + crate::core::system::shortcut::set_search_surface_shortcuts(entries) +} diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index 83baa11e..f7dde53e 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -13,6 +13,29 @@ use tauri_plugin_global_shortcut::{ static CURRENT_SHORTCUT: Mutex> = Mutex::new(None); static REGISTRATION_STATUS: Mutex<(bool, Option)> = Mutex::new((false, None)); +static SEARCH_SURFACE_SHORTCUTS: Mutex> = Mutex::new(Vec::new()); + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchSurfaceShortcutEntry { + pub action_id: String, + pub shortcut: String, +} + +#[derive(Debug, Clone)] +struct SearchSurfaceShortcut { + action_id: String, + shortcut: String, + parsed: Shortcut, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchSurfaceCommand { + pub action_id: String, + pub shortcut: String, + pub source: &'static str, +} /// 先异步跳出 WM_HOTKEY 回调栈,再把搜索窗口切换投递回 Tauri 主事件循环。 fn schedule_search_window_toggle(app_handle: AppHandle) { @@ -49,20 +72,22 @@ pub fn register_global_shortcut( ) -> Result<(), String> { let new_shortcut = parse_shortcut(&shortcut)?; - // 注销旧快捷键 - if let Ok(current) = CURRENT_SHORTCUT.lock() { - if let Some(old_shortcut) = *current { - let _ = app.global_shortcut().unregister(old_shortcut); + let old_shortcut = CURRENT_SHORTCUT.lock().ok().and_then(|current| *current); + if let Some(old_shortcut) = old_shortcut { + if let Err(error) = app.global_shortcut().unregister(old_shortcut) { + let message = format!("Failed to unregister previous shortcut: {}", error); + if let Ok(mut status) = REGISTRATION_STATUS.lock() { + *status = (true, Some(message.clone())); + } + return Err(message); } } - // 尝试注册新快捷键 let result = app .global_shortcut() .register(new_shortcut) .map_err(|e| format!("Failed to register shortcut: {}", e)); - // 更新状态 match result { Ok(_) => { if let Ok(mut current) = CURRENT_SHORTCUT.lock() { @@ -73,7 +98,27 @@ pub fn register_global_shortcut( } } Err(ref e) => { - if let Ok(mut status) = REGISTRATION_STATUS.lock() { + if let Some(old_shortcut) = old_shortcut { + match app.global_shortcut().register(old_shortcut) { + Ok(_) => { + if let Ok(mut status) = REGISTRATION_STATUS.lock() { + *status = (false, None); + } + } + Err(restore_error) => { + let message = format!( + "{}; failed to restore previous shortcut: {}", + e, restore_error + ); + if let Ok(mut current) = CURRENT_SHORTCUT.lock() { + *current = None; + } + if let Ok(mut status) = REGISTRATION_STATUS.lock() { + *status = (true, Some(message)); + } + } + } + } else if let Ok(mut status) = REGISTRATION_STATUS.lock() { *status = (true, Some(e.clone())); } } @@ -89,6 +134,167 @@ pub fn get_shortcut_status() -> (bool, Option) { .unwrap_or((false, None)) } +pub fn set_search_surface_shortcuts( + entries: Vec, +) -> Result<(), String> { + let mut parsed_entries = Vec::with_capacity(entries.len()); + let mut parse_errors = Vec::new(); + for entry in entries { + match parse_shortcut(&entry.shortcut) { + Ok(parsed) => parsed_entries.push(SearchSurfaceShortcut { + parsed, + action_id: entry.action_id, + shortcut: entry.shortcut, + }), + Err(error) => parse_errors.push(format!("{}: {}", entry.action_id, error)), + } + } + + let mut shortcuts = SEARCH_SURFACE_SHORTCUTS + .lock() + .map_err(|_| "Failed to lock search surface shortcuts".to_string())?; + *shortcuts = parsed_entries; + if parse_errors.is_empty() { + Ok(()) + } else { + Err(format!( + "Ignored unsupported search surface shortcuts: {}", + parse_errors.join("; ") + )) + } +} + +pub fn find_search_surface_command_for_windows_accelerator( + virtual_key: u32, + control: bool, + alt: bool, + shift: bool, + super_key: bool, +) -> Option { + let candidate = windows_accelerator_to_shortcut(virtual_key, control, alt, shift, super_key)?; + let shortcuts = SEARCH_SURFACE_SHORTCUTS.lock().ok()?; + shortcuts + .iter() + .find(|entry| entry.parsed.mods == candidate.mods && entry.parsed.key == candidate.key) + .map(|entry| SearchSurfaceCommand { + action_id: entry.action_id.clone(), + shortcut: entry.shortcut.clone(), + source: "webview2-accelerator", + }) +} + +fn windows_accelerator_to_shortcut( + virtual_key: u32, + control: bool, + alt: bool, + shift: bool, + super_key: bool, +) -> Option { + let key = windows_virtual_key_to_code(virtual_key)?; + let mut modifiers = Modifiers::empty(); + if control { + modifiers |= Modifiers::CONTROL; + } + if alt { + modifiers |= Modifiers::ALT; + } + if shift { + modifiers |= Modifiers::SHIFT; + } + if super_key { + modifiers |= Modifiers::SUPER; + } + + Some(Shortcut::new( + if modifiers.is_empty() { + None + } else { + Some(modifiers) + }, + key, + )) +} + +fn windows_virtual_key_to_code(virtual_key: u32) -> Option { + match virtual_key { + 0x08 => Some(Code::Backspace), + 0x09 => Some(Code::Tab), + 0x0D => Some(Code::Enter), + 0x1B => Some(Code::Escape), + 0x20 => Some(Code::Space), + 0x21 => Some(Code::PageUp), + 0x22 => Some(Code::PageDown), + 0x23 => Some(Code::End), + 0x24 => Some(Code::Home), + 0x25 => Some(Code::ArrowLeft), + 0x26 => Some(Code::ArrowUp), + 0x27 => Some(Code::ArrowRight), + 0x28 => Some(Code::ArrowDown), + 0x2D => Some(Code::Insert), + 0x2E => Some(Code::Delete), + 0x30 => Some(Code::Digit0), + 0x31 => Some(Code::Digit1), + 0x32 => Some(Code::Digit2), + 0x33 => Some(Code::Digit3), + 0x34 => Some(Code::Digit4), + 0x35 => Some(Code::Digit5), + 0x36 => Some(Code::Digit6), + 0x37 => Some(Code::Digit7), + 0x38 => Some(Code::Digit8), + 0x39 => Some(Code::Digit9), + 0x41 => Some(Code::KeyA), + 0x42 => Some(Code::KeyB), + 0x43 => Some(Code::KeyC), + 0x44 => Some(Code::KeyD), + 0x45 => Some(Code::KeyE), + 0x46 => Some(Code::KeyF), + 0x47 => Some(Code::KeyG), + 0x48 => Some(Code::KeyH), + 0x49 => Some(Code::KeyI), + 0x4A => Some(Code::KeyJ), + 0x4B => Some(Code::KeyK), + 0x4C => Some(Code::KeyL), + 0x4D => Some(Code::KeyM), + 0x4E => Some(Code::KeyN), + 0x4F => Some(Code::KeyO), + 0x50 => Some(Code::KeyP), + 0x51 => Some(Code::KeyQ), + 0x52 => Some(Code::KeyR), + 0x53 => Some(Code::KeyS), + 0x54 => Some(Code::KeyT), + 0x55 => Some(Code::KeyU), + 0x56 => Some(Code::KeyV), + 0x57 => Some(Code::KeyW), + 0x58 => Some(Code::KeyX), + 0x59 => Some(Code::KeyY), + 0x5A => Some(Code::KeyZ), + 0x70 => Some(Code::F1), + 0x71 => Some(Code::F2), + 0x72 => Some(Code::F3), + 0x73 => Some(Code::F4), + 0x74 => Some(Code::F5), + 0x75 => Some(Code::F6), + 0x76 => Some(Code::F7), + 0x77 => Some(Code::F8), + 0x78 => Some(Code::F9), + 0x79 => Some(Code::F10), + 0x7A => Some(Code::F11), + 0x7B => Some(Code::F12), + 0xBA => Some(Code::Semicolon), + 0xBB => Some(Code::Equal), + 0xBC => Some(Code::Comma), + 0xBD => Some(Code::Minus), + 0xBE => Some(Code::Period), + 0xBF => Some(Code::Slash), + 0xC0 => Some(Code::Backquote), + 0xDB => Some(Code::BracketLeft), + 0xDC => Some(Code::Backslash), + 0xDD => Some(Code::BracketRight), + 0xDE => Some(Code::Quote), + _ => None, + } +} + pub fn parse_shortcut(shortcut_str: &str) -> Result { let parts: Vec<&str> = shortcut_str.split('+').map(|s| s.trim()).collect(); @@ -102,9 +308,26 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { for part in parts { match part.to_lowercase().as_str() { "ctrl" | "control" => modifiers |= Modifiers::CONTROL, - "alt" => modifiers |= Modifiers::ALT, + "alt" | "option" => modifiers |= Modifiers::ALT, "shift" => modifiers |= Modifiers::SHIFT, + // global-hotkey 把 Cmd / Win / Super 都映射到 Modifiers::SUPER; + // HotKey::new 还会把 META 自动转为 SUPER,统一在这里直接发 SUPER。 + "cmd" | "command" | "meta" | "super" | "win" | "windows" => { + modifiers |= Modifiers::SUPER + } + // 跨平台主修饰键别名:Mac 上为 Cmd(SUPER),其余平台为 Ctrl。 + // 与前端 utils/shortcuts.ts 的 `Mod` 抽象一致。 + "mod" => { + if cfg!(target_os = "macos") { + modifiers |= Modifiers::SUPER; + } else { + modifiers |= Modifiers::CONTROL; + } + } key => { + if key_code.is_some() { + return Err("Shortcut must contain exactly one key code".to_string()); + } key_code = Some(match key.to_lowercase().as_str() { "space" => Code::Space, "enter" | "return" => Code::Enter, @@ -121,6 +344,17 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { "arrowdown" | "down" => Code::ArrowDown, "arrowleft" | "left" => Code::ArrowLeft, "arrowright" | "right" => Code::ArrowRight, + "," | "comma" => Code::Comma, + "." | "period" => Code::Period, + "=" | "equal" | "equals" => Code::Equal, + "-" | "minus" => Code::Minus, + ";" | "semicolon" => Code::Semicolon, + "/" | "slash" => Code::Slash, + "'" | "quote" => Code::Quote, + "`" | "backquote" => Code::Backquote, + "[" | "bracketleft" => Code::BracketLeft, + "]" | "bracketright" => Code::BracketRight, + "\\" | "backslash" => Code::Backslash, "a" => Code::KeyA, "b" => Code::KeyB, "c" => Code::KeyC, @@ -187,3 +421,179 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { None => Err("No key code specified".to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static SEARCH_SURFACE_SHORTCUT_TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn parse_shortcut_accepts_ctrl_alias() { + let shortcut = parse_shortcut("Ctrl+Space").expect("ctrl+space parses"); + assert_eq!(shortcut.mods, Modifiers::CONTROL); + assert_eq!(shortcut.key, Code::Space); + } + + #[test] + fn parse_shortcut_accepts_option_alias_for_alt() { + let shortcut = parse_shortcut("Option+Shift+Space").expect("option+shift+space parses"); + assert_eq!(shortcut.mods, Modifiers::ALT | Modifiers::SHIFT); + assert_eq!(shortcut.key, Code::Space); + } + + #[test] + fn parse_shortcut_accepts_super_aliases_for_cmd_or_win() { + // global-hotkey 把 Cmd(macOS)和 Win/Super(Linux)都映射到 SUPER; + // 这里不区分平台,所有别名都应解析到 Modifiers::SUPER。 + for token in ["Cmd", "Command", "Meta", "Super", "Win", "Windows"] { + let input = format!("{}+Space", token); + let shortcut = parse_shortcut(&input) + .unwrap_or_else(|error| panic!("{} should parse: {}", token, error)); + assert_eq!( + shortcut.mods, + Modifiers::SUPER, + "{} should map to SUPER", + token + ); + assert_eq!(shortcut.key, Code::Space); + } + } + + #[test] + fn parse_shortcut_mod_resolves_to_platform_primary() { + let shortcut = parse_shortcut("Mod+Space").expect("mod+space parses"); + let expected = if cfg!(target_os = "macos") { + Modifiers::SUPER + } else { + Modifiers::CONTROL + }; + assert_eq!(shortcut.mods, expected); + assert_eq!(shortcut.key, Code::Space); + } + + #[test] + fn parse_shortcut_combines_super_with_other_modifiers() { + let shortcut = parse_shortcut("Cmd+Shift+T").expect("cmd+shift+t parses"); + assert_eq!(shortcut.mods, Modifiers::SUPER | Modifiers::SHIFT); + assert_eq!(shortcut.key, Code::KeyT); + } + + #[test] + fn parse_shortcut_is_case_insensitive() { + let shortcut = parse_shortcut("cmd+SPACE").expect("lowercase cmd parses"); + assert_eq!(shortcut.mods, Modifiers::SUPER); + assert_eq!(shortcut.key, Code::Space); + } + + #[test] + fn parse_shortcut_accepts_punctuation_keys() { + let shortcut = parse_shortcut("Mod+,").expect("mod+comma parses"); + let expected_mod = if cfg!(target_os = "macos") { + Modifiers::SUPER + } else { + Modifiers::CONTROL + }; + assert_eq!(shortcut.mods, expected_mod); + assert_eq!(shortcut.key, Code::Comma); + + let shortcut = parse_shortcut("Ctrl+=").expect("ctrl+equal parses"); + assert_eq!(shortcut.mods, Modifiers::CONTROL); + assert_eq!(shortcut.key, Code::Equal); + } + + #[test] + fn parse_shortcut_rejects_multiple_key_codes() { + let error = parse_shortcut("Ctrl+A+B").expect_err("multiple keys should be rejected"); + assert_eq!(error, "Shortcut must contain exactly one key code"); + } + + #[test] + fn search_surface_command_matches_windows_accelerator() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); + set_search_surface_shortcuts(vec![ + SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+M".to_string(), + }, + SearchSurfaceShortcutEntry { + action_id: "search.settings.open".to_string(), + shortcut: "Mod+,".to_string(), + }, + ]) + .expect("shortcuts sync"); + + let command = find_search_surface_command_for_windows_accelerator( + 0xBC, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .expect("comma shortcut matches"); + assert_eq!(command.action_id, "search.settings.open"); + assert_eq!(command.shortcut, "Mod+,"); + + assert!(find_search_surface_command_for_windows_accelerator( + 0x4D, false, false, false, false + ) + .is_none()); + } + + #[test] + fn search_surface_command_matches_alt_space_windows_system_accelerator() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); + set_search_surface_shortcuts(vec![SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Alt+Space".to_string(), + }]) + .expect("shortcuts sync"); + + let command = + find_search_surface_command_for_windows_accelerator(0x20, false, true, false, false) + .expect("alt+space shortcut matches"); + assert_eq!(command.action_id, "search.model.toggle"); + assert_eq!(command.shortcut, "Alt+Space"); + } + + #[test] + fn search_surface_shortcut_sync_drops_invalid_entries_without_retaining_old_commands() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); + set_search_surface_shortcuts(vec![SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+M".to_string(), + }]) + .expect("initial shortcut sync"); + + let result = set_search_surface_shortcuts(vec![ + SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+F13".to_string(), + }, + SearchSurfaceShortcutEntry { + action_id: "search.settings.open".to_string(), + shortcut: "Mod+,".to_string(), + }, + ]); + + assert!(result.is_err()); + assert!(find_search_surface_command_for_windows_accelerator( + 0x4D, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .is_none()); + let command = find_search_surface_command_for_windows_accelerator( + 0xBC, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .expect("valid shortcut remains synced"); + assert_eq!(command.action_id, "search.settings.open"); + } +} diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index 62343c16..fe3587a1 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -2,6 +2,8 @@ //! Webview 运行时默认配置。 +#[cfg(target_os = "windows")] +use raw_window_handle::HasWindowHandle; #[cfg(target_os = "windows")] use tauri::Emitter; #[cfg(target_os = "windows")] @@ -16,7 +18,17 @@ use webview2_com::Microsoft::Web::WebView2::Win32::{ COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN, }; #[cfg(target_os = "windows")] -use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CONTROL, VK_SHIFT}; +use windows::Win32::Foundation::{BOOL, HWND, LPARAM, LRESULT, TRUE, WPARAM}; +#[cfg(target_os = "windows")] +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, VK_SPACE, +}; +#[cfg(target_os = "windows")] +use windows::Win32::UI::Shell::{DefSubclassProc, SetWindowSubclass}; +#[cfg(target_os = "windows")] +use windows::Win32::UI::WindowsAndMessaging::{ + EnumChildWindows, SC_KEYMENU, WM_SYSCHAR, WM_SYSCOMMAND, +}; #[cfg(target_os = "windows")] use windows_core::Interface; @@ -82,28 +94,128 @@ fn disable_browser_accelerator_keys_with_controller( } #[cfg(target_os = "windows")] -/// 判断是否命中了需要从宿主层兜底转发的搜索 surface 快捷键。 -fn is_search_surface_accelerator_command( - key_event_kind: i32, - virtual_key: u32, - is_control_down: bool, -) -> bool { - let is_key_down = key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN.0 - || key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0; - is_key_down && is_control_down && virtual_key == u32::from(b'M') +/// 子类化标识,用于在窗口上唯一标记系统菜单拦截子类过程。 +const SYSTEM_MENU_SUBCLASS_ID: usize = 0x5359_534D; + +#[cfg(target_os = "windows")] +/// 从 Tauri 窗口取出顶层 Win32 HWND。 +fn top_level_hwnd(window: &WebviewWindow) -> Result { + let window_handle = window + .window_handle() + .map_err(|error| format!("Failed to get window handle: {}", error))?; + + match window_handle.as_ref() { + raw_window_handle::RawWindowHandle::Win32(handle) => Ok(HWND(handle.hwnd.get() as _)), + _ => Err("Not a Win32 window".to_string()), + } +} + +#[cfg(target_os = "windows")] +/// 子类过程:拦截 Alt+Space 真正触发系统菜单的消息(WM_SYSCHAR / WM_SYSCOMMAND), +/// 阻止系统菜单弹出。注意不拦截 WM_SYSKEYDOWN,使其仍能传到 WebView2 并派发 DOM keydown, +/// 供前端捕获 Alt+Space 作为快捷键。 +unsafe extern "system" fn system_menu_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + _ref_data: usize, +) -> LRESULT { + if msg == WM_SYSCHAR && wparam.0 == VK_SPACE.0 as usize { + // 吞掉 Alt+Space 的 WM_SYSCHAR,这是真正触发系统菜单的消息。 + return LRESULT(0); + } + + if msg == WM_SYSCOMMAND && (wparam.0 & 0xFFF0) == SC_KEYMENU as usize { + return LRESULT(0); + } + + DefSubclassProc(hwnd, msg, wparam, lparam) +} + +#[cfg(target_os = "windows")] +/// EnumChildWindows 回调:为每个子窗口安装系统菜单拦截子类。 +unsafe extern "system" fn install_subclass_on_child(hwnd: HWND, _lparam: LPARAM) -> BOOL { + let _ = SetWindowSubclass( + hwnd, + Some(system_menu_subclass_proc), + SYSTEM_MENU_SUBCLASS_ID, + 0, + ); + TRUE +} + +#[cfg(target_os = "windows")] +/// 在顶层窗口上安装系统菜单拦截子类,屏蔽 Alt+Space 系统菜单热键。 +/// +/// 子窗口(WebView2 内容区)的子类需在 webview 就绪后由 +/// [`install_system_menu_interceptor_on_children`] 单独安装。 +fn install_system_menu_interceptor(window: &WebviewWindow) -> Result<(), String> { + let hwnd = top_level_hwnd(window)?; + let installed = unsafe { + SetWindowSubclass( + hwnd, + Some(system_menu_subclass_proc), + SYSTEM_MENU_SUBCLASS_ID, + 0, + ) + }; + if !installed.as_bool() { + return Err("Failed to install system menu subclass".to_string()); + } + + Ok(()) } #[cfg(target_os = "windows")] -/// 在主搜索窗口注册 WebView2 accelerator 兜底,避免 DOM 未接管焦点时首个 Ctrl+M 丢失。 -fn register_search_surface_accelerator_bridge( +/// 为窗口的所有子孙窗口安装系统菜单拦截子类。 +/// +/// WebView2 内容渲染在子 HWND 中,焦点在内容区时 WM_SYSKEYDOWN/WM_SYSCHAR 由子窗口处理, +/// 因此需在 webview 就绪、子窗口已创建后调用此函数,才能在源头拦截。 +fn install_system_menu_interceptor_on_children(hwnd: HWND) { + unsafe { + let _ = EnumChildWindows(hwnd, Some(install_subclass_on_child), LPARAM(0)); + } +} + +#[cfg(target_os = "windows")] +/// 判断是否命中了 Alt+Space。 +/// +/// WebView2 把 Alt+Space 作为 system accelerator,默认不会派发 DOM keydown; +/// 必须在 `AcceleratorKeyPressed` 中调用 `SetIsBrowserAcceleratorKeyEnabled(false)` +/// 才能让事件继续传播到 web 内容。注意不要调 `SetHandled(true)`,否则传播会被 +/// 终止,DOM 同样收不到。菜单抑制由宿主子类化吃掉 WM_SYSCHAR / SC_KEYMENU 完成。 +fn is_system_menu_accelerator_command(key_event_kind: i32, virtual_key: u32) -> bool { + let is_system_key_down = key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0; + is_system_key_down && virtual_key == u32::from(VK_SPACE.0) +} + +#[cfg(target_os = "windows")] +fn is_accelerator_key_down_event(key_event_kind: i32) -> bool { + key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN.0 + || key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0 +} + +#[cfg(target_os = "windows")] +fn should_emit_search_surface_command_to_window(label: &str) -> bool { + label == "main" || label.starts_with("popup-") +} + +#[cfg(target_os = "windows")] +/// 注册 WebView2 accelerator 处理器,将 Alt+Space 转成 Tauri 事件供前端捕获。 +/// +/// WebView2 把 Alt+Space 当作 system accelerator,默认不会派发 DOM keydown; +/// 实测 `SetIsBrowserAcceleratorKeyEnabled(false)` 对系统键不生效,因此采用事件桥模式: +/// 在 accelerator 阶段直接 emit Tauri 事件,前端捕获模式下监听该事件录入快捷键。 +/// 系统菜单的抑制由 [`system_menu_subclass_proc`] 在宿主层完成。 +fn register_system_menu_accelerator_handler( window: &WebviewWindow, controller: &ICoreWebView2Controller, ) -> Result<(), String> { - if window.label() != "main" { - return Ok(()); - } - let app_handle = window.app_handle().clone(); + let search_surface_command_window = + should_emit_search_surface_command_to_window(window.label()).then(|| window.clone()); let mut token = 0i64; let handler = AcceleratorKeyPressedEventHandler::create(Box::new( move |_controller: Option, @@ -119,29 +231,81 @@ fn register_search_surface_accelerator_bridge( let mut virtual_key = 0u32; args.VirtualKey(&mut virtual_key)?; - let mut physical_key_status = Default::default(); - args.PhysicalKeyStatus(&mut physical_key_status)?; - - let is_control_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; - if !is_search_surface_accelerator_command( - key_event_kind.0, - virtual_key, - is_control_down, - ) { + if !is_system_menu_accelerator_command(key_event_kind.0, virtual_key) { + if !is_accelerator_key_down_event(key_event_kind.0) { + return Ok(()); + } + + let Some(search_surface_command_window) = &search_surface_command_window else { + return Ok(()); + }; + + let is_ctrl_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; + let is_alt_down = (GetKeyState(i32::from(VK_MENU.0)) as u16 & 0x8000) != 0; + let is_shift_down = (GetKeyState(i32::from(VK_SHIFT.0)) as u16 & 0x8000) != 0; + let is_super_down = (GetKeyState(i32::from(VK_LWIN.0)) as u16 & 0x8000) != 0 + || (GetKeyState(i32::from(VK_RWIN.0)) as u16 & 0x8000) != 0; + + let Some(command) = + crate::core::system::shortcut::find_search_surface_command_for_windows_accelerator( + virtual_key, + is_ctrl_down, + is_alt_down, + is_shift_down, + is_super_down, + ) + else { + return Ok(()); + }; + + if let Ok(args2) = + Interface::cast::(&args) + { + let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); + } + let _ = args.SetHandled(true); + let _ = search_surface_command_window.emit("search-surface-command", command); return Ok(()); } - if let Ok(args2) = - Interface::cast::(&args) - { - let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); + let is_ctrl_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; + let is_shift_down = (GetKeyState(i32::from(VK_SHIFT.0)) as u16 & 0x8000) != 0; + let is_super_down = (GetKeyState(i32::from(VK_LWIN.0)) as u16 & 0x8000) != 0 + || (GetKeyState(i32::from(VK_RWIN.0)) as u16 & 0x8000) != 0; + + if let Some(search_surface_command_window) = &search_surface_command_window { + if let Some(command) = + crate::core::system::shortcut::find_search_surface_command_for_windows_accelerator( + virtual_key, + is_ctrl_down, + true, + is_shift_down, + is_super_down, + ) + { + if let Ok(args2) = + Interface::cast::(&args) + { + let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); + } + let _ = args.SetHandled(true); + let _ = search_surface_command_window.emit("search-surface-command", command); + return Ok(()); + } } - let _ = args.SetHandled(true); + + log::info!( + "[sysmenu-accel] Alt+Space detected, emitting shortcut-capture-system-key (ctrl={} shift={})", + is_ctrl_down, + is_shift_down + ); let _ = app_handle.emit( - "search-surface-command", + "shortcut-capture-system-key", serde_json::json!({ - "command": "toggle-model-dropdown", - "source": "webview2-accelerator" + "key": "Space", + "alt": true, + "ctrl": is_ctrl_down, + "shift": is_shift_down, }), ); } @@ -153,7 +317,7 @@ fn register_search_surface_accelerator_bridge( unsafe { controller .add_AcceleratorKeyPressed(&handler, &mut token) - .map_err(|error| format!("Failed to add WebView2 accelerator handler: {}", error))?; + .map_err(|error| format!("Failed to add system menu accelerator handler: {}", error))?; } Ok(()) @@ -251,16 +415,25 @@ fn register_devtools_accelerator_handler( pub(crate) fn apply_webview_runtime_defaults( window: &WebviewWindow, ) -> Result<(), String> { + if let Err(error) = install_system_menu_interceptor(window) { + log::warn!( + "Failed to install system menu interceptor for window '{}': {}", + window.label(), + error + ); + } + let (tx, rx) = std::sync::mpsc::channel(); let window_clone = window.clone(); window .with_webview(move |webview| { let controller = webview.controller(); let result = disable_browser_accelerator_keys_with_controller(&controller) - .and_then(|_| { - register_search_surface_accelerator_bridge(&window_clone, &controller) - }) + .and_then(|_| register_system_menu_accelerator_handler(&window_clone, &controller)) .and_then(|_| register_devtools_accelerator_handler(&controller)); + if let Ok(hwnd) = top_level_hwnd(&window_clone) { + install_system_menu_interceptor_on_children(hwnd); + } let _ = tx.send(result); }) .map_err(|error| format!("Failed to access platform webview: {}", error))?; @@ -273,6 +446,24 @@ pub(crate) fn apply_webview_runtime_defaults( })? } +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::should_emit_search_surface_command_to_window; + + #[test] + fn search_surface_commands_target_main_and_popup_windows_only() { + assert!(should_emit_search_surface_command_to_window("main")); + assert!(should_emit_search_surface_command_to_window( + "popup-model-dropdown-popup" + )); + assert!(should_emit_search_surface_command_to_window( + "popup-session-history-popup" + )); + assert!(!should_emit_search_surface_command_to_window("settings")); + assert!(!should_emit_search_surface_command_to_window("assistant")); + } +} + #[cfg(not(target_os = "windows"))] /** * 非 Windows 平台无需额外的 WebView2 默认配置。 diff --git a/apps/desktop/src/components/CustomSelect.vue b/apps/desktop/src/components/CustomSelect.vue index d48e403f..9d601a69 100644 --- a/apps/desktop/src/components/CustomSelect.vue +++ b/apps/desktop/src/components/CustomSelect.vue @@ -8,7 +8,7 @@ SelectTrigger, SelectValue, } from '@components/ui/select'; - import type { AcceptableValue } from 'reka-ui'; + import type { AcceptableValue, SelectTriggerProps } from 'reka-ui'; import { computed, ref, useAttrs } from 'vue'; import { type MessageKey, t, tt } from '@/i18n'; @@ -29,23 +29,48 @@ placeholderKey?: MessageKey; disabled?: boolean; protectOptionText?: boolean; + open?: boolean; + displayLabel?: string; + triggerTestId?: string; + contentTestId?: string; + optionTestIdPrefix?: string; + disablePortal?: boolean; + triggerAs?: SelectTriggerProps['as']; } interface Emits { (e: 'update:modelValue', value: T): void; (e: 'update:open', value: boolean): void; + (e: 'focus', event: FocusEvent): void; + (e: 'blur', event: FocusEvent): void; } const props = withDefaults(defineProps(), { placeholder: '', disabled: false, protectOptionText: false, + open: undefined, + displayLabel: '', + triggerTestId: '', + contentTestId: '', + optionTestIdPrefix: '', + disablePortal: false, + triggerAs: 'button', }); const emit = defineEmits(); + defineSlots<{ + trigger?(props: { + option: Option | undefined; + open: boolean; + displayLabel: string; + }): unknown; + }>(); const attrs = useAttrs(); - const isOpen = ref(false); + const localOpen = ref(false); + + const isOpen = computed(() => props.open ?? localOpen.value); const selectedOption = computed(() => { return props.options.find((opt) => opt.value === props.modelValue); @@ -68,9 +93,9 @@ } }; - const handleOpenChange = (value: boolean) => { - isOpen.value = value; - emit('update:open', value); + const updateOpen = (open: boolean) => { + localOpen.value = open; + emit('update:open', open); }; @@ -80,11 +105,11 @@ :disabled="disabled" :open="isOpen" @update:model-value="handleSelectValue" - @update:open="handleOpenChange" + @update:open="updateOpen" > - - + + + + + = { 'settings.general.update.dialog.action.installing': 'Installing', 'settings.language.changed': 'Language changed to {language}', 'settings.loading.general': 'Loading general settings...', + 'settings.loading.shortcuts': 'Loading shortcut settings...', 'settings.loading.aiServices': 'Loading model service settings...', 'settings.loading.builtInTools': 'Loading built-in tools...', 'settings.loading.search': 'Loading search settings...', @@ -1084,7 +1199,9 @@ const enUSMessages: Record = { 'settings.loading.dataManagement': 'Loading data management...', 'settings.loading.about': 'Loading about page...', 'settings.nav.general.label': 'General', - 'settings.nav.general.description': 'Shortcuts, startup, conversation, and window preferences', + 'settings.nav.general.description': 'Startup, conversation, and window preferences', + 'settings.nav.shortcuts.label': 'Shortcuts', + 'settings.nav.shortcuts.description': 'Global activation and window commands', 'settings.nav.aiServices.label': 'Providers and models', 'settings.nav.aiServices.description': 'Providers, models, default model, and keys', 'settings.nav.builtInTools.label': 'Built-in tools', @@ -1764,10 +1881,11 @@ const enUSMessages: Record = { 'conversation.toolbar.pinWindow': 'Keep window on top', 'conversation.timeline.jumpToMessage': 'Jump to message: {preview}', 'assistant.loadingTip.newLine': 'Shift+Enter inserts a new line for structured prompts', - 'assistant.loadingTip.switchModel': 'Ctrl+M or the model icon switches models', - 'assistant.loadingTip.history': 'Ctrl+H opens conversation history', - 'assistant.loadingTip.alwaysOnTop': 'Ctrl+P toggles always on top', - 'assistant.loadingTip.newSession': 'Ctrl+N starts a new conversation', + 'assistant.loadingTip.switchModel': + 'Use the configured shortcut or the model icon to switch models', + 'assistant.loadingTip.history': 'Use the configured shortcut to open conversation history', + 'assistant.loadingTip.alwaysOnTop': 'Use the configured shortcut to toggle always on top', + 'assistant.loadingTip.newSession': 'Use the configured shortcut to start a new conversation', 'assistant.loadingTip.historyButton': 'Use the history button in the top right to view and switch conversations', 'assistant.loadingTip.copy': 'Buttons below a message can copy content', diff --git a/apps/desktop/src/i18n/textMap.ts b/apps/desktop/src/i18n/textMap.ts index 748b938b..6024efd3 100644 --- a/apps/desktop/src/i18n/textMap.ts +++ b/apps/desktop/src/i18n/textMap.ts @@ -511,10 +511,11 @@ export const zhToEnTextMap = { 未命名图片: 'Image', 未命名文件: 'File', 'Shift+Enter 换行,适合分段表述': 'Shift+Enter inserts a new line for structured prompts', - 'Ctrl+M 或点击模型图标切换模型': 'Ctrl+M or the model icon switches models', - 'Ctrl+H 快速打开历史会话': 'Ctrl+H opens conversation history', - 'Ctrl+P 切换窗口置顶': 'Ctrl+P toggles always on top', - 'Ctrl+N 开启新会话': 'Ctrl+N starts a new conversation', + 使用配置的快捷键或点击模型图标切换模型: + 'Use the configured shortcut or the model icon to switch models', + 使用配置的快捷键快速打开历史会话: 'Use the configured shortcut to open conversation history', + 使用配置的快捷键切换窗口置顶: 'Use the configured shortcut to toggle always on top', + 使用配置的快捷键开启新会话: 'Use the configured shortcut to start a new conversation', 右上角历史按钮可查看和切换历史会话: 'Use the history button in the top right to view and switch conversations', 消息下方按钮可复制内容: 'Buttons below a message can copy content', @@ -666,8 +667,8 @@ export const zhToEnTextMap = { 加载设置失败: 'Failed to load settings', 快捷键保存成功: 'Shortcut saved', 搜索窗口尺寸已更新: 'Search window size updated', - '不支持 Win 键组合,请使用 Ctrl、Alt、Shift': - 'Win key combinations are not supported. Use Ctrl, Alt, or Shift.', + '不支持 Win/Super 键组合,请使用 Ctrl、Alt、Shift': + 'Win/Super key combinations are not supported. Use Ctrl, Alt, or Shift.', '确定要删除服务器 "{serverName}" 吗?': 'Delete server "{serverName}"?', 标准输入输出: 'Standard input/output', '兼容Streamable HTTP与SSE': 'Compatible with Streamable HTTP and SSE', diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts index bd60fb60..9d7977c6 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts @@ -2,10 +2,15 @@ import { native } from '@services/NativeService'; +import { + getSearchKeybindingDefinition, + type SearchKeybindingActionId, +} from '@/config/searchKeybindings'; import { resolveSearchWindowDefaultSize } from '@/config/searchWindow'; -import { tt } from '@/i18n'; +import { t, tt } from '@/i18n'; import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import type { GeneralSettingsData } from '@/stores/settings'; +import { isReservedGlobalShortcut, normalizeLocalShortcutString } from '@/utils/shortcuts'; import { truncateText } from '@/utils/text'; import { @@ -70,10 +75,16 @@ function buildSettingConversationSemantic( } async function applySettingSideEffect( + settingsStore: SettingsStore, key: SupportedSettingKey, value: SupportedSettingValue ): Promise { if (key === 'global_shortcut') { + ensureGlobalShortcutIsNotSystemReserved(value as GeneralSettingsData['globalShortcut']); + ensureGlobalShortcutDoesNotConflictWithSearchShortcuts( + settingsStore, + value as GeneralSettingsData['globalShortcut'] + ); try { await native.shortcut.registerGlobalShortcut( value as GeneralSettingsData['globalShortcut'] @@ -107,6 +118,53 @@ async function applySettingSideEffect( } } +function ensureGlobalShortcutIsNotSystemReserved( + shortcut: GeneralSettingsData['globalShortcut'] +): void { + if (!isReservedGlobalShortcut(shortcut)) { + return; + } + + throw new Error(t('settings.general.globalShortcutReservedOnMac')); +} + +function findGlobalShortcutSearchConflict( + settingsStore: SettingsStore, + shortcut: GeneralSettingsData['globalShortcut'] +): SearchKeybindingActionId | null { + const normalizedShortcut = normalizeLocalShortcutString(shortcut); + if (!normalizedShortcut) { + return null; + } + + const searchKeybindings = settingsStore.settings.searchKeybindings ?? {}; + for (const [actionId, searchShortcut] of Object.entries(searchKeybindings) as Array< + [SearchKeybindingActionId, string | null] + >) { + if (normalizeLocalShortcutString(searchShortcut) === normalizedShortcut) { + return actionId; + } + } + + return null; +} + +function ensureGlobalShortcutDoesNotConflictWithSearchShortcuts( + settingsStore: SettingsStore, + shortcut: GeneralSettingsData['globalShortcut'] +): void { + const conflictActionId = findGlobalShortcutSearchConflict(settingsStore, shortcut); + if (!conflictActionId) { + return; + } + + throw new Error( + t('settings.general.searchShortcuts.errors.duplicate', { + action: t(getSearchKeybindingDefinition(conflictActionId).labelKey), + }) + ); +} + async function persistSettingValue( settingsStore: SettingsStore, key: SupportedSettingKey, @@ -149,7 +207,7 @@ async function applySettingUpdate( key: SupportedSettingKey, value: SupportedSettingValue ): Promise { - await applySettingSideEffect(key, value); + await applySettingSideEffect(settingsStore, key, value); await persistSettingValue(settingsStore, key, value); } diff --git a/apps/desktop/src/services/EventService/index.ts b/apps/desktop/src/services/EventService/index.ts index f4beffbb..88bbc139 100644 --- a/apps/desktop/src/services/EventService/index.ts +++ b/apps/desktop/src/services/EventService/index.ts @@ -77,7 +77,9 @@ export type { GeneralSettingKey, McpServerStatus, McpStatusChangeEvent, + SearchSurfaceCommandEvent, SettingsGeneralUpdatedEvent, + ShortcutCaptureSystemKeyEvent, WindowFocusEvent, WindowResizeEvent, } from './types'; diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index 4ce127c0..d52b36eb 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -11,6 +11,7 @@ import type { PopupSessionSearchQueryChangePayload, } from '@services/PopupService/types'; +import type { SearchKeybindingActionId } from '@/config/searchKeybindings'; import type { GeneralSettingKey, GeneralSettingValue } from '@/stores/setting'; import type { SessionStatusReminderKind } from '@/utils/session'; @@ -59,6 +60,9 @@ export enum AppEvent { SEARCH_SURFACE_COMMAND = 'search-surface-command', SESSION_TASK_STATUS_CHANGED = 'session:task:status-changed', SESSION_STATUS_REMINDER_ACTION = 'session-status-reminder:action', + + // 快捷键捕获事件 + SHORTCUT_CAPTURE_SYSTEM_KEY = 'shortcut-capture-system-key', } // ==================== MCP 事件 ==================== @@ -117,10 +121,23 @@ export interface SearchSurfaceHiddenEvent { } export interface SearchSurfaceCommandEvent { - command: 'toggle-model-dropdown'; + actionId: SearchKeybindingActionId; + shortcut: string; source: 'webview2-accelerator'; } +/** + * 系统级快捷键(如 Alt+Space)由 WebView2 当作 system accelerator 截获, + * 不会派发 DOM keydown,因此宿主在 accelerator 阶段直接 emit 此事件, + * 由前端在快捷键录入模式下监听并复用普通保存路径。 + */ +export interface ShortcutCaptureSystemKeyEvent { + key: 'Space'; + alt: boolean; + ctrl: boolean; + shift: boolean; +} + export interface SessionStatusReminderApprovalActionPayload { callId: string; approveLabel: string; @@ -195,6 +212,9 @@ export interface AppEventMap { [AppEvent.SEARCH_SURFACE_COMMAND]: SearchSurfaceCommandEvent; [AppEvent.SESSION_TASK_STATUS_CHANGED]: SessionTaskStatusChangedEvent; [AppEvent.SESSION_STATUS_REMINDER_ACTION]: SessionStatusReminderActionEvent; + + // 快捷键捕获事件 + [AppEvent.SHORTCUT_CAPTURE_SYSTEM_KEY]: ShortcutCaptureSystemKeyEvent; } export type AppEventName = keyof AppEventMap; diff --git a/apps/desktop/src/services/NativeService/index.ts b/apps/desktop/src/services/NativeService/index.ts index 4cc78738..9b518b3a 100644 --- a/apps/desktop/src/services/NativeService/index.ts +++ b/apps/desktop/src/services/NativeService/index.ts @@ -21,6 +21,7 @@ export type { McpToolDefinition, McpTransportType, } from './mcp'; +export type { SearchSurfaceShortcutEntry } from './shortcut'; export type { AppUpdateChannel, AppUpdateChannelLatest, diff --git a/apps/desktop/src/services/NativeService/shortcut.ts b/apps/desktop/src/services/NativeService/shortcut.ts index 78afa41c..f9fe5b7f 100644 --- a/apps/desktop/src/services/NativeService/shortcut.ts +++ b/apps/desktop/src/services/NativeService/shortcut.ts @@ -1,5 +1,10 @@ import { invoke } from '@tauri-apps/api/core'; +export interface SearchSurfaceShortcutEntry { + actionId: string; + shortcut: string; +} + export const shortcut = { registerGlobalShortcut(shortcut: string): Promise { return invoke('register_global_shortcut', { shortcut }); @@ -7,4 +12,7 @@ export const shortcut = { getShortcutStatus(): Promise<[boolean, string | null]> { return invoke('get_shortcut_status'); }, + setSearchSurfaceShortcuts(entries: SearchSurfaceShortcutEntry[]): Promise { + return invoke('set_search_surface_shortcuts', { entries }); + }, } as const; diff --git a/apps/desktop/src/services/PopupService/types.ts b/apps/desktop/src/services/PopupService/types.ts index 73f6c804..bcba5a6c 100644 --- a/apps/desktop/src/services/PopupService/types.ts +++ b/apps/desktop/src/services/PopupService/types.ts @@ -79,6 +79,7 @@ export interface ModelDropdownData { selectedModelId: string; selectedProviderId: number | null; searchQuery: string; + toggleShortcut?: string | null; models?: ModelDropdownPopupItem[]; } @@ -110,6 +111,7 @@ export interface SessionHistoryData { activeSessionId: number | null; searchQuery: string; isLoading: boolean; + toggleShortcut?: string | null; } export type PopupData = ModelDropdownData | SessionHistoryData; diff --git a/apps/desktop/src/stores/setting/index.ts b/apps/desktop/src/stores/setting/index.ts index 4eb1a9ef..c8adb078 100644 --- a/apps/desktop/src/stores/setting/index.ts +++ b/apps/desktop/src/stores/setting/index.ts @@ -1,6 +1,11 @@ import { computed, type ComputedRef, type Ref } from 'vue'; import type { AppUpdateChannel } from '@/config/appUpdate'; +import { + createDefaultSearchKeybindings, + normalizeSearchKeybindings, + type SearchKeybindings, +} from '@/config/searchKeybindings'; import type { SearchWindowDefaultSize, SearchWindowSizePreset } from '@/config/searchWindow'; import type { AppLocale } from '@/i18n'; import type { BrowserSettingsConfig } from '@/stores/setting/sections/browser'; @@ -23,11 +28,12 @@ import { } from './general'; export type OutputScrollBehavior = 'follow_output' | 'stay_position' | 'jump_to_top'; -export type GeneralSettingKey = GeneralScalarSettingKey | JsonSettingsKey; -export type GeneralSettingValue = string | number | boolean | null; +export type GeneralSettingKey = GeneralScalarSettingKey | 'search_keybindings' | JsonSettingsKey; +export type GeneralSettingValue = string | number | boolean | SearchKeybindings | null; export interface GeneralSettingsData { globalShortcut: string; + searchKeybindings: SearchKeybindings; startOnBoot: boolean; startMinimized: boolean; outputScrollBehavior: OutputScrollBehavior; @@ -50,12 +56,13 @@ type GeneralSettingFieldValue = | string | boolean | null + | SearchKeybindings | BrowserSettingsConfig | SearchSettingsConfig; type GeneralScalarSettingStateKey = Exclude< keyof GeneralSettingsData, - 'browserSettings' | 'searchSettings' | 'searchWindowDefaultSize' + 'browserSettings' | 'searchKeybindings' | 'searchSettings' | 'searchWindowDefaultSize' >; type GeneralPersistedSettingStateKey = Exclude< keyof GeneralSettingsData, @@ -80,11 +87,13 @@ export interface GeneralSettingDefinition { serializeValue(value: GeneralSettingFieldValue): string; eventValue(value: GeneralSettingFieldValue): GeneralSettingValue; persistBeforeApply?: boolean; + shouldRewritePersisted?(raw: string | null): boolean; } export interface GeneralSettingsComputedRefs { outputScrollBehavior: ComputedRef; globalShortcut: ComputedRef; + searchKeybindings: ComputedRef; searchWindowSizePreset: ComputedRef; searchWindowDefaultSize: ComputedRef; language: ComputedRef; @@ -97,6 +106,7 @@ export interface GeneralSettingsComputedRefs { export interface GeneralSettingUpdaters { updateGlobalShortcut(shortcut: string): Promise; + updateSearchKeybindings(searchKeybindings: SearchKeybindings): Promise; updateStartOnBoot(enabled: boolean): Promise; updateStartMinimized(enabled: boolean): Promise; updateOutputScrollBehavior(mode: OutputScrollBehavior): Promise; @@ -139,6 +149,7 @@ export interface GeneralSettingUpdaterBinding { const DEFAULT_GENERAL_SETTINGS: GeneralSettingsData = { ...GENERAL_SETTINGS_DEFAULTS, + searchKeybindings: createDefaultSearchKeybindings(), } as GeneralSettingsData; function assignGeneralSettingField( @@ -244,22 +255,76 @@ function scalarUpdaterBindings( ); } +function parsePersistedSearchKeybindings(value: string | null): SearchKeybindings { + if (!value) { + return createDefaultSearchKeybindings(); + } + + try { + return normalizeSearchKeybindings(JSON.parse(value)); + } catch { + return createDefaultSearchKeybindings(); + } +} + +function shouldRewritePersistedSearchKeybindings(value: string | null): boolean { + if (!value) { + return false; + } + + try { + const parsed = JSON.parse(value); + return ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + ('search.request.cancel' in parsed || 'search.draft.clearAll' in parsed) + ); + } catch { + return false; + } +} + +const SEARCH_KEYBINDINGS_SETTING_DEFINITION: GeneralSettingDefinition = { + key: 'search_keybindings', + parsePersisted: parsePersistedSearchKeybindings, + parseUpdate: (value) => normalizeSearchKeybindings(value), + apply: (target, value) => { + target.searchKeybindings = { + ...(value as SearchKeybindings), + }; + }, + read: (source) => source.searchKeybindings, + serializeValue: (value) => JSON.stringify(normalizeSearchKeybindings(value)), + eventValue: (value) => ({ + ...normalizeSearchKeybindings(value), + }), + shouldRewritePersisted: shouldRewritePersistedSearchKeybindings, +}; + export const JSON_GENERAL_SETTING_DEFINITIONS: readonly GeneralSettingDefinition[] = JSON_SETTINGS_SECTIONS.map(jsonSettingDefinition); export const GENERAL_SETTING_DEFINITIONS: readonly GeneralSettingDefinition[] = [ ...GENERAL_SCALAR_SETTING_SPECS.map(scalarSettingDefinition), + SEARCH_KEYBINDINGS_SETTING_DEFINITION, ...JSON_GENERAL_SETTING_DEFINITIONS, ]; export const GENERAL_SETTING_COMPUTED_BINDINGS: readonly GeneralSettingComputedBinding[] = [ ...scalarComputedBindings(GENERAL_SCALAR_SETTING_SPECS), + { exposedName: 'searchKeybindings', stateKey: 'searchKeybindings' }, ...GENERAL_DERIVED_COMPUTED_BINDINGS, ...JSON_SETTINGS_SECTIONS.map(jsonComputedBinding), ]; export const GENERAL_SETTING_UPDATER_BINDINGS: readonly GeneralSettingUpdaterBinding[] = [ ...scalarUpdaterBindings(GENERAL_SCALAR_SETTING_SPECS), + { + exposedName: 'updateSearchKeybindings', + key: 'search_keybindings', + normalize: (value) => normalizeSearchKeybindings(value), + }, ...JSON_SETTINGS_SECTIONS.map(jsonUpdaterBinding), ]; @@ -297,6 +362,7 @@ export function createGeneralSettingUpdaters( export function createDefaultGeneralSettings(): GeneralSettingsData { const defaults: GeneralSettingsData = { ...DEFAULT_GENERAL_SETTINGS, + searchKeybindings: { ...DEFAULT_GENERAL_SETTINGS.searchKeybindings }, searchWindowDefaultSize: { ...DEFAULT_GENERAL_SETTINGS.searchWindowDefaultSize }, }; for (const section of JSON_SETTINGS_SECTIONS) { @@ -308,6 +374,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsData { export function cloneGeneralSettingsSnapshot(source: GeneralSettingsData): GeneralSettingsData { const snapshot: GeneralSettingsData = { ...source, + searchKeybindings: { ...source.searchKeybindings }, searchWindowDefaultSize: { ...source.searchWindowDefaultSize }, }; for (const section of JSON_SETTINGS_SECTIONS) { diff --git a/apps/desktop/src/stores/settings.ts b/apps/desktop/src/stores/settings.ts index 4bf5cb2c..e345b40d 100644 --- a/apps/desktop/src/stores/settings.ts +++ b/apps/desktop/src/stores/settings.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026. 鍗冭瘹. Licensed under GPL v3 +// Copyright (c) 2026. 千诚. Licensed under GPL v3 import { getSettingValue, setSetting } from '@database/queries'; import { AppEvent, eventService } from '@services/EventService'; @@ -24,6 +24,7 @@ import { serializeGeneralSetting, serializeParsedGeneralSettingValue, } from './setting'; + export type { GeneralSettingsData, OutputScrollBehavior } from './setting'; export const useSettingsStore = defineStore('settings', () => { @@ -67,6 +68,7 @@ export const useSettingsStore = defineStore('settings', () => { getSettingValue({ key: definition.key }) ) ); + GENERAL_SETTING_DEFINITIONS.forEach((definition, index) => { applyPersistedGeneralSettingValue( settings.value, @@ -75,11 +77,31 @@ export const useSettingsStore = defineStore('settings', () => { ); }); - await Promise.allSettled( - GENERAL_SETTING_DEFINITIONS.map((definition, index) => - persistDefaultIfMissing(definition.key, settingRows[index] ?? null) - ) + const persistenceResults = await Promise.allSettled( + GENERAL_SETTING_DEFINITIONS.map((definition, index) => { + const persistedValue = settingRows[index] ?? null; + if (definition.shouldRewritePersisted?.(persistedValue)) { + return setSetting({ + key: definition.key, + value: serializeSetting(definition.key), + }); + } + + return persistDefaultIfMissing(definition.key, persistedValue); + }) + ); + const failedPersistenceKeys = persistenceResults.flatMap((result, index) => + result.status === 'rejected' + ? [GENERAL_SETTING_DEFINITIONS[index]?.key ?? `unknown:${index}`] + : [] ); + if (failedPersistenceKeys.length > 0) { + console.warn( + `[SettingsStore] Failed to persist general setting rewrite/default value(s): ${failedPersistenceKeys.join( + ', ' + )}` + ); + } } finally { loading.value = false; } diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts new file mode 100644 index 00000000..416238c5 --- /dev/null +++ b/apps/desktop/src/utils/shortcuts.ts @@ -0,0 +1,476 @@ +export interface ShortcutMatchInput { + key: string; + code?: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +export interface CapturedShortcutResult { + shortcut: string; + displayShortcut: string; +} + +const MODIFIER_DISPLAY_ORDER = ['Mod', 'Ctrl', 'Alt', 'Shift'] as const; +const SUPPORTED_CAPTURE_MODIFIERS = new Set(['Ctrl', 'Alt', 'Shift', 'Mod']); +const SUPPORTED_CHARACTER_KEYS = new Set([ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), + ...'0123456789'.split(''), + ',', + '.', + '=', + '-', + ';', + '/', + "'", + '`', + '[', + ']', + '\\', +]); +const SUPPORTED_NON_CHARACTER_KEYS = new Set([ + 'Backspace', + 'Del', + 'Enter', + 'Esc', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Tab', + 'Up', + 'Down', + 'Left', + 'Right', + 'Insert', + 'Space', +]); +const RESERVED_LOCAL_SHORTCUT_KEYS = new Set([ + 'Backspace', + 'Del', + 'Enter', + 'Esc', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Tab', + 'Up', + 'Down', + 'Left', + 'Right', +]); +const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'OS']); + +const KEY_DISPLAY_MAP: Record = { + ' ': 'Space', + Spacebar: 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Esc', + Esc: 'Esc', + Delete: 'Del', + Del: 'Del', + '.': '.', + ',': ',', +}; + +const CODE_KEY_MAP: Record = { + Digit0: '0', + Digit1: '1', + Digit2: '2', + Digit3: '3', + Digit4: '4', + Digit5: '5', + Digit6: '6', + Digit7: '7', + Digit8: '8', + Digit9: '9', + Comma: ',', + Period: '.', + Equal: '=', + Minus: '-', + Semicolon: ';', + Slash: '/', + Quote: "'", + Backquote: '`', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', +}; + +const ALIAS_MAP: Record = { + mod: 'Mod', + cmd: 'Mod', + command: 'Mod', + meta: 'Mod', + win: 'Mod', + super: 'Mod', + ctrl: 'Ctrl', + control: 'Ctrl', + option: 'Alt', + alt: 'Alt', + shift: 'Shift', + esc: 'Esc', + escape: 'Esc', + delete: 'Del', + del: 'Del', + return: 'Enter', + enter: 'Enter', + tab: 'Tab', + pageup: 'PageUp', + pagedown: 'PageDown', + arrowup: 'Up', + up: 'Up', + arrowdown: 'Down', + down: 'Down', + arrowleft: 'Left', + left: 'Left', + arrowright: 'Right', + right: 'Right', + backspace: 'Backspace', + insert: 'Insert', + ins: 'Insert', + space: 'Space', +}; + +export function isMacPlatform(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + + return /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform); +} + +function getPrimaryModifierLabel(): 'Cmd' | 'Ctrl' { + return isMacPlatform() ? 'Cmd' : 'Ctrl'; +} + +function getAltModifierLabel(): 'Option' | 'Alt' { + return isMacPlatform() ? 'Option' : 'Alt'; +} + +function usesPrimaryModifier(input: ShortcutMatchInput): boolean { + return isMacPlatform() ? Boolean(input.metaKey) : Boolean(input.ctrlKey); +} + +function normalizeShortcutToken(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) { + return null; + } + + const alias = ALIAS_MAP[trimmed.toLowerCase()]; + if (alias) { + return alias; + } + + if (trimmed.length === 1) { + const normalizedCharacter = trimmed.toUpperCase(); + return SUPPORTED_CHARACTER_KEYS.has(normalizedCharacter) ? normalizedCharacter : null; + } + + const functionKeyMatch = /^f(\d{1,2})$/i.exec(trimmed); + if (functionKeyMatch) { + const functionKeyNumber = Number(functionKeyMatch[1]); + return functionKeyNumber >= 1 && functionKeyNumber <= 12 ? `F${functionKeyNumber}` : null; + } + + if (SUPPORTED_NON_CHARACTER_KEYS.has(trimmed)) { + return trimmed; + } + + return null; +} + +function normalizeEventKey(key: string): string | null { + if (!key) { + return null; + } + + const mappedKey = KEY_DISPLAY_MAP[key] ?? key; + return normalizeShortcutToken(mappedKey); +} + +function normalizeFunctionKeyCode(code: string | null | undefined): string | null { + if (!code) { + return null; + } + + const normalizedCode = normalizeShortcutToken(code); + return normalizedCode && /^F\d{1,2}$/.test(normalizedCode) ? normalizedCode : null; +} + +function normalizePrintableKeyCode(code: string | null | undefined): string | null { + if (!code) { + return null; + } + + return normalizeShortcutToken(CODE_KEY_MAP[code] ?? ''); +} + +export function resolveKeyboardEventShortcutKey( + key: string | null | undefined, + code?: string | null +): string | null { + const rawKey = key ?? ''; + const normalizedKey = normalizeEventKey(rawKey); + const normalizedFunctionKeyCode = normalizeFunctionKeyCode(code); + if (normalizedFunctionKeyCode && (!normalizedKey || !/^F\d{1,2}$/.test(normalizedKey))) { + return normalizedFunctionKeyCode; + } + + if (!normalizedKey && rawKey.length === 1) { + return normalizePrintableKeyCode(code); + } + + return normalizedKey; +} + +function createShortcutParts(shortcut: string): { modifiers: string[]; key: string | null } { + const normalizedParts = shortcut.split('+').map((part) => normalizeShortcutToken(part)); + if (normalizedParts.some((part) => !part)) { + return { modifiers: [], key: null }; + } + + const parts = normalizedParts as string[]; + const modifierSet = new Set(); + let key: string | null = null; + for (const part of parts) { + if (SUPPORTED_CAPTURE_MODIFIERS.has(part)) { + modifierSet.add(part); + continue; + } + + if (key) { + return { modifiers: [], key: null }; + } + key = part; + } + + if (!isMacPlatform() && modifierSet.has('Ctrl')) { + modifierSet.delete('Ctrl'); + modifierSet.add('Mod'); + } + + const modifiers = MODIFIER_DISPLAY_ORDER.filter((modifier) => modifierSet.has(modifier)); + return { modifiers, key }; +} + +export function normalizeLocalShortcutString(shortcut: string | null | undefined): string | null { + if (!shortcut) { + return null; + } + + const { modifiers, key } = createShortcutParts(shortcut); + if (!key) { + return null; + } + + return [...modifiers, key].join('+'); +} + +export function formatShortcutForDisplay(shortcut: string | null | undefined): string { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return '—'; + } + + const { modifiers, key } = createShortcutParts(normalized); + const displayModifiers = modifiers.map((modifier) => { + if (modifier === 'Mod') { + return getPrimaryModifierLabel(); + } + if (modifier === 'Alt') { + return getAltModifierLabel(); + } + return modifier; + }); + + return [...displayModifiers, key].join('+'); +} + +export function toCurrentPlatformShortcut(shortcut: string | null | undefined): string | null { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return null; + } + + return formatShortcutForDisplay(normalized); +} + +export function matchShortcut( + shortcut: string | null | undefined, + input: ShortcutMatchInput +): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { modifiers, key } = createShortcutParts(normalized); + const eventKey = resolveKeyboardEventShortcutKey(input.key, input.code); + if (!eventKey || eventKey !== key) { + return false; + } + + const isMac = isMacPlatform(); + const expectsMod = modifiers.includes('Mod'); + const expectsCtrl = modifiers.includes('Ctrl'); + const expectsAlt = modifiers.includes('Alt'); + const expectsShift = modifiers.includes('Shift'); + + if (expectsMod !== usesPrimaryModifier(input)) { + return false; + } + + const effectiveCtrl = isMac + ? Boolean(input.ctrlKey) + : expectsMod + ? false + : Boolean(input.ctrlKey); + if (expectsCtrl !== effectiveCtrl) { + return false; + } + + if (expectsAlt !== Boolean(input.altKey)) { + return false; + } + + if (expectsShift !== Boolean(input.shiftKey)) { + return false; + } + + if (!isMac && input.metaKey) { + return false; + } + + return true; +} + +export function captureShortcutFromKeyboardEvent( + event: KeyboardEvent +): CapturedShortcutResult | null { + if (MODIFIER_KEYS.has(event.key)) { + return null; + } + + if (!isMacPlatform() && event.metaKey) { + return null; + } + + const key = resolveKeyboardEventShortcutKey(event.key, event.code); + if (!key) { + return null; + } + + const modifiers: string[] = []; + if (usesPrimaryModifier(event)) { + modifiers.push('Mod'); + } + if (event.ctrlKey && isMacPlatform()) { + modifiers.push('Ctrl'); + } + if (event.altKey) { + modifiers.push('Alt'); + } + if (event.shiftKey) { + modifiers.push('Shift'); + } + + const shortcut = [...modifiers, key].join('+'); + return { + shortcut, + displayShortcut: formatShortcutForDisplay(shortcut), + }; +} + +export function isReservedLocalShortcut(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { key } = createShortcutParts(normalized); + return key ? RESERVED_LOCAL_SHORTCUT_KEYS.has(key) : false; +} + +export function isReservedLocalShortcutKey( + key: string | null | undefined, + code?: string | null +): boolean { + const normalizedKey = resolveKeyboardEventShortcutKey(key, code); + return normalizedKey ? RESERVED_LOCAL_SHORTCUT_KEYS.has(normalizedKey) : false; +} + +export function isReservedGlobalShortcut(shortcut: string | null | undefined): boolean { + if (!isMacPlatform()) { + return false; + } + + const normalized = normalizeLocalShortcutString(shortcut); + return normalized === 'Mod+Space' || normalized === 'Ctrl+Space'; +} + +export function hasRequiredModifier(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { modifiers } = createShortcutParts(normalized); + return modifiers.length > 0; +} + +export function hasCommandModifier(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { modifiers } = createShortcutParts(normalized); + return modifiers.some( + (modifier) => modifier === 'Mod' || modifier === 'Ctrl' || modifier === 'Alt' + ); +} + +export function isModifierlessFunctionShortcut(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const match = /^F(\d{1,2})$/.exec(normalized); + if (!match) { + return false; + } + + const functionKeyNumber = Number(match[1]); + return functionKeyNumber >= 1 && functionKeyNumber <= 12; +} + +export function findShortcutConflict( + shortcut: string | null | undefined, + entries: Array<{ id: T; shortcut: string | null | undefined }>, + excludeId?: T +): T | null { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return null; + } + + for (const entry of entries) { + if (excludeId && entry.id === excludeId) { + continue; + } + + if (normalizeLocalShortcutString(entry.shortcut) === normalized) { + return entry.id; + } + } + + return null; +} diff --git a/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue index 24869ae7..cf1b55b8 100644 --- a/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue +++ b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue @@ -138,6 +138,7 @@ import { computed, nextTick, ref, watch } from 'vue'; import { t } from '@/i18n'; + import { matchShortcut } from '@/utils/shortcuts'; defineOptions({ name: 'PopupModelDropdown', @@ -244,7 +245,7 @@ }; function handleKeyDown(event: KeyboardEvent) { - if (event.ctrlKey && event.key.toLowerCase() === 'm') { + if (matchShortcut(props.data?.toggleShortcut, event)) { event.preventDefault(); emit('close'); return; diff --git a/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue b/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue index 387a918d..0d23d18d 100644 --- a/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue +++ b/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue @@ -188,6 +188,7 @@ import { type MessageKey, t } from '@/i18n'; import { formatMonthDay, formatTime } from '@/i18n/format'; + import { matchShortcut } from '@/utils/shortcuts'; defineOptions({ name: 'SessionHistoryPopover', @@ -706,8 +707,7 @@ } function handleKeyDown(event: KeyboardEvent) { - // Ctrl+H 关闭弹窗 - if (event.ctrlKey && event.key === 'h') { + if (matchShortcut(props.data?.toggleShortcut, event)) { event.preventDefault(); emit('close'); return; diff --git a/apps/desktop/src/views/PopupView/index.vue b/apps/desktop/src/views/PopupView/index.vue index 8ba57a1a..bce2ba26 100644 --- a/apps/desktop/src/views/PopupView/index.vue +++ b/apps/desktop/src/views/PopupView/index.vue @@ -2,7 +2,7 @@ + + + + diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue new file mode 100644 index 00000000..a4dceaff --- /dev/null +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -0,0 +1,733 @@ + + + + + diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index fe8aaed8..2b23952f 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -1,27 +1,17 @@ -