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).
This commit is contained in:
John McAfee 2026-08-20 17:55:40 -05:00
parent 566e0cbccb
commit a7871b5e27
29 changed files with 5580 additions and 8 deletions

View file

@ -14,7 +14,7 @@ antivirus/
├── Cargo.toml # Rust workspace
├── crates/
│ ├── hound-api/ # shared wire types + socket client (daemon/CLI/GUI all use it)
│ ├── houndd/ # the daemon: Unix-socket API over ClamAV
│ ├── houndd/ # the daemon: Unix-socket API over a pluggable engine
│ └── hound/ # CLI client
├── assets/icons/ # dog-head brand mark + 4-state tray ladder
└── gui/ # Tauri 2 desktop app (system tray + scan UI)
@ -25,19 +25,41 @@ antivirus/
```
houndd (Rust daemon — the engine)
┌──────────────────────────────────┐
│ L1 ClamAV signatures │
│ L2 Curated threat packs (Pro) │
│ L3 Behavioral monitor (Pro) │
│ L4 Supply-chain checks (Pro) │
│ ScanEngine trait │
│ ├─ L1 ClamAV signatures (now) │
│ ├─ L2 Curated threat packs(Pro)│
│ ├─ L3 Behavioral monitor (Pro) │
│ └─ L4 Supply-chain checks(Pro) │
└──────────────┬───────────────────┘
Unix socket (JSON-RPC, line-delimited)
┌───────────┼───────────┐
hound CLI GUI (Tauri) future modules
```
The daemon is the only process that touches ClamAV. CLI and GUI are thin
clients — so future suite tools (firewall, updater, …) plug into the same
socket.
The daemon is the only process that touches a scanning engine. CLI and GUI
are thin clients — so future suite tools (firewall, updater, …) plug into
the same socket.
### Swapping the engine (the ClamAV seam)
ClamAV is a **temporary** dependency. Everything ClamAV-specific — version
probe, signature freshness, the `clamscan` subprocess + output parsing,
`freshclam` — lives in one file behind a four-method trait:
```
crates/houndd/src/engine.rs
trait ScanEngine { name; probe; scan; update }
struct ClamAvEngine // today
const ENGINE: ClamAvEngine // ← flip this line when the native
// engine lands; nothing else in the
// daemon, CLI, GUI, or wire API moves
```
The wire stays engine-agnostic: `Status.engine` names the implementation
(`"clamav"` today) and `Status.db` carries what any signature store has —
a file name and a timestamp. When our own Rust engine ships, it's a new
`ScanEngine` implementation, a one-const flip, and the tray/CLI/GUI simply
start reporting the new engine name.
## Icon system
@ -89,6 +111,39 @@ cargo run -p hound -- scan /tmp/eicar.com
# expect: exit code 1, "Eicar-Test-Signature FOUND"
```
### Updating signatures
`hound update` wraps `freshclam` (trying `sudo freshclam` first, since
plain-user runs can't write `/var/lib/clamav` and `/var/log/clamav`). The
GUI's "Update Signatures" button drives the same RPC and shows the log.
```sh
cargo run -p hound -- update # or: hound update --json
```
## The GUI (`gui/`)
A Tauri 2 desktop app — a thin view over the *same* `houndd` socket the
CLI uses (via the shared `hound-api` client), so the window and the
command line never disagree about your security state.
```
gui/
├── dist/ # the front-end (vanilla HTML/CSS/JS, premium dark shell)
└── src-tauri/ # Tauri 2 shell + system-tray sentinel
```
The tray sentinel swaps the 4-state icons (green/amber/red/gray) as your
state changes; the window shows a live protection hero, a scan progress
bar, a results table, and the signature-update log.
Build it:
```sh
cd gui
npm install
npm run tauri dev # dev with hot reload
npm run tauri build # → .deb in src-tauri/target/release/bundle/
```
## Git conventions
- Branch `main` is deployable; small, focused commits.

6
gui/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/assets/
src-tauri/target/
src-tauri/gen/
.DS_Store
*.log

17
gui/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "hound-gui",
"version": "0.1.0",
"description": "Hound Antivirus — desktop app",
"type": "module",
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0"
}
}

5200
gui/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
gui/src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "hound-gui"
description = "Hound Antivirus desktop app (Tauri 2)"
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus"
# Standalone crate: the GUI has its own workspace (different dep set from
# the CLI/daemon) and is built via `cargo tauri build`, not the root build.
[workspace]
[dependencies]
hound-api = { path = "../../crates/hound-api" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
[build-dependencies]
tauri-build = "2"
[profile.release]
strip = true

3
gui/src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

222
gui/src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,222 @@
//! `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()
}

View file

@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hound Antivirus",
"version": "0.1.0",
"identifier": "com.joelovestech.hound",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"app": {
"windows": [
{
"title": "Hound Antivirus",
"width": 940,
"height": 640,
"minWidth": 760,
"minHeight": 520,
"resizable": true,
"center": true,
"fullscreen": false,
"decorations": true
}
],
"security": {
"csp": null
},
"trayIcon": {
"iconPath": "icons/state-protected-22.png",
"iconAsTemplate": false
}
},
"bundle": {
"active": true,
"targets": ["app", "deb"],
"icon": [
"icons/hound-48.png",
"icons/state-protected-32.png"
],
"resources": ["icons/state-protected-22.png", "icons/state-scanning-22.png", "icons/state-threat-22.png", "icons/state-paused-22.png"]
}
}