diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fb0fb49..7ee699a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -468,6 +468,8 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs 5.0.1", + "ksni", + "png", "reqwest 0.12.28", "serde", "serde_json", @@ -1983,6 +1985,19 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "ksni" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc" +dependencies = [ + "futures-util", + "pastey", + "serde", + "tokio", + "zbus", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2494,6 +2509,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4350,6 +4371,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -5787,6 +5809,7 @@ dependencies = [ "rustix", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1995675..d659097 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "CodeBurn menubar / tray app for Linux and Windows" authors = ["AgentSeal"] edition = "2021" -rust-version = "1.77" +rust-version = "1.80" [lib] name = "codeburn_desktop_lib" @@ -25,6 +25,10 @@ anyhow = "1" dirs = "5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +[target.'cfg(target_os = "linux")'.dependencies] +ksni = "0.3" +png = "0.17" + [features] default = ["custom-protocol"] # This feature is used for production builds or when a dev server is not specified. Don't diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 06a64b8..72ca1dc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,13 +1,19 @@ mod cli; mod config; mod fx; +#[cfg(target_os = "linux")] +mod tray_linux; use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, WindowEvent}; +#[cfg(target_os = "linux")] +use tauri::Listener; + +#[cfg(not(target_os = "linux"))] use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, WindowEvent, }; use crate::cli::CodeburnCli; @@ -22,6 +28,8 @@ pub struct AppState { pub cli: Mutex, pub config: Mutex, pub fx: FxCache, + #[cfg(target_os = "linux")] + pub linux_tray: tray_linux::LinuxTrayHandle, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -30,16 +38,24 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { + #[cfg(target_os = "linux")] + let linux_tray = tray_linux::LinuxTrayHandle::empty(); + let state = AppState { cli: Mutex::new(CodeburnCli::resolve()), config: Mutex::new(CurrencyConfig::load_or_default()), fx: FxCache::new(), + #[cfg(target_os = "linux")] + linux_tray: linux_tray.clone(), }; app.manage(state); - build_tray(app.handle())?; + #[cfg(not(target_os = "linux"))] + build_tray_tauri(app.handle())?; + + #[cfg(target_os = "linux")] + init_tray_linux(app.handle().clone(), linux_tray); - // Hide the popover window on launch; the tray icon click toggles it. if let Some(window) = app.get_webview_window("popover") { let _ = window.hide(); } @@ -49,7 +65,8 @@ pub fn run() { .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { // Keep the popover alive between clicks. Hiding avoids spawn cost + preserves - // scroll position + in-flight data. User exits via the tray menu instead. + // scroll position + in-flight data. User exits via the in-popover quit button + // or (on non-Linux) the tray menu. api.prevent_close(); let _ = window.hide(); } @@ -58,17 +75,19 @@ pub fn run() { commands::fetch_payload, commands::set_currency, commands::open_terminal_command, + commands::set_tray_title, + commands::quit_app, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } -fn build_tray(app: &AppHandle) -> tauri::Result<()> { - let dashboard = MenuItem::with_id(app, "dashboard", "Show Dashboard", true, None::<&str>)?; +#[cfg(not(target_os = "linux"))] +fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> { let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?; let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&dashboard, &refresh, &report, &quit])?; + let menu = Menu::with_items(app, &[&refresh, &report, &quit])?; TrayIconBuilder::with_id("codeburn-tray") .tooltip("CodeBurn") @@ -76,10 +95,7 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> { .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "quit" => app.exit(0), - "dashboard" => toggle_popover(app), "refresh" => { - // Nudge the webview so it re-requests the payload. The front-end listens for - // this event and kicks off a new fetch_payload command. if let Some(window) = app.get_webview_window("popover") { let _ = window.emit("codeburn://refresh", ()); } @@ -96,7 +112,7 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> { .. } = event { - toggle_popover(tray.app_handle()); + toggle_popover(tray.app_handle(), None); } }) .build(app)?; @@ -104,43 +120,94 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -fn toggle_popover(app: &AppHandle) { +#[cfg(target_os = "linux")] +fn init_tray_linux(app: AppHandle, handle: tray_linux::LinuxTrayHandle) { + // Spawn the SNI tray on the Tokio runtime that Tauri already owns. + let spawn_app = app.clone(); + let spawn_handle = handle.clone(); + tauri::async_runtime::spawn(async move { + if let Err(err) = tray_linux::spawn(spawn_app, spawn_handle).await { + eprintln!("codeburn: failed to spawn Linux tray: {err}"); + } + }); + + // Left-click on the tray: show popover anchored to the click coordinates. + let activate_app = app.clone(); + app.listen_any("codeburn://tray-activate", move |event| { + let anchor = parse_click(event.payload()); + toggle_popover(&activate_app, anchor); + }); + + // Right-click / middle-click: same as left for now. Quit lives in the popover footer. + let secondary_app = app.clone(); + app.listen_any("codeburn://tray-secondary", move |event| { + let anchor = parse_click(event.payload()); + toggle_popover(&secondary_app, anchor); + }); +} + +#[cfg(target_os = "linux")] +fn parse_click(payload: &str) -> Option<(i32, i32)> { + let value: serde_json::Value = serde_json::from_str(payload).ok()?; + let x = value.get("x")?.as_i64()? as i32; + let y = value.get("y")?.as_i64()? as i32; + Some((x, y)) +} + +/// Show or hide the popover. When `anchor` is `Some((x, y))`, position the popover +/// centered horizontally on the click and just below it (Linux path, anchored to the +/// StatusNotifier Activate coordinates). When `None`, snap it to the top-right of the +/// primary monitor (non-Linux fallback + menu-driven invocations). +fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) { let Some(window) = app.get_webview_window("popover") else { return; }; - match window.is_visible() { - Ok(true) => { - let _ = window.hide(); - } - _ => { - position_popover_top_right(&window); - let _ = window.show(); - let _ = window.set_focus(); - } + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + return; } + position_popover(&window, anchor); + let _ = window.show(); + let _ = window.set_focus(); } -/// Snap the popover to the top-right of the primary monitor, just below the GNOME -/// top panel. Linux window managers generally ignore the tray icon's screen position, -/// so there is no reliable anchor to attach to. Top-right keeps the window visually -/// close to the StatusNotifier area on every desktop we target (GNOME, KDE, Unity). -fn position_popover_top_right(window: &tauri::WebviewWindow) { - // Matches desktop/src-tauri/tauri.conf.json popover width (logical pixels). +fn position_popover(window: &tauri::WebviewWindow, anchor: Option<(i32, i32)>) { + // Matches desktop/src-tauri/tauri.conf.json popover dimensions (logical pixels). const POPOVER_WIDTH_LOGICAL: f64 = 360.0; - const MARGIN_LOGICAL: f64 = 12.0; + const POPOVER_HEIGHT_LOGICAL: f64 = 660.0; + const MARGIN_LOGICAL: f64 = 8.0; const TOP_PANEL_LOGICAL: f64 = 36.0; let Ok(Some(monitor)) = window.primary_monitor() else { return; }; let scale = monitor.scale_factor(); - let popover_w = (POPOVER_WIDTH_LOGICAL * scale) as u32; - let margin = (MARGIN_LOGICAL * scale) as u32; - let panel = (TOP_PANEL_LOGICAL * scale) as u32; let screen = monitor.size(); - let x = screen.width.saturating_sub(popover_w).saturating_sub(margin); - let y = panel; - let _ = window.set_position(tauri::PhysicalPosition::new(x as i32, y as i32)); + let pop_w = (POPOVER_WIDTH_LOGICAL * scale) as i32; + let pop_h = (POPOVER_HEIGHT_LOGICAL * scale) as i32; + let margin = (MARGIN_LOGICAL * scale) as i32; + let panel = (TOP_PANEL_LOGICAL * scale) as i32; + let screen_w = screen.width as i32; + let screen_h = screen.height as i32; + + let (x, y) = match anchor { + Some((click_x, click_y)) => { + // Center horizontally on the click, drop the popover just below it. Clamp to + // the screen so it doesn't fall off the edge on multi-monitor setups. + let desired_x = click_x - pop_w / 2; + let max_x = (screen_w - pop_w - margin).max(margin); + let clamped_x = desired_x.clamp(margin, max_x); + let max_y = (screen_h - pop_h - margin).max(margin); + let clamped_y = (click_y + margin).clamp(margin, max_y); + (clamped_x, clamped_y) + } + None => { + let x = (screen_w - pop_w - margin).max(0); + (x, panel) + } + }; + + let _ = window.set_position(tauri::PhysicalPosition::new(x, y)); } mod commands { @@ -182,4 +249,32 @@ mod commands { let args: Vec<&str> = args.iter().map(String::as_str).collect(); crate::cli::spawn_in_terminal(&app, &args).map_err(|e| e.to_string()) } + + /// Update the text shown next to the tray icon (e.g. "🔥 $24.73"). On Linux this uses + /// the SNI `title` field that AppIndicator hosts render beside the icon. On other + /// platforms it sets the TrayIcon title/tooltip. Called from the frontend after each + /// payload fetch so the ambient number stays fresh. + #[tauri::command] + pub async fn set_tray_title( + _app: AppHandle, + title: String, + _state: State<'_, AppState>, + ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + _state.linux_tray.set_title(title).await; + } + #[cfg(not(target_os = "linux"))] + { + if let Some(tray) = _app.tray_by_id("codeburn-tray") { + let _ = tray.set_title(Some(title)); + } + } + Ok(()) + } + + #[tauri::command] + pub fn quit_app(app: AppHandle) { + app.exit(0); + } } diff --git a/desktop/src-tauri/src/tray_linux.rs b/desktop/src-tauri/src/tray_linux.rs new file mode 100644 index 0000000..b43dbc3 --- /dev/null +++ b/desktop/src-tauri/src/tray_linux.rs @@ -0,0 +1,147 @@ +use std::sync::{Arc, Mutex}; + +use ksni::{Category, Icon, Status, ToolTip, Tray, TrayMethods}; +use tauri::{AppHandle, Emitter}; + +/// StatusNotifierItem-backed tray for Linux. Bypasses libappindicator so left-click +/// fires `activate(x, y)` with real screen coordinates, which is what Tauri's Linux +/// tray path cannot deliver. See tauri-apps/tauri#7283 for the upstream gap. +/// +/// No menu() is exported. Exporting a menu causes most SNI hosts (notably +/// gnome-shell-extension-appindicator) to swallow left-click as a menu-open and +/// never fire Activate. Quit/Refresh/Open Full Report live in the popover footer. +pub struct CodeburnTray { + app: AppHandle, + title: String, + icon: Vec, +} + +impl CodeburnTray { + fn new(app: AppHandle, icon: Vec) -> Self { + Self { + app, + title: "CodeBurn".to_string(), + icon, + } + } +} + +impl Tray for CodeburnTray { + fn id(&self) -> String { + "org.agentseal.codeburn".to_string() + } + + fn title(&self) -> String { + self.title.clone() + } + + fn category(&self) -> Category { + Category::ApplicationStatus + } + + fn status(&self) -> Status { + Status::Active + } + + fn icon_pixmap(&self) -> Vec { + self.icon.clone() + } + + fn tool_tip(&self) -> ToolTip { + ToolTip { + icon_name: String::new(), + icon_pixmap: Vec::new(), + title: "CodeBurn".to_string(), + description: self.title.clone(), + } + } + + fn activate(&mut self, x: i32, y: i32) { + let _ = self + .app + .emit("codeburn://tray-activate", TrayClick { x, y }); + } + + fn secondary_activate(&mut self, x: i32, y: i32) { + let _ = self + .app + .emit("codeburn://tray-secondary", TrayClick { x, y }); + } +} + +#[derive(Clone, serde::Serialize)] +struct TrayClick { + x: i32, + y: i32, +} + +/// Type-erased handle for the Linux tray so callers can push title updates without +/// naming the `ksni::Handle` generic parameter across module boundaries. +#[derive(Clone)] +pub struct LinuxTrayHandle { + inner: Arc>>>, +} + +impl LinuxTrayHandle { + pub fn empty() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + } + } + + fn set(&self, handle: ksni::Handle) { + if let Ok(mut guard) = self.inner.lock() { + *guard = Some(handle); + } + } + + pub async fn set_title(&self, title: String) { + let handle = match self.inner.lock() { + Ok(guard) => guard.clone(), + Err(_) => return, + }; + let Some(handle) = handle else { return }; + let _ = handle.update(move |t| t.title = title).await; + } +} + +/// Decode the bundled tray.png into ARGB32 pixels that the SNI spec expects. +/// Falls back to an empty icon list (host shows a broken-icon placeholder) if the +/// asset can't be decoded. We'd rather render a blank icon than crash the tray. +fn load_icon() -> Vec { + // Embedded at build time so the binary is self-contained. + let bytes = include_bytes!("../icons/tray.png"); + let Ok(decoder) = png::Decoder::new(bytes.as_slice()).read_info().map_err(|_| ()) else { + return Vec::new(); + }; + decode_png(decoder) +} + +fn decode_png(mut reader: png::Reader<&[u8]>) -> Vec { + let info = reader.info().clone(); + let width = info.width as i32; + let height = info.height as i32; + let mut buf = vec![0u8; reader.output_buffer_size()]; + if reader.next_frame(&mut buf).is_err() { + return Vec::new(); + } + // SNI expects ARGB32 in network byte order. PNG decoder gives RGBA8. + let pixel_count = (width as usize) * (height as usize); + let mut argb = Vec::with_capacity(pixel_count * 4); + for chunk in buf.chunks_exact(4) { + let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); + argb.extend_from_slice(&[a, r, g, b]); + } + vec![Icon { + width, + height, + data: argb, + }] +} + +pub async fn spawn(app: AppHandle, handle_out: LinuxTrayHandle) -> anyhow::Result<()> { + let tray = CodeburnTray::new(app, load_icon()); + let handle = tray.spawn().await?; + handle_out.set(handle); + Ok(()) +} diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 99e722d..85ae828 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -40,12 +40,17 @@ export function App() { includeOptimize, }) setPayload(json) + // Push the hero cost to the tray label so the ambient number next to the icon + // (Linux SNI title / Win tray title) matches what the popover shows. + invoke('set_tray_title', { + title: formatCompactCurrency(json.current.cost, currency), + }).catch(() => {}) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) } - }, [period, provider]) + }, [period, provider, currency]) // Initial + interval refresh useEffect(() => { @@ -183,6 +188,7 @@ export function App() { {loading ? '...' : 'Refresh'} + {error &&
{error}
} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 1423a82..b0850e5 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -217,6 +217,17 @@ html, body, #root { padding: 6px 10px; cursor: pointer; } +.quit { + background: rgba(0, 0, 0, 0.04); + border: 0; + border-radius: var(--radius-md); + padding: 5px 9px; + font-size: 14px; + line-height: 1; + cursor: pointer; + color: var(--text-secondary); +} +.quit:hover { background: rgba(0, 0, 0, 0.08); color: var(--text-primary); } .error-toast { position: fixed;