Antivirus/gui/dist/app.js
johnmcafee 6ef296caa1 Full feature set: realtime monitor, quarantine vault, rootkit scan, settings, events
Daemon (houndd)
- realtime.rs: inotify monitor over watched dirs (default ~/Downloads,
  ~/Documents, ~/Desktop), ClamAV scan on touch, on_detect action
  (quarantine/rename/remove), ransomware heuristic (writes/renames per
  minute above threshold -> 'watching'/'alarm' + critical event)
- quarantine.rs: SHA-256-keyed vault under ~/.local/share/hound/quarantine,
  add/list/restore/remove with original-path metadata
- rootkit.rs: setuid anomaly detection (allowlisted stock binaries),
  deleted-but-executing inodes, world-writable /usr /bin; 3 severity levels
- settings.rs: persisted ~/.config/hound/settings.json, hot-reload on set
- events.rs: ring buffer of severity-tagged events, query + clear

API (hound-api): Settings, Event, QuarantineEntry, RootkitScan/
RootkitFinding, RealtimeStatus types + 10 client methods; Status gains
engine field (engine-agnostic seam)

CLI (hound): events, quarantine list|add|restore|remove, settings
[show|paused|auto-update|notify|realtime on|off|watch|on-detect|
max-size|exclude], rootkit, realtime [status|on|off] — color human
output, --json everywhere

GUI (Tauri 2):
- 16 backend commands bridging every client method
- tray watcher: 1s poll loop, 4-state icon ladder (green/amber/red/gray),
  desktop notification on fresh critical events
- 6-tab frontend: Protection (hero + scan + update), Quarantine (vault
  manager + manual add), Realtime (stats + watch list + toggle), Rootkit
  (on-demand scan), Alerts (event log + clear), Settings (full editor)
- capabilities/default.json for dialog/notification/event permissions

Verified: 27/27 workspace tests, live E2E — EICAR dropped in ~/Downloads
auto-quarantined by the running daemon (critical event logged, file
removed from origin).
2026-08-20 20:33:44 -05:00

