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
4 changes: 2 additions & 2 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ webkit2gtk = { version = "=2.0.2", features = ["v2_22"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = { version = "0.6", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSNotification", "NSOperation", "NSProcessInfo", "NSString", "block2"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
Expand Down
74 changes: 74 additions & 0 deletions desktop/src-tauri/src/app_activation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Restores the main window when Buzz becomes active with nothing visible.
//!
//! macOS surfaces Dock-icon clicks as `applicationShouldHandleReopen:` (tao
//! forwards it as `RunEvent::Reopen`), but Cmd+Tab and other app-switcher
//! activations only post `NSApplicationDidBecomeActiveNotification`.
//! Without an observer for it, switching back to a hidden-to-tray Buzz
//! brings up just the menu bar and no window, where every standard macOS
//! app re-presents one. The observer shows the main window whenever the
//! app becomes active with no visible webview window, gated on
//! `initial_window::INITIAL_REVEAL_DONE` so launch-time activation cannot
//! preempt the deliberate geometry-settled first reveal.

use std::ptr::NonNull;
use std::sync::atomic::Ordering;

use crate::initial_window::INITIAL_REVEAL_DONE;
use crate::tray_menu::show_main_window;

/// Whether an app activation should re-present the main window.
///
/// Minimized-only counts as "nothing visible" (`NSWindow.isVisible` is
/// false while miniaturized), matching how Cmd+Tab into a standard app
/// with only minimized windows de-miniaturizes one.
fn should_restore(initial_reveal_done: bool, any_window_visible: bool) -> bool {
initial_reveal_done && !any_window_visible
}

pub fn init<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) {
use block2::RcBlock;
use objc2_app_kit::NSApplicationDidBecomeActiveNotification;
use objc2_foundation::{NSNotification, NSNotificationCenter};
use tauri::Manager;

let app = app_handle.clone();
let block = RcBlock::new(move |_: NonNull<NSNotification>| {
let any_visible = app
.webview_windows()
.values()
.any(|window| window.is_visible().unwrap_or(false));
if should_restore(INITIAL_REVEAL_DONE.load(Ordering::Acquire), any_visible) {
show_main_window(&app);
}
});

// SAFETY: reading the notification-name static is sound — AppKit
// exports it for the process lifetime. For the registration: a `None`
// object matches any poster, a `None` queue delivers on the posting
// thread (the main thread for application lifecycle notifications),
// and the block is sendable — it captures only a cloned `AppHandle`,
// which is `Send + Sync`. The observer token is deliberately leaked:
// the observer must live for the whole app lifetime.
let token = unsafe {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid adding unsafe notification registration

This new AppKit observer introduces an unsafe block in production desktop code. The repo-level agent rules explicitly disallow unsafe code, so this should be refactored behind an existing safe abstraction or otherwise removed rather than landing a new unsafe call site.

AGENTS.md reference: AGENTS.md:L113-L114

Useful? React with 👍 / 👎.

NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(NSApplicationDidBecomeActiveNotification),
None,
None,
&block,
)
};
std::mem::forget(token);
}

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

#[test]
fn restores_only_after_reveal_and_only_when_nothing_is_visible() {
assert!(should_restore(true, false));
assert!(!should_restore(true, true));
assert!(!should_restore(false, false));
assert!(!should_restore(false, true));
}
}
165 changes: 144 additions & 21 deletions desktop/src-tauri/src/app_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,141 @@
//! `close_window` item in both the File and Window submenus, and muda gives
//! that item a Cmd+W key equivalent bound to `performClose:`.
//!
//! Two consequences, both wrong for Buzz:
//! An earlier revision of this module removed both `close_window` items,
//! because macOS resolves a menu key equivalent before the webview receives
//! any key event, so Buzz Term could never bind Cmd+W to "close this
//! terminal tab" while the accelerator was claimed here. That cure traded a
//! terminal-local conflict for an app-wide regression: Cmd+W is the standard
//! macOS chord for "close the focused window", and with the item gone it did
//! nothing anywhere else in the app. For a tray-resident app "close" means
//! hide-to-tray (the `CloseRequested` interception in `lib.rs`), exactly the
//! Cmd+W behavior of other tray-resident chat apps.
//!
//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned
//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole
//! app. That is already redundant with Cmd+H (Hide), which stays.
//! 2. macOS resolves a menu key equivalent before the webview receives any key
//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab"
//! while the accelerator was claimed here.
//! So the menu now restores File > Close Window as a *custom* item
//! (predefined items cannot be toggled after creation) and Buzz Term
//! disables it for exactly as long as it owns the keyboard, via the
//! `set_close_window_menu_enabled` command: a disabled menu item does not
//! consume its key equivalent, so Cmd+W falls through to the webview and the
//! terminal's close-tab chord (`matchTabChord` in `terminalState.ts`) runs.
//! Everything else still mirrors `Menu::default()` deliberately; the Window
//! submenu's duplicate close item is not restored because File is the
//! chord's canonical home.
//!
//! So this module builds the standard menu minus both `close_window` items.
//! Everything else matches `Menu::default()` deliberately: the goal is to drop
//! one item, not to design a menu.
//!
//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to
//! restore the item and disable it while the terminal owns input (a disabled
//! item does not consume its key equivalent) -- at the cost of an owner->Rust
//! IPC hop this approach does not need.
//! The app submenu additionally carries the standard Settings… (Cmd+,) and
//! Check for Updates… items in their HIG positions; both forward to the
//! webview, where the settings and updater surfaces live.

