Antivirus/gui/src-tauri/src/main.rs
dev aae41a9371 0.1.6: security hygiene, and right-click scanning
Malware scanning asks whether a file is hostile. The check that
actually loses people their accounts is a different one: what has
already been exposed, and what is about to be? crates/hound-supply/src/
hygiene.rs answers it, and runs as part of every project sweep.

  - A secret file tracked by git. The emergency case: it is in the
    history, in every clone, and in every fork. The advice says rotate
    BEFORE `git rm --cached`, because removing a pushed secret does not
    un-share it, and a test asserts that ordering.
  - Credentials hardcoded in source, recognised by issuer format —
    AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, GitLab, npm, PyPI,
    Google, and the PEM private-key headers.
  - Secret files readable by every account on the machine.
  - Secret files with nothing in .gitignore covering them: the near
    miss that the next `git add -A` turns into the emergency above.
  - GitHub Actions: pull_request_target with a checkout of the pull
    request (a stranger's code, your secrets, your write token), a
    secret echoed into the build log, a downloaded script piped into a
    shell, and third-party actions on a moving tag.

Two rules govern all of it. **Findings are actionable**: no entropy
heuristics, because "high entropy string" is a coin flip a human then
has to adjudicate, and people stop reading after the second false
alarm. Every detector recognises a documented credential format or
reports a structural fact that is true or false. **Nothing secret is
copied into a finding** — a report naming the key it found has moved
the key into a log, a CI artefact, or an assistant's context window,
which is the thing being prevented. There is a test for that.

Tracked-file status comes from parsing .git/index rather than running
git: the sweep is pointed at repositories precisely because they are
not trusted, and starting a subprocess inside one is what a hostile
repository wants.

Against a deliberately bad test repository: four critical, five
warnings, and correctly silent on .env.example and actions/checkout@v4
— flagging those is how a scanner gets ignored.

Also in this release:

  - Right-click "Scan for Threats with Hound" in Nemo, Caja and
    Dolphin, whose menu entries are system files. GNOME Files and
    Thunar keep theirs per-user, so `hound context-menu install`
    handles those. The icon is symbolic, so the file manager recolours
    it to the menu's own theme instead of dropping a violet dog into a
    monochrome menu.
  - A right-click while the app is already open hands the request to
    the running instance rather than refusing. A menu item that
    silently does nothing because the app happens to be open is
    indefensible.
  - Two fixes for the duplicate tray icon. The updater slept a fixed
    600ms after SIGTERM and then started the replacement; if the old
    process outlived that, the panel kept its item and the result was
    two dogs, the older of which could not be clicked or closed because
    nothing was behind it. It now waits for the process to actually
    leave /proc, escalating to SIGKILL after five seconds. And the app
    itself now holds an advisory lock for its lifetime, so a second
    instance cannot exist — the kernel releases the lock however the
    process dies, so a stale one is not a state that can happen.

402 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:15:35 -05:00

1031 lines
41 KiB
Rust

//! `hound-gui` — the Tauri 2 desktop app for Hound Antivirus.
//!
//! The window is a thin view over the same `houndd` Unix socket the CLI uses.
//! The system-tray sentinel swaps between the four state icons:
//!
//! protected (green) / scanning (amber) / threat (red) / paused (gray)
//!
//! `withGlobalTauri` is on in tauri.conf.json and load-bearing: the front-end
//! is plain ES modules with no bundler, so a bare specifier such as
//! "@tauri-apps/api/core" cannot resolve in the webview. It does not error
//! loudly — the script silently fails to load and the window renders its
//! static HTML forever, which is indistinguishable from a daemon that never
//! answered. The API comes off `window.__TAURI__` instead.
//!
//! A background watcher polls the daemon once a second: it keeps the tray in
//! step with daemon-side state (realtime threat → red, scan in flight →
//! amber) and fires a desktop notification for every *new* critical event
//! the daemon logs (quarantine, ransomware alarm, rootkit finding).
use hound_api::{
Client, Event, RealtimeStatus, RootkitScan, ScanResult, Settings, Status, UpdateResult,
QuarantineEntry,
};
use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
/// Whether we have already explained that closing the window is not quitting.
static TRAY_HINT_SHOWN: AtomicBool = AtomicBool::new(false);
/// The two window-behaviour settings, cached from the daemon by the watcher
/// thread. A window-close handler must answer immediately — it cannot make a
/// 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
/// 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 CONFIRM_QUIT: AtomicBool = AtomicBool::new(true);
use tauri::image::Image;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager, State};
use tauri_plugin_notification::NotificationExt as _;
type R<T> = anyhow::Result<T>;
const TRAY_ID: &str = "hound-tray";
const POLL_SECS: u64 = 1;
/// Shared between the `scan` command (sets it) and the tray watcher (reads
/// it) so the sentinel shows "scanning" while an on-demand scan is running.
static SCANNING: AtomicBool = AtomicBool::new(false);
/// States the tray can render. Anything unknown falls back to `protected`.
/// Tray states, in the order a viewer would rank their urgency. "attention"
/// is the persistent one — an update waiting, or definitions going stale —
/// as distinct from "scanning", which lasts seconds and which the user
/// started themselves. They are both amber; attention is the deeper shade,
/// so the two are distinguishable side by side.
const STATES: [&str; 5] = ["protected", "scanning", "threat", "paused", "attention"];
/// The four preloaded state icons, managed so tray swaps never hit disk.
#[derive(Clone, Default)]
struct TrayIcons(HashMap<String, Image<'static>>);
fn client() -> Client {
Client::default_path()
}
// ── Commands (window → daemon) ──────────────────────────────────────────────
#[tauri::command]
async fn status() -> Result<Status, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.status())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
/// Expand a leading `~` against the running user's home.
///
/// The tray menu and the Scan Home button both send the literal string "~".
/// A shell would have expanded it; nothing here does, so the daemon was being
/// asked to scan a directory of that name. It failed silently until the
/// per-peer readability check started reporting refusals out loud.
fn expand_home(path: &str) -> String {
let Some(home) = std::env::var_os("HOME") else {
return path.to_string();
};
let home = home.to_string_lossy();
match path {
"~" => home.into_owned(),
p if p.starts_with("~/") => format!("{home}/{}", &p[2..]),
p => p.to_string(),
}
}
#[tauri::command]
async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> {
let c = client();
let path = expand_home(&path);
SCANNING.store(true, Ordering::Relaxed);
let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
.await
.map_err(|e| e.to_string())?;
SCANNING.store(false, Ordering::Relaxed);
r.map_err(|e| e.to_string())
}
#[tauri::command]
async fn update() -> Result<UpdateResult, String> {
let c = client();
let first = tauri::async_runtime::spawn_blocking(move || c.update())
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("update", None).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn settings() -> Result<Settings, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.settings())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
/// Relaunch after the package has been replaced.
///
/// There is no state in this window worth preserving — it is a view over the
/// daemon, and everything it shows is refetched a second later. So this
/// restarts rather than asking, and says why afterwards.
fn restart_into_new_version(app: &tauri::AppHandle, installed: &str) {
// Guard against a restart loop: if the new binary somehow still reports
// the old version, one attempt is enough to notice something is wrong.
static RESTARTED: AtomicBool = AtomicBool::new(false);
if RESTARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = app
.notification()
.builder()
.title(format!("Hound updated to {installed}"))
.body("Reopening to finish.")
.show();
// Give the notification a moment to reach the daemon before this process
// goes away, or it never appears.
std::thread::sleep(std::time::Duration::from_millis(400));
app.restart();
}
// ── Assisted update ────────────────────────────────────────────────────────
/// Install a published release.
///
/// This used to download, hash-check and stage the package itself. It no
/// longer does: `hound update` performs exactly those steps, as root, and is
/// the path covered by tests. Two implementations of a privileged install is
/// one more than anybody wants to keep correct — so the app asks polkit to
/// run the real one and reports what happened.
///
/// Hound still does not replace its own binary. `hound update` hands the
/// verified package to apt, with the user present and authenticating.
fn install_update(app: &tauri::AppHandle) {
let Some(pending) = PENDING_UPDATE.lock().ok().and_then(|g| g.clone()) else {
return;
};
let proceed = app
.dialog()
.message(format!(
"Hound {} is available.\n\nThe package will be downloaded, checked against its \
signed manifest, and installed by your system package manager. You will be \
asked to authenticate.",
pending.version
))
.title(format!("Install Hound {}?", pending.version))
.buttons(MessageDialogButtons::OkCancelCustom(
"Install".into(),
"Not now".into(),
))
.blocking_show();
if !proceed {
return;
}
let notify_result = |title: String, body: &str| {
let _ = app.notification().builder().title(title).body(body).show();
};
match std::process::Command::new("pkexec")
.args(["/usr/bin/hound", "update", "--yes"])
.output()
{
Ok(out) if out.status.success() => {
notify_result(
format!("Hound {} installed", pending.version),
"The new version is active.",
);
}
// pkexec's own exit codes: 126 dismissed, 127 not authorised. Neither
// is a failure worth an alarming notification.
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}")),
}
}
// ── Elevation ───────────────────────────────────────────────────────────────
/// Run one daemon method as root, via polkit.
///
/// The daemon refuses everything that writes unless the caller is uid 0 — the
/// app runs as you, so those requests need elevating. `pkexec` asks polkit to
/// authenticate the person (password, or fingerprint on hardware that has
/// one) and then runs `hound admin-rpc` as root, which forwards a single
/// request and prints the response. See packaging/polkit/.
///
/// pkexec exits 126 when the user dismisses the dialog and 127 when
/// authorisation is refused; both are ordinary outcomes, not errors to
/// apologise for.
fn elevate(method: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
use std::io::Write as _;
use std::process::{Command, Stdio};
let mut req = json!({ "jsonrpc": "2.0", "id": 1, "method": method });
if let Some(p) = params {
req["params"] = p;
}
let mut child = Command::new("pkexec")
.arg("/usr/bin/hound")
.arg("admin-rpc")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot start pkexec: {e}"))?;
child
.stdin
.as_mut()
.ok_or("pkexec has no stdin")?
.write_all(req.to_string().as_bytes())
.map_err(|e| format!("sending the request: {e}"))?;
let out = child
.wait_with_output()
.map_err(|e| format!("waiting for pkexec: {e}"))?;
match out.status.code() {
Some(0) => serde_json::from_slice(&out.stdout)
.map_err(|e| format!("decoding the response: {e}")),
Some(126) => Err("Cancelled.".into()),
Some(127) => Err("Not authorised to make this change.".into()),
_ => {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
Err(if err.is_empty() {
"The privileged helper failed.".into()
} else {
err.to_string()
})
}
}
}
/// True when the daemon refused us for want of privileges, as opposed to
/// anything else going wrong. Only that case is worth a password prompt.
fn needs_root(e: &str) -> bool {
e.contains("requires administrator privileges")
}
#[tauri::command]
async fn set_settings(s: Settings) -> Result<Settings, String> {
let c = client();
let for_retry = s.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.set_settings(&s))
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("settings.set", Some(serde_json::to_value(&for_retry).unwrap_or_default()))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn events(limit: u32) -> Result<Vec<Event>, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.events(limit))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn clear_events() -> Result<u64, String> {
let c = client();
let first = tauri::async_runtime::spawn_blocking(move || c.clear_events())
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("events.clear", None)
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn quarantine_list() -> Result<Vec<QuarantineEntry>, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_list())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_add(path: String, virus: String) -> Result<QuarantineEntry, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_add(&path, &virus))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> {
let c = client();
let for_retry = id.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id))
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("quarantine.restore", Some(json!({ "id": for_retry })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn quarantine_remove(id: String) -> Result<u64, String> {
let c = client();
let for_retry = id.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id))
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("quarantine.remove", Some(json!({ "id": for_retry })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn rootkit_scan() -> Result<RootkitScan, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.rootkit_scan())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn realtime_status() -> Result<RealtimeStatus, String> {
let c = client();
tauri::async_runtime::spawn_blocking(move || c.realtime_status())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> {
let c = client();
let first = tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled))
.await
.map_err(|e| e.to_string())?;
match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("realtime.set_enabled", Some(json!({ "enabled": enabled })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
}
/// Swap the tray icon + tooltip. The window calls this to keep the sentinel
/// in step with what the user is doing (e.g. it starts a scan). The backend
/// watcher also calls the same routine as daemon state changes underneath.
fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
let state = if STATES.contains(&state) {
state
} else {
"protected"
};
// 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(),
None => icons
.0
.get("protected")
.cloned()
.expect("protected icon always loaded"),
};
if let Ok(mut cur) = CURRENT_STATE.lock() {
*cur = state.to_string();
}
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_icon(Some(img));
let _ = tray.set_tooltip(Some(tooltip_for(state)));
}
}
/// Switch the tray between the colour ladder and a single-tone glyph.
///
/// Appearance is a per-user preference, so it does not live in the daemon's
/// settings — those require root to write, and changing your icon style
/// should not ask for a password. The webview owns the preference and tells
/// us; we repaint immediately rather than waiting for the next poll, because
/// a toggle that takes a second to visibly do anything reads as broken.
#[tauri::command]
fn set_tray_style(app: tauri::AppHandle, icons: State<'_, TrayIcons>, monochrome: bool) {
MONOCHROME.store(monochrome, Ordering::Relaxed);
apply_state(&app, &icons, &CURRENT_STATE.lock().map(|g| g.clone()).unwrap_or_default());
}
/// 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(app: tauri::AppHandle, icons: State<'_, TrayIcons>, dark: bool) {
THEME_IS_DARK.store(dark, Ordering::Relaxed);
// The monochrome tone follows the theme, so repaint now rather than a
// second from now.
apply_state(&app, &icons, &CURRENT_STATE.lock().map(|g| g.clone()).unwrap_or_default());
}
#[tauri::command]
fn set_state(
app: tauri::AppHandle,
icons: State<'_, TrayIcons>,
state: String,
) -> Result<(), String> {
apply_state(&app, &icons, &state);
Ok(())
}
/// A release the daemon has verified as newer, cached so the menu handler
/// does not have to make a round-trip while the user is waiting on a click.
#[derive(Clone)]
struct PendingUpdate {
version: String,
deb_url: String,
sha256: String,
notes_url: String,
}
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 tray state last painted, so an appearance change can repaint the same
/// state instead of guessing one and briefly showing the wrong colour.
static CURRENT_STATE: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// The "Install update" entry, kept so the watcher can retitle and
/// enable it when the daemon learns a release exists.
struct UpdateMenuItem(MenuItem<tauri::Wry>);
/// The daemon's one-line reason for the amber state, so the tooltip can say
/// what needs attention rather than only that something does.
static ATTENTION_REASON: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
fn tooltip_for(state: &str) -> String {
match state {
"threat" => "Hound — threat found".into(),
"scanning" => "Hound — scanning…".into(),
"paused" => "Hound — paused".into(),
"attention" => {
let why = ATTENTION_REASON.lock().map(|g| g.clone()).unwrap_or_default();
if why.is_empty() {
"Hound — needs attention".into()
} else {
format!("Hound — {why}")
}
}
_ => "Hound — protected".into(),
}
}
// ── Tray watcher: state sync + critical-event notifications ─────────────────
/// Poll the daemon; reflect daemon-side state in the tray and notify on new
/// critical events the frontend hasn't already surfaced. Runs as a plain
/// OS thread: one blocking client round-trip per tick is exactly what this
/// wants, and it keeps the command side free of scheduler gymnastics.
fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
std::thread::spawn(move || {
let mut last_seen_id: u64 = 0;
loop {
std::thread::sleep(std::time::Duration::from_secs(POLL_SECS));
let c = client();
if c.status().is_err() {
continue; // daemon restarting — skip the tick
}
let paused = match c.settings() {
Ok(s) => {
CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed);
CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed);
// Appearance is deliberately NOT read from here: it is a
// per-user preference owned by the webview, and taking it
// from the daemon would make one user's choice everyone's.
s.paused
}
Err(_) => false,
};
// Derive tray state: a fresh critical event wins, then an
// in-flight scan, then paused, then protected.
let mut state = "protected";
if let Ok(evts) = c.events(20) {
if let Some(latest) = evts.first() {
if latest.severity == "critical" && latest.id > last_seen_id {
last_seen_id = latest.id;
state = "threat";
notify(&app, latest);
}
}
}
if state == "protected" {
if SCANNING.load(Ordering::Relaxed) {
state = "scanning";
} else if paused {
state = "paused";
}
}
// The package can be replaced underneath a running app — by the
// tray item, by `sudo hound update`, or by apt directly. When it
// is, this process is still the old binary showing the old
// front-end, which looks exactly like an update that did nothing.
// The daemon restarts itself on upgrade, so its version is the
// authority on what is installed.
if let Ok(st) = c.status() {
if !st.daemon_version.is_empty() && st.daemon_version != env!("CARGO_PKG_VERSION") {
restart_into_new_version(&app, &st.daemon_version);
}
}
// Amber for anything that needs the user but is not a threat: a
// published release, or definitions going stale. It ranks below
// a threat and below an in-flight scan, and above plain
// protected — it is a nudge, not an alarm.
if let Ok(st) = c.status() {
let f = &st.freshness;
*ATTENTION_REASON.lock().expect("reason lock") = f.summary.clone();
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 {
version: f.update_version.clone(),
deb_url: f.update_deb_url.clone(),
sha256: f.update_deb_sha256.clone(),
notes_url: f.update_notes_url.clone(),
});
}
if let Some(item) = app.try_state::<UpdateMenuItem>() {
if f.update_version.is_empty() {
let _ = item.0.set_text("No update available");
let _ = item.0.set_enabled(false);
} else {
let _ = item.0.set_text(format!(
"Update available — install Hound {}",
f.update_version
));
let _ = item.0.set_enabled(true);
}
}
if state == "protected" && f.wants_attention() {
state = "attention";
}
}
apply_state(&app, &icons, state);
}
});
}
fn notify(app: &tauri::AppHandle, ev: &Event) {
let title = match ev.kind.as_str() {
"quarantine" | "threat" => "Hound — threat quarantined",
"ransomware" => "Hound — ransomware suspected",
"rootkit" => "Hound — rootkit activity",
_ => "Hound — alert",
};
let notification = app.notification();
let _ = notification
.builder()
.title(title)
.body(&ev.message)
.show();
}
// ── Icon resolution ─────────────────────────────────────────────────────────
// Tray icons are compiled in rather than read from disk. They were being
// loaded from a relative "icons/" path, which resolves only when the binary
// runs from its build directory — installed to /usr/bin and launched from the
// 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
// binary, and it makes the tray icon unable to be missing.
const ICON_BYTES: [(&str, &[u8]); 7] = [
("protected", include_bytes!("../icons/state-protected-22.png")),
("scanning", include_bytes!("../icons/state-scanning-22.png")),
("threat", include_bytes!("../icons/state-threat-22.png")),
("paused", include_bytes!("../icons/state-paused-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>>> {
let mut icons = HashMap::new();
for (state, bytes) in ICON_BYTES {
let img = Image::from_bytes(bytes)
.map_err(|e| anyhow::anyhow!("decoding the {state} tray icon: {e}"))?;
icons.insert(state.to_string(), img);
}
Ok(icons)
}
// ── App ─────────────────────────────────────────────────────────────────────
/// Refuse to start if this user already has Hound running.
///
/// Two processes mean two tray icons, and the second one is a puzzle: it
/// looks identical and does nothing, because the panel is showing an item
/// registered by a process that is no longer answering. An advisory lock held
/// for the lifetime of the process is the cheap, correct answer — the kernel
/// releases it however we exit, including a crash, so a stale lock is not a
/// state that can happen.
fn claim_single_instance() -> Option<std::fs::File> {
let dir = std::env::var("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir());
let path = dir.join("hound-gui.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.ok()?;
// SAFETY: flock on a file descriptor we own. LOCK_NB means this returns
// rather than waiting if somebody else holds it.
let rc = unsafe {
libc::flock(
std::os::unix::io::AsRawFd::as_raw_fd(&file),
libc::LOCK_EX | libc::LOCK_NB,
)
};
if rc == 0 {
Some(file)
} else {
None
}
}
/// Paths passed on the command line, from a file manager's context menu.
///
/// Kept in a global rather than threaded through the builder because the
/// front-end asks for them once it is ready to display results, and by then
/// the argument vector is long gone.
static SCAN_ON_START: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
#[tauri::command]
fn take_scan_request() -> Vec<String> {
// Taken, not read: a second call must not rescan, or reopening the window
// would start the scan again.
SCAN_ON_START
.lock()
.map(|mut g| std::mem::take(&mut *g))
.unwrap_or_default()
}
pub fn run() {
// "hound-gui --scan PATH…" is how a file manager's right-click arrives.
let mut args = std::env::args().skip(1).peekable();
let mut wanted: Vec<String> = Vec::new();
while let Some(a) = args.next() {
match a.as_str() {
"--scan" => wanted.extend(args.by_ref()),
other if !other.starts_with('-') => wanted.push(other.to_string()),
_ => {}
}
}
// Held for the lifetime of the process; dropping it releases the lock.
let Some(_instance_lock) = claim_single_instance() else {
// Already running. Hand the request to the instance that owns the
// window rather than refusing — a right-click that silently does
// nothing because the app happens to be open is indefensible.
if !wanted.is_empty() {
let _ = handoff_scan(&wanted);
} else {
eprintln!("hound-gui is already running for this user");
}
return;
};
if !wanted.is_empty() {
if let Ok(mut g) = SCAN_ON_START.lock() {
*g = wanted;
}
}
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
status,
scan,
update,
settings,
set_settings,
events,
clear_events,
quarantine_list,
quarantine_add,
quarantine_restore,
quarantine_remove,
rootkit_scan,
realtime_status,
realtime_set_enabled,
set_state,
set_theme_resolved,
set_tray_style,
take_scan_request
])
.setup(|app| {
let handle = app.handle().clone();
let icons = TrayIcons(load_state_icons()?);
let initial_icon = icons
.0
.get("protected")
.expect("protected icon loaded")
.clone();
let watcher_icons = icons.clone();
app.manage(icons);
let open = MenuItem::with_id(&handle, "open", "Open Hound", true, None::<&str>)?;
let scan_home =
MenuItem::with_id(&handle, "scan-home", "Scan Home Folder", true, None::<&str>)?;
let scan_downloads = MenuItem::with_id(
&handle,
"scan-downloads",
"Scan Downloads",
true,
None::<&str>,
)?;
let update_sig =
MenuItem::with_id(&handle, "update", "Update Signatures", true, None::<&str>)?;
// Two items that exist only when they mean something. A permanently
// greyed "Install update" teaches people the menu is decorative;
// these are enabled/disabled from the watcher as the daemon's
// answer changes, and their labels carry the version.
let install_item = MenuItem::with_id(
&handle,
"install-update",
"No update available",
false,
None::<&str>,
)?;
let sep = PredefinedMenuItem::separator(&handle)?;
let quit = MenuItem::with_id(&handle, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(
&handle,
&[
&open,
&scan_home,
&scan_downloads,
&update_sig,
&install_item,
&sep,
&quit,
],
)?;
let _ = TrayIconBuilder::with_id(TRAY_ID)
.icon(initial_icon)
.tooltip("Hound — protected")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| {
let id = event.id().as_ref();
let window = match app.get_webview_window("main") {
Some(w) => w,
None => return,
};
match id {
"open" => {
let _ = window.show();
let _ = window.set_focus();
}
"quit" => {
// The only route out of the app, and it asks
// first: closing the window hides it instead, so
// a user who means to quit has come here on
// purpose and a user who clicked by accident has
// not lost their protection silently.
if !CONFIRM_QUIT.load(Ordering::Relaxed) {
app.exit(0);
return;
}
let app = app.clone();
std::thread::spawn(move || {
let quit = app
.dialog()
.message(
"Real-time protection will keep running in the \
background, but Hound's window and tray icon will \
close.",
)
.title("Quit Hound?")
.buttons(MessageDialogButtons::OkCancelCustom(
"Quit".into(),
"Keep running".into(),
))
.blocking_show();
if quit {
app.exit(0);
}
});
}
"scan-home" => {
let _ =
window.emit("tray-event", json!({ "action": "scan", "path": "~" }));
}
"scan-downloads" => {
let _ = window.emit(
"tray-event",
json!({ "action": "scan", "path": "~/Downloads" }),
);
}
"install-update" => {
let app = app.clone();
std::thread::spawn(move || install_update(&app));
}
"update" => {
let _ = window.emit("tray-event", json!({ "action": "update" }));
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(&handle)?;
app.manage(UpdateMenuItem(install_item.clone()));
start_watcher(handle.clone(), watcher_icons);
start_handoff_watcher(handle.clone());
Ok(())
})
.on_window_event(|window, event| {
// Closing the window hides it; it never exits. An antivirus that
// stops protecting you because you clicked the X is not an
// antivirus. Quitting is deliberate, from the tray menu, and
// confirmed there.
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if !CLOSE_TO_TRAY.load(Ordering::Relaxed) {
return; // the user turned this off in Settings
}
api.prevent_close();
let _ = window.hide();
// Say so once, so the window vanishing does not read as a
// crash. After that it is expected behaviour and silence is
// the right amount of noise.
if !TRAY_HINT_SHOWN.swap(true, Ordering::Relaxed) {
let _ = window
.app_handle()
.notification()
.builder()
.title("Hound is still running")
.body("Protection continues in the background. Open it again from the tray icon.")
.show();
}
}
})
.run(tauri::generate_context!())
.expect("error while running Hound");
}
/// Notice scan requests dropped by a second launch and act on them.
///
/// Polling a filename is unglamorous and exactly right here: the event is
/// rare, a missed one is retried a second later, and there is no daemon,
/// socket or bus name to keep alive.
fn start_handoff_watcher(app: tauri::AppHandle) {
std::thread::spawn(move || {
let dir = std::env::var("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir());
let request = dir.join("hound-gui.scan");
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
let Ok(body) = std::fs::read_to_string(&request) else {
continue;
};
let _ = std::fs::remove_file(&request);
let paths: Vec<String> = body
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
if paths.is_empty() {
continue;
}
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
let _ = w.emit("scan-request", paths);
}
}
});
}
/// Pass a scan request to the instance that already owns the window.
///
/// A line per path in a file the running instance watches. A socket would be
/// tidier; a file is one syscall, survives the reader being busy, and cannot
/// leave a half-written request behind because the rename is atomic.
fn handoff_scan(paths: &[String]) -> std::io::Result<()> {
let dir = std::env::var("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir());
let tmp = dir.join("hound-gui.scan.tmp");
std::fs::write(&tmp, paths.join("\n"))?;
std::fs::rename(tmp, dir.join("hound-gui.scan"))
}
fn main() {
run()
}