From 4db558da17d0e6ca3d22ed06630c9dc7c7e8f674 Mon Sep 17 00:00:00 2001 From: vikas53953 Date: Sat, 9 May 2026 22:50:30 +0530 Subject: [PATCH] Add Windows support --- README.md | 9 +- docs/windows-setup.md | 74 +++++ src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 42 ++- src-tauri/src/panel.rs | 285 +---------------- src-tauri/src/panel_macos.rs | 294 ++++++++++++++++++ src-tauri/src/panel_windows.rs | 269 ++++++++++++++++ src-tauri/src/tray.rs | 23 +- src/App.test.tsx | 8 + src/components/app/app-shell.tsx | 41 ++- .../global-shortcut-section.test.tsx | 36 ++- src/components/global-shortcut-section.tsx | 30 +- src/main-entry.tsx | 63 ++++ src/main.test.tsx | 57 +++- src/main.tsx | 36 +-- vite.config.ts | 1 + 17 files changed, 920 insertions(+), 351 deletions(-) create mode 100644 docs/windows-setup.md create mode 100644 src-tauri/src/panel_macos.rs create mode 100644 src-tauri/src/panel_windows.rs create mode 100644 src/main-entry.tsx diff --git a/README.md b/README.md index d992761a..4a8181ae 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ See your usage at a glance from your menu bar. No digging through dashboards. [**Download the latest release**](https://github.com/robinebers/openusage/releases/latest) (macOS, Apple Silicon & Intel) -The app auto-updates. Install once and you're set. +Released macOS builds auto-update. Install once and you're set. + +Windows support is available from source. See [Windows setup](docs/windows-setup.md) for prerequisites and build instructions. ## What It Does @@ -44,6 +46,11 @@ Community contributions welcome. Want a provider that's not listed? [Open an issue.](https://github.com/robinebers/openusage/issues/new) +## Supported Platforms + +- macOS (released builds) +- Windows (source builds) + ## Open Source, Community Driven OpenUsage is built by its users. Hundreds of people use it daily, and the project grows through community contributions: new providers, bug fixes, and ideas. diff --git a/docs/windows-setup.md b/docs/windows-setup.md new file mode 100644 index 00000000..c82625c2 --- /dev/null +++ b/docs/windows-setup.md @@ -0,0 +1,74 @@ +# Windows Setup + +OpenUsage can be built and run on Windows with the standard Tauri 2 Windows toolchain. + +## Prerequisites + +- Windows 10 or Windows 11, 64-bit +- Rust and Cargo from [rustup.rs](https://rustup.rs/) +- Bun from [bun.sh](https://bun.sh/) +- Visual Studio Build Tools 2022 with the **Desktop development with C++** workload +- Microsoft Edge WebView2 Runtime +- LLVM, so `rquickjs-sys` can find `libclang.dll` +- Git + +On a fresh machine, install the native dependencies first: + +1. Install Visual Studio Build Tools 2022 from . +2. Select **Desktop development with C++**. +3. Install LLVM from or with `winget install LLVM.LLVM`. +4. Install Rust with rustup and Bun with the Bun installer. +5. Confirm WebView2 is installed in Windows Apps settings, or install it from . + +## Build Environment + +Run Rust/Tauri commands from a Visual Studio developer environment, and expose LLVM's `libclang.dll`: + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vsinstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +$devcmd = Join-Path $vsinstall "Common7\Tools\VsDevCmd.bat" +$cargoPath = "$env:USERPROFILE\.cargo\bin" +$llvmBin = "C:\Program Files\LLVM\bin" + +cmd.exe /d /s /c "call `"$devcmd`" -arch=x64 -host_arch=x64 && set `"PATH=$cargoPath;$llvmBin;%PATH%`" && set `"LIBCLANG_PATH=$llvmBin`" && cargo check --manifest-path src-tauri/Cargo.toml" +``` + +## Development + +```powershell +bun install +bun run bundle:plugins +bun run tauri dev +``` + +The Windows tray behavior differs from macOS: + +- Left-click toggles the usage panel. +- Right-click opens the tray menu. +- The panel is positioned near the Windows taskbar and clamped to the current monitor. +- Application data is stored under the Windows app data directory managed by Tauri. + +## Production Build + +```powershell +bun run tauri build --no-sign +``` + +The Windows bundles are emitted under: + +```text +src-tauri/target/release/bundle/ +``` + +The project has updater signing enabled. Local unsigned installer builds should pass `--no-sign`, which produces MSI/NSIS bundles and skips updater signing. Maintainer release builds should omit `--no-sign` and provide `TAURI_SIGNING_PRIVATE_KEY` so updater artifacts are signed. + +## Tested Windows Configuration + +- Windows Pro, build 26200.8328, 64-bit +- Rust `1.95.0` +- Cargo `1.95.0` +- Bun `1.3.11` +- Git `2.52.0.windows.1` +- LLVM/clang `22.1.5` +- WebView2 Runtime `147.0.3912.98` diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab2b343d..bb93ff7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,6 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-pn tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } time = { version = "0.3.47", features = ["formatting"] } dirs = "6" log = "0.4" @@ -44,6 +43,7 @@ aes-gcm = "0.10.3" sha2 = "0.10" [target.'cfg(target_os = "macos")'.dependencies] +tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } objc2-app-kit = { version = "0.3", features = ["NSEvent", "NSScreen", "NSGraphics"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc6aa864..c90d9e72 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 98b10d92..ccbcc214 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tauri_plugin_aptabase::EventTracker; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; @@ -138,6 +138,16 @@ pub struct AppState { pub app_version: String, } +#[cfg(target_os = "macos")] +fn add_platform_plugins(builder: tauri::Builder) -> tauri::Builder { + builder.plugin(tauri_nspanel::init()) +} + +#[cfg(not(target_os = "macos"))] +fn add_platform_plugins(builder: tauri::Builder) -> tauri::Builder { + builder +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginMeta { @@ -195,10 +205,7 @@ fn init_panel(app_handle: tauri::AppHandle) { #[tauri::command] fn hide_panel(app_handle: tauri::AppHandle) { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app_handle.get_webview_panel("main") { - panel.hide(); - } + panel::hide_panel(&app_handle); } #[tauri::command] @@ -346,10 +353,20 @@ async fn start_probe_batch( #[tauri::command] fn get_log_path(app_handle: tauri::AppHandle) -> Result { - // macOS log directory: ~/Library/Logs/{bundleIdentifier} - let home = dirs::home_dir().ok_or("no home dir")?; - let bundle_id = app_handle.config().identifier.clone(); - let log_dir = home.join("Library").join("Logs").join(&bundle_id); + #[cfg(target_os = "macos")] + let log_dir = { + // macOS log directory: ~/Library/Logs/{bundleIdentifier} + let home = dirs::home_dir().ok_or("no home dir")?; + let bundle_id = app_handle.config().identifier.clone(); + home.join("Library").join("Logs").join(&bundle_id) + }; + + #[cfg(not(target_os = "macos"))] + let log_dir = app_handle + .path() + .app_log_dir() + .map_err(|error| error.to_string())?; + let log_file = log_dir.join(format!("{}.log", app_handle.package_info().name)); Ok(log_file.to_string_lossy().to_string()) } @@ -470,11 +487,12 @@ pub fn run() { let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let _guard = runtime.enter(); - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_aptabase::Builder::new("A-US-6435241436").build()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_nspanel::init()) + .plugin(tauri_plugin_store::Builder::default().build()); + + add_platform_plugins(builder) .plugin( tauri_plugin_log::Builder::new() .targets([ diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index ce440aee..48861e2b 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -1,278 +1,11 @@ -use tauri::{AppHandle, Manager, Position, Size}; -use tauri_nspanel::{ - CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, -}; - -fn monitor_contains_physical_point( - origin_x: f64, - origin_y: f64, - width: f64, - height: f64, - point_x: f64, - point_y: f64, -) -> bool { - point_x >= origin_x - && point_x < origin_x + width - && point_y >= origin_y - && point_y < origin_y + height -} - -unsafe fn set_panel_frame_top_left(panel: &tauri_nspanel::NSPanel, x: f64, y: f64) { - let point = tauri_nspanel::NSPoint::new(x, y); - let _: () = objc2::msg_send![panel, setFrameTopLeftPoint: point]; -} - -fn set_panel_top_left_immediately( - window: &tauri::WebviewWindow, - app_handle: &AppHandle, - panel_x: f64, - panel_y: f64, - primary_logical_h: f64, -) { - let Ok(panel_handle) = app_handle.get_webview_panel("main") else { - return; - }; - - let target_x = panel_x; - let target_y = primary_logical_h - panel_y; - - if objc2_foundation::MainThreadMarker::new().is_some() { - unsafe { - set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); - } - return; - } - - let (tx, rx) = std::sync::mpsc::channel(); - let panel_handle = panel_handle.clone(); - - if let Err(error) = window.run_on_main_thread(move || { - unsafe { - set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); - } - let _ = tx.send(()); - }) { - log::warn!("Failed to position panel on main thread: {}", error); - return; - } - - if rx.recv().is_err() { - log::warn!("Failed waiting for panel position on main thread"); - } -} - -/// Macro to get existing panel or initialize it if needed. -/// Returns Option - Some if panel is available, None on error. -macro_rules! get_or_init_panel { - ($app_handle:expr) => { - match $app_handle.get_webview_panel("main") { - Ok(panel) => Some(panel), - Err(_) => { - if let Err(err) = crate::panel::init($app_handle) { - log::error!("Failed to init panel: {}", err); - None - } else { - match $app_handle.get_webview_panel("main") { - Ok(panel) => Some(panel), - Err(err) => { - log::error!("Panel missing after init: {:?}", err); - None - } - } - } - } - } - }; -} - -// Export macro for use in other modules -pub(crate) use get_or_init_panel; - -/// Retrieve the tray icon rect and position the panel beneath it. -/// No-ops gracefully if the tray icon or its rect is unavailable. -fn position_panel_from_tray(app_handle: &AppHandle) { - let Some(tray) = app_handle.tray_by_id("tray") else { - log::debug!("position_panel_from_tray: tray icon not found"); - return; - }; - match tray.rect() { - Ok(Some(rect)) => { - position_panel_at_tray_icon(app_handle, rect.position, rect.size); - } - Ok(None) => { - log::debug!("position_panel_from_tray: tray rect not available yet"); - } - Err(e) => { - log::warn!("position_panel_from_tray: failed to get tray rect: {}", e); - } - } -} - -/// Show the panel (initializing if needed), positioned under the tray icon. -pub fn show_panel(app_handle: &AppHandle) { - if let Some(panel) = get_or_init_panel!(app_handle) { - panel.show_and_make_key(); - position_panel_from_tray(app_handle); - } -} - -/// Toggle panel visibility. If visible, hide it. If hidden, show it. -/// Used by global shortcut handler. -pub fn toggle_panel(app_handle: &AppHandle) { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; - - if panel.is_visible() { - log::debug!("toggle_panel: hiding panel"); - panel.hide(); - } else { - log::debug!("toggle_panel: showing panel"); - panel.show_and_make_key(); - position_panel_from_tray(app_handle); - } -} - -// Define our panel class and event handler together -tauri_panel! { - panel!(OpenUsagePanel { - config: { - can_become_key_window: true, - is_floating_panel: true - } - }) - - panel_event!(OpenUsagePanelEventHandler { - window_did_resign_key(notification: &NSNotification) -> () - }) -} - -pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { - if app_handle.get_webview_panel("main").is_ok() { - return Ok(()); - } - - let window = app_handle.get_webview_window("main").unwrap(); +#[cfg(target_os = "macos")] +#[path = "panel_macos.rs"] +mod platform; - let panel = window.to_panel::()?; +#[cfg(not(target_os = "macos"))] +#[path = "panel_windows.rs"] +mod platform; - // Disable native shadow - it causes gray border on transparent windows - // Let CSS handle shadow via shadow-xl class - panel.set_has_shadow(false); - panel.set_opaque(false); - - // Configure panel behavior - panel.set_level(PanelLevel::MainMenu.value() + 1); - - panel.set_collection_behavior( - CollectionBehavior::new() - .move_to_active_space() - .full_screen_auxiliary() - .value(), - ); - - panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); - - // Set up event handler to hide panel when it loses focus - let event_handler = OpenUsagePanelEventHandler::new(); - - let handle = app_handle.clone(); - event_handler.window_did_resign_key(move |_notification| { - if let Ok(panel) = handle.get_webview_panel("main") { - panel.hide(); - } - }); - - panel.set_event_handler(Some(event_handler.as_ref())); - - Ok(()) -} - -pub fn position_panel_at_tray_icon( - app_handle: &tauri::AppHandle, - icon_position: Position, - icon_size: Size, -) { - let window = app_handle.get_webview_window("main").unwrap(); - - let (icon_phys_x, icon_phys_y) = match &icon_position { - Position::Physical(pos) => (pos.x as f64, pos.y as f64), - Position::Logical(pos) => (pos.x, pos.y), - }; - let (icon_phys_w, icon_phys_h) = match &icon_size { - Size::Physical(s) => (s.width as f64, s.height as f64), - Size::Logical(s) => (s.width, s.height), - }; - - let monitors = window.available_monitors().expect("failed to get monitors"); - let primary_logical_h = window - .primary_monitor() - .ok() - .flatten() - .map(|m| m.size().height as f64 / m.scale_factor()) - .unwrap_or(0.0); - - let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); - let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); - - let found_monitor = monitors.iter().find(|monitor| { - let origin = monitor.position(); - let size = monitor.size(); - monitor_contains_physical_point( - origin.x as f64, - origin.y as f64, - size.width as f64, - size.height as f64, - icon_center_x, - icon_center_y, - ) - }); - - let monitor = match found_monitor { - Some(m) => m.clone(), - None => { - log::warn!( - "No monitor found for tray rect center at ({:.0}, {:.0}), using primary", - icon_center_x, - icon_center_y - ); - match window.primary_monitor() { - Ok(Some(m)) => m, - _ => return, - } - } - }; - - let target_scale = monitor.scale_factor(); - let mon_phys_x = monitor.position().x as f64; - let mon_phys_y = monitor.position().y as f64; - let mon_logical_x = mon_phys_x / target_scale; - let mon_logical_y = mon_phys_y / target_scale; - - let icon_logical_x = mon_logical_x + (icon_phys_x - mon_phys_x) / target_scale; - let icon_logical_y = mon_logical_y + (icon_phys_y - mon_phys_y) / target_scale; - let icon_logical_w = icon_phys_w / target_scale; - let icon_logical_h = icon_phys_h / target_scale; - - // Read panel width from the window, converted to logical points. - // outer_size() returns physical pixels at the window's current scale factor. - // If the window isn't available yet, parse the configured width from tauri.conf.json - // (embedded at compile time) so it stays in sync automatically. - let panel_width = match (window.outer_size(), window.scale_factor()) { - (Ok(s), Ok(win_scale)) => s.width as f64 / win_scale, - _ => { - let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) - .expect("tauri.conf.json must be valid JSON"); - conf["app"]["windows"][0]["width"] - .as_f64() - .expect("width must be set in tauri.conf.json") - } - }; - - let icon_center_x = icon_logical_x + (icon_logical_w / 2.0); - let panel_x = icon_center_x - (panel_width / 2.0); - let nudge_up: f64 = 6.0; - let panel_y = icon_logical_y + icon_logical_h - nudge_up; - - set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); -} +pub use platform::{ + hide_panel, init, is_panel_visible, position_panel_at_tray_icon, show_panel, toggle_panel, +}; diff --git a/src-tauri/src/panel_macos.rs b/src-tauri/src/panel_macos.rs new file mode 100644 index 00000000..6d7ea0b7 --- /dev/null +++ b/src-tauri/src/panel_macos.rs @@ -0,0 +1,294 @@ +use tauri::{AppHandle, Manager, Position, Size}; +use tauri_nspanel::{ + tauri_panel, CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, +}; + +fn monitor_contains_physical_point( + origin_x: f64, + origin_y: f64, + width: f64, + height: f64, + point_x: f64, + point_y: f64, +) -> bool { + point_x >= origin_x + && point_x < origin_x + width + && point_y >= origin_y + && point_y < origin_y + height +} + +unsafe fn set_panel_frame_top_left(panel: &tauri_nspanel::NSPanel, x: f64, y: f64) { + let point = tauri_nspanel::NSPoint::new(x, y); + let _: () = objc2::msg_send![panel, setFrameTopLeftPoint: point]; +} + +fn set_panel_top_left_immediately( + window: &tauri::WebviewWindow, + app_handle: &AppHandle, + panel_x: f64, + panel_y: f64, + primary_logical_h: f64, +) { + let Ok(panel_handle) = app_handle.get_webview_panel("main") else { + return; + }; + + let target_x = panel_x; + let target_y = primary_logical_h - panel_y; + + if objc2_foundation::MainThreadMarker::new().is_some() { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + let panel_handle = panel_handle.clone(); + + if let Err(error) = window.run_on_main_thread(move || { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + let _ = tx.send(()); + }) { + log::warn!("Failed to position panel on main thread: {}", error); + return; + } + + if rx.recv().is_err() { + log::warn!("Failed waiting for panel position on main thread"); + } +} + +/// Macro to get existing panel or initialize it if needed. +/// Returns Option - Some if panel is available, None on error. +macro_rules! get_or_init_panel { + ($app_handle:expr) => { + match $app_handle.get_webview_panel("main") { + Ok(panel) => Some(panel), + Err(_) => { + if let Err(err) = crate::panel::init($app_handle) { + log::error!("Failed to init panel: {}", err); + None + } else { + match $app_handle.get_webview_panel("main") { + Ok(panel) => Some(panel), + Err(err) => { + log::error!("Panel missing after init: {:?}", err); + None + } + } + } + } + } + }; +} + +// Export macro for use in other modules +pub(crate) use get_or_init_panel; + +/// Retrieve the tray icon rect and position the panel beneath it. +/// No-ops gracefully if the tray icon or its rect is unavailable. +fn position_panel_from_tray(app_handle: &AppHandle) { + let Some(tray) = app_handle.tray_by_id("tray") else { + log::debug!("position_panel_from_tray: tray icon not found"); + return; + }; + match tray.rect() { + Ok(Some(rect)) => { + position_panel_at_tray_icon(app_handle, rect.position, rect.size); + } + Ok(None) => { + log::debug!("position_panel_from_tray: tray rect not available yet"); + } + Err(e) => { + log::warn!("position_panel_from_tray: failed to get tray rect: {}", e); + } + } +} + +/// Show the panel (initializing if needed), positioned under the tray icon. +pub fn show_panel(app_handle: &AppHandle) { + if let Some(panel) = get_or_init_panel!(app_handle) { + panel.show_and_make_key(); + position_panel_from_tray(app_handle); + } +} + +pub fn hide_panel(app_handle: &AppHandle) { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.hide(); + } +} + +pub fn is_panel_visible(app_handle: &AppHandle) -> bool { + app_handle + .get_webview_panel("main") + .map(|panel| panel.is_visible()) + .unwrap_or(false) +} + +/// Toggle panel visibility. If visible, hide it. If hidden, show it. +/// Used by global shortcut handler. +pub fn toggle_panel(app_handle: &AppHandle) { + let Some(panel) = get_or_init_panel!(app_handle) else { + return; + }; + + if panel.is_visible() { + log::debug!("toggle_panel: hiding panel"); + panel.hide(); + } else { + log::debug!("toggle_panel: showing panel"); + panel.show_and_make_key(); + position_panel_from_tray(app_handle); + } +} + +// Define our panel class and event handler together +tauri_panel! { + panel!(OpenUsagePanel { + config: { + can_become_key_window: true, + is_floating_panel: true + } + }) + + panel_event!(OpenUsagePanelEventHandler { + window_did_resign_key(notification: &NSNotification) -> () + }) +} + +pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { + if app_handle.get_webview_panel("main").is_ok() { + return Ok(()); + } + + let window = app_handle.get_webview_window("main").unwrap(); + + let panel = window.to_panel::()?; + + // Disable native shadow - it causes gray border on transparent windows + // Let CSS handle shadow via shadow-xl class + panel.set_has_shadow(false); + panel.set_opaque(false); + + // Configure panel behavior + panel.set_level(PanelLevel::MainMenu.value() + 1); + + panel.set_collection_behavior( + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .value(), + ); + + panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); + + // Set up event handler to hide panel when it loses focus + let event_handler = OpenUsagePanelEventHandler::new(); + + let handle = app_handle.clone(); + event_handler.window_did_resign_key(move |_notification| { + if let Ok(panel) = handle.get_webview_panel("main") { + panel.hide(); + } + }); + + panel.set_event_handler(Some(event_handler.as_ref())); + + Ok(()) +} + +pub fn position_panel_at_tray_icon( + app_handle: &tauri::AppHandle, + icon_position: Position, + icon_size: Size, +) { + let window = app_handle.get_webview_window("main").unwrap(); + + let (icon_phys_x, icon_phys_y) = match &icon_position { + Position::Physical(pos) => (pos.x as f64, pos.y as f64), + Position::Logical(pos) => (pos.x, pos.y), + }; + let (icon_phys_w, icon_phys_h) = match &icon_size { + Size::Physical(s) => (s.width as f64, s.height as f64), + Size::Logical(s) => (s.width, s.height), + }; + + let Ok(monitors) = window.available_monitors() else { + log::warn!("Failed to get monitors for panel positioning"); + return; + }; + let primary_logical_h = window + .primary_monitor() + .ok() + .flatten() + .map(|m| m.size().height as f64 / m.scale_factor()) + .unwrap_or(0.0); + + let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); + let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); + + let found_monitor = monitors.iter().find(|monitor| { + let origin = monitor.position(); + let size = monitor.size(); + monitor_contains_physical_point( + origin.x as f64, + origin.y as f64, + size.width as f64, + size.height as f64, + icon_center_x, + icon_center_y, + ) + }); + + let monitor = match found_monitor { + Some(m) => m.clone(), + None => { + log::warn!( + "No monitor found for tray rect center at ({:.0}, {:.0}), using primary", + icon_center_x, + icon_center_y + ); + match window.primary_monitor() { + Ok(Some(m)) => m, + _ => return, + } + } + }; + + let target_scale = monitor.scale_factor(); + let mon_phys_x = monitor.position().x as f64; + let mon_phys_y = monitor.position().y as f64; + let mon_logical_x = mon_phys_x / target_scale; + let mon_logical_y = mon_phys_y / target_scale; + + let icon_logical_x = mon_logical_x + (icon_phys_x - mon_phys_x) / target_scale; + let icon_logical_y = mon_logical_y + (icon_phys_y - mon_phys_y) / target_scale; + let icon_logical_w = icon_phys_w / target_scale; + let icon_logical_h = icon_phys_h / target_scale; + + // Read panel width from the window, converted to logical points. + // outer_size() returns physical pixels at the window's current scale factor. + // If the window isn't available yet, parse the configured width from tauri.conf.json + // (embedded at compile time) so it stays in sync automatically. + let panel_width = match (window.outer_size(), window.scale_factor()) { + (Ok(s), Ok(win_scale)) => s.width as f64 / win_scale, + _ => { + let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json must be valid JSON"); + conf["app"]["windows"][0]["width"] + .as_f64() + .expect("width must be set in tauri.conf.json") + } + }; + + let icon_center_x = icon_logical_x + (icon_logical_w / 2.0); + let panel_x = icon_center_x - (panel_width / 2.0); + let nudge_up: f64 = 6.0; + let panel_y = icon_logical_y + icon_logical_h - nudge_up; + + set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); +} diff --git a/src-tauri/src/panel_windows.rs b/src-tauri/src/panel_windows.rs new file mode 100644 index 00000000..967090ae --- /dev/null +++ b/src-tauri/src/panel_windows.rs @@ -0,0 +1,269 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::{AppHandle, Manager, PhysicalPosition, Position, Size, WindowEvent}; + +static FOCUS_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); + +struct PanelLayout { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn panel_layout_for_window(window: &tauri::WebviewWindow) -> PanelLayout { + let panel_size = window.outer_size().ok(); + let conf: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).expect("tauri.conf.json is valid"); + let fallback_scale = window + .scale_factor() + .ok() + .or_else(|| { + window + .current_monitor() + .ok() + .flatten() + .map(|monitor| monitor.scale_factor()) + }) + .unwrap_or(1.0); + let fallback_width = + conf["app"]["windows"][0]["width"].as_f64().unwrap_or(400.0) * fallback_scale; + let fallback_height = conf["app"]["windows"][0]["height"] + .as_f64() + .unwrap_or(500.0) + * fallback_scale; + + PanelLayout { + x: 0.0, + y: 0.0, + width: panel_size + .as_ref() + .map(|size| size.width as f64) + .unwrap_or(fallback_width), + height: panel_size + .as_ref() + .map(|size| size.height as f64) + .unwrap_or(fallback_height), + } +} + +fn work_area_layout(monitor: &tauri::Monitor) -> PanelLayout { + let work_area = monitor.work_area(); + PanelLayout { + x: work_area.position.x as f64, + y: work_area.position.y as f64, + width: work_area.size.width as f64, + height: work_area.size.height as f64, + } +} + +fn monitor_layout(monitor: &tauri::Monitor) -> PanelLayout { + let position = monitor.position(); + let size = monitor.size(); + PanelLayout { + x: position.x as f64, + y: position.y as f64, + width: size.width as f64, + height: size.height as f64, + } +} + +fn contains_point(layout: &PanelLayout, point_x: f64, point_y: f64) -> bool { + point_x >= layout.x + && point_x < layout.x + layout.width + && point_y >= layout.y + && point_y < layout.y + layout.height +} + +fn set_window_position(window: &tauri::WebviewWindow, panel_x: f64, panel_y: f64) { + if let Err(error) = window.set_position(Position::Physical(PhysicalPosition { + x: panel_x.round() as i32, + y: panel_y.round() as i32, + })) { + log::warn!("Failed to position panel: {}", error); + } +} + +fn position_panel_from_tray(app_handle: &AppHandle) { + let Some(tray) = app_handle.tray_by_id("tray") else { + log::debug!("position_panel_from_tray: tray icon not found"); + position_panel_at_taskbar(app_handle); + return; + }; + + match tray.rect() { + Ok(Some(rect)) => position_panel_at_tray_icon(app_handle, rect.position, rect.size), + Ok(None) => { + log::debug!("position_panel_from_tray: tray rect not available yet"); + position_panel_at_taskbar(app_handle); + } + Err(error) => { + log::warn!( + "position_panel_from_tray: failed to get tray rect: {}", + error + ); + position_panel_at_taskbar(app_handle); + } + } +} + +pub fn init(app_handle: &AppHandle) -> tauri::Result<()> { + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.set_shadow(false) { + log::debug!("Failed to disable Windows panel shadow: {}", error); + } + + if FOCUS_HANDLER_INSTALLED + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + let handle = app_handle.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(false)) && is_panel_visible(&handle) { + hide_panel(&handle); + } + }); + } + + Ok(()) + } else { + Err(tauri::Error::WindowNotFound) + } +} + +pub fn hide_panel(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.hide() { + log::warn!("Failed to hide panel: {}", error); + } + } +} + +pub fn is_panel_visible(app_handle: &AppHandle) -> bool { + app_handle + .get_webview_window("main") + .and_then(|window| window.is_visible().ok()) + .unwrap_or(false) +} + +pub fn show_panel(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + log::error!("Panel window missing"); + return; + }; + + if let Err(error) = window.set_shadow(false) { + log::debug!("Failed to disable Windows panel shadow: {}", error); + } + + position_panel_from_tray(app_handle); + + if let Err(error) = window.show() { + log::warn!("Failed to show panel: {}", error); + return; + } + + if let Err(error) = window.set_focus() { + log::debug!("Failed to focus panel: {}", error); + } +} + +pub fn toggle_panel(app_handle: &AppHandle) { + if is_panel_visible(app_handle) { + hide_panel(app_handle); + } else { + show_panel(app_handle); + } +} + +pub fn position_panel_at_tray_icon( + app_handle: &AppHandle, + icon_position: Position, + icon_size: Size, +) { + let Some(window) = app_handle.get_webview_window("main") else { + log::error!("Panel window missing"); + return; + }; + + let (icon_phys_x, icon_phys_y) = match icon_position { + Position::Physical(pos) => (pos.x as f64, pos.y as f64), + Position::Logical(pos) => (pos.x, pos.y), + }; + let (icon_phys_w, icon_phys_h) = match icon_size { + Size::Physical(size) => (size.width as f64, size.height as f64), + Size::Logical(size) => (size.width, size.height), + }; + + let Ok(monitors) = window.available_monitors() else { + log::warn!("Failed to get monitors for panel positioning"); + return; + }; + + let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); + let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); + + let monitor = monitors + .iter() + .find(|monitor| { + let bounds = monitor_layout(monitor); + contains_point(&bounds, icon_center_x, icon_center_y) + }) + .cloned() + .or_else(|| window.primary_monitor().ok().flatten()); + + let Some(monitor) = monitor else { + log::warn!("No monitor available for panel positioning"); + return; + }; + + let panel = panel_layout_for_window(&window); + let work_area = work_area_layout(&monitor); + let gap = 12.0; + + let icon_near_bottom = icon_center_y > work_area.y + (work_area.height * 0.7); + let icon_near_top = icon_center_y < work_area.y + (work_area.height * 0.3); + if !icon_near_bottom && !icon_near_top { + position_panel_at_taskbar(app_handle); + return; + } + + let desired_x = icon_center_x - (panel.width / 2.0); + let desired_y = if icon_near_bottom { + icon_phys_y - panel.height - gap + } else { + icon_phys_y + icon_phys_h + gap + }; + + let max_x = work_area.x + work_area.width - panel.width; + let max_y = work_area.y + work_area.height - panel.height; + let panel_x = desired_x.clamp(work_area.x, max_x.max(work_area.x)); + let panel_y = desired_y.clamp(work_area.y, max_y.max(work_area.y)); + + set_window_position(&window, panel_x, panel_y); +} + +fn position_panel_at_taskbar(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + log::error!("Panel window missing"); + return; + }; + + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| window.primary_monitor().ok().flatten()); + + let Some(monitor) = monitor else { + log::warn!("No monitor available for taskbar panel positioning"); + return; + }; + + let panel = panel_layout_for_window(&window); + let work_area = work_area_layout(&monitor); + let gap = 12.0; + let panel_x = (work_area.x + work_area.width - panel.width - gap).max(work_area.x); + let panel_y = (work_area.y + work_area.height - panel.height - gap).max(work_area.y); + + set_window_position(&window, panel_x, panel_y); +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 545f29b0..dd8d3330 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,12 +1,11 @@ use tauri::image::Image; use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::path::BaseDirectory; -use tauri::tray::{MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager}; -use tauri_nspanel::ManagerExt; use tauri_plugin_store::StoreExt; -use crate::panel::{get_or_init_panel, position_panel_at_tray_icon, show_panel}; +use crate::panel::{hide_panel, is_panel_visible, position_panel_at_tray_icon, show_panel}; const LOG_LEVEL_STORE_KEY: &str = "logLevel"; @@ -183,23 +182,21 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let app_handle = tray.app_handle(); if let TrayIconEvent::Click { - button_state, rect, .. + button, + button_state, + rect, + .. } = event { - if button_state == MouseButtonState::Up { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; - - if panel.is_visible() { + if button == MouseButton::Left && button_state == MouseButtonState::Up { + if is_panel_visible(app_handle) { log::debug!("tray click: hiding panel"); - panel.hide(); + hide_panel(app_handle); return; } log::debug!("tray click: showing panel"); - // macOS quirk: must show window before positioning to another monitor - panel.show_and_make_key(); + show_panel(app_handle); position_panel_at_tray_icon(app_handle, rect.position, rect.size); } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 7e918742..80ae1232 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -70,6 +70,13 @@ const menuState = vi.hoisted(() => ({ menuCloseMock: vi.fn(async () => undefined), })) +function mockPlatform(platform: string) { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }) +} + vi.mock("@dnd-kit/core", () => ({ DndContext: ({ children, onDragEnd }: { children: ReactNode; onDragEnd?: (event: any) => void }) => { dndState.latestOnDragEnd = onDragEnd ?? null @@ -244,6 +251,7 @@ import { useAppUiStore } from "@/stores/app-ui-store" describe("App", () => { beforeEach(() => { + mockPlatform("MacIntel") useAppUiStore.getState().resetState() useAppPluginStore.getState().resetState() useAppPreferencesStore.getState().resetState() diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 8de76733..8aae9e2b 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -1,3 +1,6 @@ +import type { MouseEvent } from "react" +import { isTauri } from "@tauri-apps/api/core" +import { getCurrentWindow } from "@tauri-apps/api/window" import { useShallow } from "zustand/react/shallow" import { AppContent, type AppContentActionProps } from "@/components/app/app-content" import { PanelFooter } from "@/components/panel-footer" @@ -6,11 +9,17 @@ import type { DisplayPluginState } from "@/hooks/app/use-app-plugin-views" 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 { cn } from "@/lib/utils" import { useAppUpdate } from "@/hooks/use-app-update" import { useAppUiStore } from "@/stores/app-ui-store" const ARROW_OVERHEAD_PX = 37 +function isWindowsPlatform() { + if (typeof navigator === "undefined") return false + return /Win/.test(navigator.platform) +} + type AppShellProps = { onRefreshAll: () => void navPlugins: NavPlugin[] @@ -65,18 +74,42 @@ export function AppShell({ const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() + const isWindows = isWindowsPlatform() + const panelHeightOverheadPx = isWindows ? 16 : ARROW_OVERHEAD_PX + const handleDragMouseDown = (event: MouseEvent) => { + if (!isWindows || event.button !== 0 || !isTauri()) return + void getCurrentWindow().startDragging().catch((error) => { + console.error("Failed to start window drag:", error) + }) + } return (
-
+ {!isWindows &&
}
+ {isWindows && ( +