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).
222 lines
7.8 KiB
Rust
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()
|
|
}
|