diff --git a/apps/linux/src-tauri/Cargo.lock b/apps/linux/src-tauri/Cargo.lock index ebddbc4d444a..ea438a9a50be 100644 --- a/apps/linux/src-tauri/Cargo.lock +++ b/apps/linux/src-tauri/Cargo.lock @@ -1322,6 +1322,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1441,6 +1451,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2550,6 +2578,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-deep-link", + "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", @@ -3977,6 +4006,21 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -5526,6 +5570,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5536,6 +5597,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/apps/linux/src-tauri/Cargo.toml b/apps/linux/src-tauri/Cargo.toml index 9920d9eecff5..d094adcbe55e 100644 --- a/apps/linux/src-tauri/Cargo.toml +++ b/apps/linux/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ serde_json = "1.0.150" tauri = { version = "2.11.5", features = ["image-png", "tray-icon"] } tauri-plugin-autostart = "2.5.1" tauri-plugin-deep-link = "2.4.9" +tauri-plugin-global-shortcut = "2.3.2" tauri-plugin-notification = "2.3.3" tauri-plugin-opener = "2.5.4" tauri-plugin-process = "2.3.1" diff --git a/apps/linux/src-tauri/src/main.rs b/apps/linux/src-tauri/src/main.rs index ac4fe629e5a2..2b1cb4254dcf 100644 --- a/apps/linux/src-tauri/src/main.rs +++ b/apps/linux/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod discovery; mod gateway; mod installer; mod notify; +mod pending_approvals; mod tray; mod updater; @@ -144,6 +145,7 @@ struct DesktopInner { cli: Mutex>, navigation: Mutex, operation: Mutex<()>, + pending_approvals: Mutex, local_url: Url, tray: Mutex>, quitting: AtomicBool, @@ -161,6 +163,7 @@ impl DesktopState { cli: Mutex::new(None), navigation: Mutex::new(NavigationState::default()), operation: Mutex::new(()), + pending_approvals: Mutex::new(pending_approvals::PendingApprovalState::default()), local_url, tray: Mutex::new(None), quitting: AtomicBool::new(false), @@ -295,6 +298,41 @@ impl DesktopState { } } + fn poll_pending_approvals(&self, app: &AppHandle, cli: &OpenClawCli, generation: u64) { + let pending = match pending_approvals::fetch(cli) { + Ok(pending) => pending, + Err(error) => { + eprintln!("Could not poll pending approvals: {error}"); + return; + } + }; + if !self.watchdog_is_current(generation) { + return; + } + let diff = self + .inner + .pending_approvals + .lock() + .expect("pending approval mutex poisoned") + .update(&pending); + if let Some(tray) = self + .inner + .tray + .lock() + .expect("tray mutex poisoned") + .as_ref() + { + tray.update_pending_count(diff.count); + } + if !main_window(app).is_ok_and(|window| matches!(window.is_focused(), Ok(false))) { + return; + } + // Notifications are a doorbell only; approval stays in the dashboard or CLI. + for request in diff.new { + notify::notify(app, "OpenClaw", &request.notification_body()); + } + } + // Caller holds the navigation lock, keeping the final arbitration check and navigation atomic. fn navigate_locked( &self, @@ -413,6 +451,9 @@ impl DesktopState { }; if snapshot.reachable { state.update_tray(&snapshot); + drop(_operation); + // Pairing polls ride connected watchdog ticks; the reconnect loop never runs them. + state.poll_pending_approvals(&app, &cli, generation); continue; } @@ -619,6 +660,7 @@ async fn gateway_action( } fn main() { + let global_shortcuts_supported = tray::global_shortcuts_supported(); // Single-instance must run first so it can pass deep-link argv to the primary process. let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -628,7 +670,23 @@ fn main() { .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, - )) + )); + // global-hotkey's Linux backend is X11-only; omit it on Wayland instead of using XWayland. + // A GlobalShortcuts portal can follow later. + let builder = if global_shortcuts_supported { + builder.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { + tray::show_window(app); + } + }) + .build(), + ) + } else { + builder + }; + let builder = builder .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) @@ -641,7 +699,7 @@ fn main() { #[cfg(target_os = "linux")] let builder = canvas::register_protocol(builder); - let builder = builder.setup(|app| { + let builder = builder.setup(move |app| { let window = app .get_webview_window("main") .expect("tauri.conf.json must define the main window"); @@ -668,7 +726,7 @@ fn main() { } Err(error) => eprintln!("Canvas bridge unavailable: {error}"), } - state.set_tray(tray::build(app, state.clone())?); + state.set_tray(tray::build(app, state.clone(), global_shortcuts_supported)?); Ok(()) }); #[cfg(target_os = "linux")] diff --git a/apps/linux/src-tauri/src/pending_approvals.rs b/apps/linux/src-tauri/src/pending_approvals.rs new file mode 100644 index 000000000000..29a8e6d61515 --- /dev/null +++ b/apps/linux/src-tauri/src/pending_approvals.rs @@ -0,0 +1,204 @@ +use crate::cli::OpenClawCli; +use serde::Deserialize; +use std::collections::HashSet; +use std::process::Output; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum ApprovalKind { + Node, + Device, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingApproval { + kind: ApprovalKind, + request_id: String, + label: String, +} + +impl PendingApproval { + pub fn notification_body(&self) -> String { + let kind = match self.kind { + ApprovalKind::Node => "Node", + ApprovalKind::Device => "Device", + }; + format!( + "{kind} pairing request from {} — open the dashboard to approve", + self.label + ) + } +} + +#[derive(Default)] +pub struct PendingApprovalState { + visible: HashSet, +} + +pub struct PendingApprovalDiff { + pub new: Vec, + pub count: usize, +} + +impl PendingApprovalState { + pub fn update(&mut self, current: &[PendingApproval]) -> PendingApprovalDiff { + let (new, visible) = diff_pending(&self.visible, current); + let count = visible.len(); + self.visible = visible; + PendingApprovalDiff { new, count } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodePendingRequest { + request_id: String, + node_id: String, + display_name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevicePendingRequest { + request_id: String, + device_id: String, + display_name: Option, + client_id: Option, +} + +#[derive(Deserialize)] +struct DevicePairingList { + #[serde(default)] + pending: Vec, +} + +pub fn fetch(cli: &OpenClawCli) -> Result, String> { + let (nodes, node_output) = cli + .json::, _, _>(["nodes", "pending", "--json"]) + .map_err(|error| error.to_string())?; + require_success("nodes pending", &node_output)?; + + let (devices, device_output) = cli + .json::(["devices", "list", "--json"]) + .map_err(|error| error.to_string())?; + require_success("devices list", &device_output)?; + + let mut pending = Vec::with_capacity(nodes.len() + devices.pending.len()); + pending.extend(nodes.into_iter().map(|request| PendingApproval { + kind: ApprovalKind::Node, + request_id: request.request_id, + label: preferred_label([request.display_name.as_deref(), Some(&request.node_id)]), + })); + pending.extend(devices.pending.into_iter().map(|request| PendingApproval { + kind: ApprovalKind::Device, + request_id: request.request_id, + label: preferred_label([ + request.display_name.as_deref(), + request.client_id.as_deref(), + Some(&request.device_id), + ]), + })); + Ok(pending) +} + +fn require_success(command: &str, output: &Output) -> Result<(), String> { + if output.status.success() { + Ok(()) + } else { + Err(format!("openclaw {command} exited with {}", output.status)) + } +} + +fn preferred_label<'a>(candidates: impl IntoIterator>) -> String { + candidates + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .unwrap_or("unknown") + .to_string() +} + +// Only successful snapshots replace `visible`; failed polls must leave dedupe state intact. +fn diff_pending( + previous: &HashSet, + current: &[PendingApproval], +) -> (Vec, HashSet) { + let mut visible = HashSet::with_capacity(current.len()); + let mut new = Vec::new(); + for request in current { + if visible.insert(request.request_id.clone()) && !previous.contains(&request.request_id) { + new.push(request.clone()); + } + } + (new, visible) +} + +#[cfg(test)] +mod tests { + use super::{diff_pending, ApprovalKind, PendingApproval, PendingApprovalState}; + use std::collections::HashSet; + + fn request(kind: ApprovalKind, id: &str, label: &str) -> PendingApproval { + PendingApproval { + kind, + request_id: id.to_string(), + label: label.to_string(), + } + } + + #[test] + fn diff_reports_new_ids_once_and_deduplicates_a_snapshot() { + let current = vec![ + request(ApprovalKind::Node, "node-1", "Kitchen Mac"), + request(ApprovalKind::Node, "node-1", "Kitchen Mac"), + request(ApprovalKind::Device, "device-1", "Browser"), + ]; + + let (new, visible) = diff_pending(&HashSet::new(), ¤t); + + assert_eq!(new, vec![current[0].clone(), current[2].clone()]); + assert_eq!(visible.len(), 2); + } + + #[test] + fn state_deduplicates_retained_ids_and_forgets_removed_ids() { + let node = request(ApprovalKind::Node, "request-1", "Kitchen Mac"); + let device = request(ApprovalKind::Device, "request-2", "Browser"); + let mut state = PendingApprovalState::default(); + + let first = state.update(&[node.clone(), device.clone()]); + assert_eq!(first.new, vec![node.clone(), device.clone()]); + assert_eq!(first.count, 2); + + let retained = state.update(std::slice::from_ref(&device)); + assert!(retained.new.is_empty()); + assert_eq!(retained.count, 1); + + let returned = state.update(&[node.clone(), device]); + assert_eq!(returned.new, vec![node]); + assert_eq!(returned.count, 2); + } + + #[test] + fn same_id_is_deduplicated_across_request_kinds() { + let node = request(ApprovalKind::Node, "same-id", "Node"); + let device = request(ApprovalKind::Device, "same-id", "Browser"); + + let (new, visible) = diff_pending(&HashSet::new(), &[node.clone(), device]); + + assert_eq!(new, vec![node]); + assert_eq!(visible.len(), 1); + } + + #[test] + fn notification_copy_names_request_kind_and_source() { + assert_eq!( + request(ApprovalKind::Node, "request-1", "Kitchen Mac").notification_body(), + "Node pairing request from Kitchen Mac — open the dashboard to approve" + ); + assert_eq!( + request(ApprovalKind::Device, "request-2", "Browser").notification_body(), + "Device pairing request from Browser — open the dashboard to approve" + ); + } +} diff --git a/apps/linux/src-tauri/src/tray.rs b/apps/linux/src-tauri/src/tray.rs index acc74b203209..ffdfe218f1e4 100644 --- a/apps/linux/src-tauri/src/tray.rs +++ b/apps/linux/src-tauri/src/tray.rs @@ -1,13 +1,21 @@ use crate::gateway::{GatewayAction, GatewaySnapshot}; use crate::DesktopState; -use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::menu::{CheckMenuItem, MenuBuilder, MenuItem, PredefinedMenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tauri::{App, AppHandle, Manager}; use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_global_shortcut::GlobalShortcutExt; const OPEN_ID: &str = "open-dashboard"; const CHECK_UPDATES_ID: &str = "check-for-updates"; const START_AT_LOGIN_ID: &str = "start-at-login"; +const GLOBAL_SHORTCUT_ID: &str = "global-shortcut"; +const GLOBAL_SHORTCUT: &str = "CmdOrCtrl+Shift+O"; +// Marker presence is the durable user opt-out across restarts; no config schema on purpose. +const GLOBAL_SHORTCUT_DISABLED_MARKER: &str = "global-shortcut-disabled"; const START_ID: &str = "start-gateway"; const STOP_ID: &str = "stop-gateway"; const RESTART_ID: &str = "restart-gateway"; @@ -16,19 +24,81 @@ const QUIT_ID: &str = "quit"; pub struct TrayHandles { _tray: TrayIcon, status: MenuItem, + status_line: Mutex, open: MenuItem, _check_updates: MenuItem, _start_at_login: CheckMenuItem, + _global_shortcut: Option>, start: MenuItem, stop: MenuItem, restart: MenuItem, } +struct StatusLine { + gateway: String, + pending_count: usize, +} + +#[derive(Debug, PartialEq, Eq)] +struct GlobalShortcutInitialState { + should_register: bool, + checked: bool, +} + +fn global_shortcut_initial_state(marker_exists: bool) -> GlobalShortcutInitialState { + let enabled = !marker_exists; + GlobalShortcutInitialState { + should_register: enabled, + checked: enabled, + } +} + +#[cfg(any(target_os = "linux", test))] +fn linux_global_shortcuts_supported( + session_type: Option<&str>, + wayland_display: Option<&str>, + display: Option<&str>, +) -> bool { + session_type.is_some_and(|value| value.eq_ignore_ascii_case("x11")) + || (wayland_display.is_none() && display.is_some()) +} + +pub fn global_shortcuts_supported() -> bool { + #[cfg(target_os = "linux")] + { + let session_type = std::env::var("XDG_SESSION_TYPE").ok(); + let wayland_display = std::env::var("WAYLAND_DISPLAY").ok(); + let display = std::env::var("DISPLAY").ok(); + linux_global_shortcuts_supported( + session_type.as_deref(), + wayland_display.as_deref(), + display.as_deref(), + ) + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + +impl StatusLine { + fn text(&self) -> String { + match self.pending_count { + 0 => format!("Gateway: {}", self.gateway), + 1 => format!("Gateway: {} · 1 approval pending", self.gateway), + count => format!("Gateway: {} · {count} approvals pending", self.gateway), + } + } +} + impl TrayHandles { pub fn update(&self, snapshot: &GatewaySnapshot) { - let _ = self - .status - .set_text(format!("Gateway: {}", snapshot.status)); + let mut status_line = self.status_line.lock().expect("tray status mutex poisoned"); + status_line.gateway.clone_from(&snapshot.status); + if !snapshot.reachable { + status_line.pending_count = 0; + } + let _ = self.status.set_text(status_line.text()); let _ = self.open.set_enabled(true); let _ = self .start @@ -38,9 +108,19 @@ impl TrayHandles { .set_enabled(snapshot.installed && snapshot.running); let _ = self.restart.set_enabled(snapshot.installed); } + + pub fn update_pending_count(&self, count: usize) { + let mut status_line = self.status_line.lock().expect("tray status mutex poisoned"); + status_line.pending_count = count; + let _ = self.status.set_text(status_line.text()); + } } -pub fn build(app: &App, state: DesktopState) -> tauri::Result { +pub fn build( + app: &App, + state: DesktopState, + global_shortcuts_supported: bool, +) -> tauri::Result { let status = MenuItem::with_id( app, "gateway-status", @@ -71,6 +151,27 @@ pub fn build(app: &App, state: DesktopState) -> tauri::Result { autostart_enabled, None::<&str>, )?; + let shortcut_initial_state = global_shortcuts_supported.then(|| { + let shortcut_marker = global_shortcut_disabled_marker(app); + global_shortcut_initial_state( + shortcut_marker + .as_deref() + .is_some_and(global_shortcut_marker_exists), + ) + }); + let global_shortcut = shortcut_initial_state + .as_ref() + .map(|initial_state| { + CheckMenuItem::with_id( + app, + GLOBAL_SHORTCUT_ID, + "Enable Global Shortcut", + true, + initial_state.checked, + None::<&str>, + ) + }) + .transpose()?; let start = MenuItem::with_id(app, START_ID, "Start Gateway", false, None::<&str>)?; let stop = MenuItem::with_id(app, STOP_ID, "Stop Gateway", false, None::<&str>)?; let restart = MenuItem::with_id(app, RESTART_ID, "Restart Gateway", false, None::<&str>)?; @@ -78,32 +179,45 @@ pub fn build(app: &App, state: DesktopState) -> tauri::Result { let separator_one = PredefinedMenuItem::separator(app)?; let separator_two = PredefinedMenuItem::separator(app)?; let separator_three = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items( - app, - &[ - &status, - &separator_one, - &open, - &check_updates, - &start_at_login, + let menu_builder = MenuBuilder::new(app).items(&[ + &status, + &separator_one, + &open, + &check_updates, + &start_at_login, + ]); + let menu_builder = if let Some(global_shortcut) = global_shortcut.as_ref() { + menu_builder.item(global_shortcut) + } else { + menu_builder + }; + let menu = menu_builder + .items(&[ &separator_two, &start, &stop, &restart, &separator_three, &quit, - ], - )?; + ]) + .build()?; let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?; let menu_state = state.clone(); let menu_start_at_login = start_at_login.clone(); + let menu_global_shortcut = global_shortcut.clone(); let tray_builder = TrayIconBuilder::with_id("openclaw-main") .icon(tray_icon) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(move |app, event| { - handle_menu(app, &menu_state, &menu_start_at_login, event.id().as_ref()); + handle_menu( + app, + &menu_state, + &menu_start_at_login, + menu_global_shortcut.as_ref(), + event.id().as_ref(), + ); }) // Linux tray backends expose the Open action through the menu; Tauri also // emits this direct click event on platforms that support it. @@ -122,13 +236,28 @@ pub fn build(app: &App, state: DesktopState) -> tauri::Result { #[cfg(target_os = "macos")] let tray_builder = tray_builder.icon_as_template(true); let tray = tray_builder.build(app)?; + if let (Some(initial_state), Some(global_shortcut)) = + (shortcut_initial_state, global_shortcut.as_ref()) + { + if initial_state.should_register { + if let Err(error) = app.global_shortcut().register(GLOBAL_SHORTCUT) { + eprintln!("Could not register global shortcut {GLOBAL_SHORTCUT}: {error}"); + set_global_shortcut_checked(global_shortcut, false); + } + } + } Ok(TrayHandles { _tray: tray, status, + status_line: Mutex::new(StatusLine { + gateway: "Checking…".to_string(), + pending_count: 0, + }), open, _check_updates: check_updates, _start_at_login: start_at_login, + _global_shortcut: global_shortcut, start, stop, restart, @@ -152,6 +281,7 @@ fn handle_menu( app: &AppHandle, state: &DesktopState, start_at_login: &CheckMenuItem, + global_shortcut: Option<&CheckMenuItem>, id: &str, ) { match id { @@ -165,6 +295,11 @@ fn handle_menu( crate::updater::spawn_check(app.clone()); } START_AT_LOGIN_ID => toggle_autostart(app, start_at_login), + GLOBAL_SHORTCUT_ID => { + if let Some(global_shortcut) = global_shortcut { + toggle_global_shortcut(app, global_shortcut); + } + } START_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Start), STOP_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Stop), RESTART_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Restart), @@ -172,6 +307,76 @@ fn handle_menu( } } +fn toggle_global_shortcut(app: &AppHandle, item: &CheckMenuItem) { + let manager = app.global_shortcut(); + let enabled = manager.is_registered(GLOBAL_SHORTCUT); + let next = !enabled; + let result = if next { + manager.register(GLOBAL_SHORTCUT) + } else { + manager.unregister(GLOBAL_SHORTCUT) + }; + match result { + Ok(()) => { + let registered = manager.is_registered(GLOBAL_SHORTCUT); + persist_global_shortcut_state(app, registered); + set_global_shortcut_checked(item, registered); + } + Err(error) => { + eprintln!("Could not update global shortcut {GLOBAL_SHORTCUT}: {error}"); + set_global_shortcut_checked(item, enabled); + } + } +} + +fn global_shortcut_disabled_marker(app: &impl Manager) -> Option { + match app.path().app_config_dir() { + Ok(path) => Some(path.join(GLOBAL_SHORTCUT_DISABLED_MARKER)), + Err(error) => { + eprintln!("Could not resolve global shortcut preference path: {error}"); + None + } + } +} + +fn global_shortcut_marker_exists(path: &Path) -> bool { + match path.try_exists() { + Ok(exists) => exists, + Err(error) => { + eprintln!("Could not read global shortcut preference: {error}"); + false + } + } +} + +fn persist_global_shortcut_state(app: &AppHandle, registered: bool) { + let Some(marker) = global_shortcut_disabled_marker(app) else { + return; + }; + let result = if registered { + match fs::remove_file(&marker) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } else { + marker + .parent() + .map(fs::create_dir_all) + .transpose() + .and_then(|_| fs::write(&marker, b"")) + }; + if let Err(error) = result { + eprintln!("Could not persist global shortcut preference: {error}"); + } +} + +fn set_global_shortcut_checked(item: &CheckMenuItem, checked: bool) { + if let Err(error) = item.set_checked(checked) { + eprintln!("Could not update global shortcut menu state: {error}"); + } +} + fn toggle_autostart(app: &AppHandle, item: &CheckMenuItem) { let manager = app.autolaunch(); let enabled = match manager.is_enabled() { @@ -213,3 +418,62 @@ fn spawn_action(app: AppHandle, state: DesktopState, action: GatewayAction) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn linux_shortcut_support_follows_x11_session_facts() { + assert!(linux_global_shortcuts_supported( + Some("x11"), + Some("wayland-0"), + Some(":0"), + )); + assert!(linux_global_shortcuts_supported(None, None, Some(":0"))); + assert!(!linux_global_shortcuts_supported( + Some("wayland"), + Some("wayland-0"), + Some(":0"), + )); + assert!(!linux_global_shortcuts_supported( + None, + Some("wayland-0"), + Some(":0"), + )); + assert!(!linux_global_shortcuts_supported(None, None, None)); + } + + #[test] + fn global_shortcut_marker_disables_startup_registration() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock before Unix epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "openclaw-global-shortcut-test-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).expect("create test directory"); + let marker = directory.join(GLOBAL_SHORTCUT_DISABLED_MARKER); + + assert_eq!( + global_shortcut_initial_state(marker.exists()), + GlobalShortcutInitialState { + should_register: true, + checked: true, + } + ); + fs::write(&marker, b"").expect("write opt-out marker"); + assert_eq!( + global_shortcut_initial_state(marker.exists()), + GlobalShortcutInitialState { + should_register: false, + checked: false, + } + ); + + fs::remove_dir_all(directory).expect("remove test directory"); + } +}