0.1.4: settings apply when you change them, and the app reopens itself

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>
This commit is contained in:
dev 2026-08-21 12:40:59 -05:00
parent 86c33e4648
commit b8744c6bdf
11 changed files with 153 additions and 29 deletions

12
Cargo.lock generated
View file

@ -1089,7 +1089,7 @@ dependencies = [
[[package]] [[package]]
name = "hound" name = "hound"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@ -1104,7 +1104,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-api" name = "hound-api"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1114,7 +1114,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-defs" name = "hound-defs"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"serde", "serde",
@ -1124,7 +1124,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-mcp" name = "hound-mcp"
version = "0.1.3" version = "0.1.4"
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.3" version = "0.1.4"
dependencies = [ dependencies = [
"hound-defs", "hound-defs",
"serde", "serde",
@ -1143,7 +1143,7 @@ dependencies = [
[[package]] [[package]]
name = "houndd" name = "houndd"
version = "0.1.3" version = "0.1.4"
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.3" version = "0.1.4"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus.git" repository = "https://git.joelovestech.com/Hound/Antivirus.git"

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

Binary file not shown.

81
gui/dist/app.js vendored
View file

@ -13,6 +13,27 @@ const $ = (id) => document.getElementById(id);
/// Paint the chosen theme. "auto" defers to the desktop, which the webview /// Paint the chosen theme. "auto" defers to the desktop, which the webview
/// reports through prefers-color-scheme — and which can change while the app /// reports through prefers-color-scheme — and which can change while the app
/// is open, so the listener stays attached rather than sampling once. /// is open, so the listener stays attached rather than sampling once.
// Appearance is a per-user preference and is stored here, not in the daemon.
// Daemon settings need root to write, so putting a theme toggle there meant a
// password prompt to change your icon colour, and one user's choice would
// have been every user's.
const PREFS_KEY = "hound.appearance";
function loadPrefs() {
try {
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
} catch {
return {};
}
}
function savePrefs(patch) {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...patch }));
} catch {
// A browser refusing storage is not a reason to refuse the change; it
// just will not survive a restart.
}
}
const SYSTEM_DARK = window.matchMedia("(prefers-color-scheme: dark)"); const SYSTEM_DARK = window.matchMedia("(prefers-color-scheme: dark)");
let themeChoice = "auto"; let themeChoice = "auto";
function applyTheme(choice) { function applyTheme(choice) {
@ -436,9 +457,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"; const prefs = loadPrefs();
$("set-monochrome").checked = (s.tray_icon_style || "colour") === "monochrome"; $("set-theme").value = prefs.theme || "auto";
applyTheme(s.theme || "auto"); $("set-monochrome").checked = prefs.monochrome === true;
} catch (e) { } catch (e) {
$("settings-msg").textContent = "Failed to load: " + explain(e); $("settings-msg").textContent = "Failed to load: " + explain(e);
} }
@ -453,8 +474,6 @@ 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;
@ -462,17 +481,32 @@ async function saveSettings() {
s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100); s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100);
s.rootkit_enabled = $("set-rootkit").checked; s.rootkit_enabled = $("set-rootkit").checked;
try { try {
$("settings-msg").textContent = "Saving…";
const saved = await invoke("set_settings", { s }); const saved = await invoke("set_settings", { s });
paused = saved.paused; paused = saved.paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection"; $("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
$("settings-msg").textContent = "Saved ✓"; $("settings-msg").textContent = "Saved ✓";
setTimeout(() => ($("settings-msg").textContent = ""), 2500); setTimeout(() => {
if ($("settings-msg").textContent === "Saved ✓") $("settings-msg").textContent = "";
}, 2000);
loadRealtime().catch(() => {}); loadRealtime().catch(() => {});
} catch (e) { } catch (e) {
$("settings-msg").textContent = explain(e); $("settings-msg").textContent = explain(e);
// The control now shows something the daemon rejected. Put it back
// rather than leaving the screen disagreeing with reality.
loadSettings().catch(() => {});
} }
} }
/// Persist on change, with a short delay so typing in a text box does not
/// send a request per keystroke — and so toggling three switches in a row is
/// one write, and one authentication prompt, rather than three.
let saveTimer = null;
function saveSoon(delay = 400) {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => saveSettings(), delay);
}
// ── Wiring ───────────────────────────────────────────────────────── // ── Wiring ─────────────────────────────────────────────────────────
function setButtons(disabled) { function setButtons(disabled) {
for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"]) for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"])
@ -508,7 +542,20 @@ $("btn-pause").addEventListener("click", togglePause);
$("btn-qt-refresh").addEventListener("click", () => loadQuarantine()); $("btn-qt-refresh").addEventListener("click", () => loadQuarantine());
$("btn-alerts-refresh").addEventListener("click", () => loadAlerts()); $("btn-alerts-refresh").addEventListener("click", () => loadAlerts());
$("btn-rootkit").addEventListener("click", runRootkit); $("btn-rootkit").addEventListener("click", runRootkit);
$("btn-settings-save").addEventListener("click", saveSettings); // There is no Save button. A settings screen that needs one lets you leave
// with your changes discarded, and it hid a real failure: writing these needs
// root, so a save could be refused while the switch stayed where you put it.
for (const id of [
"set-paused", "set-autoupdate", "set-notify", "set-realtime",
"set-ondetect", "set-recursive", "set-ransom", "set-rootkit",
]) {
$(id).addEventListener("change", () => saveSoon(0));
}
// Typed fields wait for a pause in typing.
for (const id of ["set-watch", "set-maxsize", "set-excludes", "set-ransom-thresh"]) {
$(id).addEventListener("input", () => saveSoon(800));
$(id).addEventListener("blur", () => saveSoon(0));
}
$("btn-qt-add").addEventListener("click", async () => { $("btn-qt-add").addEventListener("click", async () => {
const path = $("qt-add-path").value.trim(); const path = $("qt-add-path").value.trim();
@ -571,6 +618,24 @@ 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)); $("set-theme").addEventListener("change", (e) => {
savePrefs({ theme: e.target.value });
applyTheme(e.target.value);
});
$("set-monochrome").addEventListener("change", (e) => {
const monochrome = e.target.checked;
savePrefs({ monochrome });
// Repaint the tray now. Waiting for a Save button, or for the next poll,
// makes a toggle feel broken.
invoke("set_tray_style", { monochrome }).catch(() => {});
});
// Apply the stored appearance before the first paint, so the window does not
// flash the wrong theme on the way in.
(() => {
const prefs = loadPrefs();
applyTheme(prefs.theme || "auto");
invoke("set_tray_style", { monochrome: prefs.monochrome === true }).catch(() => {});
})();
boot(); boot();

2
gui/dist/index.html vendored
View file

@ -285,8 +285,8 @@
</section> </section>
<div class="actions"> <div class="actions">
<button class="btn primary" id="btn-settings-save">Save Settings</button>
<span class="muted" id="settings-msg"></span> <span class="muted" id="settings-msg"></span>
<span class="muted">Changes apply as you make them.</span>
</div> </div>
</section> </section>

4
gui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.3", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hound-gui", "name": "hound-gui",
"version": "0.1.3", "version": "0.1.4",
"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.3", "version": "0.1.4",
"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.3" version = "0.1.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]] [[package]]
name = "hound-gui" name = "hound-gui"
version = "0.1.3" version = "0.1.4"
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.3" version = "0.1.4"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
repository = "https://git.joelovestech.com/Hound/Antivirus" repository = "https://git.joelovestech.com/Hound/Antivirus"

View file

@ -141,6 +141,30 @@ async fn settings() -> Result<Settings, 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 ──────────────────────────────────────────────────────── // ── Assisted update ────────────────────────────────────────────────────────
/// Install a published release. /// Install a published release.
@ -438,12 +462,28 @@ fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
.cloned() .cloned()
.expect("protected icon always loaded"), .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) { if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_icon(Some(img)); let _ = tray.set_icon(Some(img));
let _ = tray.set_tooltip(Some(tooltip_for(state))); 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. /// The webview resolved "follow system" to an actual theme.
/// ///
/// Only the page can answer this — prefers-color-scheme lives in the webview, /// Only the page can answer this — prefers-color-scheme lives in the webview,
@ -451,8 +491,11 @@ fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) {
/// frontend decides and tells us, which also keeps one place deciding which /// frontend decides and tells us, which also keeps one place deciding which
/// theme is showing. /// theme is showing.
#[tauri::command] #[tauri::command]
fn set_theme_resolved(dark: bool) { fn set_theme_resolved(app: tauri::AppHandle, icons: State<'_, TrayIcons>, dark: bool) {
THEME_IS_DARK.store(dark, Ordering::Relaxed); 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] #[tauri::command]
@ -480,6 +523,10 @@ static PENDING_UPDATE: std::sync::Mutex<Option<PendingUpdate>> = std::sync::Mute
/// once rather than on every poll. /// once rather than on every poll.
static ANNOUNCED_VERSION: std::sync::Mutex<String> = std::sync::Mutex::new(String::new()); 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 /// 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>);
@ -524,10 +571,9 @@ 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); // Appearance is deliberately NOT read from here: it is a
if s.theme != "auto" { // per-user preference owned by the webview, and taking it
THEME_IS_DARK.store(s.theme == "dark", Ordering::Relaxed); // from the daemon would make one user's choice everyone's.
}
s.paused s.paused
} }
Err(_) => false, Err(_) => false,
@ -552,6 +598,18 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
} }
} }
// 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 // Amber for anything that needs the user but is not a threat: a
// published release, or definitions going stale. It ranks below // published release, or definitions going stale. It ranks below
// a threat and below an in-flight scan, and above plain // a threat and below an in-flight scan, and above plain
@ -673,7 +731,8 @@ pub fn run() {
realtime_status, realtime_status,
realtime_set_enabled, realtime_set_enabled,
set_state, set_state,
set_theme_resolved set_theme_resolved,
set_tray_style
]) ])
.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.3", "version": "0.1.4",
"identifier": "com.joelovestech.hound", "identifier": "com.joelovestech.hound",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",