From d274e0df7f9c6cd29be3c898651feb3fe85b893a Mon Sep 17 00:00:00 2001 From: vecnode <229195741+vecnode@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:45:52 +0000 Subject: [PATCH 01/41] Draw the clean surface, so a removal is chosen from a list of what was found Wave 2's last row. Everything under it already existed and was tested: the walk, the age thresholds, the four skips, the re-check at removal, and `act::clean`, which writes the intent, acts, and writes the result. What was missing was the one thing a person actually meets: a surface where the list can be read before it is acted on. The list is the interface. Each allowlisted surface that exists on this machine gets a row with its own count, size, and the reason it kept what it kept (`12 too new`, `3 links`, `4 the system keeps`). Every entry can be unticked, and a surface's checkbox toggles all of its entries at once. 10,000 entries stay scrollable because `show_rows` builds only the rows in view. Four decisions worth naming: - **The walk stays in the shell.** The interface gets a list to draw and returns a selection by path; `clean::select` keeps an item only when the shell's own walk offered that exact path, so a string assembled anywhere else cannot become an instruction to remove a file. Everything kept is re-checked anyway. - **The dry run writes nothing**, and says so: no journal line, no removal, no file of its own. The notice reports what it walked and how long it took. - **An opt-in surface starts unticked.** `MacLogs` is on the allowlist and off by default (6.10), so it appears with its entries and with none of them chosen, rather than being hidden and forgotten. - **The confirmation is one step and names its totals**: "Remove 1,204 files and 12 empty folders, 312.0 MB from Temporary files, Crash dumps and Package manager caches?" A new dry run resets the ticks; the re-walk after a clean does not, so an entry someone deliberately left alone stays left alone. Tests: 88 core, 74 native, 19 interface, 4 xtask. New: a dry run leaves the file and the journal alone; a selection can only reach what the walk offered (an unoffered path and `..` are dropped); totals read as `1,204 files` and `2 empty folders`; a new run clears a selection while an entry it no longer offers cannot stay ticked; only a surface with something ticked becomes a choice; both cards draw and dispatch nothing until a real button is pressed. --- apps/vn-ranger/src/app.rs | 153 +++++++++ crates/native/src/act.rs | 23 ++ crates/native/src/clean.rs | 55 +++ crates/ui/src/act.rs | 58 +--- crates/ui/src/clean.rs | 681 +++++++++++++++++++++++++++++++++++++ crates/ui/src/lib.rs | 85 ++++- crates/ui/src/widgets.rs | 56 +++ crates/ui/src/world.rs | 114 +++++++ 8 files changed, 1180 insertions(+), 45 deletions(-) create mode 100644 crates/ui/src/clean.rs diff --git a/apps/vn-ranger/src/app.rs b/apps/vn-ranger/src/app.rs index 8fc2ea3..51a8fe9 100644 --- a/apps/vn-ranger/src/app.rs +++ b/apps/vn-ranger/src/app.rs @@ -34,6 +34,13 @@ pub struct App { next_scan_unix: u64, /// The clock as the previous frame saw it, which is how a resume is noticed. last_frame_unix: u64, + /// What the last dry run walked, per surface. The interface holds the list for display + /// and returns a selection by path; this is the list a path has to be in before it can + /// become an item, so the interface never hands the action path something it did not + /// see itself. + clean_previews: Vec<(vnr_core::action::Surface, vnr_native::clean::Preview)>, + /// Counts dry runs, so the interface can tell one from another. + clean_run: u64, } impl App { @@ -84,6 +91,7 @@ impl App { suppressions, watch_extra, quarantine: Vec::new(), + clean: None, journal: Vec::new(), journal_damaged: false, }; @@ -135,6 +143,8 @@ impl App { schedule_seed, next_scan_unix, last_frame_unix: now_unix(), + clean_previews: Vec::new(), + clean_run: 0, }; if app.world.snapshot.is_none() { app.start_scan(&cc.egui_ctx, false); @@ -242,6 +252,60 @@ impl App { } } + /// Walk every allowlisted surface and put what a clean would remove into the world. + /// + /// This is the dry run: it reads names, sizes and times and writes nothing — no journal + /// line, no removal, no file of its own. The walk's items stay here rather than in the + /// world, because the world is what the interface draws and this is the only list the + /// action path may remove from. + /// + /// `fresh` says whether this is a new list or the same one after a clean. A new list + /// resets the person's ticks; keeping them after a clean is what lets an entry they had + /// unticked stay unticked instead of quietly returning to a default. + fn refresh_clean(&mut self, fresh: bool) { + let now = now_unix(); + let cancel = || false; + if fresh { + self.clean_run = self.clean_run.saturating_add(1); + } + self.clean_previews.clear(); + let mut report = vnr_ui::CleanReport { + run: self.clean_run, + ..vnr_ui::CleanReport::default() + }; + for surface in vnr_core::action::Surface::ALL { + let roots = vnr_native::clean::roots(surface); + if roots.is_empty() { + // A surface with no folder on this machine is not offered: the list is + // what exists here, not what the table names. + continue; + } + let preview = vnr_native::clean::preview_roots(surface, &roots, now, &cancel); + report.cancelled |= preview.cancelled; + report.surfaces.push(vnr_ui::CleanSurface { + id: surface.id().to_owned(), + label: surface.label().to_owned(), + roots: roots.len(), + opt_in: surface.is_opt_in(), + items: preview + .items + .iter() + .map(|item| vnr_ui::CleanItem { + path: item.path.display().to_string(), + bytes: item.bytes, + is_dir: item.is_dir, + }) + .collect(), + skipped_young: preview.skipped_young, + skipped_links: preview.skipped_links, + skipped_devices: preview.skipped_devices, + skipped_excluded: preview.skipped_excluded, + }); + self.clean_previews.push((surface, preview)); + } + self.world.clean = Some(report); + } + fn drain(&mut self) { let mut messages = Vec::new(); let mut disconnected = false; @@ -699,6 +763,95 @@ impl App { } self.refresh_actions(); } + Action::PreviewClean => { + let started = std::time::Instant::now(); + self.refresh_clean(true); + let (entries, bytes) = self.world.clean.as_ref().map_or((0, 0), |report| { + report + .surfaces + .iter() + .fold((0_usize, 0_u64), |(entries, bytes), surface| { + ( + entries.saturating_add(surface.entries()), + bytes.saturating_add(surface.bytes()), + ) + }) + }); + let elapsed = started.elapsed().as_millis(); + self.world.error = None; + self.world.notice = Some(if entries == 0 { + format!( + "A dry run walked every allowlisted folder in {elapsed} ms and found \ + nothing past the age each surface keeps. It wrote nothing." + ) + } else { + format!( + "A dry run counted {entries} entries, {}, in {elapsed} ms. Nothing was \ + removed and nothing was recorded.", + vnr_ui::widgets::bytes(bytes) + ) + }); + } + Action::CleanSelected(choices) => { + let now = now_unix(); + let cancel = || false; + let mut removed = 0_usize; + let mut bytes = 0_u64; + let mut skipped = 0_usize; + let mut refused = 0_usize; + let mut surfaces = 0_usize; + let mut failure: Option = None; + for choice in &choices { + let Some(surface) = vnr_core::action::Surface::from_id(&choice.surface) else { + continue; + }; + // The selection is matched against this process's own walk, so a path + // the dry run did not offer is dropped rather than resolved. + let items = match self.clean_previews.iter().find(|(walked, _)| *walked == surface) { + Some((_, preview)) => vnr_native::clean::select(&preview.items, &choice.paths), + None => continue, + }; + if items.is_empty() { + continue; + } + surfaces = surfaces.saturating_add(1); + match vnr_native::act::clean(&mut self.journal, surface, &items, now, &cancel) { + Ok(removal) => { + removed = removed.saturating_add(removal.removed); + bytes = bytes.saturating_add(removal.bytes); + skipped = skipped.saturating_add(removal.skipped); + refused = refused.saturating_add(removal.failed.len()); + if let Some((path, reason)) = removal.failed.first() + && failure.is_none() + { + failure = Some(format!("{} could not be removed: {reason}", path.display())); + } + } + Err(error) => { + if failure.is_none() { + failure = Some(error.to_string()); + } + } + } + } + // What is left is what is shown, as the same list rather than a new one: + // the person's ticks stay where they put them. + self.refresh_clean(false); + self.world.error = failure; + self.world.notice = Some(if removed == 0 && refused == 0 { + format!( + "Nothing was removed: every selected entry had changed, vanished or \ + become a link since the dry run. All {skipped} are still in place." + ) + } else { + format!( + "Removed {removed} entries, {}, from {surfaces} surfaces. {skipped} \ + skipped, {refused} refused. The journal has the record, and the list \ + below was walked again.", + vnr_ui::widgets::bytes(bytes) + ) + }); + } } } diff --git a/crates/native/src/act.rs b/crates/native/src/act.rs index e33e122..e4fdb04 100644 --- a/crates/native/src/act.rs +++ b/crates/native/src/act.rs @@ -558,6 +558,29 @@ mod tests { ); } + #[test] + fn a_dry_run_writes_no_journal_and_removes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let journal = Journal::open_at(dir.path().join("journal.ndjson")).unwrap(); + let path = dir.path().join("old.tmp"); + std::fs::write(&path, b"still here").unwrap(); + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000); + file.set_times(std::fs::FileTimes::new().set_modified(old)) + .unwrap(); + drop(file); + let now = 1_000 + 48 * 60 * 60; + + let preview = clean::preview_roots(Surface::WinTemp, &[dir.path().to_path_buf()], now, &|| false); + assert_eq!(preview.items.len(), 1, "the walk offers the old file"); + assert!(path.exists(), "and leaves it where it is"); + assert_eq!(std::fs::read(&path).unwrap(), b"still here"); + assert!( + journal.read().entries.is_empty(), + "a dry run is not an act, so it is not a record" + ); + } + #[test] fn an_act_id_is_sixteen_hex_and_two_acts_differ() { let first = next_act_id(); diff --git a/crates/native/src/clean.rs b/crates/native/src/clean.rs index 8863d00..ba3cd71 100644 --- a/crates/native/src/clean.rs +++ b/crates/native/src/clean.rs @@ -277,6 +277,24 @@ fn walk( } } +/// Keep the items a person selected, matched by the path the preview showed. +/// +/// The selection arrives from the interface as text. An item is kept only when the walk +/// that produced it offered that exact path, so a string assembled somewhere else — by a +/// stale window, a future caller, or a mistake — cannot become an instruction to remove a +/// file the walk never saw. Everything kept is still re-checked by [`remove`], because the +/// preview is a photograph and the disk is not. +#[must_use] +pub fn select(items: &[Item], paths: &[String]) -> Vec { + let wanted: std::collections::HashSet<&std::ffi::OsStr> = + paths.iter().map(std::ffi::OsStr::new).collect(); + items + .iter() + .filter(|item| wanted.contains(item.path.as_os_str())) + .cloned() + .collect() +} + /// Remove what the preview offered, re-checking each item first. /// /// An item that changed since the preview is skipped rather than deleted: the preview is a @@ -715,4 +733,41 @@ mod tests { assert_eq!(removal.removed, 0); assert_eq!(removal.skipped, 1); } + + #[test] + fn a_selection_can_only_reach_what_the_walk_offered() { + let dir = tempfile::tempdir().unwrap(); + let offered = old_file(dir.path(), "offered.tmp", b"yes"); + let unoffered = old_file(dir.path(), "unoffered.tmp", b"no"); + let preview = preview_roots(Surface::WinTemp, &[dir.path().to_path_buf()], now(), &never); + assert_eq!(preview.items.len(), 2, "both files are old enough"); + + // A selection is text that arrives from the interface, so it may name anything at + // all. Only a path the walk itself offered becomes an item. + let asked = vec![ + offered.display().to_string(), + unoffered + .with_file_name("not-in-the-walk.tmp") + .display() + .to_string(), + "..".to_owned(), + String::new(), + ]; + let selected = select(&preview.items, &asked); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].path, offered); + assert!( + selected.iter().all(|item| item.path != unoffered), + "a path the walk saw but the person did not select is not removed" + ); + } + + #[test] + fn an_empty_selection_selects_nothing() { + let dir = tempfile::tempdir().unwrap(); + old_file(dir.path(), "kept.tmp", b"keep"); + let preview = preview_roots(Surface::WinTemp, &[dir.path().to_path_buf()], now(), &never); + assert!(select(&preview.items, &[]).is_empty()); + assert!(select(&[], &["anything".to_owned()]).is_empty()); + } } diff --git a/crates/ui/src/act.rs b/crates/ui/src/act.rs index c2b8cdb..20c1d0e 100644 --- a/crates/ui/src/act.rs +++ b/crates/ui/src/act.rs @@ -8,6 +8,7 @@ use egui::RichText; +use crate::clean::CleanState; use crate::world::{Action, JournalRow, QuarantineRow, World}; use crate::{theme, widgets}; @@ -37,7 +38,18 @@ impl PurgeAsk { } /// The whole view. -pub(crate) fn view(ui: &mut egui::Ui, world: &World, ask: &mut PurgeAsk, actions: &mut Vec) { +/// +/// Three cards in the order a person needs them: what ranger could remove (dry run only), +/// what it has already moved aside, and what it has recorded. +pub(crate) fn view( + ui: &mut egui::Ui, + world: &World, + ask: &mut PurgeAsk, + clean: &mut CleanState, + actions: &mut Vec, +) { + crate::clean::card(ui, world, clean, actions); + ui.add_space(14.0); quarantine(ui, world, ask, actions); ui.add_space(14.0); journal(ui, world); @@ -78,7 +90,7 @@ fn quarantine(ui: &mut egui::Ui, world: &World, ask: &mut PurgeAsk, actions: &mu } fn row_line(ui: &mut egui::Ui, row: &QuarantineRow, ask: &mut PurgeAsk, actions: &mut Vec) { - widget_border(ui, |ui| { + widgets::border(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label( @@ -89,7 +101,7 @@ fn row_line(ui: &mut egui::Ui, row: &QuarantineRow, ask: &mut PurgeAsk, actions: ui.add_space(2.0); let detail = format!( "{} · {} · taken {} · {}", - bytes_label(row.bytes), + widgets::bytes(row.bytes), row.hash_short, row.at_label, row.reason @@ -235,50 +247,10 @@ fn journal_line(ui: &mut egui::Ui, row: &JournalRow) { }); } -fn widget_border(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - egui::Frame::new() - .fill(ui.visuals().faint_bg_color) - .corner_radius(egui::CornerRadius::same(6)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .outer_margin(egui::Margin { - bottom: 6, - ..egui::Margin::ZERO - }) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - add(ui) - }) - .inner -} - -fn bytes_label(bytes: u64) -> String { - const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; - let mut value = bytes as f64; - let mut unit = 0; - while value >= 1024.0 && unit + 1 < UNITS.len() { - value /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{bytes} B") - } else { - format!("{value:.1} {}", UNITS[unit]) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn sizes_read_in_the_units_a_person_uses() { - assert_eq!(bytes_label(0), "0 B"); - assert_eq!(bytes_label(999), "999 B"); - assert_eq!(bytes_label(1024), "1.0 KB"); - assert_eq!(bytes_label(1024 * 1024 * 3 / 2), "1.5 MB"); - assert_eq!(bytes_label(1024 * 1024 * 1024 * 2), "2.0 GB"); - } - #[test] fn the_word_has_to_hold_the_target_and_be_typed_exactly() { let mut ask = PurgeAsk::default(); diff --git a/crates/ui/src/clean.rs b/crates/ui/src/clean.rs new file mode 100644 index 0000000..40fef98 --- /dev/null +++ b/crates/ui/src/clean.rs @@ -0,0 +1,681 @@ +//! The clean surface: an allowlisted walk, laid out so it can be read before it happens. +//! +//! Cleaning is the one act whose object is a list, so the list is the interface. Each +//! surface shows its own count and size, each entry can be unticked, and the removal asks +//! once, naming the totals and the surfaces they come from. What the card draws is the +//! result of a dry run: the walk behind it writes nothing, and the shell walks again after +//! a clean, so what is left on screen is what is left on disk. + +use std::collections::BTreeSet; + +use egui::RichText; + +use crate::world::{Action, CleanChoice, CleanItem, CleanReport, CleanSurface, World}; +use crate::{theme, widgets}; + +/// What the clean card remembers between frames. +#[derive(Default)] +pub(crate) struct CleanState { + /// Which dry run the selection belongs to, so a new walk starts from its own list. + run: Option, + /// The paths the person has ticked. + selected: BTreeSet, + /// The surfaces whose entry list is open. + open: BTreeSet, + /// Whether the confirmation is on screen. + asking: bool, +} + +impl CleanState { + /// Take a new dry run's list, and forget the last one's selection. + /// + /// A selection belongs to the list it was made against. Keeping it across a new walk + /// would let a tick outlive the entry it was made for, and the entry under that path + /// may now be a different file. + pub(crate) fn begin(&mut self, report: &CleanReport) { + if self.run == Some(report.run) { + return; + } + self.run = Some(report.run); + self.selected = report + .surfaces + .iter() + .filter(|surface| !surface.opt_in) + .flat_map(|surface| surface.items.iter().map(|item| item.path.clone())) + .collect(); + self.open.clear(); + self.asking = false; + } + + fn is_selected(&self, path: &str) -> bool { + self.selected.contains(path) + } + + fn set(&mut self, path: &str, on: bool) { + if on { + self.selected.insert(path.to_owned()); + } else { + self.selected.remove(path); + } + } + + fn set_surface(&mut self, surface: &CleanSurface, on: bool) { + for item in &surface.items { + self.set(&item.path, on); + } + } + + fn surface_selected(&self, surface: &CleanSurface) -> bool { + !surface.items.is_empty() && surface.items.iter().all(|item| self.is_selected(&item.path)) + } + + /// Open the confirmation, which is the only step between a selection and an act. + pub(crate) fn ask(&mut self) { + self.asking = true; + } + + pub(crate) fn toggle_open(&mut self, id: &str) { + if !self.open.remove(id) { + self.open.insert(id.to_owned()); + } + } + + /// What the ticked entries add up to. + fn totals(&self, report: &CleanReport) -> Totals { + let mut totals = Totals::default(); + for surface in &report.surfaces { + for item in &surface.items { + if self.is_selected(&item.path) { + totals.add(item); + } + } + } + totals + } + + /// What the shell is asked to remove: one choice per surface, and no surface with + /// nothing ticked in it. + fn choices(&self, report: &CleanReport) -> Vec { + report + .surfaces + .iter() + .filter_map(|surface| { + let paths: Vec = surface + .items + .iter() + .filter(|item| self.is_selected(&item.path)) + .map(|item| item.path.clone()) + .collect(); + if paths.is_empty() { + None + } else { + Some(CleanChoice { + surface: surface.id.clone(), + paths, + }) + } + }) + .collect() + } + + /// The surfaces a selection reaches, by the name a person calls them. + fn labels<'a>(&self, report: &'a CleanReport) -> Vec<&'a str> { + report + .surfaces + .iter() + .filter(|surface| surface.items.iter().any(|item| self.is_selected(&item.path))) + .map(|surface| surface.label.as_str()) + .collect() + } +} + +/// What a set of entries adds up to, in the terms the confirmation uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct Totals { + files: usize, + directories: usize, + bytes: u64, +} + +impl Totals { + fn add(&mut self, item: &CleanItem) { + if item.is_dir { + self.directories = self.directories.saturating_add(1); + } else { + self.files = self.files.saturating_add(1); + self.bytes = self.bytes.saturating_add(item.bytes); + } + } + + fn is_empty(&self) -> bool { + self.files == 0 && self.directories == 0 + } + + fn entries(&self) -> usize { + self.files.saturating_add(self.directories) + } + + /// The phrase a confirmation is made of: what would go, and how much of it. + /// + /// An empty directory holds no bytes, so it is named rather than measured. Nothing at + /// all reads as "nothing", which is a sentence a person can act on. + fn sentence(&self) -> String { + let mut parts = Vec::new(); + if self.files > 0 { + parts.push(plural(self.files, "file", "files")); + } + if self.directories > 0 { + parts.push(format!( + "{} empty {}", + count(self.directories), + if self.directories == 1 { + "folder" + } else { + "folders" + } + )); + } + let mut text = if parts.is_empty() { + "nothing".to_owned() + } else { + parts.join(" and ") + }; + if self.bytes > 0 { + text.push_str(&format!(", {}", widgets::bytes(self.bytes))); + } + text + } +} + +/// Everything a dry run found, ticked or not. +fn found(report: &CleanReport) -> Totals { + let mut totals = Totals::default(); + for surface in &report.surfaces { + for item in &surface.items { + totals.add(item); + } + } + totals +} + +/// A count with thousands separators: `1204` is a number, `1,204` is a quantity. +fn count(value: usize) -> String { + let digits = value.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (index, character) in digits.char_indices() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + out.push(','); + } + out.push(character); + } + out +} + +/// A count and its noun, pluralised by the count rather than by the writer's mood. +fn plural(value: usize, one: &str, many: &str) -> String { + format!("{} {}", count(value), if value == 1 { one } else { many }) +} + +/// A list of names as a person reads it: `a`, `a and b`, `a, b and c`. +fn list(labels: &[&str]) -> String { + match labels { + [] => String::new(), + [only] => (*only).to_owned(), + [first, second] => format!("{first} and {second}"), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +/// The whole card. +pub(crate) fn card(ui: &mut egui::Ui, world: &World, state: &mut CleanState, actions: &mut Vec) { + widgets::card(ui, |ui| { + header(ui, world, actions); + ui.add_space(8.0); + let Some(report) = &world.clean else { + widgets::empty( + ui, + "Nothing has been counted yet", + "A dry run walks the folders ranger is allowed to remove from and lists what \ + is older than each one's age. It writes nothing: no journal line, and no file \ + leaves its place.", + ); + return; + }; + state.begin(report); + if report.surfaces.is_empty() { + widgets::empty( + ui, + "No folder to clean on this machine", + "Ranger checked its allowlist — the temporary folders, the caches and the crash \ + dumps for this operating system — and none of them exists. That is the result \ + of the check, not a claim about the machine.", + ); + return; + } + body(ui, report, state, actions); + }); +} + +fn header(ui: &mut egui::Ui, world: &World, actions: &mut Vec) { + ui.horizontal(|ui| { + widgets::section( + ui, + "Clean", + "Temporary files and caches ranger is allowed to remove from. A dry run lists and \ + counts them; nothing moves until you say so.", + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = if world.clean.is_some() { + "Run again" + } else { + "Dry run" + }; + if ui + .add(egui::Button::new(RichText::new(label).size(11.5))) + .on_hover_text( + "Walk the allowlisted folders and count what is past each surface's age. \ + This writes nothing.", + ) + .clicked() + { + actions.push(Action::PreviewClean); + } + }); + }); +} + +fn body(ui: &mut egui::Ui, report: &CleanReport, state: &mut CleanState, actions: &mut Vec) { + for surface in &report.surfaces { + surface_row(ui, surface, state); + } + if report.cancelled { + widgets::dot_line( + ui, + theme::ATTENTION, + "The dry run was stopped before it finished, so this list is partial.", + ); + } + ui.add_space(4.0); + footer(ui, report, state, actions); +} + +fn surface_row(ui: &mut egui::Ui, surface: &CleanSurface, state: &mut CleanState) { + widgets::border(ui, |ui| { + ui.horizontal(|ui| { + let has_items = !surface.items.is_empty(); + let mut on = state.surface_selected(surface); + let check = ui.add_enabled( + has_items, + egui::Checkbox::new( + &mut on, + RichText::new(&surface.label).size(11.5).family(theme::semibold()), + ), + ); + if check.changed() { + state.set_surface(surface, on); + } + if surface.opt_in { + widgets::badge(ui, "off by default", theme::ATTENTION); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if has_items { + let open = state.open.contains(&surface.id); + let label = if open { + "Hide".to_owned() + } else { + format!("Show {}", count(surface.entries())) + }; + if ui + .add(egui::Button::new(RichText::new(label).size(11.0)).frame(false)) + .clicked() + { + state.toggle_open(&surface.id); + } + } + }); + }); + ui.add_space(2.0); + ui.label(RichText::new(detail(surface)).size(10.5).color(theme::muted(ui))); + let kept = surface.kept_sentence(); + if !kept.is_empty() { + ui.label(RichText::new(kept).size(10.0).color(theme::muted(ui))); + } + if state.open.contains(&surface.id) { + ui.add_space(4.0); + entries(ui, surface, state); + } + }); +} + +/// One surface's totals in a sentence, including the reason it has nothing to offer. +fn detail(surface: &CleanSurface) -> String { + if surface.items.is_empty() { + return "Nothing here is old enough to offer.".to_owned(); + } + let folders = plural(surface.roots, "folder", "folders"); + format!( + "{} in {folders} · {}", + plural(surface.entries(), "entry", "entries"), + widgets::bytes(surface.bytes()) + ) +} + +fn entries(ui: &mut egui::Ui, surface: &CleanSurface, state: &mut CleanState) { + // A uniform row height is what lets the list stay scrollable when a cache holds ten + // thousand files: only the rows in view are built. + const ROW_HEIGHT: f32 = 17.0; + egui::ScrollArea::vertical() + .id_salt(("clean-entries", surface.id.as_str())) + .max_height(176.0) + .auto_shrink([false, true]) + .show_rows(ui, ROW_HEIGHT, surface.items.len(), |ui, range| { + for index in range { + let Some(item) = surface.items.get(index) else { + continue; + }; + item_row(ui, item, state); + } + }); +} + +fn item_row(ui: &mut egui::Ui, item: &CleanItem, state: &mut CleanState) { + ui.horizontal(|ui| { + let mut on = state.is_selected(&item.path); + if ui.add(egui::Checkbox::without_text(&mut on)).changed() { + state.set(&item.path, on); + } + let width = (ui.available_width() - 76.0).max(80.0); + ui.add_sized( + [width, 15.0], + egui::Label::new(RichText::new(&item.path).size(10.0).monospace()).truncate(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let text = if item.is_dir { + "empty folder".to_owned() + } else { + widgets::bytes(item.bytes) + }; + ui.label(RichText::new(text).size(10.0).color(theme::muted(ui))); + }); + }); +} + +fn footer(ui: &mut egui::Ui, report: &CleanReport, state: &mut CleanState, actions: &mut Vec) { + let selected = state.totals(report); + let found = found(report); + if state.asking { + confirm(ui, report, &selected, state, actions); + return; + } + ui.horizontal(|ui| { + let line = if found.is_empty() { + "Nothing is old enough to offer on this machine yet.".to_owned() + } else if selected.is_empty() { + format!("Nothing is selected, of {} found.", found.sentence()) + } else { + format!("{} selected, of {} found.", selected.sentence(), found.sentence()) + }; + ui.label(RichText::new(line).size(11.0).color(theme::muted(ui))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled( + !selected.is_empty(), + egui::Button::new(RichText::new("Remove selected…").size(11.5)), + ) + .on_hover_text("Ask once, naming what would go.") + .clicked() + { + state.ask(); + } + }); + }); +} + +/// The one confirmation, naming the totals and the surfaces they come from. +fn confirm( + ui: &mut egui::Ui, + report: &CleanReport, + totals: &Totals, + state: &mut CleanState, + actions: &mut Vec, +) { + let labels = state.labels(report); + widgets::border(ui, |ui| { + ui.label( + RichText::new(format!("Remove {} from {}?", totals.sentence(), list(&labels))) + .size(12.0) + .family(theme::semibold()), + ); + ui.add_space(2.0); + ui.label( + RichText::new( + "Every entry is checked again first: one that changed, vanished or became a \ + link since the dry run is skipped and counted. The act is written to the \ + journal before it starts.", + ) + .size(10.5) + .color(theme::muted(ui)), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new(format!("Remove {}", plural(totals.entries(), "entry", "entries"))) + .size(11.5), + ) + .fill(theme::BAD), + ) + .on_hover_text("There is no undo for a cache entry. A program rebuilds it.") + .clicked() + { + actions.push(Action::CleanSelected(state.choices(report))); + state.asking = false; + } + if ui + .add(egui::Button::new(RichText::new("Cancel").size(11.5)).frame(false)) + .clicked() + { + state.asking = false; + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(path: &str, bytes: u64, is_dir: bool) -> CleanItem { + CleanItem { + path: path.to_owned(), + bytes, + is_dir, + } + } + + fn surface(id: &str, label: &str, opt_in: bool, items: Vec) -> CleanSurface { + CleanSurface { + id: id.to_owned(), + label: label.to_owned(), + roots: 1, + opt_in, + items, + ..CleanSurface::default() + } + } + + fn report(run: u64, surfaces: Vec) -> CleanReport { + CleanReport { + run, + surfaces, + cancelled: false, + } + } + + fn bed() -> (CleanReport, CleanState) { + let first = report( + 1, + vec![ + surface( + "win.temp", + "Temporary files", + false, + vec![ + item("C:/Temp/a.tmp", 100, false), + item("C:/Temp/b.tmp", 200, false), + item("C:/Temp/empty", 0, true), + ], + ), + surface( + "mac.logs", + "User logs", + true, + vec![item("C:/Logs/old.log", 50, false)], + ), + ], + ); + let mut state = CleanState::default(); + state.begin(&first); + (first, state) + } + + #[test] + fn the_totals_count_files_folders_and_bytes() { + let (report, state) = bed(); + let totals = state.totals(&report); + assert_eq!( + totals, + Totals { + files: 2, + directories: 1, + bytes: 300, + }, + "the opt-in log is not in a selection it never asked for" + ); + assert_eq!(found(&report).files, 3, "but it is in what was found"); + } + + #[test] + fn a_selection_resets_when_a_new_dry_run_lands() { + let (first, mut state) = bed(); + state.set("C:/Temp/a.tmp", false); + assert_eq!(state.totals(&first).files, 1); + + state.begin(&first); + assert_eq!(state.totals(&first).files, 1, "the same run keeps the selection"); + + let next = report( + 2, + vec![surface( + "win.temp", + "Temporary files", + false, + vec![item("C:/Temp/a.tmp", 100, false)], + )], + ); + state.begin(&next); + assert_eq!(state.totals(&next).files, 1, "a new run starts from its own list"); + assert!( + !state.is_selected("C:/Temp/b.tmp"), + "an entry the new walk did not offer cannot stay ticked" + ); + } + + #[test] + fn only_a_surface_with_a_selection_becomes_a_choice() { + let (report, mut state) = bed(); + assert_eq!( + state.choices(&report).len(), + 1, + "the log surface has nothing ticked" + ); + + state.set("C:/Temp/a.tmp", false); + state.set("C:/Temp/b.tmp", false); + state.set("C:/Temp/empty", false); + assert!( + state.choices(&report).is_empty(), + "an empty selection is not an act" + ); + + state.set("C:/Temp/b.tmp", true); + let choices = state.choices(&report); + assert_eq!(choices.len(), 1); + assert_eq!(choices[0].surface, "win.temp"); + assert_eq!(choices[0].paths, vec!["C:/Temp/b.tmp".to_owned()]); + } + + #[test] + fn a_surface_switch_toggles_every_entry_in_it() { + let (report, mut state) = bed(); + let row = report.surfaces.first().expect("the fixture has a surface"); + assert!(state.surface_selected(row)); + + state.set_surface(row, false); + assert!(!state.surface_selected(row)); + assert_eq!(state.totals(&report).files, 0); + + state.set_surface(row, true); + assert!(state.surface_selected(row)); + assert_eq!(state.totals(&report).files, 2); + } + + #[test] + fn a_surface_with_nothing_to_offer_is_never_selected() { + let empty = surface("win.gpu", "GPU shader cache", false, Vec::new()); + assert!(!CleanState::default().surface_selected(&empty)); + assert!(list(&[]).is_empty()); + } + + #[test] + fn the_confirmation_names_the_totals_the_way_a_person_reads_them() { + assert_eq!( + Totals { + files: 1204, + directories: 12, + bytes: 327_155_000, + } + .sentence(), + "1,204 files and 12 empty folders, 312.0 MB" + ); + assert_eq!( + Totals { + files: 1, + directories: 0, + bytes: 0, + } + .sentence(), + "1 file" + ); + assert_eq!( + Totals { + files: 0, + directories: 2, + bytes: 0, + } + .sentence(), + "2 empty folders", + "an empty folder holds no bytes to name" + ); + assert_eq!(Totals::default().sentence(), "nothing"); + } + + #[test] + fn the_confirmation_names_the_surfaces_in_reading_order() { + assert_eq!(list(&[]), ""); + assert_eq!(list(&["Temporary files"]), "Temporary files"); + assert_eq!(list(&["a", "b"]), "a and b"); + assert_eq!(list(&["a", "b", "c"]), "a, b and c"); + } + + #[test] + fn counts_read_like_quantities() { + assert_eq!(count(0), "0"); + assert_eq!(count(999), "999"); + assert_eq!(count(1000), "1,000"); + assert_eq!(count(1_234_567), "1,234,567"); + assert_eq!(plural(1, "file", "files"), "1 file"); + assert_eq!(plural(2, "file", "files"), "2 files"); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cf3af6b..b3f3152 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,7 +18,12 @@ mod world; mod act; -pub use world::{Action, JournalRow, Live, QuarantineRow, ScanState, View, WatchReport, World}; +mod clean; + +pub use world::{ + Action, CleanChoice, CleanItem, CleanReport, CleanSurface, JournalRow, Live, QuarantineRow, ScanState, + View, WatchReport, World, +}; use egui::{Align2, CornerRadius, FontId, Margin, RichText, Sense, vec2}; @@ -207,6 +212,8 @@ pub struct Ui { suppress_reason: String, /// Which quarantine entry is waiting for its word, and what has been typed. purge_ask: crate::act::PurgeAsk, + /// What the clean card has ticked, and against which dry run. + clean: crate::clean::CleanState, finding_severity: Option, finding_query: String, finding_sort: FindingSort, @@ -264,6 +271,7 @@ impl Ui { suppress_target: None, suppress_reason: String::new(), purge_ask: crate::act::PurgeAsk::default(), + clean: crate::clean::CleanState::default(), finding_severity: None, finding_query: String::new(), finding_sort: FindingSort::Severity, @@ -390,7 +398,7 @@ impl Ui { View::Startup => self.startup(ui, world, actions), View::Listeners => self.listeners(ui, world), View::Changes => self.changes(ui, world, actions), - View::Actions => act::view(ui, world, &mut self.purge_ask, actions), + View::Actions => act::view(ui, world, &mut self.purge_ask, &mut self.clean, actions), View::Report => self.report(ui, world, actions), View::Settings => self.settings(ui, world, actions), } @@ -1098,11 +1106,52 @@ mod tests { suppressions: Vec::new(), watch_extra: Vec::new(), quarantine: Vec::new(), + clean: None, journal: Vec::new(), journal_damaged: false, } } + fn clean_report() -> CleanReport { + CleanReport { + run: 7, + surfaces: vec![ + CleanSurface { + id: "win.temp".to_owned(), + label: "Temporary files".to_owned(), + roots: 2, + opt_in: false, + items: vec![ + CleanItem { + path: "C:\\Temp\\a.tmp".to_owned(), + bytes: 4_096, + is_dir: false, + }, + CleanItem { + path: "C:\\Temp\\empty".to_owned(), + bytes: 0, + is_dir: true, + }, + ], + ..CleanSurface::default() + }, + CleanSurface { + id: "mac.logs".to_owned(), + label: "User logs".to_owned(), + roots: 1, + opt_in: true, + items: vec![CleanItem { + path: "C:\\Logs\\old.log".to_owned(), + bytes: 9_000, + is_dir: false, + }], + ..CleanSurface::default() + }, + ], + cancelled: false, + } + } + fn draw(view: View, world: &World) { egui::__run_test_ui(|root| { let ctx = root.ctx().clone(); @@ -1112,6 +1161,38 @@ mod tests { }); } + #[test] + fn the_clean_card_draws_its_list() { + let mut world = base_world(None, None); + let report = clean_report(); + world.clean = Some(report.clone()); + egui::__run_test_ui(|root| { + let ctx = root.ctx().clone(); + let mut ui = Ui::new(&ctx); + ui.view = View::Actions; + ui.clean.begin(&report); + ui.clean.toggle_open("win.temp"); + let actions = ui.show(root, &world); + assert!(actions.is_empty(), "drawing a list asks for nothing on its own"); + }); + } + + #[test] + fn the_clean_confirmation_draws_and_dispatches_nothing_until_clicked() { + let mut world = base_world(None, None); + let report = clean_report(); + world.clean = Some(report.clone()); + egui::__run_test_ui(|root| { + let ctx = root.ctx().clone(); + let mut ui = Ui::new(&ctx); + ui.view = View::Actions; + ui.clean.begin(&report); + ui.clean.ask(); + let actions = ui.show(root, &world); + assert!(actions.is_empty(), "a confirmation is a question, not an act"); + }); + } + #[test] fn every_view_draws_with_an_empty_world() { let world = base_world(None, None); diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index ac0af5d..6ec3a5c 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -19,6 +19,43 @@ pub fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { .inner } +/// A bordered row inside a card, for a list of things a person can act on. +pub fn border(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .corner_radius(CornerRadius::same(6)) + .inner_margin(Margin::symmetric(10, 8)) + .outer_margin(Margin { + bottom: 6, + ..Margin::ZERO + }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add(ui) + }) + .inner +} + +/// A byte count in the units a person uses. +/// +/// Bytes are rounded to one decimal above a kilobyte: the difference between 312.4 MB and +/// 312.44 MB is not a fact anyone acts on, and a long number invites comparison instead of +/// a decision. +pub fn bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + /// A small coloured label. pub fn badge(ui: &mut egui::Ui, text: &str, color: Color32) { let galley = ui @@ -118,3 +155,22 @@ pub fn dot_line(ui: &mut egui::Ui, color: Color32, text: &str) { ui.label(RichText::new(text).size(11.5).color(theme::muted(ui))); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_read_in_the_units_a_person_uses() { + assert_eq!(bytes(0), "0 B"); + assert_eq!(bytes(999), "999 B"); + assert_eq!(bytes(1024), "1.0 KB"); + assert_eq!(bytes(1024 * 1024 * 3 / 2), "1.5 MB"); + assert_eq!(bytes(1024 * 1024 * 1024 * 2), "2.0 GB"); + assert_eq!( + bytes(1024 * 1024 * 1024 * 1024), + "1024.0 GB", + "the largest unit is the largest one shown" + ); + } +} diff --git a/crates/ui/src/world.rs b/crates/ui/src/world.rs index f004d1a..4b0e45d 100644 --- a/crates/ui/src/world.rs +++ b/crates/ui/src/world.rs @@ -134,6 +134,108 @@ pub struct QuarantineRow { pub hash_short: String, } +/// One thing a clean would remove, as the interface lists it. +/// +/// The path is the key a selection is made by. It is not a capability: the shell removes +/// only what its own walk offered, and a path that was not in that walk is dropped rather +/// than resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CleanItem { + /// Where it is. + pub path: String, + /// Its size in bytes; zero for a directory. + pub bytes: u64, + /// Whether it is an empty directory waiting to be pruned. + pub is_dir: bool, +} + +/// What one allowlisted surface would give back. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CleanSurface { + /// The surface's stable id, which is what an act names. + pub id: String, + /// What a person calls it. + pub label: String, + /// How many folders on this machine belong to it. + pub roots: usize, + /// Whether removing this surface's contents is a choice the person makes explicitly. + pub opt_in: bool, + /// The things it would remove. + pub items: Vec, + /// How many entries were left alone because they are newer than the surface's age. + pub skipped_young: usize, + /// How many links were left alone. + pub skipped_links: usize, + /// How many entries on another filesystem were left alone. + pub skipped_devices: usize, + /// How many entries this surface keeps by name. + pub skipped_excluded: usize, +} + +impl CleanSurface { + /// Everything the surface would give back, summed. + #[must_use] + pub fn bytes(&self) -> u64 { + self.items.iter().map(|item| item.bytes).sum() + } + + /// How many entries would be removed. + #[must_use] + pub fn entries(&self) -> usize { + self.items.len() + } + + /// How many entries were left alone, and why, in the surface's own words. + #[must_use] + pub fn kept_sentence(&self) -> String { + let mut parts = Vec::new(); + if self.skipped_young > 0 { + parts.push(format!("{} too new", self.skipped_young)); + } + if self.skipped_links > 0 { + parts.push(format!("{} links", self.skipped_links)); + } + if self.skipped_devices > 0 { + parts.push(format!("{} on another filesystem", self.skipped_devices)); + } + if self.skipped_excluded > 0 { + parts.push(format!("{} the system keeps", self.skipped_excluded)); + } + if parts.is_empty() { + return String::new(); + } + format!("Kept {}", parts.join(", ")) + } +} + +/// What one dry run found across every surface that exists on this machine. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CleanReport { + /// Which dry run this is. The interface resets a selection when this changes. + pub run: u64, + /// One row per surface that resolved to at least one folder. + pub surfaces: Vec, + /// Whether the walk stopped because it was asked to. + pub cancelled: bool, +} + +impl CleanReport { + /// Whether there is anything to remove at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.surfaces.iter().all(|surface| surface.items.is_empty()) + } +} + +/// One surface's share of what a person chose to remove. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CleanChoice { + /// The surface's stable id. + pub surface: String, + /// The paths that were selected, as the dry run listed them. + pub paths: Vec, +} + /// One journal line, as the interface lists it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct JournalRow { @@ -213,6 +315,8 @@ pub struct World { pub watch_extra: Vec, /// The quarantine, newest first. pub quarantine: Vec, + /// What the last clean dry run found, and nothing else: the walk writes no file. + pub clean: Option, /// The journal's newest entries, newest first. pub journal: Vec, /// Whether the journal reports a damaged line or a torn tail. @@ -309,6 +413,16 @@ pub enum Action { Restore(String), /// Move a file into the quarantine, after the detail panel has shown what it is. Quarantine(String), + /// Walk the allowlisted surfaces and report what a clean would remove. + /// + /// This is a dry run: it reads names, sizes and times, and writes nothing — no journal + /// line, no removal, not even a file of its own. + PreviewClean, + /// Remove the entries a person selected, one surface at a time. + /// + /// The paths are what the shell's own dry run listed; it drops any that were not, so a + /// path assembled somewhere else cannot arrive here as an instruction. + CleanSelected(Vec), /// Delete a quarantined entry for good, with the word the person typed. /// /// The typed word travels with the action so the shell can refuse a call that did not From 2d74d0c79aba6d7c6f1b2d288b8373fb6e9fd8ab Mon Sep 17 00:00:00 2001 From: vecnode <229195741+vecnode@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:46:08 +0000 Subject: [PATCH 02/41] Check three platforms at the cut, and record what the clean surface is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix cost three runners and up to half an hour each on every commit, and a solo maintainer's own loop is already `cargo xtask ci` on one of those three platforms. The question was whether the remote matrix is worth it at all. It is worth it at exactly one moment, so that is where it now runs in full. - A push to `main` runs one job, on Ubuntu: the platform a Windows machine cannot check for itself, and the one whose dependencies differ most. - A pull request, the Monday 04:17 run and `workflow_dispatch` run all three. - The release workflow *calls this same workflow* as its gate before it creates the draft, so a tag on a red commit publishes nothing, and the gate cannot drift from the check a pull request sees. Monday is a backstop, not a substitute: a macOS-only break that a Windows machine cannot see is found within a week or at the cut, rather than never. That is the trade the matrix is for, and it is why the answer is not "only when we cut". The record moves with it. `docs/PLAN.md` §9 gains the clean preview in wave 2's row and names what is left there (the data-folder size in Settings, and the second reviewer, which is a process row). Two claims in §12 stop overreaching: the language audit checks quoted words and cannot see whether an empty state names what it checked, and the cross-target clippy is a developer step rather than a check of its own. `AGENTS.md` §1 and §11 now point at `core/src/action.rs`, where the allowlist table actually lives, and §5 tells the truth about when the three platforms are built. `docs/RELEASES.md` §7 says the gate is what makes the checklist item enforceable. --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++++++- .github/workflows/release.yml | 10 ++++++++++ AGENTS.md | 17 +++++++++++------ docs/PLAN.md | 12 +++++++----- docs/RELEASES.md | 5 ++++- 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeefb2a..cc05cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,12 @@ name: CI +# Three operating systems, but not on every commit. +# +# A push to main runs one job, on the platform a Windows development machine cannot check +# for itself. The full matrix runs on a pull request, on the Monday schedule, when asked, +# and — through `workflow_call` — as the gate a release passes before it publishes +# anything. That is the whole policy: the check that must never be skipped is the one at +# the cut, and it is the cut that now pays for it. on: push: branches: [main] @@ -14,6 +21,13 @@ on: - 'docs/**' - 'assets/logo/**' - 'assets/screenshots/**' + schedule: + # Monday, 04:17 UTC. Early enough in the week that a platform-only break has six days + # to be found before the next one, and off the hour so it does not join the queue of + # every other project's nightly. + - cron: '17 4 * * 1' + workflow_dispatch: + workflow_call: # A superseded push has nothing to say. Cancel it and let the cache serve the next one. concurrency: @@ -28,15 +42,33 @@ env: CARGO_INCREMENTAL: '0' jobs: + # The matrix is chosen by the event rather than written twice, so the caller and the + # push cannot disagree about what "checked" means. + plan: + name: Choose the matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + os: ${{ steps.pick.outputs.os }} + steps: + - id: pick + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT" + else + echo 'os=["ubuntu-latest","windows-latest","macos-latest"]' >> "$GITHUB_OUTPUT" + fi + check: name: ${{ matrix.os }} + needs: plan runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: fail-fast: false matrix: # Ubuntu latest for the quick check; the release workflow uses 22.04 for glibc. - os: [windows-latest, ubuntu-latest, macos-latest] + os: ${{ fromJSON(needs.plan.outputs.os) }} steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2fa818..cbdb56d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,20 @@ env: GH_REPO: ${{ github.repository }} jobs: + # A tag is a promise that the commit it names was checked on three platforms. The gate + # calls the same workflow a pull request runs rather than a copy of it, so the two cannot + # drift: a red check stops the release here, before a draft exists to be published. + gate: + name: Gate + uses: ./.github/workflows/ci.yml + permissions: + contents: read + # Create the release once, as a draft. It becomes visible only after every archive # and SHA256SUMS.txt are attached, so a half-finished release cannot be installed. prepare: name: Prepare release + needs: gate runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/AGENTS.md b/AGENTS.md index ead39dd..f7015e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,12 +29,14 @@ crates/ core vnr-core pure and IO-free: facts, snapshots, timelines, diffs, rules, findings, hash signatures, reports, time arithmetic, journal records, recovery verdicts, pack schema and matcher, - clean policy, schedule arithmetic, the Sigma converter + the allowlist table and its refusals (`action.rs`), schedule + arithmetic, the Sigma converter native vnr-native the only crate that touches the world: collect/{processes,autoruns,listeners,packages,tasks,agentstack}, hash, signatures, elevation, paths, store, watch, scan, - journal, action, quarantine, clean, pack, tray, autostart - ui vnr-ui egui: theme, fonts, icons, widgets, the views, World/Action + journal, act, action, quarantine, clean, pack, tray, autostart + ui vnr-ui egui: theme, fonts, icons, widgets, the views, World/Action, + and the clean card's selection state apps/ vn-ranger the eframe shell: owns the World, runs the worker, scheduler and tray, carries out actions, and the headless @@ -206,8 +208,11 @@ capability report. ## 5. Platform rules -CI builds on three platforms, and two of them will disagree with your machine. An -attribute on a **statement** deletes it on the other platforms, which can leave a +CI builds on three platforms, and two of them will disagree with your machine. A push to +`main` checks Linux only; a pull request, the Monday run and the release gate check all +three. So a `#[cfg]` bug can live on `main` until the next Monday or until the cut, and +the target check below is how you find it on the day you write it rather than the day you +tag. An attribute on a **statement** deletes it on the other platforms, which can leave a binding unused, a variable unread or a branch unreachable: ```rust @@ -346,7 +351,7 @@ A change is done when: | Rules and findings | `core/src/rules.rs` | A rule that cannot name a benign explanation is not ready; every finding carries the evidence it fired on and a stable subject for suppressions. | | The action path | `native/src/action.rs`, `quarantine.rs`, `clean.rs` | The only code that can destroy data. `cap-std`, four refusals, a journal line before every act, and a test seam for TOCTOU — never a race. | | The journal | `native/src/journal.rs`, `core/src/journal.rs` | Append-only, `sync_data`, a torn tail tolerated, a rotation that keeps seq monotone, and a recovery verdict for four on-disk states. | -| The allowlist | `native/src/clean.rs`, `core/src/clean.rs` | A table, not a heuristic; the walk never follows a link or crosses a device, and a file changed since the preview is skipped. | +| The allowlist | `native/src/clean.rs`, `core/src/action.rs` | A table, not a heuristic; the walk never follows a link or crosses a device, and a file changed since the preview is skipped. | | Packs | `core/src/pack/`, `native/src/pack/` | Verified before parsed; a closed vocabulary; an unknown field is an error; activation and rollback are journal entries. | | The Sigma reader | `core/src/sigma/` | YAML is user input: a restricted reader that refuses anchors, tags and multi-document files rather than guessing. | | The tray | `native/src/tray/` | `tray-icon` on Windows and macOS, `ksni` on Linux, an event loop per platform, and no icon is a supported state. | diff --git a/docs/PLAN.md b/docs/PLAN.md index 07a47b8..e4fb720 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1050,7 +1050,7 @@ part that exists): | Wave | In the tree | Still to do | |---|---|---| | 1 Ledger | journal records, reader, rotation, crash-recovery verdicts, the typed action path with its four refusals, the `ActionIo` seam | — | -| 2 Quarantine and cleaning | quarantine with a manifest, verified restore, irreversible purge, all four acts journaled, the allowlist walk with preview and removal, the language audit as a check, the Actions view, the quarantine entry point, the typed-`DELETE` dialog | the clean preview surface | +| 2 Quarantine and cleaning | quarantine with a manifest, verified restore, irreversible purge, all four acts journaled, the allowlist walk with preview and removal, the clean preview (a dry run, per-surface totals, per-item selection, one confirmation naming them), the language audit as a check, the Actions view, the quarantine entry point, the typed-`DELETE` dialog | the data-directory size in Settings; the second reviewer on every deletion path, which is a process row and not code | | 3 Resident | the schedule (six hours ±10 %, seeded jitter), resume detection, a scan on resume | tray, opt-in autostart, activation, the soak recorder | | 4 Packs | — | everything | | 5 Coverage | — | everything | @@ -1401,10 +1401,12 @@ external service, nothing to install beside it. - **No YARA-X by default:** `cargo tree -e features` proves the default build has no `yara-x`, and that the feature builds it in when asked. - **Language:** `cargo xtask language` fails on "fix", "infected", "threat score" and - "your PC is clean" in user-facing strings, and on an empty state that does not name - what was checked. -- **Cross-target clippy:** the workspace is checked against Linux and macOS targets, as - vn-launcher does, because a Windows machine cannot see what they see. + "your PC is clean" in the string literals a person sees. It cannot see whether an empty + state names what was checked, so that half of §5.5 is a review rule rather than a check. +- **Cross-target clippy:** a developer step before a `#[cfg]` push, not a check of its own: + a Windows machine can install a Linux target and may stop inside a dependency, which is + why the three-OS matrix — Monday, a pull request, and every release — is the real + cross-platform check. - **Fuzzing:** the pack JSON, the journal reader, the plist/registry parsers and the restricted Sigma YAML reader, as each parser lands. - **The soak:** 24 hours with the tray, the poll, the watcher and the scheduler running, diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 1d4cc78..a96aa10 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -173,7 +173,10 @@ rule packs already use, so it is exercised for a release before it protects one. ## 7. Checklist before tagging -- [ ] `cargo xtask ci` is green on all three runners. +- [ ] `cargo xtask ci` is green on all three runners. The release workflow calls that + same check as its gate, so a tag on a red commit publishes nothing: the matrix runs at + the cut rather than on every commit, which is why the cut is the one place it cannot be + skipped. - [ ] The version in `Cargo.toml` matches the tag. - [ ] `CHANGELOG.md` has the entry. - [ ] `README.md` and `docs/PLAN.md` still describe what the binary does. From bd62c9c8ea1ab69e4a36f70f359a0d1d94a9aaff Mon Sep 17 00:00:00 2001 From: vecnode <229195741+vecnode@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:20:57 +0000 Subject: [PATCH 03/41] Put an icon in the status area, and open on paper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3's first row. The tray is one crate on three desktops — `tray-icon`, built with `default-features = false, features = ["ksni"]` — so `native/src/tray.rs` is a single file rather than three modules, and the menu is one list whose ids are mapped back to four commands. On Linux that feature is the whole point: the default backend wants GTK3 and `libappindicator`, which the dependency rules refuse, and the `ksni` backend reaches the session bus through the pure-Rust `zbus` instead. Nothing in this tree opens a socket. Four things the tray can ask for, and each one is a control the window already has: open, scan now, pause the schedule, quit. Pause is new to the window too, so Settings grew a pause and resume button beside it — a tray that could postpone a scan nobody could resume would be a hidden state, which is the thing this program refuses to be. `close_to_tray` lands in the same pass, off by default, because it is the tray's other half: closing the window hides it only when there is a tray to come back from and the person turned the setting on, a quit is always a quit, and the journal gets a line at the moment the window disappears. `World::hide_on_close` holds that rule and has a test for each of its three conditions. The window also opens on paper now (`Visuals::light`) rather than ink. The choice is remembered once it is made, and the mark in the top bar did not change with it: the application tile reads on both backgrounds, which is why it is what the window icon and the top bar both draw. One thing was tried and put back: the window icon and the top-bar mark were briefly the bare white crystal, on the theory that a 16 px tile is a black square with a white speck. The owner looked at it and preferred the tile, so the tile is what ships; the bare white mark stays where it is a status glyph, in the tray. Tests: 88 core, 77 native, 20 interface, 4 xtask. New: the menu is the four commands and only the pause item changes with the schedule; the tray's icon is 64x64 unmultiplied RGBA with a transparent corner and a white spine; a close hides only when the setting is on, the tray is present and the close is not a quit. --- AGENTS.md | 20 ++-- Cargo.lock | 100 ++++++++++++++++ Cargo.toml | 7 ++ SECURITY.md | 2 +- apps/vn-ranger/src/app.rs | 135 ++++++++++++++++++++- apps/vn-ranger/src/main.rs | 6 +- assets/logo/BRAND.md | 15 ++- assets/logo/MANIFEST.toml | 10 ++ assets/logo/vn-ranger-white.rgba | Bin 0 -> 16384 bytes crates/native/Cargo.toml | 1 + crates/native/src/lib.rs | 1 + crates/native/src/store.rs | 6 + crates/native/src/tray.rs | 200 +++++++++++++++++++++++++++++++ crates/ui/src/lib.rs | 76 ++++++++++-- crates/ui/src/views.rs | 59 ++++++++- crates/ui/src/world.rs | 59 +++++++++ docs/PLAN.md | 37 +++--- 17 files changed, 693 insertions(+), 41 deletions(-) create mode 100644 assets/logo/vn-ranger-white.rgba create mode 100644 crates/native/src/tray.rs diff --git a/AGENTS.md b/AGENTS.md index f7015e5..6b9bf8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,9 +125,11 @@ act on — never a panic. not a trade-off to be weighed; it is out of scope. - Rule packs and signature databases are local files. They cannot fetch, and rules are data, not code. -- One local socket is allowed, and only this one: the Linux tray's status-item connection - to the session bus, in `native/src/tray/linux.rs`. `xtask` asserts that it is the only - socket use in the tree, so "local IPC" cannot quietly become something else. +- One local socket exists anywhere near the product, and it belongs to the tray's Linux + backend: the status-item connection to the session bus, opened by `ksni` (pure-Rust + `zbus`), which is reached through `tray-icon`'s `ksni` feature. No file in this tree + opens a socket, `xtask` asserts that, and the dependency that does is the one named in + §8 — so "local IPC" cannot quietly become something else. ### Never hide @@ -296,11 +298,15 @@ Every new dependency is a licence, a supply chain and a size decision. - Not allowed: GPL, AGPL, LGPL without a written exception, CDLA, SSPL, BUSL, or anything whose licence text has to be guessed. - No network crate in v0.x: `reqwest`, `ureq`, `hyper`, `h2`, `curl`, `isahc`, `surf`, - `attohttpc`, `socket2` and raw socket code are banned. The one local socket the Linux - tray needs is named in §2 and asserted by `xtask`. + `attohttpc`, `socket2` and raw socket code are banned. The one local socket the tray + needs is named in §2 and asserted by `xtask`. - No C or C++ build dependency in the default build. A crate that needs one has to justify it; `netstat2` is Windows-only here for exactly that reason, and the Linux tray - uses `ksni` rather than `tray-icon` precisely to avoid GTK3 and `libappindicator`. + takes `tray-icon` with `default-features = false, features = ["ksni"]` precisely so that + no GTK3 and no `libappindicator` is compiled in. Both halves of that are a decision: + `tray-icon` (MIT OR Apache-2.0) for one menu API on three platforms, and its `ksni` + backend, which speaks StatusNotifierItem through the pure-Rust `zbus` and needs no + toolkit installed on the user's machine. - OS access goes through a safe wrapper (`winreg`, `netstat2`, `is_elevated`, `runas`, `sysinfo`, `plist`, `cap-std`), never through a hand-written FFI block. - **The action path's dependency is `cap-std`**, and it is not negotiable for @@ -354,7 +360,7 @@ A change is done when: | The allowlist | `native/src/clean.rs`, `core/src/action.rs` | A table, not a heuristic; the walk never follows a link or crosses a device, and a file changed since the preview is skipped. | | Packs | `core/src/pack/`, `native/src/pack/` | Verified before parsed; a closed vocabulary; an unknown field is an error; activation and rollback are journal entries. | | The Sigma reader | `core/src/sigma/` | YAML is user input: a restricted reader that refuses anchors, tags and multi-document files rather than guessing. | -| The tray | `native/src/tray/` | `tray-icon` on Windows and macOS, `ksni` on Linux, an event loop per platform, and no icon is a supported state. | +| The tray | `native/src/tray.rs` | One crate for three desktops, so the subtlety is not a per-platform module: it is that the icon must be created on the frame's thread, that a click arrives on a channel this process has to wake the interface for, and that "no status area" is a state rather than an error. | | Elevation | `native/src/elevation.rs`, `apps/vn-ranger/src/worker.rs` | The elevated copy is one-shot; the unelevated window must not hand it a path to act on. | | Listener ownership | `native/src/collect/listeners/` | The socket table and the fd table are read at different times; other users' sockets need privilege. | | Autorun parsing | `native/src/collect/autoruns/` | Registry views, 32/64-bit keys, quoted paths, binary plists, systemd user units, and a surface table read through one trait. | diff --git a/Cargo.lock b/Cargo.lock index 1be8410..bc6b5e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,6 +850,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1427,6 +1436,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -2063,6 +2081,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2100,6 +2128,24 @@ dependencies = [ "libc", ] +[[package]] +name = "ksni" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b" +dependencies = [ + "async-executor", + "async-io", + "async-lock", + "futures-channel", + "futures-lite", + "futures-util", + "pastey", + "serde", + "task-local", + "zbus", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2279,6 +2325,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -2979,6 +3043,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "peniko" version = "0.6.1" @@ -3817,6 +3887,15 @@ dependencies = [ "windows", ] +[[package]] +name = "task-local" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4004,6 +4083,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tray-icon" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9" +dependencies = [ + "crossbeam-channel", + "ksni", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -4183,6 +4282,7 @@ dependencies = [ "sha2", "sysinfo", "tempfile", + "tray-icon", "vnr-core", "winreg", ] diff --git a/Cargo.toml b/Cargo.toml index 0135f49..9dd2586 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,13 @@ runas = "1.2.0" # Unix-only: effective uid for the elevation status. nix = { version = "0.31.3", features = ["user"] } +# The tray, on all three platforms, from one crate with one menu API. The default +# backend on Linux is `libappindicator`, a GTK3 C dependency this workspace refuses +# (AGENTS.md §8), so the feature that replaces it is the one taken: `ksni` speaks +# StatusNotifierItem over the session bus and needs no toolkit. On Windows and macOS +# that feature resolves to nothing, because the crate gates the dependency by target. +tray-icon = { version = "0.26.1", default-features = false, features = ["ksni"] } + [profile.dev] opt-level = 1 diff --git a/SECURITY.md b/SECURITY.md index dc050be..7822fc1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ are no backports to older 0.x lines. | Promise | Enforcement | |---|---| -| No telemetry, no account, no network in v0.x | No HTTP client in the dependency tree; `cargo xtask ci` bans network crates and greps the workspace for socket code, and asserts that the Linux tray's local session-bus socket is the only one | +| No telemetry, no account, no network in v0.x | No HTTP client in the dependency tree; `cargo xtask ci` bans network crates and asserts that no file in this tree opens a socket. The tray's one local session-bus connection is inside the `ksni` crate (pure-Rust `zbus`) rather than in ranger's own code | | No standing elevation | The window is unelevated; an administrator scan is a separate one-shot copy that the OS asks you to approve, writes one report and exits; no service and no driver | | No silent deletion | Quarantine before delete; preview; the allowlist table; an action journal written before the act; a typed confirmation for a permanent delete; two-person review of deletion paths | | No path outside the allowlist is reachable | A destructive act is named by an id, never a path, and resolved through an open directory handle (`cap-std`); `..`, absolute, UNC, links and hard links are refused by construction | diff --git a/apps/vn-ranger/src/app.rs b/apps/vn-ranger/src/app.rs index 51a8fe9..00645fb 100644 --- a/apps/vn-ranger/src/app.rs +++ b/apps/vn-ranger/src/app.rs @@ -41,6 +41,14 @@ pub struct App { clean_previews: Vec<(vnr_core::action::Surface, vnr_native::clean::Preview)>, /// Counts dry runs, so the interface can tell one from another. clean_run: u64, + /// The tray, when this desktop has one. + tray: Option, + /// Whether the tray has been attempted, so a machine without one tries once. + tray_started: bool, + /// Whether the scheduled scan is postponed. + paused: bool, + /// Whether this close is the end of the program rather than a trip to the tray. + quitting: bool, } impl App { @@ -83,6 +91,9 @@ impl App { enabled: settings.watch.unwrap_or(true), ..WatchReport::default() }, + tray: vnr_ui::TrayState::Unknown, + paused: false, + close_to_tray: settings.close_to_tray.unwrap_or(false), baseline, baseline_error, changes, @@ -145,6 +156,10 @@ impl App { last_frame_unix: now_unix(), clean_previews: Vec::new(), clean_run: 0, + tray: None, + tray_started: false, + paused: false, + quitting: false, }; if app.world.snapshot.is_none() { app.start_scan(&cc.egui_ctx, false); @@ -306,6 +321,78 @@ impl App { self.world.clean = Some(report); } + /// Put the icon in the status area, once, on the first frame. + /// + /// A frame is the safe moment to do it: on Windows and macOS the icon needs an event + /// loop running on this thread, and by the time a frame is drawn there is one. A + /// desktop with no status area is not an error to recover from, it is a state to say + /// out loud, in Settings and in the journal. + fn start_tray(&mut self, ctx: &egui::Context) { + if self.tray_started { + return; + } + self.tray_started = true; + let wake = { + let ctx = ctx.clone(); + move || ctx.request_repaint() + }; + let started = + vnr_native::tray::Tray::start(vnr_native::tray::ICON.to_vec(), vnr_native::tray::ICON_SIZE, wake); + match started { + Ok(tray) => { + tray.set_paused(self.paused); + self.tray = Some(tray); + self.world.tray = vnr_ui::TrayState::Present; + } + Err(reason) => { + log::warn!("no tray: {reason}"); + self.note(Draft::event("no tray").with_reason(&reason)); + self.world.tray = vnr_ui::TrayState::Absent(reason); + } + } + } + + /// Turn a close request into a hide, when that is what was asked for. + /// + /// The window is only ever hidden when there is a tray to bring it back, and the journal + /// says so at the moment it happens: a person who closed a window and finds a process + /// still running deserves to read why in the one file ranger keeps. + fn close_or_hide(&mut self, ctx: &egui::Context) { + if !ctx.input(|input| input.viewport().close_requested()) || !self.world.hide_on_close(self.quitting) + { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + self.note( + Draft::event("window hidden") + .with_reason("the window was closed while close-to-tray is on; the tray icon opens it again"), + ); + self.world.notice = Some("The window is in the status area; its menu opens it again.".to_owned()); + } + + /// Carry out whatever the tray menu reported. + /// + /// The tray never gets a fifth action: everything here is an [`Action`] the window can + /// also produce, so the two surfaces cannot diverge. + fn poll_tray(&mut self, ctx: &egui::Context) { + let commands = match &self.tray { + Some(tray) => tray.poll(), + None => return, + }; + for command in commands { + match command { + vnr_native::tray::Command::Open => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + vnr_native::tray::Command::ScanNow => self.apply(Action::Scan, ctx), + vnr_native::tray::Command::Pause => self.apply(Action::PauseScan, ctx), + vnr_native::tray::Command::Quit => self.apply(Action::Quit, ctx), + } + } + } + fn drain(&mut self) { let mut messages = Vec::new(); let mut disconnected = false; @@ -592,6 +679,25 @@ impl App { self.world.poll_led = enabled; self.persist_settings(); } + Action::PauseScan => { + self.paused = !self.paused; + let now = now_unix(); + if self.paused { + self.next_scan_unix = now.saturating_add(3600); + self.world.notice = Some( + "The next scheduled scan is an hour away. Scan now, or press Resume \ + scheduled scans, to end the postponement." + .to_owned(), + ); + } else { + self.next_scan_unix = vnr_core::schedule::next_at(now, self.plan, self.schedule_seed); + self.world.notice = Some("Scheduled scans are back on.".to_owned()); + } + self.world.paused = self.paused; + if let Some(tray) = &self.tray { + tray.set_paused(self.paused); + } + } Action::SuppressFinding { rule_id, subject, @@ -701,7 +807,24 @@ impl App { } } } - Action::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::Quit => { + // A quit is a quit even when close-to-tray is on: the setting is about the + // close button, and a program that cannot be ended from its own menu is + // worse than one that cannot be hidden. + self.quitting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Action::SetCloseToTray(enabled) => { + self.world.close_to_tray = enabled; + self.settings.close_to_tray = Some(enabled); + self.persist_settings(); + self.world.notice = Some(if enabled { + "Closing the window now hides it; the tray icon brings it back. Quit still quits." + .to_owned() + } else { + "Closing the window ends ranger again.".to_owned() + }); + } Action::RefreshActions => { self.refresh_actions(); self.world.notice = Some("The quarantine and the journal were read again.".to_owned()); @@ -899,6 +1022,7 @@ impl App { self.settings.poll = Some(self.world.poll_enabled); self.settings.watch = Some(self.world.watch.enabled); self.settings.led = Some(self.world.poll_led); + self.settings.close_to_tray = Some(self.world.close_to_tray); if let Err(error) = vnr_native::store::save_ui_settings(&self.settings) { self.world.error = Some(format!("The interface preference could not be saved: {error}")); } @@ -932,6 +1056,8 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); self.world.now_unix = now_unix(); + self.start_tray(&ctx); + self.close_or_hide(&ctx); self.drain(); self.drain_watch(&ctx); self.tick(&ctx); @@ -952,6 +1078,13 @@ impl App { /// Both are skipped while a scan is running, and the next due time moves only when a /// scan actually starts, so a slow scan cannot queue a second one behind itself. fn tick(&mut self, ctx: &egui::Context) { + self.poll_tray(ctx); + if self.paused { + // A postponed schedule means the clock has nothing to say; the poll and the + // watcher still run, and a person can always scan by hand. + self.last_frame_unix = now_unix(); + return; + } let now = now_unix(); let gap = now.saturating_sub(self.last_frame_unix); // The poll beats every two seconds; three missed beats is a lid that closed. diff --git a/apps/vn-ranger/src/main.rs b/apps/vn-ranger/src/main.rs index 35f6383..f4e081e 100644 --- a/apps/vn-ranger/src/main.rs +++ b/apps/vn-ranger/src/main.rs @@ -20,7 +20,11 @@ const STARTING_SIZE: [f32; 2] = [1180.0, 780.0]; /// The smallest the window may be made before the layout stops reflowing. const MINIMUM_SIZE: [f32; 2] = [920.0, 600.0]; -/// The application's mark, as unmultiplied RGBA. +/// The application's mark for the window: the crystal on the suite's ink tile. +/// +/// The tile, not the bare mark. It is what the interface draws beside the name and what +/// Windows and macOS show in a title bar, and its ink fill is what keeps a white crystal +/// readable on a light title bar as well as a dark one (`assets/logo/BRAND.md`). const ICON: &[u8] = include_bytes!("../../../assets/logo/vn-ranger-app.rgba"); /// The icon is square and this many pixels on a side. diff --git a/assets/logo/BRAND.md b/assets/logo/BRAND.md index edef042..4d078b0 100644 --- a/assets/logo/BRAND.md +++ b/assets/logo/BRAND.md @@ -88,7 +88,11 @@ gives the eye a solid silhouette and the ember gives it a colour to find. | `vn-ranger.svg`, `vn-ranger-.png` | Light backgrounds. Ink linework, ember inlay, transparent elsewhere. | | `vn-ranger-white.svg`, `vn-ranger-white-.png` | Dark backgrounds. White linework, ember inlay, transparent elsewhere. | | `vn-ranger-app.svg`, `vn-ranger-app-.png` | An application icon: the mark on the suite's black tile, with white ridges. | -| `vn-ranger-app.rgba` | 64×64 unmultiplied RGBA for `egui::IconData`; embedded with `include_bytes!`. | +| `vn-ranger-app.rgba` | 64×64 unmultiplied RGBA of the tile, for packaging, a store listing or a shortcut. | +| `vn-ranger-white.rgba` | 64×64 unmultiplied RGBA of the bare white mark, for the tray icon. | + +If a target takes raw bytes rather than a file, it gets one of the two `.rgba` files and +not a PNG decoder: both are 16,384 bytes, exactly the three fields `egui::IconData` takes. Sizes are 16, 24, 32, 48, 64, 128, 256, 512 and 1024 pixels square. Every PNG was rendered at its exact size from the SVG, not downscaled from a master. Prefer the SVG @@ -107,6 +111,12 @@ The tile is black, not ember. An icon has to hold its own against whatever is be it: a bare black glyph disappears on a dark taskbar and a bare white one on a light taskbar, whereas a black tile with a white crystal and an ember inlay reads on both. +**Where ranger itself uses which.** The application tile is the window icon and the mark +in the top bar, exactly as it always was: it reads on paper and on ink, so the interface +does not swap artwork when the theme changes. The **tray** is the one place the bare white +mark is used, because a status area asks for a glyph at 16–24 px rather than a tile, and a +tile there is a black square among the system's own icons. + ## Using it **Do** @@ -154,7 +164,8 @@ The SVGs are the source of truth. To regenerate: 2. For the application tile, keep the black rounded square; only the corners are transparent. 3. Decode `vn-ranger-app-64.png` to unmultiplied RGBA and write the 16,384 raw bytes to - `vn-ranger-app.rgba`. + `vn-ranger-app.rgba`; decode `vn-ranger-white-64.png` the same way to + `vn-ranger-white.rgba`. 4. Regenerate `contact-sheet.png` from the rendered set. 5. Regenerate `MANIFEST.toml` so every digest matches the files that shipped. diff --git a/assets/logo/MANIFEST.toml b/assets/logo/MANIFEST.toml index e728362..7e36c0f 100644 --- a/assets/logo/MANIFEST.toml +++ b/assets/logo/MANIFEST.toml @@ -252,6 +252,16 @@ purpose = "Application icon source; ink tile, white linework, ember inlay." bytes = 1008 sha256 = "34e59bd212876b81f5e1e486312e52c0aaa023e06b2e7198a0dd4a42e3deda07" +[[file]] +path = "vn-ranger-white.rgba" +variant = "dark" +format = "rgba" +size = "64" +background = "transparent" +purpose = "Unmultiplied RGBA for the tray icon: the bare crystal with no tile, because a status area asks for a glyph rather than an application tile (16,384 bytes)." +bytes = 16384 +sha256 = "51ed65f4ffa22d732c143b009661ce1f572dab891b368558d751d17ea092e82f" + [[file]] path = "vn-ranger-white-1024.png" variant = "dark" diff --git a/assets/logo/vn-ranger-white.rgba b/assets/logo/vn-ranger-white.rgba new file mode 100644 index 0000000000000000000000000000000000000000..cf6d36704478aa3050a3d69b159bdeb5d22951b2 GIT binary patch literal 16384 zcmeHOYitx{6jc&pNce@I!9>J}4~U4M1SP}=N~oe3qX^XmHHr!$h{f`V%CkUugP@ow zMo_3i1(aA6H9mq$P&5W#C@qlkD1|L;ceXpTyPe~?%T8Ig`^`*ew!5Ic$;qAXaqm6n zo9%RG_L~bXXtTBzXj_47t^j}wi3f-c2@IFMY;wp|E$Zw+94At(iXA<2#jCaJQd?JI zH4!6JCS$J?U0a(^w(?U;Ct?xNK)5xTD>0q2#WT}&$h{*mo6rcaCb5#EW2X7FRz7m= zKok-uiA(mHolO4|(D4Cx$iih@WyLI!#^SGSrVIH?gcSfcPOzefM3BQT1HAF?Ce( zh;SR=MoUZPFlTRKdvc)`A6M^3D>~I>E<#=f8uk( zB)nNoC1Pds5nXv-9H2&})mJyoi)}9k80*7?^A4haI&DsUjQh(+ZGc*0JC-7{{2oMC z+}q@(UupHno90<@k^`FWuM)pG=}jIdzuiiXP6Jv1u`galWLX{}ONX98qV1^Pib;u& zeYp|{)i_nR8~-j@qw1ImMX7OqF^+c#k>ABM=1AutvXN$Z{TtK#DQD~7SgTL5c$ zv~DWhRzLH~J(_#tH9NMegu3sCvG6WOfS+-t)g5kGCE5H;YT;S#URV%QfJX6AQcX@s>`NXYeSy1g^$-jw7Ee zgkCfV;W>lO@}}MSudQk}U*e<8WqaLlMJhgNluEbN&sq8I)7j571C@wuo{8|Rfe6ng z=uLjcRc4C*#60nlt#e@399B*D(w}eXRCy+S%u8as7_;gh80#k?Jo6TaMwOYh$Ivii z0F07Cn8)|4rupfARHfVMXY!2ew+J^L{+-`t0#>uR=U8LqZ_WEN*W+7%^!pNW+?zQ@ z8Ojvn+D3Q2=akL2{>Vr35t`N)`nrjb&vmX%I0zTvWPHSX27B|Z-`Fx2p(%Zc8{%Fs znaXRFJeJC?$#qdLnFek0X&%DI*gD^dLezVC6V zp>LcXU;C!|2|jXe9L&G|59dIe)C<9aUU9D_dszpb12gFReyy1d$!Tnz$2ISxtZ!^C zY8IE$;a`90Yy>A>2aSmP7ZToTi!Zzp#wW}8i$`5bc@ItIkX#ybo>kYXmsDRbo#Rx% zxBl>^nFv0A4T2M{j{AD?6H?mN_|%(V?BMN`5pv?ye4H`HjyF(0rJp)K>m=9LOZn`` z-hAuVKbV2~@mC>8h__(1Q+<5yl$#OxdMyw&?AggE=NMnT1C23O?s$$wt#tXV@$g1I zU$Zyg`or%}NBy`fJs~*l79bK}GIbQRJd2Spm1NvV@~H zSNJsW+&GwT{hrxx}4R1Hl@w+Mw{;njnwZHZ#SL|{o00?5g5}A zHP3WIz=E6hgkGqfJBE$dQU!m`57eI zfr5Uho&O+$YbK)M<7LoyZ9%mBXIsrGzOoh)D>)jrEsN)r+<}6=%KF;+Ea=~|2&Wd0 zL~z{{gtos9V^0~Phj`D%Ygg9HXkg8~&{?~o>^0P{os3#?(zeQ$30re>{xkOc!I z2HiaW@c;ZPH`?4(b@TjdjGIK~>71vm=R7J;T}#eWZEk2UQ0cb%nZ)r+q?Ju}MPG!d&g24mlDXgfE-Y*7DpWrpey+F1&nb0r3h&9Ggj(w*GTnx23AHNzUT z?>D2WI3GtQ4aC7wU2rh}k|rNJseYavJ5kv*&x$)za4V`7aP__aC3TIMa{wWGj69oZ^9;mp%w<90mht^hY1Sc+ zb@sUAl#OY!|1B?-XKS}vQ}#dZ%Z5Nj4XOQbYwNVj&-$|e^>N|=ku9rWq0vN6_rE5u z3%Rr>#u2T#|5M&-hWE74%bp&HIZtvG3Gz} k{#)`oP^Y~ADDOPVhK+X<$N!7>ACuRi?YM0Pa=ilo0`(;c82|tP literal 0 HcmV?d00001 diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 0860869..7a97252 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -26,6 +26,7 @@ plist = { workspace = true } tempfile = { workspace = true } notify = { workspace = true } cap-std = { workspace = true } +tray-icon = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] notify = { workspace = true, features = ["macos_kqueue"] } diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs index 67f0c89..08d5772 100644 --- a/crates/native/src/lib.rs +++ b/crates/native/src/lib.rs @@ -22,6 +22,7 @@ pub mod paths; pub mod quarantine; pub mod signatures; pub mod store; +pub mod tray; pub mod watch; mod scan; diff --git a/crates/native/src/store.rs b/crates/native/src/store.rs index 56fb7bc..f00318f 100644 --- a/crates/native/src/store.rs +++ b/crates/native/src/store.rs @@ -98,6 +98,12 @@ pub struct UiSettings { pub watch: Option, /// The live indicator. pub led: Option, + /// Whether closing the window hides it to the tray. + /// + /// Off unless a person turns it on, and it means nothing on a desktop with no tray: the + /// default is that closing the window ends the application, which is the one behaviour + /// nobody has to learn. + pub close_to_tray: Option, } /// Read the interface preferences, falling back to the defaults. diff --git a/crates/native/src/tray.rs b/crates/native/src/tray.rs new file mode 100644 index 0000000..841ed91 --- /dev/null +++ b/crates/native/src/tray.rs @@ -0,0 +1,200 @@ +//! The tray: an icon for a window that is still there. +//! +//! One crate covers the three desktops (`tray-icon`), with one menu and one event queue, so +//! there is no per-platform module here and nothing to drift. On Linux the crate's `ksni` +//! feature is what is compiled: a StatusNotifierItem over the session bus, with no GTK3 and +//! no `libappindicator` (§8). On Windows and macOS it is the platform's own status area. +//! +//! The tray is a second door to four things, and every one of them is a control the window +//! already has: open, scan now, pause the schedule, quit. It never gets a fifth. +//! +//! A tray is optional. Where the desktop has no status area — a bare session, a container, +//! a Linux machine with no StatusNotifier host — the application runs without one and says +//! so in Settings rather than pretending an icon is somewhere. + +use std::sync::{Arc, Mutex}; + +use tray_icon::menu::{Menu, MenuEvent, MenuItem}; +use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; + +/// The mark the tray shows: the bare crystal, white linework, ember inlay. +/// +/// The same bytes the window icon uses. A tile would be a black square in a tray, which is +/// the one place a 16-pixel silhouette matters most. +pub const ICON: &[u8] = include_bytes!("../../../assets/logo/vn-ranger-white.rgba"); + +/// The icon is square and this many pixels on a side. +pub const ICON_SIZE: u32 = 64; + +/// What a person asked for from the tray. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + /// Raise the window. + Open, + /// Start a scan now, if one is not already running. + ScanNow, + /// Postpone the next scheduled scan by an hour, or end a postponement. + Pause, + /// Close the application. + Quit, +} + +impl Command { + /// The menu, in the order it is drawn. + /// + /// One list, built once and mapped back from the ids the menu returns, so the order and + /// the meaning cannot disagree. + pub const ALL: [Command; 4] = [Command::Open, Command::ScanNow, Command::Pause, Command::Quit]; + + /// What the menu says for this command. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Open => "Open vn-ranger", + Self::ScanNow => "Scan now", + Self::Pause => "Pause for an hour", + Self::Quit => "Quit", + } + } + + /// What the pause item says once the schedule is already postponed. + #[must_use] + pub fn paused_label(self) -> &'static str { + match self { + Self::Pause => "Resume scheduled scans", + other => other.label(), + } + } +} + +/// A tray that is on screen, and the queue of what has been clicked. +pub struct Tray { + /// Held for as long as the icon should exist: dropping it removes the icon. + _icon: TrayIcon, + /// The pause item, kept so its label can follow the scheduler. + pause_item: Option, + /// What the menu has reported since the last poll. + queue: Arc>>, +} + +impl std::fmt::Debug for Tray { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("Tray").finish_non_exhaustive() + } +} + +impl Tray { + /// Put the icon in the status area, or say why it could not be. + /// + /// `wake` is called on every click, and is how the interface is asked to draw a frame: + /// an egui window that nobody is touching is otherwise asleep. The tray keeps no + /// knowledge of egui, which is why this is a callback rather than a context. + pub fn start(rgba: Vec, size: u32, wake: impl Fn() + Send + Sync + 'static) -> Result { + let menu = Menu::new(); + let mut ids = Vec::with_capacity(Command::ALL.len()); + let mut pause_item = None; + for command in Command::ALL { + let item = MenuItem::new(command.label(), true, None); + ids.push((item.id().clone(), command)); + if command == Command::Pause { + pause_item = Some(item.clone()); + } + menu.append(&item) + .map_err(|error| format!("the tray menu was refused: {error}"))?; + } + + let queue: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&queue); + let mapping = ids.clone(); + // Set before the icon is built, so a click cannot arrive before there is a queue to + // put it in. The handler replaces the channel: this is the only listener there is. + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + if let Some((_, command)) = mapping.iter().find(|(id, _)| *id == event.id) + && let Ok(mut queue) = sink.lock() + { + queue.push(*command); + } + wake(); + })); + + let icon = Icon::from_rgba(rgba, size, size) + .map_err(|error| format!("the tray icon was refused: {error}"))?; + let icon = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + // The menu is the whole interface, so a left click is the menu rather than a + // second, undocumented way to open the window. + .with_menu_on_left_click(true) + .with_tooltip("vn-ranger · a quiet ranger for your machine") + .with_icon(icon) + .build() + .map_err(|error| format!("the tray icon could not be created: {error}"))?; + + Ok(Self { + _icon: icon, + pause_item, + queue, + }) + } + + /// Everything clicked since the last call. + #[must_use] + pub fn poll(&self) -> Vec { + let Ok(mut queue) = self.queue.lock() else { + return Vec::new(); + }; + std::mem::take(&mut *queue) + } + + /// Say whether the schedule is postponed, so the pause item reads as what it will do. + pub fn set_paused(&self, paused: bool) { + if let Some(item) = &self.pause_item { + item.set_text(Command::Pause.paused_label_or(paused)); + } + } +} + +impl Command { + /// The pause item's text, given the schedule. + fn paused_label_or(self, paused: bool) -> &'static str { + if paused { self.paused_label() } else { self.label() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_menu_is_the_four_things_the_window_can_also_do() { + assert_eq!(Command::ALL.len(), 4); + let labels: Vec<&str> = Command::ALL.iter().map(|command| command.label()).collect(); + assert_eq!( + labels, + vec!["Open vn-ranger", "Scan now", "Pause for an hour", "Quit"] + ); + } + + #[test] + fn the_pause_item_says_which_way_it_will_go() { + assert_eq!(Command::Pause.label(), "Pause for an hour"); + assert_eq!(Command::Pause.paused_label(), "Resume scheduled scans"); + assert_eq!(Command::Pause.paused_label_or(true), "Resume scheduled scans"); + assert_eq!(Command::Pause.paused_label_or(false), "Pause for an hour"); + assert_eq!( + Command::ScanNow.paused_label(), + "Scan now", + "only the pause item changes with the schedule" + ); + } + + #[test] + fn the_icon_is_a_square_of_unmultiplied_rgba() { + let expected = (ICON_SIZE * ICON_SIZE * 4) as usize; + assert_eq!(ICON.len(), expected, "egui and tray-icon take the same shape"); + // The corner is transparent and the spine is white: this is the bare mark, not the + // application tile, which would be ink at the edge. + assert_eq!(&ICON[3..4], &[0_u8]); + let middle = ((ICON_SIZE * ICON_SIZE / 2 + ICON_SIZE / 2) * 4) as usize; + assert_eq!(&ICON[middle..middle + 3], &[255, 255, 255]); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b3f3152..617f09a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -22,7 +22,7 @@ mod clean; pub use world::{ Action, CleanChoice, CleanItem, CleanReport, CleanSurface, JournalRow, Live, QuarantineRow, ScanState, - View, WatchReport, World, + TrayState, View, WatchReport, World, }; use egui::{Align2, CornerRadius, FontId, Margin, RichText, Sense, vec2}; @@ -67,7 +67,7 @@ pub(crate) enum LedState { /// /// This is a square wave's opposite: a beat is an event with a timestamp, so the lamp /// flashes and then goes quiet. A boolean toggled per poll would be a metronome, and a -/// metronome cannot say that the poll has stopped — which is the one thing this lamp is +/// metronome cannot say that the poll has stopped — which is the one thing this lamp is /// for. #[must_use] pub(crate) fn led_state( @@ -229,6 +229,7 @@ pub struct Ui { watch_new: String, error_seen: Option<(String, u64)>, error_dismissed: Option, + /// The application mark: the crystal on its ink tile, the same bytes the window icon uses. mark: egui::TextureHandle, } @@ -247,14 +248,34 @@ enum PaletteHit { Action(Action), } +/// Upload one of the embedded marks as a texture. +/// +/// A mark that is the wrong size is a one-pixel transparent texture rather than a panic: an +/// asset edited by hand should cost the picture, not the window. +fn upload(ctx: &egui::Context, name: &str, rgba: &[u8]) -> egui::TextureHandle { + const SIDE: usize = 64; + let image = if rgba.len() == SIDE * SIDE * 4 { + egui::ColorImage::from_rgba_unmultiplied([SIDE, SIDE], rgba) + } else { + egui::ColorImage::from_rgba_unmultiplied([1, 1], &[0, 0, 0, 0]) + }; + ctx.load_texture(name, image, egui::TextureOptions::LINEAR) +} + impl Ui { /// Build the interface and upload the application mark once. + /// + /// One mark for both themes: the crystal on the suite's ink tile, which is what the + /// window icon is built from too. The tile reads on paper as well as on ink, so the top + /// bar does not have to swap artwork when the theme changes. #[must_use] pub fn new(ctx: &egui::Context) -> Self { fonts::install(ctx); - let pixels: &[u8] = include_bytes!("../../../assets/logo/vn-ranger-app.rgba"); - let image = egui::ColorImage::from_rgba_unmultiplied([64, 64], pixels); - let mark = ctx.load_texture("vn-ranger-mark", image, egui::TextureOptions::LINEAR); + let mark = upload( + ctx, + "vn-ranger-mark", + include_bytes!("../../../assets/logo/vn-ranger-app.rgba"), + ); Self { view: View::Overview, last_view: View::Overview, @@ -263,7 +284,9 @@ impl Ui { selected_autorun: None, only_public: false, confirm_elevate: false, - dark: true, + // Paper is what a new installation opens on. Dark is one click away in the top + // bar, and the choice is remembered once it is made. + dark: false, open_path: None, led_flash: None, project_open: false, @@ -509,7 +532,7 @@ impl Ui { ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { ui.add_space(4.0); ui.label( - RichText::new("No network · No telemetry") + RichText::new("No network · No telemetry") .size(10.0) .color(theme::muted(ui)), ); @@ -584,7 +607,7 @@ impl Ui { ui.add_sized( vec2(240.0, 26.0), egui::TextEdit::singleline(&mut self.search) - .hint_text("Filter…") + .hint_text("Filter…") .margin(Margin::symmetric(8, 6)), ); }); @@ -830,21 +853,21 @@ impl Ui { .collect(); for process in world.processes().iter().take(200) { entries.push(PaletteEntry { - label: format!("Process · {} (pid {})", process.name, process.pid), + label: format!("Process · {} (pid {})", process.name, process.pid), hit: PaletteHit::Process(process.pid), }); } if let Some(snapshot) = world.snapshot() { for autorun in snapshot.autoruns.iter().take(200) { entries.push(PaletteEntry { - label: format!("Startup · {} ({})", autorun.name, autorun.kind.label()), + label: format!("Startup · {} ({})", autorun.name, autorun.kind.label()), hit: PaletteHit::Autorun(autorun.id.clone()), }); } } for finding in world.findings.iter().take(100) { entries.push(PaletteEntry { - label: format!("Finding · {}", finding.title), + label: format!("Finding · {}", finding.title), hit: PaletteHit::Finding(finding.title.to_owned()), }); } @@ -902,7 +925,7 @@ impl Ui { ui.set_max_width(380.0); ui.horizontal(|ui| { ui.label(RichText::new(error.as_str()).size(11.5).color(theme::BAD)); - if ui.small_button("✕").clicked() { + if ui.small_button("✕").clicked() { dismiss = true; } }); @@ -1098,6 +1121,9 @@ mod tests { poll_enabled: true, poll_led: true, watch: WatchReport::default(), + tray: TrayState::Present, + paused: false, + close_to_tray: false, baseline, baseline_error: None, changes, @@ -1193,6 +1219,32 @@ mod tests { }); } + #[test] + fn a_close_hides_only_when_it_can_be_undone() { + let mut world = base_world(None, None); + assert!( + !world.hide_on_close(false), + "off by default: a close button closes" + ); + + world.close_to_tray = true; + assert!(world.hide_on_close(false), "on, with a tray to come back from"); + + assert!( + !world.hide_on_close(true), + "a quit is a quit, even with close-to-tray on" + ); + + world.tray = TrayState::Absent("no status area".to_owned()); + assert!( + !world.hide_on_close(false), + "a window hidden with no tray is a process nobody can reach" + ); + + world.tray = TrayState::Unknown; + assert!(!world.hide_on_close(false), "an unknown tray is not a tray"); + } + #[test] fn every_view_draws_with_an_empty_world() { let world = base_world(None, None); diff --git a/crates/ui/src/views.rs b/crates/ui/src/views.rs index 59f779b..998aaad 100644 --- a/crates/ui/src/views.rs +++ b/crates/ui/src/views.rs @@ -6,7 +6,7 @@ use egui_extras::{Column, TableBuilder}; use vnr_core::time::{describe_age, format_utc}; use vnr_core::{AutorunScope, ProcessFact, Snapshot}; -use crate::{Action, ChangeFilter, FindingSort, ProcessFilter, SortKey, Ui, View, theme, widgets}; +use crate::{Action, ChangeFilter, FindingSort, ProcessFilter, SortKey, TrayState, Ui, View, theme, widgets}; const PROCESS_FOOTER: f32 = 190.0; const STARTUP_FOOTER: f32 = 210.0; @@ -1291,6 +1291,63 @@ impl Ui { } }); ui.add_space(10.0); + widgets::card(ui, |ui| { + ui.label(RichText::new("Residency").family(theme::semibold())); + ui.add_space(4.0); + ui.horizontal(|ui| { + let (button, hint) = if world.paused { + ( + "Resume scheduled scans", + "Scheduled scans are postponed. Nothing runs on its own until you resume or scan now.", + ) + } else { + ( + "Pause for an hour", + "Scheduled scans run on their own; this postpones the next one by an hour.", + ) + }; + if ui.button(button).clicked() { + actions.push(Action::PauseScan); + } + ui.add( + egui::Label::new(RichText::new(hint).size(10.5).color(theme::muted(ui))) + .wrap(), + ); + }); + ui.add_space(4.0); + ui.label( + RichText::new(world.tray.sentence()) + .size(10.5) + .color(theme::muted(ui)), + ); + ui.add_space(4.0); + let has_tray = matches!(world.tray, TrayState::Present); + let mut close_to_tray = world.close_to_tray; + if ui + .add_enabled( + has_tray, + egui::Checkbox::new( + &mut close_to_tray, + RichText::new("Hide the window to the tray when I close it").size(11.0), + ), + ) + .on_hover_text( + "Off by default. When it is on, the tray icon brings the window back and \ + the journal records each time it is hidden.", + ) + .changed() + { + actions.push(Action::SetCloseToTray(close_to_tray)); + } + if !has_tray { + ui.label( + RichText::new("With no tray there is nowhere to hide, so closing quits.") + .size(10.5) + .color(theme::muted(ui)), + ); + } + }); + ui.add_space(10.0); widgets::card(ui, |ui| { ui.label(RichText::new("Privacy").family(theme::semibold())); ui.add_space(4.0); diff --git a/crates/ui/src/world.rs b/crates/ui/src/world.rs index 4b0e45d..eeaf275 100644 --- a/crates/ui/src/world.rs +++ b/crates/ui/src/world.rs @@ -236,6 +236,38 @@ pub struct CleanChoice { pub paths: Vec, } +/// Whether the application has a tray icon, and why not when it has none. +/// +/// "No tray" is a supported state, not a failure: a bare session, a container and a Linux +/// desktop with no StatusNotifier host are all machines ranger has to be honest about. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum TrayState { + /// The tray has not been attempted yet. + #[default] + Unknown, + /// The icon is in the status area. + Present, + /// There is no tray on this desktop, and this is why. + Absent(String), +} + +impl TrayState { + /// The sentence Settings shows. + #[must_use] + pub fn sentence(&self) -> String { + match self { + Self::Unknown => "Not started yet.".to_owned(), + Self::Present => "The icon is in the status area. Its menu opens this window, scans now, \ + pauses the schedule or quits — the same four things the window does." + .to_owned(), + Self::Absent(reason) => format!( + "None on this desktop: {reason}. Closing this window quits, and nothing is \ + hidden in an icon that is not there." + ), + } + } +} + /// One journal line, as the interface lists it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct JournalRow { @@ -299,6 +331,15 @@ pub struct World { pub poll_led: bool, /// What the file watcher is reading. pub watch: WatchReport, + /// Whether the tray icon is on screen, and why not when it is not. + pub tray: TrayState, + /// Whether the scheduled scan is postponed. + pub paused: bool, + /// Whether closing the window hides it to the tray instead of quitting. + /// + /// Off by default. A setting that hides the program has to be one a person chose, and + /// the only honest default for a close button is that it closes. + pub close_to_tray: bool, /// The baseline a person accepted, when there is one. pub baseline: Option, /// Why a baseline file could not be used, when one exists but is unreadable. @@ -336,6 +377,17 @@ impl World { self.snapshot.as_ref() } + /// Whether a close request should hide the window rather than end the application. + /// + /// Three conditions, and all three matter: the person turned it on, there is a tray to + /// come back from, and this close is not a quit. A window hidden on a desktop with no + /// tray is a process nobody can reach, which is the one thing this program must never + /// become. + #[must_use] + pub fn hide_on_close(&self, quitting: bool) -> bool { + !quitting && self.close_to_tray && matches!(self.tray, TrayState::Present) + } + /// The newest process list the interface has, preferring a non-empty poll over the /// scan. A poll that could read nothing leaves the last scan on screen rather than /// claiming the machine has no processes. @@ -377,6 +429,13 @@ pub enum Action { ClearBaseline, /// Turn the two-second process poll on or off. SetPoll(bool), + /// Postpone the next scheduled scan by an hour, or end a postponement. + /// + /// The tray's pause item and the Settings button both send this: the tray has no way to + /// postpone the schedule that the window does not have. + PauseScan, + /// Hide the window to the tray when it is closed, or stop doing that. + SetCloseToTray(bool), /// Turn the file watcher on or off. SetWatch(bool), /// Show or hide the live poll indicator. diff --git a/docs/PLAN.md b/docs/PLAN.md index e4fb720..9882704 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -520,11 +520,13 @@ uses `--background`, starts without focus, and shows the window only when a scan something the baseline does not explain. **The tray** is a menu, not a second interface: open, scan now, pause for an hour, -quit. Windows and macOS use `tray-icon` (no C dependency). Linux uses `ksni`, a pure -Rust implementation of the StatusNotifierItem protocol; the decision and its licence are -in §14.2. A desktop with no status-notifier host (GNOME without the extension, a bare -window manager) is a supported configuration: there is no icon, the setting says so, and -closing the window quits. The tray is never the only way to reach a feature. +quit. One crate holds it on all three platforms: `tray-icon` (MIT OR Apache-2.0), built +with `default-features = false, features = ["ksni"]`, so Linux compiles the +StatusNotifierItem backend instead of the GTK3 and `libappindicator` one. The decision and +its licences are in §14.2. A desktop with no status-notifier host (GNOME without the +extension, a bare window manager) is a supported configuration: there is no icon, the +setting says so, and closing the window quits. The tray is never the only way to reach a +feature. ### 6.6 No network, enforced @@ -829,7 +831,7 @@ release turns into `●`; this table is rewritten at that tag, not before it. | Journal and crash recovery | ○ v0.1.6 | ○ v0.1.6 | ○ v0.1.6 | | Quarantine and verified restore | ○ v0.1.6 | ○ v0.1.6 | ○ v0.1.6 | | Allowlisted cleaning | ○ v0.1.6 | ○ v0.1.6 | ○ v0.1.6 | -| Tray | ○ v0.1.6 (`tray-icon`) | ○ v0.1.6 (`tray-icon`) | ○ v0.1.6 (`ksni`; no icon without a host) | +| Tray | ◐ v0.1.6 (`tray-icon`) | ◐ v0.1.6 (`tray-icon`) | ◐ v0.1.6 (`tray-icon` + its `ksni` backend; no icon without a host) | | Opt-in autostart | ○ v0.1.6 (HKCU `Run`) | ○ v0.1.6 (LaunchAgent) | ○ v0.1.6 (`~/.config/autostart`) | | Signed rule packs | ○ v0.1.6 | ○ v0.1.6 | ○ v0.1.6 | | Content scan (YARA-X) | ○ v0.1.6, build feature | ○ v0.1.6, build feature | ○ v0.1.6, build feature | @@ -1051,7 +1053,7 @@ part that exists): |---|---|---| | 1 Ledger | journal records, reader, rotation, crash-recovery verdicts, the typed action path with its four refusals, the `ActionIo` seam | — | | 2 Quarantine and cleaning | quarantine with a manifest, verified restore, irreversible purge, all four acts journaled, the allowlist walk with preview and removal, the clean preview (a dry run, per-surface totals, per-item selection, one confirmation naming them), the language audit as a check, the Actions view, the quarantine entry point, the typed-`DELETE` dialog | the data-directory size in Settings; the second reviewer on every deletion path, which is a process row and not code | -| 3 Resident | the schedule (six hours ±10 %, seeded jitter), resume detection, a scan on resume | tray, opt-in autostart, activation, the soak recorder | +| 3 Resident | the schedule (six hours ±10 %, seeded jitter), resume detection, a scan on resume, the tray and its menu (open, scan now, pause an hour, quit), "no tray" as a state with a reason, the pause it offers in Settings, close-to-tray off by default with a quit that still quits | opt-in autostart, activation through the `wake` file, the soak recorder, the battery preference | | 4 Packs | — | everything | | 5 Coverage | — | everything | @@ -1396,8 +1398,9 @@ external service, nothing to install beside it. not belong to its subject. Each is a named rejection. - **Golden reports:** a fixed fixture set produces a byte-stable JSON report. - **No-network test:** `cargo deny` bans plus a `Cargo.lock` grep, as in §6.6. From - v0.1.6 it also asserts the only local socket in the tree is the Linux tray's D-Bus - connection, in one file. + v0.1.6 it also asserts that no file in this tree opens a socket: the tray's one + session-bus connection lives in the `ksni` crate, not in ours, so the check can stay a + grep over our own sources rather than a claim about a dependency. - **No YARA-X by default:** `cargo tree -e features` proves the default build has no `yara-x`, and that the feature builds it in when asked. - **Language:** `cargo xtask language` fails on "fix", "infected", "threat score" and @@ -1528,13 +1531,15 @@ and that is the launcher's, not ranger's. 12. **Signatures are minisign:** `minisign-verify` (MIT, zero dependencies) verifies packs in v0.1.6 and the archives in v0.2.0. One primitive, one format, one public key story for both, and no crypto dependency tree to audit. -13. **The tray:** `tray-icon` (Apache-2.0/MIT) on Windows and macOS; `ksni` on Linux, - which is **Unlicense**. Unlicense is a public-domain dedication with no attribution - or copyleft burden — the same class as the CC0-1.0 admitted for `notify` — and it is - admitted here for this crate alone. The alternative, `tray-icon` on Linux, needs GTK3 - and `libappindicator` at build time, which §8 forbids in the default build. `ksni` - reaches the session bus over one local socket, in one file, with no network - capability, and the no-network check is narrowed to assert exactly that. +13. **The tray:** `tray-icon` (MIT OR Apache-2.0) on all three platforms, with its `ksni` + feature so that Linux compiles the StatusNotifierItem backend. That backend is `ksni`, + which is **Unlicense** — a public-domain dedication with no attribution or + copyleft burden, the same class as the CC0-1.0 admitted for `notify`, and admitted + here for this crate alone. The reason for the feature rather than the default is in + the same place: `tray-icon`'s default Linux backend needs GTK3 and `libappindicator` + at build time, which §8 forbids in the default build. `ksni` reaches the session bus + through the pure-Rust `zbus`, so the socket lives in a dependency rather than in this + tree, and no file of ours opens one. 14. **One file per pack:** a pack is JSON plus a detached signature, never an archive. Refusing to extract anything removes zip-slip, permission bits and symlink entries from the attack surface by construction. From b3f2dfd603ea2238fd95ae616ee1c962218be0a9 Mon Sep 17 00:00:00 2001 From: vecnode <229195741+vecnode@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:25:53 +0000 Subject: [PATCH 04/41] Write one autostart entry, and show exactly where it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3's second row. Autostart is the first thing this program puts somewhere the operating system will run, so the design is one entry per machine, named, removable by that name alone, and shown in Settings as the registry value, plist or desktop file a person would go and look at. Three mechanisms, and the least invasive one on each (`docs/PLAN.md` §6.5): a value under `HKCU\...\Run`, never `HKLM`; `~/Library/LaunchAgents`, never `/Library/LaunchDaemons`; a desktop file in `~/.config/autostart`, never a systemd unit. Each carries `--background`, which now starts the window in the status area instead of on top of whatever the person just opened — and only when there is a tray to come back from. The texts live in `autostart/entry.rs` with no IO, so every host tests all three formats: the Windows value is quoted (an unquoted path under `C:\Program Files` is a command somebody else can finish), the plist parses back as a plist with `ProgramArguments` and `RunAtLoad` and deliberately no `KeepAlive` (a resident ranger that restarts whenever it exits is a watchdog), and the desktop file has the four lines a session actually reads. The registry writes sit behind a three-method `Store` trait. That is not ceremony: this machine's sandbox refuses `HKCU` writes from the test process, and the first version of these tests failed on it. The trait lets the behaviour that matters — a second enable is the same entry, removal takes only ranger's name, "already gone" is success, "cannot look" is a state rather than a silent off — be tested on every host against an in-memory store, leaving three lines of `winreg` as the only part a machine has to allow. Tests: 88 core, 88 native, 20 interface, 4 xtask. New: eleven autostart tests, of which four are the registry's behaviour through the double and three assert the three entry formats parse; the macOS and Linux file paths use a temp directory and are exercised by their own platforms in the matrix. --- apps/vn-ranger/src/app.rs | 77 +++++++-- apps/vn-ranger/src/main.rs | 12 +- crates/native/src/autostart/entry.rs | 169 ++++++++++++++++++++ crates/native/src/autostart/linux.rs | 153 ++++++++++++++++++ crates/native/src/autostart/macos.rs | 156 +++++++++++++++++++ crates/native/src/autostart/mod.rs | 148 ++++++++++++++++++ crates/native/src/autostart/windows.rs | 206 +++++++++++++++++++++++++ crates/native/src/lib.rs | 1 + crates/ui/src/lib.rs | 21 +-- crates/ui/src/views.rs | 65 +++++--- crates/ui/src/world.rs | 50 ++++++ docs/PLAN.md | 2 +- 12 files changed, 1013 insertions(+), 47 deletions(-) create mode 100644 crates/native/src/autostart/entry.rs create mode 100644 crates/native/src/autostart/linux.rs create mode 100644 crates/native/src/autostart/macos.rs create mode 100644 crates/native/src/autostart/mod.rs create mode 100644 crates/native/src/autostart/windows.rs diff --git a/apps/vn-ranger/src/app.rs b/apps/vn-ranger/src/app.rs index 00645fb..3e7cad8 100644 --- a/apps/vn-ranger/src/app.rs +++ b/apps/vn-ranger/src/app.rs @@ -21,7 +21,7 @@ pub struct App { instance: Option, cancel: Option>, /// The one writer of `journal.ndjson`. Observations are written as they arrive; - /// an intent or a result is synced (§6.10). + /// an intent or a result is synced (§6.10). journal: vnr_native::journal::Journal, /// Set once the journal has failed, so one broken disk is one message rather than a /// notice per event. @@ -49,12 +49,14 @@ pub struct App { paused: bool, /// Whether this close is the end of the program rather than a trip to the tray. quitting: bool, + /// Whether this launch came from the login entry, and so starts out of the way. + background: bool, } impl App { /// Build the world from the last snapshot and start the first scan if there is /// nothing to show. - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>, background: bool) -> Self { let settings = vnr_native::store::load_ui_settings(); let (instance, instance_error) = match vnr_native::instance::acquire() { Ok(guard) => (Some(guard), None), @@ -94,6 +96,7 @@ impl App { tray: vnr_ui::TrayState::Unknown, paused: false, close_to_tray: settings.close_to_tray.unwrap_or(false), + autostart: autostart_view(vnr_native::autostart::state()), baseline, baseline_error, changes, @@ -129,7 +132,7 @@ impl App { }; if world.error.is_none() { let _ = journal.record(Draft::event("session started").with_reason(&format!( - "vn-ranger {} · journal schema {}", + "vn-ranger {} · journal schema {}", env!("CARGO_PKG_VERSION"), vnr_core::journal::ENTRY_SCHEMA ))); @@ -160,6 +163,7 @@ impl App { tray_started: false, paused: false, quitting: false, + background, }; if app.world.snapshot.is_none() { app.start_scan(&cc.egui_ctx, false); @@ -269,7 +273,7 @@ impl App { /// Walk every allowlisted surface and put what a clean would remove into the world. /// - /// This is the dry run: it reads names, sizes and times and writes nothing — no journal + /// This is the dry run: it reads names, sizes and times and writes nothing — no journal /// line, no removal, no file of its own. The walk's items stay here rather than in the /// world, because the world is what the interface draws and this is the only list the /// action path may remove from. @@ -353,7 +357,6 @@ impl App { } /// Turn a close request into a hide, when that is what was asked for. - /// /// The window is only ever hidden when there is a tray to bring it back, and the journal /// says so at the moment it happens: a person who closed a window and finds a process /// still running deserves to read why in the one file ranger keeps. @@ -430,7 +433,7 @@ impl App { ScanMessage::Done(snapshot) => { let snapshot = *snapshot; self.note(Draft::event("scan").with_reason(&format!( - "{} processes · {} startup items · {} listeners · {} packages", + "{} processes · {} startup items · {} listeners · {} packages", snapshot.processes.len(), snapshot.autoruns.len(), snapshot.listeners.len(), @@ -495,7 +498,7 @@ impl App { worker::WatchMessage::Events(events) => { for event in events { self.note(Draft::event(&event.subject).with_reason(&format!( - "{} · {} · {}", + "{} · {} · {}", event.kind.label(), event.source, event.detail @@ -530,7 +533,7 @@ impl App { EventKind::BaselineAccepted, "This scan", format!( - "{} processes · {} startup items", + "{} processes · {} startup items", snapshot.processes.len(), snapshot.autoruns.len() ), @@ -611,10 +614,9 @@ impl App { )); } for (rule, subject, severity) in raised { - self.note( - Draft::finding(&rule) - .with_reason(&format!("{severity} · {subject} · raised from the rules engine")), - ); + self.note(Draft::finding(&rule).with_reason(&format!( + "{severity} · {subject} · raised from the rules engine" + ))); } } self.world.findings = findings; @@ -626,7 +628,7 @@ impl App { Action::CancelScan => { if let Some(token) = &self.cancel { token.store(true, Ordering::Relaxed); - self.world.notice = Some("Stopping after the current collector…".to_owned()); + self.world.notice = Some("Stopping after the current collector…".to_owned()); } } Action::ScanElevated => self.start_scan(ctx, true), @@ -814,6 +816,32 @@ impl App { self.quitting = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + Action::SetAutostart(enabled) => { + let outcome = if enabled { + vnr_native::autostart::enable().map(|location| { + format!( + "Ranger starts at login from {}. The same click removes it.", + location.at + ) + }) + } else { + vnr_native::autostart::disable().map(|()| { + "Ranger no longer starts at login. Its entry is gone and nothing else \ + was touched." + .to_owned() + }) + }; + match outcome { + Ok(message) => { + self.world.error = None; + self.world.notice = Some(message); + } + Err(error) => self.world.error = Some(error), + } + // Read the entry back rather than trusting the write: what Settings shows + // is what the operating system would run. + self.world.autostart = autostart_view(vnr_native::autostart::state()); + } Action::SetCloseToTray(enabled) => { self.world.close_to_tray = enabled; self.settings.close_to_tray = Some(enabled); @@ -1057,6 +1085,18 @@ impl eframe::App for App { let ctx = ui.ctx().clone(); self.world.now_unix = now_unix(); self.start_tray(&ctx); + if self.background { + // A login launch starts in the status area rather than on top of whatever the + // person opened. Without a tray there is nowhere to be, so the window stays. + self.background = false; + if self.tray.is_some() { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + self.note( + Draft::event("started at login") + .with_reason("the window opened in the status area; the tray icon opens it"), + ); + } + } self.close_or_hide(&ctx); self.drain(); self.drain_watch(&ctx); @@ -1127,6 +1167,17 @@ impl Drop for App { } } +/// The interface's mirror of the entry the operating system would run at login. +fn autostart_view(state: vnr_native::autostart::State) -> vnr_ui::AutostartState { + match state { + vnr_native::autostart::State::On(location) => vnr_ui::AutostartState::On { + at: location.at, + command: location.command, + }, + vnr_native::autostart::State::Off => vnr_ui::AutostartState::Off, + vnr_native::autostart::State::Unavailable(reason) => vnr_ui::AutostartState::Unavailable(reason), + } +} fn now_unix() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/apps/vn-ranger/src/main.rs b/apps/vn-ranger/src/main.rs index f4e081e..6374ca8 100644 --- a/apps/vn-ranger/src/main.rs +++ b/apps/vn-ranger/src/main.rs @@ -1,4 +1,4 @@ -//! vn-ranger — a quiet, local-only system ranger. +//! vn-ranger — a quiet, local-only system ranger. //! //! The binary opens one window and owns the world the interface draws. It also has a //! headless mode, used twice: by the person from a terminal, and by the short-lived @@ -35,7 +35,7 @@ fn main() -> eframe::Result<()> { // The elevated copy is one operation: it scans and writes one snapshot. It does not // touch the journal, because a privileged process writing to the user's data - // directory is exactly the kind of standing privilege §6.9 refuses. + // directory is exactly the kind of standing privilege §6.9 refuses. if let Some(path) = arg_value("--elevated-scan") { write_headless_report(&path, false); return Ok(()); @@ -45,6 +45,10 @@ fn main() -> eframe::Result<()> { return Ok(()); } + // A login launch carries `--background`: the window belongs in the status area rather + // than on top of whatever the person just opened. + let background = std::env::args_os().any(|argument| argument.to_string_lossy() == "--background"); + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title(format!("vn-ranger {}", env!("CARGO_PKG_VERSION"))) @@ -62,7 +66,7 @@ fn main() -> eframe::Result<()> { eframe::run_native( "vn-ranger", options, - Box::new(|cc| Ok(Box::new(app::App::new(cc)))), + Box::new(move |cc| Ok(Box::new(app::App::new(cc, background)))), ) } @@ -86,7 +90,7 @@ fn write_headless_report(path: &Path, record: bool) { } }; let draft = vnr_core::Draft::event("scan").with_reason(&format!( - "headless report to {} · {} processes · {} startup items · {} listeners · {} packages", + "headless report to {} · {} processes · {} startup items · {} listeners · {} packages", path.display(), snapshot.processes.len(), snapshot.autoruns.len(), diff --git a/crates/native/src/autostart/entry.rs b/crates/native/src/autostart/entry.rs new file mode 100644 index 0000000..3a74eed --- /dev/null +++ b/crates/native/src/autostart/entry.rs @@ -0,0 +1,169 @@ +//! The text of each autostart entry, built without touching the machine. +//! +//! Three formats, three rules, and one property they share: they are strings a person can +//! read before they are strings a system will run. Building them here, with no IO, is what +//! lets every host test all three — a Windows machine checks the macOS plist and the Linux +//! desktop file, so a quoting mistake is caught by the suite rather than by the person +//! whose login breaks. + +use std::path::Path; + +/// The value Windows stores under `HKCU\...\Run`. +/// +/// A quoted path, then the flag. Quoted because a program under `C:\Program Files` has a +/// space in it, and an unquoted command line is a command somebody else can complete. +#[must_use] +pub(super) fn run_value(exe: &Path) -> String { + format!("\"{}\" --background", exe.display()) +} + +/// The `LaunchAgent` plist, as XML. +/// +/// `ProgramArguments` rather than a `Program` string: an array has no quoting rules to get +/// wrong, and `RunAtLoad` is the whole mechanism. `KeepAlive` is deliberately absent — a +/// resident ranger that is restarted every time it exits is a watchdog, and the plan says +/// there is no watchdog. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[must_use] +pub(super) fn launch_agent(label: &str, exe: &Path) -> String { + format!( + concat!( + "\n", + "\n", + "\n", + "\n", + " Label\n", + " {label}\n", + " ProgramArguments\n", + " \n", + " {exe}\n", + " --background\n", + " \n", + " RunAtLoad\n", + " \n", + " ProcessType\n", + " Interactive\n", + "\n", + "\n" + ), + label = xml_escape(label), + exe = xml_escape(&exe.display().to_string()), + ) +} + +/// One `[Desktop Entry]`, the freedesktop way. +/// +/// `Exec` needs its own quoting: a path with a space must be quoted *inside* the value, +/// and a desktop-file parser is not a shell. `X-GNOME-Autostart-enabled` is what the GNOME +/// session actually reads; the others ignore it, which is why it is not the only line. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[must_use] +pub(super) fn desktop_entry(exe: &Path, name: &str) -> String { + format!( + concat!( + "[Desktop Entry]\n", + "Type=Application\n", + "Name={name}\n", + "Comment=A quiet ranger for your machine\n", + "Exec=\"{exe}\" --background\n", + "Terminal=false\n", + "X-GNOME-Autostart-enabled=true\n" + ), + name = name, + exe = exe.display(), + ) +} + +/// Escape the five characters XML reserves. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for character in text.chars() { + match character { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exe() -> std::path::PathBuf { + std::path::PathBuf::from("/opt/vn ranger/vn-ranger") + } + + #[test] + fn the_windows_value_quotes_the_path_and_asks_for_the_background() { + let value = run_value(&exe()); + assert_eq!(value, "\"/opt/vn ranger/vn-ranger\" --background"); + assert!( + value.starts_with('"') && value.contains("\" --background"), + "an unquoted path with a space is a command somebody else can finish" + ); + } + + #[test] + fn the_launch_agent_parses_as_a_plist_without_a_watchdog() { + let text = launch_agent("dev.vecnode.ranger", &exe()); + let value: plist::Value = plist::from_bytes(text.as_bytes()).unwrap(); + let dict = value.as_dictionary().unwrap(); + assert_eq!( + dict.get("Label").and_then(plist::Value::as_string), + Some("dev.vecnode.ranger") + ); + assert_eq!( + dict.get("RunAtLoad").and_then(plist::Value::as_boolean), + Some(true) + ); + assert!( + dict.get("KeepAlive").is_none(), + "restarting ranger whenever it exits would be a watchdog" + ); + let arguments = dict + .get("ProgramArguments") + .and_then(plist::Value::as_array) + .expect("an array of arguments"); + assert_eq!( + arguments + .iter() + .filter_map(plist::Value::as_string) + .collect::>(), + vec!["/opt/vn ranger/vn-ranger", "--background"] + ); + } + + #[test] + fn a_label_or_a_path_cannot_close_the_xml_early() { + let text = launch_agent("a