mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-25 02:14:44 +00:00
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:
1 parent
17d77bde82
commit
974d9c1afd
5 files changed
+147
-53
No files matched your search
+58
-38
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user