feat(linux): pending-approval notifications and a summon shortcut for the companion (#109322)

The companion now rings the doorbell for gateway approvals: it polls pending
node and device pairing requests over the existing CLI seam on the connected
watchdog cadence, fires one native notification per new request when the
window is unfocused, and shows a pending count in the tray status line.
Approval itself stays in the dashboard/CLI (owner boundary).

Adds a CmdOrCtrl+Shift+O summon shortcut with a tray toggle whose opt-out
persists as a marker file (no config schema). The X11-only global-hotkey
backend is gated off on native Wayland sessions — the feature is omitted
rather than half-registered through XWayland; a portal implementation can
follow.
This commit is contained in:
Peter Steinberger
2026-07-16 13:06:18 -07:00
committed by GitHub
parent 54eb03fcf0
commit da8a443a4d
5 changed files with 613 additions and 19 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<Option<OpenClawCli>>,
navigation: Mutex<NavigationState>,
operation: Mutex<()>,
pending_approvals: Mutex<pending_approvals::PendingApprovalState>,
local_url: Url,
tray: Mutex<Option<tray::TrayHandles>>,
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")]

View File

@@ -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<String>,
}
pub struct PendingApprovalDiff {
pub new: Vec<PendingApproval>,
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<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevicePendingRequest {
request_id: String,
device_id: String,
display_name: Option<String>,
client_id: Option<String>,
}
#[derive(Deserialize)]
struct DevicePairingList {
#[serde(default)]
pending: Vec<DevicePendingRequest>,
}
pub fn fetch(cli: &OpenClawCli) -> Result<Vec<PendingApproval>, String> {
let (nodes, node_output) = cli
.json::<Vec<NodePendingRequest>, _, _>(["nodes", "pending", "--json"])
.map_err(|error| error.to_string())?;
require_success("nodes pending", &node_output)?;
let (devices, device_output) = cli
.json::<DevicePairingList, _, _>(["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<Item = Option<&'a str>>) -> 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<String>,
current: &[PendingApproval],
) -> (Vec<PendingApproval>, HashSet<String>) {
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(), &current);
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"
);
}
}

View File

@@ -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<tauri::Wry>,
status: MenuItem<tauri::Wry>,
status_line: Mutex<StatusLine>,
open: MenuItem<tauri::Wry>,
_check_updates: MenuItem<tauri::Wry>,
_start_at_login: CheckMenuItem<tauri::Wry>,
_global_shortcut: Option<CheckMenuItem<tauri::Wry>>,
start: MenuItem<tauri::Wry>,
stop: MenuItem<tauri::Wry>,
restart: MenuItem<tauri::Wry>,
}
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<TrayHandles> {
pub fn build(
app: &App,
state: DesktopState,
global_shortcuts_supported: bool,
) -> tauri::Result<TrayHandles> {
let status = MenuItem::with_id(
app,
"gateway-status",
@@ -71,6 +151,27 @@ pub fn build(app: &App, state: DesktopState) -> tauri::Result<TrayHandles> {
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<TrayHandles> {
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<TrayHandles> {
#[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<tauri::Wry>,
global_shortcut: Option<&CheckMenuItem<tauri::Wry>>,
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<tauri::Wry>) {
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<tauri::Wry>) -> Option<PathBuf> {
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<tauri::Wry>, 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<tauri::Wry>) {
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");
}
}