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).
514 lines
19 KiB
JavaScript
514 lines
19 KiB
JavaScript
// 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 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(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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """ }[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();
|