mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-15 07:00:32 +02:00
263 lines
9.7 KiB
Rust
263 lines
9.7 KiB
Rust
//! Cross-platform tray / menu-bar background lifecycle.
|
|
//!
|
|
//! Provides:
|
|
//! - System tray icon with Show / Hide / Quit menu
|
|
//! - Window close interception (hide to background instead of quit)
|
|
//! - Restore from tray on menu action or tray icon click
|
|
//!
|
|
//! **Close behavior is conditional on tray availability:**
|
|
//! - If tray setup succeeds: close hides to background (tray can restore/quit)
|
|
//! - If tray setup fails: close behaves normally (app exits)
|
|
//! - The user is never stranded with a hidden app and no restore path.
|
|
//!
|
|
//! Platform behavior:
|
|
//! - **Windows**: Tray icon in system notification area. Left-click opens
|
|
//! the menu; "Show ShadowBroker" restores the window. "Quit" exits fully.
|
|
//! - **macOS**: Menu bar icon. Click opens menu (macOS convention).
|
|
//! - **Linux**: Appindicator tray icon (requires libayatana-appindicator3).
|
|
//! Click opens menu. Behavior depends on the desktop environment —
|
|
//! not all DEs render appindicator icons identically.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use tauri::image::Image;
|
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
|
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
|
use tauri::{AppHandle, CloseRequestApi, Manager};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tray menu item IDs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub const MENU_ID_SHOW: &str = "sb_tray_show";
|
|
pub const MENU_ID_HIDE: &str = "sb_tray_hide";
|
|
pub const MENU_ID_QUIT: &str = "sb_tray_quit";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tray icon generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ICON_SIZE: u32 = 32;
|
|
|
|
/// Generate a minimal 32x32 RGBA tray icon: a filled teal circle on a
|
|
/// transparent background. Avoids requiring external asset files.
|
|
pub fn generate_tray_icon_rgba() -> (Vec<u8>, u32, u32) {
|
|
let size = ICON_SIZE;
|
|
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
|
let center = size as f32 / 2.0;
|
|
let radius = center - 2.0;
|
|
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let dx = x as f32 - center;
|
|
let dy = y as f32 - center;
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
let idx = ((y * size + x) * 4) as usize;
|
|
|
|
if dist <= radius {
|
|
// Teal/green brand accent
|
|
rgba[idx] = 0x1B; // R
|
|
rgba[idx + 1] = 0xC4; // G
|
|
rgba[idx + 2] = 0x9D; // B
|
|
rgba[idx + 3] = 0xFF; // A
|
|
}
|
|
// Transparent otherwise (already zeroed)
|
|
}
|
|
}
|
|
(rgba, size, size)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tray readiness state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Shared atomic flag indicating whether the tray icon was successfully set up.
|
|
/// Used by `should_hide_on_close()` to decide whether close should hide to
|
|
/// background (tray alive → restore path exists) or quit normally (no tray →
|
|
/// hiding would strand the user).
|
|
pub static TRAY_READY: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Returns `true` if the tray icon is live and the app should hide on close
|
|
/// instead of quitting.
|
|
pub fn should_hide_on_close() -> bool {
|
|
TRAY_READY.load(Ordering::Relaxed)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tray setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Set up the system tray icon with a Show / Hide / Quit menu.
|
|
/// On success, returns the `TrayIcon` handle — the caller **must** retain it
|
|
/// for the lifetime of the app (dropping it may unregister the tray icon).
|
|
/// Also sets `TRAY_READY` to `true`.
|
|
///
|
|
/// On failure (e.g. missing appindicator on Linux), returns an error string
|
|
/// and `TRAY_READY` remains `false`.
|
|
pub fn setup_tray(app: &AppHandle) -> Result<TrayIcon, String> {
|
|
let show_item = MenuItem::with_id(app, MENU_ID_SHOW, "Show ShadowBroker", true, None::<&str>)
|
|
.map_err(|e| format!("tray_menu_show:{e}"))?;
|
|
let hide_item = MenuItem::with_id(app, MENU_ID_HIDE, "Hide to Background", true, None::<&str>)
|
|
.map_err(|e| format!("tray_menu_hide:{e}"))?;
|
|
let separator =
|
|
PredefinedMenuItem::separator(app).map_err(|e| format!("tray_menu_separator:{e}"))?;
|
|
let quit_item = MenuItem::with_id(app, MENU_ID_QUIT, "Quit ShadowBroker", true, None::<&str>)
|
|
.map_err(|e| format!("tray_menu_quit:{e}"))?;
|
|
|
|
let menu = Menu::with_items(app, &[&show_item, &hide_item, &separator, &quit_item])
|
|
.map_err(|e| format!("tray_menu_build:{e}"))?;
|
|
|
|
let (rgba, width, height) = generate_tray_icon_rgba();
|
|
let icon = Image::new_owned(rgba, width, height);
|
|
|
|
let tray = TrayIconBuilder::new()
|
|
.icon(icon)
|
|
.tooltip("ShadowBroker")
|
|
.menu(&menu)
|
|
.show_menu_on_left_click(true)
|
|
.on_menu_event(|app, event| {
|
|
handle_tray_menu_event(app, event.id.as_ref());
|
|
})
|
|
.on_tray_icon_event(|tray, event| {
|
|
// Double-click left button: show window (cross-platform convenience)
|
|
if let TrayIconEvent::DoubleClick {
|
|
button: MouseButton::Left,
|
|
..
|
|
} = event
|
|
{
|
|
show_main_window(tray.app_handle());
|
|
}
|
|
})
|
|
.build(app)
|
|
.map_err(|e| format!("tray_build:{e}"))?;
|
|
|
|
TRAY_READY.store(true, Ordering::Relaxed);
|
|
Ok(tray)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Menu event handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn handle_tray_menu_event(app: &AppHandle, id: &str) {
|
|
match id {
|
|
MENU_ID_SHOW => show_main_window(app),
|
|
MENU_ID_HIDE => hide_main_window(app),
|
|
MENU_ID_QUIT => app.exit(0),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Window lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Show, unminimize, and focus the main window.
|
|
pub fn show_main_window(app: &AppHandle) {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.show();
|
|
let _ = window.unminimize();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
|
|
/// Hide the main window to the background.
|
|
pub fn hide_main_window(app: &AppHandle) {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.hide();
|
|
}
|
|
}
|
|
|
|
/// Handle a window close request. Behavior depends on tray availability:
|
|
/// - **Tray alive** (`should_hide_on_close()` = true): prevent close, hide to
|
|
/// background. The user can restore via tray menu or quit via "Quit ShadowBroker".
|
|
/// - **No tray** (`should_hide_on_close()` = false): allow the close to proceed
|
|
/// normally so the app exits. Never strand the user with a hidden window and
|
|
/// no visible restore path.
|
|
pub fn handle_close_requested(window: &tauri::Window, api: &CloseRequestApi) {
|
|
if window.label() == "main" && should_hide_on_close() {
|
|
api.prevent_close();
|
|
let _ = window.hide();
|
|
}
|
|
// If tray is not ready or window is not "main", close proceeds normally.
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn icon_rgba_has_correct_dimensions() {
|
|
let (rgba, w, h) = generate_tray_icon_rgba();
|
|
assert_eq!(w, ICON_SIZE);
|
|
assert_eq!(h, ICON_SIZE);
|
|
assert_eq!(rgba.len(), (w * h * 4) as usize);
|
|
}
|
|
|
|
#[test]
|
|
fn icon_center_pixel_is_opaque_teal() {
|
|
let (rgba, w, _h) = generate_tray_icon_rgba();
|
|
let center = w / 2;
|
|
let idx = ((center * w + center) * 4) as usize;
|
|
// R=0x1B, G=0xC4, B=0x9D, A=0xFF
|
|
assert_eq!(rgba[idx], 0x1B);
|
|
assert_eq!(rgba[idx + 1], 0xC4);
|
|
assert_eq!(rgba[idx + 2], 0x9D);
|
|
assert_eq!(rgba[idx + 3], 0xFF);
|
|
}
|
|
|
|
#[test]
|
|
fn icon_corner_pixel_is_transparent() {
|
|
let (rgba, _w, _h) = generate_tray_icon_rgba();
|
|
// Top-left corner (0,0) should be transparent
|
|
assert_eq!(rgba[0], 0); // R
|
|
assert_eq!(rgba[1], 0); // G
|
|
assert_eq!(rgba[2], 0); // B
|
|
assert_eq!(rgba[3], 0); // A
|
|
}
|
|
|
|
#[test]
|
|
fn menu_ids_are_distinct() {
|
|
assert_ne!(MENU_ID_SHOW, MENU_ID_HIDE);
|
|
assert_ne!(MENU_ID_SHOW, MENU_ID_QUIT);
|
|
assert_ne!(MENU_ID_HIDE, MENU_ID_QUIT);
|
|
}
|
|
|
|
#[test]
|
|
fn menu_ids_are_namespaced() {
|
|
// All IDs should be prefixed to avoid collisions
|
|
assert!(MENU_ID_SHOW.starts_with("sb_tray_"));
|
|
assert!(MENU_ID_HIDE.starts_with("sb_tray_"));
|
|
assert!(MENU_ID_QUIT.starts_with("sb_tray_"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_hide_reflects_tray_ready_state() {
|
|
// Reset to known state
|
|
TRAY_READY.store(false, Ordering::Relaxed);
|
|
assert!(
|
|
!should_hide_on_close(),
|
|
"should not hide when tray is not ready"
|
|
);
|
|
|
|
TRAY_READY.store(true, Ordering::Relaxed);
|
|
assert!(should_hide_on_close(), "should hide when tray is ready");
|
|
|
|
// Clean up for other tests
|
|
TRAY_READY.store(false, Ordering::Relaxed);
|
|
}
|
|
|
|
#[test]
|
|
fn tray_ready_default_is_false() {
|
|
// TRAY_READY is initialized to false — if no tray setup runs,
|
|
// close should behave normally (no stranding).
|
|
// Note: other tests may have mutated TRAY_READY, so we verify
|
|
// the semantic contract via should_hide_on_close after explicit reset.
|
|
TRAY_READY.store(false, Ordering::Relaxed);
|
|
assert!(!should_hide_on_close());
|
|
}
|
|
}
|