Files
openclaw/apps/linux/src-tauri/src/tray.rs
Peter Steinberger 277f009cc9 feat(linux): Quick Chat floating composer for the Tauri companion (#109947)
* feat(linux): add Quick Chat floating composer to the Tauri companion

- frameless transparent always-on-top quickchat window (on-demand, canvas
  pattern), positioned top-third on the cursor monitor, CSS fade+zoom in/out
- global shortcut Ctrl+Shift+Space on X11 (GNOME reserves Alt+Space); tray
  'Quick Chat' item is the Wayland entry point; blur/Esc/close-request hide
- sends via the CLI agent turn with --message-file staged 0600 (argv is
  world-readable procfs) and cleaned up after the turn; failures surface as
  desktop notifications; Ctrl+Enter also opens the dashboard
- default-agent identity chip (emoji/monogram) from agents list --json, 60s cache
- window-scoped capability + permission set; window-state denylist; docs

* chore(linux): register quickchat.js as a knip native-asset entry
2026-07-17 18:25:27 +01:00

498 lines
16 KiB
Rust

use crate::gateway::{GatewayAction, GatewaySnapshot};
use crate::quickchat;
use crate::DesktopState;
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 QUICKCHAT_ID: &str = "quickchat";
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";
const QUIT_ID: &str = "quit";
pub struct TrayHandles {
_tray: TrayIcon<tauri::Wry>,
status: MenuItem<tauri::Wry>,
status_line: Mutex<StatusLine>,
_quickchat: MenuItem<tauri::Wry>,
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 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
.set_enabled(snapshot.installed && !snapshot.running && !snapshot.reachable);
let _ = self
.stop
.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,
global_shortcuts_supported: bool,
) -> tauri::Result<TrayHandles> {
let status = MenuItem::with_id(
app,
"gateway-status",
"Gateway: Checking…",
false,
None::<&str>,
)?;
let quickchat = MenuItem::with_id(app, QUICKCHAT_ID, "Quick Chat", true, None::<&str>)?;
let open = MenuItem::with_id(app, OPEN_ID, "Open Dashboard", true, None::<&str>)?;
let check_updates = MenuItem::with_id(
app,
CHECK_UPDATES_ID,
"Check for Updates",
true,
None::<&str>,
)?;
let autostart_enabled = match app.autolaunch().is_enabled() {
Ok(enabled) => enabled,
Err(error) => {
eprintln!("Could not read autostart state: {error}");
false
}
};
let start_at_login = CheckMenuItem::with_id(
app,
START_AT_LOGIN_ID,
"Start at Login",
true,
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>)?;
let quit = MenuItem::with_id(app, QUIT_ID, "Quit OpenClaw", true, None::<&str>)?;
let separator_one = PredefinedMenuItem::separator(app)?;
let separator_two = PredefinedMenuItem::separator(app)?;
let separator_three = PredefinedMenuItem::separator(app)?;
let menu_builder = MenuBuilder::new(app).items(&[
&status,
&separator_one,
&quickchat,
&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,
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.
.on_tray_icon_event(|tray, event| {
if matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
) {
show_window(tray.app_handle());
}
});
#[cfg(target_os = "macos")]
let tray_builder = tray_builder.icon_as_template(true);
let tray = tray_builder.build(app)?;
if global_shortcuts_supported {
if let Err(error) = app
.global_shortcut()
.register(quickchat::QUICKCHAT_SHORTCUT)
{
eprintln!(
"Could not register Quick Chat shortcut {}: {error}",
quickchat::QUICKCHAT_SHORTCUT
);
}
}
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,
}),
_quickchat: quickchat,
open,
_check_updates: check_updates,
_start_at_login: start_at_login,
_global_shortcut: global_shortcut,
start,
stop,
restart,
})
}
pub fn show_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
pub fn open_dashboard(app: &AppHandle, state: &DesktopState) {
show_window(app);
spawn_connect(app.clone(), state.clone());
}
fn handle_menu(
app: &AppHandle,
state: &DesktopState,
start_at_login: &CheckMenuItem<tauri::Wry>,
global_shortcut: Option<&CheckMenuItem<tauri::Wry>>,
id: &str,
) {
match id {
QUIT_ID => {
state.quit();
app.exit(0);
}
QUICKCHAT_ID => quickchat::toggle_quickchat(app),
OPEN_ID => open_dashboard(app, state),
CHECK_UPDATES_ID => {
show_window(app);
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),
_ => {}
}
}
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() {
Ok(enabled) => enabled,
Err(error) => {
eprintln!("Could not read autostart state: {error}");
return;
}
};
let next = !enabled;
let result = if next {
manager.enable()
} else {
manager.disable()
};
match result {
Ok(()) => {
let _ = item.set_checked(next);
}
Err(error) => {
eprintln!("Could not update autostart state: {error}");
let _ = item.set_checked(enabled);
}
}
}
fn spawn_connect(app: AppHandle, state: DesktopState) {
std::thread::spawn(move || {
if let Err(error) = state.connect_explicit_local(&app) {
state.show_error(&app, &error);
}
});
}
fn spawn_action(app: AppHandle, state: DesktopState, action: GatewayAction) {
std::thread::spawn(move || {
if let Err(error) = state.gateway_action(&app, action) {
state.show_error(&app, &error);
}
});
}
#[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");
}
}