Add system tray icon (Show/Hide/Refresh/Quit; left-click restores window)

Solves the 'lost window' problem — if the widget gets dragged off-screen
or hidden behind other apps, users can recover it via the tray.

Enables tauri 'tray-icon' feature; tray runs entirely Rust-side so no
new JS capabilities needed.
This commit is contained in:
megaproxy 2026-05-09 14:47:03 +01:00
parent 9be856d37c
commit 79fc144235
3 changed files with 75 additions and 1 deletions

View file

@ -52,6 +52,9 @@ displays, refreshed every 5 minutes.
- **⚙** — Settings (custom claude command, refresh interval, autostart, distro override).
- **×** — quit.
- The window is **resizable** — drag any edge.
- **System tray** — there's a tray icon next to the Windows clock while the
widget is running. Left-click to bring the window back if you lose it
off-screen; right-click for Show / Hide / Refresh / Quit.
## Troubleshooting

View file

@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-autostart = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"

View file

@ -13,8 +13,24 @@ mod usage;
mod watch;
use tauri::{Emitter, Manager};
use tauri::menu::{MenuBuilder, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use std::time::Duration;
fn show_main_window(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
fn hide_main_window(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
}
}
async fn refresh_cli_loop(app: tauri::AppHandle, state: state::SharedState) {
// Small initial delay so we don't compete with cold-start file scanning.
tokio::time::sleep(Duration::from_secs(2)).await;
@ -66,6 +82,61 @@ pub fn run() {
let shared = state::AppState::new(settings);
app.manage(shared.clone());
// Tray icon: always-visible recovery handle in case the window
// gets lost (off-screen, hidden by another app, etc.).
let show_item = MenuItem::with_id(app, "tray-show", "Show", true, None::<&str>)?;
let hide_item = MenuItem::with_id(app, "tray-hide", "Hide", true, None::<&str>)?;
let refresh_item = MenuItem::with_id(app, "tray-refresh", "Refresh /usage", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "tray-quit", "Quit", true, None::<&str>)?;
let menu = MenuBuilder::new(app)
.item(&show_item)
.item(&hide_item)
.separator()
.item(&refresh_item)
.separator()
.item(&quit_item)
.build()?;
let state_for_tray = shared.clone();
let _tray = TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("Claude Usage Widget")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id().as_ref() {
"tray-show" => show_main_window(app),
"tray-hide" => hide_main_window(app),
"tray-refresh" => {
let app = app.clone();
let state = state_for_tray.clone();
tauri::async_runtime::spawn(async move {
let cmd = state.settings.read().claude_command.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
cli_usage::fetch_blocking(cmd.as_deref())
})
.await;
if let Ok(Ok(u)) = result {
*state.cli_usage.write() = Some(u.clone());
let _ = app.emit("cli-usage-updated", &u);
}
});
}
"tray-quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
let handle = app.handle().clone();
let state_for_task = shared.clone();
tauri::async_runtime::spawn(async move {