use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::Utc;
use tauri::{
App, AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Wry,
image::Image,
menu::{IsMenuItem, Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use crate::models::{SnapshotStatus, TraySummary, UsageSnapshot};
const TRAY_ID: &str = "main";
pub(crate) const MAIN_WINDOW: &str = "main";
pub(crate) const TRAY_WINDOW: &str = "tray";
const TRAY_REOPEN_GUARD: Duration = Duration::from_millis(250);
const TRAY_MARGIN: f64 = 8.0;
const TRAY_OFFSET_Y: f64 = 12.0;
#[derive(Default)]
pub(crate) struct TrayWindowState {
inner: Mutex<TrayWindowInner>,
}
#[derive(Default)]
struct TrayWindowInner {
last_hidden_at: Option<Instant>,
last_anchor: Option<PhysicalPosition<f64>>,
}
impl TrayWindowState {
fn mark_hidden(&self) {
self.inner
.lock()
.expect("tray window state lock")
.last_hidden_at = Some(Instant::now());
}
fn set_anchor(&self, anchor: PhysicalPosition<f64>) {
self.inner
.lock()
.expect("tray window state lock")
.last_anchor = Some(anchor);
}
pub(crate) fn anchor(&self) -> Option<PhysicalPosition<f64>> {
self.inner
.lock()
.expect("tray window state lock")
.last_anchor
}
fn should_suppress_show(&self, now: Instant) -> bool {
self.inner
.lock()
.expect("tray window state lock")
.last_hidden_at
.is_some_and(|hidden| should_suppress_show(hidden, now, TRAY_REOPEN_GUARD))
}
}
fn should_suppress_show(last_hidden_at: Instant, now: Instant, guard: Duration) -> bool {
now.duration_since(last_hidden_at) < guard
}
pub(crate) fn clamp_tray_height(
content_height: f64,
chrome: f64,
work_height: f64,
margin: f64,
min: f64,
) -> f64 {
let available = (work_height - 2.0 * margin).max(1.0);
let lower = min.min(available);
(content_height + chrome).ceil().clamp(lower, available)
}
pub(crate) fn summarize(snapshots: &[UsageSnapshot]) -> TraySummary {
let critical_count = snapshots
.iter()
.filter(|snapshot| {
matches!(
snapshot.status,
SnapshotStatus::Exhausted | SnapshotStatus::Error
)
})
.count();
let warning_count = snapshots
.iter()
.filter(|snapshot| snapshot.status == SnapshotStatus::Warning)
.count();
let stale_count = snapshots
.iter()
.filter(|snapshot| snapshot.status == SnapshotStatus::Stale)
.count();
let status = if critical_count > 0 {
SnapshotStatus::Exhausted
} else if warning_count > 0 {
SnapshotStatus::Warning
} else if stale_count > 0 {
SnapshotStatus::Stale
} else if snapshots.is_empty() {
SnapshotStatus::NotConfigured
} else {
SnapshotStatus::Healthy
};
let label = match status {
SnapshotStatus::Healthy => "Burnrate: all quotas healthy".to_string(),
SnapshotStatus::Warning => format!("Burnrate: {warning_count} warning"),
SnapshotStatus::Exhausted => format!("Burnrate: {critical_count} critical"),
SnapshotStatus::NotConfigured => "Burnrate: no enabled accounts".to_string(),
SnapshotStatus::Error => "Burnrate: refresh error".to_string(),
SnapshotStatus::Stale => "Burnrate: data is stale".to_string(),
};
TraySummary {
label,
status,
critical_count,
warning_count,
updated_at: Utc::now(),
}
}
pub(crate) fn install(app: &mut App<Wry>) -> tauri::Result<()> {
rebuild(app.handle())
}
pub(crate) fn rebuild(app: &AppHandle<Wry>) -> tauri::Result<()> {
let preferences =
MenuItem::with_id(app, "preferences", "Open Preferences", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Burnrate", true, None::<&str>)?;
let check_updates = if crate::updater::updater_available() {
Some(MenuItem::with_id(
app,
"check-updates",
"Check for Updates…",
true,
None::<&str>,
)?)
} else {
None
};
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&preferences, &refresh];
if let Some(ref check_updates) = check_updates {
items.push(check_updates);
}
items.push(&quit);
let menu = Menu::with_items(app, &items)?;
let _ = app.remove_tray_by_id(TRAY_ID);
TrayIconBuilder::with_id(TRAY_ID)
.icon(tray_icon()?)
.icon_as_template(true)
.tooltip("Burnrate")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"preferences" => show_main_window(app),
"refresh" => {
let _ = app.emit("burnrate-refresh-requested", ());
}
"check-updates" => {
let _ = app.emit("burnrate-check-update-requested", ());
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
position,
..
} = event
{
show_tray_window(tray.app_handle(), position);
}
})
.build(app)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn apply_activation_policy(app: &AppHandle<Wry>, hide_from_dock: bool) {
let policy = if hide_from_dock {
tauri::ActivationPolicy::Accessory
} else {
tauri::ActivationPolicy::Regular
};
let _ = app.set_activation_policy(policy);
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn apply_activation_policy(_app: &AppHandle<Wry>, _hide_from_dock: bool) {}
#[cfg(target_os = "macos")]
pub(crate) fn set_dock_icon_if_unbundled() {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
const ICON_PNG: &[u8] = include_bytes!("../icons/icon.png");
if running_in_app_bundle() {
return;
}
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let data = NSData::with_bytes(ICON_PNG);
let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else {
return;
};
let app = NSApplication::sharedApplication(mtm);
unsafe { app.setApplicationIconImage(Some(&image)) };
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn set_dock_icon_if_unbundled() {}
#[cfg(target_os = "macos")]
pub(crate) fn running_in_app_bundle() -> bool {
std::env::current_exe()
.map(|path| path_is_in_app_bundle(&path.to_string_lossy()))
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn path_is_in_app_bundle(path: &str) -> bool {
path.contains(".app/Contents/MacOS/")
}
pub(crate) fn update_summary(app: &AppHandle<Wry>, summary: &TraySummary) {
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_tooltip(Some(summary.label.as_str()));
}
}
pub(crate) fn show_main_window(app: &AppHandle<Wry>) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW) {
if let Ok(icon) = app_icon() {
let _ = window.set_icon(icon);
}
#[cfg(target_os = "macos")]
{
apply_activation_policy(app, false);
let _ = app.show();
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
pub(crate) fn close_main_window(app: &AppHandle<Wry>) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW) {
let _ = window.hide();
}
#[cfg(target_os = "macos")]
apply_activation_policy(app, true);
}
fn show_tray_window(app: &AppHandle<Wry>, position: tauri::PhysicalPosition<f64>) {
let Some(window) = app.get_webview_window(TRAY_WINDOW) else {
return;
};
if window.is_visible().unwrap_or(false) {
hide_tray_window(app);
return;
}
let tray_state = app.state::<TrayWindowState>();
if tray_state.should_suppress_show(Instant::now()) {
return;
}
let (scale, work_pos, work_size) = cursor_monitor_geometry(app, position);
let current_scale = window.scale_factor().unwrap_or(scale);
let window_size = window
.outer_size()
.map(|size| {
let logical = size.to_logical::<f64>(current_scale);
PhysicalSize::new(logical.width * scale, logical.height * scale)
})
.unwrap_or_else(|_| PhysicalSize::new(360.0 * scale, 440.0 * scale));
tray_state.set_anchor(position);
let target = popup_position(
position,
window_size,
work_pos,
work_size,
TRAY_MARGIN * scale,
TRAY_OFFSET_Y * scale,
);
let _ = window.set_position(PhysicalPosition::new(
target.x.round() as i32,
target.y.round() as i32,
));
let _ = window.show();
#[cfg(target_os = "macos")]
activate_app();
let _ = window.set_focus();
let _ = app.emit("burnrate-refresh-requested", ());
}
pub(crate) fn cursor_monitor_geometry(
app: &AppHandle<Wry>,
point: PhysicalPosition<f64>,
) -> (f64, PhysicalPosition<f64>, PhysicalSize<f64>) {
let monitor = app
.monitor_from_point(point.x, point.y)
.ok()
.flatten()
.or_else(|| app.primary_monitor().ok().flatten());
match monitor {
Some(monitor) => {
let area = monitor.work_area();
(
monitor.scale_factor(),
PhysicalPosition::new(area.position.x as f64, area.position.y as f64),
PhysicalSize::new(area.size.width as f64, area.size.height as f64),
)
}
None => (
1.0,
PhysicalPosition::new(0.0, 0.0),
PhysicalSize::new(1920.0, 1080.0),
),
}
}
pub(crate) fn hide_tray_window(app: &AppHandle<Wry>) {
if let Some(window) = app.get_webview_window(TRAY_WINDOW) {
let _ = window.hide();
}
app.state::<TrayWindowState>().mark_hidden();
}
#[cfg(target_os = "macos")]
fn activate_app() {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
}
#[cfg(target_os = "macos")]
pub(crate) fn apply_tray_vibrancy(app: &AppHandle<Wry>) {
use window_vibrancy::{NSVisualEffectMaterial, NSVisualEffectState, apply_vibrancy};
if let Some(window) = app.get_webview_window(TRAY_WINDOW) {
let _ = apply_vibrancy(
&window,
NSVisualEffectMaterial::Popover,
Some(NSVisualEffectState::Active),
Some(12.0),
);
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn apply_tray_vibrancy(_app: &AppHandle<Wry>) {}
pub(crate) fn popup_position(
cursor: PhysicalPosition<f64>,
window_size: PhysicalSize<f64>,
work_position: PhysicalPosition<f64>,
work_size: PhysicalSize<f64>,
margin: f64,
offset_y: f64,
) -> PhysicalPosition<f64> {
let min_x = work_position.x + margin;
let min_y = work_position.y + margin;
let max_x = (work_position.x + work_size.width - window_size.width - margin).max(min_x);
let max_y = (work_position.y + work_size.height - window_size.height - margin).max(min_y);
PhysicalPosition::new(
(cursor.x - window_size.width / 2.0).clamp(min_x, max_x),
(cursor.y + offset_y).clamp(min_y, max_y),
)
}
fn tray_icon() -> tauri::Result<Image<'static>> {
Image::from_bytes(include_bytes!("../icons/tray.png"))
}
fn app_icon() -> tauri::Result<Image<'static>> {
Image::from_bytes(include_bytes!("../icons/icon.png"))
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
use crate::models::{ProviderKind, UsageSnapshot};
fn snapshot(status: SnapshotStatus) -> UsageSnapshot {
UsageSnapshot {
account_id: "account".to_string(),
provider: ProviderKind::OpenRouter,
label: "OpenRouter".to_string(),
status,
email: None,
subscription: None,
usage_buckets: Vec::new(),
quota: None,
message: None,
fetched_at: Utc::now(),
}
}
#[test]
fn summary_promotes_errors_to_critical() {
let summary = summarize(&[
snapshot(SnapshotStatus::Healthy),
snapshot(SnapshotStatus::Error),
]);
assert_eq!(summary.status, SnapshotStatus::Exhausted);
assert_eq!(summary.critical_count, 1);
}
#[test]
fn summary_reports_empty_state() {
let summary = summarize(&[]);
assert_eq!(summary.status, SnapshotStatus::NotConfigured);
}
#[test]
fn summary_reports_stale_when_cached_data_is_used() {
let summary = summarize(&[snapshot(SnapshotStatus::Stale)]);
assert_eq!(summary.status, SnapshotStatus::Stale);
assert_eq!(summary.label, "Burnrate: data is stale");
}
#[test]
fn tray_icon_loads_packaged_asset() {
let icon = tray_icon().expect("tray icon should decode");
assert_eq!(icon.width(), 32);
assert_eq!(icon.height(), 32);
}
#[test]
fn app_icon_loads_packaged_asset() {
let icon = app_icon().expect("app icon should decode");
assert!(icon.width() >= 128);
assert!(icon.height() >= 128);
}
#[cfg(target_os = "macos")]
#[test]
fn detects_app_bundle_versus_bare_binary() {
assert!(path_is_in_app_bundle(
"/Applications/Burnrate.app/Contents/MacOS/burnrate"
));
assert!(!path_is_in_app_bundle(
"/Users/dev/Projects/burnrate/target/release/burnrate"
));
assert!(!path_is_in_app_bundle("/usr/local/bin/burnrate"));
}
#[test]
fn popup_position_clamps_to_work_area() {
let size = PhysicalSize::new(380.0, 520.0);
let work_pos = PhysicalPosition::new(0.0, 0.0);
let work_size = PhysicalSize::new(1024.0, 768.0);
let position = popup_position(
PhysicalPosition::new(20.0, -40.0),
size,
work_pos,
work_size,
8.0,
12.0,
);
assert_eq!(position.x, 8.0);
assert_eq!(position.y, 8.0);
let position = popup_position(
PhysicalPosition::new(1000.0, 760.0),
size,
work_pos,
work_size,
8.0,
12.0,
);
assert_eq!(position.x, 636.0);
assert_eq!(position.y, 240.0);
}
#[test]
fn popup_position_anchors_to_a_secondary_monitor() {
let size = PhysicalSize::new(360.0, 440.0);
let work_pos = PhysicalPosition::new(1440.0, 0.0);
let work_size = PhysicalSize::new(1440.0, 900.0);
let position = popup_position(
PhysicalPosition::new(1700.0, 100.0),
size,
work_pos,
work_size,
8.0,
12.0,
);
assert_eq!(position.x, 1520.0);
assert_eq!(position.y, 112.0);
assert!(position.x >= 1448.0, "stays on the secondary monitor");
}
#[test]
fn install_removes_existing_tray_before_rebuild() {
let src = include_str!("tray.rs");
let remove_pos = src
.find("remove_tray_by_id(TRAY_ID)")
.expect("tray rebuild should remove the previous tray by id");
let build_pos = src
.find("TrayIconBuilder::with_id(TRAY_ID)")
.expect("tray rebuild should build the tray by id");
assert!(remove_pos < build_pos);
}
#[test]
fn suppresses_reopen_within_guard_window() {
let now = Instant::now();
let guard = Duration::from_millis(250);
assert!(should_suppress_show(
now - Duration::from_millis(50),
now,
guard
));
assert!(!should_suppress_show(
now - Duration::from_millis(300),
now,
guard
));
assert!(!should_suppress_show(now - guard, now, guard));
}
#[test]
fn clamp_tray_height_fits_content_within_work_area() {
let margin = 8.0;
assert_eq!(clamp_tray_height(50.0, 0.0, 1000.0, margin, 200.0), 200.0);
assert_eq!(clamp_tray_height(500.4, 2.0, 1000.0, margin, 200.0), 503.0);
assert_eq!(
clamp_tray_height(5000.0, 0.0, 1000.0, margin, 200.0),
1000.0 - 2.0 * margin
);
assert_eq!(clamp_tray_height(500.0, 0.0, 10.0, margin, 200.0), 1.0);
}
#[test]
fn popup_position_keeps_tall_window_on_screen() {
let size = PhysicalSize::new(360.0, 700.0);
let position = popup_position(
PhysicalPosition::new(500.0, 760.0),
size,
PhysicalPosition::new(0.0, 0.0),
PhysicalSize::new(1024.0, 768.0),
8.0,
12.0,
);
assert_eq!(position.y, 768.0 - 700.0 - 8.0);
assert!(position.y >= 8.0);
assert_eq!(position.x, 320.0);
}
}