mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 02:16:34 +00:00
Each enabled live provider gets a tray icon with the usage badge, a four-line tooltip and the context menu; a left click toggles the widget. Menus are rebuilt from state after every click so native check marks stay right, and a bubble right-click pops the same menu. Alerts go through the notification plugin. On Windows the app id is registered under HKCU with a name and icon first, so a portable exe without a Start-menu shortcut can show toasts. Start at login uses the autostart plugin under the Run value name earlier releases wrote, so an existing entry is picked up as is. On Linux the tray is skipped when no AppIndicator library loads instead of aborting.
924 lines
33 KiB
Rust
924 lines
33 KiB
Rust
//! App runtime: start-up, the shared state, the poll worker and the bridge
|
|
//! that pushes state to the webviews.
|
|
//!
|
|
//! Locking rule: no Tauri window, tray, menu or emit API is called while the
|
|
//! [`AppState`] mutex is held. Code reads state through
|
|
//! [`Runtime::with_state`], which returns owned data, and calls Tauri after
|
|
//! the guard is gone. Tauri calls from a worker block on the main thread, so
|
|
//! holding the lock across one can deadlock against a main-thread handler.
|
|
|
|
use std::error::Error;
|
|
use std::ffi::OsString;
|
|
use std::io;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::{self, Sender};
|
|
use std::sync::{Arc, Mutex, PoisonError};
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
|
|
use tauri::{
|
|
AppHandle, Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, Window, WindowEvent,
|
|
};
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
use crate::cli::{self, CliArgs};
|
|
use crate::core::geom::{self, Rect, DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE};
|
|
use crate::core::i18n::{I18n, LocaleStrings};
|
|
use crate::core::menu_model::MenuCommand;
|
|
use crate::core::payload::{self, BubblePayload, Canvas};
|
|
use crate::core::settings::{self, Settings};
|
|
use crate::core::state::{Alert, PollOutcome, UpdateStatus, UsageState};
|
|
use crate::menu::{self, MenuCache, MenuSnapshot};
|
|
use crate::scheduler::{self, Action, Message, SchedulerHost};
|
|
use crate::tray::{self, Trays};
|
|
use crate::usage::{self, refresh, ProviderId, Registry};
|
|
use crate::{diag, handoff, net, platform};
|
|
|
|
/// Event carrying a [`BubblePayload`] to a bubble webview.
|
|
pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update";
|
|
|
|
/// Label prefix of every bubble window.
|
|
pub const BUBBLE_LABEL_PREFIX: &str = "bubble-";
|
|
|
|
/// How long a relaunched instance waits for its parent to exit. The
|
|
/// single-instance plugin cannot retry, so this must outlast a slow
|
|
/// WebView2 teardown.
|
|
const WAIT_PID_TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Per-provider budget for the CLI credential refresh.
|
|
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";
|
|
|
|
#[cfg(windows)]
|
|
const WEBVIEW2_MISSING_TITLE: &str = "Claude Code Usage Bubble";
|
|
#[cfg(windows)]
|
|
const WEBVIEW2_MISSING_TEXT: &str = "Claude Code Usage Bubble needs the Microsoft Edge \
|
|
WebView2 Runtime.\n\nDownload the Evergreen runtime from:\n\
|
|
https://developer.microsoft.com/microsoft-edge/webview2/";
|
|
|
|
/// Everything the app knows, behind one mutex in [`Runtime`].
|
|
#[derive(Debug)]
|
|
pub struct AppState {
|
|
pub settings: Settings,
|
|
pub strings: LocaleStrings,
|
|
pub is_dark: bool,
|
|
pub usage: UsageState,
|
|
pub update_status: UpdateStatus,
|
|
/// The "update applied" alert from `--updated-to`, shown once the
|
|
/// notification backend is up.
|
|
pub pending_update_alert: Option<Alert>,
|
|
}
|
|
|
|
/// Holds the `AppHandle`; every push to a webview goes through it.
|
|
pub struct UiBridge {
|
|
app: AppHandle,
|
|
}
|
|
|
|
impl UiBridge {
|
|
pub fn app(&self) -> &AppHandle {
|
|
&self.app
|
|
}
|
|
|
|
/// Send each bubble its payload. The window lookups and emits run on the
|
|
/// main thread; `snapshot` is owned, so no state lock is involved.
|
|
fn emit_bubble_updates(&self, snapshot: BubbleSnapshot) {
|
|
let app = self.app.clone();
|
|
let dispatched = self.app.run_on_main_thread(move || {
|
|
let now = SystemTime::now();
|
|
for provider in &snapshot.providers {
|
|
let label = bubble_label(*provider);
|
|
let Some(window) = app.get_webview_window(&label) else {
|
|
continue;
|
|
};
|
|
let scale = match window.scale_factor() {
|
|
Ok(scale) => scale,
|
|
Err(err) => {
|
|
log::warn!("scale factor of {label} unavailable: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
let payload = snapshot.payload(*provider, scale, now);
|
|
if let Err(err) = app.emit_to(label.as_str(), BUBBLE_UPDATE_EVENT, payload) {
|
|
log::warn!("emit {BUBBLE_UPDATE_EVENT} to {label} failed: {err}");
|
|
}
|
|
}
|
|
});
|
|
if let Err(err) = dispatched {
|
|
log::warn!("bubble update could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An owned copy of what the bubble payloads need.
|
|
struct BubbleSnapshot {
|
|
providers: Vec<ProviderId>,
|
|
usage: UsageState,
|
|
strings: LocaleStrings,
|
|
is_dark: bool,
|
|
size_logical: i32,
|
|
}
|
|
|
|
impl BubbleSnapshot {
|
|
fn of(state: &AppState) -> Self {
|
|
Self {
|
|
providers: enabled_live_providers(&state.settings),
|
|
usage: state.usage.clone(),
|
|
strings: state.strings.clone(),
|
|
is_dark: state.is_dark,
|
|
size_logical: state.settings.bubble_size_logical,
|
|
}
|
|
}
|
|
|
|
fn payload(&self, provider: ProviderId, scale_factor: f64, now: SystemTime) -> BubblePayload {
|
|
payload::bubble_payload(
|
|
provider,
|
|
self.usage.snapshots.get(&provider),
|
|
&self.strings,
|
|
self.is_dark,
|
|
bubble_canvas(self.size_logical, scale_factor),
|
|
now,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Shared runtime, managed by Tauri as `Arc<Runtime>`.
|
|
pub struct Runtime {
|
|
ui: UiBridge,
|
|
state: Mutex<AppState>,
|
|
http: net::Client,
|
|
registry: Mutex<Registry>,
|
|
/// At most one poll runs at a time; a Refresh spam or a timer firing
|
|
/// during a slow poll is dropped instead of stacking requests.
|
|
poll_in_flight: AtomicBool,
|
|
scheduler: Sender<Message>,
|
|
trays: Trays,
|
|
menu_cache: MenuCache,
|
|
/// Set once the process is ending, so destroyed bubbles are not
|
|
/// recreated during shutdown.
|
|
exiting: AtomicBool,
|
|
}
|
|
|
|
impl Runtime {
|
|
/// Run `f` on the state and return what it returns. Return owned data
|
|
/// and call Tauri only after this returns (see the module docs).
|
|
pub fn with_state<T>(&self, f: impl FnOnce(&mut AppState) -> T) -> T {
|
|
let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
|
f(&mut guard)
|
|
}
|
|
|
|
pub fn ui(&self) -> &UiBridge {
|
|
&self.ui
|
|
}
|
|
|
|
/// Hand a message to the scheduler thread.
|
|
pub fn send(&self, message: Message) {
|
|
if self.scheduler.send(message).is_err() {
|
|
log::warn!("scheduler is not running; message dropped");
|
|
}
|
|
}
|
|
|
|
/// The payload for one bubble at the given window scale factor.
|
|
pub fn bubble_payload(&self, provider: ProviderId, scale_factor: f64) -> BubblePayload {
|
|
let snapshot = self.with_state(|s| BubbleSnapshot::of(s));
|
|
snapshot.payload(provider, scale_factor, SystemTime::now())
|
|
}
|
|
|
|
/// Push the current state to every UI surface, then re-arm the
|
|
/// countdown for the next visible change.
|
|
pub fn propagate(&self) {
|
|
let (snapshot, countdown) = self.with_state(|s| {
|
|
(
|
|
BubbleSnapshot::of(s),
|
|
s.usage.countdown_delay(SystemTime::now()),
|
|
)
|
|
});
|
|
self.ui.emit_bubble_updates(snapshot);
|
|
self.refresh_shell(false);
|
|
self.send(Message::RecomputeCountdown(countdown));
|
|
}
|
|
|
|
/// 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.
|
|
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 entries = menu_snapshot.entries(menu::autostart_enabled(&app));
|
|
runtime.trays.sync(&app, &views, &entries);
|
|
if runtime.menu_cache.update(&entries, force_menu) {
|
|
runtime.trays.set_menus(&app, &entries);
|
|
}
|
|
});
|
|
if let Err(err) = dispatched {
|
|
log::warn!("tray refresh could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
|
|
/// Start a poll on a worker thread unless one is already running.
|
|
pub fn spawn_poll(self: &Arc<Self>) {
|
|
if self
|
|
.poll_in_flight
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.is_err()
|
|
{
|
|
log::debug!("poll already in flight; skipped");
|
|
return;
|
|
}
|
|
let runtime = Arc::clone(self);
|
|
let spawned = std::thread::Builder::new()
|
|
.name("poll".into())
|
|
.spawn(move || {
|
|
runtime.poll_once();
|
|
runtime.poll_in_flight.store(false, Ordering::Release);
|
|
runtime.propagate();
|
|
});
|
|
if let Err(err) = spawned {
|
|
log::error!("poll thread could not start: {err}");
|
|
self.poll_in_flight.store(false, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
/// Poll every enabled provider, apply the results, then run the CLI
|
|
/// credential refresh for the providers whose credentials were rejected.
|
|
/// Blocking; runs on the poll worker.
|
|
fn poll_once(&self) {
|
|
let settings = self.with_state(|s| s.settings.clone());
|
|
let results = self
|
|
.registry
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.poll_enabled(&self.http, &settings);
|
|
let outcomes = results
|
|
.into_iter()
|
|
.map(|(provider, result)| (provider, poll_outcome(provider, result)))
|
|
.collect();
|
|
let applied =
|
|
self.with_state(|s| s.usage.apply_results(outcomes, &s.strings, Instant::now()));
|
|
self.deliver_alerts(applied.alerts);
|
|
if !applied.auth_failures.is_empty() {
|
|
self.refresh_credentials(&applied.auth_failures);
|
|
}
|
|
}
|
|
|
|
/// Run the CLI refresh for each provider in order. The session-expired
|
|
/// alert names the first provider that did not refresh; the next poll
|
|
/// picks the new credentials up, there is no immediate re-poll.
|
|
fn refresh_credentials(&self, failures: &[ProviderId]) {
|
|
let orchestrator = refresh::Orchestrator::new(REFRESH_TIMEOUT);
|
|
let mut expired: Option<ProviderId> = None;
|
|
for &provider in failures {
|
|
let outcome = self
|
|
.registry
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.try_refresh(provider, &orchestrator);
|
|
log::info!("credential refresh for {}: {outcome:?}", provider.slug());
|
|
if outcome != refresh::Outcome::Refreshed {
|
|
expired.get_or_insert(provider);
|
|
}
|
|
}
|
|
if let Some(provider) = expired {
|
|
let alert =
|
|
self.with_state(|s| s.usage.session_expired_alert(provider, Instant::now()));
|
|
self.deliver_alerts(alert.into_iter().collect());
|
|
}
|
|
}
|
|
|
|
/// Show alerts as desktop notifications. The cooldown was applied when
|
|
/// they were raised; nothing here filters them.
|
|
fn deliver_alerts(&self, alerts: Vec<Alert>) {
|
|
if alerts.is_empty() {
|
|
return;
|
|
}
|
|
let strings = self.with_state(|s| s.strings.clone());
|
|
for alert in alerts {
|
|
let (title, body) = alert.text(&strings);
|
|
log::info!("alert: {title}: {body}");
|
|
let shown = self
|
|
.ui
|
|
.app
|
|
.notification()
|
|
.builder()
|
|
.title(&title)
|
|
.body(&body)
|
|
.show();
|
|
if let Err(err) = shown {
|
|
log::warn!("notification {title:?} could not be shown: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show the "update applied" alert from `--updated-to` once, after the
|
|
/// tray work queued before it. It ignores the alert cooldown.
|
|
fn deliver_pending_update_alert(self: &Arc<Self>) {
|
|
let Some(alert) = self.with_state(|s| s.pending_update_alert.take()) else {
|
|
return;
|
|
};
|
|
let runtime = Arc::clone(self);
|
|
let dispatched = self
|
|
.ui
|
|
.app
|
|
.run_on_main_thread(move || runtime.deliver_alerts(vec![alert]));
|
|
if let Err(err) = dispatched {
|
|
log::warn!("update alert could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
|
|
/// Run one menu command. The caller rebuilds the menus afterwards.
|
|
pub fn on_menu_command(self: &Arc<Self>, command: MenuCommand) {
|
|
log::debug!("menu command {}", command.id());
|
|
match command {
|
|
MenuCommand::Refresh => self.spawn_poll(),
|
|
MenuCommand::PollInterval(ms) => self.set_poll_interval(ms),
|
|
MenuCommand::ToggleProvider(provider) => self.toggle_provider(provider),
|
|
MenuCommand::Autostart => menu::toggle_autostart(&self.ui.app),
|
|
MenuCommand::ResetPosition => self.reset_positions(),
|
|
MenuCommand::VersionAction => self.check_for_updates(),
|
|
MenuCommand::UpdateInterval(secs) => self.set_update_interval(secs),
|
|
MenuCommand::SizeSmaller => self.resize_bubbles(-geom::RESIZE_STEP_LOGICAL),
|
|
MenuCommand::SizeLarger => self.resize_bubbles(geom::RESIZE_STEP_LOGICAL),
|
|
MenuCommand::SizeReset => self.set_bubble_size(DEFAULT_BUBBLE_SIZE),
|
|
MenuCommand::ToggleWidget => self.toggle_widget(),
|
|
MenuCommand::Restart => self.restart(),
|
|
MenuCommand::Exit => self.exit(),
|
|
}
|
|
}
|
|
|
|
/// Change a setting under the lock, then save a copy outside it.
|
|
fn update_settings<T>(&self, f: impl FnOnce(&mut Settings) -> T) -> T {
|
|
let (result, snapshot) = self.with_state(|s| {
|
|
let result = f(&mut s.settings);
|
|
(result, s.settings.clone())
|
|
});
|
|
if let Err(err) = settings::save(&snapshot) {
|
|
log::warn!("settings save failed: {err}");
|
|
}
|
|
result
|
|
}
|
|
|
|
fn set_poll_interval(&self, ms: u32) {
|
|
self.update_settings(|s| s.poll_interval_ms = ms);
|
|
self.send(Message::SetPollInterval(Duration::from_millis(u64::from(
|
|
ms,
|
|
))));
|
|
}
|
|
|
|
fn set_update_interval(&self, interval_secs: Option<u64>) {
|
|
let last_check_unix = self.update_settings(|s| {
|
|
s.update_check_interval_secs = interval_secs;
|
|
s.last_update_check_unix
|
|
});
|
|
self.send(Message::SetUpdateInterval {
|
|
interval_secs,
|
|
last_check_unix,
|
|
});
|
|
}
|
|
|
|
/// Toggle a live provider, keeping at least one enabled, then create or
|
|
/// destroy its bubble, update the tray and poll.
|
|
fn toggle_provider(self: &Arc<Self>, provider: ProviderId) {
|
|
if !provider.metadata().live_usage {
|
|
return;
|
|
}
|
|
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());
|
|
}
|
|
} else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) {
|
|
if let Err(err) = window.destroy() {
|
|
log::warn!(
|
|
"bubble for {} could not be destroyed: {err}",
|
|
provider.slug()
|
|
);
|
|
}
|
|
}
|
|
self.propagate();
|
|
self.spawn_poll();
|
|
}
|
|
|
|
/// Show or hide every bubble and remember the choice.
|
|
pub fn toggle_widget(&self) {
|
|
let (visible, providers) = self.update_settings(|s| {
|
|
s.widget_visible = !s.widget_visible;
|
|
(s.widget_visible, enabled_live_providers(s))
|
|
});
|
|
for provider in providers {
|
|
let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) else {
|
|
continue;
|
|
};
|
|
let result = if visible {
|
|
window.show()
|
|
} else {
|
|
window.hide()
|
|
};
|
|
if let Err(err) = result {
|
|
log::warn!("bubble for {} not toggled: {err}", provider.slug());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Forget the saved positions, push the cached state and poll.
|
|
fn reset_positions(self: &Arc<Self>) {
|
|
self.update_settings(|s| s.bubble_positions.reset_all());
|
|
self.propagate();
|
|
self.spawn_poll();
|
|
}
|
|
|
|
fn resize_bubbles(self: &Arc<Self>, delta: i32) {
|
|
let current = self.with_state(|s| s.settings.bubble_size_logical);
|
|
self.set_bubble_size(current.saturating_add(delta));
|
|
}
|
|
|
|
/// Clamp and store the bubble size; a no-op when it does not change.
|
|
fn set_bubble_size(self: &Arc<Self>, size_logical: i32) {
|
|
let size = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
|
let changed = self.with_state(|s| s.settings.bubble_size_logical != size);
|
|
if !changed {
|
|
return;
|
|
}
|
|
self.update_settings(|s| s.bubble_size_logical = size);
|
|
self.propagate();
|
|
}
|
|
|
|
/// Re-format the countdown texts after time passed and push them out.
|
|
fn refresh_countdowns(&self) {
|
|
self.with_state(|s| s.usage.refresh_texts(&s.strings));
|
|
self.propagate();
|
|
}
|
|
|
|
/// The update check, automatic or from the menu. No updater is
|
|
/// registered in this build, so the deadline stays disarmed until a
|
|
/// check reports back.
|
|
fn check_for_updates(&self) {
|
|
log::debug!("update check due; no updater is registered");
|
|
}
|
|
|
|
/// Spawn the next instance with `--wait-pid` plus `extra_args`, then
|
|
/// exit. On a failed spawn this instance keeps running.
|
|
pub fn relaunch_and_exit(&self, extra_args: &[OsString]) -> io::Result<()> {
|
|
handoff::relaunch(extra_args)?;
|
|
self.exit();
|
|
Ok(())
|
|
}
|
|
|
|
/// Save the settings and start a fresh instance.
|
|
pub fn restart(&self) {
|
|
let snapshot = self.with_state(|s| s.settings.clone());
|
|
if let Err(err) = settings::save(&snapshot) {
|
|
log::warn!("restart: settings save failed: {err}");
|
|
}
|
|
if let Err(err) = self.relaunch_and_exit(&[]) {
|
|
log::error!("restart: relaunch failed, keeping this instance: {err}");
|
|
}
|
|
}
|
|
|
|
/// End the process. The scheduler stops from the `Exit` run event.
|
|
pub fn exit(&self) {
|
|
self.exiting.store(true, Ordering::Release);
|
|
self.ui.app.exit(0);
|
|
}
|
|
}
|
|
|
|
impl SchedulerHost for Arc<Runtime> {
|
|
fn run(&self, action: Action) {
|
|
match action {
|
|
Action::Poll => self.spawn_poll(),
|
|
Action::Countdown => self.refresh_countdowns(),
|
|
Action::CheckForUpdates => self.check_for_updates(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map a provider error onto the portable outcome, logging transient ones.
|
|
fn poll_outcome(
|
|
provider: ProviderId,
|
|
result: Result<usage::UsageWindows, usage::Error>,
|
|
) -> PollOutcome {
|
|
match result {
|
|
Ok(windows) => {
|
|
log::info!(
|
|
"poll {}: session {:.1}%, weekly {:.1}%",
|
|
provider.slug(),
|
|
windows.primary.utilization,
|
|
windows.secondary.utilization
|
|
);
|
|
PollOutcome::Ok(windows)
|
|
}
|
|
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
|
|
log::info!("poll {}: credentials rejected", provider.slug());
|
|
PollOutcome::AuthFailed
|
|
}
|
|
Err(err) => {
|
|
log::warn!("poll {} failed: {err}", provider.slug());
|
|
PollOutcome::Failed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Window label of a provider's bubble.
|
|
pub fn bubble_label(provider: ProviderId) -> String {
|
|
format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug())
|
|
}
|
|
|
|
/// Live providers the user has enabled, in display order.
|
|
pub fn enabled_live_providers(settings: &Settings) -> Vec<ProviderId> {
|
|
ProviderId::LIVE_USAGE
|
|
.into_iter()
|
|
.filter(|p| settings.is_provider_enabled(*p))
|
|
.collect()
|
|
}
|
|
|
|
/// The bubble canvas in physical px for a window scale factor.
|
|
pub fn bubble_canvas(size_logical: i32, scale_factor: f64) -> Canvas {
|
|
let dpi = if scale_factor.is_finite() && scale_factor > 0.0 {
|
|
(scale_factor * f64::from(geom::BASE_DPI)).round() as u32
|
|
} else {
|
|
geom::BASE_DPI
|
|
};
|
|
let (w, h) = geom::bubble_size_px(size_logical, dpi);
|
|
Canvas {
|
|
w: u32::try_from(w).unwrap_or(1),
|
|
h: u32::try_from(h).unwrap_or(1),
|
|
dpi,
|
|
}
|
|
}
|
|
|
|
/// Whether to run GTK on X11 (Xwayland) under a Wayland session:
|
|
/// positioning, snapping and always-on-top only work there. An explicit
|
|
/// `GDK_BACKEND` wins, and without an X server (`DISPLAY` unset) GTK would
|
|
/// fail to start, so the native Wayland backend stays.
|
|
pub fn needs_x11_backend(
|
|
wayland_display: Option<OsString>,
|
|
gdk_backend: Option<OsString>,
|
|
display: Option<OsString>,
|
|
) -> bool {
|
|
let set = |v: &Option<OsString>| v.as_ref().is_some_and(|v| !v.is_empty());
|
|
set(&wayland_display) && !set(&gdk_backend) && set(&display)
|
|
}
|
|
|
|
/// Process entry: everything that must happen before the Tauri builder,
|
|
/// then the builder itself.
|
|
pub fn run() {
|
|
let cli = cli::from_env();
|
|
match diag::init(cli.diagnose) {
|
|
Ok(Some(path)) => log::info!(
|
|
"startup args={:?} log_path={}",
|
|
std::env::args_os().collect::<Vec<_>>(),
|
|
path.display()
|
|
),
|
|
Ok(None) => {}
|
|
Err(err) => eprintln!("diagnostic log could not be opened: {err}"),
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
if !platform::windows::webview2_available() {
|
|
log::error!("WebView2 runtime missing; exiting");
|
|
platform::windows::message_box_error(WEBVIEW2_MISSING_TITLE, WEBVIEW2_MISSING_TEXT);
|
|
std::process::exit(0);
|
|
}
|
|
|
|
if let Some(pid) = cli.wait_pid {
|
|
log::info!("waiting up to {WAIT_PID_TIMEOUT:?} for pid {pid} to exit");
|
|
let started = Instant::now();
|
|
if platform::wait_pid(pid, WAIT_PID_TIMEOUT) {
|
|
log::info!("pid {pid} gone after {:?}", started.elapsed());
|
|
} else {
|
|
log::warn!("pid {pid} still running after {WAIT_PID_TIMEOUT:?}; starting anyway");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
if needs_x11_backend(
|
|
std::env::var_os("WAYLAND_DISPLAY"),
|
|
std::env::var_os("GDK_BACKEND"),
|
|
std::env::var_os("DISPLAY"),
|
|
) {
|
|
log::info!("Wayland session with Xwayland: using GDK_BACKEND=x11");
|
|
// No other thread exists yet, so changing the environment is safe.
|
|
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()
|
|
// 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_notification::init())
|
|
.invoke_handler(tauri::generate_handler![
|
|
crate::commands::get_bubble_state,
|
|
crate::commands::get_strings,
|
|
crate::commands::log_js,
|
|
crate::commands::bubble_ready,
|
|
crate::menu::show_context_menu,
|
|
])
|
|
.on_window_event(on_window_event)
|
|
.on_menu_event(menu::on_menu_event)
|
|
.setup(move |app| setup(app, &cli))
|
|
.build(tauri::generate_context!());
|
|
|
|
let app = match app {
|
|
Ok(app) => app,
|
|
Err(err) => {
|
|
log::error!("tauri builder failed: {err}");
|
|
eprintln!("Claude Code Usage Bubble failed to start: {err}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
app.run(|app, event| match event {
|
|
// Destroying the last window must not end the process; only an
|
|
// explicit `exit` (which carries an exit code) does.
|
|
RunEvent::ExitRequested {
|
|
code: None, api, ..
|
|
} => api.prevent_exit(),
|
|
RunEvent::ExitRequested { .. } => {
|
|
if let Some(runtime) = app.try_state::<Arc<Runtime>>() {
|
|
runtime.exiting.store(true, Ordering::Release);
|
|
}
|
|
}
|
|
RunEvent::Exit => {
|
|
if let Some(runtime) = app.try_state::<Arc<Runtime>>() {
|
|
runtime.send(Message::Shutdown);
|
|
}
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|
|
|
|
/// Bubbles ignore close requests (Alt+F4); they are only ever destroyed.
|
|
/// A bubble destroyed while its provider is enabled again (a fast off/on
|
|
/// toggle) is recreated once the old window is gone.
|
|
fn on_window_event(window: &Window, event: &WindowEvent) {
|
|
let Some(slug) = window.label().strip_prefix(BUBBLE_LABEL_PREFIX) else {
|
|
return;
|
|
};
|
|
match event {
|
|
WindowEvent::CloseRequested { api, .. } => api.prevent_close(),
|
|
WindowEvent::Destroyed => {
|
|
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}");
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
|
let mut settings = settings::load();
|
|
settings::drop_offscreen_positions(&mut settings, &monitor_rects(app.handle()));
|
|
let is_dark = platform::system_is_dark();
|
|
let http = net::Client::new(net::USER_AGENT)?;
|
|
|
|
let state = AppState {
|
|
strings: I18n::load().strings().clone(),
|
|
is_dark,
|
|
usage: UsageState::default(),
|
|
update_status: UpdateStatus::Idle,
|
|
pending_update_alert: cli
|
|
.updated_to
|
|
.clone()
|
|
.map(|version| Alert::UpdateApplied { version }),
|
|
settings,
|
|
};
|
|
let config = scheduler::Config {
|
|
poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)),
|
|
update_interval_secs: state.settings.update_check_interval_secs,
|
|
last_update_check_unix: state.settings.last_update_check_unix,
|
|
};
|
|
let providers = enabled_live_providers(&state.settings);
|
|
|
|
let (tx, rx) = mpsc::channel();
|
|
let runtime = Arc::new(Runtime {
|
|
ui: UiBridge {
|
|
app: app.handle().clone(),
|
|
},
|
|
state: Mutex::new(state),
|
|
http,
|
|
registry: Mutex::new(Registry::with_defaults()),
|
|
poll_in_flight: AtomicBool::new(false),
|
|
scheduler: tx,
|
|
trays: Trays::default(),
|
|
menu_cache: MenuCache::default(),
|
|
exiting: AtomicBool::new(false),
|
|
});
|
|
app.manage(Arc::clone(&runtime));
|
|
|
|
match std::env::current_exe() {
|
|
Ok(exe) => handoff::cleanup_stale_files(&exe, handoff::updates_dir().as_deref()),
|
|
Err(err) => log::warn!("startup cleanup skipped, no executable path: {err}"),
|
|
}
|
|
|
|
// Toasts from a portable exe need the app id registered first.
|
|
#[cfg(windows)]
|
|
register_notification_identity(app.handle());
|
|
|
|
// The thread is detached; it stops on `Message::Shutdown` at exit.
|
|
scheduler::spawn(config, rx, Arc::clone(&runtime))?;
|
|
runtime.send(Message::PollNow);
|
|
|
|
for provider in providers {
|
|
create_bubble_window(app.handle(), provider)?;
|
|
}
|
|
runtime.refresh_shell(true);
|
|
runtime.deliver_pending_update_alert();
|
|
Ok(())
|
|
}
|
|
|
|
/// Register the app id the notification plugin shows toasts under, with
|
|
/// the app name and an icon PNG under the local data dir.
|
|
#[cfg(windows)]
|
|
fn register_notification_identity(app: &AppHandle) {
|
|
const ICON_PNG: &[u8] = include_bytes!("../icons/128x128.png");
|
|
let Some(icon_path) =
|
|
dirs::data_local_dir().map(|d| d.join(settings::APP_DIR_NAME).join("icon.png"))
|
|
else {
|
|
log::warn!("no local data dir; toasts may not show");
|
|
return;
|
|
};
|
|
let config = app.config();
|
|
let name = config
|
|
.product_name
|
|
.as_deref()
|
|
.unwrap_or("Claude Code Usage Bubble");
|
|
match platform::windows::register_aumid(&config.identifier, name, ICON_PNG, &icon_path) {
|
|
Ok(()) => log::info!("notification app id {} registered", config.identifier),
|
|
Err(err) => log::warn!("notification app id registration incomplete: {err}"),
|
|
}
|
|
}
|
|
|
|
/// Monitor rects in physical px. Empty when they cannot be enumerated,
|
|
/// which keeps every saved position.
|
|
fn monitor_rects(app: &AppHandle) -> Vec<Rect> {
|
|
match app.available_monitors() {
|
|
Ok(monitors) => monitors
|
|
.iter()
|
|
.map(|m| {
|
|
let pos = m.position();
|
|
let size = m.size();
|
|
Rect::from_xywh(
|
|
pos.x,
|
|
pos.y,
|
|
i32::try_from(size.width).unwrap_or(i32::MAX),
|
|
i32::try_from(size.height).unwrap_or(i32::MAX),
|
|
)
|
|
})
|
|
.collect(),
|
|
Err(err) => {
|
|
log::warn!("monitors could not be enumerated: {err}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A plain window for one provider that renders its payload as JSON,
|
|
/// 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.
|
|
fn create_bubble_window(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
|
|
let label = bubble_label(provider);
|
|
if app.get_webview_window(&label).is_some() {
|
|
log::debug!("{label} still exists; not creating it again");
|
|
return Ok(());
|
|
}
|
|
WebviewWindowBuilder::new(app, label, WebviewUrl::App("bubble.html".into()))
|
|
.title("Claude Code Usage Bubble")
|
|
.inner_size(460.0, 420.0)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.build()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn os(v: &str) -> Option<OsString> {
|
|
Some(OsString::from(v))
|
|
}
|
|
|
|
#[test]
|
|
fn x11_backend_only_under_wayland_with_xwayland_and_no_override() {
|
|
assert!(needs_x11_backend(os("wayland-0"), None, os(":0")));
|
|
assert!(needs_x11_backend(os("wayland-0"), os(""), os(":0")));
|
|
assert!(!needs_x11_backend(os("wayland-0"), os("wayland"), os(":0")));
|
|
assert!(!needs_x11_backend(None, None, os(":0")));
|
|
assert!(!needs_x11_backend(os(""), None, os(":0")));
|
|
// No X server: forcing x11 would stop GTK from starting.
|
|
assert!(!needs_x11_backend(os("wayland-0"), None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn canvas_follows_the_scale_factor() {
|
|
assert_eq!(
|
|
bubble_canvas(200, 1.0),
|
|
Canvas {
|
|
w: 200,
|
|
h: 66,
|
|
dpi: 96
|
|
}
|
|
);
|
|
assert_eq!(
|
|
bubble_canvas(200, 1.5),
|
|
Canvas {
|
|
w: 300,
|
|
h: 99,
|
|
dpi: 144
|
|
}
|
|
);
|
|
assert_eq!(bubble_canvas(200, 1.25).dpi, 120);
|
|
assert_eq!(bubble_canvas(200, f64::NAN).dpi, 96);
|
|
assert_eq!(bubble_canvas(200, 0.0).dpi, 96);
|
|
}
|
|
|
|
#[test]
|
|
fn enabled_providers_keep_display_order_and_skip_non_live_ones() {
|
|
let mut settings = Settings {
|
|
show_codex: true,
|
|
show_opencode_go: true,
|
|
..Settings::default()
|
|
};
|
|
assert_eq!(
|
|
enabled_live_providers(&settings),
|
|
[ProviderId::Claude, ProviderId::ChatGpt]
|
|
);
|
|
settings.show_claude_code = false;
|
|
assert_eq!(enabled_live_providers(&settings), [ProviderId::ChatGpt]);
|
|
}
|
|
|
|
#[test]
|
|
fn bubble_labels_use_the_slug() {
|
|
assert_eq!(bubble_label(ProviderId::ChatGpt), "bubble-chatgpt");
|
|
}
|
|
|
|
#[test]
|
|
fn auth_errors_become_auth_failures() {
|
|
assert!(matches!(
|
|
poll_outcome(ProviderId::Claude, Err(usage::Error::TokenExpired)),
|
|
PollOutcome::AuthFailed
|
|
));
|
|
assert!(matches!(
|
|
poll_outcome(ProviderId::Claude, Err(usage::Error::AuthRequired)),
|
|
PollOutcome::AuthFailed
|
|
));
|
|
assert!(matches!(
|
|
poll_outcome(ProviderId::ChatGpt, Err(usage::Error::NoCredentials)),
|
|
PollOutcome::Failed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_payload_uses_the_window_canvas() {
|
|
let state = AppState {
|
|
settings: Settings::default(),
|
|
strings: I18n::load().strings().clone(),
|
|
is_dark: true,
|
|
usage: UsageState::default(),
|
|
update_status: UpdateStatus::Idle,
|
|
pending_update_alert: None,
|
|
};
|
|
let snapshot = BubbleSnapshot::of(&state);
|
|
assert_eq!(snapshot.providers, [ProviderId::Claude]);
|
|
let p = snapshot.payload(ProviderId::Claude, 2.0, SystemTime::now());
|
|
assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (400, 132, 192));
|
|
assert_eq!(p.head_label, "…");
|
|
}
|
|
}
|