feat(desktop): make the toggle-window global shortcut customizable by cwyptt · Pull Request #2040 · SableClient/Sable · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/customizable-toggle-window-shortcut.md
111 changes: 106 additions & 5 deletions src-tauri/src/desktop/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ use tauri::{AppHandle, Manager};
#[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")]
Expand Down Expand Up @@ -63,10 +61,113 @@ pub fn global_shortcut_plugin() -> tauri::plugin::TauriPlugin<crate::BrowserEngi
.build()
}

pub fn register_global_shortcuts(app: &AppHandle<crate::BrowserEngine>) {
/// Convert a web hotkey binding (`"mod+shift+s"`) into a Tauri accelerator
/// string and validate it parses as a real `Shortcut`. The accelerator parser
/// is case-insensitive and accepts the web token names natively (`arrowdown`,
/// `home`, `f5`, …), so only the `mod` and `meta` aliases need rewriting.
fn web_to_tauri_accelerator(web: &str) -> Result<String, String> {
use tauri_plugin_global_shortcut::Shortcut;

let tauri_str = web
.split('+')
.map(|part| match part.to_lowercase().as_str() {
"mod" => "CmdOrCtrl".to_string(),
"meta" => "Super".to_string(),
_ => part.to_string(),
})
.collect::<Vec<_>>()
.join("+");

tauri_str
.parse::<Shortcut>()
.map_err(|error| format!("Invalid shortcut '{tauri_str}': {error}"))
.map(|_| tauri_str)
}

/// Error returned when a global shortcut is requested on a Wayland session.
/// Wayland compositors never route key presses through the X server unless an
/// X11 window is focused, so an XWayland grab "registers" but cannot fire
/// reliably. `global-hotkey` has no Wayland backend; X11 sessions work.
#[cfg(target_os = "linux")]
const WAYLAND_UNSUPPORTED: &str =
"Global shortcuts are not supported on Wayland sessions; they require an X11 session on Linux.";

#[cfg(target_os = "linux")]
fn linux_global_shortcut_error() -> Option<String> {
std::env::var_os("WAYLAND_DISPLAY")
.is_some()
.then(|| WAYLAND_UNSUPPORTED.to_string())
}

/// Apply the current toggle-window global shortcut. Unregisters any previously
/// registered shortcut, then registers the new one when `binding` is `Some`.
/// `binding` is in web hotkey format.
pub fn apply_toggle_window_shortcut(
app: &AppHandle<crate::BrowserEngine>,
binding: Option<&str>,
) -> Result<(), String> {
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}");
#[cfg(target_os = "linux")]
if let Some(error) = linux_global_shortcut_error() {
return Err(error);
}

// The app only ever registers a single global shortcut, so unregistering
// everything is precise and avoids tracking the live accelerator.
let _ = app.global_shortcut().unregister_all();

if let Some(web) = binding {
let accelerator = web_to_tauri_accelerator(web)?;
app.global_shortcut()
.register(accelerator.as_str())
.map_err(|error| format!("Failed to register shortcut '{accelerator}': {error}"))?;
}
Ok(())
}

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

#[test]
fn rewrites_mod_and_meta_tokens() {
assert_eq!(
web_to_tauri_accelerator("mod+shift+s").unwrap(),
"CmdOrCtrl+shift+s"
);
assert_eq!(web_to_tauri_accelerator("meta+j").unwrap(), "Super+j");
}

#[test]
fn passes_web_tokens_through_to_the_parser() {
assert!(web_to_tauri_accelerator("control+e").is_ok());
assert!(web_to_tauri_accelerator("alt+shift+arrowdown").is_ok());
assert!(web_to_tauri_accelerator("mod+home").is_ok());
assert!(web_to_tauri_accelerator("mod+shift+f5").is_ok());
}

#[test]
fn rejects_invalid_bindings() {
assert!(web_to_tauri_accelerator("").is_err());
assert!(web_to_tauri_accelerator("mod").is_err());
assert!(web_to_tauri_accelerator("mod+notakey").is_err());
}

#[test]
#[cfg(target_os = "linux")]
fn rejects_global_shortcuts_only_on_wayland_sessions() {
let original = std::env::var_os("WAYLAND_DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
assert!(linux_global_shortcut_error().is_none());
std::env::set_var("WAYLAND_DISPLAY", "wayland-1");
assert_eq!(
linux_global_shortcut_error(),
Some(WAYLAND_UNSUPPORTED.to_string())
);
match original {
Some(value) => std::env::set_var("WAYLAND_DISPLAY", value),
None => std::env::remove_var("WAYLAND_DISPLAY"),
}
}
}
7 changes: 7 additions & 0 deletions src-tauri/src/desktop/runtime_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use ts_rs::TS;
#[ts(export, export_to = "desktop/", rename_all = "camelCase")]
pub struct DesktopRuntimeState {
pub tray_available: bool,
/// Currently registered global toggle-window shortcut in web hotkey format
/// (e.g. `"mod+shift+s"`), or `None` when the feature is disabled.
#[ts(optional = nullable)]
pub toggle_window_shortcut: Option<String>,
}

