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).
71
README.md
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/assets/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
.DS_Store
|
||||
*.log
|
||||
17
gui/package.json
Normal 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
26
gui/src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
gui/src-tauri/icons/hound-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/src-tauri/icons/state-paused-16.png
Normal file
|
After Width: | Height: | Size: 610 B |
BIN
gui/src-tauri/icons/state-paused-22.png
Normal file
|
After Width: | Height: | Size: 889 B |
BIN
gui/src-tauri/icons/state-paused-24.png
Normal file
|
After Width: | Height: | Size: 905 B |
BIN
gui/src-tauri/icons/state-paused-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gui/src-tauri/icons/state-paused-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/src-tauri/icons/state-protected-16.png
Normal file
|
After Width: | Height: | Size: 640 B |
BIN
gui/src-tauri/icons/state-protected-22.png
Normal file
|
After Width: | Height: | Size: 918 B |
BIN
gui/src-tauri/icons/state-protected-24.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
gui/src-tauri/icons/state-protected-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gui/src-tauri/icons/state-protected-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/src-tauri/icons/state-scanning-16.png
Normal file
|
After Width: | Height: | Size: 611 B |
BIN
gui/src-tauri/icons/state-scanning-22.png
Normal file
|
After Width: | Height: | Size: 862 B |
BIN
gui/src-tauri/icons/state-scanning-24.png
Normal file
|
After Width: | Height: | Size: 922 B |
BIN
gui/src-tauri/icons/state-scanning-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gui/src-tauri/icons/state-scanning-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/src-tauri/icons/state-threat-16.png
Normal file
|
After Width: | Height: | Size: 625 B |
BIN
gui/src-tauri/icons/state-threat-22.png
Normal file
|
After Width: | Height: | Size: 881 B |
BIN
gui/src-tauri/icons/state-threat-24.png
Normal file
|
After Width: | Height: | Size: 919 B |
BIN
gui/src-tauri/icons/state-threat-32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gui/src-tauri/icons/state-threat-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
222
gui/src-tauri/src/main.rs
Normal 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()
|
||||
}
|
||||
43
gui/src-tauri/tauri.conf.json
Normal 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"]
|
||||
}
|
||||
}
|
||||