diff --git a/Cargo.lock b/Cargo.lock index 60fd5db..d5a77ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 2f4806e..e525e31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/core/menu_model.rs b/src/core/menu_model.rs index 2fe094e..b9f0ece 100644 --- a/src/core/menu_model.rs +++ b/src/core/menu_model.rs @@ -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) { + 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); diff --git a/src/lib.rs b/src/lib.rs index bc3346b..3cf94d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..373cb90 --- /dev/null +++ b/src/menu.rs @@ -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 { + 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>>, +} + +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::().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::().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> { + 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>> { + 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::>() 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>, + 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 { + fn find(entries: &[MenuEntry]) -> Option { + 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)); + } +} diff --git a/src/os/mod.rs b/src/os/mod.rs index 8b6d5ae..81f16b5 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -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; diff --git a/src/os/registry.rs b/src/os/registry.rs deleted file mode 100644 index 45eff81..0000000 --- a/src/os/registry.rs +++ /dev/null @@ -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\`. -/// Returns `None` if the key or value does not exist. -pub fn read_u32(subkey: &str, value_name: &str) -> Option { - 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::() 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\`. -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\`. -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::(), - ); - 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\`. 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(()) - } -} diff --git a/src/os/theme.rs b/src/os/theme.rs deleted file mode 100644 index 419bf56..0000000 --- a/src/os/theme.rs +++ /dev/null @@ -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)) -} diff --git a/src/platform/linux.rs b/src/platform/linux.rs new file mode 100644 index 0000000..d685f63 --- /dev/null +++ b/src/platform/linux.rs @@ -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 = 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 + }) +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 9d9a2e4..a557632 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,8 @@ use std::time::{Duration, Instant}; +#[cfg(target_os = "linux")] +pub mod linux; #[cfg(windows)] pub mod windows; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index f8e5aab..098dcba 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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\` 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()), + } +} diff --git a/src/runtime.rs b/src/runtime.rs index c43af99..2bcb92d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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, + 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::>() 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) { 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) { 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) { + 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, 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(&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) { + 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, 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.update_settings(|s| s.bubble_positions.reset_all()); + self.propagate(); + self.spawn_poll(); + } + + fn resize_bubbles(self: &Arc, 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, 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::>() { + runtime.exiting.store(true, Ordering::Release); + } + } RunEvent::Exit => { if let Some(runtime) = app.try_state::>() { 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::>() 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> { +fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { 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> { 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> { 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> { 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 { @@ -548,19 +807,22 @@ fn monitor_rects(app: &AppHandle) -> Vec { } } -/// 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(()) } diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..82f95d9 --- /dev/null +++ b/src/tray.rs @@ -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, + 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 { + 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, [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, + create: Vec, + /// Existing icons with `(new badge?, new tooltip?)`. + update: Vec<(ProviderId, bool, bool)>, +} + +fn plan(shown: &HashMap, 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>, +} + +impl Trays { + fn shown(&self) -> HashMap { + self.shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn record(&self, provider: ProviderId, shown: Option) { + 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 { + 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> { + 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, 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::>() { + 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, 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])); + } +} diff --git a/ui/bubble.js b/ui/bubble.js index 4fb1d2e..d95de95 100644 --- a/ui/bubble.js +++ b/ui/bubble.js @@ -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)}`));