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 && (
+
+ )}
{
+ beforeEach(() => {
+ mockPlatform("MacIntel")
+ })
+
afterEach(() => {
vi.useRealTimers()
})
@@ -29,6 +40,12 @@ describe("GlobalShortcutSection", () => {
expect(screen.getByText("Cmd + Opt + Delete")).toBeInTheDocument()
})
+ it("formats persisted shortcuts for Windows display", () => {
+ mockPlatform("Win32")
+ renderSection("CommandOrControl+Alt+Delete")
+ expect(screen.getByText("Ctrl + Alt + Delete")).toBeInTheDocument()
+ })
+
it("records and saves CommandOrControl + Shift + key", async () => {
const { onGlobalShortcutChange } = renderSection()
const textbox = await startRecording()
@@ -96,6 +113,23 @@ describe("GlobalShortcutSection", () => {
expect(onGlobalShortcutChange).toHaveBeenCalledWith("Alt+Slash")
})
+ it("records Windows shortcuts with Ctrl display while saving CommandOrControl", async () => {
+ mockPlatform("Win32")
+ const { onGlobalShortcutChange } = renderSection()
+ const textbox = await startRecording()
+
+ fireEvent.keyDown(textbox, { key: "Control", code: "ControlLeft" })
+ fireEvent.keyDown(textbox, { key: "Shift", code: "ShiftLeft" })
+ fireEvent.keyDown(textbox, { key: "U", code: "KeyU" })
+ expect(screen.getByText("Ctrl + Shift + U")).toBeInTheDocument()
+
+ fireEvent.keyUp(textbox, { key: "U", code: "KeyU" })
+ fireEvent.keyUp(textbox, { key: "Shift", code: "ShiftLeft" })
+ fireEvent.keyUp(textbox, { key: "Control", code: "ControlLeft" })
+
+ expect(onGlobalShortcutChange).toHaveBeenCalledWith("CommandOrControl+Shift+U")
+ })
+
it("does not save when only modifiers are pressed", async () => {
const { onGlobalShortcutChange } = renderSection()
const textbox = await startRecording()
diff --git a/src/components/global-shortcut-section.tsx b/src/components/global-shortcut-section.tsx
index 22427ccb..6d730e83 100644
--- a/src/components/global-shortcut-section.tsx
+++ b/src/components/global-shortcut-section.tsx
@@ -3,15 +3,30 @@ import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { GlobalShortcut } from "@/lib/settings"
-// Convert internal shortcut format to display format
-// e.g., "CommandOrControl+Shift+U" -> "Cmd + Shift + U"
+function isApplePlatform(): boolean {
+ if (typeof navigator === "undefined") return false
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
+}
+
+function platformModifierLabels() {
+ const apple = isApplePlatform()
+ return {
+ commandOrControl: apple ? "Cmd" : "Ctrl",
+ alt: apple ? "Opt" : "Alt",
+ }
+}
+
+// Convert internal shortcut format to display format.
+// e.g. macOS: "CommandOrControl+Shift+U" -> "Cmd + Shift + U"
+// Windows: "CommandOrControl+Shift+U" -> "Ctrl + Shift + U"
function formatShortcutForDisplay(shortcut: string): string {
+ const labels = platformModifierLabels()
return shortcut
- .replace(/CommandOrControl/g, "Cmd")
+ .replace(/CommandOrControl/g, labels.commandOrControl)
.replace(/Command/g, "Cmd")
.replace(/Control/g, "Ctrl")
- .replace(/Option/g, "Opt")
- .replace(/Alt/g, "Opt")
+ .replace(/Option/g, labels.alt)
+ .replace(/Alt/g, labels.alt)
.replace(/\+/g, " + ")
}
@@ -99,6 +114,7 @@ function codeToTauriKey(code: string): string {
function buildShortcutFromCodes(codes: Set): { display: string; tauri: string | null } {
const modifiers: string[] = []
const displayMods: string[] = []
+ const labels = platformModifierLabels()
let mainCode: string | null = null
for (const code of codes) {
@@ -107,12 +123,12 @@ function buildShortcutFromCodes(codes: Set): { display: string; tauri: s
if (normalized === "Meta" || normalized === "Control") {
if (!modifiers.includes("CommandOrControl")) {
modifiers.push("CommandOrControl")
- displayMods.push("Cmd")
+ displayMods.push(labels.commandOrControl)
}
} else if (normalized === "Alt") {
if (!modifiers.includes("Alt")) {
modifiers.push("Alt")
- displayMods.push("Opt")
+ displayMods.push(labels.alt)
}
} else if (normalized === "Shift") {
if (!modifiers.includes("Shift")) {
diff --git a/src/main-entry.tsx b/src/main-entry.tsx
new file mode 100644
index 00000000..af2a6ae7
--- /dev/null
+++ b/src/main-entry.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log";
+import { App } from "./App";
+
+type ConsoleForwardingState = {
+ installed: boolean;
+ originalError: (...args: unknown[]) => void;
+ originalWarn: (...args: unknown[]) => void;
+};
+
+type ConsoleWithForwardingState = Console & {
+ __openUsageLogForwarding?: ConsoleForwardingState;
+};
+
+function getConsoleForwardingState(): ConsoleForwardingState {
+ const consoleWithState = console as ConsoleWithForwardingState;
+ if (!consoleWithState.__openUsageLogForwarding) {
+ consoleWithState.__openUsageLogForwarding = {
+ installed: false,
+ originalError: console.error.bind(console),
+ originalWarn: console.warn.bind(console),
+ };
+ }
+ return consoleWithState.__openUsageLogForwarding;
+}
+
+function stringify(arg: unknown): string {
+ if (arg === null) return "null";
+ if (arg === undefined) return "undefined";
+ if (typeof arg === "string") return arg;
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
+ try {
+ return JSON.stringify(arg);
+ } catch {
+ return String(arg);
+ }
+}
+
+export function installConsoleLogForwarding() {
+ const forwardingState = getConsoleForwardingState();
+ if (forwardingState.installed) return;
+ forwardingState.installed = true;
+
+ console.error = (...args: unknown[]) => {
+ forwardingState.originalError(...args);
+ logError(args.map(stringify).join(" ")).catch(() => {});
+ };
+
+ console.warn = (...args: unknown[]) => {
+ forwardingState.originalWarn(...args);
+ logWarn(args.map(stringify).join(" ")).catch(() => {});
+ };
+}
+
+export function mountApp() {
+ installConsoleLogForwarding();
+ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+ );
+}
diff --git a/src/main.test.tsx b/src/main.test.tsx
index 5283dc37..5760c5b2 100644
--- a/src/main.test.tsx
+++ b/src/main.test.tsx
@@ -1,7 +1,20 @@
-import { describe, expect, it, vi } from "vitest"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const renderMock = vi.fn()
const createRootMock = vi.fn(() => ({ render: renderMock }))
+const { logErrorMock, logWarnMock } = vi.hoisted(() => ({
+ logErrorMock: vi.fn(() => Promise.resolve()),
+ logWarnMock: vi.fn(() => Promise.resolve()),
+}))
+
+vi.mock("@/App", () => ({
+ App: () => null,
+}))
+
+vi.mock("@tauri-apps/plugin-log", () => ({
+ error: logErrorMock,
+ warn: logWarnMock,
+}))
vi.mock("react-dom/client", () => ({
default: {
@@ -9,11 +22,51 @@ vi.mock("react-dom/client", () => ({
},
}))
+const originalError = console.error
+const originalWarn = console.warn
+
describe("main", () => {
+ beforeEach(() => {
+ vi.resetModules()
+ createRootMock.mockClear()
+ renderMock.mockClear()
+ logErrorMock.mockClear()
+ logWarnMock.mockClear()
+ console.error = originalError
+ console.warn = originalWarn
+ delete (console as Console & { __openUsageLogForwarding?: unknown }).__openUsageLogForwarding
+ })
+
+ afterEach(() => {
+ console.error = originalError
+ console.warn = originalWarn
+ delete (console as Console & { __openUsageLogForwarding?: unknown }).__openUsageLogForwarding
+ })
+
it("mounts app", async () => {
document.body.innerHTML = ''
- await import("@/main")
+ const { mountApp } = await import("@/main-entry")
+ mountApp()
expect(createRootMock).toHaveBeenCalled()
expect(renderMock).toHaveBeenCalled()
})
+
+ it("installs console forwarding only once", async () => {
+ const originalWarnMock = vi.fn()
+ const originalErrorMock = vi.fn()
+ console.warn = originalWarnMock
+ console.error = originalErrorMock
+
+ const { installConsoleLogForwarding } = await import("@/main-entry")
+ installConsoleLogForwarding()
+ installConsoleLogForwarding()
+
+ console.warn("warning")
+ console.error("error")
+
+ expect(originalWarnMock).toHaveBeenCalledTimes(1)
+ expect(originalErrorMock).toHaveBeenCalledTimes(1)
+ expect(logWarnMock).toHaveBeenCalledTimes(1)
+ expect(logErrorMock).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/src/main.tsx b/src/main.tsx
index 9d680d5c..7e727605 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,36 +1,4 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log";
-import { App } from "./App";
import "./index.css";
+import { mountApp } from "./main-entry";
-// Forward console.error and console.warn to Tauri log file
-function stringify(arg: unknown): string {
- if (arg === null) return "null";
- if (arg === undefined) return "undefined";
- if (typeof arg === "string") return arg;
- if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
- try {
- return JSON.stringify(arg);
- } catch {
- return String(arg);
- }
-}
-
-const originalError = console.error;
-console.error = (...args: unknown[]) => {
- originalError(...args);
- logError(args.map(stringify).join(" ")).catch(() => {});
-};
-
-const originalWarn = console.warn;
-console.warn = (...args: unknown[]) => {
- originalWarn(...args);
- logWarn(args.map(stringify).join(" ")).catch(() => {});
-};
-
-ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
- ,
-);
+mountApp();
diff --git a/vite.config.ts b/vite.config.ts
index fb266892..945242f2 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -21,6 +21,7 @@ export default defineConfig(async () => ({
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}", "plugins/**/*.test.js"],
exclude: ["**/node_modules/**", "**/src-tauri/target/**"],
+ testTimeout: 15_000,
clearMocks: true,
mockReset: true,
restoreMocks: true,