Skip to content
Closed
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
1 change: 1 addition & 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 @@ -58,7 +58,7 @@ user-idle = { version = "0.6", default-features = false }
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = ["macos-private-api"] }
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
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 @@ -55,6 +55,7 @@ mod relay_reconnect;
mod social;
mod team_snapshot;
mod teams;
mod tray;
mod updater;
mod window_chrome;
mod window_vibrancy;
Expand Down Expand Up @@ -106,6 +107,7 @@ pub use relay_reconnect::*;
pub use social::*;
pub use team_snapshot::*;
pub use teams::*;
pub use tray::*;
pub use updater::*;
pub use window_chrome::*;
pub use window_vibrancy::*;
Expand Down
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/commands/tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// Set whether closing the main window keeps Buzz running in the background
/// instead of quitting. The frontend owns the persisted preference
/// (`localStorage`) and pushes it here on launch and whenever it changes.
///
/// Windows and Linux need a tray icon as the reopen/quit affordance. macOS
/// keeps the standard Dock icon, and `RunEvent::Reopen` restores the window.
#[tauri::command]
pub fn set_close_to_tray(enabled: bool, app: tauri::AppHandle) -> Result<(), String> {
#[cfg(not(target_os = "macos"))]
{
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);
}
}
// macOS keeps the Dock icon instead of a tray icon, so `app` is unused.
#[cfg(target_os = "macos")]
let _ = &app;
crate::tray::set_close_to_tray_enabled(enabled);
Ok(())
}
16 changes: 16 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod reset;
mod secret_store;
mod shutdown;
mod templates;
mod tray;
mod util;
#[cfg(target_os = "linux")]
pub mod webkit_rendering;
Expand Down Expand Up @@ -345,6 +346,7 @@ pub fn run() {
let builder = builder;

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 Down Expand Up @@ -390,6 +392,10 @@ pub fn run() {
return Ok(());
}

// On Windows and Linux, the background-mode tray icon is created
// lazily when the frontend pushes the persisted preference. macOS
// uses its standard Dock icon to reopen the hidden window.

// Run all pre-identity data migrations before state loads from disk.
if reset_outcome.completed {
migration::run_boot_migrations_after_reset(&app_handle);
Expand Down Expand Up @@ -878,6 +884,7 @@ pub fn run() {
fetch_workspace_icon,
fetch_join_policy,
set_prevent_sleep_active,
set_close_to_tray,
get_agent_memory,
relay_reconnect_hook,
relay_reconnect_hook_configured,
Expand Down Expand Up @@ -908,6 +915,9 @@ pub fn run() {
let restart_requested = Arc::new(AtomicBool::new(false));
app.run(move |app_handle, event| match event {
RunEvent::ExitRequested { code, .. } => {
// Mark a genuine quit so the close-to-tray window handler lets the
// window close instead of hiding it during teardown.
tray::mark_quitting();
if is_restart_request(code) {
restart_requested.store(true, Ordering::SeqCst);
}
Expand All @@ -930,6 +940,12 @@ pub fn run() {
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
hard_exit_after_mesh_shutdown();
}
// 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);
}
_ => {}
});
}
150 changes: 150 additions & 0 deletions desktop/src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Background-window lifecycle and system tray handling.
//!
//! When "Keep Buzz running when the window closes" is on, closing the main
//! window hides it instead of quitting. Windows and Linux expose a tray icon:
//! left-click (or "Show Buzz") reopens the window, and "Quit Buzz" exits.
//! macOS uses its normal Dock icon and application Quit command.
//!
//! The non-macOS tray icon exists only while the setting is enabled.

use std::sync::atomic::{AtomicBool, Ordering};

use tauri::{AppHandle, Manager, WindowEvent};

// These flags describe process-wide native window lifecycle, not
// community-scoped application data, so they intentionally live beside the
// tray handlers instead of growing the already-oversized AppState.
static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(false);
static QUITTING: AtomicBool = AtomicBool::new(false);

/// Stable id for the close-to-tray icon, used to create and remove it.
#[cfg(not(target_os = "macos"))]
const TRAY_ID: &str = "main-tray";

/// Apply the frontend-owned persisted preference to the native close handler.
pub fn set_close_to_tray_enabled(enabled: bool) {
CLOSE_TO_TRAY.store(enabled, Ordering::SeqCst);
}

/// Allow window teardown to proceed during a genuine application exit.
pub fn mark_quitting() {
QUITTING.store(true, Ordering::SeqCst);
}

/// 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 should_hide_window(
window.label(),
CLOSE_TO_TRAY.load(Ordering::SeqCst),
QUITTING.load(Ordering::SeqCst),
) {
api.prevent_close();
let _ = window.hide();
}
}
}

fn should_hide_window(label: &str, keep_running: bool, quitting: bool) -> bool {
label == "main" && keep_running && !quitting
}

/// 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.
#[cfg(not(target_os = "macos"))]
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" => {
mark_quitting();
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.
#[cfg(not(target_os = "macos"))]
pub fn remove_tray_icon(app: &AppHandle) {
app.remove_tray_by_id(TRAY_ID);
}

#[cfg(test)]
mod tests {
use super::should_hide_window;

#[test]
fn hides_main_window_when_background_mode_is_enabled() {
assert!(should_hide_window("main", true, false));
}

#[test]
fn allows_main_window_to_close_during_explicit_quit() {
assert!(!should_hide_window("main", true, true));
}

#[test]
fn allows_close_when_background_mode_is_disabled() {
assert!(!should_hide_window("main", false, false));
}

#[test]
fn never_intercepts_auxiliary_windows() {
assert!(!should_hide_window("settings", true, false));
}
}
18 changes: 18 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import {
type MachineOnboardingPage,
type PostOnboardingNavigation,
} from "@/features/onboarding/ui/MachineOnboardingFlow";
import {
applyCloseToTray,
getCloseToTrayPref,
setCloseToTrayPref,
} from "@/features/settings/lib/closeToTray";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
Expand Down Expand Up @@ -699,6 +704,19 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
export function App() {
useReloadShortcut();
useInitialRenderReady();

// 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(() => {
const enabled = getCloseToTrayPref();
void applyCloseToTray(enabled).then((applied) => {
if (enabled && !applied) {
setCloseToTrayPref(false);
}
});
}, []);

const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
const [queryClient] = useState(createBuzzQueryClient);

Expand Down
31 changes: 31 additions & 0 deletions desktop/src/features/settings/hooks/useCloseToTray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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 [isUpdating, setIsUpdating] = React.useState(false);

const setEnabled = React.useCallback(async (next: boolean) => {
const previous = getCloseToTrayPref();
setIsUpdating(true);
const applied = await applyCloseToTray(next);
if (applied) {
setEnabledState(next);
setCloseToTrayPref(next);
} else {
setEnabledState(previous);
}
setIsUpdating(false);
}, []);

return { enabled, isUpdating, setEnabled };
}
44 changes: 44 additions & 0 deletions desktop/src/features/settings/lib/closeToTray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { invoke, isTauri } from "@tauri-apps/api/core";

/**
* Persistence + backend sync for the "keep Buzz running" 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 keeping Buzz running. */
export function getCloseToTrayPref(): boolean {
try {
return window.localStorage.getItem(CLOSE_TO_TRAY_KEY) !== "false";
} catch {
return true;
}
}

/** 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<boolean> {
if (!isTauri()) {
return true;
}
try {
await invoke("set_close_to_tray", { enabled });
return true;
} catch (err) {
console.warn("set_close_to_tray command failed:", err);
return false;
}
}
Loading