diff --git a/Cargo.lock b/Cargo.lock index a8d5416..6f8af31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "hound" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "clap", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "hound-api" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "serde", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "hound-defs" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ed25519-dalek", "serde", @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "hound-mcp" -version = "0.1.3" +version = "0.1.4" dependencies = [ "hound-api", "hound-supply", @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "hound-supply" -version = "0.1.3" +version = "0.1.4" dependencies = [ "hound-defs", "serde", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "houndd" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index f794998..ee66e19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.3" +version = "0.1.4" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus.git" diff --git a/dist/hound_0.1.4_amd64.deb b/dist/hound_0.1.4_amd64.deb new file mode 100644 index 0000000..5a78369 Binary files /dev/null and b/dist/hound_0.1.4_amd64.deb differ diff --git a/gui/dist/app.js b/gui/dist/app.js index e523cef..1cc77e1 100644 --- a/gui/dist/app.js +++ b/gui/dist/app.js @@ -13,6 +13,27 @@ 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. +// 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)"); let themeChoice = "auto"; function applyTheme(choice) { @@ -436,9 +457,9 @@ async function loadSettings() { $("set-ransom").checked = s.ransomware_guard; $("set-ransom-thresh").value = s.ransomware_threshold_per_min; $("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"); + const prefs = loadPrefs(); + $("set-theme").value = prefs.theme || "auto"; + $("set-monochrome").checked = prefs.monochrome === true; } catch (e) { $("settings-msg").textContent = "Failed to load: " + explain(e); } @@ -453,8 +474,6 @@ async function saveSettings() { s.realtime_enabled = $("set-realtime").checked; s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean); s.on_detect = $("set-ondetect").value; - s.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.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean); 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.rootkit_enabled = $("set-rootkit").checked; try { + $("settings-msg").textContent = "Saving…"; const saved = await invoke("set_settings", { s }); paused = saved.paused; $("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection"; $("settings-msg").textContent = "Saved ✓"; - setTimeout(() => ($("settings-msg").textContent = ""), 2500); + setTimeout(() => { + if ($("settings-msg").textContent === "Saved ✓") $("settings-msg").textContent = ""; + }, 2000); loadRealtime().catch(() => {}); } catch (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 ───────────────────────────────────────────────────────── function setButtons(disabled) { 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-alerts-refresh").addEventListener("click", () => loadAlerts()); $("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 () => { 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. 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(); diff --git a/gui/dist/index.html b/gui/dist/index.html index 1d3e97b..030cefa 100644 --- a/gui/dist/index.html +++ b/gui/dist/index.html @@ -285,8 +285,8 @@
- + Changes apply as you make them.
diff --git a/gui/package-lock.json b/gui/package-lock.json index 9df7d0a..ca25279 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "hound-gui", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hound-gui", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.2", diff --git a/gui/package.json b/gui/package.json index 88ff2b1..67b1c8d 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "hound-gui", - "version": "0.1.3", + "version": "0.1.4", "description": "Hound Antivirus — desktop app", "type": "module", "scripts": { diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index b284a2b..9ee7589 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hound-api" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "serde", @@ -1477,7 +1477,7 @@ dependencies = [ [[package]] name = "hound-gui" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "hound-api", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 36a4a25..da1a587 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hound-gui" description = "Hound Antivirus desktop app (Tauri 2)" -version = "0.1.3" +version = "0.1.4" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus" diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 1c20879..9969061 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -141,6 +141,30 @@ async fn settings() -> Result { } +/// 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. @@ -438,12 +462,28 @@ fn apply_state(app: &tauri::AppHandle, icons: &TrayIcons, state: &str) { .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, @@ -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 /// theme is showing. #[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); + // 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] @@ -480,6 +523,10 @@ static PENDING_UPDATE: std::sync::Mutex> = std::sync::Mute /// once rather than on every poll. static ANNOUNCED_VERSION: std::sync::Mutex = 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 = 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); @@ -524,10 +571,9 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) { Ok(s) => { CLOSE_TO_TRAY.store(s.close_to_tray, 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); - } + // 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, @@ -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 // published release, or definitions going stale. It ranks below // a threat and below an in-flight scan, and above plain @@ -673,7 +731,8 @@ pub fn run() { realtime_status, realtime_set_enabled, set_state, - set_theme_resolved + set_theme_resolved, + set_tray_style ]) .setup(|app| { let handle = app.handle().clone(); diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index de3fbb1..ff4e253 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hound Antivirus", - "version": "0.1.3", + "version": "0.1.4", "identifier": "com.joelovestech.hound", "build": { "frontendDist": "../dist",