crates/veilvoice-gui/src/firstrun.rs

what this file is for · veilvoice-gui · 693 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 first run: the four things worth deciding before anything else.
//!
//! # What this replaced
//!
//! Two checkboxes about animation. Everything that actually matters -- the app
//! lock, the passphrase recordings are encrypted with, whether the window locks
//! itself -- was left to be discovered on a tab most people never opened.
//!
//! That is a defensible choice for a preference and a bad one for a
//! protection. A default nobody is shown is not a question, it is an answer,
//! and for a privacy tool the answer it was quietly giving was "none of it".
//!
//! # What it asks, and what it will not do
//!
//! Four cards, each skippable, each stating what it buys before asking for
//! anything:
//!
//! 1. **Appearance.** The two animation choices, kept from the old panel.
//! 2. **The app lock.** A passphrase for the window, and -- since 0.1.18 --
//!    the key that names and encrypts VeilVoice's own files. The card says
//!    both, and says the sentence that has to be said out loud: forget it and
//!    those files are gone.
//! 3. **The recording passphrase.** What veiled recordings are encrypted
//!    with. Separate from the app lock by default, with the option to use one
//!    passphrase for both and a plain statement of what that trades.
//! 4. **Locking itself.** On at half an hour, with the delay and the off
//!    switch right there.
//!
//! **Nothing here is a gate.** Every card has a way past it, and skipping all
//! four leaves VeilVoice exactly as it was before this module existed. A setup
//! flow that will not let somebody reach the program is a setup flow they
//! resent; this one is a set of offers made at the moment they make sense.
//!
//! The tour runs after it, so a person meets the decisions first and the tabs
//! second, which is the order they matter in.
//!
//! # In plain words
//!
//! The first time you open VeilVoice it offers you a password for the app, a
//! password for your recordings, and a timer that locks the window when you
//! walk away. You can skip any of them and set them later.

use crate::theme::palette as p;
use egui::{RichText, Ui};


/// Which card is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Step {
    /// How the interface should look. First because it is the lightest, and
    /// because somebody who bounces off the setup entirely has still answered
    /// the one question with no security consequence.
    #[default]
    Appearance,
    /// A passphrase for the window, and for VeilVoice's own files.
    AppLock,
    /// A passphrase for the recordings themselves.
    Recording,
    /// Whether the window locks itself, and after how long.
    Autolock,
    /// What this machine actually reports, and the one decision that follows
    /// from it.
    ///
    /// **Roadmap item 135.** Last, because it is the card that reports rather than
    /// asks: somebody who skipped everything else has still been shown where
    /// their recordings will go and how much room there is for them, which are
    /// the two facts a setup screen usually asserts and never measures.
    Machine,
}


impl Step {

    /// The step after this one, or `None` at the end of the tour.
    fn next(self) -> Option<Self> {
        match self {
            Self::Appearance => Some(Self::AppLock),
            Self::AppLock => Some(Self::Recording),
            Self::Recording => Some(Self::Autolock),
            Self::Autolock => Some(Self::Machine),
            Self::Machine => None,
        }
    }



    /// One-based position, for "step 2 of 4".
    fn position(self) -> usize {
        match self {
            Self::Appearance => 1,
            Self::AppLock => 2,
            Self::Recording => 3,
            Self::Autolock => 4,
            Self::Machine => 5,
        }
    }



    const COUNT: usize = 5;

}


/// What the setup is holding while it runs.
#[derive(Default)]
pub struct FirstRun {
    /// The card showing.
    pub step: Step,
    /// Typed app-lock passphrase, and its confirmation.
    lock_entry: String,
    lock_repeat: String,
    /// Typed recording passphrase, and its confirmation.
    rec_entry: String,
    rec_repeat: String,
    /// Whether the recording passphrase should be the app-lock one.
    same_passphrase: bool,
    /// Set once the lock has been asked for, so the card stops offering.
    lock_requested: bool,
}



/// What the panel wants the application to do after drawing.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Outcome {
    /// Still going.
    #[default]
    Continue,
    /// Every card is answered or skipped.
    Finished,
}


impl FirstRun {

