Three reports, one shape: the app told the user something had happened when it had not. **Appearance was stored in the wrong place.** theme and tray_icon_style lived in the daemon's settings, which need root to write. So toggling to a monochrome tray produced a rejected write, no visible change, and a switch still sitting where it had been put — the screen disagreeing with reality. Appearance is a per-user preference: it belongs to the person, not the machine, and putting it in the daemon would also have made one user's choice everyone's on a shared box. It is client-side now, applies the instant the control moves, and never prompts for a password. **There was a Save button.** A settings screen with one lets you walk away with your changes discarded, and here it also hid the failure above. It is gone. Every control writes on change, batched behind a short delay so toggling three switches is one request and one authentication prompt rather than three, and typed fields wait for a pause rather than firing per keystroke. A write the daemon refuses now reloads the real settings, so a control never keeps a value that was not accepted. **The app did not notice being replaced.** After an update the running process is still the old binary showing the old front-end, which is indistinguishable from an update that did nothing — the Appearance panel was in the installed package the whole time and could not be found without quitting and relaunching. The daemon restarts on upgrade, so its reported version is the authority on what is installed; when it stops matching this process's own, the app says so and reopens. One attempt only, so a version that never matches cannot become a restart loop. Also: the tray repaints immediately on an appearance change rather than waiting for the next poll, and the stored appearance is applied before first paint so the window does not flash the wrong theme on the way in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
899 lines
36 KiB
Rust
899 lines
36 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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
pub fn run() {
|
|
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
|
|
])
|
|
.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);
|
|
|
|
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");
|
|
}
|
|
|
|
fn main() {
|
|
run()
|
|
}
|