From 761d73419e14d11eacd1dab53e353148e61c7456 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Sat, 8 Aug 2026 03:34:25 +0000 Subject: [PATCH] fix(shortcuts): make the global show/hide window shortcut opt-in and configurable --- .../configurable-global-window-shortcut.md | 5 ++ src-tauri/src/desktop/menu.rs | 53 +++++++++++++++++-- src-tauri/src/desktop/settings.rs | 1 + src-tauri/src/lib.rs | 2 + .../keyboard-shortcuts/KeyboardShortcuts.tsx | 38 +++++++++++-- src/app/generated/tauri/commands.ts | 4 ++ src/app/generated/tauri/types.ts | 5 ++ src/app/keyboard/shortcuts.test.ts | 25 +++++++++ src/app/keyboard/shortcuts.ts | 34 +++++++++++- 9 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 .changeset/configurable-global-window-shortcut.md diff --git a/.changeset/configurable-global-window-shortcut.md b/.changeset/configurable-global-window-shortcut.md new file mode 100644 index 0000000000..01ddb11529 --- /dev/null +++ b/.changeset/configurable-global-window-shortcut.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Make the global show/hide window shortcut (previously hardcoded to Ctrl+Shift+S) configurable in Settings > Keyboard Shortcuts, and disable it by default so Sable never claims a global hotkey unless you assign one yourself. diff --git a/src-tauri/src/desktop/menu.rs b/src-tauri/src/desktop/menu.rs index c52c69ae6b..c2d906abdd 100644 --- a/src-tauri/src/desktop/menu.rs +++ b/src-tauri/src/desktop/menu.rs @@ -1,12 +1,14 @@ #[cfg(target_os = "macos")] use tauri::Emitter; +use serde_json::json; use tauri::{AppHandle, Manager}; +use tauri_plugin_store::StoreExt; + +use crate::desktop::settings::{DESKTOP_SETTINGS_PATH, GLOBAL_WINDOW_SHORTCUT_KEY}; #[cfg(target_os = "macos")] pub const SETTINGS_MENU_ID: &str = "settings"; -pub const TOGGLE_WINDOW_ACCELERATOR: &str = "CmdOrCtrl+Shift+S"; - // Extend the standard menu (Edit submenu for webview copy/paste, Quit, Close) // with a Settings item. #[cfg(target_os = "macos")] @@ -63,10 +65,53 @@ pub fn global_shortcut_plugin() -> tauri::plugin::TauriPlugin) -> Option { + let store = app.store(DESKTOP_SETTINGS_PATH).ok()?; + // Absent, null and empty all mean "unregistered": no shortcut exists until + // the user assigns one. + store + .get(GLOBAL_WINDOW_SHORTCUT_KEY) + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|binding| !binding.is_empty()) +} + pub fn register_global_shortcuts(app: &AppHandle) { use tauri_plugin_global_shortcut::GlobalShortcutExt; - if let Err(error) = app.global_shortcut().register(TOGGLE_WINDOW_ACCELERATOR) { - log::warn!("Failed to register global show/hide shortcut: {error}"); + match get_global_window_shortcut(app) { + Some(binding) => { + if let Err(error) = app.global_shortcut().register(binding.as_str()) { + log::warn!("Failed to register global show/hide shortcut {binding}: {error}"); + } + } + None => log::info!("Global show/hide shortcut is unassigned"), + } +} + +#[tauri::command] +pub fn set_global_window_shortcut( + app: AppHandle, + shortcut: Option, +) -> Result<(), String> { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + let store = app + .store(DESKTOP_SETTINGS_PATH) + .map_err(|error| error.to_string())?; + + match &shortcut { + Some(binding) => store.set(GLOBAL_WINDOW_SHORTCUT_KEY, json!(binding)), + None => store.set(GLOBAL_WINDOW_SHORTCUT_KEY, json!(null)), + } + store.save().map_err(|error| error.to_string())?; + + app.global_shortcut() + .unregister_all() + .map_err(|error| error.to_string())?; + if let Some(binding) = &shortcut { + app.global_shortcut() + .register(binding.as_str()) + .map_err(|error| error.to_string())?; } + Ok(()) } diff --git a/src-tauri/src/desktop/settings.rs b/src-tauri/src/desktop/settings.rs index 21598694da..c5b7883c27 100644 --- a/src-tauri/src/desktop/settings.rs +++ b/src-tauri/src/desktop/settings.rs @@ -17,6 +17,7 @@ pub(crate) const SHOW_SYSTEM_TRAY_ICON_KEY: &str = "showSystemTrayIcon"; pub(crate) const USE_CUSTOM_TITLE_BAR_KEY: &str = "useCustomTitleBar"; pub(crate) const SPELLCHECK_KEY: &str = "spellcheck"; pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning"; +pub(crate) const GLOBAL_WINDOW_SHORTCUT_KEY: &str = "globalWindowShortcut"; pub(crate) const fn use_custom_title_bar_default() -> bool { cfg!(target_os = "windows") diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b99275d85f..5c65036fc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -497,6 +497,8 @@ pub fn run() { desktop::tray::get_desktop_runtime_state, #[cfg(desktop)] desktop::tray::sync_desktop_settings, + #[cfg(desktop)] + desktop::menu::set_global_window_shortcut, #[cfg(target_os = "linux")] desktop::tray_badge::set_tray_badge, #[cfg(windows)] diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx index 68a97379ac..b9c01fb400 100644 --- a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; +import { isTauri } from '@tauri-apps/api/core'; +import { setGlobalWindowShortcut } from '$generated/tauri/commands'; import { Box, Button, Scroll, Text, config } from 'folds'; import { PageContent, SettingsSectionPage } from '$components/page'; import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; @@ -11,11 +13,14 @@ import { findShortcutConflict, formatShortcut, getShortcutBinding, + toTauriAccelerator, } from '../../../keyboard/shortcuts'; import type { ShortcutDefinition, ShortcutId } from '../../../keyboard/shortcuts'; const CATEGORIES = ['General', 'Navigation', 'Messages'] as const; +const GLOBAL_WINDOW_SHORTCUT_ID: ShortcutId = 'app.toggleWindowVisibility'; + function ShortcutKeys({ binding }: { binding: string | null }) { const label = formatShortcut(binding); return ( @@ -126,6 +131,25 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu [setOverrides] ); + // Global shortcuts must register with the OS before they are persisted, so a + // binding the OS rejects never lands in the settings. + const applyBinding = useCallback( + async (id: ShortcutId, binding: string | null) => { + if (id === GLOBAL_WINDOW_SHORTCUT_ID && isTauri()) { + try { + await setGlobalWindowShortcut({ + shortcut: binding === null ? null : toTauriAccelerator(binding), + }); + } catch { + setError('That shortcut could not be registered on this device.'); + return; + } + } + updateOverride(id, binding); + }, + [updateOverride] + ); + useEffect(() => { const id = editingId; if (!id) return undefined; @@ -139,7 +163,7 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu return; } if (event.key === 'Backspace' || event.key === 'Delete') { - updateOverride(id, null); + void applyBinding(id, null); return; } const binding = captureShortcut(event); @@ -149,12 +173,20 @@ export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcu setError(`Already used by “${conflict.label}” in this context.`); return; } - updateOverride(id, binding); + void applyBinding(id, binding); }; window.addEventListener('keydown', handleCapture, true); return () => window.removeEventListener('keydown', handleCapture, true); - }, [editingId, overrides, updateOverride]); + }, [editingId, overrides, applyBinding]); + + useEffect(() => { + if (!isTauri()) return; + const binding = getShortcutBinding(GLOBAL_WINDOW_SHORTCUT_ID, overrides); + setGlobalWindowShortcut({ + shortcut: binding === null ? null : toTauriAccelerator(binding), + }).catch(() => undefined); + }, [overrides]); return ( { + return invoke('set_global_window_shortcut', params); +} + export async function setImmersiveMode(params: types.SetImmersiveModeParams): Promise { return invoke('set_immersive_mode', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 6bd95705b6..c070dc9d6e 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -132,6 +132,11 @@ export interface SaveMediaToPhotosParams { [key: string]: unknown; } +export interface SetGlobalWindowShortcutParams { + shortcut?: string | null; + [key: string]: unknown; +} + export interface SetImmersiveModeParams { enabled: boolean; [key: string]: unknown; diff --git a/src/app/keyboard/shortcuts.test.ts b/src/app/keyboard/shortcuts.test.ts index c554e9344c..82123dc8ae 100644 --- a/src/app/keyboard/shortcuts.test.ts +++ b/src/app/keyboard/shortcuts.test.ts @@ -5,6 +5,7 @@ import { getShortcutBinding, matchesShortcut, sanitizeShortcutOverrides, + toTauriAccelerator, } from './shortcuts'; const keyEvent = (key: string, init: Partial = {}) => @@ -60,4 +61,28 @@ describe('keyboard shortcuts', () => { }) ).toEqual({ 'composer.bold': 'alt+b', 'composer.italic': null }); }); + + it('leaves the global window shortcut unassigned by default', () => { + expect(getShortcutBinding('app.toggleWindowVisibility', {})).toBe(null); + }); + + it('converts captured bindings to Tauri accelerator strings', () => { + expect(toTauriAccelerator('mod+shift+s')).toBe('CmdOrCtrl+Shift+S'); + expect(toTauriAccelerator('ctrl+alt+c')).toBe('Ctrl+Alt+C'); + expect(toTauriAccelerator('alt+space')).toBe('Alt+Space'); + expect(toTauriAccelerator('shift+arrowup')).toBe('Shift+Up'); + expect(toTauriAccelerator('mod+f')).toBe('CmdOrCtrl+F'); + expect(toTauriAccelerator('mod+escape')).toBe('CmdOrCtrl+Esc'); + }); + + it('round-trips captured shortcuts through the Tauri format', () => { + for (const [key, init, expected] of [ + ['S', { ctrlKey: true, shiftKey: true }, 'CmdOrCtrl+Shift+S'], + ['C', { ctrlKey: true, altKey: true }, 'CmdOrCtrl+Alt+C'], + ['ArrowUp', { shiftKey: true }, 'Shift+Up'], + ] as const) { + const captured = captureShortcut(keyEvent(key, init))!; + expect(toTauriAccelerator(captured)).toBe(expected); + } + }); }); diff --git a/src/app/keyboard/shortcuts.ts b/src/app/keyboard/shortcuts.ts index f85ee4c6dc..9de32fa164 100644 --- a/src/app/keyboard/shortcuts.ts +++ b/src/app/keyboard/shortcuts.ts @@ -9,6 +9,7 @@ export type ShortcutId = | 'app.searchMessages' | 'app.openBookmarks' | 'app.createRoom' + | 'app.toggleWindowVisibility' | 'navigation.nextUnread' | 'navigation.cycleNextUnread' | 'navigation.cyclePreviousUnread' @@ -38,7 +39,7 @@ export type ShortcutDefinition = { id: ShortcutId; label: string; category: 'General' | 'Navigation' | 'Messages'; - defaultBinding: string; + defaultBinding: string | null; scope: ShortcutScope; allowInEditable?: boolean; }; @@ -66,6 +67,14 @@ export const SHORTCUTS: readonly ShortcutDefinition[] = [ defaultBinding: 'mod+shift+n', scope: 'global', }, + { + id: 'app.toggleWindowVisibility', + label: 'Show/Hide Sable', + category: 'General', + // Opt-in: disabled by default so Sable never claims a system-wide key on its own. + defaultBinding: null, + scope: 'global', + }, { id: 'navigation.nextUnread', label: 'Jump to the highest-priority unread room', @@ -294,6 +303,29 @@ export const captureShortcut = (event: ShortcutEvent): string | undefined => { return [...modifiers, key].join('+'); }; +export const toTauriAccelerator = (binding: string): string => { + return binding + .split('+') + .map((part) => { + const lower = part.toLowerCase(); + if (lower === 'mod' || lower === 'cmd' || lower === 'command') return 'CmdOrCtrl'; + if (lower === 'control' || lower === 'ctrl') return 'Ctrl'; + if (lower === 'shift') return 'Shift'; + if (lower === 'alt' || lower === 'option') return 'Alt'; + if (lower === 'meta') return 'Super'; + if (lower === 'space') return 'Space'; + if (lower === 'arrowup') return 'Up'; + if (lower === 'arrowdown') return 'Down'; + if (lower === 'arrowleft') return 'Left'; + if (lower === 'arrowright') return 'Right'; + if (lower === 'escape') return 'Esc'; + if (lower === 'enter') return 'Enter'; + if (lower === 'tab') return 'Tab'; + return lower.length === 1 ? lower.toUpperCase() : lower; + }) + .join('+'); +}; + export const findShortcutConflict = ( id: ShortcutId, binding: string,