    /// Draw the current card.
    ///
    /// Takes the settings and the security state because it changes both, and
    /// returns whether it is done rather than deciding that itself: the caller
    /// owns what happens next, which is the tour.
    pub fn panel(
        &mut self,
        ui: &mut Ui,
        prefs: &mut crate::settings::Settings,
        security: &mut crate::security::Security,
    ) -> Outcome {
        ui.add_space(18.0);
        ui.vertical_centered(|ui| {
            ui.label(
                RichText::new("Setting up VeilVoice")
                    .size(20.0)
                    .color(p::fg())
                    .strong(),
            );
            ui.label(
                RichText::new(format!(
                    "step {} of {}. Every one of them can be skipped, and \
                     changed later in Settings.",
                    self.step.position(),
                    Step::COUNT
                ))
                .small()
                .color(p::muted()),
            );
        });
        ui.add_space(16.0);

        let advance = match self.step {
            Step::Appearance => self.appearance(ui, prefs),
            Step::AppLock => self.app_lock(ui, security),
            Step::Recording => self.recording(ui, security),
            Step::Autolock => self.autolock(ui, prefs),
            Step::Machine => self.machine(ui, prefs),
        };

        if advance {
            match self.step.next() {
                Some(next) => {
                    self.step = next;
                    // A card whose question is already answered elsewhere has
                    // nothing to ask, so it is stepped past rather than shown
                    // with everything greyed out.
                    if self.should_skip(self.step, security) {
                        if let Some(after) = self.step.next() {
                            self.step = after;
                        } else {
                            return Outcome::Finished;
                        }
                    }
                }
                None => return Outcome::Finished,
            }
        }
        Outcome::Continue
    }



    /// Whether a card has nothing left to ask.
    ///
    /// The app lock and the recording passphrase can both be set already --
    /// from the command line, from a previous run, or from a copied
    /// configuration. Asking somebody to set a thing they have set is how a
    /// setup flow teaches people to click through it without reading.
    fn should_skip(&self, step: Step, security: &crate::security::Security) -> bool {
        match step {
            Step::AppLock => security.has_lock(),
            Step::Recording => security.has_recording_passphrase(),
            _ => false,
        }
    }



    /// The appearance step: pick a palette and see it applied immediately.
    fn appearance(&mut self, ui: &mut Ui, prefs: &mut crate::settings::Settings) -> bool {
        card(ui, "How it should look", |ui| {
            ui.label(
                RichText::new(
                    "Both are on. Nothing here leaves your machine, and neither \
                     affects what VeilVoice does to a recording.",
                )
                .color(p::muted()),
            );
            ui.add_space(10.0);
            prefs.first_run_appearance(ui);
        });
        buttons(ui, "continue", None).0
    }