#[cfg(target_os = "macos")]
use tauri::menu::{
AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::AppHandle;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{Builder, Runtime};

/// Menu id for File > Close Window.
#[cfg(target_os = "macos")]
const CLOSE_WINDOW_ID: &str = "close-window";

/// Menu id for the app submenu's Settings… (Cmd+,) item.
#[cfg(target_os = "macos")]
const SETTINGS_ID: &str = "settings";

/// Menu id for the app submenu's Check for Updates… item.
#[cfg(target_os = "macos")]
const CHECK_FOR_UPDATES_ID: &str = "check-for-updates";

/// Handle to the File > Close Window item, managed so
/// `set_close_window_menu_enabled` can toggle it after the menu is built.
#[cfg(target_os = "macos")]
struct CloseWindowMenuItem<R: Runtime>(MenuItem<R>);

/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise
/// auto-install. A no-op off macOS, where that default is never created and
/// the Cmd+W accelerator does not exist.
pub fn install<R: Runtime>(builder: Builder<R>) -> Builder<R> {
#[cfg(target_os = "macos")]
let builder = builder.menu(build);
let builder = builder
.menu(build)
.on_menu_event(|app, event| match event.id().as_ref() {
CLOSE_WINDOW_ID => close_focused_window(app),
SETTINGS_ID => forward_menu_action(app, "menu-open-settings"),
CHECK_FOR_UPDATES_ID => forward_menu_action(app, "menu-check-for-updates"),
_ => {}
});
builder
}

/// Mirrors `Menu::default()` with every `close_window` item omitted.
/// Shows the main window and forwards a menu action to its webview.
///
/// Settings-flavored items stay clickable while the window is hidden to the
/// tray (the menu bar is reachable whenever the app is active), so the
/// window is re-presented first — acting invisibly would look like the item
/// did nothing.
#[cfg(target_os = "macos")]
fn forward_menu_action<R: Runtime>(app: &AppHandle<R>, event: &str) {
crate::tray_menu::show_main_window(app);
if let Err(error) = app.emit_to("main", event, ()) {
eprintln!("buzz-desktop: failed to forward menu action {event}: {error}");
}
}

/// Closes the focused window, falling back to the main window.
///
/// `close()` goes through `CloseRequested`, so the main window takes the
/// hide-to-tray path in `lib.rs` and huddle windows keep their
/// drawer-restore behavior — the same outcome as clicking the native close
/// button.
#[cfg(target_os = "macos")]
fn close_focused_window<R: Runtime>(app: &AppHandle<R>) {
let windows = app.webview_windows();
let target = windows
.values()
.find(|window| window.is_focused().unwrap_or(false))
.or_else(|| windows.get("main"));
let Some(window) = target else {
return;
};
if let Err(error) = window.close() {
eprintln!("buzz-desktop: failed to close window from menu: {error}");
}
}

/// Enables or disables File > Close Window (Cmd+W).
///
/// Buzz Term claims Cmd+W to close terminal tabs while it owns the keyboard.
/// macOS resolves menu key equivalents before the webview sees any key
/// event, so the item must be disabled for the chord to reach the terminal
/// at all — a disabled item does not consume its key equivalent. A no-op off
/// macOS, where this menu is never installed.
#[tauri::command]
pub fn set_close_window_menu_enabled<R: Runtime>(
app: AppHandle<R>,
enabled: bool,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let Some(item) = app.try_state::<CloseWindowMenuItem<R>>() else {
return Ok(());
};
let item = item.0.clone();
app.run_on_main_thread(move || {
if let Err(error) = item.set_enabled(enabled) {
eprintln!("buzz-desktop: failed to set Close Window enabled={enabled}: {error}");
}
})
.map_err(|error| error.to_string())
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app, enabled);
Ok(())
}
}

