// Hound Antivirus — webview front-end. // Thin view over houndd via Tauri commands; 6-tab layout. // No bundler here — the webview loads this file directly, so a bare module // specifier ("@tauri-apps/api/core") cannot be resolved and the entire script // fails to parse. The window then sits on its static HTML forever, looking // exactly like a daemon that never answered. The API comes off the global // that `withGlobalTauri` installs instead. const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; const $ = (id) => document.getElementById(id); /// Paint the chosen theme. "auto" defers to the desktop, which the webview /// reports through prefers-color-scheme — and which can change while the app /// is open, so the listener stays attached rather than sampling once. const SYSTEM_DARK = window.matchMedia("(prefers-color-scheme: dark)"); let themeChoice = "auto"; function applyTheme(choice) { themeChoice = choice || "auto"; const dark = themeChoice === "dark" || (themeChoice === "auto" && SYSTEM_DARK.matches); document.documentElement.setAttribute("data-theme", dark ? "dark" : "light"); // The tray needs this too: which monochrome tone to draw depends on it, // and only the webview can resolve "follow system". invoke("set_theme_resolved", { dark }).catch(() => {}); } SYSTEM_DARK.addEventListener("change", () => { if (themeChoice === "auto") applyTheme("auto"); }); // The daemon refuses anything that writes unless the caller is root, because // quarantine restores files back out as root and that is not something group // membership should confer. The app runs as you, so those actions need a // terminal for now. Translate the wire error rather than showing it raw. function explain(e) { const raw = String(e && e.message ? e.message : e); const m = raw.match(/([a-z.]+) requires administrator privileges/); if (m) { return "That needs administrator rights. Run it from a terminal: sudo hound " + ({ "settings.set": "settings …", "update": "update", "quarantine.add": "quarantine add ", "quarantine.restore": "quarantine restore ", "quarantine.remove": "quarantine remove ", "events.clear": "alerts clear", "realtime.set_enabled": "settings realtime on|off", }[m[1]] || m[1]); } if (/cannot read .* as uid/.test(raw)) { return raw.replace(/^.*?cannot read/, "Hound will not scan") + " (it only scans files you could open yourself)."; } return raw.replace(/^daemon error -?\d+: /, ""); } 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 || "—"); // The footer used to claim ClamAV. It reports whatever the daemon actually // loaded, which has been yara-x since the engine was replaced. $("foot-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: " + explain(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 = `
✔ All clear ${r.scanned.toLocaleString()} files scanned, nothing flagged
`; } else { for (const f of r.found.slice(0, 50)) { const div = document.createElement("div"); div.className = "result-row infected"; div.innerHTML = `✘ ${escapeHtml(f.virus)} ${escapeHtml(f.path)} `; 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: " + explain(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 = '

Nothing in quarantine. Your clean system thanks you.

'; return; } for (const e of list) { const row = document.createElement("div"); row.className = "qt-row" + (e.restored ? " restored" : ""); row.innerHTML = `${escapeHtml(e.virus)} ${escapeHtml(e.original_path)}
${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""}
`; 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 = `

Failed to load: ${escapeHtml(explain(e))}

`; } } // ── 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: " + explain(e); } } // ── Rootkit tab ──────────────────────────────────────────────────── async function runRootkit() { const btn = $("btn-rootkit"); btn.disabled = true; btn.textContent = "Scanning…"; $("rootkit-banner").classList.add("hidden"); $("rootkit-body").innerHTML = '

Checking setuid bits, deleted executables, world-writable binaries…

'; 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 = '

No findings at all — squeaky clean.

'; 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 = `${escapeHtml(f.severity)} ${escapeHtml(f.check)} ${escapeHtml(f.detail)}`; body.appendChild(row); } } catch (e) { $("rootkit-body").innerHTML = `

Scan failed: ${escapeHtml(explain(e))}

`; } 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 = '

No events yet — the dog hasn’t barked.

'; return; } for (const e of list) { const row = document.createElement("div"); row.className = "event-row"; row.innerHTML = `${escapeHtml(e.severity)} ${escapeHtml(e.kind)} ${escapeHtml(e.message)} ${fmtTime(e.ts)}`; body.appendChild(row); } } catch (e) { $("alerts-body").innerHTML = `

Failed to load: ${escapeHtml(explain(e))}

`; } } // ── 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; $("set-theme").value = s.theme || "auto"; $("set-monochrome").checked = (s.tray_icon_style || "colour") === "monochrome"; applyTheme(s.theme || "auto"); } catch (e) { $("settings-msg").textContent = "Failed to load: " + explain(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.theme = $("set-theme").value; s.tray_icon_style = $("set-monochrome").checked ? "monochrome" : "colour"; 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 = explain(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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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. Check that the service is up (`systemctl status houndd`). " + "If it is, you may not be in the `hound` group yet — the installer adds you, " + "but it only takes effect after you log out and back in."; } } // Tells the watchdog in index.html that the script actually ran. window.__houndBooted = true; $("set-theme").addEventListener("change", (e) => applyTheme(e.target.value)); boot();