0.1.3: appearance settings, and stop detecting our own quarantine

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>
This commit is contained in:
dev 2026-08-21 12:28:05 -05:00
parent cb943c66c4
commit ec1fa4e26f
26 changed files with 256 additions and 172 deletions

12
Cargo.lock generated
View file

@ -1089,7 +1089,7 @@ dependencies = [
[[package]] [[package]]
name = "hound" name = "hound"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@ -1104,7 +1104,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-api" name = "hound-api"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1114,7 +1114,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-defs" name = "hound-defs"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"serde", "serde",
@ -1124,7 +1124,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-mcp" name = "hound-mcp"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"hound-api", "hound-api",
"hound-supply", "hound-supply",
@ -1134,7 +1134,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-supply" name = "hound-supply"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"hound-defs", "hound-defs",
"serde", "serde",
@ -1143,7 +1143,7 @@ dependencies = [
[[package]] [[package]]
name = "houndd" name = "houndd"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ed25519-dalek", "ed25519-dalek",

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus.git" repository = "https://git.joelovestech.com/Hound/Antivirus.git"

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -48,15 +48,6 @@ enum Cmd {
/// privilege. /// privilege.
#[command(hide = true)] #[command(hide = true)]
AdminRpc, AdminRpc,
/// Write stdin to a staged update file (internal).
///
/// The desktop app downloads a published package and checks it against
/// the signed manifest, then needs it somewhere root-owned so nothing can
/// substitute the file between that check and the package manager reading
/// it. Deliberately narrow: one fixed directory, one filename shape, and
/// no way to name a path outside it.
#[command(hide = true)]
StageUpdate { path: String },
/// Scan a file or directory /// Scan a file or directory
Scan { Scan {
/// Path to scan (file or directory) /// Path to scan (file or directory)
@ -419,39 +410,6 @@ fn client(sock: &Option<String>) -> Result<Client> {
/// Returns the process exit code. /// Returns the process exit code.
fn run(client: &Client, cmd: &Cmd) -> Result<i32> { fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
match cmd { match cmd {
Cmd::StageUpdate { path } => {
use std::io::Read as _;
const STAGE_DIR: &str = "/var/lib/hound/updates";
let p = std::path::Path::new(path);
// The caller is unprivileged and we are not; treat the path as
// hostile. It must sit directly in the staging directory and be
// named like a package — no traversal, no subdirectories, no
// writing over anything else on the system.
let name = p
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("no filename"))?;
if p.parent() != Some(std::path::Path::new(STAGE_DIR))
|| !name.ends_with(".deb")
|| name.contains('/')
|| name.starts_with('.')
{
anyhow::bail!("a staged update must be a .deb directly in {STAGE_DIR}");
}
std::fs::create_dir_all(STAGE_DIR)?;
std::fs::set_permissions(
STAGE_DIR,
std::os::unix::fs::PermissionsExt::from_mode(0o700),
)?;
let mut body = Vec::new();
// A package is tens of megabytes; this bound is generous and
// still cannot fill a disk.
std::io::stdin().take(256 << 20).read_to_end(&mut body)?;
std::fs::write(p, &body)?;
std::fs::set_permissions(p, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
println!("staged {} bytes", body.len());
Ok(0)
}
Cmd::AdminRpc => { Cmd::AdminRpc => {
use std::io::Read as _; use std::io::Read as _;
let mut line = String::new(); let mut line = String::new();

View file

@ -265,12 +265,74 @@ impl HoundEngine {
} }
/// Pseudo-filesystems and our own vault: never walked. /// Pseudo-filesystems and our own vault: never walked.
///
/// The per-user vault lives under `$XDG_DATA_HOME/hound/quarantine`, which is
/// a different absolute path for every user, so it cannot be a literal in
/// SKIP_PREFIXES. Scanning it means re-detecting every threat already caught
/// — a home scan reported two EICAR hits whose paths were Hound's own vault,
/// which is alarming, useless, and would re-quarantine files already
/// quarantined.
fn is_skipped_dir(path: &Path) -> bool { fn is_skipped_dir(path: &Path) -> bool {
SKIP_PREFIXES.iter().any(|p| path.starts_with(p)) if SKIP_PREFIXES.iter().any(|p| path.starts_with(p)) {
return true;
}
is_quarantine_path(path)
}
/// Any user's quarantine vault, wherever their data directory happens to be.
fn is_quarantine_path(path: &Path) -> bool {
let mut parts = path.components().peekable();
while let Some(c) = parts.next() {
if c.as_os_str() == "hound" {
if let Some(next) = parts.peek() {
if next.as_os_str() == "quarantine" || next.as_os_str() == "vault" {
return true;
}
}
}
}
false
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// Hound quarantined two EICAR files, then a home scan found them again
/// — in its own vault — and reported the machine as infected. A scanner
/// that detects its own evidence locker reports a threat that no longer
/// exists anywhere it can hurt anyone.
#[test]
fn the_quarantine_vault_is_never_scanned() {
for p in [
"/home/anon/.local/share/hound/quarantine/3193804faa76105a",
"/home/someone/.local/share/hound/quarantine",
"/var/lib/hound/vault/abc123",
"/root/.local/share/hound/quarantine/x",
] {
assert!(
is_skipped_dir(std::path::Path::new(p)),
"{p} is a quarantine vault and must not be scanned"
);
}
}
/// The check keys on "hound" followed by "quarantine" or "vault", so it
/// must not swallow ordinary directories that merely mention either word.
#[test]
fn ordinary_paths_are_still_scanned() {
for p in [
"/home/anon/projects/hound/src/main.rs",
"/home/anon/quarantine-notes.txt",
"/home/anon/hound-vault-design.md",
"/srv/vault/data",
"/home/anon/Downloads/hound.deb",
] {
assert!(
!is_skipped_dir(std::path::Path::new(p)),
"{p} is a normal path and must still be scanned"
);
}
}
use super::*; use super::*;
use std::fs; use std::fs;

BIN
dist/hound_0.1.3_amd64.deb vendored Normal file

Binary file not shown.

24
gui/dist/app.js vendored
View file

@ -10,6 +10,23 @@ const { listen } = window.__TAURI__.event;
const $ = (id) => document.getElementById(id); 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 // 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 // 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 // membership should confer. The app runs as you, so those actions need a
@ -419,6 +436,9 @@ async function loadSettings() {
$("set-ransom").checked = s.ransomware_guard; $("set-ransom").checked = s.ransomware_guard;
$("set-ransom-thresh").value = s.ransomware_threshold_per_min; $("set-ransom-thresh").value = s.ransomware_threshold_per_min;
$("set-rootkit").checked = s.rootkit_enabled; $("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) { } catch (e) {
$("settings-msg").textContent = "Failed to load: " + explain(e); $("settings-msg").textContent = "Failed to load: " + explain(e);
} }
@ -433,6 +453,8 @@ async function saveSettings() {
s.realtime_enabled = $("set-realtime").checked; s.realtime_enabled = $("set-realtime").checked;
s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean); s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.on_detect = $("set-ondetect").value; 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.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.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.recursive_default = $("set-recursive").checked; s.recursive_default = $("set-recursive").checked;
@ -549,4 +571,6 @@ async function boot() {
// Tells the watchdog in index.html that the script actually ran. // Tells the watchdog in index.html that the script actually ran.
window.__houndBooted = true; window.__houndBooted = true;
$("set-theme").addEventListener("change", (e) => applyTheme(e.target.value));
boot(); boot();

16
gui/dist/index.html vendored
View file

@ -197,6 +197,22 @@
<!-- ══ Settings ═════════════════════════════════════════════ --> <!-- ══ Settings ═════════════════════════════════════════════ -->
<section class="tab-panel" id="panel-settings"> <section class="tab-panel" id="panel-settings">
<section class="panel">
<div class="panel-head"><h3>Appearance</h3></div>
<label class="switch-row setting">
<span><strong>Theme</strong><small>Follow your desktop, or pick one.</small></span>
<select id="set-theme" class="input compact">
<option value="auto">Follow system</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label class="switch-row setting">
<span><strong>Monochromatic tray icon</strong><small>A single-tone glyph that follows your panel, instead of the colour states.</small></span>
<input type="checkbox" id="set-monochrome" /><span class="switch"></span>
</label>
</section>
<section class="panel"> <section class="panel">
<div class="panel-head"><h3>Protection</h3></div> <div class="panel-head"><h3>Protection</h3></div>
<label class="switch-row setting"> <label class="switch-row setting">

39
gui/dist/styles.css vendored
View file

@ -22,6 +22,43 @@
--radius: 14px; --radius: 14px;
--radius-sm: 9px; --radius-sm: 9px;
--shadow: 0 8px 30px rgb(0 0 0 / 0.45); --shadow: 0 8px 30px rgb(0 0 0 / 0.45);
--bg-log: #0A0D13;
}
/* Light theme.
*
* Not an inversion of the dark palette a flat inversion turns a deep navy
* ground into a saturated cream and makes the brand violet unreadable. The
* neutrals keep a slight violet bias so they read as chosen rather than as
* the browser's default grey, and the state colours are darkened until they
* hold their contrast against white rather than glowing on it.
*
* Applied by [data-theme] only. The app writes that attribute from the
* user's setting, resolving "auto" against prefers-color-scheme itself, so
* there is exactly one place that decides which theme is showing. */
:root[data-theme="light"] {
--bg: #F7F7FB;
--bg-raised: #FFFFFF;
--bg-panel: #FFFFFF;
--bg-hover: #EEEEF6;
--border: #E0E0EC;
--border-hi: #C6C6DC;
--fg: #1A1B26;
--fg-dim: #5A5C72;
--fg-faint: #8A8CA3;
/* Darkened for contrast on a light ground: the dark theme's #22C55E is
2.2:1 on white and unreadable as text. */
--brand: #5A58C8;
--ok: #16A34A;
--warn: #B45309;
--bad: #DC2626;
--off: #6B7280;
--shadow: 0 8px 24px rgb(26 27 38 / 0.10);
/* A console keeps its recessed feel in light mode without going black. */
--bg-log: #F0F0F7;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@ -250,7 +287,7 @@ html, body {
/* log */ /* log */
.log { .log {
background: #0A0D13; background: var(--bg-log);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 12px 14px; padding: 12px 14px;

4
gui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.2", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.2", "version": "0.1.3",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-dialog": "^2.7.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.2", "version": "0.1.3",
"description": "Hound Antivirus — desktop app", "description": "Hound Antivirus — desktop app",
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hound-api" name = "hound-api"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-gui" name = "hound-gui"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hound-api", "hound-api",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "hound-gui" name = "hound-gui"
description = "Hound Antivirus desktop app (Tauri 2)" description = "Hound Antivirus desktop app (Tauri 2)"
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus" repository = "https://git.joelovestech.com/Hound/Antivirus"

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

View file

@ -33,6 +33,12 @@ static TRAY_HINT_SHOWN: AtomicBool = AtomicBool::new(false);
/// socket round-trip while the user is watching the window not close — so /// socket round-trip while the user is watching the window not close — so
/// these mirror `close_to_tray` and `confirm_quit`, and default to the safe /// these mirror `close_to_tray` and `confirm_quit`, and default to the safe
/// answer if the daemon has not been reached yet. /// answer if the daemon has not been reached yet.
/// True when the user asked for a single-tone tray glyph instead of the
/// colour state ladder.
static MONOCHROME: AtomicBool = AtomicBool::new(false);
/// The resolved theme, used only to pick which monochrome tone to draw.
static THEME_IS_DARK: AtomicBool = AtomicBool::new(true);
static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(true); static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(true);
static CONFIRM_QUIT: AtomicBool = AtomicBool::new(true); static CONFIRM_QUIT: AtomicBool = AtomicBool::new(true);
use tauri::image::Image; use tauri::image::Image;
@ -137,21 +143,17 @@ async fn settings() -> Result<Settings, String> {
// ── Assisted update ──────────────────────────────────────────────────────── // ── Assisted update ────────────────────────────────────────────────────────
/// Download the published package, check it, and hand it to the system /// Install a published release.
/// package manager.
/// ///
/// Hound does not replace its own binary. A root daemon that can rewrite /// This used to download, hash-check and stage the package itself. It no
/// itself is precisely the mechanism a supply-chain attacker wants, so the /// longer does: `hound update` performs exactly those steps, as root, and is
/// install goes through `apt`, with the user present and authenticating — /// the path covered by tests. Two implementations of a privileged install is
/// the same path they would take by hand, minus the typing. /// one more than anybody wants to keep correct — so the app asks polkit to
/// run the real one and reports what happened.
/// ///
/// Two checks before anything is handed over, because a signed manifest /// Hound still does not replace its own binary. `hound update` hands the
/// establishes what the publisher intended and nothing more: the URL must be /// verified package to apt, with the user present and authenticating.
/// on our own download host, and the file must hash to what the manifest
/// said. The manifest's signature is what makes that hash trustworthy.
fn install_update(app: &tauri::AppHandle) { fn install_update(app: &tauri::AppHandle) {
use sha2::{Digest, Sha256};
let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else { let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else {
return; return;
}; };
@ -159,9 +161,9 @@ fn install_update(app: &tauri::AppHandle) {
let proceed = app let proceed = app
.dialog() .dialog()
.message(format!( .message(format!(
"Hound {} is available.\n\nThe package will be downloaded, checked against \ "Hound {} is available.\n\nThe package will be downloaded, checked against its \
its signed manifest, and installed by your system package manager. You will \ signed manifest, and installed by your system package manager. You will be \
be asked to authenticate.", asked to authenticate.",
pending.version pending.version
)) ))
.title(format!("Install Hound {}?", pending.version)) .title(format!("Install Hound {}?", pending.version))
@ -174,106 +176,35 @@ fn install_update(app: &tauri::AppHandle) {
return; return;
} }
let fail = |msg: String| { let notify_result = |title: String, body: &str| {
let _ = app let _ = app.notification().builder().title(title).body(body).show();
.notification()
.builder()
.title("Update failed")
.body(&msg)
.show();
}; };
// A signed manifest says what the publisher intended; it does not stop a
// publisher's mistake pointing somewhere else. Refuse anything off our
// own download host.
if !pending.deb_url.starts_with("https://dl.houndav.com/") {
fail("The update points somewhere unexpected and was not downloaded.".into());
return;
}
let body = match ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(300))
.build()
.get(&pending.deb_url)
.call()
{
Ok(r) => {
let mut buf = Vec::new();
use std::io::Read as _;
// 256 MB is far beyond any real package and well short of
// filling a disk.
if r.into_reader().take(256 << 20).read_to_end(&mut buf).is_err() {
fail("The download did not complete.".into());
return;
}
buf
}
Err(e) => {
fail(format!("Could not download the update: {e}"));
return;
}
};
let got = Sha256::digest(&body);
let got = got.iter().map(|b| format!("{b:02x}")).collect::<String>();
if !pending.sha256.is_empty() && got != pending.sha256 {
fail("The downloaded package does not match its signed checksum. It was discarded.".into());
return;
}
// Into a root-owned directory, so nothing can swap the file between the
// check above and apt reading it.
let path = std::path::Path::new("/var/lib/hound/updates").join(format!(
"hound_{}_amd64.deb",
pending.version
));
if elevate_write(&path, &body).is_err() {
fail("Could not stage the update.".into());
return;
}
match std::process::Command::new("pkexec") match std::process::Command::new("pkexec")
.arg("/usr/bin/apt-get") .args(["/usr/bin/hound", "update", "--yes"])
.arg("install") .output()
.arg("-y")
.arg(&path)
.status()
{ {
Ok(st) if st.success() => { Ok(out) if out.status.success() => {
let _ = app notify_result(
.notification() format!("Hound {} installed", pending.version),
.builder() "The new version is active.",
.title(format!("Hound {} installed", pending.version)) );
.body("The new version is active.")
.show();
} }
Ok(st) if st.code() == Some(126) => {} // dismissed; not a failure // pkexec's own exit codes: 126 dismissed, 127 not authorised. Neither
Ok(_) => fail("The package manager refused the update.".into()), // is a failure worth an alarming notification.
Err(e) => fail(format!("Could not start the installer: {e}")), Ok(out) if matches!(out.status.code(), Some(126) | Some(127)) => {}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
notify_result(
"Update failed".into(),
if err.trim().is_empty() {
"The package manager refused the update."
} else {
err.trim()
},
);
} }
} Err(e) => notify_result("Update failed".into(), &format!("Could not start the installer: {e}")),
/// Write a staged package as root, via the same polkit path as everything
/// else that needs privilege.
fn elevate_write(path: &std::path::Path, body: &[u8]) -> Result<(), String> {
use std::io::Write as _;
let mut child = std::process::Command::new("pkexec")
.arg("/usr/bin/hound")
.arg("stage-update")
.arg(path)
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
child
.stdin
.as_mut()
.ok_or("no stdin")?
.write_all(body)
.map_err(|e| e.to_string())?;
match child.wait() {
Ok(st) if st.success() => Ok(()),
Ok(st) => Err(format!("staging exited {:?}", st.code())),
Err(e) => Err(e.to_string()),
} }
} }
@ -487,7 +418,19 @@ fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
} else { } else {
"protected" "protected"
}; };
let img = match icons.0.get(state) { // Monochrome deliberately drops the state colour — that is the whole
// point of asking for it. The tooltip still says which state we are in,
// so the information is not lost, only the colour is.
let state_key = if MONOCHROME.load(Ordering::Relaxed) {
if THEME_IS_DARK.load(Ordering::Relaxed) {
"mono-light"
} else {
"mono-dark"
}
} else {
state
};
let img = match icons.0.get(state_key) {
Some(i) => i.clone(), Some(i) => i.clone(),
None => icons None => icons
.0 .0
@ -501,6 +444,17 @@ fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
} }
} }
/// The webview resolved "follow system" to an actual theme.
///
/// Only the page can answer this — prefers-color-scheme lives in the webview,
/// and there is no portable way for the Rust side to ask the desktop. So the
/// frontend decides and tells us, which also keeps one place deciding which
/// theme is showing.
#[tauri::command]
fn set_theme_resolved(dark: bool) {
THEME_IS_DARK.store(dark, Ordering::Relaxed);
}
#[tauri::command] #[tauri::command]
fn set_state( fn set_state(
app: tauri::AppHandle, app: tauri::AppHandle,
@ -522,6 +476,10 @@ struct PendingUpdate {
} }
static PENDING_UPDATE: std::sync::Mutex<Option<PendingUpdate>> = std::sync::Mutex::new(None); static PENDING_UPDATE: std::sync::Mutex<Option<PendingUpdate>> = std::sync::Mutex::new(None);
/// The version we have already told the user about, so a release is announced
/// once rather than on every poll.
static ANNOUNCED_VERSION: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// The "Install update" entry, kept so the watcher can retitle and /// The "Install update" entry, kept so the watcher can retitle and
/// enable it when the daemon learns a release exists. /// enable it when the daemon learns a release exists.
struct UpdateMenuItem(MenuItem<tauri::Wry>); struct UpdateMenuItem(MenuItem<tauri::Wry>);
@ -566,6 +524,10 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
Ok(s) => { Ok(s) => {
CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed); CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed);
CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed); CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed);
MONOCHROME.store(s.tray_icon_style == "monochrome", Ordering::Relaxed);
if s.theme != "auto" {
THEME_IS_DARK.store(s.theme == "dark", Ordering::Relaxed);
}
s.paused s.paused
} }
Err(_) => false, Err(_) => false,
@ -598,6 +560,20 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
let f = &st.freshness; let f = &st.freshness;
*ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone(); *ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone();
if !f.update_version.is_empty() { if !f.update_version.is_empty() {
// Once per version, not once per poll — this loop runs
// every second and nobody wants that notification 3,600
// times an hour.
let mut announced = ANNOUNCED_VERSION.lock().expect("announce lock");
if *announced != f.update_version {
*announced = f.update_version.clone();
let _ = app
.notification()
.builder()
.title(format!("Hound {} is available", f.update_version))
.body("Open the Hound tray menu to install it.")
.show();
}
drop(announced);
*PENDING_UPDATE.lock().expect("update lock") = Some(PendingUpdate { *PENDING_UPDATE.lock().expect("update lock") = Some(PendingUpdate {
version: f.update_version.clone(), version: f.update_version.clone(),
deb_url: f.update_deb_url.clone(), deb_url: f.update_deb_url.clone(),
@ -610,7 +586,10 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
let _ = item.0.set_text("No update available"); let _ = item.0.set_text("No update available");
let _ = item.0.set_enabled(false); let _ = item.0.set_enabled(false);
} else { } else {
let _ = item.0.set_text(format!("Install Hound {}", f.update_version)); let _ = item.0.set_text(format!(
"Update available — install Hound {}…",
f.update_version
));
let _ = item.0.set_enabled(true); let _ = item.0.set_enabled(true);
} }
} }
@ -646,12 +625,19 @@ fn notify(app: &tauri::AppHandle, ev: &Event) {
// applications menu, the setup hook failed and the whole app panicked before // applications menu, the setup hook failed and the whole app panicked before
// a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB // a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB
// binary, and it makes the tray icon unable to be missing. // binary, and it makes the tray icon unable to be missing.
const ICON_BYTES: [(&str, &[u8]); 5] = [ const ICON_BYTES: [(&str, &[u8]); 7] = [
("protected", include_bytes!("../icons/state-protected-22.png")), ("protected", include_bytes!("../icons/state-protected-22.png")),
("scanning", include_bytes!("../icons/state-scanning-22.png")), ("scanning", include_bytes!("../icons/state-scanning-22.png")),
("threat", include_bytes!("../icons/state-threat-22.png")), ("threat", include_bytes!("../icons/state-threat-22.png")),
("paused", include_bytes!("../icons/state-paused-22.png")), ("paused", include_bytes!("../icons/state-paused-22.png")),
("attention", include_bytes!("../icons/state-attention-22.png")), ("attention", include_bytes!("../icons/state-attention-22.png")),
// Monochrome: one glyph, no state colour. Which tone depends on the
// panel it sits in, and no portable way exists to ask a panel what
// colour it is — so it follows the app's own resolved theme, on the
// reasonable assumption that someone running a light desktop has a
// light panel.
("mono-light", include_bytes!("../icons/state-mono-light-22.png")),
("mono-dark", include_bytes!("../icons/state-mono-dark-22.png")),
]; ];
fn load_state_icons() -> R<HashMap<String, Image<'static>>> { fn load_state_icons() -> R<HashMap<String, Image<'static>>> {
@ -686,7 +672,8 @@ pub fn run() {
rootkit_scan, rootkit_scan,
realtime_status, realtime_status,
realtime_set_enabled, realtime_set_enabled,
set_state set_state,
set_theme_resolved
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Hound Antivirus", "productName": "Hound Antivirus",
"version": "0.1.2", "version": "0.1.3",
"identifier": "com.joelovestech.hound", "identifier": "com.joelovestech.hound",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",