A home scan reported two EICAR threats whose paths were Hound's own
vault. SKIP_PREFIXES held the system vault at /var/lib/hound/vault but
not the per-user one under $XDG_DATA_HOME/hound/quarantine, which is a
different absolute path for every user and so cannot be a literal. The
check now matches a `hound` component followed by `quarantine` or
`vault` anywhere in the path, with a test that ordinary paths merely
mentioning either word are still scanned. A scanner that detects its
own evidence locker reports threats that no longer exist anywhere they
can hurt anyone.
Appearance settings, the third pair of fields declared from the start
and wired to nothing (after close_to_tray and auto_update_signatures):
- Theme: follow system | light | dark, previewing live rather than
only on save, since a control that does nothing until you press
another control feels broken.
- Monochromatic tray icon: one glyph instead of the colour ladder.
The tooltip still names the state, so only the colour is dropped.
The light theme had to be built — the stylesheet was dark-only. It is
not an inversion: the state colours are darkened until they hold their
contrast on white (the dark theme's green is 2.2:1 there, unreadable as
text) and the neutrals keep a slight violet bias so they read as chosen
rather than as a default grey. One hardcoded near-black on the log
panel would have been near-invisible in light mode; it is a token now.
Which monochrome tone to draw depends on the panel, and no portable way
exists to ask a panel what colour it is — so the webview resolves the
theme (including "follow system", which only prefers-color-scheme can
answer) and tells the tray. One place decides which theme is showing.
The tray now announces a release once per version rather than once per
poll, and its menu entry reads "Update available — install Hound X…".
Also removes ~120 lines: the GUI had its own download, hash-check and
staging implementation for updates. `hound update` does exactly that,
as root, and is the path with tests behind it, so the app runs it under
pkexec instead. That left `hound stage-update` with no caller, and dead
privileged code paths are liabilities, so it is gone.
Verified end to end: 0.1.2 discovered 0.1.3 and installed it
unattended. 380 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
576 lines
22 KiB
JavaScript
576 lines
22 KiB
JavaScript
// 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 <file>",
|
||
"quarantine.restore": "quarantine restore <id>",
|
||
"quarantine.remove": "quarantine remove <id>",
|
||
"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 =
|
||
`<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: " + 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 = '<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(explain(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: " + 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 = '<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(explain(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 hasn’t 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(explain(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;
|
||
$("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();
|