#[cfg(test)]
Expand All @@ -18,12 +22,14 @@ mod tests {
fn desktop_runtime_state_serializes_with_camel_case_keys() {
let state = DesktopRuntimeState {
tray_available: true,
toggle_window_shortcut: Some("mod+shift+s".to_string()),
};

assert_eq!(
serde_json::to_value(state).unwrap(),
json!({
"trayAvailable": true,
"toggleWindowShortcut": "mod+shift+s",
})
);
}
Expand All @@ -34,5 +40,6 @@ mod tests {

assert!(output.contains("type DesktopRuntimeState"));
assert!(output.contains("trayAvailable: boolean"));
assert!(output.contains("toggleWindowShortcut?: string | null"));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/desktop/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub(crate) const CLOSE_TO_BACKGROUND_ON_CLOSE_KEY: &str = "closeToBackgroundOnCl
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 TOGGLE_WINDOW_SHORTCUT_KEY: &str = "toggleWindowShortcut";
pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning";

pub(crate) const fn use_custom_title_bar_default() -> bool {
Expand Down
100 changes: 83 additions & 17 deletions src-tauri/src/desktop/tray.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;

use crate::desktop::runtime_state::DesktopRuntimeState;
use crate::desktop::settings::{
desktop_settings_from_values, tray_available_for_session, use_custom_title_bar_default,
DesktopSettings, CLOSE_TO_BACKGROUND_ON_CLOSE_KEY, DESKTOP_SETTINGS_PATH,
LEGACY_KEEP_BACKGROUND_RUNNING_KEY, SHOW_SYSTEM_TRAY_ICON_KEY, SPELLCHECK_KEY,
USE_CUSTOM_TITLE_BAR_KEY,
TOGGLE_WINDOW_SHORTCUT_KEY, USE_CUSTOM_TITLE_BAR_KEY,
};
use serde_json::json;
use tauri::{
Expand All @@ -29,6 +30,10 @@ pub struct DesktopSettingsState {
use_custom_title_bar: AtomicBool,
spellcheck: AtomicBool,
tray_available: AtomicBool,
/// Currently registered toggle-window global shortcut in web hotkey format,
/// or `None` when the feature is disabled. Tracked so `desktop_runtime_state`
/// can report it without re-reading the store.
toggle_window_shortcut: Mutex<Option<String>>,
}

impl Default for DesktopSettingsState {
Expand All @@ -39,10 +44,21 @@ impl Default for DesktopSettingsState {
use_custom_title_bar: AtomicBool::new(use_custom_title_bar_default()),
spellcheck: AtomicBool::new(true),
tray_available: AtomicBool::new(false),
toggle_window_shortcut: Mutex::new(None),
}
}
}

impl DesktopSettingsState {
pub(crate) fn toggle_window_shortcut(&self) -> Option<String> {
self.toggle_window_shortcut.lock().unwrap().clone()
}

pub(crate) fn set_toggle_window_shortcut(&self, binding: Option<String>) {
*self.toggle_window_shortcut.lock().unwrap() = binding;
}
}

pub fn setup_close_to_background(webview_window: &WebviewWindow<crate::BrowserEngine>) {
let window = webview_window.clone();
webview_window.on_window_event(move |event| {
Expand All @@ -53,6 +69,7 @@ pub fn setup_close_to_background(webview_window: &WebviewWindow<crate::BrowserEn
if state.close_to_background_on_close.load(Ordering::Relaxed)
&& can_restore_from_background(DesktopRuntimeState {
tray_available: state.tray_available.load(Ordering::Relaxed),
toggle_window_shortcut: state.toggle_window_shortcut(),
})
{
api.prevent_close();
Expand Down Expand Up @@ -134,11 +151,10 @@ fn current_desktop_settings(app: &AppHandle<crate::BrowserEngine>) -> DesktopSet
}

fn desktop_runtime_state(app: &AppHandle<crate::BrowserEngine>) -> DesktopRuntimeState {
let state = app.state::<DesktopSettingsState>();
DesktopRuntimeState {
tray_available: app
.state::<DesktopSettingsState>()
.tray_available
.load(Ordering::Relaxed),
tray_available: state.tray_available.load(Ordering::Relaxed),
toggle_window_shortcut: state.toggle_window_shortcut(),
}
}

Expand All @@ -147,6 +163,50 @@ pub fn get_desktop_runtime_state(app: AppHandle<crate::BrowserEngine>) -> Deskto
desktop_runtime_state(&app)
}

/// Set the global toggle-window shortcut. `binding` is in web hotkey format
/// (`"mod+shift+s"`); `None` disables the feature. Persists to the desktop
/// preferences store, registers (or unregisters) the OS accelerator, and
/// returns the refreshed runtime state.
#[tauri::command]
pub fn set_toggle_window_shortcut(
app: AppHandle<crate::BrowserEngine>,
binding: Option<String>,
) -> Result<DesktopRuntimeState, String> {
let state = app.state::<DesktopSettingsState>();

crate::desktop::menu::apply_toggle_window_shortcut(&app, binding.as_deref())?;

// Persist after a successful (un)registration so the store never claims a
// binding the OS rejected.
app.store(DESKTOP_SETTINGS_PATH)
.map_err(|error| error.to_string())?
.set(TOGGLE_WINDOW_SHORTCUT_KEY, json!(binding));

state.set_toggle_window_shortcut(binding);
Ok(desktop_runtime_state(&app))
}

/// Called once at startup. Reads the persisted toggle-window shortcut from the
/// desktop preferences store and registers it if set. Nothing is registered
/// when the key is absent (the default — feature is off).
pub fn startup_register_toggle_window_shortcut(app: &AppHandle<crate::BrowserEngine>) {
let state = app.state::<DesktopSettingsState>();

let binding = app
.store(DESKTOP_SETTINGS_PATH)
.ok()
.and_then(|store| store.get(TOGGLE_WINDOW_SHORTCUT_KEY))
.and_then(|value| value.as_str().map(str::to_string));

state.set_toggle_window_shortcut(binding.clone());

if let Some(ref web) = binding {
if let Err(error) = crate::desktop::menu::apply_toggle_window_shortcut(app, Some(web)) {
log::warn!("Failed to register persisted toggle-window shortcut: {error}");
}
}
}

#[tauri::command]
pub fn sync_desktop_settings(
app: AppHandle<crate::BrowserEngine>,
Expand Down Expand Up @@ -391,12 +451,18 @@ mod tests {
desktop_settings_from_values, tray_available_for_session, DesktopSettings,
};

const TRAY_UP: DesktopRuntimeState = DesktopRuntimeState {
tray_available: true,
};
const NO_TRAY: DesktopRuntimeState = DesktopRuntimeState {
tray_available: false,
};
fn tray_up() -> DesktopRuntimeState {
DesktopRuntimeState {
tray_available: true,
toggle_window_shortcut: None,
}
}
fn no_tray() -> DesktopRuntimeState {
DesktopRuntimeState {
tray_available: false,
toggle_window_shortcut: None,
}
}

#[test]
fn close_behavior_keeps_sable_running() {
Expand All @@ -408,7 +474,7 @@ mod tests {
};

assert_eq!(
exit_request_action(settings, TRAY_UP, None),
exit_request_action(settings, tray_up(), None),
ExitRequestAction::CloseWindowsToBackground
);
}
Expand All @@ -423,7 +489,7 @@ mod tests {
};

assert_eq!(
exit_request_action(settings, TRAY_UP, None),
exit_request_action(settings, tray_up(), None),
ExitRequestAction::AllowExit
);
}
Expand All @@ -439,7 +505,7 @@ mod tests {

assert!(!tray_available_for_session(settings, false));
assert_eq!(
exit_request_action(settings, NO_TRAY, None),
exit_request_action(settings, no_tray(), None),
if cfg!(target_os = "macos") {
ExitRequestAction::CloseWindowsToBackground
} else {
Expand All @@ -451,10 +517,10 @@ mod tests {
#[test]
fn macos_closes_to_background_without_a_tray() {
assert_eq!(
can_restore_from_background(NO_TRAY),
can_restore_from_background(no_tray()),
cfg!(target_os = "macos")
);
assert!(can_restore_from_background(TRAY_UP));
assert!(can_restore_from_background(tray_up()));
}

#[test]
Expand All @@ -467,7 +533,7 @@ mod tests {
};

assert_eq!(
exit_request_action(settings, TRAY_UP, Some(0)),
exit_request_action(settings, tray_up(), Some(0)),
ExitRequestAction::AllowExit
);
}
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/lib.rs
Loading
Loading