diff --git a/docs/buzz-help.md b/docs/buzz-help.md index bb2699f505..a8e2f73c01 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -728,6 +728,9 @@ for 30 days expires. - **Closing the window** in the macOS app hides it; Buzz and its agents keep running. Switching back to Buzz, for example with ⌘Tab or its Dock icon, shows it again. A minimized window stays in the Dock. ⌘Q quits. +- **Closing the window** in the Windows app hides it to the notification area; + Buzz and its agents keep running. Click Buzz's icon there, or open Buzz + again, to show it. Right-click the icon and choose **Quit Buzz** to quit. - **Sharing a Solo chat** grants access to its conversation history, not only the next message. Search for a channel in its access dialog and click **Share** beside the result. This makes a Private chat **View only** and posts a link diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f48302ac6f..376d570312 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -115,6 +115,8 @@ objc2-user-notifications = { version = "=0.3.2", default-features = false, featu objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std", "objc2-app-kit", "block2", "WKWebView", "WKUIDelegate", "WKFrameInfo", "WKSecurityOrigin", "WKWebViewConfiguration", "WKWebsiteDataStore", "WKHTTPCookieStore", "WKDownload", "WKDownloadDelegate", "WKNavigation", "WKNavigationDelegate"] } [target.'cfg(target_os = "windows")'.dependencies] +# The notification-area icon that reopens and quits Buzz (tray.rs). +tauri = { version = "=2.12.4", features = ["tray-icon"] } webview2-com = "=0.39.1" windows-core = "=0.62.2" tauri-winrt-notification = "=0.8.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1940587756..343f34ec55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,8 @@ mod microphone; mod mouse_nav; #[cfg(target_os = "macos")] mod reshow; +#[cfg(windows)] +mod tray; mod notifications; mod os_idle; @@ -1024,6 +1026,12 @@ pub fn run() { mouse_nav::install(app.handle()); #[cfg(target_os = "macos")] reshow::install(app.handle()); + // Before any close hides the window: the icon is the way back. + // Without it, closing quits as before (see the close handler). + #[cfg(windows)] + if let Err(error) = tray::install(app.handle()) { + eprintln!("Could not add Buzz to the notification area: {error}"); + } #[cfg(target_os = "macos")] if let Some(home) = dirs::home_dir() { if let Err(error) = legacy_cli_shortcut::remove_stale(&home.join(".local/bin")) { @@ -1285,6 +1293,18 @@ pub fn run() { return; } } + // As on macOS; the notification-area icon reopens and quits Buzz. + // Hide only when that icon exists, or the window could not come back. + #[cfg(windows)] + if window.label() == "main" && tray::installed(window.app_handle()) { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + if let Err(error) = window.hide() { + eprintln!("Could not close Buzz window: {error}"); + } + return; + } + } browser::window_event(window, event); }) .build(context) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000000..2d09b1e591 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,125 @@ +//! Windows notification-area icon. +//! +//! Closing the main window on Windows hides it (see the `CloseRequested` +//! handler), so running agents keep working. This icon is the way back: a left +//! click opens Buzz, and its menu offers Open and Quit. Once the window is +//! hidden it is the way to quit, since Alt+F4 and the taskbar's Close only +//! close the window. +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Runtime, +}; + +const TRAY_ID: &str = "buzz-tray"; +const OPEN_ID: &str = "tray-open"; +const QUIT_ID: &str = "tray-quit"; + +#[derive(Debug, PartialEq, Eq)] +enum TrayCommand { + Open, + Quit, +} + +fn command(id: &str) -> Option { + match id { + OPEN_ID => Some(TrayCommand::Open), + QUIT_ID => Some(TrayCommand::Quit), + _ => None, + } +} + +/// Only a left-button release opens Buzz: the right button belongs to the +/// menu, and each click also reports a press. +fn opens(event: &TrayIconEvent) -> bool { + matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) +} + +/// Adds Buzz's icon, which lasts as long as the app. +pub(crate) fn install(app: &AppHandle) -> tauri::Result<()> { + let menu = Menu::with_items( + app, + &[ + &MenuItem::with_id(app, OPEN_ID, "Open Buzz", true, None::<&str>)?, + &MenuItem::with_id(app, QUIT_ID, "Quit Buzz", true, None::<&str>)?, + ], + )?; + let mut builder = TrayIconBuilder::with_id(TRAY_ID) + .menu(&menu) + .tooltip("Buzz") + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match command(event.id.as_ref()) { + Some(TrayCommand::Open) => crate::deep_links::focus_main(app), + // The same exit as quitting from the app menu: agents stop with Buzz. + Some(TrayCommand::Quit) => app.exit(0), + None => {} + }) + .on_tray_icon_event(|tray, event| { + if opens(&event) { + crate::deep_links::focus_main(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + builder.build(app)?; + Ok(()) +} + +/// Whether Buzz's icon is in the notification area, so a closed window can +/// come back. +pub(crate) fn installed(app: &AppHandle) -> bool { + app.tray_by_id(TRAY_ID).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::{tray::TrayIconId, PhysicalPosition, Rect}; + + fn click(button: MouseButton, button_state: MouseButtonState) -> TrayIconEvent { + TrayIconEvent::Click { + id: TrayIconId::new(TRAY_ID), + position: PhysicalPosition::new(0.0, 0.0), + rect: Rect::default(), + button, + button_state, + } + } + + #[test] + fn installed_only_after_install() { + let app = tauri::test::mock_builder() + .build(crate::app_context()) + .expect("mock app"); + assert!(!installed(app.handle())); + install(app.handle()).expect("tray icon"); + assert!(installed(app.handle())); + } + + #[test] + fn menu_items_open_and_quit() { + assert_eq!(command(OPEN_ID), Some(TrayCommand::Open)); + assert_eq!(command(QUIT_ID), Some(TrayCommand::Quit)); + assert_eq!(command(""), None); + } + + #[test] + fn only_a_left_release_opens_buzz() { + assert!(opens(&click(MouseButton::Left, MouseButtonState::Up))); + assert!(!opens(&click(MouseButton::Left, MouseButtonState::Down))); + assert!(!opens(&click(MouseButton::Right, MouseButtonState::Up))); + assert!(!opens(&TrayIconEvent::Enter { + id: TrayIconId::new(TRAY_ID), + position: PhysicalPosition::new(0.0, 0.0), + rect: Rect::default(), + })); + } +}