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 ( -