Antivirus/gui/src-tauri/src/main.rs
johnmcafee a7871b5e27 Engine seam (ScanEngine trait) + Tauri GUI
ClamAV is a temporary dependency. Everything ClamAV-specific moves
behind a 4-method trait in crates/houndd/src/engine.rs:

    trait ScanEngine { name; probe; scan; update }
    struct ClamAvEngine   // today
    const ENGINE          // one-line flip when the native engine lands

- parse_clamscan() is now a pure fn with unit tests (OK/FOUND/INCOMPLETE,
  dedup, malformed lines)
- main.rs drops all clamscan/clamd/freshclam knowledge; Status reports
  engine='clamav' via the trait
- README documents the seam and the wire contract that stays stable

GUI (gui/, Tauri 2, standalone workspace, vanilla JS premium-dark shell):
- system-tray sentinel on the 4-state dog-head ladder (green/amber/red/gray)
  with tooltips; clicks open the window / scan / update
- window: protection hero, path + recursive scan, progress bar, results
  table, signature update log; state mirrored to the tray
- compiles clean (cargo build, 0 warnings) on Mint 22.3 + webkit2gtk-4.1

All 11 workspace tests pass, incl. the E2E EICAR scan over the real
Unix-socket daemon (clamscan finds the planted EICAR).
2026-08-20 17:55:40 -05:00

222 lines
7.8 KiB
Rust

//! `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<T> = anyhow::Result<T>;
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<String, Image<'static>>);
fn client() -> Client {
Client::default_path()
}
// ── Commands (window → daemon) ──────────────────────────────────────────────
#[tauri::command]
async fn status() -> Result<Status, String> {
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<ScanResult, String> {
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<UpdateResult, String> {
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<HashMap<String, Image<'static>>> {
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<'static>> {
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()
}