mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 00:57:09 +00:00
feat(desktop): replace Linux tray with ksni for Mac-style click UX
Tauri's Linux tray uses libappindicator, which by design never fires left-click events to the app and exposes no icon screen position. That forced a menu-first UX on GNOME and made anchoring the popover near the icon impossible (tauri-apps/tauri#7283, closed not-planned). Drop libappindicator on Linux and talk StatusNotifierItem directly via the ksni crate: * new src-tauri/src/tray_linux.rs implements ksni::Tray. activate(x, y) and secondary_activate(x, y) arrive with real screen coordinates. The implementation exports no menu on purpose, so SNI hosts (notably the gnome-shell-extension-appindicator) call Activate on left click instead of opening a context menu. * LinuxTrayHandle wraps the async ksni::Handle with a sync Mutex so the Tauri command handler can push title updates without naming the generic Handle<CodeburnTray> across module boundaries. * lib.rs gates TrayIconBuilder behind cfg(not(target_os = "linux")) and spawns ksni on the Tokio runtime Tauri already owns. Tray click events go through codeburn://tray-activate with {x, y} so the positioning logic can anchor the popover directly below the click, clamped to the monitor. * new set_tray_title command pushes the hero cost to the tray label on every payload fetch. On Linux that's the SNI title next to the icon; on other platforms it's the TrayIcon::set_title. * new quit_app command + popover-footer × button gives Linux users a way to exit without a tray menu. * empty-state copy already landed in a previous commit. Combined with the footer Quit button, the popover is now self-contained on Linux. Windows is unaffected: TrayIconBuilder there fires left-click events correctly and set_title/tooltip work out of the box. Known limitation: on vanilla GNOME without AppIndicator support, there is no tray to click. Documentation will link to the AppIndicator extension install, same caveat Slack, Discord and 1Password ship with.
This commit is contained in:
parent
86bdbfcd1c
commit
ea5e311d4a
6 changed files with 322 additions and 36 deletions
23
desktop/src-tauri/Cargo.lock
generated
23
desktop/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CodeburnCli>,
|
||||
pub config: Mutex<CurrencyConfig>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
desktop/src-tauri/src/tray_linux.rs
Normal file
147
desktop/src-tauri/src/tray_linux.rs
Normal file
|
|
@ -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<Icon>,
|
||||
}
|
||||
|
||||
impl CodeburnTray {
|
||||
fn new(app: AppHandle, icon: Vec<Icon>) -> 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<Icon> {
|
||||
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<CodeburnTray>` generic parameter across module boundaries.
|
||||
#[derive(Clone)]
|
||||
pub struct LinuxTrayHandle {
|
||||
inner: Arc<Mutex<Option<ksni::Handle<CodeburnTray>>>>,
|
||||
}
|
||||
|
||||
impl LinuxTrayHandle {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, handle: ksni::Handle<CodeburnTray>) {
|
||||
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<Icon> {
|
||||
// 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<Icon> {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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'}
|
||||
</button>
|
||||
<button className="report" onClick={openFullReport}>Open Full Report</button>
|
||||
<button className="quit" onClick={() => invoke('quit_app').catch(console.error)} title="Quit CodeBurn">×</button>
|
||||
</footer>
|
||||
|
||||
{error && <div className="error-toast">{error}</div>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue