From 974d9c1afdf4493f190c8c331dc3d97568592d49 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 14:05:06 +0700 Subject: [PATCH] fix: build bubbles off event handlers, snapshot tray state on the main thread, keep Windows autostart per user Building a webview window inside a menu or window event handler deadlocks on Windows, so provider toggles and the Destroyed recreate now build the bubble on a short-lived thread, serialised so only one of them creates it. refresh_shell now takes its state snapshot inside the main-thread closure, so a refresh queued by the poll worker cannot re-apply a tree older than one a menu click already applied. On Windows the start-at-login entry is written through auto-launch in current-user mode with the quoted exe path, so an elevated run can no longer write an all-users entry with an unquoted path that a normal run cannot remove. Linux and macOS keep the autostart plugin. --- Cargo.lock | 1 + Cargo.toml | 7 ++- src/menu.rs | 63 +++++++++++++++++++++------ src/platform/windows.rs | 33 ++++++++++++++ src/runtime.rs | 96 +++++++++++++++++++++++++---------------- 5 files changed, 147 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5a77ef..0826a05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,7 @@ dependencies = [ name = "claude-code-usage-bubble" version = "0.5.0" dependencies = [ + "auto-launch", "dirs 6.0.0", "flate2", "libc", diff --git a/Cargo.toml b/Cargo.toml index e525e31..074c94e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ path = "src/main.rs" [dependencies] tauri = { version = "2", features = ["tray-icon", "macos-private-api"] } tauri-plugin-single-instance = "2" -tauri-plugin-autostart = "2" # rustls with the OS verifier keeps corporate roots trusted; gzip is a default. ureq = { version = "3", features = ["platform-verifier"] } serde = { version = "1", features = ["derive"] } @@ -32,6 +31,9 @@ sha2 = "0.10" self-replace = "1.5" tauri-plugin-notification = "2" +[target.'cfg(not(windows))'.dependencies] +tauri-plugin-autostart = "2" + [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -55,6 +57,9 @@ windows = { version = "0.62", features = [ webview2-com = "0.39" # Same major as the copy auto-launch pulls; reads the system proxy. windows-registry = "0.6" +# The start-at-login entry, pinned to the current user (the autostart +# plugin's builder cannot set that mode). +auto-launch = "0.6" [dev-dependencies] flate2 = "1" diff --git a/src/menu.rs b/src/menu.rs index 373cb90..ea66566 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -13,6 +13,7 @@ use tauri::menu::{ CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, }; use tauri::{AppHandle, Manager, State, Wry}; +#[cfg(not(windows))] use tauri_plugin_autostart::{AutoLaunchManager, ManagerExt}; use crate::core::i18n::LocaleStrings; @@ -72,10 +73,7 @@ impl MenuCache { /// Whether the app starts at login. Reads the live entry, so a Task /// Manager disable shows as unchecked; any read error counts as off. pub fn autostart_enabled(app: &AppHandle) -> bool { - if app.try_state::().is_none() { - return false; - } - app.autolaunch().is_enabled().unwrap_or_else(|err| { + autostart_state(app).unwrap_or_else(|err| { log::warn!("autostart state unreadable: {err}"); false }) @@ -84,21 +82,58 @@ pub fn autostart_enabled(app: &AppHandle) -> bool { /// Flip the start-at-login entry. Errors are logged; the next menu rebuild /// shows the real state either way. pub fn toggle_autostart(app: &AppHandle) { - if app.try_state::().is_none() { - log::warn!("autostart plugin is not available"); - return; - } - let manager = app.autolaunch(); - let result = if autostart_enabled(app) { - manager.disable() - } else { - manager.enable() - }; + let result = autostart_state(app).and_then(|enabled| set_autostart(app, !enabled)); if let Err(err) = result { log::error!("autostart toggle failed: {err}"); } } +/// Windows bypasses the autostart plugin: its builder cannot pin the +/// current-user mode, so an elevated run would write an all-users entry +/// with an unquoted path (see `platform::windows::autostart_entry`). +#[cfg(windows)] +fn autostart_state(_app: &AppHandle) -> Result { + crate::platform::windows::autostart_entry(crate::runtime::AUTOSTART_NAME) + .map_err(|err| err.to_string())? + .is_enabled() + .map_err(|err| err.to_string()) +} + +#[cfg(windows)] +fn set_autostart(_app: &AppHandle, enable: bool) -> Result<(), String> { + let entry = crate::platform::windows::autostart_entry(crate::runtime::AUTOSTART_NAME) + .map_err(|err| err.to_string())?; + let result = if enable { + entry.enable() + } else { + entry.disable() + }; + result.map_err(|err| err.to_string()) +} + +/// Without the plugin (it failed to set up) start at login reads as off. +#[cfg(not(windows))] +fn autostart_state(app: &AppHandle) -> Result { + if app.try_state::().is_none() { + return Ok(false); + } + app.autolaunch().is_enabled().map_err(|err| err.to_string()) +} + +#[cfg(not(windows))] +fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> { + if app.try_state::().is_none() { + return Err("autostart plugin is not available".into()); + } + let manager = app.autolaunch(); + let result = if enable { + manager.enable() + } else { + manager.disable() + }; + result.map_err(|err| err.to_string()) +} + /// Build a native menu from the tree, keeping order, labels, check marks /// and greyed states. Hint rows are disabled items with generated ids. pub fn build(app: &AppHandle, entries: &[MenuEntry]) -> tauri::Result> { diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 098dcba..be4abd9 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -139,6 +139,26 @@ fn write_if_changed(path: &Path, bytes: &[u8]) -> io::Result<()> { std::fs::write(path, bytes) } +/// The current user's start-at-login entry, `HKCU\…\Run\`. Its data +/// is the quoted exe path, as every earlier release wrote it, so a path with +/// spaces cannot resolve to a planted prefix. HKLM is never written, so an +/// elevated run cannot leave an entry that a normal run cannot remove. +/// `is_enabled` honours a Task Manager disable in `StartupApproved\Run`. +pub fn autostart_entry(name: &str) -> io::Result { + let exe = std::env::current_exe()?; + Ok(auto_launch::AutoLaunch::new( + name, + "ed_path(&exe), + auto_launch::WindowsEnableMode::CurrentUser, + &[] as &[&str], + )) +} + +/// `path` wrapped in double quotes, the form a `Run` value needs. +fn quoted_path(path: &Path) -> String { + format!("\"{}\"", path.display()) +} + /// Bring `window` to the foreground. A popup menu needs its owner in the /// foreground, or it does not dismiss on a click elsewhere. pub fn set_foreground(window: &tauri::WebviewWindow) { @@ -152,3 +172,16 @@ pub fn set_foreground(window: &tauri::WebviewWindow) { Err(err) => log::warn!("no HWND for {}: {err}", window.label()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_value_path_is_quoted() { + assert_eq!( + quoted_path(Path::new(r"D:\My Tools\claude-code-usage-bubble.exe")), + r#""D:\My Tools\claude-code-usage-bubble.exe""# + ); + } +} diff --git a/src/runtime.rs b/src/runtime.rs index 2bcb92d..bdde3fc 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -49,7 +49,7 @@ const REFRESH_TIMEOUT: Duration = Duration::from_secs(8); /// Name of the start-at-login entry: the Windows `Run` value name of every /// earlier release, so an existing entry is already the plugin's entry. -const AUTOSTART_NAME: &str = "ClaudeCodeUsageBubble"; +pub const AUTOSTART_NAME: &str = "ClaudeCodeUsageBubble"; #[cfg(windows)] const WEBVIEW2_MISSING_TITLE: &str = "Claude Code Usage Bubble"; @@ -202,19 +202,20 @@ impl Runtime { /// Bring the tray icons in line with state and rebuild their menus when /// the menu tree changed, or always with `force_menu` (after a menu /// click, whose native check mark may now be wrong). Runs on the main - /// thread; the state is snapshotted first. + /// thread and snapshots the state there, so a refresh queued by a worker + /// can never apply a view older than one a menu click applied inline. pub fn refresh_shell(&self, force_menu: bool) { - let (views, menu_snapshot) = self.with_state(|s| { - ( - tray::views(&enabled_live_providers(&s.settings), &s.usage, &s.strings), - MenuSnapshot::of(s), - ) - }); let app = self.ui.app.clone(); let dispatched = self.ui.app.run_on_main_thread(move || { let Some(runtime) = app.try_state::>() else { return; }; + let (views, menu_snapshot) = runtime.with_state(|s| { + ( + tray::views(&enabled_live_providers(&s.settings), &s.usage, &s.strings), + MenuSnapshot::of(s), + ) + }); let entries = menu_snapshot.entries(menu::autostart_enabled(&app)); runtime.trays.sync(&app, &views, &entries); if runtime.menu_cache.update(&entries, force_menu) { @@ -394,9 +395,7 @@ impl Runtime { } let enabled = self.update_settings(|s| s.toggle_live_provider(provider)); if enabled { - if let Err(err) = create_bubble_window(&self.ui.app, provider) { - log::error!("bubble for {} could not be created: {err}", provider.slug()); - } + spawn_bubble_window(&self.ui.app, provider); } else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) { if let Err(err) = window.destroy() { log::warn!( @@ -611,14 +610,20 @@ pub fn run() { std::env::set_var("GDK_BACKEND", "x11"); } - let autostart = tauri_plugin_autostart::Builder::new().app_name(AUTOSTART_NAME); - #[cfg(target_os = "macos")] - let autostart = autostart.macos_launcher(tauri_plugin_autostart::MacosLauncher::LaunchAgent); - - let app = tauri::Builder::default() + let builder = tauri::Builder::default() // First, so a second instance exits before anything else starts. - .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})) - .plugin(autostart.build()) + .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})); + // Windows writes its own current-user entry (see `menu::autostart_enabled`). + #[cfg(not(windows))] + let builder = { + let autostart = tauri_plugin_autostart::Builder::new().app_name(AUTOSTART_NAME); + #[cfg(target_os = "macos")] + let autostart = + autostart.macos_launcher(tauri_plugin_autostart::MacosLauncher::LaunchAgent); + builder.plugin(autostart.build()) + }; + + let app = builder .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ crate::commands::get_bubble_state, @@ -674,26 +679,16 @@ fn on_window_event(window: &Window, event: &WindowEvent) { let Some(provider) = ProviderId::from_slug(slug) else { return; }; - let app = window.app_handle().clone(); - let dispatched = window.run_on_main_thread(move || { - let Some(runtime) = app.try_state::>() else { - return; - }; - if runtime.exiting.load(Ordering::Acquire) { - return; - } - let enabled = runtime.with_state(|s| s.settings.is_provider_enabled(provider)); - if enabled && app.get_webview_window(&bubble_label(provider)).is_none() { - if let Err(err) = create_bubble_window(&app, provider) { - log::error!( - "bubble for {slug} could not be recreated: {err}", - slug = provider.slug() - ); - } - } - }); - if let Err(err) = dispatched { - log::warn!("bubble recreate check could not be queued: {err}"); + let Some(runtime) = window.try_state::>() else { + return; + }; + if runtime.exiting.load(Ordering::Acquire) { + return; + } + // The old window is already out of the manager here, so the + // existence check in `create_bubble_window` sees it gone. + if runtime.with_state(|s| s.settings.is_provider_enabled(provider)) { + spawn_bubble_window(window.app_handle(), provider); } } _ => {} @@ -811,6 +806,31 @@ fn monitor_rects(app: &AppHandle) -> Vec { /// standing in for the real bubble window. Does nothing while a window /// with the label still exists; a destroyed one is recreated from its /// `Destroyed` event. +/// Create a bubble from a short-lived thread. Building a webview window +/// inside a menu or window event handler deadlocks on Windows (WebView2), +/// while off the main thread the build is posted to the event loop. The +/// lock makes a toggle and a `Destroyed` recreate for the same provider +/// check and build one at a time, so only one of them creates the window. +/// The main thread never takes it. +fn spawn_bubble_window(app: &AppHandle, provider: ProviderId) { + static CREATE: Mutex<()> = Mutex::new(()); + let app = app.clone(); + let spawned = std::thread::Builder::new() + .name("bubble-create".into()) + .spawn(move || { + let _serial = CREATE.lock().unwrap_or_else(PoisonError::into_inner); + if let Err(err) = create_bubble_window(&app, provider) { + log::error!("bubble for {} could not be created: {err}", provider.slug()); + } + }); + if let Err(err) = spawned { + log::error!( + "bubble for {} not created, no thread: {err}", + provider.slug() + ); + } +} + fn create_bubble_window(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> { let label = bubble_label(provider); if app.get_webview_window(&label).is_some() {