/// Mirrors `Menu::default()` with File > Close Window as a toggleable custom
/// item, the Window submenu's duplicate close item omitted, and Settings… /
/// Check for Updates… added to the app submenu.
///
/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu`
/// looks them up by id to call `set_as_windows_menu_for_nsapp` and
Expand All @@ -58,6 +157,15 @@ pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
..Default::default()
};

let close_window = MenuItem::with_id(
app,
CLOSE_WINDOW_ID,
"Close Window",
true,
Some("CmdOrCtrl+W"),
)?;
app.manage(CloseWindowMenuItem(close_window.clone()));

Menu::with_items(
app,
&[
Expand All @@ -68,6 +176,19 @@ pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
&[
&PredefinedMenuItem::about(app, None, Some(about_metadata))?,
&PredefinedMenuItem::separator(app)?,
// Standard app-submenu items macOS users expect between
// About and Services; both forward to the webview (the
// settings UI lives there).
&MenuItem::with_id(
app,
CHECK_FOR_UPDATES_ID,
"Check for Updates…",
true,
None::<&str>,
)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, SETTINGS_ID, "Settings…", true, Some("CmdOrCtrl+,"))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::services(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
Expand All @@ -76,8 +197,10 @@ pub fn build<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
&PredefinedMenuItem::quit(app, None)?,
],
)?,
// `Menu::default()`'s File submenu holds exactly one item on macOS
// -- close_window -- so dropping that item drops the submenu too.
// `Menu::default()`'s File submenu holds exactly one item on
// macOS -- close_window -- restored here as the custom item so
// Buzz Term can release the accelerator while it owns Cmd+W.
&Submenu::with_items(app, "File", true, &[&close_window])?,
&Submenu::with_items(
app,
"Edit",
Expand Down
16 changes: 16 additions & 0 deletions desktop/src-tauri/src/initial_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@
#[cfg(target_os = "macos")]
pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";

/// Whether the main window has been deliberately shown this launch — the
/// first-frame reveal below or an explicit show (tray, Dock reopen). Until
/// then `app_activation` must ignore activations: the window starts hidden
/// while saved geometry restores, and the app is already "active" during
/// launch, so reacting early would preempt the geometry-settled reveal.
#[cfg(target_os = "macos")]
pub(crate) static INITIAL_REVEAL_DONE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);

#[cfg(target_os = "macos")]
pub(crate) fn mark_initial_reveal_done() {
INITIAL_REVEAL_DONE.store(true, std::sync::atomic::Ordering::Release);
}

pub(crate) fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>) {
if let Err(error) = window.show() {
eprintln!("buzz-desktop: failed to reveal main window: {error}");
return;
}
#[cfg(target_os = "macos")]
mark_initial_reveal_done();
if let Err(error) = window.set_focus() {
eprintln!("buzz-desktop: failed to focus main window: {error}");
}
Expand Down
19 changes: 19 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
#[cfg(target_os = "macos")]
mod app_activation;
mod app_menu;
mod app_state;
mod archive;
Expand Down Expand Up @@ -310,6 +312,8 @@ pub fn run() {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
tray_menu::init(&app_handle)?;
#[cfg(target_os = "macos")]
app_activation::init(&app_handle);

// ── Phase 2: boot-time sentinel wipe ──────────────────────────────
// Must run before migrations and identity resolution so the wipe
Expand Down Expand Up @@ -625,6 +629,7 @@ pub fn run() {
unarchive_builderlab_community,
transfer_builderlab_community,
title_bar_double_click,
app_menu::set_close_window_menu_enabled,
get_identity,
get_nsec,
generate_backup_passphrase,
Expand Down Expand Up @@ -927,6 +932,20 @@ pub fn run() {
eprintln!("buzz-desktop: failed to hide main window: {error}");
}
}
// With nothing left visible, hiding the app yields activation to
// the next app — the same handoff as closing the last window of
// a standard macOS app. Without it Buzz stays frontmost with
// zero windows: menu bar only, dead Cmd+Tab target. Skipped
// while another window (e.g. a huddle) is still up.
let any_visible = app_handle
.webview_windows()
.values()
.any(|window| window.is_visible().unwrap_or(false));
if !any_visible {
if let Err(error) = app_handle.hide() {
eprintln!("buzz-desktop: failed to yield activation after close: {error}");
}
}
}
RunEvent::WindowEvent {
label,
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/tray_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ pub(crate) fn show_main_window<R: Runtime>(app: &AppHandle<R>) {
eprintln!("buzz-desktop: failed to show main window from tray: {error}");
return;
}
// Any deliberate show counts as the initial reveal: from here on,
// activating the app with nothing visible may re-present the window
// (see `app_activation`).
#[cfg(target_os = "macos")]
crate::initial_window::mark_initial_reveal_done();
if let Err(error) = window.set_focus() {
eprintln!("buzz-desktop: failed to focus main window from tray: {error}");
}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"app": {
"windows": [
{
"title": "",
"title": "Buzz",
"width": 800,
"height": 600,
"maximized": true,
Expand Down
Loading