From 5f1cd71e8c9a717b885ca0db18abc373e4787643 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Wed, 3 Jun 2026 09:08:06 +0400 Subject: [PATCH] feat: add Tray/Dock app position with draggable, centered Dock window Adds an "App Position" setting (Tray vs Dock, default Tray) so the app shows EITHER the menu bar (tray) icon OR the Dock icon, never both, to save tray space. The choice persists across restarts and is applied natively at startup. In Dock mode the panel behaves like a normal window: centered on launch, draggable via the sidebar (deep drag region, interactive icons excluded), and it stays open on blur (Escape still hides). A "Always keep on top" toggle (Dock-only, default off) floats the window above other windows. Co-authored-by: Cursor --- src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 174 +++++++++++++++++- src-tauri/src/panel.rs | 105 +++++++++++ src/App.tsx | 12 ++ src/components/app/app-content.test.tsx | 2 + src/components/app/app-content.tsx | 12 ++ src/components/app/app-shell.tsx | 11 +- src/components/side-nav.tsx | 13 +- src/hooks/app/use-settings-bootstrap.test.ts | 14 ++ src/hooks/app/use-settings-bootstrap.ts | 31 ++++ .../app/use-settings-system-actions.test.ts | 47 +++++ src/hooks/app/use-settings-system-actions.ts | 28 +++ src/lib/settings.ts | 29 +++ src/pages/settings.test.tsx | 49 +++++ src/pages/settings.tsx | 52 ++++++ src/stores/app-preferences-store.ts | 10 + 16 files changed, 583 insertions(+), 7 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f5388a0c..bf946451 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "core:window:allow-outer-size", "core:window:allow-inner-size", "core:window:allow-scale-factor", + "core:window:allow-start-dragging", "opener:default", "store:default", "aptabase:allow-track-event", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf55ba43..eed4d9bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,22 @@ const DAILY_ACTIVE_TRACKED_DAY_KEY: &str = "analytics.daily_active_day"; const DAILY_ACTIVE_EVENT_NAME: &str = "app_started"; const MAX_CONCURRENT_PROBES: usize = 4; +// Mirrors the frontend `hideDockIcon` setting and its default. OpenUsage has +// always run as a menu bar-only app, so the Dock icon stays hidden unless the +// user opts in. Read natively at startup so the policy is applied before the +// Dock would otherwise show an icon. +#[cfg(target_os = "macos")] +const HIDE_DOCK_ICON_STORE_KEY: &str = "hideDockIcon"; +#[cfg(target_os = "macos")] +const DEFAULT_HIDE_DOCK_ICON: bool = true; + +// Mirrors the frontend `alwaysOnTop` setting. Only meaningful in Dock mode, +// where it keeps the window floating above other windows. Defaults to off. +#[cfg(target_os = "macos")] +const ALWAYS_ON_TOP_STORE_KEY: &str = "alwaysOnTop"; +#[cfg(target_os = "macos")] +const DEFAULT_ALWAYS_ON_TOP: bool = false; + fn probe_worker_count(plugin_count: usize) -> usize { plugin_count.min(MAX_CONCURRENT_PROBES) } @@ -384,6 +400,125 @@ fn get_log_path(app_handle: tauri::AppHandle) -> Result { log_path::for_app(&app_handle).map(|path| path.to_string_lossy().to_string()) } +/// Reads the persisted `hideDockIcon` preference from the settings store. +/// Falls back to the default when the store or key is unavailable so a missing +/// or unreadable setting never changes the long-standing menu bar-only behavior. +#[cfg(target_os = "macos")] +fn get_stored_hide_dock_icon(app_handle: &tauri::AppHandle) -> bool { + use tauri_plugin_store::StoreExt; + + let store = match app_handle.store("settings.json") { + Ok(store) => store, + Err(error) => { + log::warn!( + "Failed to access settings store for dock icon visibility: {}", + error + ); + return DEFAULT_HIDE_DOCK_ICON; + } + }; + + store + .get(HIDE_DOCK_ICON_STORE_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(DEFAULT_HIDE_DOCK_ICON) +} + +/// Reads the persisted `alwaysOnTop` preference from the settings store. +/// Falls back to the default when the store or key is unavailable. +#[cfg(target_os = "macos")] +fn get_stored_always_on_top(app_handle: &tauri::AppHandle) -> bool { + use tauri_plugin_store::StoreExt; + + let store = match app_handle.store("settings.json") { + Ok(store) => store, + Err(error) => { + log::warn!("Failed to access settings store for always on top: {}", error); + return DEFAULT_ALWAYS_ON_TOP; + } + }; + + store + .get(ALWAYS_ON_TOP_STORE_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(DEFAULT_ALWAYS_ON_TOP) +} + +/// Applies Dock icon visibility by switching the macOS activation policy. +/// `Accessory` hides the Dock icon (menu bar-only); `Regular` shows it. +#[cfg(target_os = "macos")] +fn apply_dock_icon_visibility(app_handle: &tauri::AppHandle, hidden: bool) -> Result<(), String> { + let policy = if hidden { + tauri::ActivationPolicy::Accessory + } else { + tauri::ActivationPolicy::Regular + }; + app_handle + .set_activation_policy(policy) + .map_err(|e| format!("Failed to set activation policy: {}", e)) +} + +/// Shows or hides the menu bar (tray) icon. The tray icon and the Dock icon are +/// mutually exclusive (see `update_dock_icon_visibility`), so the tray is shown +/// only when the Dock icon is hidden. +#[cfg(target_os = "macos")] +fn apply_tray_visibility(app_handle: &tauri::AppHandle, visible: bool) -> Result<(), String> { + match app_handle.tray_by_id("tray") { + Some(tray) => tray + .set_visible(visible) + .map_err(|e| format!("Failed to set tray visibility: {}", e)), + None => { + log::warn!("Tray icon 'tray' not found while updating visibility"); + Ok(()) + } + } +} + +/// Switches OpenUsage between menu bar-only and Dock-only presentation. +/// `hidden = true` hides the Dock icon and shows the menu bar (tray) icon; +/// `hidden = false` shows the Dock icon and hides the menu bar icon. The two are +/// mutually exclusive so the menu bar never shows a redundant icon. No-op on +/// other platforms, where there is no Dock concept. +#[tauri::command] +fn update_dock_icon_visibility( + #[allow(unused)] app_handle: tauri::AppHandle, + #[allow(unused)] hidden: bool, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + log::info!("Updating dock icon visibility: hidden={}", hidden); + apply_dock_icon_visibility(&app_handle, hidden)?; + // Tray is the inverse of the Dock icon: visible only when Dock is hidden. + apply_tray_visibility(&app_handle, hidden)?; + + // In Dock-only mode the panel becomes a normal, draggable window centered + // on screen; in menu-bar mode it returns to the anchored dropdown. + let dock_mode = !hidden; + panel::set_dock_mode(dock_mode); + panel::apply_panel_presentation(&app_handle); + if dock_mode { + panel::position_panel_centered(&app_handle); + } + } + Ok(()) +} + +/// Toggles whether the Dock-mode window floats above other windows. Only has a +/// visible effect in Dock mode; the panel level is updated immediately. +#[tauri::command] +fn update_always_on_top( + #[allow(unused)] app_handle: tauri::AppHandle, + #[allow(unused)] enabled: bool, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + log::info!("Updating always on top: enabled={}", enabled); + panel::set_always_on_top(enabled); + panel::apply_panel_presentation(&app_handle); + } + Ok(()) +} + /// Update the global shortcut registration. /// Pass `null` to disable the shortcut, or a shortcut string like "CommandOrControl+Shift+U". #[cfg(desktop)] @@ -530,11 +665,31 @@ pub fn run() { start_probe_batch, list_plugins, get_log_path, - update_global_shortcut + update_global_shortcut, + update_dock_icon_visibility, + update_always_on_top ]) .setup(|app| { + // Apply the persisted Dock icon preference before anything else so the + // Dock never briefly shows an icon on launch. The app shows EITHER the + // menu bar (tray) icon OR the Dock icon, never both, to save menu bar + // space. Defaults to hidden Dock (menu bar-only). Tray visibility is + // applied below, right after the tray is created. + #[cfg(target_os = "macos")] + let hide_dock_icon = get_stored_hide_dock_icon(app.handle()); #[cfg(target_os = "macos")] - app.set_activation_policy(tauri::ActivationPolicy::Accessory); + { + let policy = if hide_dock_icon { + tauri::ActivationPolicy::Accessory + } else { + tauri::ActivationPolicy::Regular + }; + app.set_activation_policy(policy); + // Dock-only mode (Dock icon shown) makes the panel a normal, + // persistent window; record it so the panel behaves accordingly. + panel::set_dock_mode(!hide_dock_icon); + panel::set_always_on_top(get_stored_always_on_top(app.handle())); + } #[cfg(target_os = "macos")] { @@ -582,6 +737,13 @@ pub fn run() { tray::create(app.handle())?; + // Show the tray icon only when the Dock icon is hidden, so the user + // gets the menu bar icon OR the Dock icon, never both. + #[cfg(target_os = "macos")] + if let Err(error) = apply_tray_visibility(app.handle(), hide_dock_icon) { + log::warn!("Failed to set initial tray visibility: {}", error); + } + app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; @@ -621,10 +783,16 @@ pub fn run() { }) .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|_, event| match event { + .run(|_app_handle, event| match event { tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { local_http_api::flush_cache(); } + // In Dock-only mode there is no tray icon to click, so clicking the + // Dock icon (which triggers Reopen) is how the user opens the panel. + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + panel::show_panel_dock(_app_handle); + } _ => {} }); } diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index ce440aee..a5816606 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -1,8 +1,42 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use tauri::{AppHandle, Manager, Position, Size}; use tauri_nspanel::{ CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, }; +/// True while the app runs as a Dock-only window (no tray icon). In this mode +/// the panel behaves like a normal window: it does not auto-hide on blur and is +/// centered instead of anchored under the (absent) tray icon. +static DOCK_MODE: AtomicBool = AtomicBool::new(false); + +/// When in Dock mode, keep the window floating above other windows. +static ALWAYS_ON_TOP: AtomicBool = AtomicBool::new(false); + +/// Tracks whether the Dock-only window has been positioned at least once this +/// session. We center it once per launch (the first time it shows), then leave +/// it wherever the user dragged it. Leaving Dock mode resets this. +static DOCK_POSITIONED: AtomicBool = AtomicBool::new(false); + +pub fn set_dock_mode(enabled: bool) { + DOCK_MODE.store(enabled, Ordering::SeqCst); + if !enabled { + DOCK_POSITIONED.store(false, Ordering::SeqCst); + } +} + +pub fn set_always_on_top(enabled: bool) { + ALWAYS_ON_TOP.store(enabled, Ordering::SeqCst); +} + +fn is_dock_mode() -> bool { + DOCK_MODE.load(Ordering::SeqCst) +} + +fn is_dock_positioned() -> bool { + DOCK_POSITIONED.load(Ordering::SeqCst) +} + fn monitor_contains_physical_point( origin_x: f64, origin_y: f64, @@ -116,6 +150,72 @@ pub fn show_panel(app_handle: &AppHandle) { } } +/// Show the panel in Dock-only mode, where there is no tray icon to anchor to. +/// It is centered the first time it shows each launch, then left wherever the +/// user dragged it for the rest of the session. +pub fn show_panel_dock(app_handle: &AppHandle) { + if let Some(panel) = get_or_init_panel!(app_handle) { + panel.show_and_make_key(); + apply_panel_presentation(app_handle); + if !is_dock_positioned() { + position_panel_centered(app_handle); + } + } +} + +/// Set the panel window level for the current mode. Menu-bar mode floats above +/// everything as a dropdown. Dock mode uses a normal window level, unless +/// "always on top" is enabled, in which case it floats above other windows. +pub fn apply_panel_presentation(app_handle: &AppHandle) { + let Ok(panel) = app_handle.get_webview_panel("main") else { + return; + }; + let level = if is_dock_mode() { + if ALWAYS_ON_TOP.load(Ordering::SeqCst) { + PanelLevel::Floating.value() + } else { + PanelLevel::Normal.value() + } + } else { + PanelLevel::MainMenu.value() + 1 + }; + panel.set_level(level); +} + +/// Center the window on the primary monitor. Dock mode places the window here +/// on launch (and when first switching to Dock mode); the user can drag it +/// afterwards. Marks the window as positioned for the session. +pub fn position_panel_centered(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + + let monitor = match window.primary_monitor() { + Ok(Some(monitor)) => monitor, + _ => return, + }; + + let scale = monitor.scale_factor(); + let mon_logical_x = monitor.position().x as f64 / scale; + let mon_logical_y = monitor.position().y as f64 / scale; + let mon_logical_w = monitor.size().width as f64 / scale; + let mon_logical_h = monitor.size().height as f64 / scale; + + let (panel_w, panel_h) = match (window.outer_size(), window.scale_factor()) { + (Ok(size), Ok(win_scale)) => ( + size.width as f64 / win_scale, + size.height as f64 / win_scale, + ), + _ => (400.0, 500.0), + }; + + let panel_x = mon_logical_x + (mon_logical_w - panel_w) / 2.0; + let panel_y = mon_logical_y + (mon_logical_h - panel_h) / 2.0; + + set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, mon_logical_h); + DOCK_POSITIONED.store(true, Ordering::SeqCst); +} + /// Toggle panel visibility. If visible, hide it. If hidden, show it. /// Used by global shortcut handler. pub fn toggle_panel(app_handle: &AppHandle) { @@ -178,6 +278,11 @@ pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { let handle = app_handle.clone(); event_handler.window_did_resign_key(move |_notification| { + // In Dock-only mode the panel is a normal, persistent window, so it + // must stay open when it loses focus instead of auto-hiding. + if is_dock_mode() { + return; + } if let Ok(panel) = handle.get_webview_panel("main") { panel.hide(); } diff --git a/src/App.tsx b/src/App.tsx index f9c420ea..19c074ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,8 @@ function App() { setTimeFormatMode, setGlobalShortcut, setStartOnLogin, + setHideDockIcon, + setAlwaysOnTop, } = useAppPreferencesStore( useShallow((state) => ({ autoUpdateInterval: state.autoUpdateInterval, @@ -73,6 +75,8 @@ function App() { setTimeFormatMode: state.setTimeFormatMode, setGlobalShortcut: state.setGlobalShortcut, setStartOnLogin: state.setStartOnLogin, + setHideDockIcon: state.setHideDockIcon, + setAlwaysOnTop: state.setAlwaysOnTop, })) ) @@ -122,6 +126,8 @@ function App() { setTimeFormatMode, setGlobalShortcut, setStartOnLogin, + setHideDockIcon, + setAlwaysOnTop, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -150,12 +156,16 @@ function App() { handleAutoUpdateIntervalChange, handleGlobalShortcutChange, handleStartOnLoginChange, + handleHideDockIconChange, + handleAlwaysOnTopChange, } = useSettingsSystemActions({ pluginSettings, setAutoUpdateInterval, setAutoUpdateNextAt, setGlobalShortcut, setStartOnLogin, + setHideDockIcon, + setAlwaysOnTop, applyStartOnLogin, }) @@ -254,6 +264,8 @@ function App() { traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, + onHideDockIconChange: handleHideDockIconChange, + onAlwaysOnTopChange: handleAlwaysOnTopChange, }} /> ) diff --git a/src/components/app/app-content.test.tsx b/src/components/app/app-content.test.tsx index 0cce5238..d07a68b9 100644 --- a/src/components/app/app-content.test.tsx +++ b/src/components/app/app-content.test.tsx @@ -65,6 +65,8 @@ function createProps(): AppContentProps { onResetTimerDisplayModeToggle: vi.fn(), onGlobalShortcutChange: vi.fn(), onStartOnLoginChange: vi.fn(), + onHideDockIconChange: vi.fn(), + onAlwaysOnTopChange: vi.fn(), } } diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index c6afaec2..524d7b5d 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -37,6 +37,8 @@ export type AppContentActionProps = { traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void + onHideDockIconChange: (value: boolean) => void + onAlwaysOnTopChange: (value: boolean) => void } export type AppContentProps = AppContentDerivedProps & AppContentActionProps @@ -58,6 +60,8 @@ export function AppContent({ traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, + onHideDockIconChange, + onAlwaysOnTopChange, }: AppContentProps) { const { activeView } = useAppUiStore( useShallow((state) => ({ @@ -74,6 +78,8 @@ export function AppContent({ globalShortcut, themeMode, startOnLogin, + hideDockIcon, + alwaysOnTop, } = useAppPreferencesStore( useShallow((state) => ({ displayMode: state.displayMode, @@ -84,6 +90,8 @@ export function AppContent({ globalShortcut: state.globalShortcut, themeMode: state.themeMode, startOnLogin: state.startOnLogin, + hideDockIcon: state.hideDockIcon, + alwaysOnTop: state.alwaysOnTop, })) ) @@ -123,6 +131,10 @@ export function AppContent({ onGlobalShortcutChange={onGlobalShortcutChange} startOnLogin={startOnLogin} onStartOnLoginChange={onStartOnLoginChange} + hideDockIcon={hideDockIcon} + onHideDockIconChange={onHideDockIconChange} + alwaysOnTop={alwaysOnTop} + onAlwaysOnTopChange={onAlwaysOnTopChange} /> ) } diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 8de76733..b0450ea9 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -7,9 +7,12 @@ import type { SettingsPluginState } from "@/hooks/app/use-settings-plugin-list" import { useAppVersion } from "@/hooks/app/use-app-version" import { usePanel } from "@/hooks/app/use-panel" import { useAppUpdate } from "@/hooks/use-app-update" +import { useAppPreferencesStore } from "@/stores/app-preferences-store" import { useAppUiStore } from "@/stores/app-ui-store" const ARROW_OVERHEAD_PX = 37 +// Dock mode has no tray arrow; only the container's vertical padding eats height. +const DOCK_OVERHEAD_PX = 30 type AppShellProps = { onRefreshAll: () => void @@ -66,16 +69,19 @@ export function AppShell({ const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() + const dockMode = useAppPreferencesStore((state) => !state.hideDockIcon) + const topOverhead = dockMode ? DOCK_OVERHEAD_PX : ARROW_OVERHEAD_PX + return (
-
+ {!dockMode &&
}
diff --git a/src/components/side-nav.tsx b/src/components/side-nav.tsx index b363e888..bd59b6f5 100644 --- a/src/components/side-nav.tsx +++ b/src/components/side-nav.tsx @@ -48,6 +48,8 @@ interface SideNavProps { onPluginContextAction?: (pluginId: string, action: PluginContextAction) => void isPluginRefreshAvailable?: (pluginId: string) => boolean onReorder?: (orderedIds: string[]) => void + /** In Dock-only mode the sidebar doubles as the window drag handle. */ + draggable?: boolean } interface NavButtonProps { @@ -66,7 +68,7 @@ function NavButton({ isActive, onClick, onContextMenu, children, "aria-label": a onContextMenu={onContextMenu} aria-label={ariaLabel} className={cn( - "relative flex items-center justify-center w-full p-2.5 transition-colors", + "relative flex items-center justify-center w-full p-2.5 transition-colors cursor-pointer", "hover:bg-accent", isActive ? "text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary dark:before:bg-page-accent before:rounded-full" @@ -146,6 +148,7 @@ export function SideNav({ onPluginContextAction, isPluginRefreshAvailable, onReorder, + draggable = false, }: SideNavProps) { const isDark = useDarkMode() @@ -215,7 +218,13 @@ export function SideNav({ ) return ( -