diff --git a/desktop/src-tauri/src/app_menu.rs b/desktop/src-tauri/src/app_menu.rs index e6d7944a10..a01d512a0f 100644 --- a/desktop/src-tauri/src/app_menu.rs +++ b/desktop/src-tauri/src/app_menu.rs @@ -25,7 +25,7 @@ #[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; @@ -67,6 +67,13 @@ pub fn build(app: &AppHandle) -> tauri::Result> { true, &[ &PredefinedMenuItem::about(app, None, Some(about_metadata))?, + &MenuItem::with_id( + app, + crate::tray_menu::OPEN_SETTINGS_ID, + "Settings…", + true, + Some("CmdOrCtrl+,"), + )?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::services(app, None)?, &PredefinedMenuItem::separator(app)?, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4f935631b6..cf936e333c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -909,6 +909,8 @@ pub fn run() { #[cfg(target_os = "macos")] tray_menu::clear_tray_agent_activity, #[cfg(target_os = "macos")] + tray_menu::set_settings_tray_actions_enabled, + #[cfg(target_os = "macos")] tray_menu::requeue_tray_actions, #[cfg(target_os = "macos")] tray_menu::take_tray_actions, diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 3f9fe49fe0..810b1fcdb0 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -28,6 +28,7 @@ use tauri::{ const TRAY_ID: &str = "buzz-tray"; const OPEN_BUZZ_ID: &str = "tray-open-buzz"; const NEW_CHANNEL_ID: &str = "tray-new-channel"; +pub(crate) const OPEN_SETTINGS_ID: &str = "open-settings"; const QUIT_ID: &str = "tray-quit"; const OPEN_CHANNEL_PREFIX: &str = "tray-open-channel:"; const OPEN_CHANNEL_ACTIVITY_SEPARATOR: char = '|'; @@ -200,6 +201,7 @@ struct TrayActivityMenuItem { struct TrayActionQueue { community_generation: u64, + settings_actions_enabled: bool, pending_actions: Vec, } @@ -212,6 +214,7 @@ struct TrayMenuState { #[serde(rename_all = "camelCase", tag = "kind")] pub enum TrayAction { NewChannel, + OpenSettings, OpenChannel { #[serde(rename = "channelId")] channel_id: String, @@ -237,12 +240,10 @@ pub(crate) fn show_main_window(app: &AppHandle) { } } -fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { - let state = app.state::>(); - let Ok(mut queue) = state.action_queue.lock() else { - eprintln!("buzz-desktop: tray action queue is unavailable"); - return; - }; +fn enqueue_action(queue: &mut TrayActionQueue, mut action: TrayAction) -> bool { + if matches!(action, TrayAction::OpenSettings) && !queue.settings_actions_enabled { + return false; + } if let TrayAction::OpenChannel { community_generation, .. @@ -251,6 +252,18 @@ fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { *community_generation = queue.community_generation; } queue.pending_actions.push(action); + true +} + +fn queue_tray_action(app: &AppHandle, action: TrayAction) { + let state = app.state::>(); + let Ok(mut queue) = state.action_queue.lock() else { + eprintln!("buzz-desktop: tray action queue is unavailable"); + return; + }; + if !enqueue_action(&mut queue, action) { + return; + } drop(queue); if let Err(error) = app.emit("tray-action-available", ()) { @@ -341,6 +354,13 @@ fn build_menu( None::<&str>, )?)?; append_separator(app, &menu)?; + menu.append(&MenuItem::with_id( + app, + OPEN_SETTINGS_ID, + "Settings", + true, + None::<&str>, + )?)?; menu.append(&MenuItem::with_id( app, QUIT_ID, @@ -444,13 +464,17 @@ fn apply_activity_presentation( Ok(()) } -fn handle_menu_event(app: &AppHandle, id: &str) { +pub(crate) fn handle_menu_event(app: &AppHandle, id: &str) { match id { OPEN_BUZZ_ID => show_main_window(app), NEW_CHANNEL_ID => { show_main_window(app); queue_tray_action(app, TrayAction::NewChannel); } + OPEN_SETTINGS_ID => { + show_main_window(app); + queue_tray_action(app, TrayAction::OpenSettings); + } QUIT_ID => app.exit(0), _ => { let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else { @@ -483,6 +507,7 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { activity_items: Mutex::new(activity_items), action_queue: Mutex::new(TrayActionQueue { community_generation: 0, + settings_actions_enabled: false, pending_actions: Vec::new(), }), }); @@ -510,9 +535,35 @@ pub fn take_tray_actions(app: AppHandle) -> Result( + app: AppHandle, + enabled: bool, +) -> Result<(), String> { + let state = app.state::>(); + let mut queue = state + .action_queue + .lock() + .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; + set_settings_actions_enabled(&mut queue, enabled); + Ok(()) +} + fn requeue_actions(queue: &mut TrayActionQueue, mut actions: Vec) { actions.retain(|action| match action { TrayAction::NewChannel => true, + TrayAction::OpenSettings => queue.settings_actions_enabled, TrayAction::OpenChannel { community_generation, .. @@ -552,7 +603,7 @@ pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), St queue.community_generation = queue.community_generation.wrapping_add(1); queue .pending_actions - .retain(|action| matches!(action, TrayAction::NewChannel)); + .retain(|action| matches!(action, TrayAction::NewChannel | TrayAction::OpenSettings)); drop(queue); update_tray_agent_activity(app, Vec::new(), Vec::new()) @@ -614,7 +665,9 @@ pub fn update_tray_agent_activity( #[cfg(test)] mod tests { - use super::{requeue_actions, TrayAction, TrayActionQueue}; + use super::{ + enqueue_action, requeue_actions, set_settings_actions_enabled, TrayAction, TrayActionQueue, + }; #[test] fn open_channel_action_serializes_with_frontend_field_names() { @@ -633,10 +686,51 @@ mod tests { ); } + #[test] + fn open_settings_action_serializes_with_frontend_field_names() { + assert_eq!( + serde_json::to_value(TrayAction::OpenSettings).expect("tray action should serialize"), + serde_json::json!({ "kind": "openSettings" }) + ); + } + + #[test] + fn settings_actions_are_ignored_until_app_shell_is_ready() { + let mut queue = TrayActionQueue { + community_generation: 2, + settings_actions_enabled: false, + pending_actions: vec![TrayAction::NewChannel], + }; + + assert!(!enqueue_action(&mut queue, TrayAction::OpenSettings)); + queue.settings_actions_enabled = true; + assert!(enqueue_action(&mut queue, TrayAction::OpenSettings)); + + assert_eq!( + queue.pending_actions, + vec![TrayAction::NewChannel, TrayAction::OpenSettings] + ); + } + + #[test] + fn disabling_settings_actions_clears_queued_and_in_flight_settings() { + let mut queue = TrayActionQueue { + community_generation: 2, + settings_actions_enabled: true, + pending_actions: vec![TrayAction::NewChannel, TrayAction::OpenSettings], + }; + + set_settings_actions_enabled(&mut queue, false); + requeue_actions(&mut queue, vec![TrayAction::OpenSettings]); + + assert_eq!(queue.pending_actions, vec![TrayAction::NewChannel]); + } + #[test] fn stale_channel_actions_are_not_requeued_after_community_change() { let mut queue = TrayActionQueue { community_generation: 2, + settings_actions_enabled: true, pending_actions: Vec::new(), }; @@ -652,14 +746,21 @@ mod tests { } #[test] - fn new_channel_actions_survive_community_change() { + fn installation_global_actions_survive_community_change() { let mut queue = TrayActionQueue { community_generation: 2, + settings_actions_enabled: true, pending_actions: Vec::new(), }; - requeue_actions(&mut queue, vec![TrayAction::NewChannel]); + requeue_actions( + &mut queue, + vec![TrayAction::NewChannel, TrayAction::OpenSettings], + ); - assert_eq!(queue.pending_actions, vec![TrayAction::NewChannel]); + assert_eq!( + queue.pending_actions, + vec![TrayAction::NewChannel, TrayAction::OpenSettings] + ); } } diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..46a6f19649 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,4 +1,4 @@ -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; @@ -245,6 +245,9 @@ function AppReady({ isCommunitySwitch: boolean; }) { const onboarding = useAppOnboardingState(isSharedIdentity); + const settingsActionsReady = useSettingsTrayActionReadiness( + onboarding.stage === "ready", + ); if (onboarding.stage === "reset-failed") { return ; @@ -273,6 +276,10 @@ function AppReady({ return isCommunitySwitch ? : ; } + if (!settingsActionsReady) { + return ; + } + return ( @@ -289,6 +296,34 @@ function AppReady({ ); } +function useSettingsTrayActionReadiness(enabled: boolean): boolean { + const [ready, setReady] = useState(() => !isMacOSTauri()); + + useEffect(() => { + if (!isMacOSTauri()) return; + + let disposed = false; + setReady(false); + void invoke("set_settings_tray_actions_enabled", { enabled }) + .then(() => { + if (!disposed) setReady(enabled); + }) + .catch((error) => { + console.error("Failed to update native Settings menu readiness", error); + }); + + return () => { + disposed = true; + }; + }, [enabled]); + + return !enabled || ready; +} + +function isMacOSTauri(): boolean { + return isTauri() && navigator.userAgent.includes("Macintosh"); +} + function CommunityApp({ currentPubkey, onBackToMachineConfig, @@ -615,6 +650,15 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { const [postOnboardingNav, setPostOnboardingNav] = useState(null); + useEffect(() => { + if (!isMacOSTauri() || machine.stage === "ready") return; + void invoke("set_settings_tray_actions_enabled", { enabled: false }).catch( + (error) => { + console.error("Failed to disable native Settings menu actions", error); + }, + ); + }, [machine.stage]); + const reopenMachineConfig = useCallback(() => { setMachineInitialPage("config"); machine.reopen(); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3..211781e4a1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -602,10 +602,11 @@ export function AppShell() { ); const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { + if (settingsOpen) return; setIsChannelManagementOpen(false); void goSettings(section); }, - [goSettings], + [goSettings, settingsOpen], ); const handleCloseSettings = React.useCallback( () => closeSettings(), @@ -703,7 +704,6 @@ export function AppShell() { settingsOpen, ]); useSettingsShortcuts({ - onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, open: isHuddleRoom ? undefined : settingsOpen, }); @@ -721,6 +721,7 @@ export function AppShell() { channels={channels} goChannel={goChannel} openCreateChannel={handleOpenCreateChannel} + openSettings={handleOpenSettings} /> ) : null} diff --git a/desktop/src/app/useAppShellTrayMenu.tsx b/desktop/src/app/useAppShellTrayMenu.tsx index e4b9774848..92707834d0 100644 --- a/desktop/src/app/useAppShellTrayMenu.tsx +++ b/desktop/src/app/useAppShellTrayMenu.tsx @@ -8,10 +8,12 @@ export function AppShellTrayMenu({ channels, goChannel, openCreateChannel, + openSettings, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; openCreateChannel: () => void; + openSettings: () => void; }) { if (!isMacPlatform()) return null; return ( @@ -19,6 +21,7 @@ export function AppShellTrayMenu({ channels={channels} goChannel={goChannel} openCreateChannel={openCreateChannel} + openSettings={openSettings} /> ); } @@ -27,15 +30,18 @@ function MacAppShellTrayMenu({ channels, goChannel, openCreateChannel, + openSettings, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; openCreateChannel: () => void; + openSettings: () => void; }): null { useTrayMenu({ channels, goChannel, openCreateChannel, + openSettings, }); return null; } diff --git a/desktop/src/app/useSettingsShortcuts.ts b/desktop/src/app/useSettingsShortcuts.ts index 757ddc65e1..2a91c4aa72 100644 --- a/desktop/src/app/useSettingsShortcuts.ts +++ b/desktop/src/app/useSettingsShortcuts.ts @@ -3,13 +3,11 @@ import * as React from "react"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; type UseSettingsShortcutsOptions = { - onClose: () => void; onOpenSettings: () => void; open?: boolean; }; export function useSettingsShortcuts({ - onClose, onOpenSettings, open, }: UseSettingsShortcutsOptions) { @@ -29,11 +27,6 @@ export function useSettingsShortcuts({ event.preventDefault(); event.stopImmediatePropagation(); - if (open) { - onClose(); - return; - } - onOpenSettings(); } @@ -41,5 +34,5 @@ export function useSettingsShortcuts({ return () => { window.removeEventListener("keydown", handleKeyDown, true); }; - }, [onClose, onOpenSettings, open]); + }, [onOpenSettings, open]); } diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f..246a8159fc 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -25,6 +25,7 @@ type TrayAgentActivity = { type TrayAction = | { kind: "newChannel" } + | { kind: "openSettings" } | { kind: "openChannel"; channelId: string }; const MAX_RECENT_TRAY_ACTIVITIES = 5; @@ -37,10 +38,12 @@ export function useTrayMenu({ channels, goChannel, openCreateChannel, + openSettings, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; openCreateChannel: () => void; + openSettings: () => void; }): void { const activeTurns = useActiveAgentTurnsByChannel(); const now = useNow(1000); @@ -134,6 +137,8 @@ export function useTrayMenu({ for (const action of actions) { if (action.kind === "newChannel") { openCreateChannel(); + } else if (action.kind === "openSettings") { + openSettings(); } else { void goChannel(action.channelId); } @@ -156,5 +161,5 @@ export function useTrayMenu({ disposed = true; unlisten?.(); }; - }, [goChannel, openCreateChannel]); + }, [goChannel, openCreateChannel, openSettings]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index abf74078da..05ae779e0e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11133,6 +11133,7 @@ export function maybeInstallE2eTauriMocks() { } case "update_tray_agent_activity": case "clear_tray_agent_activity": + case "set_settings_tray_actions_enabled": case "requeue_tray_actions": return null; case "take_tray_actions": diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..1819fbbd8b 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1468,11 +1468,14 @@ test("opens settings with the keyboard shortcut and updates theme", async ({ .poll(() => page.evaluate(() => localStorage.getItem("buzz-theme"))) .toBe("dracula"); - // Close settings with keyboard shortcut + // Repeating the shortcut keeps Settings open. await page.keyboard.press( process.platform === "darwin" ? "Meta+," : "Control+,", ); - await expect(page.getByTestId("settings-view")).toHaveCount(0); + await expect(page.getByTestId("settings-view")).toBeVisible(); + + // Close explicitly. + await page.getByTestId("settings-back-to-app").click(); await expectHomeView(page); });