//! `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) //! //! `withGlobalTauri` is on in tauri.conf.json and load-bearing: the front-end //! is plain ES modules with no bundler, so a bare specifier such as //! "@tauri-apps/api/core" cannot resolve in the webview. It does not error //! loudly — the script silently fails to load and the window renders its //! static HTML forever, which is indistinguishable from a daemon that never //! answered. The API comes off `window.__TAURI__` instead. //! //! A background watcher polls the daemon once a second: it keeps the tray in //! step with daemon-side state (realtime threat → red, scan in flight → //! amber) and fires a desktop notification for every *new* critical event //! the daemon logs (quarantine, ransomware alarm, rootkit finding). use hound_api::{ Client, Event, RealtimeStatus, RootkitScan, ScanResult, Settings, Status, UpdateResult, QuarantineEntry, }; use serde_json::json; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; /// Whether we have already explained that closing the window is not quitting. static TRAY_HINT_SHOWN: AtomicBool = AtomicBool::new(false); /// The two window-behaviour settings, cached from the daemon by the watcher /// thread. A window-close handler must answer immediately — it cannot make a /// socket round-trip while the user is watching the window not close — so /// these mirror `close_to_tray` and `confirm_quit`, and default to the safe /// answer if the daemon has not been reached yet. /// True when the user asked for a single-tone tray glyph instead of the /// colour state ladder. static MONOCHROME: AtomicBool = AtomicBool::new(false); /// The resolved theme, used only to pick which monochrome tone to draw. static THEME_IS_DARK: AtomicBool = AtomicBool::new(true); static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(true); static CONFIRM_QUIT: AtomicBool = AtomicBool::new(true); use tauri::image::Image; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager, State}; use tauri_plugin_notification::NotificationExt as _; type R = anyhow::Result; const TRAY_ID: &str = "hound-tray"; const POLL_SECS: u64 = 1; /// Shared between the `scan` command (sets it) and the tray watcher (reads /// it) so the sentinel shows "scanning" while an on-demand scan is running. static SCANNING: AtomicBool = AtomicBool::new(false); /// States the tray can render. Anything unknown falls back to `protected`. /// Tray states, in the order a viewer would rank their urgency. "attention" /// is the persistent one — an update waiting, or definitions going stale — /// as distinct from "scanning", which lasts seconds and which the user /// started themselves. They are both amber; attention is the deeper shade, /// so the two are distinguishable side by side. const STATES: [&str; 5] = ["protected", "scanning", "threat", "paused", "attention"]; /// The four preloaded state icons, managed so tray swaps never hit disk. #[derive(Clone, 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()) } /// Expand a leading `~` against the running user's home. /// /// The tray menu and the Scan Home button both send the literal string "~". /// A shell would have expanded it; nothing here does, so the daemon was being /// asked to scan a directory of that name. It failed silently until the /// per-peer readability check started reporting refusals out loud. fn expand_home(path: &str) -> String { let Some(home) = std::env::var_os("HOME") else { return path.to_string(); }; let home = home.to_string_lossy(); match path { "~" => home.into_owned(), p if p.starts_with("~/") => format!("{home}/{}", &p[2..]), p => p.to_string(), } } #[tauri::command] async fn scan(path: String, recursive: bool) -> Result { let c = client(); let path = expand_home(&path); SCANNING.store(true, Ordering::Relaxed); let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive)) .await .map_err(|e| e.to_string())?; SCANNING.store(false, Ordering::Relaxed); r.map_err(|e| e.to_string()) } #[tauri::command] async fn update() -> Result { let c = client(); let first = tauri::async_runtime::spawn_blocking(move || c.update()) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("update", None).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(e.to_string()), } } #[tauri::command] async fn settings() -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.settings()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } /// Relaunch after the package has been replaced. /// /// There is no state in this window worth preserving — it is a view over the /// daemon, and everything it shows is refetched a second later. So this /// restarts rather than asking, and says why afterwards. fn restart_into_new_version(app: &tauri::AppHandle, installed: &str) { // Guard against a restart loop: if the new binary somehow still reports // the old version, one attempt is enough to notice something is wrong. static RESTARTED: AtomicBool = AtomicBool::new(false); if RESTARTED.swap(true, Ordering::Relaxed) { return; } let _ = app .notification() .builder() .title(format!("Hound updated to {installed}")) .body("Reopening to finish.") .show(); // Give the notification a moment to reach the daemon before this process // goes away, or it never appears. std::thread::sleep(std::time::Duration::from_millis(400)); app.restart(); } // ── Assisted update ──────────────────────────────────────────────────────── /// Install a published release. /// /// This used to download, hash-check and stage the package itself. It no /// longer does: `hound update` performs exactly those steps, as root, and is /// the path covered by tests. Two implementations of a privileged install is /// one more than anybody wants to keep correct — so the app asks polkit to /// run the real one and reports what happened. /// /// Hound still does not replace its own binary. `hound update` hands the /// verified package to apt, with the user present and authenticating. fn install_update(app: &tauri::AppHandle) { let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else { return; }; let proceed = app .dialog() .message(format!( "Hound {} is available.\n\nThe package will be downloaded, checked against its \ signed manifest, and installed by your system package manager. You will be \ asked to authenticate.", pending.version )) .title(format!("Install Hound {}?", pending.version)) .buttons(MessageDialogButtons::OkCancelCustom( "Install".into(), "Not now".into(), )) .blocking_show(); if !proceed { return; } let notify_result = |title: String, body: &str| { let _ = app.notification().builder().title(title).body(body).show(); }; match std::process::Command::new("pkexec") .args(["/usr/bin/hound", "update", "--yes"]) .output() { Ok(out) if out.status.success() => { notify_result( format!("Hound {} installed", pending.version), "The new version is active.", ); } // pkexec's own exit codes: 126 dismissed, 127 not authorised. Neither // is a failure worth an alarming notification. Ok(out) if matches!(out.status.code(), Some(126) | Some(127)) => {} Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); notify_result( "Update failed".into(), if err.trim().is_empty() { "The package manager refused the update." } else { err.trim() }, ); } Err(e) => notify_result("Update failed".into(), &format!("Could not start the installer: {e}")), } } // ── Elevation ─────────────────────────────────────────────────────────────── /// Run one daemon method as root, via polkit. /// /// The daemon refuses everything that writes unless the caller is uid 0 — the /// app runs as you, so those requests need elevating. `pkexec` asks polkit to /// authenticate the person (password, or fingerprint on hardware that has /// one) and then runs `hound admin-rpc` as root, which forwards a single /// request and prints the response. See packaging/polkit/. /// /// pkexec exits 126 when the user dismisses the dialog and 127 when /// authorisation is refused; both are ordinary outcomes, not errors to /// apologise for. fn elevate(method: &str, params: Option) -> Result { use std::io::Write as _; use std::process::{Command, Stdio}; let mut req = json!({ "jsonrpc": "2.0", "id": 1, "method": method }); if let Some(p) = params { req["params"] = p; } let mut child = Command::new("pkexec") .arg("/usr/bin/hound") .arg("admin-rpc") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("cannot start pkexec: {e}"))?; child .stdin .as_mut() .ok_or("pkexec has no stdin")? .write_all(req.to_string().as_bytes()) .map_err(|e| format!("sending the request: {e}"))?; let out = child .wait_with_output() .map_err(|e| format!("waiting for pkexec: {e}"))?; match out.status.code() { Some(0) => serde_json::from_slice(&out.stdout) .map_err(|e| format!("decoding the response: {e}")), Some(126) => Err("Cancelled.".into()), Some(127) => Err("Not authorised to make this change.".into()), _ => { let err = String::from_utf8_lossy(&out.stderr); let err = err.trim(); Err(if err.is_empty() { "The privileged helper failed.".into() } else { err.to_string() }) } } } /// True when the daemon refused us for want of privileges, as opposed to /// anything else going wrong. Only that case is worth a password prompt. fn needs_root(e: &str) -> bool { e.contains("requires administrator privileges") } #[tauri::command] async fn set_settings(s: Settings) -> Result { let c = client(); let for_retry = s.clone(); let first = tauri::async_runtime::spawn_blocking(move || c.set_settings(&s)) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("settings.set", Some(serde_json::to_value(&for_retry).unwrap_or_default())) .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(e.to_string()), } } #[tauri::command] async fn events(limit: u32) -> Result, String> { let c = client(); tauri::async_runtime::spawn_blocking(move || c.events(limit)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn clear_events() -> Result { let c = client(); let first = tauri::async_runtime::spawn_blocking(move || c.clear_events()) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("events.clear", None) .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(e.to_string()), } } #[tauri::command] async fn quarantine_list() -> Result, String> { let c = client(); tauri::async_runtime::spawn_blocking(move || c.quarantine_list()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn quarantine_add(path: String, virus: String) -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.quarantine_add(&path, &virus)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn quarantine_restore(id: String) -> Result { let c = client(); let for_retry = id.clone(); let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id)) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("quarantine.restore", Some(json!({ "id": for_retry }))) .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(e.to_string()), } } #[tauri::command] async fn quarantine_remove(id: String) -> Result { let c = client(); let for_retry = id.clone(); let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id)) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("quarantine.remove", Some(json!({ "id": for_retry }))) .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(e.to_string()), } } #[tauri::command] async fn rootkit_scan() -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.rootkit_scan()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn realtime_status() -> Result { let c = client(); tauri::async_runtime::spawn_blocking(move || c.realtime_status()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } #[tauri::command] async fn realtime_set_enabled(enabled: bool) -> Result { let c = client(); let first = tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled)) .await .map_err(|e| e.to_string())?; match first { Ok(v) => Ok(v), Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { elevate("realtime.set_enabled", Some(json!({ "enabled": enabled }))) .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) }) .await .map_err(|e| e.to_string())?, Err(e) => Err(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). The backend /// watcher also calls the same routine as daemon state changes underneath. fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) { let state = if STATES.contains(&state) { state } else { "protected" }; // Monochrome deliberately drops the state colour — that is the whole // point of asking for it. The tooltip still says which state we are in, // so the information is not lost, only the colour is. let state_key = if MONOCHROME.load(Ordering::Relaxed) { if THEME_IS_DARK.load(Ordering::Relaxed) { "mono-light" } else { "mono-dark" } } else { state }; let img = match icons.0.get(state_key) { Some(i) => i.clone(), None => icons .0 .get("protected") .cloned() .expect("protected icon always loaded"), }; if let Ok(mut cur) = CURRENT_STATE.lock() { *cur = state.to_string(); } if let Some(tray) = app.tray_by_id(TRAY_ID) { let _ = tray.set_icon(Some(img)); let _ = tray.set_tooltip(Some(tooltip_for(state))); } } /// Switch the tray between the colour ladder and a single-tone glyph. /// /// Appearance is a per-user preference, so it does not live in the daemon's /// settings — those require root to write, and changing your icon style /// should not ask for a password. The webview owns the preference and tells /// us; we repaint immediately rather than waiting for the next poll, because /// a toggle that takes a second to visibly do anything reads as broken. #[tauri::command] fn set_tray_style(app: tauri::AppHandle, icons: State<'_, TrayIcons>, monochrome: bool) { MONOCHROME.store(monochrome, Ordering::Relaxed); apply_state(&app, &icons, &CURRENT_STATE.lock().map(|g| g.clone()).unwrap_or_default()); } /// The webview resolved "follow system" to an actual theme. /// /// Only the page can answer this — prefers-color-scheme lives in the webview, /// and there is no portable way for the Rust side to ask the desktop. So the /// frontend decides and tells us, which also keeps one place deciding which /// theme is showing. #[tauri::command] fn set_theme_resolved(app: tauri::AppHandle, icons: State<'_, TrayIcons>, dark: bool) { THEME_IS_DARK.store(dark, Ordering::Relaxed); // The monochrome tone follows the theme, so repaint now rather than a // second from now. apply_state(&app, &icons, &CURRENT_STATE.lock().map(|g| g.clone()).unwrap_or_default()); } #[tauri::command] fn set_state( app: tauri::AppHandle, icons: State<'_, TrayIcons>, state: String, ) -> Result<(), String> { apply_state(&app, &icons, &state); Ok(()) } /// A release the daemon has verified as newer, cached so the menu handler /// does not have to make a round-trip while the user is waiting on a click. #[derive(Clone)] struct PendingUpdate { version: String, deb_url: String, sha256: String, notes_url: String, } static PENDING_UPDATE: std::sync::Mutex> = std::sync::Mutex::new(None); /// The version we have already told the user about, so a release is announced /// once rather than on every poll. static ANNOUNCED_VERSION: std::sync::Mutex = std::sync::Mutex::new(String::new()); /// The tray state last painted, so an appearance change can repaint the same /// state instead of guessing one and briefly showing the wrong colour. static CURRENT_STATE: std::sync::Mutex = std::sync::Mutex::new(String::new()); /// The "Install update" entry, kept so the watcher can retitle and /// enable it when the daemon learns a release exists. struct UpdateMenuItem(MenuItem); /// The daemon's one-line reason for the amber state, so the tooltip can say /// what needs attention rather than only that something does. static ATTENTION_REASON: std::sync::Mutex = std::sync::Mutex::new(String::new()); fn tooltip_for(state: &str) -> String { match state { "threat" => "Hound — threat found".into(), "scanning" => "Hound — scanning…".into(), "paused" => "Hound — paused".into(), "attention" => { let why = ATTENTION_REASON.lock().map(|g| g.clone()).unwrap_or_default(); if why.is_empty() { "Hound — needs attention".into() } else { format!("Hound — {why}") } } _ => "Hound — protected".into(), } } // ── Tray watcher: state sync + critical-event notifications ───────────────── /// Poll the daemon; reflect daemon-side state in the tray and notify on new /// critical events the frontend hasn't already surfaced. Runs as a plain /// OS thread: one blocking client round-trip per tick is exactly what this /// wants, and it keeps the command side free of scheduler gymnastics. fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) { std::thread::spawn(move || { let mut last_seen_id: u64 = 0; loop { std::thread::sleep(std::time::Duration::from_secs(POLL_SECS)); let c = client(); if c.status().is_err() { continue; // daemon restarting — skip the tick } let paused = match c.settings() { Ok(s) => { CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed); CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed); // Appearance is deliberately NOT read from here: it is a // per-user preference owned by the webview, and taking it // from the daemon would make one user's choice everyone's. s.paused } Err(_) => false, }; // Derive tray state: a fresh critical event wins, then an // in-flight scan, then paused, then protected. let mut state = "protected"; if let Ok(evts) = c.events(20) { if let Some(latest) = evts.first() { if latest.severity == "critical" && latest.id > last_seen_id { last_seen_id = latest.id; state = "threat"; notify(&app, latest); } } } if state == "protected" { if SCANNING.load(Ordering::Relaxed) { state = "scanning"; } else if paused { state = "paused"; } } // The package can be replaced underneath a running app — by the // tray item, by `sudo hound update`, or by apt directly. When it // is, this process is still the old binary showing the old // front-end, which looks exactly like an update that did nothing. // The daemon restarts itself on upgrade, so its version is the // authority on what is installed. if let Ok(st) = c.status() { if !st.daemon_version.is_empty() && st.daemon_version != env!("CARGO_PKG_VERSION") { restart_into_new_version(&app, &st.daemon_version); } } // Amber for anything that needs the user but is not a threat: a // published release, or definitions going stale. It ranks below // a threat and below an in-flight scan, and above plain // protected — it is a nudge, not an alarm. if let Ok(st) = c.status() { let f = &st.freshness; *ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone(); if !f.update_version.is_empty() { // Once per version, not once per poll — this loop runs // every second and nobody wants that notification 3,600 // times an hour. let mut announced = ANNOUNCED_VERSION.lock().expect("announce lock"); if *announced != f.update_version { *announced = f.update_version.clone(); let _ = app .notification() .builder() .title(format!("Hound {} is available", f.update_version)) .body("Open the Hound tray menu to install it.") .show(); } drop(announced); *PENDING_UPDATE.lock().expect("update lock") = Some(PendingUpdate { version: f.update_version.clone(), deb_url: f.update_deb_url.clone(), sha256: f.update_deb_sha256.clone(), notes_url: f.update_notes_url.clone(), }); } if let Some(item) = app.try_state::() { if f.update_version.is_empty() { let _ = item.0.set_text("No update available"); let _ = item.0.set_enabled(false); } else { let _ = item.0.set_text(format!( "Update available — install Hound {}…", f.update_version )); let _ = item.0.set_enabled(true); } } if state == "protected" && f.wants_attention() { state = "attention"; } } apply_state(&app, &icons, state); } }); } fn notify(app: &tauri::AppHandle, ev: &Event) { let title = match ev.kind.as_str() { "quarantine" | "threat" => "Hound — threat quarantined", "ransomware" => "Hound — ransomware suspected", "rootkit" => "Hound — rootkit activity", _ => "Hound — alert", }; let notification = app.notification(); let _ = notification .builder() .title(title) .body(&ev.message) .show(); } // ── Icon resolution ───────────────────────────────────────────────────────── // Tray icons are compiled in rather than read from disk. They were being // loaded from a relative "icons/" path, which resolves only when the binary // runs from its build directory — installed to /usr/bin and launched from the // applications menu, the setup hook failed and the whole app panicked before // a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB // binary, and it makes the tray icon unable to be missing. const ICON_BYTES: [(&str, &[u8]); 7] = [ ("protected", include_bytes!("../icons/state-protected-22.png")), ("scanning", include_bytes!("../icons/state-scanning-22.png")), ("threat", include_bytes!("../icons/state-threat-22.png")), ("paused", include_bytes!("../icons/state-paused-22.png")), ("attention", include_bytes!("../icons/state-attention-22.png")), // Monochrome: one glyph, no state colour. Which tone depends on the // panel it sits in, and no portable way exists to ask a panel what // colour it is — so it follows the app's own resolved theme, on the // reasonable assumption that someone running a light desktop has a // light panel. ("mono-light", include_bytes!("../icons/state-mono-light-22.png")), ("mono-dark", include_bytes!("../icons/state-mono-dark-22.png")), ]; fn load_state_icons() -> R>> { let mut icons = HashMap::new(); for (state, bytes) in ICON_BYTES { let img = Image::from_bytes(bytes) .map_err(|e| anyhow::anyhow!("decoding the {state} tray icon: {e}"))?; icons.insert(state.to_string(), img); } Ok(icons) } // ── App ───────────────────────────────────────────────────────────────────── /// Refuse to start if this user already has Hound running. /// /// Two processes mean two tray icons, and the second one is a puzzle: it /// looks identical and does nothing, because the panel is showing an item /// registered by a process that is no longer answering. An advisory lock held /// for the lifetime of the process is the cheap, correct answer — the kernel /// releases it however we exit, including a crash, so a stale lock is not a /// state that can happen. fn claim_single_instance() -> Option { let dir = std::env::var("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir()); let path = dir.join("hound-gui.lock"); let file = std::fs::OpenOptions::new() .create(true) .write(true) .truncate(false) .open(&path) .ok()?; // SAFETY: flock on a file descriptor we own. LOCK_NB means this returns // rather than waiting if somebody else holds it. let rc = unsafe { libc::flock( std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX | libc::LOCK_NB, ) }; if rc == 0 { Some(file) } else { None } } /// Paths passed on the command line, from a file manager's context menu. /// /// Kept in a global rather than threaded through the builder because the /// front-end asks for them once it is ready to display results, and by then /// the argument vector is long gone. static SCAN_ON_START: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); #[tauri::command] fn take_scan_request() -> Vec { // Taken, not read: a second call must not rescan, or reopening the window // would start the scan again. SCAN_ON_START .lock() .map(|mut g| std::mem::take(&mut *g)) .unwrap_or_default() } pub fn run() { // "hound-gui --scan PATH…" is how a file manager's right-click arrives. let mut args = std::env::args().skip(1).peekable(); let mut wanted: Vec = Vec::new(); while let Some(a) = args.next() { match a.as_str() { "--scan" => wanted.extend(args.by_ref()), other if !other.starts_with('-') => wanted.push(other.to_string()), _ => {} } } // Held for the lifetime of the process; dropping it releases the lock. let Some(_instance_lock) = claim_single_instance() else { // Already running. Hand the request to the instance that owns the // window rather than refusing — a right-click that silently does // nothing because the app happens to be open is indefensible. if !wanted.is_empty() { let _ = handoff_scan(&wanted); } else { eprintln!("hound-gui is already running for this user"); } return; }; if !wanted.is_empty() { if let Ok(mut g) = SCAN_ON_START.lock() { *g = wanted; } } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ status, scan, update, settings, set_settings, events, clear_events, quarantine_list, quarantine_add, quarantine_restore, quarantine_remove, rootkit_scan, realtime_status, realtime_set_enabled, set_state, set_theme_resolved, set_tray_style, take_scan_request ]) .setup(|app| { let handle = app.handle().clone(); let icons = TrayIcons(load_state_icons()?); let initial_icon = icons .0 .get("protected") .expect("protected icon loaded") .clone(); let watcher_icons = icons.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>)?; // Two items that exist only when they mean something. A permanently // greyed "Install update" teaches people the menu is decorative; // these are enabled/disabled from the watcher as the daemon's // answer changes, and their labels carry the version. let install_item = MenuItem::with_id( &handle, "install-update", "No update available", false, 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, &install_item, &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" => { // The only route out of the app, and it asks // first: closing the window hides it instead, so // a user who means to quit has come here on // purpose and a user who clicked by accident has // not lost their protection silently. if !CONFIRM_QUIT.load(Ordering::Relaxed) { app.exit(0); return; } let app = app.clone(); std::thread::spawn(move || { let quit = app .dialog() .message( "Real-time protection will keep running in the \ background, but Hound's window and tray icon will \ close.", ) .title("Quit Hound?") .buttons(MessageDialogButtons::OkCancelCustom( "Quit".into(), "Keep running".into(), )) .blocking_show(); if 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" }), ); } "install-update" => { let app = app.clone(); std::thread::spawn(move || install_update(&app)); } "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)?; app.manage(UpdateMenuItem(install_item.clone())); start_watcher(handle.clone(), watcher_icons); start_handoff_watcher(handle.clone()); Ok(()) }) .on_window_event(|window, event| { // Closing the window hides it; it never exits. An antivirus that // stops protecting you because you clicked the X is not an // antivirus. Quitting is deliberate, from the tray menu, and // confirmed there. if let tauri::WindowEvent::CloseRequested { api, .. } = event { if !CLOSE_TO_TRAY.load(Ordering::Relaxed) { return; // the user turned this off in Settings } api.prevent_close(); let _ = window.hide(); // Say so once, so the window vanishing does not read as a // crash. After that it is expected behaviour and silence is // the right amount of noise. if !TRAY_HINT_SHOWN.swap(true, Ordering::Relaxed) { let _ = window .app_handle() .notification() .builder() .title("Hound is still running") .body("Protection continues in the background. Open it again from the tray icon.") .show(); } } }) .run(tauri::generate_context!()) .expect("error while running Hound"); } /// Notice scan requests dropped by a second launch and act on them. /// /// Polling a filename is unglamorous and exactly right here: the event is /// rare, a missed one is retried a second later, and there is no daemon, /// socket or bus name to keep alive. fn start_handoff_watcher(app: tauri::AppHandle) { std::thread::spawn(move || { let dir = std::env::var("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir()); let request = dir.join("hound-gui.scan"); loop { std::thread::sleep(std::time::Duration::from_secs(1)); let Ok(body) = std::fs::read_to_string(&request) else { continue; }; let _ = std::fs::remove_file(&request); let paths: Vec = body .lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(str::to_string) .collect(); if paths.is_empty() { continue; } if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); let _ = w.emit("scan-request", paths); } } }); } /// Pass a scan request to the instance that already owns the window. /// /// A line per path in a file the running instance watches. A socket would be /// tidier; a file is one syscall, survives the reader being busy, and cannot /// leave a half-written request behind because the rename is atomic. fn handoff_scan(paths: &[String]) -> std::io::Result<()> { let dir = std::env::var("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir()); let tmp = dir.join("hound-gui.scan.tmp"); std::fs::write(&tmp, paths.join("\n"))?; std::fs::rename(tmp, dir.join("hound-gui.scan")) } fn main() { run() }