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