//! `hound-gui` — the Tauri 2 desktop app for Hound Antivirus. //! //! The window is a thin view over the same `houndd` Unix socket the CLI uses. //! The system-tray sentinel swaps between the four state icons: //! //! protected (green) / scanning (amber) / threat (red) / paused (gray) use hound_api::{Client, ScanResult, Status, UpdateResult}; use serde_json::json; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager, State}; type R = anyhow::Result; const TRAY_ID: &str = "hound-tray"; /// States the tray can render. Anything unknown falls back to `protected`. const STATES: [&str; 4] = ["protected", "scanning", "threat", "paused"]; /// The four preloaded state icons, managed so tray swaps never hit disk. #[derive(Default)] struct TrayIcons(HashMap>); fn client() -> Client { Client::default_path() } // ── Commands (window → daemon) ────────────────────────────────────────────── #[tauri::command] async fn status() -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.status()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn scan(path: String, recursive: bool) -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn update() -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.update()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } /// Swap the tray icon + tooltip. The window calls this to keep the sentinel /// in step with what the user is doing (e.g. it starts a scan). #[tauri::command] fn set_state( app: tauri::AppHandle, icons: State<'_, TrayIcons>, state: String, ) -> Result<(), String> { let state = if STATES.contains(&state.as_str()) { state } else { "protected".into() }; let img = icons.0.get(&state).cloned().unwrap_or_else(|| { icons .0 .get("protected") .cloned() .expect("protected icon always loaded") }); if let Some(tray) = app.tray_by_id(TRAY_ID) { let _ = tray.set_icon(Some(img)); let _ = tray.set_tooltip(Some(tooltip_for(&state))); } Ok(()) } fn tooltip_for(state: &str) -> String { match state { "threat" => "Hound — threat found".into(), "scanning" => "Hound — scanning…".into(), "paused" => "Hound — paused".into(), _ => "Hound — protected".into(), } } // ── Icon resolution ───────────────────────────────────────────────────────── fn icon_dir(app: &tauri::AppHandle) -> PathBuf { // Packaged: resources dir. Dev: the repo's assets/icons. if let Ok(dir) = app.path().resource_dir() { let d = dir.join("icons"); if d.join("state-protected-22.png").exists() { return d; } } for candidate in [ PathBuf::from("icons"), PathBuf::from("../src-tauri/icons"), PathBuf::from("../assets/icons"), ] { if candidate.join("state-protected-22.png").exists() { return candidate; } } PathBuf::from("icons") } fn load_state_icons(app: &tauri::AppHandle) -> R>> { let dir = icon_dir(app); let mut icons = HashMap::new(); for state in STATES { let path = dir.join(format!("state-{state}-22.png")); icons.insert(state.to_string(), load_icon(&path)?); } Ok(icons) } /// Load a PNG tray icon; Tauri decodes and converts to RGBA for us. fn load_icon(path: &Path) -> R> { Image::from_path(path).map_err(|e| anyhow::anyhow!("loading tray icon {}: {e}", path.display())) } // ── App ───────────────────────────────────────────────────────────────────── pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![status, scan, update, set_state]) .setup(|app| { let handle = app.handle().clone(); let icons = TrayIcons(load_state_icons(&handle)?); let initial_icon = icons .0 .get("protected") .expect("protected icon loaded") .clone(); app.manage(icons); let open = MenuItem::with_id(&handle, "open", "Open Hound", true, None::<&str>)?; let scan_home = MenuItem::with_id(&handle, "scan-home", "Scan Home Folder", true, None::<&str>)?; let scan_downloads = MenuItem::with_id( &handle, "scan-downloads", "Scan Downloads", true, None::<&str>, )?; let update_sig = MenuItem::with_id(&handle, "update", "Update Signatures", true, None::<&str>)?; let sep = PredefinedMenuItem::separator(&handle)?; let quit = MenuItem::with_id(&handle, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items( &handle, &[&open, &scan_home, &scan_downloads, &update_sig, &sep, &quit], )?; let _ = TrayIconBuilder::with_id(TRAY_ID) .icon(initial_icon) .tooltip("Hound — protected") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| { let id = event.id().as_ref(); let window = match app.get_webview_window("main") { Some(w) => w, None => return, }; match id { "open" => { let _ = window.show(); let _ = window.set_focus(); } "quit" => app.exit(0), "scan-home" => { let _ = window.emit("tray-event", json!({ "action": "scan", "path": "~" })); } "scan-downloads" => { let _ = window.emit( "tray-event", json!({ "action": "scan", "path": "~/Downloads" }), ); } "update" => { let _ = window.emit("tray-event", json!({ "action": "update" })); } _ => {} } }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { .. } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } }) .build(&handle)?; Ok(()) }) .run(tauri::generate_context!()) .expect("error while running Hound"); } fn main() { run() }