    /// The app-lock step: set a passphrase for VeilVoice itself, or decline
    /// it.
    fn app_lock(&mut self, ui: &mut Ui, security: &mut crate::security::Security) -> bool {
        let mut advance = false;
        card(ui, "A password for VeilVoice itself", |ui| {
            ui.label(RichText::new(
                "It stops somebody who picks up your unlocked computer from \
                 opening VeilVoice, seeing what you have processed, or starting \
                 a live scramble.",
            ));
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "It also encrypts VeilVoice's own files and gives them \
                     meaningless names, with decoy files among them, so the \
                     folder says nothing about what you have done. Without a \
                     password none of that is possible: there is no key.",
                )
                .color(p::muted()),
            );
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "Forget this password and those files are gone. It is not a \
                     lock you can take off; it is the only way back to them.",
                )
                .color(p::yellow()),
            );
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "It is not protection against somebody who has your disk. \
                     For that, use full-volume encryption as well.",
                )
                .small()
                .color(p::muted()),
            );
            ui.add_space(12.0);

            if self.lock_requested {
                ui.label(RichText::new("setting it now…").color(p::muted()));
                if security.has_lock() {
                    advance = true;
                }
                return;
            }

            field(ui, "password", &mut self.lock_entry);
            field(ui, "again", &mut self.lock_repeat);
            let matched = !self.lock_entry.is_empty() && self.lock_entry == self.lock_repeat;
            if !self.lock_entry.is_empty() && !matched {
                ui.label(
                    RichText::new("the two entries differ")
                        .color(p::yellow())
                        .small(),
                );
            }
            ui.add_space(8.0);
            if ui
                .add_enabled(matched, egui::Button::new("set this password"))
                .clicked()
            {
                let entry = std::mem::take(&mut self.lock_entry);
                self.lock_repeat.clear();
                security.set_lock_from_setup(entry);
                self.lock_requested = true;
            }
        });
        let (next, _) = buttons(ui, "skip for now", None);
        advance || next
    }



    /// The recording step: the at-rest passphrase, and what it is separate
    /// from.
    fn recording(&mut self, ui: &mut Ui, security: &mut crate::security::Security) -> bool {
        let mut advance = false;
        card(ui, "A password for your recordings", |ui| {
            ui.label(RichText::new(
                "Veiled recordings are encrypted before they are written. This \
                 is what opens them again.",
            ));
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "A different password from the one above, by default, \
                     because they protect different things: that one guards a \
                     session, this one guards files that outlive it.",
                )
                .color(p::muted()),
            );
            ui.add_space(10.0);

            if security.has_lock() {
                ui.checkbox(
                    &mut self.same_passphrase,
                    "Use the VeilVoice password for recordings too",
                );
                ui.label(
                    RichText::new(
                        "  One password to remember instead of two. What it \
                         trades is that one password then opens the \
                         application and everything it has written.",
                    )
                    .small()
                    .color(p::muted()),
                );
                ui.add_space(8.0);
            }

            if self.same_passphrase {
                security.prefer_app_lock_sealing(true);
                ui.label(
                    RichText::new(
                        "Recordings will be sealed with the VeilVoice password. \
                         Unlock once and it is in hand.",
                    )
                    .color(p::muted()),
                );
                return;
            }

            field(ui, "password", &mut self.rec_entry);
            field(ui, "again", &mut self.rec_repeat);
            let matched = !self.rec_entry.is_empty() && self.rec_entry == self.rec_repeat;
            if !self.rec_entry.is_empty() && !matched {
                ui.label(
                    RichText::new("the two entries differ")
                        .color(p::yellow())
                        .small(),
                );
            }
            ui.add_space(8.0);
            if ui
                .add_enabled(matched, egui::Button::new("use this password"))
                .clicked()
            {
                let entry = std::mem::take(&mut self.rec_entry);
                self.rec_repeat.clear();
                security.set_recording_passphrase(entry);
                advance = true;
            }
        });
        let (next, _) = buttons(ui, "skip for now", None);
        advance || next
    }



    /// What this machine says about itself, and the one choice that follows.
    ///
    /// **Roadmap item 135.** Every number here is read from the machine at the moment
    /// the card is drawn. None of it is a default written into this program: a
    /// setup screen that asserts how much room there is, or that the graphics
    /// will be fine, is guessing on somebody else's hardware and sounding
    /// certain about it.
    ///
    /// Where the machine will not say, the card says that instead. "This system
    /// would not tell us" is a real answer and is a different one from a
    /// number.
    fn machine(&mut self, ui: &mut Ui, prefs: &mut crate::settings::Settings) -> bool {
        card(ui, "What this machine says", |ui| {
            ui.label(
                RichText::new(
                    "Read from this computer just now, rather than assumed. \
                     Nothing here is sent anywhere, and nothing on this card has \
                     to be answered.",
                )
                .color(p::muted()),
            );
            ui.add_space(12.0);

            // Where the recordings will go, and how much room is there for
            // them. The two belong together: a folder nobody can find and a
            // disk with nothing left on it are the same problem to somebody
            // whose recording did not save.
            ui.label(RichText::new("Where recordings will go").color(p::blue()));
            match veilvoice_crypto::lock::default_dir() {
                Some(dir) => {
                    ui.label(RichText::new(dir.display().to_string()).color(p::cyan()));
                    ui.label(
                        RichText::new(match veilvoice_setup::space::free_bytes(&dir) {
                            Some(free) => format!(
                                "{} free there, which is room for about {} of an \
                                 hour's veiled audio.",
                                crate::studio::size(usize::try_from(free).unwrap_or(usize::MAX)),
                                // An hour of 48 kHz mono 16-bit audio, which is
                                // what the recorder writes. Worked out from the
                                // free space rather than stated, so it is this
                                // machine's answer. Unsigned, so it cannot go
                                // below zero and is not clamped as though it
                                // could.
                                free / (48_000 * 2 * 3_600)
                            ),
                            None => "This system would not say how much room is \
                                     free there, so check before a long recording."
                                .to_string(),
                        })
                        .small()
                        .color(p::muted()),
                    );
                }
                None => {
                    ui.label(
                        RichText::new(
                            "This system does not say where an application should \
                             keep its files, so nothing will be kept between runs \
                             and the vault cannot be opened. The About tab says \
                             the same thing in more detail.",
                        )
                        .color(p::red()),
                    );
                }
            }

            ui.add_space(12.0);
            ui.label(RichText::new("Sound devices").color(p::blue()));
            let (inputs, outputs) = device_counts();
            ui.label(
                RichText::new(match (inputs, outputs) {
                    (0, 0) => "None found. Anonymising a file still works; the \
                               live and Studio tabs need a microphone."
                        .to_string(),
                    (0, _) => "No microphone found. Anonymising a file works; \
                               recording does not."
                        .to_string(),
                    (i, o) => format!(
                        "{i} to record from, {o} to play to. The Settings tab \
                         picks which."
                    ),
                })
                .small()
                .color(p::muted()),
            );

            ui.add_space(12.0);
            ui.label(RichText::new("Drawing the window").color(p::blue()));
            let mut accelerated = prefs.acceleration();
            if ui
                .checkbox(
                    &mut accelerated,
                    "Ask the graphics driver to draw the window",
                )
                .changed()
            {
                prefs.set_acceleration(accelerated);
            }
            ui.label(
                RichText::new(
                    "On, and asking is the safe direction: a machine that cannot \
                     give a hardware context is given a software one and the \
                     window still opens. Turn it off if the window is black or \
                     wrong, which happens on some drivers that accept and then \
                     draw badly. It costs speed and nothing else, and the About \
                     tab shows what the driver actually gave.",
                )
                .small()
                .color(p::muted()),
            );
        });
        buttons(ui, "finish", None).0
    }



    /// The auto-lock step: how long idle before the window locks itself.
    fn autolock(&mut self, ui: &mut Ui, prefs: &mut crate::settings::Settings) -> bool {
        card(ui, "Locking itself when you walk away", |ui| {
            ui.label(RichText::new(
                "VeilVoice locks its window again after half an hour with \
                 nobody touching it.",
            ));
            ui.add_space(8.0);
            ui.label(
                RichText::new(
                    "A job running does not count as touching it. If you start \
                     a long render and leave the room, that is exactly when you \
                     would want it locked.",
                )
                .small()
                .color(p::muted()),
            );
            ui.add_space(12.0);
            prefs.first_run_autolock(ui);
        });
        buttons(ui, "finish", None).0
    }

}


