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:
AgentSeal 2026-04-18 03:59:13 -07:00
parent 86bdbfcd1c
commit ea5e311d4a
6 changed files with 322 additions and 36 deletions

View file

@ -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",

View file

@ -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

View file

@ -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);
}
}

View 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(())
}

View file

@ -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>}

View file

@ -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;