**The action.** `hound hygiene` and `hound supply-chain` only ever saw repositories somebody had already cloned onto a machine with Hound installed. action/ runs them on every push and pull request: it installs the published .deb, verifies it against the same signed checksum the desktop agent uses, and annotates findings on the lines of the files they concern so a reviewer sees them in the diff rather than in a log nobody opens. report.py will not print a credential it found — GitHub masks only values registered as secrets, so anything else in an annotation is readable by everyone who can see the run and stays in the API afterwards. And it will not report a partial scan as clean: a history walk that hits its limit says so, because "no findings" and "no findings in the part we looked at" mean different things to somebody deciding whether to merge. **The background.** A radial gradient was set on `html, body` — both, each 100% tall — so it painted twice and the seam between the two layers appeared as a band across the middle of the page when scrolled. It was also a hardcoded near-black the light theme had no way to override. Three more like it: the active tab, button hover, and the log panel. The ground is a token now, and every colour in the stylesheet comes from one, so no rule can put one theme's text on the other's background. **The palette.** The light theme now uses houndav.com's values exactly — #5A58C8 buttons, #147A3D, #9A6100, #C22222 — so the app and the site are recognisably the same product rather than two guesses at it. Then measured rather than assumed, and found two failures Joe had not mentioned: "faint" text was 2.90:1 in dark and 3.37:1 in light, and the dark button hover was 4.41:1. All three now clear 4.5:1, and all eight text pairs pass WCAG AA in both themes. **And four more places still naming ClamAV**, which has not been the engine for a long time: the auto-update caption said the daemon runs freshclam, the update log said the same, an error suggested `apt install clamav`, and a permissions hint pointed at /var/lib/clamav. The engine swap replaced the code and left the copy describing software this product no longer runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
661 lines
25 KiB
JavaScript
661 lines
25 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.
|
||
// Appearance is a per-user preference and is stored here, not in the daemon.
|
||
// Daemon settings need root to write, so putting a theme toggle there meant a
|
||
// password prompt to change your icon colour, and one user's choice would
|
||
// have been every user's.
|
||
const PREFS_KEY = "hound.appearance";
|
||
function loadPrefs() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
function savePrefs(patch) {
|
||
try {
|
||
localStorage.setItem(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...patch }));
|
||
} catch {
|
||
// A browser refusing storage is not a reason to refuse the change; it
|
||
// just will not survive a restart.
|
||
}
|
||
}
|
||
|
||
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 = "Checking for new definitions…\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;
|
||
const prefs = loadPrefs();
|
||
$("set-theme").value = prefs.theme || "auto";
|
||
$("set-monochrome").checked = prefs.monochrome === true;
|
||
} 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.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 {
|
||
$("settings-msg").textContent = "Saving…";
|
||
const saved = await invoke("set_settings", { s });
|
||
paused = saved.paused;
|
||
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
|
||
$("settings-msg").textContent = "Saved ✓";
|
||
setTimeout(() => {
|
||
if ($("settings-msg").textContent === "Saved ✓") $("settings-msg").textContent = "";
|
||
}, 2000);
|
||
loadRealtime().catch(() => {});
|
||
} catch (e) {
|
||
$("settings-msg").textContent = explain(e);
|
||
// The control now shows something the daemon rejected. Put it back
|
||
// rather than leaving the screen disagreeing with reality.
|
||
loadSettings().catch(() => {});
|
||
}
|
||
}
|
||
|
||
/// Persist on change, with a short delay so typing in a text box does not
|
||
/// send a request per keystroke — and so toggling three switches in a row is
|
||
/// one write, and one authentication prompt, rather than three.
|
||
let saveTimer = null;
|
||
function saveSoon(delay = 400) {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => saveSettings(), delay);
|
||
}
|
||
|
||
// ── 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);
|
||
// There is no Save button. A settings screen that needs one lets you leave
|
||
// with your changes discarded, and it hid a real failure: writing these needs
|
||
// root, so a save could be refused while the switch stayed where you put it.
|
||
for (const id of [
|
||
"set-paused", "set-autoupdate", "set-notify", "set-realtime",
|
||
"set-ondetect", "set-recursive", "set-ransom", "set-rootkit",
|
||
]) {
|
||
$(id).addEventListener("change", () => saveSoon(0));
|
||
}
|
||
// Typed fields wait for a pause in typing.
|
||
for (const id of ["set-watch", "set-maxsize", "set-excludes", "set-ransom-thresh"]) {
|
||
$(id).addEventListener("input", () => saveSoon(800));
|
||
$(id).addEventListener("blur", () => saveSoon(0));
|
||
}
|
||
|
||
$("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) => {
|
||
savePrefs({ theme: e.target.value });
|
||
applyTheme(e.target.value);
|
||
});
|
||
$("set-monochrome").addEventListener("change", (e) => {
|
||
const monochrome = e.target.checked;
|
||
savePrefs({ monochrome });
|
||
// Repaint the tray now. Waiting for a Save button, or for the next poll,
|
||
// makes a toggle feel broken.
|
||
invoke("set_tray_style", { monochrome }).catch(() => {});
|
||
});
|
||
|
||
// Apply the stored appearance before the first paint, so the window does not
|
||
// flash the wrong theme on the way in.
|
||
(() => {
|
||
const prefs = loadPrefs();
|
||
applyTheme(prefs.theme || "auto");
|
||
invoke("set_tray_style", { monochrome: prefs.monochrome === true }).catch(() => {});
|
||
})();
|
||
|
||
// A right-click in the file manager arrives either as a startup argument or,
|
||
// when the window is already open, as an event from the second launch.
|
||
listen("scan-request", (e) => {
|
||
const paths = e.payload || [];
|
||
if (paths.length) {
|
||
switchTab("protection");
|
||
doScan(paths[0]);
|
||
}
|
||
});
|
||
|
||
boot().then(async () => {
|
||
try {
|
||
const paths = await invoke("take_scan_request");
|
||
if (paths && paths.length) {
|
||
switchTab("protection");
|
||
doScan(paths[0]);
|
||
}
|
||
} catch {
|
||
// No pending request is the normal case.
|
||
}
|
||
});
|