From 2c6f121b6a96d47fdec1cd83d4aeb0636a2ddfca Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 26 Jun 2026 15:57:15 -0500 Subject: [PATCH] Add close-to-tray option to the desktop app Add a "Keep Buzz running in the tray" General setting. When enabled, closing the main window hides it to a system tray icon instead of quitting; left-click or the tray menu reopens it, and "Quit Buzz" exits. The preference is persisted in localStorage and pushed to the backend, which decides at window-close time whether to hide or quit. The tray icon is created lazily only while the feature is enabled, so users who never opt in never see it. Enabling fails closed: if the tray icon cannot be built the setting stays off, so the close button always has a way to quit. A genuine app exit marks the window as quitting so teardown is not trapped by the hide-to-tray handler, the macOS dock icon re-shows a hidden window, and Linux opens the tray menu on left-click where activation events are unreliable. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: benthecarman --- desktop/scripts/check-file-sizes.mjs | 11 +- desktop/src-tauri/Cargo.lock | 36 ++++++ desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/app_state.rs | 14 ++- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/tray.rs | 27 +++++ desktop/src-tauri/src/lib.rs | 19 ++++ desktop/src-tauri/src/tray.rs | 107 ++++++++++++++++++ desktop/src/app/App.tsx | 11 ++ .../features/settings/hooks/useCloseToTray.ts | 23 ++++ .../src/features/settings/lib/closeToTray.ts | 42 +++++++ .../settings/ui/GeneralSettingsCard.tsx | 40 +++++++ .../features/settings/ui/SettingsPanels.tsx | 11 ++ .../src/features/settings/ui/SettingsView.tsx | 1 + 14 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/commands/tray.rs create mode 100644 desktop/src-tauri/src/tray.rs create mode 100644 desktop/src/features/settings/hooks/useCloseToTray.ts create mode 100644 desktop/src/features/settings/lib/closeToTray.ts create mode 100644 desktop/src/features/settings/ui/GeneralSettingsCard.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3bf4631fb..06d9d4dde 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -83,7 +83,11 @@ const overrides = new Map([ // syncs team-dir edits before all personas.json readers; run_event_sync // signs the persona/team retention events post-identity) layered on top of // main's growth. Load-bearing feature growth, queued to split with the list. - ["src-tauri/src/lib.rs", 1034], + // close-to-tray: the tray module is split out into tray.rs; only the module + // registration, the `set_close_to_tray` command handler entry, the + // `on_window_event` builder hook, and the macOS dock-reopen / genuine-quit + // RunEvent arms remain here. Load-bearing feature wiring, queued to split. + ["src-tauri/src/lib.rs", 1054], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. @@ -97,7 +101,10 @@ const overrides = new Map([ // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at // 893 lines, not generic debt growth. Approved override; still queued to split. - ["src-tauri/src/app_state.rs", 1012], + // close-to-tray: adds the `close_to_tray` / `quitting` AppState flags (the + // window-close handler reads them) plus their initializers. Load-bearing + // feature state, queued to split with the list. + ["src-tauri/src/app_state.rs", 1024], ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 62093911a..981dcad49 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -945,6 +945,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -3623,6 +3629,19 @@ dependencies = [ "xmltree", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4936,6 +4955,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.2" @@ -6763,6 +6792,12 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-xml" version = "0.37.5" @@ -8793,6 +8828,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni 0.21.1", "libc", "log", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 4acdc1f2c..c11364a88 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -53,7 +53,7 @@ keyring = { version = "3.6.3", default-features = false, features = ["windows-na atomic-write-file = "0.3" anyhow = "1" dirs = "6" -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 4f595c348..696936bde 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,7 +1,10 @@ use std::{ collections::HashMap, io::Write, - sync::{atomic::AtomicU16, Arc, Mutex}, + sync::{ + atomic::{AtomicBool, AtomicU16}, + Arc, Mutex, + }, }; use nostr::{Keys, ToBech32}; @@ -35,6 +38,13 @@ pub struct AppState { pub media_proxy_port: AtomicU16, /// IOKit power assertion state — prevents idle sleep while agents run. pub prevent_sleep: Arc>, + /// When true, closing the main window hides it to the system tray instead + /// of quitting. Mirrors the user's "Keep Buzz running in the tray" setting, + /// pushed from the frontend on launch and whenever the toggle changes. + pub close_to_tray: Arc, + /// Set just before a real quit (tray "Quit", app menu) so the + /// `CloseRequested` handler lets the window close instead of hiding it. + pub quitting: Arc, /// In-process mesh-llm node started by Buzz Desktop. #[cfg(feature = "mesh-llm")] pub mesh_llm_runtime: AsyncMutex>, @@ -99,6 +109,8 @@ pub fn build_app_state() -> AppState { prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), )), + close_to_tray: Arc::new(AtomicBool::new(false)), + quitting: Arc::new(AtomicBool::new(false)), #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e8a2756eb..fabf9e6a1 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -26,6 +26,7 @@ mod relay_members; mod relay_reconnect; mod social; mod teams; +mod tray; mod workflows; mod workspace; @@ -55,5 +56,6 @@ pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; pub use teams::*; +pub use tray::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/tray.rs b/desktop/src-tauri/src/commands/tray.rs new file mode 100644 index 000000000..4644d8f63 --- /dev/null +++ b/desktop/src-tauri/src/commands/tray.rs @@ -0,0 +1,27 @@ +use std::sync::atomic::Ordering; + +use crate::app_state::AppState; + +/// Set whether closing the main window hides it to the system tray instead of +/// quitting. The frontend owns the persisted preference (localStorage) and +/// pushes the current value here on launch and whenever the user toggles it. +/// +/// The tray icon is created/removed to match the setting so it exists only +/// while the feature is on. Enabling fails closed: if the tray icon cannot be +/// built we leave `close_to_tray` off and return an error, so the window-close +/// button never hides the window with no way to get it back. +#[tauri::command] +pub fn set_close_to_tray( + enabled: bool, + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + if enabled { + crate::tray::build_tray_icon(&app) + .map_err(|error| format!("failed to create tray icon: {error}"))?; + } else { + crate::tray::remove_tray_icon(&app); + } + state.close_to_tray.store(enabled, Ordering::SeqCst); + Ok(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ab4a0a09d..62c27413a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod prevent_sleep; mod relay; mod secret_store; mod templates; +mod tray; mod util; #[cfg(not(feature = "mesh-llm"))] @@ -516,6 +517,7 @@ pub fn run() { let shutdown_started = Arc::new(AtomicBool::new(false)); let restore_shutdown_started = Arc::clone(&shutdown_started); let app = builder + .on_window_event(tray::handle_window_event) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); tauri::async_runtime::spawn(async move { @@ -529,6 +531,10 @@ pub fn run() { let app_handle = app.handle().clone(); let shutdown_started = Arc::clone(&restore_shutdown_started); + // The close-to-tray tray icon is created lazily by `set_close_to_tray` + // when the frontend pushes the enabled preference, so users who never + // opt in never see a tray icon. + // Run all pre-identity data migrations before state loads from disk. migration::run_boot_migrations(&app_handle); @@ -908,6 +914,7 @@ pub fn run() { validate_repos_dir, get_active_workspace, set_prevent_sleep_active, + set_close_to_tray, get_agent_memory, relay_reconnect_hook, ]) @@ -941,6 +948,12 @@ pub fn run() { let run_shutdown_done = Arc::clone(&shutdown_done); app.run(move |app_handle, event| match event { RunEvent::ExitRequested { .. } | RunEvent::Exit => { + // Mark a genuine quit so the close-to-tray window handler lets the + // window close instead of hiding it during teardown. + app_handle + .state::() + .quitting + .store(true, Ordering::SeqCst); shutdown_started.store(true, Ordering::SeqCst); if !run_shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app_handle.state::().prevent_sleep); @@ -949,6 +962,12 @@ pub fn run() { } } } + // macOS: clicking the dock icon while the window is hidden to the tray + // re-shows it (the standard re-open affordance). + #[cfg(target_os = "macos")] + RunEvent::Reopen { .. } => { + tray::show_main_window(app_handle); + } _ => {} }); } diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs new file mode 100644 index 000000000..103414b90 --- /dev/null +++ b/desktop/src-tauri/src/tray.rs @@ -0,0 +1,107 @@ +//! System tray icon and close-to-tray window handling. +//! +//! When the "Keep Buzz running in the tray" setting is on, closing the main +//! window hides it instead of quitting. The tray icon is the escape hatch: +//! left-click (or "Show Buzz") reopens the window, and "Quit Buzz" exits. +//! +//! The tray icon exists only while the setting is enabled — it is created when +//! the frontend pushes `set_close_to_tray(true)` and removed on `false`, so +//! users who never opt in never see a tray icon. + +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, Manager, WindowEvent}; + +use crate::app_state::AppState; + +/// Stable id for the close-to-tray icon, used to create and remove it. +const TRAY_ID: &str = "main-tray"; + +/// Show, unminimize, and focus the main window. Used by the tray menu, +/// left-click, and the macOS dock-reopen handler to surface the window after +/// close-to-tray has hidden it. +pub fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +/// Builder `on_window_event` handler implementing close-to-tray: when the user +/// closes the main window and the setting is on, hide the window instead of +/// quitting. A genuine quit (tray "Quit Buzz", or any app exit, which the +/// `RunEvent::ExitRequested` handler marks) sets `quitting` first so the window +/// is allowed to close normally. +pub fn handle_window_event(window: &tauri::Window, event: &WindowEvent) { + if let WindowEvent::CloseRequested { api, .. } = event { + if window.label() != "main" { + return; + } + let state = window.state::(); + if state.close_to_tray.load(Ordering::SeqCst) && !state.quitting.load(Ordering::SeqCst) { + api.prevent_close(); + let _ = window.hide(); + } + } +} + +/// Build the system tray icon with a "Show Buzz" / "Quit Buzz" menu. Left-click +/// reopens the window; "Quit Buzz" sets the `quitting` flag (so the close +/// handler does not re-hide the window) and exits the app. Idempotent — a +/// no-op if the tray icon already exists. +pub fn build_tray_icon(app: &AppHandle) -> tauri::Result<()> { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + + if app.tray_by_id(TRAY_ID).is_some() { + return Ok(()); + } + + let show_item = MenuItem::with_id(app, "tray-show", "Show Buzz", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "tray-quit", "Quit Buzz", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let mut builder = TrayIconBuilder::with_id(TRAY_ID) + .tooltip("Buzz") + .menu(&menu) + // Linux (StatusNotifierItem) frequently does not deliver left-click + // activation events, so left-click-to-show is unreliable there; open + // the menu on left-click instead so "Show Buzz" stays reachable. + // macOS/Windows keep left-click-to-show with the menu on right-click. + .show_menu_on_left_click(cfg!(target_os = "linux")) + .on_menu_event(|app, event| match event.id.as_ref() { + "tray-show" => show_main_window(app), + "tray-quit" => { + if let Some(state) = app.try_state::() { + state.quitting.store(true, Ordering::SeqCst); + } + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }); + + // Reuse the bundled window icon for the tray glyph. + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + + builder.build(app)?; + Ok(()) +} + +/// Remove the tray icon. Called when the user turns close-to-tray off so the +/// icon does not linger after the feature is disabled. +pub fn remove_tray_icon(app: &AppHandle) { + app.remove_tray_by_id(TRAY_ID); +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 4be18f502..ffbd7a89c 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -14,6 +14,10 @@ import { router } from "@/app/router"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; +import { + applyCloseToTray, + getCloseToTrayPref, +} from "@/features/settings/lib/closeToTray"; import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import type { Workspace } from "@/features/workspaces/types"; @@ -208,6 +212,13 @@ export function App() { void getCurrentWindow().show(); }, []); + // Push the persisted close-to-tray preference to the backend on launch so the + // window-close handler knows whether to hide-to-tray before the user ever + // opens Settings. + useEffect(() => { + void applyCloseToTray(getCloseToTrayPref()); + }, []); + const [sharedIdentity, setSharedIdentity] = useState(null); useEffect(() => { isSharedIdentityCmd() diff --git a/desktop/src/features/settings/hooks/useCloseToTray.ts b/desktop/src/features/settings/hooks/useCloseToTray.ts new file mode 100644 index 000000000..586102ed2 --- /dev/null +++ b/desktop/src/features/settings/hooks/useCloseToTray.ts @@ -0,0 +1,23 @@ +import * as React from "react"; + +import { + applyCloseToTray, + getCloseToTrayPref, + setCloseToTrayPref, +} from "../lib/closeToTray"; + +/** + * Reads/writes the "Keep Buzz running in the tray" preference. Persists to + * localStorage and pushes the value to the Tauri backend on every change. + */ +export function useCloseToTray() { + const [enabled, setEnabledState] = React.useState(getCloseToTrayPref); + + const setEnabled = React.useCallback((next: boolean) => { + setEnabledState(next); + setCloseToTrayPref(next); + void applyCloseToTray(next); + }, []); + + return { enabled, setEnabled }; +} diff --git a/desktop/src/features/settings/lib/closeToTray.ts b/desktop/src/features/settings/lib/closeToTray.ts new file mode 100644 index 000000000..2e03e59ae --- /dev/null +++ b/desktop/src/features/settings/lib/closeToTray.ts @@ -0,0 +1,42 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +/** + * Persistence + backend sync for the "Keep Buzz running in the tray" setting. + * + * The frontend owns the source of truth (localStorage). The Rust side reads a + * flag set via the `set_close_to_tray` command — it decides, at window-close + * time, whether to hide the window to the tray instead of quitting. We push the + * persisted value to the backend on launch and whenever the toggle changes. + */ +const CLOSE_TO_TRAY_KEY = "buzz-close-to-tray"; + +/** Read the persisted preference. Defaults to `false` (quit on close). */ +export function getCloseToTrayPref(): boolean { + try { + return window.localStorage.getItem(CLOSE_TO_TRAY_KEY) === "true"; + } catch { + return false; + } +} + +/** Persist the preference to localStorage. */ +export function setCloseToTrayPref(enabled: boolean): void { + try { + window.localStorage.setItem(CLOSE_TO_TRAY_KEY, enabled ? "true" : "false"); + } catch { + // Best-effort — a storage failure just means the backend keeps the + // last-applied value for this session. + } +} + +/** Push the current value to the Tauri backend. No-op outside Tauri. */ +export async function applyCloseToTray(enabled: boolean): Promise { + if (!isTauri()) { + return; + } + try { + await invoke("set_close_to_tray", { enabled }); + } catch (err) { + console.warn("set_close_to_tray command failed:", err); + } +} diff --git a/desktop/src/features/settings/ui/GeneralSettingsCard.tsx b/desktop/src/features/settings/ui/GeneralSettingsCard.tsx new file mode 100644 index 000000000..18b05ea94 --- /dev/null +++ b/desktop/src/features/settings/ui/GeneralSettingsCard.tsx @@ -0,0 +1,40 @@ +import { Switch } from "@/shared/ui/switch"; +import { useCloseToTray } from "../hooks/useCloseToTray"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; + +export function GeneralSettingsCard() { + const { enabled, setEnabled } = useCloseToTray(); + + return ( +
+ + + + +
+ +

+ Closing the window keeps Buzz in the tray instead of quitting. + Reopen or quit from the tray icon. +

+
+ +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 944b32246..791f2c3cd 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useRef } from "react"; import { + AppWindow, BellRing, Bot, Check, @@ -38,6 +39,7 @@ import { Switch } from "@/shared/ui/switch"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; +import { GeneralSettingsCard } from "./GeneralSettingsCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; @@ -55,6 +57,7 @@ export type SettingsSection = | "channel-templates" | "compute" | "appearance" + | "general" | "shortcuts" | "relay-members" | "custom-emoji" @@ -72,6 +75,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "channel-templates", "compute", "appearance", + "general", "shortcuts", "relay-members", "custom-emoji", @@ -148,6 +152,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Compute", icon: Cpu, }, + { + value: "general", + label: "General", + icon: AppWindow, + }, { value: "shortcuts", label: "Shortcuts", @@ -380,6 +389,8 @@ export function renderSettingsSection( return ; case "appearance": return ; + case "general": + return ; case "shortcuts": return ; case "relay-members": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 4a8de7cb7..14ae156ec 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -63,6 +63,7 @@ const settingsNavGroups: Array<{ { label: "App", sections: [ + "general", "agents", "compute", "experimental",