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.
This commit is contained in:
tiennm99 committed 2026-10-21 14:05:06 +07:00
1 parent 17d77bde82
commit 974d9c1afd
5 files changed
+147 -53

No files matched your search

Generated
+1
View File
@@ -508,6 +508,7 @@ dependencies = [
name = "claude-code-usage-bubble"
version = "0.5.0"
dependencies = [
"auto-launch",
"dirs 6.0.0",
"flate2",
"libc",
+6 -1
View File
@@ -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"
+49 -14
View File
@@ -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::<AutoLaunchManager>().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::<AutoLaunchManager>().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<bool, String> {
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<bool, String> {
if app.try_state::<AutoLaunchManager>().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::<AutoLaunchManager>().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<Menu<Wry>> {
+33
View File
@@ -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\<name>`. 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<auto_launch::AutoLaunch> {
let exe = std::env::current_exe()?;
Ok(auto_launch::AutoLaunch::new(
name,
&quoted_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""#
);
}
}
+58 -38
View File
@@ -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::<Arc<Runtime>>() 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::<Arc<Runtime>>() 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::<Arc<Runtime>>() 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<Rect> {
/// 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() {