/// A bordered card, so each step reads as one thing rather than a page of text.
fn card(ui: &mut Ui, title: &str, contents: impl FnOnce(&mut Ui)) {
    egui::Frame::new()
        .fill(p::bg_dark())
        .stroke(egui::Stroke::new(1.0, p::border()))
        .inner_margin(16.0)
        .corner_radius(6.0)
        .show(ui, |ui| {
            ui.set_max_width(560.0);
            ui.label(RichText::new(title).size(16.0).color(p::fg()).strong());
            ui.add_space(10.0);
            contents(ui);
        });
}



/// How many recording and playback devices this machine has.
///
/// Counted rather than listed on the setup card: the names are long, the list
/// belongs in Settings where it can be chosen from, and the question at first
/// run is "is there one at all", which a number answers.
///
/// A platform that will not enumerate reports zero of each, which the card
/// reads the same way as a machine with no sound card. That is the right
/// reading here: from the person's side, "we cannot see a microphone" and
/// "there is no microphone" have the same consequence.
///
/// # Not called by any test, and that is deliberate
///
/// This asks the platform for its real devices, and a test that did so was
/// **F-165**: the desktop crate's test binary already enumerates once, on
/// purpose, in `app`, and a second enumerator running beside it killed the
/// process on Windows. One enumeration, in one place, is what this crate does.
/// What can be checked without a device is that the card calls this rather than
/// carrying a number, and a test reads the card's source for exactly that.
fn device_counts() -> (usize, usize) {
    use veilvoice_audio::devices::Direction;
    (
        veilvoice_audio::devices::list(Direction::Input)
            .map(|d| d.len())
            .unwrap_or(0),
        veilvoice_audio::devices::list(Direction::Output)
            .map(|d| d.len())
            .unwrap_or(0),
    )
}