514 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Hound Antivirus — webview front-end.
// Thin view over houndd via Tauri commands; 6-tab layout.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
const $ = (id) => document.getElementById(id);
const ICONS = {
protected: "state-protected-48.png",
scanning: "state-scanning-48.png",
threat: "state-threat-48.png",
paused: "state-paused-48.png",
};
const TITLES = {
protected: "Protected",
scanning: "Scanning…",
threat: "Threat Detected",
paused: "Paused",
};
const SUBS = {
protected: "Your system looks healthy.",
scanning: "Hound is working the queue.",
threat: "We found something that should not be there.",
paused: "Real-time protection is off.",
};
let currentState = "protected";
let busy = false;
let paused = false;
// ── Tabs ───────────────────────────────────────────────────────────
const LOADERS = {
quarantine: loadQuarantine,
realtime: loadRealtime,
rootkit: null, // on demand
alerts: loadAlerts,
settings: loadSettings,
};
function switchTab(name) {
document.querySelectorAll(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.tab === name));
document.querySelectorAll(".tab-panel").forEach((p) =>
p.classList.toggle("active", p.id === "panel-" + name));
const fn = LOADERS[name];
if (fn) fn().catch(() => {});
}
document.querySelectorAll(".tab").forEach((t) =>
t.addEventListener("click", () => switchTab(t.dataset.tab)));
// ── State rendering (hero + tray) ─────────────────────────────────
function setState(state) {
if (!ICONS[state]) state = "protected";
currentState = state;
$("hero").dataset.state = state;
$("shield-icon").src = ICONS[state];
$("hero-title").textContent = TITLES[state];
$("hero-sub").textContent = SUBS[state];
invoke("set_state", { state }).catch(() => {});
}
function setEngineDot(cls, label) {
$("engine-dot").className = "engine-dot " + cls;
$("engine-label").textContent = label;
}
function fmtDbAge(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return `${days} days ago`;
const mo = Math.floor(days / 30);
return `${mo} month${mo > 1 ? "s" : ""} ago`;
}
function renderStatus(st) {
setEngineDot(st.engine_present ? "ok" : "bad",
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
$("pill-os").textContent = "OS: " + (st.os || "—");
$("pill-engine").textContent = "engine: " + (st.engine || "—");
const pill = $("pill-db");
if (st.db) {
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
const staleDays = (Date.now() - new Date(st.db.updated_at).getTime()) / 86400000;
pill.classList.toggle("stale", staleDays > 3);
$("foot-ver").textContent = `houndd ${st.daemon_version} · ${st.db.file}`;
} else {
pill.textContent = "signatures: none";
pill.classList.add("stale");
$("foot-ver").textContent = `houndd ${st.daemon_version}`;
}
}
// ── Scans ──────────────────────────────────────────────────────────
async function doScan(path) {
if (busy) return;
busy = true;
setButtons(true);
$("scan-panel").classList.remove("hidden");
$("scan-panel").querySelector(".progress").classList.add("indeterminate");
$("scan-title").textContent = "Scanning…";
$("scan-target").textContent = path;
setState("scanning");
try {
const r = await invoke("scan", { path, recursive: true });
renderScanResult(r, path);
} catch (e) {
$("scan-note").textContent = "Scan failed: " + String(e);
setState(paused ? "paused" : "protected");
} finally {
busy = false;
setButtons(false);
$("scan-panel").querySelector(".progress").classList.remove("indeterminate");
}
}
function renderScanResult(r, path) {
const clean = r.infected === 0;
setState(clean ? (paused ? "paused" : "protected") : "threat");
$("scan-title").textContent = clean
? "Scan complete — clean"
: `Scan complete — ${r.infected} threat${r.infected > 1 ? "s" : ""}`;
$("scan-note").textContent =
`${r.scanned.toLocaleString()} files scanned · ${r.clean.toLocaleString()} clean · ${r.infected.toLocaleString()} infected`;
$("progress-bar").style.width = "100%";
$("results-meta").textContent = `${r.scanned} files · ${path}`;
const body = $("results-body");
body.innerHTML = "";
if (clean) {
body.innerHTML =
`<div class="result-row clean">
<span class="sig">✔ All clear</span>
<span class="path">${r.scanned.toLocaleString()} files scanned, nothing flagged</span>
<span></span>
</div>`;
} else {
for (const f of r.found.slice(0, 50)) {
const div = document.createElement("div");
div.className = "result-row infected";
div.innerHTML =
`<span class="sig">✘ ${escapeHtml(f.virus)}</span>
<span class="path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</span>
<span></span>`;
body.appendChild(div);
}
if (r.found.length > 50) {
const more = document.createElement("div");
more.className = "muted";
more.style.padding = "4px 2px";
more.textContent = `…and ${r.found.length - 50} more`;
body.appendChild(more);
}
}
}
// ── Signature update ───────────────────────────────────────────────
async function doUpdate() {
if (busy) return;
busy = true;
setButtons(true);
$("update-panel").classList.remove("hidden");
const log = $("update-log");
log.className = "log";
log.textContent = "Running freshclam — this can take a minute…\n";
try {
const u = await invoke("update");
log.textContent = u.output.trim();
log.className = "log " + (u.ok ? "ok" : "fail");
if (u.status) renderStatus(u.status);
setState(paused ? "paused" : "protected");
} catch (e) {
log.textContent = "Update failed: " + String(e);
log.className = "log fail";
} finally {
busy = false;
setButtons(false);
}
}
// ── Quarantine tab ─────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let v = bytes, i = -1;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v < 10 ? 1 : 0) + " " + units[i];
}
function fmtTime(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
return t.toLocaleString();
}
function setQuarantineBadge(n) {
const b = $("badge-quarantine");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadQuarantine() {
try {
const list = await invoke("quarantine_list");
const body = $("qt-body");
body.innerHTML = "";
setQuarantineBadge(list.length);
$("qt-count").textContent = list.length
? `${list.length} item${list.length > 1 ? "s" : ""} held`
: "vault is empty";
if (!list.length) {
body.innerHTML = '<p class="muted empty">Nothing in quarantine. Your clean system thanks you.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "qt-row" + (e.restored ? " restored" : "");
row.innerHTML =
`<span class="qt-virus">${escapeHtml(e.virus)}</span>
<span>
<span class="qt-path" title="${escapeHtml(e.original_path)}">${escapeHtml(e.original_path)}</span><br/>
<span class="qt-meta">${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""}</span>
</span>
<span class="qt-actions"></span>`;
const actions = row.querySelector(".qt-actions");
if (!e.restored) {
const rb = document.createElement("button");
rb.className = "btn small";
rb.textContent = "Restore";
rb.onclick = async () => {
rb.disabled = true;
try { await invoke("quarantine_restore", { id: e.id }); loadQuarantine(); }
catch (err) { rb.disabled = false; alert("Restore failed: " + err); }
};
actions.appendChild(rb);
}
const xb = document.createElement("button");
xb.className = "btn small danger";
xb.textContent = "Remove";
xb.onclick = async () => {
if (!confirm(`Remove ${e.virus} from the vault? The bytes are deleted for good.`)) return;
xb.disabled = true;
try { await invoke("quarantine_remove", { id: e.id }); loadQuarantine(); }
catch (err) { xb.disabled = false; alert("Remove failed: " + err); }
};
actions.appendChild(xb);
body.appendChild(row);
}
} catch (e) {
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
}
}
// ── Realtime tab ───────────────────────────────────────────────────
function fmtUptime(secs) {
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
async function loadRealtime() {
try {
const rt = await invoke("realtime_status");
const st = await invoke("settings");
$("rt-toggle").checked = rt.enabled;
$("rt-enabled-label").textContent = rt.enabled ? "monitor running" : "monitor off";
$("rt-seen").textContent = rt.files_seen.toLocaleString();
$("rt-quar").textContent = rt.files_quarantined.toLocaleString();
$("rt-uptime").textContent = rt.active ? fmtUptime(rt.uptime_secs) : "—";
const rw = $("rt-ransom");
rw.textContent = rt.ransomware;
rw.className = "stat-val " +
(rt.ransomware === "alarm" ? "alarm" : rt.ransomware === "watching" ? "watching" : "calm");
$("rt-watch").textContent = rt.watching.join(", ") || "—";
$("rt-last").textContent = rt.last_event_at ? fmtTime(rt.last_event_at) : "none yet";
$("rt-action").textContent = st.on_detect;
const wl = $("rt-watch-list");
wl.innerHTML = "";
for (const dir of st.realtime_watch) {
const chip = document.createElement("span");
chip.className = "watch-chip";
chip.textContent = dir;
wl.appendChild(chip);
}
} catch (e) {
$("rt-enabled-label").textContent = "error: " + String(e);
}
}
// ── Rootkit tab ────────────────────────────────────────────────────
async function runRootkit() {
const btn = $("btn-rootkit");
btn.disabled = true;
btn.textContent = "Scanning…";
$("rootkit-banner").classList.add("hidden");
$("rootkit-body").innerHTML = '<p class="muted empty">Checking setuid bits, deleted executables, world-writable binaries…</p>';
try {
const r = await invoke("rootkit_scan");
const banner = $("rootkit-banner");
const dirty = r.critical + r.warn > 0;
banner.textContent = dirty ? `${r.verdict}` : `${r.verdict}${r.info} informational note${r.info === 1 ? "" : "s"}`;
banner.className = "verdict " + (dirty ? "dirty" : "clean");
banner.classList.remove("hidden");
const body = $("rootkit-body");
body.innerHTML = "";
if (!r.findings.length) {
body.innerHTML = '<p class="muted empty">No findings at all — squeaky clean.</p>';
return;
}
const rank = { critical: 0, warn: 1, info: 2 };
const sorted = [...r.findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3));
for (const f of sorted) {
const row = document.createElement("div");
row.className = "finding-row";
row.innerHTML =
`<span class="sev ${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span>
<span class="check">${escapeHtml(f.check)}</span>
<span class="detail">${escapeHtml(f.detail)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(String(e))}</p>`;
} finally {
btn.disabled = false;
btn.textContent = "Run Scan";
}
}
// ── Alerts tab ─────────────────────────────────────────────────────
function setAlertsBadge(n) {
const b = $("badge-alerts");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadAlerts() {
try {
const list = await invoke("events", { limit: 200 });
$("alerts-count").textContent = list.length ? `${list.length} events` : "";
setAlertsBadge(list.filter((e) => e.severity === "critical").length);
const body = $("alerts-body");
body.innerHTML = "";
if (!list.length) {
body.innerHTML = '<p class="muted empty">No events yet — the dog hasnt barked.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "event-row";
row.innerHTML =
`<span class="ev-sev ${escapeHtml(e.severity)}">${escapeHtml(e.severity)}</span>
<span class="ev-kind">${escapeHtml(e.kind)}</span>
<span class="ev-msg">${escapeHtml(e.message)}</span>
<span class="ev-ts">${fmtTime(e.ts)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
}
}
// ── Settings tab ───────────────────────────────────────────────────
let settingsCache = null;
async function loadSettings() {
try {
const s = await invoke("settings");
settingsCache = s;
$("set-paused").checked = s.paused;
$("set-autoupdate").checked = s.auto_update_signatures;
$("set-notify").checked = s.notify_desktop;
$("set-realtime").checked = s.realtime_enabled;
$("set-watch").value = (s.realtime_watch || []).join("\n");
$("set-ondetect").value = s.on_detect;
$("set-maxsize").value = s.max_file_size_mb;
$("set-excludes").value = (s.exclude_paths || []).join("\n");
$("set-recursive").checked = s.recursive_default;
$("set-ransom").checked = s.ransomware_guard;
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
$("set-rootkit").checked = s.rootkit_enabled;
} catch (e) {
$("settings-msg").textContent = "Failed to load: " + String(e);
}
}
async function saveSettings() {
if (!settingsCache) return;
const s = settingsCache;
s.paused = $("set-paused").checked;
s.auto_update_signatures = $("set-autoupdate").checked;
s.notify_desktop = $("set-notify").checked;
s.realtime_enabled = $("set-realtime").checked;
s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.on_detect = $("set-ondetect").value;
s.max_file_size_mb = Math.max(1, parseInt($("set-maxsize").value, 10) || 0);
s.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.recursive_default = $("set-recursive").checked;
s.ransomware_guard = $("set-ransom").checked;
s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100);
s.rootkit_enabled = $("set-rootkit").checked;
try {
const saved = await invoke("set_settings", { s });
paused = saved.paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
$("settings-msg").textContent = "Saved ✓";
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
loadRealtime().catch(() => {});
} catch (e) {
$("settings-msg").textContent = "Save failed: " + String(e);
}
}
// ── Wiring ─────────────────────────────────────────────────────────
function setButtons(disabled) {
for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"])
$(id).disabled = disabled;
}
function escapeHtml(s) {
return String(s).replace(/[&<>\"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
async function pickFolder() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const dir = await open({ directory: true, multiple: false, title: "Choose a folder to scan" });
if (dir) doScan(dir);
} catch {
const p = prompt("Folder to scan:");
if (p) doScan(p);
}
}
function togglePause() {
paused = !paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
setState(paused ? "paused" : "protected");
}
$("btn-scan-home").addEventListener("click", () => doScan("~"));
$("btn-scan-custom").addEventListener("click", pickFolder);
$("btn-update").addEventListener("click", doUpdate);
$("btn-pause").addEventListener("click", togglePause);
$("btn-qt-refresh").addEventListener("click", () => loadQuarantine());
$("btn-alerts-refresh").addEventListener("click", () => loadAlerts());
$("btn-rootkit").addEventListener("click", runRootkit);
$("btn-settings-save").addEventListener("click", saveSettings);
$("btn-qt-add").addEventListener("click", async () => {
const path = $("qt-add-path").value.trim();
const virus = $("qt-add-virus").value.trim() || "manual";
if (!path) return alert("Enter a file path to quarantine.");
try {
await invoke("quarantine_add", { path, virus });
$("qt-add-path").value = "";
loadQuarantine();
} catch (e) { alert("Quarantine failed: " + e); }
});
$("btn-alerts-clear").addEventListener("click", async () => {
if (!confirm("Clear the whole event log?")) return;
try {
const n = await invoke("clear_events");
$("alerts-count").textContent = `${n} event${n === 1 ? "" : "s"} cleared`;
loadAlerts();
} catch (e) { alert("Clear failed: " + e); }
});
$("rt-toggle").addEventListener("change", async (ev) => {
try {
await invoke("realtime_set_enabled", { enabled: ev.target.checked });
loadRealtime();
} catch (e) {
ev.target.checked = !ev.target.checked;
alert("Toggle failed: " + e);
}
});
// Tray menu events (Scan Home / Scan Downloads / Update).
listen("tray-event", (e) => {
const p = e.payload;
if (p?.action === "scan" && p.path) {
switchTab("protection");
doScan(p.path);
} else if (p?.action === "update") {
switchTab("protection");
doUpdate();
}
});
// Initial boot.
async function boot() {
try {
const st = await invoke("status");
renderStatus(st);
if (st.engine_present) setState(paused ? "paused" : "protected");
else setState("paused");
} catch {
setEngineDot("bad", "engine offline");
setState("paused");
$("hero-sub").textContent =
"Can't reach houndd. Is the daemon running? (try `cargo run -p houndd`)";
}
}
boot();