crates/veilvoice-gui/src/app.rs
what this file is for · veilvoice-gui · 3584 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 VeilVoice desktop application: seven tabs, one window, no menus.
//!
//! One window, seven tabs, no menus and no settings file to hunt for. This file
//! owns the window: the tab strip, the state behind it, and the rules about
//! what the user is allowed to do before they have answered the questions that
//! matter. The tabs themselves live partly here and partly in siblings --
//! [`crate::security`] draws the lock tab and the unlock screen,
//! [`crate::prefs`] draws settings.
//!
//! # The tabs, and why these
//!
//! One row, in the order [`Tab::ALL`] lists them, which is the order they are
//! drawn in and the order this table is in.
//!
//! | Tab | What it is |
//! |---|---|
//! | **anonymise file** | Process a recording on disk. The default path. |
//! | **group** | One recording with several people in it, each given their own voice. |
//! | **studio** | Scramble a microphone in real time, and keep what was said if it was asked for. |
//! | **browser** | What is in the recording vault, without opening any of it. |
//! | **monitor** | Which applications currently hold the microphone and camera. |
//! | **lock** | The app lock, and a plain statement of what it is worth. |
//! | **verify** | Check a download against the signed list of hashes. |
//! | **settings** | Colour scheme, animation, and where those choices are kept. |
//! | **install** | Whether this copy is portable or installed, and the optional companions. |
//! | **about** | Versions, licence, and the honest scope. |
//!
//! **Roadmap item 130** took one row out of this table rather than adding one. Live
//! scramble was a tab, and everything it did the Studio also did, through the
//! same session, with the devices the other tab happened to be set to. Two
//! screens for one act, and two starters for one microphone.
//!
//! There is no "advanced" tab and no hidden pane. Everything the program can
//! do is reachable in one click from the strip, because a privacy tool whose
//! important controls are buried is a privacy tool whose important controls do
//! not get used.
//!
//! # Nothing slow runs on the UI thread
//!
//! [`VeilVoiceApp::start_job`] spawns a worker and hands back an
//! [`std::sync::mpsc`] receiver; [`VeilVoiceApp::poll_job`] drains it with
//! `try_recv` once per frame. The window keeps painting while a job runs.
//!
//! That split is not tidiness. A long recording takes real time to process,
//! and sealing it runs Argon2id at 256 MiB, which is **deliberately** slow --
//! that is the whole point of a memory-hard KDF. Doing either on the UI thread
//! means a frozen window and an operating system offering to kill the
//! application, in the middle of the operation the user cares most about
//! completing.
//!
//! `poll_job` handles all three channel outcomes, including
//! `Disconnected` -- a worker that panicked. The user is told the thread
//! stopped rather than watching a progress state that will never finish.
//!
//! # The at-rest choice is enforced here, not merely offered
//!
//! Recordings are encrypted at rest by default (locked decision 4.10), and a
//! job **cannot start** until the user has answered the modal that appears if
//! they try to turn that off. The rule is asserted by a test in this file
//! rather than left as a property of the layout code, because "the button was
//! disabled" is a claim about pixels and "the job refuses to start" is a claim
//! about behaviour.
//!
//! The worker encodes the WAV **in memory** and seals it before anything is
//! written, so a recording that is going to be encrypted never touches the disk
//! in the clear -- not even briefly, not even in a temporary file that would
//! be deleted afterwards. Deleting a file does not remove its contents from a
//! flash device; not writing it does.
//!
//! # Nothing that talks to the operating system runs on this thread
//!
//! The device monitor is the one that got this wrong and shipped. It was polled
//! straight from `update`, and asking Windows which applications hold the
//! microphone cost about a hundred and ninety subprocesses -- so the window
//! froze for seconds at a time, every two seconds. Both halves are fixed:
//! `veilvoice-watch` now costs two subprocesses, and [`crate::watchfeed`] keeps
//! even that on a thread of its own.
//!
//! The rule this file keeps, and the reason the defect is worth a paragraph:
//! **`update` may read state and paint it, and may start work, and may never
//! wait for any.** A job, a lock operation, a monitor scan and an install all
//! go to a worker and come back through a channel.
//!
//! # The monitor indicator
//!
//! [`VeilVoiceApp::watch_indicator`] shows, in the header, whether anything is
//! holding the microphone or camera right now, and clicking it goes to the
//! monitor tab. It is polled on a timer rather than watched continuously,
//! because the underlying platform code enumerates processes and doing that
//! every frame would cost more than the rest of the window put together.
//!
//! What it reports is bounded by what the platform allows, and
//! `veilvoice_watch::support()` states that bound rather than letting an empty
//! list imply an empty machine. The indicator must never present "we could not
//! see" as "nothing is there".
//!
//! # A policy tightens the controls, and the tightening is not the drawing code
//!
//! [`crate::policy::InForce`] holds whatever `veilvoice policy` fixed on this
//! machine. Fixed controls are drawn disabled with the reason underneath, but
//! that is a courtesy: the values a job actually uses come from
//! [`VeilVoiceApp::posture`], which applies the policy every time it is asked.
//! A policy that held only while a checkbox was drawn would not be a policy,
//! and this file already keeps that rule for the at-rest choice.
//!
//! # Where the honest limits are stated
//!
//! The about tab carries the scope text, and the lock tab carries
//! `veilvoice_crypto::lock::SCOPE`. Neither is decoration: tests fail the build
//! if that wording is softened, because a user who over-trusts the app lock is
//! left worse off than one who never had it. If you are editing text in this
//! file and a test starts failing, it is that rule, and it is working.
//!
//! # In plain words
//!
//! The window itself: the tabs along the top, what each one shows, and the state
//! they all share.
//!
//! One window with tabs, no menus, and no settings file to go hunting for.
//! Everything VeilVoice can do is reachable from something visible.
//!
//! The one rule this file follows without exception is that painting the window
//! never waits for anything. Reading a recording or running the engine takes
//! seconds; if that happened here the window would stop responding, so it is
//! started on another thread and the answer is collected later.
use crate::policy::InForce;
use crate::security::Security;
use crate::setup::Setup;
use crate::theme::palette as p;
use crate::watchfeed::WatchFeed;
use egui::{Color32, RichText};
use std::path::PathBuf;
use std::sync::mpsc;
use veilvoice_audio::devices;
use veilvoice_core::{AccentConfig, DeidConfig};
/// The things VeilVoice does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Tab {
/// Process a file on disk.
File,
/// Several people in one recording, each with a name and a colour.
Group,
/// Scramble a microphone in real time, and keep what was said if it was
/// asked for. **Roadmap item 130**: live scramble used to be a tab of its own.
Studio,
/// What is in the vault, without opening any of it.
Browser,
/// Who is using the microphone and camera.
Watch,
/// The app lock, and what it is worth.
Security,
/// Check a download against the signed list of hashes.
Verify,
/// Colour scheme, animation, and where those choices are kept.
Preferences,
/// Portable or installed, and the optional third-party companions.
Setup,
/// Versions, licence and honest scope.
About,
}
impl Tab {
/// The name this tab answers to on the command line.
///
/// Lower case and stable. These are what `--tab` accepts and what
/// `tools/shots/gui.ps1` names each picture after, so changing one renames
/// a screenshot and breaks a link in the README. They are not the labels
/// on screen, which are written for a reader and may be reworded.
pub fn key(self) -> &'static str {
match self {
Self::File => "file",
Self::Group => "group",
Self::Studio => "studio",
Self::Browser => "browser",
Self::Watch => "monitor",
Self::Security => "lock",
Self::Verify => "verify",
Self::Preferences => "settings",
Self::Setup => "install",
Self::About => "about",
}
}
/// Every tab, in the order the window shows them.
pub const ALL: &'static [Tab] = &[
Tab::File,
Tab::Group,
Tab::Studio,
Tab::Browser,
Tab::Watch,
Tab::Security,
Tab::Verify,
Tab::Preferences,
Tab::Setup,
Tab::About,
];
/// The tab with this name, if it is one.
pub fn from_key(key: &str) -> Option<Tab> {
let key = key.trim().to_ascii_lowercase();
// **Roadmap item 130.** Live scramble is the Studio now. `--tab live` still
// opens something rather than failing, because it is written into
// shortcuts and scripts that were made before the tab moved, and the
// honest destination for it is the tab that does that job today.
if key == "live" {
return Some(Tab::Studio);
}
Self::ALL.iter().copied().find(|tab| tab.key() == key)
}
}
/// Result of a background file job.
enum JobDone {
Ok {
output: PathBuf,
secs: f32,
speed: f32,
metadata: Vec<String>,
},
Failed(String),
}
/// Application state.
pub struct VeilVoiceApp {
tab: Tab,
jetbrains: bool,
/// The OpenGL context actually obtained, read once at startup.
///
/// Read from the context rather than from the request: what was asked for
/// is in `graphics`, and the two differ on exactly the machines where the
/// answer matters.
drawing: String,
/// Whether frames are being counted, from `VEILVOICE_FRAME_LOG`.
frame_log: bool,
/// Frames drawn since the last report.
frames: u32,
/// When that report was, in the window's own clock.
frames_since: f64,
/// Whether the window has been fitted to the screen yet.
///
/// The fit happens once, on the first frame, because that is the
/// first moment the monitor size is known. See
/// [`VeilVoiceApp::fit_to_the_screen`].
fitted: bool,
// Shared engine settings.
intensity: f32,
neutralise_accent: bool,
reseed_secs: f32,
/// The randomised ratchet range for this run, drawn at launch.
///
/// F-73: `DeidConfig::with_random_reseed_range` was documented as "the
/// front ends call this at launch" and was called by nothing but its own
/// test, so every shipped copy rolled on the same fixed two-second period.
/// Drawn once here rather than per render, because it is a property of the
/// session -- a fresh one for every file would be no worse, but it would
/// make the value shown beside the slider a lie the moment it was read.
reseed_range: Option<(f32, f32)>,
/// The input-file picker, while it is open.
choosing_input: crate::dialog::Pending,
/// The safety catch. On by default; see `veilvoice_guard::failsafe`.
failsafe: veilvoice_guard::failsafe::Guard,
/// What Failsafe last found, so the panel and the notice agree.
failsafe_finding: Option<veilvoice_guard::failsafe::Finding>,
/// What is being shown to the reader right now, if anything.
///
/// One at a time. A stack of cards covering the window is how somebody
/// dismisses six warnings without reading any of them.
notice: Option<crate::notify::Notice>,
/// The short tour, on a first run and after an upgrade that adds a tab.
tour: crate::tour::Tour,
/// Set once the tour has been considered for this launch, so the decision
/// is taken from the saved version once rather than on every frame.
tour_considered: bool,
/// The report from a previous crash, offered above whatever tab is open.
/// It used to be one line on the About tab, which is the tab somebody who
/// has just had a crash is least likely to open.
crash: crate::crashreport::Offer,
/// The clean-shutdown session marker, and any antivirus notice it produced.
///
/// Held for the life of the window: dropped means the window closed. A
/// clean close calls `end`; a kill leaves the marker for the next launch
/// to notice. See `crate::avnotice`.
av_session: Option<crate::avnotice::Session>,
av_notice: Option<crate::avnotice::Notice>,
// File mode.
input: Option<PathBuf>,
output: Option<PathBuf>,
clean_metadata: bool,
job: Option<mpsc::Receiver<JobDone>>,
status: Option<(String, Color32)>,
last_metadata: Vec<String>,
// Group mode. Off unless the saved preference says to start it on, and
// the mode itself is never saved -- see `crate::group`.
group: crate::group::Group,
// The devices the Studio veils between. The lists are the window's
// because the window is what enumerates them, once, at startup; the
// session that uses them is the Studio's, because roadmap item 130 made the
// Studio the one place a session is started.
inputs: Vec<devices::DeviceInfo>,
outputs: Vec<devices::DeviceInfo>,
chosen_input: Option<String>,
chosen_output: Option<String>,
/// The Studio and the Browser, which share one vault, and which own the
/// live session and the levels drawn from it.
studio: crate::studio::Studio,
/// What the running session reported this frame, or `None` when none is
/// running. Read once, in `update`, because `stats` resets the peaks as it
/// reads them and two readers would each see half the level.
///
/// **Roadmap item 147.** One microphone and a room report different shapes, and
/// this holds whichever it was rather than a flattened pair of numbers:
/// flattening is what would lose the per-guest bars.
reading: Option<crate::studio::Reading>,
/// A rolling average of how long a frame takes, in milliseconds.
///
/// Shown on the About tab. Somebody reporting that the window is slow can
/// then say how slow, and whoever reads that report can tell a window
/// drawing at sixty frames a second from one drawing at eight. Describing
/// an interface is not measuring it, and this is the smallest thing that
/// turns one into the other.
frame_ms: f32,
/// How often the window draws while something moves, what the display
/// turned out to be, and how many frames arrived late. See [`crate::pace`].
pace: crate::pace::Pace,
/// Whether the dropped-frame notice has already been shown this run. Once
/// per launch: a window that is struggling is struggling continuously, and
/// a notice that returned every few seconds would be the worse problem.
said_dropping: bool,
// The app lock, and at-rest encryption of what jobs write.
security: Security,
/// The integrity record, taken at the first launch and checked at every
/// one after. See [`crate::integrity`].
integrity: crate::integrity::Integrity,
/// Where veiled recordings go, and the encrypted volume that may hold
/// them. See [`crate::storage`].
storage: crate::storage::Storage,
/// Seconds since the window was last touched, for the autolock.
///
/// Roadmap item 92. Counted from egui's own frame time rather than the system
/// clock, so moving the machine's clock neither brings the lock forward nor
/// pushes it back.
idle_secs: f32,
// Colour scheme and animation. Named `preferences` rather than `settings`
// because this type already has a `settings` method, which is the engine's
// intensity and accent controls -- a different thing entirely.
preferences: crate::settings::Settings,
/// The first-run setup, while it is running.
first_run: crate::firstrun::FirstRun,
/// Where VeilVoice keeps its own files, obfuscated once a lock is set.
///
/// Opened by the key the unlock derives and closed when the window locks,
/// so between those two moments there is no key in the process at all.
files: crate::vault_store::VaultStore,
// Settings somebody else fixed. Read once; never asks for a passphrase.
policy: InForce,
// Portable or installed, and the optional companions. Reads the machine
// on construction and changes nothing until a button is pressed.
setup: Setup,
// Device monitor, on a thread of its own. Never polled from here.
watch: WatchFeed,
// The manual update check. Holds no clock: the only path into it is the
// button on the about tab.
updates: crate::updates::Updates,
// Checking a download against the signed hashes. Shares its arithmetic
// with the portable verifier rather than reimplementing it.
verify: crate::verify::Verify,
}
/// Pick the output to start on: a virtual cable if the machine has one,
/// because routing there is what lets other applications hear the veiled voice
/// at all; otherwise the system default.
///
/// A free function over a device list rather than a step inside `Default`, so
/// the choice can be tested against every arrangement of devices without
/// touching the machine's audio stack. That matters more than it looks:
/// building the app enumerates devices through `cpal`, and several tests doing
/// that at once on a headless runner is a good way to find out what WASAPI does
/// when there are no endpoints and COM is being initialised from four threads
/// at once. The answer was an access violation.
fn preferred_output(outputs: &[devices::DeviceInfo]) -> Option<String> {
outputs
.iter()
.find(|d| d.is_virtual_cable)
.or_else(|| outputs.iter().find(|d| d.is_default))
.map(|d| d.name.clone())
}
/// Pick the input to start on: the system default, else whatever is first.
fn preferred_input(inputs: &[devices::DeviceInfo]) -> Option<String> {
inputs
.iter()
.find(|d| d.is_default)
.or_else(|| inputs.first())
.map(|d| d.name.clone())
}
impl VeilVoiceApp {
/// Frames per second, to stderr, when `VEILVOICE_FRAME_LOG` is set.
///
/// # Why a counter and not a frame time
///
/// The About tab already shows how long a frame took, which answers "is
/// drawing slow". It does not answer the question people actually have,
/// which is "why is this using processor time when I am not touching it".
/// An idle window should draw *no* frames. One drawing sixty a second is
/// costing a laptop its battery whether each frame is fast or not, and a
/// frame time cannot tell those apart: the fast-drawing runaway looks
/// healthiest of all.
///
/// Off unless asked for, printed once a second, and to stderr rather than
/// into the window, because the person reading it is diagnosing rather
/// than using.
fn count_frames(&mut self, ctx: &egui::Context) {
if !self.frame_log {
return;
}
self.frames += 1;
let now = ctx.input(|i| i.time);
if self.frames_since == 0.0 {
self.frames_since = now;
return;
}
let elapsed = now - self.frames_since;
if elapsed >= 1.0 {
eprintln!(
"veilvoice-gui: {:.1} frames/s on the {} tab ({:.1} ms each)",
self.frames as f64 / elapsed,
self.tab.key(),
self.frame_ms
);
self.frames = 0;
self.frames_since = now;
}
}
/// Roadmap item 148. The frame rate in the header, when it has been asked for.
///
/// Two numbers and no more: the rate as drawn, and how many frames arrived
/// late in the last second. The second one turns yellow rather than
/// appearing, because a readout that changes shape is harder to read at a
/// glance than one that changes colour, and somebody who turned this on is
/// watching it.
///
/// Nothing is formatted unless the readout is on, and nothing here is
/// measured: `Pace` did that once at the top of the frame.
fn frame_readout(&mut self, ui: &mut egui::Ui) {
if !self.preferences.show_frame_rate() {
return;
}
let fps = self.pace.fps();
if fps <= 0.0 {
return;
}
let dropped = self.pace.dropped_last_second();
let colour = if self.pace.is_dropping() {
p::yellow()
} else {
p::muted()
};
let text = if dropped > 0 {
format!("{fps:.0} fps, {dropped} late")
} else {
format!("{fps:.0} fps")
};
ui.label(RichText::new(text).color(colour).small())
.on_hover_text(self.frame_rate_detail());
}
/// The sentence behind the readout, and the one the About tab prints.
///
/// Says what the window is aiming at, what it measured the display to be,
/// and what it is drawing with, because a rate well under the target on
/// software rendering is a different conversation from the same rate on a
/// GPU.
fn frame_rate_detail(&self) -> String {
let target = match self.pace.target() {
crate::pace::Target::Display => match self.pace.display_hz() {
Some(hz) => format!("the display, measured at {hz} a second"),
None => "the display, not measured yet".to_string(),
},
crate::pace::Target::Fixed(hz) => format!("{hz} a second, chosen in Settings"),
};
format!(
"Aiming at {target}. {} frames arrived late since this window opened. \nDrawing with {}.",
self.pace.dropped_total(),
self.drawing
)
}
/// The application with no devices enumerated.
///
/// `Default` calls this after asking the system what it has. Tests that are
/// not about device selection use it directly, so the suite touches the
/// platform's audio stack exactly once instead of once per test.
fn without_devices() -> Self {
Self {
frame_log: std::env::var_os("VEILVOICE_FRAME_LOG").is_some(),
frames: 0,
frames_since: 0.0,
fitted: false,
tab: Tab::File,
jetbrains: false,
drawing: crate::graphics::describe(None),
intensity: 1.0,
neutralise_accent: true,
reseed_secs: 2.0,
// Drawn from the operating system's random source, once, now.
reseed_range: DeidConfig::default()
.with_random_reseed_range()
.reseed_range_ms,
notice: None,
crash: crate::crashreport::Offer::default(),
av_session: None,
av_notice: None,
tour: crate::tour::Tour::default(),
tour_considered: false,
choosing_input: crate::dialog::Pending::new(),
failsafe: veilvoice_guard::failsafe::Guard::new(),
failsafe_finding: None,
input: None,
output: None,
clean_metadata: true,
job: None,
status: None,
last_metadata: Vec::new(),
inputs: Vec::new(),
outputs: Vec::new(),
chosen_input: None,
chosen_output: None,
studio: crate::studio::Studio::default(),
reading: None,
frame_ms: 0.0,
pace: crate::pace::Pace::default(),
said_dropping: false,
security: Security::default(),
integrity: crate::integrity::Integrity::default(),
storage: crate::storage::Storage::default(),
idle_secs: 0.0,
// Off. `VeilVoiceApp::new` is the only place the saved preference
// is consulted, so no test and no `Default` can open in group mode
// because of something on this machine's disk.
group: crate::group::Group::default(),
preferences: crate::settings::Settings::default(),
first_run: crate::firstrun::FirstRun::default(),
files: crate::vault_store::VaultStore::new(
crate::prefs::default_path().and_then(|p| p.parent().map(|d| d.to_path_buf())),
),
// No policy here, so `without_devices` and `Default` touch no file
// that belongs to the user. `VeilVoiceApp::new` loads the real one,
// exactly as it does for the app lock.
policy: InForce::none(),
setup: Setup::new(),
// Idle here, so `without_devices` and `Default` start no thread
// and touch no machine. `VeilVoiceApp::new` starts the real one.
watch: WatchFeed::idle(),
updates: crate::updates::Updates::default(),
verify: crate::verify::Verify::default(),
}
}
}
impl Default for VeilVoiceApp {
fn default() -> Self {
let inputs = devices::list(devices::Direction::Input).unwrap_or_default();
let outputs = devices::list(devices::Direction::Output).unwrap_or_default();
Self {
chosen_input: preferred_input(&inputs),
chosen_output: preferred_output(&outputs),
inputs,
outputs,
..Self::without_devices()
}
}
}
impl VeilVoiceApp {
/// Build the app, applying theme and fonts to `ctx`.
///
/// This is where the lock file is read, rather than in `Default`: tests and
/// anything else constructing the app must not touch the real one.
/// Which tab to open on, if one was named on the command line.
///
/// `veilvoice-gui --tab verify`. It exists so the screenshot tool can put
/// the window on a tab without clicking: driving the interface with
/// synthetic mouse events needs the window in the foreground, Windows
/// refuses to give a background process the foreground, and the refusal is
/// reported by a return value that nothing was reading. Every capture then
/// silently showed whichever tab was already open.
///
/// A deep link into a tab is a reasonable thing for an application to have
/// on its own account, which is why this is a real argument rather than a
/// hidden one.
/// What the integrity record found, drawn under the lock controls.
///
/// **Roadmap item 75.** An associated function rather than a method so it borrows
/// the state it reads and nothing else: `self.security.tab` already holds a
/// mutable borrow of the same struct on the line above.
///
/// It says which of the two records was consulted, because a sealed record
/// and a plain one are worth different amounts and a reader who is not told
/// which they have will assume the better one.
fn integrity_panel(ui: &mut egui::Ui, state: &crate::integrity::State) {
use crate::integrity::State;
ui.add_space(16.0);
ui.separator();
ui.label(RichText::new("Its own files").color(p::blue()).small());
match state {
State::Idle => {
ui.label(RichText::new("not checked this session").color(p::muted()));
}
State::Working => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("reading and hashing…").color(p::muted()));
});
}
State::Recorded { sealed } => {
ui.label(
RichText::new(if *sealed {
"first record taken, sealed with your app-lock passphrase"
} else {
"first record taken, written in the clear"
})
.color(p::green()),
);
}
State::Clean { files, sealed } => {
ui.label(
RichText::new(format!(
"{files} file(s) match the {} record",
if *sealed { "sealed" } else { "plain" }
))
.color(p::green()),
);
}
State::Changed(changes) => {
ui.label(
RichText::new("VeilVoice's own files have changed since the record")
.color(p::red())
.strong(),
);
for change in changes.iter().take(8) {
ui.label(RichText::new(change).color(p::fg()).small());
}
if changes.len() > 8 {
ui.label(
RichText::new(format!("and {} more", changes.len() - 8))
.color(p::muted())
.small(),
);
}
ui.label(
RichText::new(
"An update you installed looks exactly like this. So does a file \
somebody swapped. This cannot tell the two apart.",
)
.color(p::muted())
.small(),
);
}
State::Failed(why) => {
ui.label(RichText::new(why).color(p::yellow()));
}
}
if matches!(state, State::Recorded { sealed: false }) {
ui.label(
RichText::new(
"With no app lock set there is no passphrase to seal this with, so the \
record is readable. It catches a file that changed by accident. It \
does not catch one changed by somebody who also rewrote the record.",
)
.color(p::muted())
.small(),
);
}
ui.add_space(6.0);
ui.label(
RichText::new(veilvoice_guard::SCOPE)
.color(p::muted())
.small(),
);
}
/// The tab `--tab=` asks for, so a capture can open one screen directly.
///
/// Read here rather than through the argument parser the command-line
/// program uses, because the window takes no other arguments and pulling
/// that dependency in for one flag would be the larger cost.
fn tab_from_arguments() -> Option<Tab> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix("--tab=") {
return Tab::from_key(value);
}
if arg == "--tab" {
return Tab::from_key(&args.next()?);
}
}
None
}
/// Build the application, ready for its first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
let jetbrains = crate::theme::install_fonts(&cc.egui_ctx);
// The user's own palettes are read **before** preferences are applied,
// and the order is load-bearing. `Settings::load` selects the theme
// named in the preferences file; if that names a custom palette and the
// table does not hold it yet, the lookup fails, the default is kept,
// and the user's choice is quietly discarded -- on every single launch,
// with nothing to indicate why.
let palette_problems = crate::palettes::default_dir()
.map(|dir| crate::theme::load_custom(&dir))
.unwrap_or_default();
// Preferences second: `Settings::load` applies the chosen colour
// scheme, so the window opens in it rather than flashing the default
// for a frame and then switching.
let mut preferences = crate::settings::Settings::load(&cc.egui_ctx);
preferences.palette_problems = palette_problems;
crate::theme::install(&cc.egui_ctx);
let policy = InForce::load();
// The one place "always start in group mode" is read. The mode itself
// is never persisted -- see `crate::group` for why two controls exist
// where one would look like enough.
let group = crate::group::Group::start_from(preferences.prefs.always_group);
let mut app = Self {
jetbrains,
drawing: crate::graphics::describe(cc.gl.as_deref()),
security: {
// The baseline is read here rather than inside `Security::load`
// so that `Security::default()` stays free of I/O, which is
// what keeps every test in this crate off the real files.
let mut security = Security::load();
security.load_mandate();
security
},
group,
preferences,
policy,
// The one place the monitor thread is started. Everything else
// constructs an idle feed, so no test and no `Default` reaches the
// machine.
watch: WatchFeed::start(cc.egui_ctx.clone()),
..Default::default()
};
// Roadmap item 148. The saved frame-rate target, before the first frame, so
// the first animation is paced by what was chosen rather than by the
// default for one frame.
app.pace.set_target(app.preferences.frame_target());
// Roadmap item 86. Before the first frame, and so before anything can be
// unlocked: `Security` captures the app-lock passphrase as the lock
// opens, and only when this mode is already the chosen one.
let seal_with_app_lock = app.preferences.seal_with_app_lock();
app.security.prefer_app_lock_sealing(seal_with_app_lock);
// Roadmap items 82 to 84. The remembered destination, and one look at what is
// mounted. Both at startup rather than per frame: `refresh` reads the
// mount table, and the draw path reads no files.
app.storage.destination = app.preferences.destination();
app.storage.refresh();
// The integrity record, started before anything is drawn and finished
// on its own thread. With an app lock set this run is skipped: the
// record is sealed under the app-lock passphrase, so it can only be
// read once that passphrase exists, and `poll` starts it again the
// moment the window unlocks.
if !app.security.has_lock() {
app.integrity.start(None);
}
// The clean-shutdown marker for this session, and -- if the last one
// was killed and an antivirus is installed -- a notice explaining it.
// Done here rather than in `launch` so a test can reach the decision;
// the probe itself is best effort and returns nothing off Windows.
let session = crate::avnotice::Session::begin();
let had_crash_report = crate::crashlog::previous().is_some();
app.av_notice = crate::avnotice::diagnose(
session.prior_was_unclean(),
had_crash_report,
&crate::avnotice::detect(),
);
app.av_session = Some(session);
app.apply_policy();
// After the policy, so a named tab is what the window opens on rather
// than something the policy pass happened to leave selected.
if let Some(tab) = Self::tab_from_arguments() {
app.tab = tab;
}
app
}
/// Bring the controls into line with the policy, once, at startup.
///
/// Not the enforcement -- [`VeilVoiceApp::posture`] is. This is so the
/// interface *opens* showing the values a job would use, rather than
/// showing something looser that silently changes when the job runs.
fn apply_policy(&mut self) {
let constrained = self.posture();
self.intensity = constrained.intensity;
self.neutralise_accent = constrained.neutralise_accent;
self.clean_metadata = constrained.clean_metadata;
self.security.encryption_pinned = self
.policy
.requires(&veilvoice_policy::Requirement::EncryptRecordings);
// The sealed policy or the user's own baseline. Either is a reason to
// say the lock should be set; only the policy makes it unchangeable.
self.security.lock_required = self
.policy
.requires(&veilvoice_policy::Requirement::AppLock)
|| self.security.mandate_requires_app_lock();
if self.security.encryption_pinned {
self.security.encrypt_recordings = true;
}
}
/// The settings as they will actually be used, after the policy.
///
/// Everything that runs a job reads this rather than the fields directly.
/// The policy can only tighten it, so the worst this can do is process a
/// recording more thoroughly than the sliders show -- which is the right
/// direction for the one mistake that is unrecoverable.
fn posture(&self) -> veilvoice_policy::Posture {
self.policy.constrain(veilvoice_policy::Posture {
encrypt_recordings: self.security.encrypt_recordings,
clean_metadata: self.clean_metadata,
neutralise_accent: self.neutralise_accent,
app_lock: self.security.has_lock(),
intensity: self.intensity,
})
}
/// The de-identification settings the panels currently describe.
fn config(&self) -> DeidConfig {
let posture = self.posture();
DeidConfig {
intensity: posture.intensity,
accent: AccentConfig {
enabled: posture.neutralise_accent,
..AccentConfig::default()
},
reseed_secs: self.reseed_secs,
reseed_range_ms: self.reseed_range,
..DeidConfig::default()
}
}
}
impl VeilVoiceApp {
/// Open at a size this screen can actually show, once, on the first frame.
///
/// The size the window is *created* with has to be chosen before there is
/// a window, and therefore before anything knows how big the screen is.
/// So it is created at the preferred size and corrected here, on the first
/// frame, when egui can say what the monitor is.
///
/// Once only. Re-fitting on every frame would undo a resize the moment
/// somebody made one, which is a window that fights its user; and because
/// a resize causes a frame, it would also be a loop.
///
/// Skipped entirely when `--size` was given: that is somebody, or the
/// screenshot harness, saying exactly what they want.
fn fit_to_the_screen(&mut self, ctx: &egui::Context) {
if self.fitted {
return;
}
self.fitted = true;
if crate::window::requested_size().is_some() {
return;
}
let monitor = ctx.input(|i| i.viewport().monitor_size);
let Some(monitor) = monitor else {
return;
};
let want = crate::window::opening_size(Some([monitor.x, monitor.y]), None);
let now = ctx.input(|i| i.viewport().inner_rect.map(|r| r.size()));
// Only when it actually differs, and by enough to be a real
// difference rather than a rounding one. Sending the command
// unconditionally would make the window flicker on every launch.
if now.is_none_or(|size| (size.x - want[0]).abs() > 1.0 || (size.y - want[1]).abs() > 1.0) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
want[0], want[1],
)));
}
}
}
/// A small-text button drawn to the size of the control beside it.
///
/// **Finding F-178.** The header's right-hand controls sit in one centred row,
/// and the theme picker is the tallest thing in it. A button around small text
/// works its own height out from the padding in the style and lands a pixel
/// short of the picker, which is enough for two boxes side by side to read as
/// not quite lining up, and nothing anywhere said the two should match.
///
/// So the height is passed in from the picker's own rectangle rather than
/// written down: whatever the picker turns out to be, the button is that.
///
/// **Finding F-196.** The width was left alone, on the reasoning that a button
/// as wide as the picker would be a different complaint. It was the same
/// complaint: a 46-point button against a 132-point dropdown, sharing a middle
/// and agreeing on nothing else, is what "not aligned" was describing. It is
/// drawn at [`crate::layout::LOCK_WIDTH`] now, which is the picker's width and
/// the unlock button's width too.
fn header_button(
ui: &mut egui::Ui,
text: &str,
colour: egui::Color32,
height: f32,
) -> egui::Response {
ui.add(crate::layout::lock_button(
RichText::new(text).color(colour).small(),
height,
))
}
impl eframe::App for VeilVoiceApp {
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
// A clean close removes the session marker, so the next launch does not
// mistake this for a kill. A process terminated from outside never
// reaches here, which is exactly what leaves the marker for
// `crate::avnotice` to find.
if let Some(session) = self.av_session.take() {
session.end();
}
}
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
// eframe 0.36 hands the root `Ui` rather than the context. The
// context is what most of this reads, so it is taken once; panels
// are laid into `root`.
let ctx = &root.ctx().clone();
self.fit_to_the_screen(ctx);
self.count_frames(ctx);
// Roadmap item 148. One reading per drawn frame, before anything is
// painted, so the header and the About tab report the same frame.
// Whether this frame was one an animation asked for is read from the
// flag `pace::next_frame` set at the end of the frame before.
self.pace.frame(ctx.input(|i| i.time));
// How long the last frame took, smoothed. `stable_dt` rather than `dt`
// because the raw one spikes whenever the window has been idle and the
// spike says nothing about how fast the drawing is.
let dt = ctx.input(|i| i.stable_dt) * 1000.0;
if dt.is_finite() && dt > 0.0 {
self.frame_ms = if self.frame_ms == 0.0 {
dt
} else {
self.frame_ms * 0.9 + dt * 0.1
};
}
// Roadmap item 148. Frames have been arriving late for two seconds running.
// Said once, and only where a notice is not already in the way: the
// first-run cards and a crash report are both more urgent than this.
if self.pace.is_dropping()
&& !self.said_dropping
&& self.notice.is_none()
&& !self.preferences.needs_first_run()
{
self.said_dropping = true;
self.notice = Some(crate::notify::Notice::note(format!(
"Frames are arriving late: {:.0} a second against {} asked for. \
Drawing with {}. Settings can lower the rate, and the About tab \
has the numbers.",
self.pace.fps(),
self.pace.target_hz(),
self.drawing
)));
}
// Roadmap item 92. Any input at all is use; the passage of a job is not.
// Somebody who starts a long render and walks away has walked away, and
// what they are producing is the thing worth locking away.
let (touched, dt) = ctx.input(|i| {
let touched = !i.events.is_empty()
|| i.pointer.velocity() != egui::Vec2::ZERO
|| i.smooth_scroll_delta != egui::Vec2::ZERO;
(touched, i.stable_dt)
});
if touched || self.security.is_locked() {
self.idle_secs = 0.0;
} else if dt.is_finite() {
self.idle_secs += dt;
}
let autolock = self.preferences.autolock();
if autolock.expired(std::time::Duration::from_secs_f32(self.idle_secs.max(0.0))) {
self.security.lock_after_idle();
self.idle_secs = 0.0;
}
self.poll_job();
// The integrity record. Two things happen here and both are cheap: a
// finished check is collected, and a just-completed unlock hands over
// the passphrase the sealed record needs. Neither touches the disk on
// this thread.
if self.integrity.poll() {
ctx.request_repaint();
}
if let Some(passphrase) = self.security.take_unlock_passphrase() {
self.integrity.start(Some(passphrase));
}
// The obfuscated program folder, opened with the key the unlock
// derived. Everything VeilVoice keeps about itself moves in on the
// first unlock after a lock is set, and the audit that comes back says
// whether anything in there was edited or removed while the window was
// shut. See `crate::vault_store` for what that is worth and, just as
// importantly, what it is not.
if let Some(key) = self.security.take_unlock_store_key() {
match self.files.unlocked(key) {
Ok(audit) => {
if !audit.is_clean() {
// Said to somebody who has just proved the passphrase,
// which is the only person it should be said to. The
// count is what is actionable; which record it was is
// on the security tab.
let touched = audit.tampered.len() + audit.missing.len();
self.notice = Some(crate::notify::Notice::warn(format!(
"{touched} of VeilVoice's own files changed while it was \
closed. The security tab says which."
)));
}
}
Err(why) => {
self.notice = Some(crate::notify::Notice::warn(format!(
"the program folder could not be opened: {why}"
)))
}
}
}
if self.security.is_locked() {
self.files.locked();
}
// Roadmap item 86, kept in step. `set_` is a no-op when nothing changed, so
// this costs a comparison per frame and never a write.
self.preferences
.set_seal_with_app_lock(self.security.seals_with_app_lock());
// Before anything is drawn, and it has to be: this was at the *bottom*
// of `update`, after the panel that shows the result had already been
// painted, so a dropped file and the highlight under a hovering one
// were both a frame late. The comment there said "before anything is
// drawn", which is how a wrong thing survives a reading -- it agreed
// with itself.
//
// Read whatever tab is open. A file dropped on the window is meant for
// the verify tab wherever the reader happens to be; telling them
// nothing because the wrong tab was open would be the interface
// refusing to do the obvious thing.
self.verify.take_dropped(ctx);
// The group panel renders with the engine settings the rest of the
// application is set to, rather than with the defaults. Copied here,
// before anything is painted, so the limit it shows and the render it
// starts are both computed from the same thing (F-67).
self.group.config = self.config();
// The gate comes before everything: while locked, no device list, no
// file names and no live session are reachable or even drawn.
if self.security.is_locked() {
// The vault closes with the window. Its key is derived from both
// passphrases and held only while it is open, so leaving it open
// behind a lock screen would undo the thing the second passphrase
// was for. A take still recording is stopped and **stored** first,
// inside `close`: the vault is still open at that moment, and
// discarding somebody's recording because an idle timer fired
// would be the worst thing this tab could do.
self.studio.close();
// The motion preference is resolved here rather than inside the
// screen: the lock is drawn before the rest of the window exists,
// and the setting belongs to the application rather than to the
// lock.
let motion = self.preferences.motion(ctx);
egui::CentralPanel::default().show(root, |ui| self.security.unlock_screen(ui, motion));
// The rate limit counts down whether or not anything else moves.
ctx.request_repaint_after(std::time::Duration::from_millis(500));
return;
}
let dialogue_open = self.security.disable_dialogue(ctx);
// The application bar: a band of its own colour across the top, with
// room to breathe and rounded lower corners so it reads as a surface
// the content sits under rather than a line somebody drew.
//
// Deliberately still a Windows window. The system's own title bar, its
// buttons and its behaviour are all left alone: an application that
// draws its own title bar has to reimplement dragging, snapping,
// maximising and the accessibility that comes with them, and gets some
// of it subtly wrong on somebody else's machine. This is the band below
// that, which is ours to make pleasant.
egui::Panel::top("header")
.frame(
egui::Frame::new()
.fill(p::bg_dark())
.inner_margin(egui::Margin {
left: 18,
right: 18,
top: 12,
bottom: 10,
})
.corner_radius(egui::CornerRadius {
nw: 0,
ne: 0,
sw: 10,
se: 10,
}),
)
.show(root, |ui| {
let motion = self.preferences.motion(ctx);
let time = ui.input(|i| i.time) as f32;
ui.horizontal(|ui| {
// The mark, animated as on the website unless it has been
// stilled. `draw` requests no repaint when it is still, so the
// toggle saves the work as well as the movement.
crate::soundbar::draw(ui, egui::vec2(46.0, 22.0), motion, time)
.on_hover_text("VeilVoice");
ui.add_space(4.0);
ui.label(
RichText::new("VeilVoice")
.size(21.0)
.color(p::fg())
.strong(),
);
ui.add_space(2.0);
ui.label(
RichText::new(format!("v{}", env!("CARGO_PKG_VERSION")))
.color(p::muted())
.small(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new("offline").color(p::green()).small());
self.frame_readout(ui);
// **Roadmap item 78.** The colour scheme, in the header.
//
// Every one of the website's themes has been in this
// application since roadmap item 26, and the picker was on a
// page inside Settings, which is a place somebody looks
// only if they already believe there is something to
// find. The website puts its picker in the header on
// every page; so does this now, and Settings keeps the
// fuller panel with the swatches and the custom
// palettes.
let picker = self.preferences.theme_picker(ui, ctx);
if self.security.has_lock()
&& header_button(ui, "lock", p::yellow(), picker.height())
.on_hover_text("Lock the app and clear the session passphrase")
.clicked()
{
self.security.lock_now();
}
// A monitor you have to go looking for is not doing its
// job, so the warning rides the header on every tab.
self.watch_indicator(ui);
});
});
ui.add_space(10.0);
// The install tab is not always offered. An installed copy never
// shows it -- a program offering to install itself when it already
// is tells the user something untrue about what they are running --
// and a portable copy shows it unless the reader has ticked it away
// under settings, interface.
let offer_install = self
.preferences
.show_install_tab(self.setup.running_installed());
// A tab that is not shown must not stay selected, or the window
// keeps drawing a panel with nothing to reach it by. Sent back to
// the first tab, which is where the app opens anyway.
if !offer_install && self.tab == Tab::Setup {
self.tab = Tab::File;
}
// Greyed out while a panel that takes over the whole body is
// up: the first-run choices, or the tour. Both of those draw
// instead of the tab and return, so a click on the strip moved
// the highlight and changed nothing underneath it, which looks
// exactly like a window that has stopped responding. Disabled
// is the honest version of what was already true.
let taken_over = self.preferences.needs_first_run() || self.tour.running();
ui.add_enabled_ui(!taken_over, |ui| {
ui.horizontal(|ui| {
for (tab, label) in [
(Tab::File, "Anonymise file"),
(Tab::Group, "Group"),
(Tab::Studio, "Studio"),
(Tab::Browser, "Browser"),
(Tab::Watch, "Monitor"),
(Tab::Security, "Lock"),
(Tab::Verify, "Verify"),
(Tab::Preferences, "Settings"),
(Tab::Setup, "Install"),
(Tab::About, "About"),
] {
if tab == Tab::Setup && !offer_install {
continue;
}
let selected = self.tab == tab;
let text = RichText::new(label).color(if selected {
p::blue()
} else {
p::muted()
});
if ui.selectable_label(selected, text).clicked() {
self.tab = tab;
}
// A real gap between tabs, not just the default padding.
//
// It reads better, and it is load-bearing for
// `tools/shots/gui.ps1`, which finds the tabs by scanning
// the strip for lit columns separated by gaps. Capitalising
// the labels widened them enough to close the space between
// the first two, and the scanner read "Anonymise file Live
// scramble" as one label and refused to continue -- which
// is the failure working as intended, and the fix is to
// give it something unambiguous to see.
ui.add_space(6.0);
}
});
});
});
// Resolved once above for the header mark, and read again here so
// the setup tab's progress strip obeys the same answer rather than
// asking the question a second time in the same frame.
let motion = self.preferences.motion(ctx);
// The levels, once a frame, before anything draws them.
//
// Here rather than in the Studio, because the monitor strip below is
// drawn on every tab and the Studio is drawn on one. Reading the
// session from whichever happened to run would have made the strip
// freeze the moment somebody navigated away, which is the exact moment
// this feature exists for. `stats` resets the peaks as it reads them,
// so this is the only reader and everything else is shown what it got.
self.reading = self.studio.tick();
// **Roadmap item 132.** Who else is holding the microphone, while a take is
// being made and only then. Outside one this is the Monitor tab's
// question and the safety catch's, and building a list every frame for
// a question nobody is asking is what roadmap item 126 is about.
//
// Independent of the safety catch's posture on purpose. That is a
// setting about closing other programs; this is a fact about a
// recording being made now, and somebody who turned the catch off did
// not ask to be told less about their own take.
if self.studio.is_recording() {
self.studio.note_microphone_holders(
self.watch
.active()
.iter()
.filter(|use_| use_.kind == veilvoice_watch::DeviceKind::Microphone)
.map(|use_| use_.app.as_str()),
);
}
// The live monitor, on every tab and above the panel. Docked by
// default; a floating card or nothing if the reader has said so.
//
// Drawn before the central panel so the panel is laid out inside what
// is left, rather than under a strip that arrives after it.
if crate::monitor::show(
root,
self.preferences.live_monitor(),
self.studio.is_veiling(),
self.studio.is_previewing(),
self.studio.levels(),
) == crate::monitor::Action::Dismiss
{
// Closing the always-on-top window brings the strip back rather
// than turning the meters off. Its close button is the window
// manager's, so somebody pressing it means "not in my way", and
// reading that as "never show me my microphone again" would take
// the meters away from under a call without being asked.
if self.preferences.live_monitor() == crate::monitor::Style::OnTop {
self.preferences
.set_live_monitor(crate::monitor::Style::Toolbar);
self.notice = Some(crate::notify::Notice::note(
"The monitor is back in the window, along the bottom. Settings can put \
it above other windows again, or turn it off.",
));
} else {
self.preferences
.set_live_monitor(crate::monitor::Style::Off);
self.notice = Some(crate::notify::Notice::note(
"The live monitor is off. Settings brings it back, and the Studio still \
has the full meters.",
));
}
}
// The antivirus notice, promoted into the ordinary notice slot once the
// first-run setup is out of the way and nothing else is showing. Once:
// `take` leaves it `None`, so dismissing it is the end of it.
if self.av_notice.is_some() && self.notice.is_none() && !self.preferences.needs_first_run()
{
if let Some(av) = self.av_notice.take() {
self.notice = Some(crate::notify::Notice::warn(av.message()));
}
}
// Above the panel content and below the tab strip, so it is seen
// whatever tab is open. Drawn before the tab body rather than after,
// for the same reason F-61 moved the dropped-file read to the top:
// painting a notice under the thing it is about is a notice a frame
// late and half a window away.
if let Some(notice) = self.notice.clone() {
let style = self.preferences.notify_style();
egui::Panel::top("notice").show(root, |ui| {
ui.add_space(6.0);
if crate::notify::show(ui, style, ¬ice) {
self.notice = None;
}
ui.add_space(6.0);
});
}
// The report from a crash, on the first run after one, above whichever
// tab the person landed on. Looked for once rather than every frame.
//
// Held back while the first-run panel is up. Both can be true at once,
// and it happens for a specific reason: the application crashed before
// it saved the answers, so the next launch is still a first run and
// also has a report waiting. Drawn together they were two unrelated
// things demanding a decision on the same screen, with the welcome
// underneath the wreckage. The two choices come first, and the report
// is there when they are made.
self.crash.look();
if self.crash.waiting() && !self.preferences.needs_first_run() {
egui::Panel::top("crash").show(root, |ui| {
ui.add_space(8.0);
self.crash.panel(ui);
ui.add_space(8.0);
});
}
egui::CentralPanel::default().show(root, |ui| {
// While the "unencrypted?" question is open, clicks must not land
// on the window behind it.
ui.add_enabled_ui(!dialogue_open, |ui| {
// Offered once, and only once: two choices with sensible
// defaults, so it is a courtesy rather than a gate.
if self.preferences.needs_first_run() {
// Four cards rather than two checkboxes: the app lock, the
// recording passphrase and the autolock are offered here
// because a protection nobody is shown is a protection
// nobody has. Every card can be skipped.
if self
.first_run
.panel(ui, &mut self.preferences, &mut self.security)
== crate::firstrun::Outcome::Finished
{
self.preferences.finish_first_run();
}
return;
}
// The tour, once the two choices are made. Considered once per
// launch: a first run sees every card, an upgrade sees only
// the tabs that did not exist when it last ran, and a version
// that has already toured sees nothing.
if !self.tour_considered {
self.tour_considered = true;
let seen = self.preferences.toured_tabs();
if seen.is_empty() {
self.tour.start();
} else {
self.tour.start_new_only(&seen);
}
}
if self.tour.running() && self.tour.panel(ui, self.setup.running_installed()) {
self.preferences.mark_toured(&crate::tour::all_keys());
}
if self.tour.running() {
return;
}
// Every tab, inside one scroller.
//
// This is what "nothing is ever out of reach" actually
// requires. A window can be any size the person makes it, and
// several of these panels are taller than a small one: the
// security tab had no scroller at all, so on a short window the
// controls below the fold could not be reached by any means --
// not scrolled to, not tabbed to, not resized into view without
// making the window taller than the screen.
//
// Here rather than in each tab so that a tab added later gets
// it without anybody remembering to, and so there is exactly
// one of them: a scroller inside a scroller traps the wheel in
// whichever the pointer happens to be over.
//
// `auto_shrink([false, false])` so a short panel still fills
// the window rather than collapsing the layout around itself.
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match self.tab {
Tab::File => self.file_tab(ui),
Tab::Group => self.group.tab(ui, &mut self.preferences),
Tab::Studio => self.studio_tab(ui),
Tab::Browser => self.studio.browser(ui),
Tab::Watch => self.watch_tab(ui),
Tab::Security => {
self.security.tab(ui);
Self::integrity_panel(ui, self.integrity.state());
// Roadmap items 82 to 84. Returns true when the choice
// changed, which is when it is worth a write to
// the settings file rather than every frame.
if crate::storage::panel(&mut self.storage, ui) {
self.preferences.set_destination(&self.storage.destination);
}
}
Tab::Verify => self.verify.tab(ui),
Tab::Preferences => self.preferences.tab(ui, ctx),
Tab::Setup => self.setup.tab(ui, motion),
Tab::About => self.about_tab(ui),
});
});
});
self.watch.drain();
self.check_failsafe();
self.updates.drain();
self.group.drain();
self.verify.drain();
// **Roadmap item 79.** How often to come back, decided by what is moving.
//
// This was one number, 50 ms, for everything: a live session, a
// download, a file being veiled. Twenty frames a second is fine for a
// progress line that changes once a second and is not fine for a meter
// that follows a voice, which is the one thing here that moves
// continuously. At 20 Hz a meter steps rather than sweeps, and a window
// whose only moving part is stepping reads as a window that is
// struggling.
//
// So a live session asks for 16 ms and everything else keeps 50. The
// cost is bounded and it is paid only while somebody is actually being
// veiled, which is the moment worth spending it on.
//
// Not claimed: that this fixes anything somebody has reported. This
// machine has no display and the frame time is not measurable from
// here, which is why the About tab now shows it. A number the person
// with the problem can read is worth more than a change made blind.
if self.studio.is_veiling() {
// The next frame, paced by the display or by the chosen target.
// This used to ask for one "in sixteen milliseconds", which on a
// display at sixty is just after the frame it could have joined,
// so the drawing landed on the one after and the window ran at
// thirty (finding F-179).
crate::pace::next_frame(ctx);
} else if self.updates.is_busy()
// Hovering, not only busy. An idle window requests no repaint, so
// dragging a file over it lit nothing up and the file did not
// appear until the mouse moved for some other reason -- the one
// moment in this application where the user is waiting for the
// window to react and the window has decided nothing is happening.
|| self.verify.wants_repaint()
|| self.group.is_busy()
|| self.job.is_some()
|| self.security.is_busy()
|| self.integrity.is_busy()
|| self.setup.is_busy()
{
// Progress bars and spinners, at the same rate as everything else
// that moves. Fifty milliseconds here was twenty a second beside a
// header animating at its own rate, which is what the judder was.
crate::pace::next_frame(ctx);
} else if autolock.enabled && !self.security.is_locked() {
// Roadmap item 92. Once a second is enough to notice a delay measured in
// minutes, and it is what makes the lock actually engage: an idle
// window requests no repaint, so without this the countdown would
// only advance while somebody was looking at it, which is the one
// time it should not.
ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
// Nothing here for the microphone monitor. It runs on its own thread
// and asks for a repaint when it has something to report, which is the
// only moment a repaint is worth anything. Waking twice a second to
// ask whether it had news is what made an untouched window draw 2.1
// frames a second on every tab, for ever.
}
}
impl VeilVoiceApp {
/// Take the result of a finished job without ever waiting for one.
///
/// Called once a frame from the drawing thread, so it uses `try_recv`:
/// blocking here would hand the window's responsiveness to however long
/// the job takes.
fn poll_job(&mut self) {
let Some(rx) = &self.job else { return };
match rx.try_recv() {
Ok(JobDone::Ok {
output,
secs,
speed,
metadata,
}) => {
self.status = Some((
format!(
"done in {secs:.1}s ({speed:.0}x realtime) → {}",
output.display()
),
p::green(),
));
self.last_metadata = metadata;
self.job = None;
}
Ok(JobDone::Failed(message)) => {
self.status = Some((message, p::red()));
self.job = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.status = Some((
"the processing thread stopped unexpectedly".into(),
p::red(),
));
self.job = None;
}
}
}
/// Draw the settings panel, with a policy floor shown as a floor rather
/// than as a value somebody can move.
fn settings(&mut self, ui: &mut egui::Ui) {
ui.label(RichText::new("Settings").color(p::blue()).small());
// A floor becomes the bottom of the slider's range rather than a value
// the slider snaps back from. A control that visibly refuses to go
// where it is dragged reads as broken; one whose range starts higher
// reads as a decision, which is what it is.
let floor = self.policy.minimum_intensity();
if self.intensity < floor {
self.intensity = floor;
}
ui.add(
egui::Slider::new(&mut self.intensity, floor..=1.0)
.text("intensity")
.fixed_decimals(2),
);
if floor > 0.0 {
self.policy.note(
ui,
&veilvoice_policy::Requirement::MinimumIntensity((floor * 100.0).round() as u8),
);
}
let accent_fixed = self
.policy
.requires(&veilvoice_policy::Requirement::NeutraliseAccent);
if accent_fixed {
self.neutralise_accent = true;
}
ui.add_enabled(
!accent_fixed,
egui::Checkbox::new(
&mut self.neutralise_accent,
"neutralise accent and intonation",
),
);
self.policy
.note(ui, &veilvoice_policy::Requirement::NeutraliseAccent);
ui.label(
RichText::new(if self.neutralise_accent {
"every speaker is mapped onto one canonical register and vocal tract"
} else {
"the speaker's accent, intonation and vocal tract are left intact"
})
.color(p::muted())
.small(),
);
// The ratchet. Two ways to set it, and the control that is *not* in
// force is disabled rather than left looking live -- a slider that
// silently does nothing is how somebody ends up certain they changed a
// setting they did not.
let mut randomised = self.reseed_range.is_some();
if ui
.checkbox(&mut randomised, "randomise the seed-roll interval")
.on_hover_text(
"A fixed interval is a fixed thing to observe. With this on, the gap \
before every roll is drawn fresh, so the ratchet has no period.",
)
.changed()
{
self.reseed_range = if randomised {
// Drawn again rather than remembered, so turning it off and on
// is not a way to get the same range back.
DeidConfig::default()
.with_random_reseed_range()
.reseed_range_ms
} else {
None
};
}
ui.add_enabled_ui(!randomised, |ui| {
ui.add(
egui::Slider::new(&mut self.reseed_secs, 0.0..=30.0)
.text("seed roll (s)")
.fixed_decimals(1),
);
});
// What the engine will actually do, quantised to whole frames. Showing
// the range as asked for would describe a spread that does not exist:
// the ratchet can only fire on a frame boundary.
let effective = self.config().effective_reseed_range_ms();
ui.label(
RichText::new(match effective {
Some((lo, hi)) => format!(
"{lo:.0}-{hi:.0} ms, drawn fresh before every roll, so there is no period to observe"
),
None if self.reseed_secs <= 0.0 => {
"one modulation stream for the whole session".to_string()
}
None => "the modulation stream rolls forward; earlier audio is sealed \
off behind it"
.to_string(),
})
.color(p::muted())
.small(),
);
if effective.is_some() {
ui.label(
RichText::new(
"drawn from the operating system's random source at launch, so it \
is a property of this run rather than a number compiled in",
)
.color(p::muted())
.small(),
);
}
}
/// Draw the File tab: pick a recording, veil it, write it somewhere else.
fn file_tab(&mut self, ui: &mut egui::Ui) {
ui.add_space(4.0);
ui.label(RichText::new("Input").color(p::blue()).small());
ui.horizontal(|ui| {
// Started, not waited for. The picker runs on its own thread and
// the answer is collected below, so the window keeps painting
// while somebody browses -- see `crate::dialog`.
if ui
.add_enabled(
!self.choosing_input.is_open(),
egui::Button::new("choose file…"),
)
.clicked()
{
self.choosing_input.start(crate::dialog::Ask::open_filtered(
"audio",
&["wav", "mp3", "flac", "ogg", "m4a", "aac", "opus"],
));
}
if let Some(path) = self.choosing_input.taken() {
let mut out = path.clone();
out.set_extension("veiled.wav");
self.input = Some(path);
self.output = Some(out);
self.status = None;
}
match &self.input {
Some(path) => ui.label(RichText::new(path.display().to_string()).color(p::cyan())),
None => ui.label(RichText::new("no file selected").color(p::muted())),
};
});
ui.add_space(8.0);
self.settings(ui);
let metadata_fixed = self
.policy
.requires(&veilvoice_policy::Requirement::CleanMetadata);
if metadata_fixed {
self.clean_metadata = true;
}
ui.add_enabled(
!metadata_fixed,
egui::Checkbox::new(&mut self.clean_metadata, "strip metadata from the result"),
);
self.policy
.note(ui, &veilvoice_policy::Requirement::CleanMetadata);
ui.add_space(12.0);
self.security.recording_controls(ui);
ui.add_space(12.0);
let busy = self.job.is_some();
// Roadmap item 83. A destination whose hidden-volume question is unanswered
// blocks the job rather than quietly writing beside the source file.
// The silent fallback is the failure this exists to prevent: a veiled
// recording sitting outside a vault while its owner believes it is
// inside one.
let ready = self.input.is_some()
&& !busy
&& self.security.ready_to_write()
&& self.storage.destination.ready();
let button = ui.add_enabled(
ready,
egui::Button::new(RichText::new(" anonymise ").strong()),
);
if button.clicked() {
self.start_job();
}
if let Some(reason) = self.security.blocked_reason() {
ui.label(RichText::new(reason).color(p::yellow()).small());
}
if let Some(reason) = self.storage.destination.blocked() {
ui.label(RichText::new(reason).color(p::yellow()).small());
}
if busy {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("processing…").color(p::muted()));
});
}
if let Some((message, colour)) = &self.status {
ui.add_space(8.0);
ui.label(RichText::new(message).color(*colour));
}
if !self.last_metadata.is_empty() {
ui.label(
RichText::new(format!(
"metadata removed: {}",
self.last_metadata.join(", ")
))
.color(p::muted())
.small(),
);
}
ui.add_space(16.0);
ui.separator();
ui.label(
RichText::new(
"The words survive on purpose. A scrambler you cannot understand is \
useless. Encrypting the result at rest is what keeps them from being \
read off the disk afterwards, which is why it is on by default.",
)
.color(p::muted())
.small(),
);
}
/// Hand the work to a thread, so the window keeps drawing while it runs.
fn start_job(&mut self) {
let Some(input) = self.input.clone() else {
return;
};
let output = self.output.clone().unwrap_or_else(|| {
let mut o = input.clone();
o.set_extension("veiled.wav");
o
});
// Roadmap item 82. The encrypted destination replaces the folder and keeps
// the name. `place` returns the original untouched when nothing is
// chosen, and also when the destination is not cleared to be used, so
// a job that got past the button somehow still cannot write into a
// volume whose hidden-volume question is unanswered.
// F-95. The mount table is read here, at the moment of writing, rather
// than taken from what the panel last saw. A vault locked since it was
// chosen leaves its mount point behind as an empty directory, and
// writing into that puts a veiled recording on the ordinary disk while
// its owner believes it went into the vault. `start_job` runs on a
// click and spawns a thread, so this is not the draw path.
let mounts = veilvoice_setup::volumes::mounted();
let placed = self.storage.destination.place(&output, &mounts);
if self.storage.destination.volume.is_some() && placed == output {
self.status = Some((
"that encrypted folder is not open now, so nothing was written. \
Unlock it in its own program, or choose the ordinary folder again."
.to_string(),
p::red(),
));
return;
}
let output = placed;
let config = self.config();
let clean = self.posture().clean_metadata;
let plan = self.security.plan();
let (tx, rx) = mpsc::channel();
self.job = Some(rx);
self.status = None;
self.last_metadata.clear();
// Off the UI thread: a long file would otherwise freeze the window, and
// Argon2id at 256 MiB is deliberately slow on top of that.
std::thread::spawn(move || {
let started = std::time::Instant::now();
let result = (|| -> Result<(PathBuf, f32, Vec<String>), String> {
let audio = veilvoice_audio::io::load(&input).map_err(|e| e.to_string())?;
let veiled =
veilvoice_audio::deidentify(&audio, config).map_err(|e| e.to_string())?;
// Encoded in memory, so a recording that is going to be sealed
// never lands on the disk in the clear even briefly.
let mut wav = veilvoice_audio::io::wav_bytes(&veiled).map_err(|e| e.to_string())?;
let mut removed = Vec::new();
if clean {
if let Ok((cleaned, report)) =
veilvoice_meta::clean_wav_bytes(&wav, veilvoice_meta::Policy::Strip)
{
wav = cleaned;
removed = report.removed;
}
}
let written =
plan.write(&output, &wav, veilvoice_crypto::kdf::KdfParams::default())?;
Ok((written, audio.duration_secs(), removed))
})();
let secs = started.elapsed().as_secs_f32();
let _ = tx.send(match result {
Ok((output, duration, metadata)) => JobDone::Ok {
output,
secs,
speed: duration / secs.max(1e-6),
metadata,
},
Err(message) => JobDone::Failed(message),
});
});
}
/// The Recording Studio: the voice first, then the take.
///
/// # Roadmap item 130: live scramble is not a separate tab any more
///
/// It was, and the split was in the wrong place. The Studio has always
/// recorded through the same `LiveSession` the live tab ran, with the same
/// engine and the same routing, so the two screens were one act performed
/// in two rooms: pick the devices over there, come here, press record. The
/// devices the Studio recorded with were the ones the other tab happened to
/// be set to, and nothing on this screen said so.
///
/// Worse, each tab started a session of its own. Veiling on one and
/// recording on the other opened the same microphone twice, which on the
/// platforms that allow it at all gives the second stream a copy of the
/// input nobody asked for. There is now exactly one starter, in
/// `studio::Studio::start_session`, and this tab is the only thing that
/// calls it.
///
/// So: the voice half is here, at the top, and it works with the vault shut
/// because veiling a call has never needed a vault and requiring one would
/// be a worse program. The take half is under it and needs both
/// passphrases, as it always has.
///
/// The lists and the settings widgets stay the window's rather than the
/// Studio's, because the window is what enumerates the devices, once, at
/// startup, and because the file tab shows the same engine settings.
/// The room: a name and a microphone each, and what that costs.
///
/// **Roadmap item 147.** Drawn by the window rather than by the Studio for the
/// reason the device pickers are: the device list belongs to the window.
/// What the Studio owns is the list of guests, because the session it
/// starts is built from it.
fn guest_list(&mut self, ui: &mut egui::Ui) {
let count = self.studio.room_guests().len();
let mut remove = None;
for index in 0..count {
ui.horizontal(|ui| {
let Some(guest) = self.studio.room_guest_mut(index) else {
return;
};
ui.label(RichText::new(format!("{:>2}.", index + 1)).color(p::muted()));
let hint = format!("guest {}", index + 1);
ui.add(
egui::TextEdit::singleline(&mut guest.name)
.desired_width(140.0)
.hint_text(&hint),
);
// Its own salt per row: two combo boxes sharing an identifier
// are one combo box drawn twice, and opening either would open
// the other.
let current = guest
.device
.clone()
.unwrap_or_else(|| "system default".into());
let chosen = &mut guest.device;
egui::ComboBox::from_id_salt(("room guest", index))
.width(300.0)
.selected_text(RichText::new(current).color(p::cyan()))
.show_ui(ui, |ui| {
ui.selectable_value(chosen, None, "system default");
for device in &self.inputs {
ui.selectable_value(
chosen,
Some(device.name.clone()),
device.name.clone(),
);
}
});
if ui.button("remove").clicked() {
remove = Some(index);
}
});
}
if let Some(index) = remove {
self.studio.remove_guest(index);
}
ui.horizontal(|ui| {
let room = veilvoice_audio::MAX_GUESTS;
ui.add_enabled_ui(count < room, |ui| {
if ui.button(" add a guest ").clicked() {
self.studio.add_guest();
}
});
ui.label(
RichText::new(format!("{count} of at most {room}"))
.small()
.color(p::muted()),
);
});
// Said before the session refuses rather than after, because the
// refusal is easier to act on when the row it is about is on screen.
if let Some(shared) = crate::studio::sharing_a_microphone(self.studio.room_guests()) {
ui.label(RichText::new(shared).color(p::yellow()).small());
}
ui.label(
RichText::new(
" Each guest is veiled into a voice of their own, from the same set a \
group render hands out, and the results are mixed into the output. \
Four voices is four times the work on one deadline: the load below \
says how much of it is being used.",
)
.small()
.color(p::muted()),
);
}
/// Draw the Recording Studio: record into the vault, veiled on the way in.
fn studio_tab(&mut self, ui: &mut egui::Ui) {
let veiling = self.studio.is_veiling();
ui.add_space(4.0);
ui.label(RichText::new("Devices").color(p::blue()).small());
ui.add_enabled_ui(!veiling, |ui| {
// **Roadmap item 147.** One microphone, or one per person in the room.
//
// A room is not a bigger version of one microphone. One microphone
// carrying four people is one signal, so whatever it is turned into
// turns all four of them into the same thing and a listener can no
// longer follow who is speaking. A microphone each is the only way
// to give them a voice each.
let mut room = self.studio.wants_a_room();
if ui
.checkbox(&mut room, "several microphones, a guest each")
.on_hover_text(
"Everybody in the room on their own microphone. Each voice is \
veiled separately, into a voice of its own, and the results are \
mixed into the output below. One microphone for four people \
cannot do this: it is one signal, and everybody in it comes out \
as the same person.",
)
.changed()
{
self.studio.want_a_room(room);
}
if room {
self.guest_list(ui);
} else {
device_picker(ui, "input ", &self.inputs, &mut self.chosen_input);
}
device_picker(ui, "output", &self.outputs, &mut self.chosen_output);
});
let routed = self
.chosen_output
.as_ref()
.and_then(|name| self.outputs.iter().find(|d| &d.name == name))
.map(|d| d.is_virtual_cable)
.unwrap_or(false);
if !routed {
ui.label(
RichText::new(
"no virtual cable selected, so other applications will not receive this",
)
.color(p::yellow())
.small(),
);
}
ui.add_space(8.0);
ui.add_enabled_ui(!veiling, |ui| self.settings(ui));
ui.add_space(12.0);
ui.horizontal(|ui| {
if !veiling {
if ui
.button(RichText::new(" start veiling ").strong())
.on_hover_text(
"The veiled voice goes to the output selected above. Nothing is \
kept unless a take is started.",
)
.clicked()
{
let config = self.config();
let input = self.chosen_input.clone();
let output = self.chosen_output.clone();
if self.studio.wants_a_room() {
self.studio.start_room(config, output.as_deref(), false);
} else {
self.studio.start_veiling(
config,
input.as_deref(),
output.as_deref(),
false,
);
}
}
// Listening to yourself before anybody else does.
//
// Same session, one thing different: the veiled voice goes to
// this machine's own output rather than to a virtual cable, so
// it reaches your headphones and nothing else. It is the only
// check that answers the question the meters cannot, which is
// whether the voice coming out is a voice that is not yours.
if ui
.button(" preview to my headphones ")
.on_hover_text(
"Hear yourself veiled. The output goes to this machine's \
speakers or headphones and to nothing else, so nobody on a \
call hears it. Use headphones: speakers plus a microphone \
is a feedback loop.",
)
.clicked()
{
self.start_preview();
}
} else if ui
.button(RichText::new(" stop ").strong())
.on_hover_text(
"Stops the veiling. A take still running is stopped and stored \
first, never discarded.",
)
.clicked()
{
self.studio.stop_veiling();
}
if veiling {
ui.label(
RichText::new(if self.studio.is_previewing() {
"● preview"
} else {
"● live"
})
.color(if self.studio.is_previewing() {
p::yellow()
} else {
p::green()
}),
);
// **Roadmap item 150.** The way to the always-on-top meters, where
// the session is started rather than only in Settings.
//
// This is the moment somebody is about to put a call or a
// stream in front of this window, which is the moment the
// meters are about to disappear behind it. A setting they
// have to go looking for afterwards is a setting they find
// after the call.
if self.preferences.live_monitor() != crate::monitor::Style::OnTop
&& ui
.button(RichText::new("keep the meters on top").small())
.on_hover_text(
"Puts the two levels in a small window above other windows, so \
they stay visible with a call or streaming software in front. \
Closing that window brings the strip back.",
)
.clicked()
{
self.preferences
.set_live_monitor(crate::monitor::Style::OnTop);
}
}
});
// **Roadmap item 132.** What the platform said about the streams, where the
// person is looking when they are veiling. This used to go to standard
// error, which on Windows is a console the window does not have, so a
// microphone unplugged mid-call said nothing at all.
if let Some(trouble) = self.studio.trouble() {
ui.add_space(10.0);
ui.label(
RichText::new(if trouble.device_gone {
format!(
"The {} device this was using is gone. Nothing is reaching the \
engine from it, and this will not come back on its own: choose \
a device and start again.",
trouble.side.word()
)
} else {
format!(
"The {} stream reported: {}",
trouble.side.word(),
trouble.said
)
})
.color(p::red()),
);
if trouble.count > 1 {
ui.label(
RichText::new(format!(
" {} so far this session.",
crate::studio::counted_interruptions(trouble.count)
))
.small()
.color(p::muted()),
);
}
// The limit, beside the report rather than after it. This is what
// VeilVoice's own path noticed happening to it; it is not a
// statement about the machine, and a microphone that was lying
// before this program opened it lies here too.
ui.label(
RichText::new(
" This is what VeilVoice's own audio path noticed. It cannot vouch \
for a microphone that was already being intercepted before this \
opened it.",
)
.small()
.color(p::muted()),
);
}
let intruders: Vec<&str> = self.studio.intruders().collect();
if !intruders.is_empty() {
ui.add_space(6.0);
ui.label(
RichText::new(format!(
"Something else took the microphone while this take was running: {}. \
That program heard your real voice.",
intruders.join(", ")
))
.color(p::yellow()),
);
}
// What the bars cannot say, in the same words wherever they are drawn.
const WHAT_A_METER_IS_WORTH: &str =
" These say sound is arriving and sound is leaving. They cannot say the \
voice has been changed: a working meter and a bypassed engine draw the \
same bar. Listen to the output to hear that.";
match &self.reading {
Some(crate::studio::Reading::One(stats)) => {
// The smoothing happens once a frame in `update`, so the strip
// and this panel are the same numbers rather than two readings
// taken a frame apart.
let levels = self.studio.levels();
ui.add_space(12.0);
ui.label(RichText::new("Levels").color(p::blue()).small());
crate::monitor::meter(ui, "in ", levels.input, levels.hold_input);
crate::monitor::meter(ui, "out", levels.output, levels.hold_output);
ui.label(
RichText::new(WHAT_A_METER_IS_WORTH)
.small()
.color(p::muted()),
);
ui.add_space(12.0);
ui.label(RichText::new("Performance").color(p::blue()).small());
field(
ui,
"processing",
&format!("{:.2} ms/block", stats.process.ema_block_ms()),
);
field(
ui,
"engine latency",
&format!("{:.1} ms", stats.process.algorithmic_latency_ms),
);
field(
ui,
"realtime factor",
&format!("{:.3}", stats.process.last_realtime_factor()),
);
if stats.dropped > 0 || stats.starved > 0 {
ui.label(
RichText::new(format!(
"glitches: {} dropped, {} starved",
stats.dropped, stats.starved
))
.color(p::yellow())
.small(),
);
}
}
// **Roadmap item 147.** Two bars per guest, and the cost of running all
// of them, which is the honest account the row asked for.
Some(crate::studio::Reading::Room(stats)) => {
let levels = self.studio.guest_levels();
ui.add_space(12.0);
ui.label(RichText::new("Levels").color(p::blue()).small());
for (index, pair) in levels.iter().enumerate() {
let name = self
.studio
.room_guests()
.get(index)
.map(|guest| guest.called(index))
.unwrap_or_else(|| format!("guest {}", index + 1));
ui.label(RichText::new(name).color(p::cyan()).small());
crate::monitor::meter(ui, " in ", pair.input, pair.hold_input);
crate::monitor::meter(ui, " out", pair.output, pair.hold_output);
if let Some(guest) = stats.guests.get(index) {
if guest.dropped > 0 {
ui.label(
RichText::new(format!(
" {} samples dropped: this microphone is running \
faster than the output.",
guest.dropped
))
.color(p::yellow())
.small(),
);
}
}
}
let mix = self.studio.levels();
ui.label(RichText::new("everybody, mixed").color(p::cyan()).small());
crate::monitor::meter(ui, " out", mix.output, mix.hold_output);
ui.label(
RichText::new(WHAT_A_METER_IS_WORTH)
.small()
.color(p::muted()),
);
ui.add_space(12.0);
ui.label(RichText::new("Performance").color(p::blue()).small());
// The one number that says whether this machine can carry this
// room. Every guest's engine runs inside one output callback,
// so they share one deadline and the cost is the sum of them.
field(
ui,
"load",
&format!("{:.0}% of the block", stats.load * 100.0),
);
field(ui, "guests", &format!("{}", stats.guests.len()));
if stats.load >= 0.8 {
ui.label(
RichText::new(
"The engines are using most of the time each block has. Past \
the whole of it the sound breaks up: take a guest out, or \
use a larger frame size.",
)
.color(p::yellow())
.small(),
);
}
if stats.clipped > 0 {
// Never hidden and never limited. A limiter is a dynamics
// processor: it would change the voice, which is the one
// thing this program is careful about.
ui.label(
RichText::new(format!(
"{} blocks went past full scale and were clipped. Several \
people talking at once is several signals added together. \
Nothing here compresses them, because that would be a \
second thing changing the voice: turn the microphones \
down instead.",
stats.clipped
))
.color(p::yellow())
.small(),
);
}
if stats.starved > 0 {
ui.label(
RichText::new(format!("{} starved blocks", stats.starved))
.color(p::yellow())
.small(),
);
}
}
None => {}
}
ui.add_space(14.0);
ui.separator();
// The take, and the vault it goes into. Everything below this line
// needs both passphrases; everything above it does not.
let config = self.config();
let input = self.chosen_input.clone();
let output = self.chosen_output.clone();
self.studio
.tab(ui, config, input.as_deref(), output.as_deref());
}
/// Veil to this machine's own output, and say where it is going.
///
/// The chosen output is deliberately ignored by the session this starts: a
/// preview that went to the virtual cable would be heard by whatever is
/// listening on it, which is the one place somebody checking their setup
/// does not want it to go.
fn start_preview(&mut self) {
let config = self.config();
let input = self.chosen_input.clone();
// A room previews as a room: the mix goes to the headphones instead of
// to the cable, and everything else about it is the same session.
// Previewing a room as one microphone would hear one of the guests.
if self.studio.wants_a_room() {
self.studio.start_room(config, None, true);
} else {
self.studio
.start_veiling(config, input.as_deref(), None, true);
}
if !self.studio.is_veiling() {
// It did not start. The Studio says why, in its own message line,
// and a note claiming to describe a preview that is not running
// would be the second wrong thing on the screen.
return;
}
// **F-84.** The claim is checked rather than asserted.
//
// A preview goes to the default output, and on a machine whose default
// output *is* a virtual cable, whatever is listening on that cable
// hears it. Telling somebody the opposite in the one place they are
// checking their setup is worse than telling them nothing, because
// checking is what they came to do.
let cable = devices::find_virtual_cable().map(|d| d.name);
let default = devices::open(devices::Direction::Output, None)
.ok()
.map(|d| devices::name_of(&d));
let into_cable = cable.is_some() && cable == default;
self.notice = Some(if into_cable {
crate::notify::Notice::warn(
"Preview, but this machine's default output is a virtual cable, \
so whatever is listening on it hears this too.",
)
} else {
crate::notify::Notice::note(
"Preview: the veiled voice is going to this machine's own output \
and nowhere else. Listen for a voice that is not yours.",
)
});
}
/// Ask the safety catch what it makes of what is holding a microphone.
///
/// Called once a frame, straight after the watch feed is drained, because
/// that is where the information arrives. The decision is arithmetic over a
/// list -- see `veilvoice_guard::failsafe` -- so doing it every frame costs
/// nothing and means the answer is never a frame out of date.
///
/// **Closing a program is done here and nowhere else**, and only when the
/// guard has said it may be.
fn check_failsafe(&mut self) {
self.failsafe.posture = self.preferences.failsafe();
if !self.failsafe.posture.is_on() {
self.failsafe_finding = None;
return;
}
// What VeilVoice is itself veiling, so a program on our own cable is
// not mistaken for the accident.
self.failsafe.live = self.studio.is_veiling();
self.failsafe.veiling = self.chosen_output.clone();
let holders: Vec<veilvoice_guard::failsafe::Holder> = self
.watch
.active()
.iter()
.filter(|use_| use_.kind == veilvoice_watch::DeviceKind::Microphone)
.map(|use_| veilvoice_guard::failsafe::Holder {
app: use_.app.clone(),
pid: use_.pid,
device: use_.device.clone(),
})
.collect();
let problems: Vec<String> = self
.watch
.error()
.map(|e| e.to_string())
.into_iter()
.collect();
let finding = self.failsafe.look(&holders, &problems);
// Only act on a *change*, or the same program is closed and reported
// sixty times a second for as long as it takes to die.
let fresh = self.failsafe_finding.as_ref() != Some(&finding);
if fresh {
if let veilvoice_guard::failsafe::Finding::Foreign {
app,
pid,
closeable,
..
} = &finding
{
let words = finding.phrasing();
self.notice = Some(crate::notify::Notice::warn(words.clone()));
if *closeable {
match veilvoice_guard::failsafe::act::close(app, *pid) {
Ok(done) => {
self.failsafe
.record(std::time::SystemTime::now(), app, true, &done)
}
Err(why) => {
self.failsafe
.record(std::time::SystemTime::now(), app, false, &why);
// Said, not swallowed. A guard that tried and could
// not is a different situation from one that did.
self.notice = Some(crate::notify::Notice::warn(format!(
"{words} It could not be closed: {why}"
)));
}
}
} else {
self.failsafe.record(
std::time::SystemTime::now(),
app,
false,
"left alone: protected, or the posture is warn-only",
);
}
}
}
self.failsafe_finding = Some(finding);
}
/// Re-scan on a timer rather than every frame.
/// The always-visible indicator.
fn watch_indicator(&mut self, ui: &mut egui::Ui) {
let support = self.watch.support();
if !(support.microphone || support.camera) {
return;
}
let active = self.watch.active();
if active.is_empty() {
return;
}
let camera = active
.iter()
.any(|u| u.kind == veilvoice_watch::DeviceKind::Camera);
let colour = if camera { p::red() } else { p::yellow() };
let names: Vec<&str> = active.iter().map(|u| u.app.as_str()).collect();
let label = format!(
"* {} IN USE - {}",
if camera { "CAMERA" } else { "MIC" },
names.join(", ")
);
if ui
.label(RichText::new(label).color(colour).small().strong())
.on_hover_text("Open the monitor tab for detail")
.clicked()
{
self.tab = Tab::Watch;
}
}
/// Draw the Watch tab: what is recording the screen, and what is allowed
/// to.
fn watch_tab(&mut self, ui: &mut egui::Ui) {
ui.add_space(4.0);
ui.label(RichText::new("What is listening").color(p::blue()).small());
let support = self.watch.support();
ui.label(RichText::new(support.explanation).color(p::muted()).small());
// An empty list from a platform that cannot see is not good news, and
// must never be allowed to read like it.
if !(support.microphone || support.camera) {
ui.add_space(10.0);
ui.label(
RichText::new(
"This platform exposes no way to tell which application is using \
the microphone or camera, so nothing is shown. That is not the \
same as nothing being active.",
)
.color(p::yellow()),
);
return;
}
if let Some(problem) = self.watch.error() {
ui.label(RichText::new(problem).color(p::red()));
}
ui.add_space(10.0);
let active: Vec<_> = self.watch.active().to_vec();
if active.is_empty() {
ui.label(RichText::new("Nothing is using the microphone or camera.").color(p::green()));
} else {
for entry in &active {
let colour = if entry.kind == veilvoice_watch::DeviceKind::Camera {
p::red()
} else {
p::yellow()
};
ui.horizontal(|ui| {
ui.label(RichText::new("*").color(colour));
ui.label(RichText::new(entry.describe()).color(p::fg()).strong());
ui.label(RichText::new(entry.kind.to_string()).color(colour).small());
});
if let Some(path) = &entry.path {
ui.label(
RichText::new(format!(" {path}"))
.color(p::muted())
.small(),
);
}
if let Some(held) = entry.held_for() {
ui.label(
RichText::new(format!(" held for {}s", held.as_secs()))
.color(p::muted())
.small(),
);
}
ui.add_space(6.0);
}
}
if !self.watch.log().is_empty() {
ui.add_space(14.0);
ui.separator();
ui.label(RichText::new("Recent").color(p::blue()).small());
egui::ScrollArea::vertical()
.max_height(160.0)
.show(ui, |ui| {
for line in self.watch.log().iter().rev() {
ui.label(RichText::new(line).color(p::muted()).small());
}
});
}
}
/// Say so if the last run ended badly, and offer the file.
///
/// A report written to disk that nobody is told about is a report nobody
/// reads. The crash log exists because this application had no way at all
/// to explain a failure -- no console, and an abort on panic -- and leaving
/// its output for the user to stumble across would only half fix that.
///
/// Shown in the about tab rather than as a modal on launch: the previous
/// run failing is worth knowing and is not worth a dialog in front of
/// somebody who has just successfully opened the application.
/// A way to report a fault, whether or not anything has crashed.
///
/// What was here was the crash notice itself, and it was only here. The
/// panel above every tab has taken that over, because About is where
/// somebody goes to read version numbers rather than where they land after
/// a crash. What stays is the standing offer: the version numbers a report
/// needs are directly above this line, and now so is somewhere to send it.
fn report_a_fault(&mut self, ui: &mut egui::Ui) {
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("Something wrong?").color(p::fg()).size(12.0));
ui.hyperlink_to("open an issue", crate::crashreport::ISSUES);
ui.label(
RichText::new(
"The versions above are what a report needs. Nothing is sent \
from here: the link opens your browser.",
)
.color(p::muted())
.size(12.0),
);
});
}
/// Draw the About tab: versions, where files live, and the companion list.
fn about_tab(&mut self, ui: &mut egui::Ui) {
ui.add_space(4.0);
field(ui, "app", env!("CARGO_PKG_VERSION"));
field(ui, "engine", veilvoice_core::VERSION);
field(ui, "audio", veilvoice_audio::VERSION);
field(ui, "metadata", veilvoice_meta::VERSION);
field(ui, "monitor", veilvoice_watch::VERSION);
field(ui, "crypto", veilvoice_crypto::VERSION);
field(ui, "licence", "GPL-3.0-or-later");
// Measured, not described. Somebody reporting that this window is slow
// can now say how slow, and 16 ms and 120 ms are different problems
// with different causes.
field(
ui,
"frame time",
&if self.frame_ms > 0.0 {
format!(
"{:.1} ms ({:.0} a second)",
self.frame_ms,
1000.0 / self.frame_ms.max(0.1)
)
} else {
"not measured yet".to_string()
},
);
// Roadmap item 148. What the window is aiming at, what it got, and how many
// frames missed. The frame time above answers "is drawing slow"; these
// answer "is it drawing as often as the screen shows".
field(
ui,
"frame rate",
&match self.pace.target() {
crate::pace::Target::Display => match self.pace.display_hz() {
Some(hz) => format!("the display, measured at {hz} a second"),
None => "the display, not measured yet".to_string(),
},
crate::pace::Target::Fixed(hz) => format!("{hz} a second, chosen in Settings"),
},
);
field(
ui,
"frames drawn",
&if self.pace.fps() > 0.0 {
format!(
"{:.0} a second, {} arrived late",
self.pace.fps(),
self.pace.dropped_total()
)
} else {
"not measured yet".to_string()
},
);
// Precise rather than short. "None" stopped being true the moment the
// update button existed, and a version string that overstates the thing
// it is printed beside is worse than no version screen at all.
field(
ui,
"network access",
"none, except the update check you press",
);
field(
ui,
"typeface",
if self.jetbrains {
"JetBrains Mono"
} else {
"built-in monospace"
},
);
// Both halves, side by side. The request and the result agree on most
// machines and disagree on exactly the ones where somebody is asking
// why the window is slow, and neither line answers that on its own.
field(
ui,
"drawing asked for",
crate::graphics::asked_for(self.preferences.acceleration()),
);
field(ui, "drawing obtained", &self.drawing);
ui.add_space(16.0);
paths_section(ui);
ui.add_space(16.0);
self.updates.section(ui, env!("CARGO_PKG_VERSION"));
ui.add_space(16.0);
ui.label(RichText::new("What this protects").color(p::blue()).small());
ui.label(
RichText::new(
"The biometric voiceprint (pitch, formants, timbre, micro-timing and \
the melody of an accent) is destroyed and cannot be recovered from the \
output. Each frame's measured phase is discarded, and every speaker is \
mapped onto one canonical register and vocal tract.",
)
.color(p::fg()),
);
ui.add_space(12.0);
ui.label(
RichText::new("What it does not do")
.color(p::yellow())
.small(),
);
ui.label(
RichText::new(
"The words are preserved on purpose, so de-identification alone does \
not keep the message secret, which is why the result is encrypted at \
rest by default. Nor can any signal-level transform change which \
phonemes you produced, so a strong regional accent may still be \
audible even though its melody is gone.",
)
.color(p::fg()),
);
ui.add_space(12.0);
ui.label(RichText::new("The app lock").color(p::yellow()).small());
ui.label(RichText::new(veilvoice_crypto::lock::SCOPE).color(p::fg()));
ui.add_space(16.0);
self.policy.panel(ui);
self.report_a_fault(ui);
}
}
/// Where this copy is keeping things, on this machine.
///
/// Read out of the same functions the rest of the application calls, so the
/// panel cannot say one thing while the program does another. See
/// [`crate::paths`] for what is listed and for the one path that is
/// deliberately not.
fn paths_section(ui: &mut egui::Ui) {
ui.label(
RichText::new("Where this copy keeps things")
.color(p::blue())
.small(),
);
ui.label(
RichText::new(
"Worked out on this machine rather than written down, and different on \
every platform. The settings folder is the one to back up.",
)
.color(p::muted())
.small(),
);
ui.add_space(4.0);
ui.label(
RichText::new(crate::paths::arrangement())
.color(p::yellow())
.small(),
);
ui.add_space(6.0);
for place in crate::paths::all() {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{:<18}", place.label)).color(p::muted()));
match &place.path {
Some(path) => {
// Selectable, because the point of showing a path is that
// somebody can go to it, and typing one out from a
// screenshot is how the wrong directory gets deleted.
let mut text = path.display().to_string();
ui.add(
egui::TextEdit::singleline(&mut text)
.desired_width(560.0)
// No box around it: this is a path to read and
// copy, not a field to fill in. `frame` takes the
// frame itself since egui 0.36, where it used to
// take a flag.
.frame(egui::Frame::NONE)
.text_color(p::cyan()),
);
}
// Said rather than left blank. This system not saying where a
// configuration directory is has a consequence, which is that
// nothing here is kept between runs.
None => {
ui.label(
RichText::new("this system does not say, so nothing is kept here")
.color(p::yellow()),
);
}
}
});
ui.indent(place.label, |ui| {
ui.label(RichText::new(place.note).color(p::muted()).small());
});
}
}
/// A dropdown of devices that keeps working when the chosen one disappears.
fn device_picker(
ui: &mut egui::Ui,
label: &str,
devices: &[devices::DeviceInfo],
chosen: &mut Option<String>,
) {
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(p::muted()));
let current = chosen.clone().unwrap_or_else(|| "system default".into());
egui::ComboBox::from_id_salt(label)
.width(360.0)
.selected_text(RichText::new(current).color(p::cyan()))
.show_ui(ui, |ui| {
ui.selectable_value(chosen, None, "system default");
for device in devices {
let mut text = device.name.clone();
if device.is_virtual_cable {
text.push_str(" · virtual cable");
}
ui.selectable_value(chosen, Some(device.name.clone()), text);
}
});
});
}
/// One labelled read-only value, in the shape the About tab uses throughout.
fn field(ui: &mut egui::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 header_layout_tests {
use super::*;
/// **Finding F-178.** The lock button is the theme picker's height.
///
/// Measured from the widgets rather than from a photograph: the capture
/// scripts photograph a window with no app lock set, and the lock button
/// is only drawn when there is one, so no screenshot this repository
/// produces contains the control in question.
///
/// The theme is installed first. Without it the default spacing makes
/// every control the same height and the test would pass while measuring
/// nothing about this application.
#[test]
fn the_lock_button_is_the_theme_pickers_height() {
let ctx = egui::Context::default();
crate::theme::install(&ctx);
let mut picker = egui::Rect::NOTHING;
let mut button = egui::Rect::NOTHING;
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
picker = egui::ComboBox::from_id_salt("header-theme")
.selected_text(RichText::new("Tokyo Night").small())
.width(crate::layout::LOCK_WIDTH)
.show_ui(ui, |_| {})
.response
.rect;
button = header_button(ui, "lock", p::yellow(), picker.height()).rect;
});
});
});
});
assert!(
picker.height() > 0.0 && button.height() > 0.0,
"nothing was drawn"
);
assert!(
(picker.height() - button.height()).abs() < 0.5,
"the two controls are different heights: {:.1} against {:.1}",
picker.height(),
button.height()
);
assert!(
(picker.center().y - button.center().y).abs() < 0.5,
"the lock button sits {:.1} pixels off the picker's middle",
picker.center().y - button.center().y
);
// **Finding F-196.** And the same width, which is the half the first
// fix left out and the half that was still visible.
assert!(
(picker.width() - button.width()).abs() < 0.5,
"the two controls are different widths: {:.1} against {:.1}",
picker.width(),
button.width()
);
}
/// And the shape this corrects, so the assertion above is known to be able
/// to fail: a button that works its own height out from padding is a pixel
/// shorter than the picker beside it.
#[test]
fn a_button_left_to_size_itself_does_not_match_the_picker() {
let ctx = egui::Context::default();
crate::theme::install(&ctx);
let mut picker = egui::Rect::NOTHING;
let mut bare = egui::Rect::NOTHING;
let _ = crate::headless_frame(&ctx, Default::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
picker = egui::ComboBox::from_id_salt("header-theme")
.selected_text(RichText::new("Tokyo Night").small())
.width(crate::layout::LOCK_WIDTH)
.show_ui(ui, |_| {})
.response
.rect;
bare = ui.button(RichText::new("lock").small()).rect;
});
});
});
});
assert!(
(bare.height() - picker.height()).abs() >= 0.5,
"the two are the same height without being made to match, so the \
test above would pass either way: {:.1} against {:.1}",
bare.height(),
picker.height()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **Roadmap item 79.** Nothing that waits happens on the thread that draws.
///
/// A window stutters for one of two reasons: it is asked to draw too
/// rarely, or it is doing something slow between frames. The second is the
/// one that cannot be tuned away, and it is invisible in a screenshot: the
/// window simply stops for as long as the call takes.
///
/// So the draw path is read for the calls that wait. Everything this
/// application does that can block already runs on its own thread and
/// reports back through a channel: the file dialogs after seven of them
/// froze the window, the update check, the verifier, the group render, the
/// key derivation. This keeps that true rather than assuming it.
///
/// Comments are stripped first, for the reason the lock screen's guard
/// gives: the first version of a test like this flags its own explanation.
#[test]
fn the_drawing_thread_never_waits_on_anything() {
let source = include_str!("app.rs").replace("\r\n", "\n");
let body: String = source
.split("#[cfg(test)]")
.next()
.unwrap()
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
// The draw path is `update` and everything it reaches. Device
// enumeration is the one filesystem-shaped call in this file and it
// lives in `Default`, which runs once before the window opens.
let update_at = body.find("fn ui(&mut self").expect("update exists");
let drawing = &body[update_at..];
for waits in [
"Command::new",
"std::fs::read",
"std::fs::write",
"read_to_string",
"join()",
"thread::sleep",
"devices::list",
// Each of these is a stat syscall, which is cheap on a warm local
// disk and is not cheap on a network share or a sleeping drive.
// One per frame at 60 Hz is sixty of them a second for an answer
// that changed when a file was dropped, which is where the check
// that needs them lives. Added after reading the draw path for
// roadmap item 79 and finding none, so this keeps it that way rather
// than fixing something.
".exists()",
".is_file()",
".is_dir()",
"fs::metadata",
"read_dir(",
"canonicalize(",
] {
assert!(
!drawing.contains(waits),
"the draw path calls {waits:?}, which waits. Move it to a thread \
and report back through a channel, as everything else here does."
);
}
// The channels are drained without blocking. Counted rather than
// searched for, because `try_recv()` contains `recv()` and the first
// version of this reported the correct call as the fault.
let blocking = drawing.matches("recv()").count() - drawing.matches("try_recv()").count();
assert_eq!(
blocking, 0,
"a channel on the draw path is read with a blocking recv; use try_recv"
);
}
/// An untouched window draws nothing.
///
/// # The measurement this is here to keep
///
/// With the animations off and nobody touching it, the window drew **2.1
/// frames a second on every one of the nine tabs, for ever**, and cost 7
/// to 9 per cent of a core doing it. After this it draws none, and costs
/// 0.2 per cent. Measured on the same machine, twenty seconds a tab, with
/// `VEILVOICE_FRAME_LOG=1` counting the frames and `/proc` counting the
/// time.
///
/// The cause was a pair of reasonable-looking decisions meeting. The
/// microphone monitor sent an update on every poll whether or not
/// anything had changed, and this file woke the window twice a second to
/// ask the channel whether anything had arrived. Each half is the sort of
/// thing that reads fine in review. Together they are a program that never
/// sleeps.
///
/// The rule now is that the thread with the news asks for the repaint,
/// because it is the only thing that knows there is any. This checks the
/// window is not asking on a timer instead.
#[test]
fn the_window_does_not_wake_itself_to_check_on_the_monitor() {
let source = include_str!("app.rs").replace("\r\n", "\n");
// The code only. This file is read by this test, and the name in the
// assertion below is itself a match: the first version of this test
// failed on its own message.
let source = source.split("\n#[cfg(test)]").next().unwrap();
let update_at = source.find("fn ui(&mut self").expect("update exists");
let drawing = &source[update_at..];
assert!(
!drawing.contains("self.watch.is_watching()"),
"the draw path asks whether the monitor is running so it can wake \
on a timer. An idle window then never stops drawing: this cost \
2.1 frames a second on every tab. The monitor thread asks for a \
repaint when it has something to report."
);
}
/// The user guide describes the application that exists.
///
/// It said "Five tabs" and documented five, and there are nine. The four
/// it left out were **group**, **verify**, **settings** and **install** --
/// among them the verify tab, which is the one this project tells people
/// to use before running a download it has just told them not to trust.
///
/// This is the fourth finding of one shape in this repository: a document
/// describing the program, with nothing comparing the two. F-71 was two
/// hand-typed copies of a number, F-101 a page linking files that were
/// never published, F-110 an example the parser refused. The remedy is
/// always the same one, and this is it for the guide.
///
/// Each tab gets a heading of its own, named for the key the tab answers
/// to, so `veilvoice-gui --tab verify` and the section explaining that tab
/// cannot come apart. A tab added without a section fails the build here.
#[test]
fn the_user_guide_documents_every_tab() {
let guide = include_str!("../../../docs/USER_GUIDE.md").replace("\r\n", "\n");
let headings: Vec<&str> = guide
.lines()
.filter(|line| line.starts_with("### "))
.map(|line| line.trim_start_matches("### ").trim())
.collect();
let missing: Vec<&str> = Tab::ALL
.iter()
.map(|tab| tab.key())
.filter(|key| {
!headings
.iter()
.any(|heading| heading.to_ascii_lowercase().contains(*key))
})
.collect();
assert!(
missing.is_empty(),
"docs/USER_GUIDE.md has no section for these tabs: {}. Every tab \
the window shows needs one, named for the key it answers to, or \
the guide describes a different application from the one that \
ships.",
missing.join(", ")
);
}
/// A count of the tabs, written out in the guide, is a second copy of a
/// fact and drifts from the first. It already did: "Five tabs", nine tabs.
#[test]
fn the_user_guide_does_not_count_the_tabs_by_hand() {
/// The number words, in order, so a written count can be compared with
/// the list it is a copy of.
///
/// This used to be a list of *wrong* counts to forbid, which is the
/// same mistake one level up: the list had to be edited every time the
/// number changed, and roadmap item 130 took a tab away and made "ten" both
/// the truth and one of the forbidden words. Reading the number and
/// comparing it needs no maintenance at all.
const WORDS: &[&str] = &[
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve", "thirteen", "fourteen", "fifteen",
];
for (file, text) in [
(
"docs/USER_GUIDE.md",
include_str!("../../../docs/USER_GUIDE.md"),
),
("README.md", include_str!("../../../README.md")),
] {
let text = text.replace("\r\n", "\n").to_lowercase();
for (n, word) in WORDS.iter().enumerate() {
let counted = format!("{word} tabs");
if !text.contains(&counted) {
continue;
}
assert_eq!(
n,
Tab::ALL.len(),
"{file} says {counted:?} and the window shows {}. A number \
typed beside a list is a copy of the list's length, and it \
goes stale the first time a tab is added or taken away.",
Tab::ALL.len()
);
}
}
}
/// The tab names `veilvoice-gui --help` lists have to be the tab names
/// that exist.
///
/// Written after the first version of that help text named `watch`,
/// `security` and no `install`, when the keys are `monitor`, `lock` and
/// `install`. Three wrong names in the one place somebody reads to find
/// out what the right ones are, and the manual page is generated from that
/// text, so the error would have shipped inside the package as well.
#[test]
fn the_help_text_lists_the_tabs_that_exist() {
let usage = include_str!("main.rs")
.split("const USAGE: &str = \"\\\n")
.nth(1)
.and_then(|rest| rest.split("\";").next())
.expect("the usage text has to be findable");
for tab in Tab::ALL {
assert!(
usage.contains(tab.key()),
"`--help` does not mention the {:?} tab, whose name is {:?}",
tab,
tab.key()
);
}
}
/// Roadmap item 92. A job running is not the window being used.
///
/// The tempting version of an idle timer treats "something is happening" as
/// "somebody is here", and it is exactly backwards for this program:
/// somebody who starts a long render and walks away has walked away, and
/// the recording being produced is the thing worth locking away.
#[test]
fn a_running_job_does_not_count_as_using_the_window() {
let source = include_str!("app.rs").replace("\r\n", "\n");
let start = source
.find("let (touched, dt) = ctx.input(")
.expect("the idle check exists");
let end = source[start..]
.find("self.poll_job();")
.map(|at| start + at)
.unwrap_or(source.len());
let body = &source[start..end];
for excuse in ["self.job", "is_busy()", "session.is_some()"] {
assert!(
!body.contains(excuse),
"the idle timer consults {excuse:?}, so walking away from a \
running job would hold the window unlocked"
);
}
assert!(
body.contains("self.security.lock_after_idle()"),
"nothing actually locks the window"
);
assert!(
!body.contains("self.security.lock_now()"),
"the idle path must use lock_after_idle, or the lock screen cannot tell \
somebody the window locked itself rather than that they left it locked"
);
}
/// Roadmap item 83. An unanswered hidden-volume question must stop the job, not
/// quietly redirect it back beside the source file. A user who believes
/// their recording went into a vault and finds it next to the original is
/// the failure the whole question exists to prevent.
#[test]
fn an_unanswered_vault_question_blocks_the_job_rather_than_redirecting_it() {
let source = include_str!("app.rs").replace("\r\n", "\n");
let start = source
.find("let ready = self.input.is_some()")
.expect("the gate exists");
let gate = &source[start..start + 400];
assert!(
gate.contains("self.storage.destination.ready()"),
"the start button ignores whether the destination is cleared for use"
);
}
/// Roadmap item 75. The record has to be taken without anybody knowing to ask,
/// and it has to wait for the passphrase when there is one to wait for.
#[test]
fn the_integrity_record_runs_itself_at_launch_and_again_at_unlock() {
let source = include_str!("app.rs").replace("\r\n", "\n");
let start = source
.find("pub fn new(cc:")
.expect("the constructor exists");
let end = source[start..]
.find("\n /// Bring the controls")
.map(|at| start + at)
.unwrap_or(source.len());
let constructor = &source[start..end];
assert!(
constructor.contains("app.integrity.start(None)"),
"nothing takes the record at launch, so it is still a command \
somebody has to know to run"
);
assert!(
constructor.contains("if !app.security.has_lock()"),
"a sealed record cannot be read before the passphrase exists, so \
the launch run has to stand aside when a lock is set"
);
let update_at = source.find("fn ui(&mut self").expect("update exists");
let drawing = &source[update_at..];
assert!(
drawing.contains("self.security.take_unlock_passphrase()"),
"the unlock is the one moment the sealing passphrase exists and \
nothing collects it"
);
}
/// Every tab has a name, they are unique, and they round trip. The
/// screenshot tool names each picture after one of these, so a change here
/// renames a file the README links to.
#[test]
fn every_tab_has_a_stable_unique_name() {
let mut keys: Vec<&str> = Tab::ALL.iter().map(|tab| tab.key()).collect();
let count = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), count, "two tabs share a name");
// No count is written here. `the_user_guide_does_not_count_the_tabs_by_hand`
// in this same module says why: a number typed beside a list is a copy
// of the list's length and goes stale the first time a tab is added.
// This assertion did exactly that, said nine, and failed the moment
// Studio and Browser arrived. What is worth asserting is that there
// are tabs at all, and that the tour and the guide cover every one,
// which the tests either side of this one already do.
assert!(!keys.is_empty(), "the window shows no tabs at all");
for tab in Tab::ALL {
assert_eq!(Tab::from_key(tab.key()), Some(*tab));
assert_eq!(Tab::from_key(&tab.key().to_uppercase()), Some(*tab));
}
assert_eq!(Tab::from_key("nothing-like-this"), None);
assert_eq!(Tab::from_key(""), None);
// **Roadmap item 130.** `live` is not a tab any more and is still a name
// people have in shortcuts, in scripts and in the older manual page.
// It opens the tab that does that job now rather than failing.
assert_eq!(Tab::from_key("live"), Some(Tab::Studio));
assert!(
!Tab::ALL.iter().any(|tab| tab.key() == "live"),
"`live` answers, but it is not a tab of its own any more"
);
}
/// Every tab is introduced, and the tour introduces nothing that is gone.
///
/// The tour reads as a list of tabs, and a list of tabs written by hand
/// beside a list of tabs written by hand is two lists that drift. This is
/// what stops a tab being added with no sentence to explain it, which is
/// the failure that matters: an unexplained tab looks the same as an
/// explained one until somebody opens it.
#[test]
fn every_tab_has_a_tour_card_and_every_card_has_a_tab() {
let tabs: Vec<&str> = Tab::ALL.iter().map(|tab| tab.key()).collect();
for tab in &tabs {
assert!(
crate::tour::CARDS.iter().any(|(key, _, _)| key == tab),
"the {tab} tab has no tour card, so nothing tells anybody what it is"
);
}
for (key, _, _) in crate::tour::CARDS {
assert!(
tabs.contains(key),
"the tour introduces a {key} tab that does not exist"
);
}
}
/// Device selection is tested against synthetic lists, never the machine.
/// See `preferred_output` for why that is not merely tidier.
fn device(name: &str, default: bool, cable: bool) -> devices::DeviceInfo {
devices::DeviceInfo {
name: name.to_string(),
is_default: default,
is_virtual_cable: cable,
}
}
/// The bar and the number beside it have to be saying the same thing.
///
/// They did not: the bar was filled linearly and the number was decibels,
/// so at ordinary speech the number said -12 and the bar showed a quarter.
/// Both now come from `veilvoice_audio::meter`, and this is the assertion
/// that keeps them there.
#[test]
fn the_bar_and_the_number_agree_about_the_level() {
use veilvoice_audio::meter;
for peak in [0.0f32, 0.001, 0.06, 0.251, 0.5, 0.9, 1.0] {
let along = meter::position(peak);
let db = meter::dbfs(peak);
// The bar's fill is `position`; the number is `dbfs`. One is an
// affine map of the other, so if they ever stop agreeing this fails.
let from_db = ((db - meter::FLOOR_DB) / -meter::FLOOR_DB).clamp(0.0, 1.0);
assert!(
(along - from_db).abs() < 1e-6,
"peak {peak}: bar at {along}, number says {db} dBFS"
);
}
// And the thing the old meter got wrong, stated outright.
assert!(
meter::position(0.251) > 0.7,
"speech at -12 dBFS must fill most of the bar"
);
}
#[test]
fn defaults_are_the_safe_ones() {
let app = VeilVoiceApp::without_devices();
assert!(
app.neutralise_accent,
"accent neutralisation should default on"
);
assert!(app.clean_metadata, "metadata stripping should default on");
assert_eq!(app.intensity, 1.0);
assert_eq!(app.reseed_secs, 2.0, "the seed should roll by default");
assert!(
!app.studio.is_veiling(),
"nothing should be veiling before anybody has asked for it"
);
assert!(
app.security.encrypt_recordings,
"recordings should be encrypted at rest by default"
);
}
/// The default is only worth anything if the button honours it: with
/// encryption on and nothing to encrypt with, a job must not start.
#[test]
fn a_job_cannot_start_before_the_at_rest_choice_is_made() {
let app = VeilVoiceApp::without_devices();
assert!(!app.security.ready_to_write());
assert!(app.security.blocked_reason().is_some());
}
#[test]
fn config_reflects_the_controls() {
let app = VeilVoiceApp {
intensity: 0.5,
neutralise_accent: false,
reseed_secs: 5.0,
..VeilVoiceApp::without_devices()
};
let cfg = app.config();
assert_eq!(cfg.intensity, 0.5);
assert!(!cfg.accent.enabled);
assert_eq!(cfg.reseed_secs, 5.0);
cfg.checked()
.expect("every value the sliders can reach must be valid");
}
/// The slider's whole range must produce a configuration the engine
/// accepts, or a user could drag it into an error.
#[test]
fn every_reachable_reseed_setting_is_valid() {
let mut app = VeilVoiceApp::without_devices();
for step in 0..=30 {
app.reseed_secs = step as f32;
app.config()
.checked()
.unwrap_or_else(|e| panic!("reseed_secs={step} rejected: {e}"));
}
}
/// A virtual cable must win, because routing there is the whole point of
/// live mode. Every arrangement, none of them involving real hardware.
#[test]
fn a_virtual_cable_is_preferred_over_the_system_default() {
let cable = device("CABLE Input (VB-Audio Virtual Cable)", false, true);
let speakers = device("Speakers", true, false);
let other = device("HDMI", false, false);
assert_eq!(
preferred_output(&[speakers.clone(), cable.clone(), other.clone()]).as_deref(),
Some(cable.name.as_str()),
"a cable must beat the system default"
);
assert_eq!(
preferred_output(&[other.clone(), speakers.clone()]).as_deref(),
Some("Speakers"),
"with no cable, the default"
);
// With neither a cable nor a default, the picker stays on "system
// default" rather than seizing on an arbitrary device. Output is not
// input here, and deliberately so: guessing an input wrong means the
// user hears nothing and fixes it, while guessing an *output* wrong
// means the veiled voice is quietly playing out of the wrong device.
assert_eq!(
preferred_output(std::slice::from_ref(&other)),
None,
"an arbitrary output must not be seized on"
);
assert_eq!(
preferred_output(&[]),
None,
"an empty machine chooses nothing"
);
}
#[test]
fn the_default_input_is_preferred_then_the_first() {
let default = device("Microphone", true, false);
let first = device("Line In", false, false);
assert_eq!(
preferred_input(&[first.clone(), default.clone()]).as_deref(),
Some("Microphone")
);
assert_eq!(
preferred_input(std::slice::from_ref(&first)).as_deref(),
Some("Line In"),
"with no default, the first is better than nothing"
);
assert_eq!(preferred_input(&[]), None);
}
/// A policy that fixes the engine settings must reach the *job*, not just
/// the widgets. The fields are deliberately left loose here: if `config`
/// read them directly, this would fail.
#[test]
fn a_policy_constrains_the_settings_a_job_actually_uses() {
let mut policy = veilvoice_policy::Policy::new();
policy.require(veilvoice_policy::Requirement::NeutraliseAccent);
policy.require(veilvoice_policy::Requirement::MinimumIntensity(80));
policy.require(veilvoice_policy::Requirement::CleanMetadata);
let app = VeilVoiceApp {
intensity: 0.1,
neutralise_accent: false,
clean_metadata: false,
policy: InForce::from_policy(policy),
..VeilVoiceApp::without_devices()
};
let config = app.config();
assert!(
(config.intensity - 0.8).abs() < 1e-6,
"the floor must reach the engine: {}",
config.intensity
);
assert!(
config.accent.enabled,
"a required accent neutralisation must reach the engine"
);
assert!(
app.posture().clean_metadata,
"a required metadata strip must reach the job"
);
config
.checked()
.expect("a constrained configuration must still be a valid one");
}
/// Whatever the sliders say, a policy may only ever make the result more
/// thoroughly processed. Checked across the slider's whole range.
#[test]
fn a_policy_never_loosens_what_a_job_would_do() {
let mut policy = veilvoice_policy::Policy::new();
policy.require(veilvoice_policy::Requirement::MinimumIntensity(60));
for step in 0..=10 {
let asked = step as f32 / 10.0;
let app = VeilVoiceApp {
intensity: asked,
neutralise_accent: false,
policy: InForce::from_policy(policy.clone()),
..VeilVoiceApp::without_devices()
};
assert!(
app.config().intensity >= asked,
"asked for {asked}, got {}",
app.config().intensity
);
}
}
/// The at-rest requirement is pinned in the state, not merely drawn
/// disabled -- and pinning it must also close the dialogue that turns it
/// off, which is reachable from more than one frame's worth of state.
#[test]
fn a_required_encryption_is_pinned_rather_than_only_disabled() {
let mut policy = veilvoice_policy::Policy::new();
policy.require(veilvoice_policy::Requirement::EncryptRecordings);
let mut app = VeilVoiceApp {
policy: InForce::from_policy(policy),
..VeilVoiceApp::without_devices()
};
app.security.encrypt_recordings = false;
// The integrity record, started before anything is drawn and finished
// on its own thread. With an app lock set this run is skipped: the
// record is sealed under the app-lock passphrase, so it can only be
// read once that passphrase exists, and `poll` starts it again the
// moment the window unlocks.
if !app.security.has_lock() {
app.integrity.start(None);
}
app.apply_policy();
assert!(app.security.encryption_pinned);
assert!(app.security.encrypt_recordings);
assert!(app.posture().encrypt_recordings);
}
/// A required lock is announced and never imposed: VeilVoice cannot set a
/// lock, because that needs a passphrase only the user has.
#[test]
fn a_required_lock_is_announced_and_not_imposed() {
let mut policy = veilvoice_policy::Policy::new();
policy.require(veilvoice_policy::Requirement::AppLock);
let mut app = VeilVoiceApp {
policy: InForce::from_policy(policy),
..VeilVoiceApp::without_devices()
};
// The integrity record, started before anything is drawn and finished
// on its own thread. With an app lock set this run is skipped: the
// record is sealed under the app-lock passphrase, so it can only be
// read once that passphrase exists, and `poll` starts it again the
// moment the window unlocks.
if !app.security.has_lock() {
app.integrity.start(None);
}
app.apply_policy();
assert!(app.security.lock_required);
assert!(
!app.security.has_lock(),
"nothing may invent a lock the user did not set"
);
assert!(
!app.security.is_locked(),
"and the application must stay usable"
);
}
/// With no policy -- the ordinary case -- nothing is pinned and nothing is
/// raised.
#[test]
fn without_a_policy_nothing_is_fixed() {
let mut app = VeilVoiceApp {
intensity: 0.25,
neutralise_accent: false,
clean_metadata: false,
..VeilVoiceApp::without_devices()
};
// The integrity record, started before anything is drawn and finished
// on its own thread. With an app lock set this run is skipped: the
// record is sealed under the app-lock passphrase, so it can only be
// read once that passphrase exists, and `poll` starts it again the
// moment the window unlocks.
if !app.security.has_lock() {
app.integrity.start(None);
}
app.apply_policy();
assert!(!app.security.encryption_pinned);
// Nothing is *fixed*, which is what this test is about: no policy is in
// force, so nothing is beyond the user's reach.
assert!(!app.policy.requires(&veilvoice_policy::Requirement::AppLock));
// The lock is still asked for, by the user's own baseline rather than
// by a policy. That is a prompt they can answer or turn off, not a
// control taken away from them, and the two must not be conflated.
assert!(app.security.lock_required);
assert!(app.security.mandate_requires_app_lock());
assert_eq!(app.intensity, 0.25);
assert!(!app.neutralise_accent);
assert!(!app.clean_metadata);
assert_eq!(app.config().intensity, 0.25);
}
/// The one test that talks to the machine's audio stack. Kept single, and
/// last: enumerating devices from several test threads at once on a
/// headless runner is what produced an access violation in CI.
#[test]
fn building_the_app_with_real_device_enumeration_does_not_panic() {
let app = VeilVoiceApp::default();
// Whatever the machine has, the choice must be one of its own devices.
if let Some(chosen) = app.chosen_output.as_deref() {
assert!(app.outputs.iter().any(|d| d.name == chosen));
}
if let Some(chosen) = app.chosen_input.as_deref() {
assert!(app.inputs.iter().any(|d| d.name == chosen));
}
}
}