/// A password field with its label, laid out like the rest of the application.
fn field(ui: &mut Ui, label: &str, value: &mut String) {
    ui.horizontal(|ui| {
        ui.label(RichText::new(label).color(p::muted()));
        ui.add(
            egui::TextEdit::singleline(value)
                .password(true)
                .desired_width(240.0),
        );
    });
}



/// The row that moves on. Returns whether it was pressed.
fn buttons(ui: &mut Ui, forward: &str, _unused: Option<&str>) -> (bool, bool) {
    ui.add_space(14.0);
    let mut pressed = false;
    crate::layout::centred_row(ui, |ui| {
        pressed = ui.button(forward).clicked();
    });
    (pressed, false)
}


#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_steps_run_in_order_and_then_stop() {
        let mut step = Step::default();
        let mut seen = vec![step];
        while let Some(next) = step.next() {
            step = next;
            seen.push(step);
        }
        assert_eq!(
            seen,
            vec![
                Step::Appearance,
                Step::AppLock,
                Step::Recording,
                Step::Autolock,
                Step::Machine
            ]
        );
        assert_eq!(seen.len(), Step::COUNT);
    }

    #[test]
    fn every_step_knows_where_it_is() {
        let mut step = Step::default();
        let mut expected = 1;
        loop {
            assert_eq!(step.position(), expected);
            match step.next() {
                Some(next) => {
                    step = next;
                    expected += 1;
                }
                None => break,
            }
        }
        assert_eq!(expected, Step::COUNT, "the count and the walk must agree");
    }

    #[test]
    fn a_question_already_answered_is_not_asked_again() {
        let run = FirstRun::default();
        let mut security = crate::security::Security::default();
        assert!(
            !run.should_skip(Step::AppLock, &security),
            "with no lock set, the card has something to ask"
        );
        security.set_recording_passphrase("already chosen".into());
        assert!(
            run.should_skip(Step::Recording, &security),
            "a passphrase set from the command line must not be asked for again"
        );
    }

    #[test]
    fn appearance_and_autolock_are_always_shown() {
        // Neither can be "already answered": both have a default that is a
        // real choice, and both are worth stating once.
        let run = FirstRun::default();
        let security = crate::security::Security::default();
        assert!(!run.should_skip(Step::Appearance, &security));
        assert!(!run.should_skip(Step::Autolock, &security));
    }

    /// **Roadmap item 135's whole point.** The card has to read the machine rather
    /// than carry numbers written here. A constant would be a claim about
    /// somebody else's hardware, stated with the confidence of a measurement.
    #[test]
    fn the_machine_card_measures_rather_than_asserts() {
        let source = include_str!("firstrun.rs");
        let at = source.find("fn machine").expect("the card exists");
        let rest = &source[at..];
        let body = rest.split("\n    fn ").next().unwrap_or(rest);

        for reads in [
            "veilvoice_setup::space::free_bytes",
            "veilvoice_crypto::lock::default_dir",
            "device_counts()",
            "prefs.acceleration()",
        ] {
            assert!(
                body.contains(reads),
                "the card no longer reads {reads}, so it is asserting something \
                 about this machine instead of measuring it"
            );
        }
        // And it says so when the machine will not answer, rather than
        // printing a number it did not get.
        assert!(
            body.contains("would not say"),
            "the card has no answer for a system that will not say how much \
             room is free, so it would show one that was never measured"
        );
    }

    #[test]
    fn nothing_here_is_a_gate() {
        // Every card's source has a way past it. Read from the source rather
        // than by driving egui, which needs a context these tests do not build.
        //
        // The window used to be a fixed four thousand characters after the
        // function's name, which is a length rather than a body: a card longer
        // than that reported no way past it, and a card shorter than that was
        // checked against the one after it as well. It now ends where the
        // function does.
        let source = include_str!("firstrun.rs");
        for card in [
            "fn appearance",
            "fn app_lock",
            "fn recording",
            "fn autolock",
            "fn machine",
        ] {
            let at = source.find(card).expect("every card exists");
            let rest = &source[at..];
            let body = rest.split("\n    fn ").next().unwrap_or(rest);
            assert!(
                body.contains("buttons(ui,"),
                "{card} has no way past it, which makes the setup a gate"
            );
        }
    }
}