mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 08:16:18 +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
Generated
+1
@@ -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
@@ -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
@@ -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>> {
|
||||
|
||||
@@ -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,
|
||||
"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""#
|
||||
);
|
||||
}
|
||||
}
|
||||
+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