Files
claude-code-usage-bubble/src/runtime.rs
T
tiennm99 747de03eb2 fix: send update checks and downloads through a SOCKS environment proxy
The updater left an environment proxy to the plugin's reqwest, which was
built without SOCKS support, so a SOCKS ALL_PROXY broke updates and, on
Windows, displaced a working system proxy. Enable reqwest's socks feature
and pass the chosen environment or system proxy to the plugin explicitly.
2026-10-21 19:22:02 +07:00

1151 lines
42 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 serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, RunEvent};
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, PanelPayload};
use crate::core::settings::{self, Settings};
use crate::core::state::{Alert, PollOutcome, UpdateStatus, UsageState};
use crate::core::visibility::{self, Visibility};
use crate::menu::{self, MenuCache, MenuSnapshot};
use crate::scheduler::{self, Action, Message, SchedulerHost};
use crate::tray::{self, Trays};
use crate::update::{self, CheckOutcome};
use crate::usage::{self, refresh, ProviderId, Registry};
use crate::{bubble, diag, fullscreen, handoff, net, panel, platform};
/// Event carrying a [`BubblePayload`] to a bubble webview.
pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update";
/// Event telling every webview the system theme changed.
pub const THEME_EVENT: &str = "theme";
/// 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.
pub 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,
/// Why the bubbles are hidden, if they are.
pub visibility: Visibility,
pub usage: UsageState,
pub update_status: UpdateStatus,
/// How this copy was installed, which decides whether an available
/// update can be applied.
pub update_channel: update::Channel,
/// 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}");
}
}
}
/// Payload of [`THEME_EVENT`].
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ThemePayload {
is_dark: bool,
}
/// 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,
/// The release the last check found, applied by the version menu item.
/// Never locked together with `state`.
pending_update: Mutex<Option<tauri_plugin_updater::Update>>,
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())
}
/// The details-panel payload for `provider`.
pub fn panel_payload(&self, provider: ProviderId) -> PanelPayload {
self.with_state(|s| panel_payload_of(s, provider))
}
/// Push the current state to every UI surface, then re-arm the
/// countdown for the next visible change.
pub fn propagate(&self) {
let panel_provider = panel::shown_provider();
let (snapshot, panel_payload, countdown) = self.with_state(|s| {
(
BubbleSnapshot::of(s),
panel_provider.map(|p| panel_payload_of(s, p)),
s.usage.countdown_delay(SystemTime::now()),
)
});
self.ui.emit_bubble_updates(snapshot);
if let Some(payload) = panel_payload {
panel::refresh_if_visible(&self.ui.app, payload);
}
self.refresh_shell(false);
self.send(Message::RecomputeCountdown(countdown));
}
/// Send every bubble its payload, sized for its current window.
pub fn push_bubbles(&self) {
let snapshot = self.with_state(|s| BubbleSnapshot::of(s));
self.ui.emit_bubble_updates(snapshot);
}
/// Record the system theme. A change reaches every open webview at
/// once and is followed by fresh payloads, whose fills depend on it.
pub fn set_dark(&self, is_dark: bool) {
let changed = self.with_state(|s| std::mem::replace(&mut s.is_dark, is_dark) != is_dark);
if !changed {
return;
}
log::info!("theme is now {}", if is_dark { "dark" } else { "light" });
if let Err(err) = self.ui.app.emit(THEME_EVENT, ThemePayload { is_dark }) {
log::warn!("emit {THEME_EVENT} failed: {err}");
}
self.propagate();
}
/// 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 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 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) {
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.version_action(),
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 {
bubble::spawn_create(&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!(
"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 event = if self.with_state(|s| s.visibility.user_hidden) {
visibility::Event::UserShow
} else {
visibility::Event::UserHide
};
self.apply_visibility(event);
}
/// Apply a visibility event to every bubble. A user show or hide is
/// also saved as `widget_visible`.
pub fn apply_visibility(&self, event: visibility::Event) {
let (action, saved) = self.with_state(|s| {
let (next, action) = visibility::next(s.visibility, event);
s.visibility = next;
let user_event = matches!(
event,
visibility::Event::UserShow | visibility::Event::UserHide
);
let saved = user_event.then(|| {
s.settings.widget_visible = !next.user_hidden;
s.settings.clone()
});
(action, saved)
});
if let Some(snapshot) = saved {
if let Err(err) = settings::save(&snapshot) {
log::warn!("settings save failed: {err}");
}
}
if let Some(action) = action {
log::debug!("bubbles {action:?} after {event:?}");
bubble::apply(&self.ui.app, action);
}
}
/// Forget the saved positions, move the bubbles to their default spots,
/// push the cached state and poll.
fn reset_positions(self: &Arc<Self>) {
self.update_settings(|s| s.bubble_positions.reset_all());
bubble::move_all_to_defaults(&self.ui.app);
self.propagate();
self.spawn_poll();
}
/// Save where the user dropped a bubble.
pub fn save_bubble_position(&self, provider: ProviderId, pos: (i32, i32)) {
self.update_settings(|s| s.bubble_positions.set(provider, pos));
}
/// Grow or shrink the bubbles by `delta` logical px.
pub 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, then resize every bubble around its
/// centre (no snap, no position save); a no-op when it does not change.
pub 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);
bubble::resize_all(&self.ui.app, size);
self.propagate();
}
/// A left click on a bubble toggles the details panel for it.
pub fn bubble_clicked(&self, provider: ProviderId) {
log::debug!("bubble click on {}", provider.slug());
panel::toggle(&self.ui.app, provider);
}
/// 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 version menu item: apply the update the last check found, or
/// check again.
fn version_action(self: &Arc<Self>) {
let (status, can_apply) =
self.with_state(|s| (s.update_status, s.update_channel.can_apply()));
if status == UpdateStatus::Available && can_apply {
self.apply_update();
} else {
self.check_for_updates();
}
}
/// Run an update check on a worker thread, automatic or from the menu.
/// The scheduler re-arms the automatic check once it reports back.
fn check_for_updates(self: &Arc<Self>) {
let (previous, interval_secs) = self.with_state(|s| {
let previous = s.update_status;
if !previous.is_busy() {
s.update_status = UpdateStatus::Checking;
}
(previous, s.settings.update_check_interval_secs)
});
match previous {
UpdateStatus::Checking => {
// The running check re-arms the schedule when it finishes.
log::debug!("update check already running");
return;
}
UpdateStatus::Applying => {
// The apply may fail and keep this process running, so the
// automatic check must stay armed.
log::debug!("update check skipped while an update is applied");
self.send(Message::UpdateCheckDone { interval_secs });
return;
}
_ => {}
}
self.refresh_shell(false);
let runtime = Arc::clone(self);
let spawned = std::thread::Builder::new()
.name("update-check".into())
.spawn(move || runtime.run_update_check());
if let Err(err) = spawned {
log::error!("update check thread could not start: {err}");
self.with_state(|s| s.update_status = UpdateStatus::Failed);
self.send(Message::UpdateCheckDone { interval_secs });
self.refresh_shell(false);
}
}
/// Blocking body of [`Self::check_for_updates`].
fn run_update_check(&self) {
let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
let outcome = update::check(&self.ui.app, proxy);
let now_unix = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let (status, found) = match outcome {
Ok(CheckOutcome::UpToDate) => {
log::info!("update check: up to date");
(UpdateStatus::UpToDate, None)
}
Ok(CheckOutcome::Available(found)) => {
log::info!("update check: {} available", found.version);
(UpdateStatus::Available, Some(found))
}
Err(err) => {
log::warn!("update check failed: {err}");
(UpdateStatus::Failed, None)
}
};
// Stored before the status, so "Update available" always has a
// release behind it.
*self
.pending_update
.lock()
.unwrap_or_else(PoisonError::into_inner) = found;
let (snapshot, interval_secs) = self.with_state(|s| {
s.update_status = status;
s.settings.last_update_check_unix = Some(now_unix);
(s.settings.clone(), s.settings.update_check_interval_secs)
});
if let Err(err) = settings::save(&snapshot) {
log::warn!("settings save failed: {err}");
}
self.send(Message::UpdateCheckDone { interval_secs });
self.refresh_shell(false);
}
/// Download and install the release the last check found on a worker
/// thread, then relaunch into it.
fn apply_update(self: &Arc<Self>) {
// Claim the apply in one step, so a check that started since the
// click cannot run alongside it.
let channel = self.with_state(|s| {
(s.update_status == UpdateStatus::Available).then(|| {
s.update_status = UpdateStatus::Applying;
s.update_channel
})
});
let Some(channel) = channel else {
log::debug!("update apply skipped, no update is available any more");
return;
};
let found = self
.pending_update
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
let Some(found) = found else {
// Nothing to apply after all; a fresh check sorts the state out.
self.with_state(|s| s.update_status = UpdateStatus::Idle);
self.check_for_updates();
return;
};
self.refresh_shell(false);
let runtime = Arc::clone(self);
let spawned = std::thread::Builder::new()
.name("update-apply".into())
.spawn(move || runtime.run_update_apply(found, channel));
if let Err(err) = spawned {
log::error!("update apply thread could not start: {err}");
self.update_failed(&update::Error::Io(err));
}
}
/// Blocking body of [`Self::apply_update`].
fn run_update_apply(&self, found: tauri_plugin_updater::Update, channel: update::Channel) {
let installed = match update::apply(found, channel) {
Ok(version) => version,
Err(err) => {
self.update_failed(&err);
return;
}
};
log::info!("update {installed} installed; relaunching");
let snapshot = self.with_state(|s| s.settings.clone());
if let Err(err) = settings::save(&snapshot) {
log::warn!("settings save before the update relaunch failed: {err}");
}
let args = [
OsString::from("--updated-to"),
OsString::from(installed.to_string()),
];
if let Err(err) = self.relaunch_and_exit(&args) {
// The new binary is in place but not running; the next restart
// or login starts it.
self.update_failed(&update::Error::Relaunch(err));
}
}
/// Record a failed apply: Failed status plus `update-error.log`.
fn update_failed(&self, err: &update::Error) {
log::error!("update apply failed: {err}");
update::install::write_update_error(err);
self.with_state(|s| s.update_status = UpdateStatus::Failed);
self.refresh_shell(false);
}
/// 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}");
}
}
/// Whether the process is shutting down.
pub fn is_exiting(&self) -> bool {
self.exiting.load(Ordering::Acquire)
}
/// 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
}
}
}
/// The details-panel payload for `provider` from the state.
fn panel_payload_of(state: &AppState, provider: ProviderId) -> PanelPayload {
payload::panel_payload(
provider,
state.usage.snapshots.get(&provider),
&state.strings,
state.is_dark,
)
}
/// 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 DPI for a window or monitor scale factor; 96 for a bad factor.
pub fn dpi_for_scale(scale_factor: f64) -> u32 {
if scale_factor.is_finite() && scale_factor > 0.0 {
(scale_factor * f64::from(geom::BASE_DPI)).round() as u32
} else {
geom::BASE_DPI
}
}
/// The bubble canvas in physical px for a window scale factor.
pub fn bubble_canvas(size_logical: i32, scale_factor: f64) -> Canvas {
let dpi = dpi_for_scale(scale_factor);
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 builder = tauri::Builder::default()
// First, so a second instance exits before anything else starts.
.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())
// Driven from Rust only; no capability exposes it to the webviews.
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
crate::commands::get_bubble_state,
crate::commands::get_panel_state,
crate::commands::get_strings,
crate::commands::log_js,
crate::commands::bubble_ready,
crate::commands::bubble_click,
crate::commands::bubble_drag_start,
crate::commands::bubble_resize_step,
crate::menu::show_context_menu,
])
.on_window_event(|window, event| {
bubble::on_window_event(window, event);
panel::on_window_event(window, event);
})
.on_menu_event(menu::on_menu_event)
.on_tray_icon_event(tray::on_tray_icon_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);
}
}
_ => {}
});
}
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,
visibility: Visibility::from_widget_visible(settings.widget_visible),
usage: UsageState::default(),
update_status: UpdateStatus::Idle,
update_channel: update::current_channel(),
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),
pending_update: Mutex::new(None),
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);
// Without the thread the bubbles just stay up over fullscreen apps.
if let Err(err) = fullscreen::spawn(Arc::clone(&runtime)) {
log::error!("fullscreen check thread could not start: {err}");
}
for provider in providers {
bubble::create(app.handle(), provider)?;
}
// The app still works without the panel; a bubble click then only logs.
if let Err(err) = panel::create(app.handle(), is_dark) {
log::error!("details panel could not be created: {err}");
}
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.
pub 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()
}
}
}
#[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);
assert_eq!(dpi_for_scale(1.75), 168);
assert_eq!(dpi_for_scale(-1.0), 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,
visibility: Visibility::default(),
usage: UsageState::default(),
update_status: UpdateStatus::Idle,
update_channel: update::Channel::Portable,
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, "…");
}
}