mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 15:41:13 +00:00
* 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
498 lines
16 KiB
Rust
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");
|
|
}
|
|
}
|