crates/veilvoice-gui/src/setup.rs
what this file is for · veilvoice-gui · 1038 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! The setup tab: install this copy, undo that, and the optional companions.
//!
//! This is the graphical front end to [`veilvoice_setup`], and it is a *front
//! end* in the strict sense: it holds no installation logic of its own. Every
//! change to the machine goes through the same functions `veilvoice install`
//! calls, so the careful part (editing `PATH`, which is the one operation here
//! that can damage a machine) has one implementation and one set of tests.
//!
//! # Portable is the default, and this tab says so first
//!
//! The screen opens by telling the user what they already have: a portable
//! copy that needs nothing, or an installed one. Installing is offered as a
//! convenience with an exact list of what it will change, not as a step
//! somebody has to complete before the program is usable. A privacy tool that
//! implies it must be installed has trained its user badly.
//!
//! # Nothing is ticked, because there is nothing to tick
//!
//! The companions are not a checklist with defaults. Each is a row that states
//! what the software is, who wrote it and under what licence, whether it was
//! found on this machine, and a single button that does one thing to one named
//! program. There is no "install recommended extras", because that is the
//! control through which unwanted software historically arrived.
//!
//! Where the answer is a proprietary driver the button opens the vendor's page
//! and nothing else; where it needs root the command is shown and not run.
//! Both of those refusals live in [`veilvoice_setup::companions`], not here, so
//! that a second front end cannot be more permissive than this one.
//!
//! # Nothing slow runs on the UI thread
//!
//! Copying binaries, editing the registry and running a package manager all
//! take real time, because a package manager can take minutes. Each runs on a worker
//! and reports back through an [`std::sync::mpsc`] channel, exactly as the file
//! job in [`crate::VeilVoiceApp`] does, so the window keeps painting and the
//! progress strip keeps moving.
//!
//! The strip honours the reduced-motion decision like everything else: with
//! motion off it is a static bar and a word, not a frozen animation.
//!
//! # In plain words
//!
//! The tab that installs this copy, removes it again, and points at the optional
//! extra software.
//!
//! It is only a front end: every decision about where files go and what gets
//! changed lives in one place shared with the command line, so the two cannot
//! disagree about what "installed" means.
//!
//! Nothing here installs anything belonging to somebody else without being asked.
//! What each thing is, who makes it and what it is for are shown first, and the
//! exact command is shown before the question.
use crate::theme::palette as p;
use egui::{RichText, Ui};
use std::sync::mpsc;
use veilvoice_setup::companions::{self, Companion, Offer, Presence};
use veilvoice_setup::install;
/// What a worker finished doing: lines to show, and whether it went well.
struct Done {
lines: Vec<String>,
good: bool,
}
/// One companion as this tab needs it: the facts, plus what was found.
struct Row {
companion: &'static Companion,
presence: Presence,
offer: Offer,
}
/// The setup tab's state.
pub struct Setup {
status: install::Status,
rows: Vec<Row>,
job: Option<mpsc::Receiver<Done>>,
/// A probe of the companions, running off the paint thread.
///
/// `detect_all` runs a command per companion: `where` or `which`, and on
/// Windows a PowerShell call that enumerates sound devices. On the paint
/// thread that is hundreds of milliseconds inside one frame, which is the
/// window going white and the pointer becoming a spinner. It was doing
/// exactly that every time somebody pressed "look again".
probe: Option<mpsc::Receiver<Vec<Row>>>,
/// What is running, in words, for the progress strip.
busy: Option<String>,
/// The last report, and whether it was a success.
report: Option<(Vec<String>, bool)>,
/// The uninstall confirmation, which is deliberately a second click.
confirming_uninstall: bool,
/// Whether an install should carry a portable copy's settings across, once
/// the question has been answered.
///
/// `None` until it is, and the install button waits for it: guessing here
/// either copies somebody's vault onto a machine they do not own, or leaves
/// somebody who moved off a stick wondering where their recordings went.
carry: Option<bool>,
}
impl Default for Setup {
fn default() -> Self {
Self::new()
}
}
impl Setup {
/// Read the machine's current state. Changes nothing.
pub fn new() -> Self {
Self {
status: install::status(),
rows: detect_all(),
job: None,
probe: None,
busy: None,
carry: None,
report: None,
confirming_uninstall: false,
}
}
/// Whether this copy is the installed one.
///
/// Read once, when the panel was built. That is deliberate: the tab row is
/// drawn every frame and this answer involves the filesystem, so asking per
/// frame would put a `stat` in the paint path -- which is exactly the shape
/// of the defect that made the window freeze every couple of seconds.
pub fn running_installed(&self) -> bool {
self.status.running_installed
}
/// True while a worker is running, so the app can keep repainting.
pub fn is_busy(&self) -> bool {
self.job.is_some() || self.probe.is_some()
}
/// Drain the worker channel. Called once per frame.
///
/// Handles `Disconnected` as well as a message: a worker that panicked
/// must leave the interface saying so rather than spinning for ever.
pub fn poll(&mut self) {
// The probe first, and separately: it can finish while an install is
// still running, and one `return` for both would leave its answer in
// the channel until the install ended.
if let Some(rx) = &self.probe {
match rx.try_recv() {
Ok(rows) => {
self.rows = rows;
self.probe = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
// A probe that died leaves what was already on screen,
// which is the last true answer, rather than an empty list
// that would read as "you have none of these".
self.probe = None;
}
}
}
let Some(rx) = &self.job else { return };
match rx.try_recv() {
Ok(Done { lines, good }) => {
self.report = Some((lines, good));
self.job = None;
self.busy = None;
// Anything a worker did could have changed both of these.
self.status = install::status();
self.rows = detect_all();
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.report = Some((
vec![
"the worker stopped without reporting anything. Nothing here \
can say how far it got; check the state above before trying \
again."
.to_string(),
],
false,
));
self.job = None;
self.busy = None;
self.status = install::status();
}
}
}
/// Draw the tab.
pub fn tab(&mut self, ui: &mut Ui, motion: crate::prefs::Motion) {
self.poll();
// The scroll area fills the tab rather than shrinking to its content,
// and the inner width is pinned to the tab's. This screen is mostly
// long sentences stating limits, and a limit that wraps off the edge
// of the window is a limit nobody read.
// The application scrolls every tab in one place now; a second
// scroller here would trap the wheel in whichever the pointer was over.
// The width pin stays, because it is what stops the long sentences
// wrapping off the edge.
let width = ui.available_width();
ui.set_max_width(width);
self.where_this_copy_lives(ui);
ui.add_space(14.0);
self.install_controls(ui, motion);
ui.add_space(18.0);
ui.separator();
ui.add_space(12.0);
self.companion_rows(ui);
}
/// Why this tab is here, and how to make it not be.
///
/// Under the header rather than buried in settings, because the question
/// "why is this program asking to install itself" is asked *here*, while
/// looking at the tab. The control that answers it lives in settings --
/// a tab that could hide itself and nothing else could bring it back would
/// be a one-way door.
fn visibility_note(&self, ui: &mut Ui) {
ui.label(
RichText::new(
"This tab is only here because you are running a portable copy. Once \
VeilVoice is installed it disappears by itself: a program offering to \
install itself when it already is tells you something untrue about \
what you are running. To hide it on a portable copy too, there is a \
tick under settings -> interface.",
)
.small()
.color(p::muted()),
);
ui.add_space(10.0);
}
// --- the state of this copy --------------------------------------------
/// Say whether this copy is portable or installed, and where its files
/// are.
fn where_this_copy_lives(&mut self, ui: &mut Ui) {
ui.label(RichText::new("This copy").color(p::blue()).small());
ui.add_space(6.0);
// Portable first, and stated as a working arrangement rather than as
// something missing. It is how most people will run this.
self.visibility_note(ui);
let (headline, colour) = match (self.status.installed, self.status.running_installed) {
(_, true) => ("you are running the installed copy", p::green()),
(true, false) => (
"you are running a portable copy, and an installed one also exists",
p::yellow(),
),
(false, false) => (
"you are running a portable copy. Nothing is installed, and nothing needs to be",
p::fg(),
),
};
ui.label(RichText::new(headline).color(colour));
ui.add_space(8.0);
field(
ui,
"running from",
&self
.status
.running_from
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "unknown".into()),
);
field(
ui,
"install goes to",
&self
.status
.prefix
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "not resolvable on this system".into()),
);
field(
ui,
"on PATH",
if self.status.on_path {
"yes, for this session"
} else {
"no"
},
);
// The two facts above are different, and saying which one "on PATH"
// answered stops somebody concluding a new terminal will behave the
// same way this one does.
ui.label(
RichText::new(
"\"on PATH\" is read from this running process. A terminal opened \
after an install is a different question, and the answer there is \
what an install changes.",
)
.small()
.color(p::muted()),
);
}
// --- install and uninstall ---------------------------------------------
/// The install and uninstall buttons, and what each will do before it is
/// pressed.
fn install_controls(&mut self, ui: &mut Ui, motion: crate::prefs::Motion) {
ui.label(
RichText::new("Install for this user")
.color(p::blue())
.small(),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Optional. It exists so that typing `veilvoice` in a terminal works. \
No administrator rights are asked for and nothing is written outside \
your own account.",
)
.color(p::fg()),
);
ui.add_space(8.0);
// Exactly what will change, before the button rather than after it.
for line in install_changes() {
ui.label(
RichText::new(format!(" · {line}"))
.small()
.color(p::muted()),
);
}
ui.add_space(10.0);
if self.job.is_some() {
self.progress(ui, motion);
return;
}
// Only where there is something to carry: a copy keeping its state
// beside itself. An ordinary copy already keeps it where an installed
// one looks, so there is no question to ask and none is asked.
let portable = veilvoice_crypto::lock::is_portable();
if portable {
self.carry_question(ui);
}
ui.horizontal(|ui| {
let can_install = can_install(
self.status.prefix.is_some(),
self.status.running_installed,
portable,
self.carry,
);
let install_button = ui.add_enabled(
can_install,
egui::Button::new(RichText::new("install").color(p::green())),
);
if install_button.clicked() {
self.confirming_uninstall = false;
let carry = self.carry == Some(true);
self.start("installing", move || {
let mut said = install::install()?;
if carry {
// Reported inside the same result, so one press gives
// one account of what happened rather than two.
said.extend(crate::paths::carry_over()?);
}
Ok(said)
});
}
if self.status.running_installed {
install_button
.on_hover_text("this program is already running from the install directory");
}
if self.status.installed {
if self.confirming_uninstall {
if ui
.button(RichText::new("yes, remove it").color(p::red()))
.clicked()
{
self.confirming_uninstall = false;
self.start("removing", install::uninstall);
}
if ui
.button(RichText::new("cancel").color(p::muted()))
.clicked()
{
self.confirming_uninstall = false;
}
} else if ui
.button(RichText::new("uninstall").color(p::yellow()))
.clicked()
{
self.confirming_uninstall = true;
}
}
});
if self.confirming_uninstall {
ui.add_space(6.0);
ui.label(
RichText::new(
"This removes the installed copy, its PATH entry and its uninstall \
entry. Your recordings, keys and settings are somewhere else and \
are not touched.",
)
.color(p::yellow()),
);
}
if let Some((lines, good)) = &self.report {
ui.add_space(12.0);
let colour = if *good { p::green() } else { p::red() };
for line in lines {
ui.label(RichText::new(line).color(colour));
}
if *good {
ui.add_space(6.0);
ui.label(
RichText::new(
"Open a new terminal for a PATH change to take effect. VeilVoice \
never checks for updates and cannot tell you when one exists. \
it has no network code at all.",
)
.small()
.color(p::muted()),
);
}
}
}
/// Ask whether an install should take the settings with it.
///
/// A question rather than a default, because the two right answers point in
/// opposite directions and nothing here can tell which one applies.
/// Somebody moving off a memory stick onto their own machine wants their
/// settings and vaults carried over. Somebody installing on a shared or
/// borrowed machine wants them left exactly where they are, and guessing
/// wrong there copies their vault onto a computer that is not theirs.
fn carry_question(&mut self, ui: &mut Ui) {
ui.label(
RichText::new("This copy keeps its settings beside itself")
.color(p::yellow())
.small(),
);
ui.label(
RichText::new(
"An installed copy looks in this platform's own configuration \
directory instead. Say what should happen to what is beside \
the program before installing.",
)
.color(p::fg()),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.selectable_value(&mut self.carry, Some(true), "carry them across");
ui.selectable_value(&mut self.carry, Some(false), "leave them here");
});
ui.label(
RichText::new(match self.carry {
Some(true) => {
"Copied, never moved and never overwritten: anything already \
in the configuration directory is left alone and named in \
the report, and this copy keeps working from the folder \
beside it."
}
Some(false) => {
"Nothing is copied. The installed copy starts with no \
settings, and this one carries on using the folder beside \
it."
}
None => {
"Unanswered, so the install button is waiting. There is no \
sensible default: one answer is right when moving onto your \
own machine and the other when installing on somebody \
else's."
}
})
.color(p::muted())
.small(),
);
ui.add_space(10.0);
}
/// Start a worker, and remember what it is doing so the strip can say.
fn start<F>(&mut self, what: &str, work: F)
where
F: FnOnce() -> Result<Vec<String>, String> + Send + 'static,
{
let (tx, rx) = mpsc::channel();
self.report = None;
self.busy = Some(what.to_string());
self.job = Some(rx);
std::thread::spawn(move || {
let done = match work() {
Ok(lines) => Done { lines, good: true },
Err(error) => Done {
lines: vec![error],
good: false,
},
};
// The receiver is gone only if the window closed mid-job, which is
// not an error worth reporting to a window that is not there.
let _ = tx.send(done);
});
}
/// The progress strip: a travelling highlight, or a plain bar when motion
/// is off.
fn progress(&self, ui: &mut Ui, motion: crate::prefs::Motion) {
let label = self.busy.clone().unwrap_or_else(|| "working".to_string());
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{label}…")).color(p::cyan()));
});
ui.add_space(4.0);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().min(420.0), 8.0),
egui::Sense::hover(),
);
let painter = ui.painter();
painter.rect_filled(rect, 3.0, p::bg_dark());
painter.rect_stroke(
rect,
3.0,
egui::Stroke::new(1.0, p::border()),
egui::StrokeKind::Inside,
);
if motion.enabled {
// A quarter-width highlight travelling left to right, so the
// window is visibly alive without claiming to know a percentage.
// It does not: `reg.exe` and a package manager report no progress,
// and a bar that fills to 90% and waits is a lie with a shape.
let time = ui.input(|i| i.time) as f32;
let width = rect.width() * 0.25;
let travel = rect.width() + width;
let position = (time * 0.45).fract() * travel - width;
let mut lit = rect;
lit.min.x = rect.min.x + position.max(0.0);
lit.max.x = (rect.min.x + position + width).min(rect.max.x);
if lit.max.x > lit.min.x {
painter.rect_filled(lit, 3.0, p::blue());
}
} else {
// Still, but not empty: an empty bar reads as "stuck".
let mut lit = rect;
lit.set_width(rect.width() * 0.35);
painter.rect_filled(lit, 3.0, p::blend(p::blue(), p::bg_dark(), 0.45));
}
}
// --- the companions ----------------------------------------------------
/// One row per companion: whether it is here, and how to get it if not.
fn companion_rows(&mut self, ui: &mut Ui) {
ui.label(RichText::new("Companion software").color(p::blue()).small());
ui.add_space(6.0);
ui.label(
RichText::new(
"None of this is part of VeilVoice and none of it is required. Nothing \
here is pre-selected: each is one button, for one named program, and \
VeilVoice never runs somebody else's installer.",
)
.color(p::fg()),
);
ui.add_space(4.0);
if self.probe.is_some() {
// Said rather than left blank. The button that has just been
// pressed going quiet for half a second is what reads as a freeze,
// and the fix is as much this line as it is the worker.
ui.horizontal(|ui| {
ui.spinner();
ui.label(
RichText::new("looking for each of these on this machine")
.color(p::muted())
.small(),
);
});
} else if ui
.button(RichText::new("look again").color(p::muted()).small())
.clicked()
{
let (tx, rx) = mpsc::channel();
// Detached: nothing waits for it, and a probe that outlives the
// tab simply sends into a channel nobody reads.
std::thread::spawn(move || {
let _ = tx.send(detect_all());
});
self.probe = Some(rx);
}
ui.add_space(10.0);
let mut action: Option<(String, Offer)> = None;
let busy = self.job.is_some();
for row in &self.rows {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(row.companion.name).color(p::fg()).strong());
ui.label(
RichText::new(format!("· {}", row.companion.vendor))
.small()
.color(p::muted()),
);
ui.label(
RichText::new(format!("· {}", row.companion.licence))
.small()
.color(p::yellow()),
);
});
ui.label(RichText::new(row.companion.what).small().color(p::fg()));
ui.label(RichText::new(row.companion.why).small().color(p::muted()));
ui.add_space(4.0);
let (text, colour) = match &row.presence {
Presence::Present(_) => (row.presence.describe(), p::green()),
Presence::NotDetected => (
format!(
"{}. That is where VeilVoice looked, not a claim about your machine",
row.presence.describe()
),
p::muted(),
),
Presence::Unknown(_) => (row.presence.describe(), p::yellow()),
};
ui.label(RichText::new(text).small().color(colour));
if row.presence.is_present() {
return;
}
ui.add_space(6.0);
match &row.offer {
Offer::Page(url) => {
ui.label(
RichText::new(
"VeilVoice will not install this for you: it is not free \
software and it is a driver. This opens their page, where \
their licence is yours to accept and their installer is \
yours to run.",
)
.small()
.color(p::muted()),
);
if ui
.add_enabled(
!busy,
egui::Button::new(
RichText::new("open their page").color(p::blue()),
),
)
.clicked()
{
action = Some((row.companion.name.to_string(), row.offer.clone()));
}
ui.label(RichText::new(*url).small().color(p::muted()));
}
Offer::Command {
via,
needs_privilege,
..
} => {
let line = row.offer.command_line().unwrap_or_default();
ui.label(
RichText::new(format!("via {via}: {line}"))
.small()
.color(p::cyan()),
);
if *needs_privilege {
ui.label(
RichText::new(
"This needs root. VeilVoice does not ask for a password \
and will not run it. Run that command in a terminal, \
where you can see what you are approving.",
)
.small()
.color(p::yellow()),
);
} else if ui
.add_enabled(
!busy,
egui::Button::new(
RichText::new(format!("install {}", row.companion.name))
.color(p::green()),
),
)
.clicked()
{
action = Some((row.companion.name.to_string(), row.offer.clone()));
}
}
Offer::PartOfTheSystem(explanation) => {
ui.label(RichText::new(*explanation).small().color(p::muted()));
}
Offer::NoKnownRoute(reason) => {
ui.label(RichText::new(reason).small().color(p::yellow()));
}
// Filtered out by `for_this_platform`, so unreachable in
// practice; drawn as nothing rather than as an empty group.
Offer::NotOnThisPlatform => {}
}
});
ui.add_space(8.0);
}
if let Some((name, offer)) = action {
match offer {
Offer::Page(url) => match companions::open_page(url) {
Ok(()) => self.report = Some((vec![format!("opened {url}")], true)),
Err(error) => self.report = Some((vec![error], false)),
},
offer => {
self.start(&format!("installing {name}"), move || {
companions::run(&offer).map(|output| {
let mut lines: Vec<String> = output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.to_string())
.collect();
lines.push("done".to_string());
lines
})
});
}
}
}
}
}
/// Probe for every companion that applies to this platform.
fn detect_all() -> Vec<Row> {
companions::for_this_platform()
.into_iter()
.map(|companion| Row {
companion,
presence: companion.detect(),
offer: companion.offer(),
})
.collect()
}
/// Exactly what an install changes, in the order it changes it.
///
/// Written out beside the button rather than in a manual. An installer that
/// says "install?" and nothing else is asking somebody to consent to something
/// they have not been told.
/// Whether the install button is live.
///
/// A function rather than four conditions in a closure, so the rule that
/// matters can be asserted: **a portable copy cannot be installed until the
/// question about its settings has been answered**. An install that ran on the
/// first click would have taken one of the two answers by default, and the
/// wrong one either copies a vault onto somebody else's machine or leaves a
/// person wondering where their recordings went.
fn can_install(
somewhere_to_install: bool,
already_running_installed: bool,
portable: bool,
carry: Option<bool>,
) -> bool {
somewhere_to_install && !already_running_installed && (!portable || carry.is_some())
}
/// Everything installing alters, listed so the button is never a surprise.
fn install_changes() -> Vec<&'static str> {
let mut lines = vec![
"copies the VeilVoice programs beside this one into your own program directory",
"adds that directory to your PATH, appending to the value it first reads",
];
if cfg!(windows) {
lines.push("adds an entry to Apps & features, so Windows can list and remove it");
}
lines.push("nothing else: no service, no system directory, no administrator rights");
lines
}
/// One labelled read-only value, in the shape this tab uses throughout.
fn field(ui: &mut Ui, label: &str, value: &str) {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{label:<18}")).color(p::muted()));
ui.label(RichText::new(value).color(p::cyan()));
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The tab reads the machine on construction and must not change it.
#[test]
fn a_portable_copy_is_not_installed_until_the_question_is_answered() {
// The one thing this gate exists for. Unanswered means the button is
// dead, and either answer makes it live: both are real choices and
// neither is a default.
assert!(!can_install(true, false, true, None), "it installed anyway");
assert!(can_install(true, false, true, Some(true)));
assert!(can_install(true, false, true, Some(false)));
// A copy that is not portable has nothing to carry, so it is never
// asked and never waits.
assert!(can_install(true, false, false, None));
}
#[test]
fn nothing_installs_where_there_is_nowhere_to_install_it() {
assert!(!can_install(false, false, false, None));
assert!(!can_install(false, false, true, Some(true)));
assert!(
!can_install(true, true, false, None),
"the installed copy offered to install itself"
);
}
#[test]
fn opening_the_tab_changes_nothing() {
let first = Setup::new();
let second = Setup::new();
assert_eq!(first.status.installed, second.status.installed);
assert_eq!(first.status.on_path, second.status.on_path);
assert_eq!(first.status.prefix, second.status.prefix);
assert_eq!(first.rows.len(), second.rows.len());
assert!(first.job.is_none());
assert!(first.report.is_none());
assert!(
!first.confirming_uninstall,
"the uninstall confirmation must start closed"
);
}
/// Drive the tab once with no window, in both motion states.
///
/// The progress strip does arithmetic on a rectangle, and a rectangle in a
/// headless context can be zero-width. A panic there would only ever be
/// seen on somebody's desktop, mid-install.
fn render(setup: &mut Setup, motion: crate::prefs::Motion) {
let ctx = egui::Context::default();
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| setup.tab(ui, motion));
});
}
fn motion(enabled: bool) -> crate::prefs::Motion {
crate::prefs::Motion {
enabled,
icon: enabled,
system_reduced: !enabled,
}
}
#[test]
fn the_tab_renders_without_a_window() {
let mut setup = Setup::new();
render(&mut setup, motion(true));
render(&mut setup, motion(false));
// Drawing must not have started anything.
assert!(setup.job.is_none(), "drawing the tab started a worker");
}
/// The uninstall confirmation is drawn, and drawing it must not act.
#[test]
fn the_uninstall_confirmation_renders_and_does_nothing_by_itself() {
let mut setup = Setup::new();
setup.confirming_uninstall = true;
render(&mut setup, motion(true));
assert!(setup.job.is_none());
assert!(setup.report.is_none());
}
/// A report from a worker that vanished must say so rather than leaving
/// the strip running for ever.
#[test]
fn a_worker_that_disappears_is_reported() {
let mut setup = Setup::new();
let (tx, rx) = mpsc::channel::<Done>();
setup.job = Some(rx);
setup.busy = Some("installing".to_string());
drop(tx);
setup.poll();
assert!(setup.job.is_none(), "the job must be cleared");
assert!(!setup.is_busy());
let (lines, good) = setup.report.expect("a disappearance must be reported");
assert!(!good);
assert!(lines[0].contains("stopped without reporting"), "{lines:?}");
}
/// Every companion shown is one that applies here, with a probe result.
#[test]
fn every_row_has_been_probed() {
for row in detect_all() {
assert!(!matches!(row.offer, Offer::NotOnThisPlatform));
if let Presence::Unknown(reason) = &row.presence {
assert!(!reason.contains("no probe is written"), "{reason}");
}
}
}
/// The list of changes is what the user consents to, so it must name the
/// three things `veilvoice_setup::install` actually does, and must not
/// stop mentioning that it asks for no administrator rights.
#[test]
fn the_consent_text_names_what_install_changes() {
let lines = install_changes();
let joined = lines.join(" ");
assert!(joined.contains("PATH"), "{joined}");
assert!(joined.contains("copies"), "{joined}");
assert!(
joined.contains("no administrator rights"),
"the per-user promise must be stated beside the button: {joined}"
);
if cfg!(windows) {
assert!(joined.contains("Apps & features"), "{joined}");
}
}
/// A front end must not offer a button for anything the library refuses to
/// run. This is the same rule as `companions::run`, checked from the side
/// that draws the button.
#[test]
fn no_button_is_drawn_for_a_command_that_cannot_be_run() {
for row in detect_all() {
if let Offer::Command {
needs_privilege, ..
} = row.offer
{
assert_eq!(
!needs_privilege,
row.offer.is_runnable(),
"{} disagrees with the library about whether it is runnable",
row.companion.key
);
}
}
}
}
#[cfg(test)]
mod companion_tests {
/// The probe runs off the paint thread.
///
/// `detect_all` runs a command per companion, and on Windows one of them
/// enumerates sound devices through PowerShell. On the paint thread that is
/// hundreds of milliseconds inside a single frame, which is the window
/// going white and the pointer becoming a spinner. Pressing "look again"
/// did exactly that.
#[test]
fn looking_again_does_not_probe_on_the_paint_thread() {
let source = std::fs::read_to_string("src/setup.rs").expect("its own source");
let at = source
.find("fn companion_rows")
.expect("the companion rows");
let end = source[at..]
.find("\n fn ")
.map(|offset| at + offset)
.unwrap_or(source.len());
let body = &source[at..end];
assert!(
body.contains("std::thread::spawn"),
"the probe is started on the thread that is painting"
);
assert!(
!body.contains("self.rows = detect_all()"),
"the rows are still being filled in by probing inside the frame"
);
assert!(
body.contains("ui.spinner()"),
"nothing on screen says the probe is running, so the button reads as dead"
);
}
/// Every message that names a missing ffmpeg says where to get it.
///
/// Naming a thing somebody cannot act on from where they are standing is
/// the failure this guards: the render said "ffmpeg is not on this machine"
/// and the tab that installs software had never heard of it.
#[test]
fn a_missing_ffmpeg_always_says_where_it_can_be_installed() {
for file in ["src/studio.rs", "src/group.rs"] {
let source = std::fs::read_to_string(file).expect("a source file");
for (at, _) in source.match_indices("`ffmpeg` is not on") {
// The sentence and what follows it, which is where the offer
// has to be. Bounded rather than to the end of the file, so a
// mention of Setup somewhere else entirely cannot satisfy this.
let window = &source[at..(at + 600).min(source.len())];
assert!(
window.contains("Setup tab"),
"{file} says ffmpeg is missing without saying where to get it"
);
}
}
}
}
#[cfg(test)]
mod typeface_tests {
/// The capture script refuses to photograph in the wrong face.
///
/// The window prefers JetBrains Mono and falls back to the built-in
/// monospace. That fallback is right for somebody running the program and
/// wrong for a screenshot: a capture in the wrong face looks subtly unlike
/// every other picture in the set and nothing about the run says so, so
/// half a set could be published before anybody noticed.
#[test]
fn the_capture_script_will_not_photograph_in_the_fallback_face() {
let script =
std::fs::read_to_string("../../tools/shots/gui.sh").expect("the Linux capture script");
assert!(
script.contains("--typeface"),
"the script does not ask which face the window would use"
);
assert!(
script.contains("JetBrains Mono"),
"the script does not check for the face it wants"
);
assert!(
script.contains("exit 1"),
"the script does not refuse, so it would capture in the fallback"
);
}
/// The flag exists and is documented in the usage text.
///
/// A flag the capture script depends on and the help does not mention is
/// one somebody removes as unused.
#[test]
fn the_typeface_flag_is_documented_where_it_is_answered() {
let main = std::fs::read_to_string("src/main.rs").expect("the binary's source");
assert!(
main.contains("--typeface"),
"the flag is not answered in main.rs"
);
assert!(
main.contains("--typeface Say which face"),
"the flag is answered and not documented in the usage text"
);
}
}