Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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({
Expand Down
36 changes: 36 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Mutex<crate::prevent_sleep::PreventSleepState>>,
/// 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<AtomicBool>,
/// 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<AtomicBool>,
/// In-process mesh-llm node started by Buzz Desktop.
#[cfg(feature = "mesh-llm")]
pub mesh_llm_runtime: AsyncMutex<Option<crate::mesh_llm::DesktopMeshRuntime>>,
Expand Down Expand Up @@ -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")]
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod relay_members;
mod relay_reconnect;
mod social;
mod teams;
mod tray;
mod workflows;
mod workspace;

Expand Down Expand Up @@ -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::*;
27 changes: 27 additions & 0 deletions desktop/src-tauri/src/commands/tray.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
19 changes: 19 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod prevent_sleep;
mod relay;
mod secret_store;
mod templates;
mod tray;
mod util;

#[cfg(not(feature = "mesh-llm"))]
Expand Down Expand Up @@ -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 {
Expand All @@ -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);

Expand Down Expand Up @@ -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,
])
Expand Down Expand Up @@ -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::<AppState>()
.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::<AppState>().prevent_sleep);
Expand All @@ -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);
}
_ => {}
});
}
Expand Down
107 changes: 107 additions & 0 deletions desktop/src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
@@ -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::<AppState>();
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::<AppState>() {
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);
}
11 changes: 11 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<boolean | null>(null);
useEffect(() => {
isSharedIdentityCmd()
Expand Down
Loading