mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 22:16:30 +00:00
feat: add tray icons, the native context menu, notifications and start at login
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.
This commit is contained in:
1 parent
539f26019b
commit
f2063bda4f
14 files changed
+1250
-209
No files matched your search
Generated
+119
-1
@@ -521,6 +521,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-single-instance",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
@@ -2089,6 +2090,20 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2221,6 +2236,20 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2750,6 +2779,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2848,6 +2886,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3052,7 +3119,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3855,6 +3922,26 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"url",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.4"
|
||||
@@ -3971,6 +4058,17 @@ dependencies = [
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5266,6 +5364,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -30,6 +30,7 @@ thiserror = "2"
|
||||
tiny-skia = "0.11"
|
||||
sha2 = "0.10"
|
||||
self-replace = "1.5"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -419,6 +419,99 @@ mod tests {
|
||||
.all(|e| matches!(e, MenuEntry::Hint { .. })));
|
||||
}
|
||||
|
||||
/// One line per node, indented by depth: `label [id] kind`.
|
||||
fn outline(entries: &[MenuEntry], depth: usize, out: &mut Vec<String>) {
|
||||
let pad = " ".repeat(depth);
|
||||
for e in entries {
|
||||
match e {
|
||||
MenuEntry::Action {
|
||||
command,
|
||||
label,
|
||||
enabled,
|
||||
} => out.push(format!(
|
||||
"{pad}{label} [{}]{}",
|
||||
command.id(),
|
||||
if *enabled { "" } else { " grey" }
|
||||
)),
|
||||
MenuEntry::Check {
|
||||
command,
|
||||
label,
|
||||
checked,
|
||||
enabled,
|
||||
} => out.push(format!(
|
||||
"{pad}{label} [{}] {}{}",
|
||||
command.id(),
|
||||
if *checked { "on" } else { "off" },
|
||||
if *enabled { "" } else { " grey" }
|
||||
)),
|
||||
MenuEntry::Hint { label } => out.push(format!("{pad}{label} hint")),
|
||||
MenuEntry::Separator => out.push(format!("{pad}-")),
|
||||
MenuEntry::Submenu { label, entries } => {
|
||||
out.push(format!("{pad}{label} >"));
|
||||
outline(entries, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_tree_keeps_order_labels_ids_and_states() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let m = build(MenuInput {
|
||||
settings: &Settings::default(),
|
||||
strings: &strings,
|
||||
autostart_enabled: false,
|
||||
update_status: UpdateStatus::Idle,
|
||||
});
|
||||
let mut lines = Vec::new();
|
||||
outline(&m, 0, &mut lines);
|
||||
let autostart = format!(" {} [autostart] off", strings.autostart_label());
|
||||
let version = format!(
|
||||
" Check for updates · v{} [version]",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
let expected = [
|
||||
"Refresh [refresh]",
|
||||
"Update frequency >",
|
||||
" 1 minute [freq:60000] off",
|
||||
" 5 minutes [freq:300000] on",
|
||||
" 15 minutes [freq:900000] off",
|
||||
" 1 hour [freq:3600000] off",
|
||||
"Providers >",
|
||||
" Claude Code [provider:claude] on",
|
||||
" Codex [provider:chatgpt] off",
|
||||
" OpenCode Go [provider:opencode-go] off grey",
|
||||
"Settings >",
|
||||
&autostart,
|
||||
" Reset position [reset-position]",
|
||||
&version,
|
||||
" Auto-update check >",
|
||||
" Disabled [update-interval:off] off",
|
||||
" Hourly [update-interval:3600] on",
|
||||
" Daily [update-interval:86400] off",
|
||||
" Weekly [update-interval:604800] off",
|
||||
"Controls >",
|
||||
" Make smaller [size-smaller]",
|
||||
" Make larger [size-larger]",
|
||||
" Reset size [size-reset] grey",
|
||||
" -",
|
||||
];
|
||||
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
assert_eq!(got[..expected.len()], expected);
|
||||
let tail: Vec<&str> = got[expected.len()..].to_vec();
|
||||
assert_eq!(tail.len(), 9);
|
||||
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
|
||||
assert_eq!(
|
||||
tail[5..],
|
||||
[
|
||||
"Show widget [toggle-widget] on",
|
||||
"-",
|
||||
"Restart [restart]",
|
||||
"Exit [exit]"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_round_trip_and_unknown_ids_are_rejected() {
|
||||
let m = menu(&Settings::default(), UpdateStatus::Idle);
|
||||
|
||||
@@ -10,10 +10,12 @@ pub mod core;
|
||||
pub mod creds;
|
||||
pub mod diag;
|
||||
pub mod handoff;
|
||||
pub mod menu;
|
||||
pub mod net;
|
||||
pub mod platform;
|
||||
pub mod runtime;
|
||||
pub mod scheduler;
|
||||
pub mod tray;
|
||||
pub mod usage;
|
||||
|
||||
/// Starts the application and blocks until it exits.
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//! The native context menu: [`core::menu_model`](crate::core::menu_model)
|
||||
//! mapped onto `tauri::menu`, shown on every tray icon and on a bubble
|
||||
//! right-click.
|
||||
//!
|
||||
//! Native check items toggle their own mark when clicked, so the menus are
|
||||
//! rebuilt from state after every handled click, including clicks that
|
||||
//! change nothing. Every function here that touches Tauri runs without the
|
||||
//! state lock held.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use tauri::menu::{
|
||||
CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, State, Wry};
|
||||
use tauri_plugin_autostart::{AutoLaunchManager, ManagerExt};
|
||||
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::menu_model::{self, MenuCommand, MenuEntry, MenuInput};
|
||||
use crate::core::settings::Settings;
|
||||
use crate::core::state::UpdateStatus;
|
||||
use crate::runtime::{AppState, Runtime, BUBBLE_LABEL_PREFIX};
|
||||
|
||||
/// An owned copy of the state the menu reflects, taken under the lock.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MenuSnapshot {
|
||||
settings: Settings,
|
||||
strings: LocaleStrings,
|
||||
update_status: UpdateStatus,
|
||||
}
|
||||
|
||||
impl MenuSnapshot {
|
||||
pub fn of(state: &AppState) -> Self {
|
||||
Self {
|
||||
settings: state.settings.clone(),
|
||||
strings: state.strings.clone(),
|
||||
update_status: state.update_status,
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu tree for this snapshot and the autostart state.
|
||||
pub fn entries(&self, autostart_enabled: bool) -> Vec<MenuEntry> {
|
||||
menu_model::build(MenuInput {
|
||||
settings: &self.settings,
|
||||
strings: &self.strings,
|
||||
autostart_enabled,
|
||||
update_status: self.update_status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu tree last applied to the trays, so an unchanged tree is not
|
||||
/// rebuilt on every poll or countdown tick.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MenuCache {
|
||||
last: Mutex<Option<Vec<MenuEntry>>>,
|
||||
}
|
||||
|
||||
impl MenuCache {
|
||||
/// Record `entries` and report whether the trays need a new menu: when
|
||||
/// forced or when the tree differs from the last one recorded.
|
||||
pub fn update(&self, entries: &[MenuEntry], force: bool) -> bool {
|
||||
let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !force && last.as_deref() == Some(entries) {
|
||||
return false;
|
||||
}
|
||||
*last = Some(entries.to_vec());
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
log::warn!("autostart state unreadable: {err}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()
|
||||
};
|
||||
if let Err(err) = result {
|
||||
log::error!("autostart toggle failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let menu = Menu::new(app)?;
|
||||
for entry in entries {
|
||||
menu.append(item(app, entry)?.as_ref())?;
|
||||
}
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
fn item(app: &AppHandle, entry: &MenuEntry) -> tauri::Result<Box<dyn IsMenuItem<Wry>>> {
|
||||
Ok(match entry {
|
||||
MenuEntry::Action {
|
||||
command,
|
||||
label,
|
||||
enabled,
|
||||
} => Box::new(MenuItem::with_id(
|
||||
app,
|
||||
command.id(),
|
||||
label,
|
||||
*enabled,
|
||||
None::<&str>,
|
||||
)?),
|
||||
MenuEntry::Check {
|
||||
command,
|
||||
label,
|
||||
checked,
|
||||
enabled,
|
||||
} => Box::new(CheckMenuItem::with_id(
|
||||
app,
|
||||
command.id(),
|
||||
label,
|
||||
*enabled,
|
||||
*checked,
|
||||
None::<&str>,
|
||||
)?),
|
||||
MenuEntry::Hint { label } => Box::new(MenuItem::new(app, label, false, None::<&str>)?),
|
||||
MenuEntry::Separator => Box::new(PredefinedMenuItem::separator(app)?),
|
||||
MenuEntry::Submenu { label, entries } => {
|
||||
let submenu = Submenu::new(app, label, true)?;
|
||||
for child in entries {
|
||||
submenu.append(item(app, child)?.as_ref())?;
|
||||
}
|
||||
Box::new(submenu)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The one handler for every menu click, from a tray or a bubble menu.
|
||||
/// Runs on the main thread.
|
||||
pub fn on_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
let id = event.id().as_ref();
|
||||
let Some(command) = MenuCommand::parse(id) else {
|
||||
log::debug!("menu: ignoring id {id:?}");
|
||||
return;
|
||||
};
|
||||
let Some(runtime) = app.try_state::<Arc<Runtime>>() else {
|
||||
return;
|
||||
};
|
||||
let runtime = Arc::clone(&runtime);
|
||||
runtime.on_menu_command(command);
|
||||
if !matches!(command, MenuCommand::Restart | MenuCommand::Exit) {
|
||||
runtime.refresh_shell(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the context menu on a bubble at the cursor.
|
||||
#[tauri::command]
|
||||
pub async fn show_context_menu(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, Arc<Runtime>>,
|
||||
label: String,
|
||||
) -> Result<(), String> {
|
||||
if !label.starts_with(BUBBLE_LABEL_PREFIX) {
|
||||
return Err("not a bubble window".into());
|
||||
}
|
||||
let window = app
|
||||
.get_webview_window(&label)
|
||||
.ok_or_else(|| "no such bubble window".to_string())?;
|
||||
let snapshot = runtime.with_state(|s| MenuSnapshot::of(s));
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let entries = snapshot.entries(autostart_enabled(&handle));
|
||||
let menu = match build(&handle, &entries) {
|
||||
Ok(menu) => menu,
|
||||
Err(err) => {
|
||||
log::error!("context menu could not be built: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The popup only dismisses on an outside click when its owner is
|
||||
// the foreground window.
|
||||
#[cfg(windows)]
|
||||
crate::platform::windows::set_foreground(&window);
|
||||
if let Err(err) = window.popup_menu(&menu) {
|
||||
log::error!(
|
||||
"context menu could not be shown on {}: {err}",
|
||||
window.label()
|
||||
);
|
||||
}
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
|
||||
fn snapshot(settings: Settings) -> MenuSnapshot {
|
||||
MenuSnapshot {
|
||||
settings,
|
||||
strings: I18n::load().strings().clone(),
|
||||
update_status: UpdateStatus::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_skips_an_unchanged_tree_unless_forced() {
|
||||
let cache = MenuCache::default();
|
||||
let entries = snapshot(Settings::default()).entries(false);
|
||||
assert!(
|
||||
cache.update(&entries, false),
|
||||
"first tree is always applied"
|
||||
);
|
||||
assert!(!cache.update(&entries, false));
|
||||
assert!(
|
||||
cache.update(&entries, true),
|
||||
"a check-item click rebuilds even when nothing changed"
|
||||
);
|
||||
let toggled = snapshot(Settings::default()).entries(true);
|
||||
assert!(cache.update(&toggled, false));
|
||||
assert!(!cache.update(&toggled, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_entries_follow_the_autostart_state() {
|
||||
let snap = snapshot(Settings::default());
|
||||
let autostart = |entries: &[MenuEntry]| -> Option<bool> {
|
||||
fn find(entries: &[MenuEntry]) -> Option<bool> {
|
||||
entries.iter().find_map(|e| match e {
|
||||
MenuEntry::Check {
|
||||
command: MenuCommand::Autostart,
|
||||
checked,
|
||||
..
|
||||
} => Some(*checked),
|
||||
MenuEntry::Submenu { entries, .. } => find(entries),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
find(entries)
|
||||
};
|
||||
assert_eq!(autostart(&snap.entries(true)), Some(true));
|
||||
assert_eq!(autostart(&snap.entries(false)), Some(false));
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1,14 +1,12 @@
|
||||
// `os` namespace: thin, typed wrappers over the slice of Win32 we use.
|
||||
//
|
||||
// Each submodule covers one concern (color conversion, UTF-16 strings,
|
||||
// DPI math, registry I/O, theme detection). Nothing in here knows about
|
||||
// DPI math). Nothing in here knows about
|
||||
// the bubble UI or the polling loop — it's pure platform glue.
|
||||
|
||||
pub mod color;
|
||||
pub mod dpi;
|
||||
pub mod registry;
|
||||
pub mod string;
|
||||
pub mod theme;
|
||||
|
||||
pub use color::Rgb;
|
||||
pub use string::to_utf16_nul;
|
||||
@@ -1,159 +0,0 @@
|
||||
// Typed wrapper over a tiny subset of the Win32 registry API.
|
||||
//
|
||||
// All operations target `HKEY_CURRENT_USER` keys by default (the app only
|
||||
// reads/writes user-scoped state — startup entry, theme detection, etc.).
|
||||
// Each call opens and closes the key internally; there is no caching so
|
||||
// state changes by other processes are visible immediately.
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY,
|
||||
HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ,
|
||||
};
|
||||
|
||||
use super::string::to_utf16_nul;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RegistryError {
|
||||
#[error("registry open failed for {key}: code {code}")]
|
||||
Open { key: String, code: u32 },
|
||||
#[error("registry write failed for {key}\\{value}: code {code}")]
|
||||
Write { key: String, value: String, code: u32 },
|
||||
}
|
||||
|
||||
/// Read a `REG_DWORD` value under `HKEY_CURRENT_USER\<subkey>`.
|
||||
/// Returns `None` if the key or value does not exist.
|
||||
pub fn read_u32(subkey: &str, value_name: &str) -> Option<u32> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let mut data: u32 = 0;
|
||||
let mut size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some((&mut data as *mut u32) as *mut u8),
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if query == ERROR_SUCCESS {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test whether a value (any type) exists under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn value_exists(subkey: &str, value_name: &str) -> bool {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return false;
|
||||
}
|
||||
let mut size: u32 = 0;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
query == ERROR_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a string value as `REG_SZ` under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn write_string(subkey: &str, value_name: &str, value: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
let data_w = to_utf16_nul(value);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
data_w.as_ptr() as *const u8,
|
||||
data_w.len() * std::mem::size_of::<u16>(),
|
||||
);
|
||||
let res = RegSetValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(bytes),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if res == ERROR_SUCCESS {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RegistryError::Write {
|
||||
key: subkey.to_string(),
|
||||
value: value_name.to_string(),
|
||||
code: res.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a value under `HKEY_CURRENT_USER\<subkey>`. Returns `Ok(())` even
|
||||
/// if the value never existed.
|
||||
pub fn delete_value(subkey: &str, value_name: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(value_w.as_ptr()));
|
||||
let _ = RegCloseKey(hkey);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Windows light/dark theme detection.
|
||||
//
|
||||
// Windows stores the current theme under
|
||||
// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`
|
||||
// with a `SystemUsesLightTheme` DWORD: 1 means light, 0 means dark.
|
||||
|
||||
use super::registry;
|
||||
|
||||
const THEME_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const LIGHT_VALUE: &str = "SystemUsesLightTheme";
|
||||
|
||||
/// `true` if the system is in dark mode. Defaults to dark when the registry
|
||||
/// value is missing (matches Windows 11 first-boot behaviour).
|
||||
pub fn is_dark() -> bool {
|
||||
!matches!(registry::read_u32(THEME_KEY, LIGHT_VALUE), Some(1))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Linux-only helpers.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The AppIndicator libraries the tray backend loads, in its own order.
|
||||
const APPINDICATOR_LIBRARIES: [&str; 2] =
|
||||
["libayatana-appindicator3.so.1", "libappindicator3.so.1"];
|
||||
|
||||
/// Whether an AppIndicator library can be loaded. The tray backend panics
|
||||
/// when neither loads, and the release profile aborts on panic, so the tray
|
||||
/// is built only when this is true. Probed once per process.
|
||||
pub fn appindicator_available() -> bool {
|
||||
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
*AVAILABLE.get_or_init(|| {
|
||||
for name in APPINDICATOR_LIBRARIES {
|
||||
// SAFETY: these are the libraries the tray backend itself loads;
|
||||
// their initialisers run either way once a tray is built.
|
||||
match unsafe { libloading::Library::new(name) } {
|
||||
Ok(_) => {
|
||||
log::info!("tray: {name} found");
|
||||
return true;
|
||||
}
|
||||
Err(err) => log::debug!("tray: {name} not loadable: {err}"),
|
||||
}
|
||||
}
|
||||
log::warn!("tray: no AppIndicator library found; running without a tray");
|
||||
false
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
|
||||
+66
-1
@@ -1,17 +1,23 @@
|
||||
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::GetAvailableCoreWebView2BrowserVersionString;
|
||||
use windows::core::{HSTRING, PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND};
|
||||
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, SetForegroundWindow, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
|
||||
};
|
||||
|
||||
use crate::core::proxy::{self, SystemProxy};
|
||||
|
||||
const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
const PERSONALIZE_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const AUMID_KEY_PREFIX: &str = r"Software\Classes\AppUserModelId\";
|
||||
|
||||
/// The static proxy from `HKCU\…\Internet Settings`, or `None` when
|
||||
/// `ProxyEnable` is not 1 or no usable `ProxyServer` entry exists.
|
||||
@@ -87,3 +93,62 @@ pub fn message_box_error(title: &str, text: &str) {
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Register `aumid` so toasts from this portable exe are shown although no
|
||||
/// Start-menu shortcut carries the id: write
|
||||
/// `HKCU\Software\Classes\AppUserModelId\<aumid>` with `DisplayName` and
|
||||
/// `IconUri` (`icon_png` written to `icon_path`), then make it the process
|
||||
/// id. Each step is attempted; the first error is returned after all ran.
|
||||
pub fn register_aumid(
|
||||
aumid: &str,
|
||||
display_name: &str,
|
||||
icon_png: &[u8],
|
||||
icon_path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let icon = write_if_changed(icon_path, icon_png);
|
||||
if let Err(err) = &icon {
|
||||
log::warn!("AUMID icon {} not written: {err}", icon_path.display());
|
||||
}
|
||||
|
||||
let key = windows_registry::CURRENT_USER
|
||||
.create(format!("{AUMID_KEY_PREFIX}{aumid}"))
|
||||
.and_then(|key| {
|
||||
key.set_string("DisplayName", display_name)?;
|
||||
if icon.is_ok() {
|
||||
key.set_string("IconUri", icon_path.to_string_lossy().as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(io::Error::from);
|
||||
|
||||
// SAFETY: the HSTRING outlives the call.
|
||||
let process = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(aumid)) }
|
||||
.map_err(io::Error::from);
|
||||
|
||||
key.and(process).and(icon)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` unless it already holds exactly them.
|
||||
fn write_if_changed(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if std::fs::read(path).is_ok_and(|current| current == bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
match window.hwnd() {
|
||||
// SAFETY: the handle belongs to a live window of this process.
|
||||
Ok(hwnd) => {
|
||||
if !unsafe { SetForegroundWindow(hwnd) }.as_bool() {
|
||||
log::debug!("SetForegroundWindow({}) refused", window.label());
|
||||
}
|
||||
}
|
||||
Err(err) => log::warn!("no HWND for {}: {err}", window.label()),
|
||||
}
|
||||
}
|
||||
+291
-29
@@ -16,17 +16,20 @@ use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use tauri::{
|
||||
App, AppHandle, Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, Window,
|
||||
WindowEvent,
|
||||
AppHandle, Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, Window, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
use crate::cli::{self, CliArgs};
|
||||
use crate::core::geom::{self, Rect};
|
||||
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};
|
||||
|
||||
@@ -44,6 +47,10 @@ 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)]
|
||||
@@ -146,6 +153,11 @@ pub struct Runtime {
|
||||
/// 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 {
|
||||
@@ -183,9 +195,37 @@ impl Runtime {
|
||||
)
|
||||
});
|
||||
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
|
||||
@@ -256,8 +296,8 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show alerts. Until a notification backend is registered they only
|
||||
/// reach the diagnostic log.
|
||||
/// 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;
|
||||
@@ -266,17 +306,162 @@ impl Runtime {
|
||||
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 automatic update check. No updater is registered in this build,
|
||||
/// so the deadline stays disarmed until a check reports back.
|
||||
/// 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");
|
||||
}
|
||||
@@ -302,6 +487,7 @@ impl Runtime {
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -425,16 +611,24 @@ 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()
|
||||
// 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!());
|
||||
|
||||
@@ -453,6 +647,11 @@ pub fn run() {
|
||||
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);
|
||||
@@ -463,15 +662,45 @@ pub fn run() {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label().starts_with(BUBBLE_LABEL_PREFIX) {
|
||||
api.prevent_close();
|
||||
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 App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
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();
|
||||
@@ -493,10 +722,7 @@ fn setup(app: &mut App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
update_interval_secs: state.settings.update_check_interval_secs,
|
||||
last_update_check_unix: state.settings.last_update_check_unix,
|
||||
};
|
||||
let first_provider = enabled_live_providers(&state.settings)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(ProviderId::Claude);
|
||||
let providers = enabled_live_providers(&state.settings);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let runtime = Arc::new(Runtime {
|
||||
@@ -508,6 +734,9 @@ fn setup(app: &mut App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
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));
|
||||
|
||||
@@ -516,14 +745,44 @@ fn setup(app: &mut App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
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);
|
||||
|
||||
create_test_window(app, first_provider)?;
|
||||
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> {
|
||||
@@ -548,19 +807,22 @@ fn monitor_rects(app: &AppHandle) -> Vec<Rect> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain window for the first enabled provider that renders its payload
|
||||
/// as JSON, standing in for the real bubble window.
|
||||
fn create_test_window(app: &App, provider: ProviderId) -> tauri::Result<()> {
|
||||
WebviewWindowBuilder::new(
|
||||
app,
|
||||
bubble_label(provider),
|
||||
WebviewUrl::App("bubble.html".into()),
|
||||
)
|
||||
.title("Claude Code Usage Bubble")
|
||||
.inner_size(460.0, 420.0)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.build()?;
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
//! One tray icon per enabled live provider, with a usage badge, a
|
||||
//! four-line tooltip and the context menu. A left click toggles the widget.
|
||||
//!
|
||||
//! [`Trays::sync`] runs on the main thread with owned [`TrayView`]s; the
|
||||
//! state lock is never held while a tray is touched.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, Wry};
|
||||
|
||||
use crate::badge;
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::menu_model::MenuEntry;
|
||||
use crate::core::state::{tray_tooltip, UsageState};
|
||||
use crate::menu;
|
||||
use crate::runtime::Runtime;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// The tooltip limit of the Windows notification area (`szTip` holds 128
|
||||
/// UTF-16 units including the terminator).
|
||||
pub const TOOLTIP_MAX_UTF16: usize = 127;
|
||||
|
||||
/// What one provider's tray icon shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrayView {
|
||||
pub provider: ProviderId,
|
||||
/// Session usage, or `None` (track only) until a poll succeeded.
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
/// The tray icons for the enabled live providers, in display order. The
|
||||
/// badge shows usage only while the last poll succeeded, as before.
|
||||
pub fn views(enabled: &[ProviderId], usage: &UsageState, strings: &LocaleStrings) -> Vec<TrayView> {
|
||||
enabled
|
||||
.iter()
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.map(|&provider| {
|
||||
let entry = usage.snapshots.get(&provider);
|
||||
TrayView {
|
||||
provider,
|
||||
percent: if usage.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: truncate_utf16(
|
||||
&tray_tooltip(strings.provider_label(provider), entry, strings),
|
||||
TOOLTIP_MAX_UTF16,
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cut `text` to at most `max` UTF-16 units without splitting a character.
|
||||
pub fn truncate_utf16(text: &str, max: usize) -> String {
|
||||
let mut units = 0;
|
||||
let mut end = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
units += c.len_utf16();
|
||||
if units > max {
|
||||
break;
|
||||
}
|
||||
end = i + c.len_utf8();
|
||||
}
|
||||
text[..end].to_string()
|
||||
}
|
||||
|
||||
/// What an icon currently shows, so unchanged icons and tooltips are not
|
||||
/// pushed again.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Shown {
|
||||
/// Percent bits and fill colour of the drawn badge.
|
||||
badge: (Option<u64>, [u8; 3]),
|
||||
tooltip: String,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn of(view: &TrayView) -> Self {
|
||||
let colour = view
|
||||
.percent
|
||||
.map(|p| {
|
||||
let c = crate::core::usage_color::bar_fill_color(view.provider, true, p);
|
||||
[c.r, c.g, c.b]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
badge: (view.percent.map(f64::to_bits), colour),
|
||||
tooltip: view.tooltip.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The diff between the icons that exist and the ones wanted.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Plan {
|
||||
remove: Vec<ProviderId>,
|
||||
create: Vec<ProviderId>,
|
||||
/// Existing icons with `(new badge?, new tooltip?)`.
|
||||
update: Vec<(ProviderId, bool, bool)>,
|
||||
}
|
||||
|
||||
fn plan(shown: &HashMap<ProviderId, Shown>, views: &[TrayView]) -> Plan {
|
||||
let mut plan = Plan {
|
||||
remove: ProviderId::LIVE_USAGE
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p) && !views.iter().any(|v| v.provider == *p))
|
||||
.collect(),
|
||||
..Plan::default()
|
||||
};
|
||||
for view in views {
|
||||
let next = Shown::of(view);
|
||||
match shown.get(&view.provider) {
|
||||
None => plan.create.push(view.provider),
|
||||
Some(current) => {
|
||||
let badge = current.badge != next.badge;
|
||||
let tooltip = current.tooltip != next.tooltip;
|
||||
if badge || tooltip {
|
||||
plan.update.push((view.provider, badge, tooltip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// The tray icons this process created and what they show.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Trays {
|
||||
shown: Mutex<HashMap<ProviderId, Shown>>,
|
||||
}
|
||||
|
||||
impl Trays {
|
||||
fn shown(&self) -> HashMap<ProviderId, Shown> {
|
||||
self.shown
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn record(&self, provider: ProviderId, shown: Option<Shown>) {
|
||||
let mut map = self.shown.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match shown {
|
||||
Some(s) => map.insert(provider, s),
|
||||
None => map.remove(&provider),
|
||||
};
|
||||
}
|
||||
|
||||
/// The providers that currently have an icon.
|
||||
pub fn providers(&self) -> Vec<ProviderId> {
|
||||
let shown = self.shown();
|
||||
ProviderId::LIVE_USAGE
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bring the icons in line with `views`: remove the ones no longer
|
||||
/// wanted, create new ones with a menu from `entries`, and push a badge
|
||||
/// or tooltip only when it changed. Main thread only. On Linux nothing
|
||||
/// happens without an AppIndicator library.
|
||||
pub fn sync(&self, app: &AppHandle, views: &[TrayView], entries: &[MenuEntry]) {
|
||||
if !tray_supported() {
|
||||
return;
|
||||
}
|
||||
let plan = plan(&self.shown(), views);
|
||||
for provider in plan.remove {
|
||||
app.remove_tray_by_id(provider.slug());
|
||||
self.record(provider, None);
|
||||
}
|
||||
for view in views {
|
||||
if plan.create.contains(&view.provider) {
|
||||
match create(app, view, entries) {
|
||||
Ok(_) => self.record(view.provider, Some(Shown::of(view))),
|
||||
Err(err) => log::error!(
|
||||
"tray icon for {} could not be created: {err}",
|
||||
view.provider.slug()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (provider, badge_changed, tooltip_changed) in plan.update {
|
||||
let Some(view) = views.iter().find(|v| v.provider == provider) else {
|
||||
continue;
|
||||
};
|
||||
let Some(tray) = app.tray_by_id(provider.slug()) else {
|
||||
self.record(provider, None);
|
||||
continue;
|
||||
};
|
||||
let mut ok = true;
|
||||
if badge_changed {
|
||||
if let Err(err) = tray.set_icon(Some(badge_image(view))) {
|
||||
log::warn!("tray icon of {} not updated: {err}", provider.slug());
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if tooltip_changed {
|
||||
if let Err(err) = tray.set_tooltip(Some(&view.tooltip)) {
|
||||
log::warn!("tray tooltip of {} not updated: {err}", provider.slug());
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
self.record(provider, Some(Shown::of(view)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every icon a fresh menu built from `entries`. Main thread only.
|
||||
pub fn set_menus(&self, app: &AppHandle, entries: &[MenuEntry]) {
|
||||
for provider in self.providers() {
|
||||
let Some(tray) = app.tray_by_id(provider.slug()) else {
|
||||
continue;
|
||||
};
|
||||
let result = menu::build(app, entries).and_then(|m| tray.set_menu(Some(m)));
|
||||
if let Err(err) = result {
|
||||
log::warn!("tray menu of {} not updated: {err}", provider.slug());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tray can be created on this system without aborting.
|
||||
fn tray_supported() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::platform::linux::appindicator_available()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn badge_image(view: &TrayView) -> Image<'static> {
|
||||
let pixmap = badge::render_pixmap(view.provider, view.percent);
|
||||
Image::new_owned(
|
||||
badge::demultiplied_rgba(&pixmap),
|
||||
badge::BADGE_PX,
|
||||
badge::BADGE_PX,
|
||||
)
|
||||
}
|
||||
|
||||
fn create(app: &AppHandle, view: &TrayView, entries: &[MenuEntry]) -> tauri::Result<TrayIcon<Wry>> {
|
||||
TrayIconBuilder::with_id(view.provider.slug())
|
||||
.icon(badge_image(view))
|
||||
.tooltip(&view.tooltip)
|
||||
.menu(&menu::build(app, entries)?)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_tray_icon_event(on_tray_icon_event)
|
||||
.build(app)
|
||||
}
|
||||
|
||||
fn on_tray_icon_event(tray: &TrayIcon<Wry>, event: TrayIconEvent) {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(runtime) = app.try_state::<Arc<Runtime>>() {
|
||||
let runtime = Arc::clone(&runtime);
|
||||
runtime.toggle_widget();
|
||||
runtime.refresh_shell(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
use crate::core::state::ProviderUiState;
|
||||
use crate::usage::{UsageWindows, Window};
|
||||
|
||||
fn strings() -> LocaleStrings {
|
||||
I18n::load().strings().clone()
|
||||
}
|
||||
|
||||
fn usage(pct: f64, ok: bool) -> UsageState {
|
||||
let mut usage = UsageState {
|
||||
last_poll_ok: ok,
|
||||
..UsageState::default()
|
||||
};
|
||||
usage.snapshots.insert(
|
||||
ProviderId::Claude,
|
||||
ProviderUiState {
|
||||
windows: UsageWindows {
|
||||
primary: Window {
|
||||
utilization: pct,
|
||||
resets_at: None,
|
||||
},
|
||||
secondary: Window::default(),
|
||||
},
|
||||
primary_text: format!("{pct}%"),
|
||||
secondary_text: String::new(),
|
||||
},
|
||||
);
|
||||
usage
|
||||
}
|
||||
|
||||
fn view(provider: ProviderId, percent: Option<f64>, tooltip: &str) -> TrayView {
|
||||
TrayView {
|
||||
provider,
|
||||
percent,
|
||||
tooltip: tooltip.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_follow_enabled_providers_and_last_poll() {
|
||||
let s = strings();
|
||||
let all = [
|
||||
ProviderId::Claude,
|
||||
ProviderId::ChatGpt,
|
||||
ProviderId::OpenCodeGo,
|
||||
];
|
||||
let v = views(&all, &usage(42.0, true), &s);
|
||||
assert_eq!(v.len(), 2, "OpenCode Go never gets an icon");
|
||||
assert_eq!(v[0].provider, ProviderId::Claude);
|
||||
assert_eq!(v[0].percent, Some(42.0));
|
||||
assert_eq!(
|
||||
v[0].tooltip,
|
||||
"Claude Code\n5h: 42%\n7d: ...\nLeft-click: show/hide"
|
||||
);
|
||||
assert_eq!(v[1].provider, ProviderId::ChatGpt);
|
||||
assert_eq!(v[1].percent, None);
|
||||
// A failed last poll greys every badge to the track.
|
||||
let v = views(&all, &usage(42.0, false), &s);
|
||||
assert_eq!(v[0].percent, None);
|
||||
assert!(views(&[ProviderId::ChatGpt], &usage(1.0, true), &s)
|
||||
.iter()
|
||||
.all(|v| v.provider == ProviderId::ChatGpt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltips_are_cut_to_the_utf16_limit() {
|
||||
assert_eq!(truncate_utf16("abc", 127), "abc");
|
||||
assert_eq!(truncate_utf16("abcdef", 3), "abc");
|
||||
// '·' is one unit, '😀' two: never split a surrogate pair.
|
||||
assert_eq!(truncate_utf16("a·😀b", 3), "a·");
|
||||
assert_eq!(truncate_utf16("a·😀b", 4), "a·😀");
|
||||
let long = "x".repeat(200);
|
||||
assert_eq!(truncate_utf16(&long, TOOLTIP_MAX_UTF16).len(), 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_creates_removes_and_updates_only_changes() {
|
||||
let mut shown = HashMap::new();
|
||||
let first = [view(ProviderId::Claude, None, "a")];
|
||||
let p = plan(&shown, &first);
|
||||
assert_eq!(p.create, [ProviderId::Claude]);
|
||||
assert!(p.remove.is_empty() && p.update.is_empty());
|
||||
|
||||
shown.insert(ProviderId::Claude, Shown::of(&first[0]));
|
||||
assert_eq!(plan(&shown, &first), Plan::default(), "nothing changed");
|
||||
|
||||
let p = plan(&shown, &[view(ProviderId::Claude, None, "b")]);
|
||||
assert_eq!(p.update, [(ProviderId::Claude, false, true)]);
|
||||
let p = plan(&shown, &[view(ProviderId::Claude, Some(10.0), "a")]);
|
||||
assert_eq!(p.update, [(ProviderId::Claude, true, false)]);
|
||||
|
||||
let p = plan(&shown, &[view(ProviderId::ChatGpt, None, "c")]);
|
||||
assert_eq!(p.remove, [ProviderId::Claude]);
|
||||
assert_eq!(p.create, [ProviderId::ChatGpt]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_key_tracks_percent_and_colour() {
|
||||
let a = Shown::of(&view(ProviderId::Claude, Some(59.0), "t"));
|
||||
let b = Shown::of(&view(ProviderId::Claude, Some(61.0), "t"));
|
||||
assert_ne!(a.badge, b.badge);
|
||||
assert_ne!(a.badge.1, b.badge.1, "crossing 60% changes the colour");
|
||||
let none = Shown::of(&view(ProviderId::Claude, None, "t"));
|
||||
assert_eq!(none.badge, (None, [0, 0, 0]));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,13 @@ window.addEventListener('unhandledrejection', (event) => {
|
||||
logJs('error', `unhandled rejection: ${String(event.reason)}`);
|
||||
});
|
||||
|
||||
window.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
tauri.core
|
||||
.invoke('show_context_menu', { label: webview.label })
|
||||
.catch((err) => logJs('error', `show_context_menu failed: ${String(err)}`));
|
||||
});
|
||||
|
||||
webview
|
||||
.listen('bubble:update', (event) => render(event.payload))
|
||||
.catch((err) => logJs('error', `listen failed: ${String(err)}`));
|
||||
|
||||
Reference in new issue
Block a user