crates/veilvoice-gui/src/prefs.rs

what this file is for · veilvoice-gui · 730 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
//! What the user has chosen about how the app looks and moves.
//!
//! # Nothing here is secret, and nothing here is required
//!
//! Preferences are a convenience. Every field has a working default, a missing
//! file is not an error, and a corrupt one is not either -- it falls back to
//! the defaults and says so in the settings panel rather than refusing to
//! start. An app that will not open because its preferences file has a stray
//! byte in it has turned a cosmetic setting into an outage.
//!
//! This is deliberately *not* written through
//! [`veilvoice_crypto::privatefile`]. That module exists for files whose
//! contents are sensitive, and using it here would blur a distinction worth
//! keeping: your choice of colour scheme is not a secret, and treating it like
//! one would make the real protections look like decoration.
//!
//! # The format
//!
//! One `key = value` per line, ASCII, with `#` comments -- readable and
//! editable with any text editor, for the same reason the integrity manifest
//! is a text format. No parser dependency, and no way for a malformed file to
//! do anything more interesting than be ignored.
//!
//! # Animations
//!
//! On by default, and switchable off in two places: the settings panel, and
//! the `VEILVOICE_NO_ANIMATION` environment variable, which wins over the file
//! so that a machine which struggles with them can be fixed without opening
//! the UI that is struggling.
//!
//! The system's own "reduce motion" setting is honoured above both. Someone who
//! has told their operating system they do not want movement has already
//! answered this question, and a privacy tool asking again -- and defaulting to
//! yes -- would be ignoring them.
//!
//! # In plain words
//!
//! What you have chosen about how VeilVoice looks and behaves, kept in a small
//! text file.
//!
//! Nothing in it is secret and nothing in it is required: delete the file and the
//! application opens with its defaults. You can read it and edit it by hand.
//!
//! A setting this version does not recognise falls back to the default rather than
//! stopping the program, and where the safe direction matters, the default is the
//! one that keeps a protection on.

use std::path::{Path, PathBuf};


/// Everything the user can choose about presentation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Prefs {
    /// Stable identifier of the colour scheme, e.g. `tokyo-night`.
    pub theme: String,
    /// Whether transitions and easing run at all.
    pub animations: bool,
    /// Whether the header mark animates as a soundbar.
    pub animated_icon: bool,
    /// Whether the first-run panel has been answered.
    pub configured: bool,
    /// The tabs the tour has already covered, comma separated.
    ///
    /// The tab list rather than a "seen" flag or a version number. A flag
    /// cannot answer the question an upgrade asks, and a version answers it
    /// only indirectly: it says that something changed, where this says what.
    /// Empty means the tour has never run.
    pub toured_tabs: String,
    /// Whether the window asks the platform for a hardware-drawn context.
    ///
    /// **Roadmap item 137.** On, and the setting exists for the machines where on is
    /// the wrong answer. Asking is already the safe direction: `Preferred`
    /// takes a software context when no GPU one is available, so a virtual
    /// machine, a remote desktop or a server with no card still opens.
    ///
    /// What it cannot survive is a driver that *accepts* the request and then
    /// draws badly, which is a real class of machine: a hybrid-graphics laptop
    /// handing over the wrong adapter, or a driver whose OpenGL path is broken
    /// in a way that shows as a black window rather than as a refusal. Nothing
    /// here can detect that, because from inside the process it looks like
    /// success. So it is one setting, off in one place, and the About tab shows
    /// what was asked for beside what the driver actually gave.
    pub acceleration: bool,
    /// Whether the install tab is hidden even on a portable copy.
    ///
    /// The tab already disappears once VeilVoice is installed -- an installed
    /// program offering to install itself is a tab that can only mislead. This
    /// is for somebody who runs the portable copy on purpose and does not want
    /// to be asked again.
    pub hide_install_tab: bool,
    /// Whether the app opens with group mode already on.
    ///
    /// The *mode itself* is deliberately not stored. Group mode changes what a
    /// recording is treated as, and a mode that survives a restart is a mode
    /// somebody eventually forgets is on -- which for this tool means a
    /// single-speaker recording rendered against a plan that does not describe
    /// it. So the toggle is per-run, and this separate, explicit tick is the
    /// only way it starts on.
    pub always_group: bool,
    /// Whether every recording is sealed with the app-lock passphrase.
    ///
    /// **Roadmap item 86.** Persisted, unlike group mode, and the difference is what
    /// happens when somebody forgets it is on. A forgotten group mode renders a
    /// single speaker against a plan that does not describe them, which is
    /// wrong output. A forgotten sealing mode encrypts a file that would
    /// otherwise have been encrypted some other way, which is not. The
    /// direction it can fail in is the safe one, so it survives a restart and
    /// the user is not asked to choose it again every launch.
    pub seal_with_app_lock: bool,
    /// The encrypted folder veiled recordings are written into, or empty.
    ///
    /// **Roadmap items 82 to 84.** Three fields rather than one, because the hidden
    /// state has to survive a restart alongside the path: a destination whose
    /// answer was forgotten would ask again, and a user asked the same question
    /// every launch stops reading it.
    pub vault_dir: String,
    /// Which tool that folder belongs to: `cryptomator` or `veracrypt`.
    pub vault_tool: String,
    /// The answer to the hidden-volume question. Anything unrecognised, or
    /// missing, reads as unanswered and blocks writing.
    pub vault_hidden: String,
    /// Whether the window locks itself after a period of no use.
    ///
    /// **Roadmap item 92.** On at half an hour, and the reason it is no longer off is
    /// in [`crate::autolock`]: a default nobody is shown is an answer rather
    /// than a question, and the answer it used to give was no protection at
    /// all to everybody who never opened this tab.
    pub autolock: bool,
    /// How long that period is, in seconds.
    pub autolock_after: u64,
    /// The bottom of the range the interface offers, in seconds.
    pub autolock_floor: u64,
    /// The top of it.
    pub autolock_ceiling: u64,
    /// How notifications are shown: `overlay`, `alert` or `off`.
    ///
    /// Stored as its key rather than as a number, so a settings file stays
    /// readable and so reordering the enum cannot silently change somebody's
    /// choice -- which for the value `off` would mean turning their warnings
    /// back on, or worse, off.
    pub notify_style: String,
    /// Where the live monitor sits: `toolbar`, `overlay` or `off`.
    ///
    /// Stored as its key, like the others. An unreadable value reads back as
    /// the default, which **shows** it: the monitor is the only picture of a
    /// live microphone available from a tab that is not the live one, and a
    /// settings file this build cannot parse must not be the reason it is
    /// missing.
    pub live_monitor: String,
    /// The Failsafe posture: `close`, `warn` or `off`.
    ///
    /// Stored as its key. An unreadable value reads back as the **default**,
    /// which is on -- a settings file this build cannot parse must never be
    /// the reason the safety catch is off.
    pub failsafe: String,
    /// Frames a second to aim for while something is moving. Zero means the
    /// display's own rate, measured rather than asked for: see
    /// [`crate::pace`].
    pub frame_rate: u32,
    /// Whether the header carries a live frame-rate readout.
    pub show_frame_rate: bool,
    /// Set when the file on disk could not be understood, so the settings
    /// panel can say the defaults are in force and why. Never persisted.
    pub recovered_from_corrupt_file: bool,
}


impl Default for Prefs {

    fn default() -> Self {
        Self {
            theme: "tokyo-night".to_string(),
            // On by default, as asked. The system's reduce-motion setting still
            // overrides this at the point of use -- see `Motion`.
            animations: true,
            animated_icon: true,
            configured: false,
            toured_tabs: String::new(),
            acceleration: true,
            hide_install_tab: false,
            always_group: false,
            seal_with_app_lock: false,
            vault_dir: String::new(),
            vault_tool: String::new(),
            vault_hidden: String::new(),
            autolock: true,
            autolock_after: crate::autolock::DEFAULT_SECS,
            autolock_floor: crate::autolock::FLOOR_SECS,
            autolock_ceiling: crate::autolock::CEILING_SECS,
            notify_style: crate::notify::Style::default().key().to_string(),
            // On, and docked. Veiling as it runs is the mode where what is being
            // protected is happening now, and the two questions a person has
            // are "is it hearing me" and "is anything coming out". Both are
            // answered by a strip that is already on screen.
            live_monitor: crate::monitor::Style::default().key().to_string(),
            failsafe: veilvoice_guard::failsafe::Posture::default()
                .key()
                .to_string(),
            // The display's own rate. A number here would be a guess about
            // somebody else's monitor, and the whole point of `pace` is that
            // the window measures it instead.
            frame_rate: 0,
            // Off: a number that changes sixty times a second in the corner of
            // a privacy tool is a distraction for everybody who is not
            // diagnosing it.
            show_frame_rate: false,
            recovered_from_corrupt_file: false,
        }
    }

}


/// Where preferences live: beside the app lock, in this platform's config
/// directory. `None` when the environment does not say where that is, in which
/// case the app runs on defaults and simply does not persist them.
pub fn default_path() -> Option<PathBuf> {
    veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("settings.conf"))
}


impl Prefs {

    /// Read preferences from `path`.
    ///
    /// Never fails. A missing file gives the defaults; an unreadable or
    /// unparseable one gives the defaults with
    /// [`recovered_from_corrupt_file`](Self::recovered_from_corrupt_file) set,
    /// so the UI can be honest about it.
    pub fn load(path: &Path) -> Self {
        let Ok(text) = std::fs::read_to_string(path) else {
            // Missing is the ordinary case on a first run, and is not worth
            // reporting. An unreadable file is reported below only if it
            // parses to nothing useful, because the distinction the user cares
            // about is "are my settings in force", not "which syscall failed".
            return Self::default();
        };
        Self::parse(&text)
    }



    /// Parse the `key = value` format. Unknown keys are ignored, so a file
    /// written by a newer build still works in an older one.
    pub fn parse(text: &str) -> Self {
        let mut prefs = Self::default();
        let mut understood = 0usize;
        let mut lines = 0usize;

        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            lines += 1;
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            let key = key.trim();
            let value = value.trim();
            match key {
                "theme" => {
                    // Only accept a theme this build actually has. An unknown
                    // one keeps the default rather than leaving the app
                    // pointing at a scheme that does not exist.
                    if crate::theme::by_id(value).is_some() {
                        prefs.theme = value.to_string();
                        understood += 1;
                    }
                }
                "animations" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.animations = on;
                        understood += 1;
                    }
                }
                "animated_icon" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.animated_icon = on;
                        understood += 1;
                    }
                }
                "toured_tabs" => {
                    prefs.toured_tabs = value.to_string();
                    understood += 1;
                }
                "configured" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.configured = on;
                        understood += 1;
                    }
                }
                "acceleration" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.acceleration = on;
                        understood += 1;
                    }
                }
                "hide_install_tab" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.hide_install_tab = on;
                        understood += 1;
                    }
                }
                "failsafe" => {
                    // Through `from_key`, so an unrecognised value lands on the
                    // default rather than being stored and acted on. The
                    // default is on.
                    prefs.failsafe = veilvoice_guard::failsafe::Posture::from_key(value)
                        .key()
                        .to_string();
                }
                "notify_style" => {
                    // Anything unrecognised becomes the default rather than an
                    // error, and the default shows something. A file this build
                    // cannot read must never be the reason a warning is silent.
                    prefs.notify_style = crate::notify::Style::from_key(value).key().to_string();
                }
                "live_monitor" => {
                    // Through `from_key`, so anything unrecognised lands on the
                    // default, which shows the monitor.
                    prefs.live_monitor = crate::monitor::Style::from_key(value).key().to_string();
                }
                "always_group" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.always_group = on;
                        understood += 1;
                    }
                }
                "seal_with_app_lock" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.seal_with_app_lock = on;
                        understood += 1;
                    }
                }
                "vault_dir" => {
                    prefs.vault_dir = value.to_string();
                    understood += 1;
                }
                "vault_tool" => {
                    prefs.vault_tool = value.to_string();
                    understood += 1;
                }
                "vault_hidden" => {
                    prefs.vault_hidden = value.to_string();
                    understood += 1;
                }
                "autolock" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.autolock = on;
                        understood += 1;
                    }
                }
                "frame_rate" => {
                    // Clamped on the way in, so a hand-edited file cannot ask
                    // for one frame a second or ten thousand. Zero is the
                    // display and is left alone.
                    if let Ok(hz) = value.parse::<u32>() {
                        prefs.frame_rate = if hz == 0 {
                            0
                        } else {
                            hz.clamp(crate::pace::DISPLAY_FLOOR, crate::pace::DISPLAY_CEILING)
                        };
                        understood += 1;
                    }
                }
                "show_frame_rate" => {
                    if let Some(on) = parse_bool(value) {
                        prefs.show_frame_rate = on;
                        understood += 1;
                    }
                }
                "autolock_after" => {
                    if let Ok(secs) = value.parse() {
                        prefs.autolock_after = secs;
                        understood += 1;
                    }
                }
                "autolock_floor" => {
                    if let Ok(secs) = value.parse() {
                        prefs.autolock_floor = secs;
                        understood += 1;
                    }
                }
                "autolock_ceiling" => {
                    if let Ok(secs) = value.parse() {
                        prefs.autolock_ceiling = secs;
                        understood += 1;
                    }
                }
                _ => {}
            }
        }

        // A file with content, none of which we could use, is a corrupt file.
        // An empty one is just an empty one.
        prefs.recovered_from_corrupt_file = lines > 0 && understood == 0;
        prefs
    }



    /// Serialise to the text format.
    pub fn to_text(&self) -> String {
        let mut out = String::new();
        out.push_str("# VeilVoice settings. Plain text on purpose: edit it, or delete\n");
        out.push_str("# it to go back to the defaults. Nothing here is secret.\n");
        out.push_str(&format!("theme = {}\n", self.theme));
        out.push_str(&format!("animations = {}\n", self.animations));
        out.push_str(&format!("animated_icon = {}\n", self.animated_icon));
        out.push_str(&format!("configured = {}\n", self.configured));
        out.push_str(&format!("toured_tabs = {}\n", self.toured_tabs));
        out.push_str(&format!("acceleration = {}\n", self.acceleration));
        out.push_str(&format!("hide_install_tab = {}\n", self.hide_install_tab));
        out.push_str(&format!("always_group = {}\n", self.always_group));
        out.push_str(&format!(
            "seal_with_app_lock = {}\n",
            self.seal_with_app_lock
        ));
        out.push_str(&format!("vault_dir = {}\n", self.vault_dir));
        out.push_str(&format!("vault_tool = {}\n", self.vault_tool));
        out.push_str(&format!("vault_hidden = {}\n", self.vault_hidden));
        out.push_str(&format!("autolock = {}\n", self.autolock));
        out.push_str(&format!("autolock_after = {}\n", self.autolock_after));
        out.push_str(&format!("autolock_floor = {}\n", self.autolock_floor));
        out.push_str(&format!("autolock_ceiling = {}\n", self.autolock_ceiling));
        out.push_str(&format!("notify_style = {}\n", self.notify_style));
        out.push_str(&format!("live_monitor = {}\n", self.live_monitor));
        out.push_str(&format!("failsafe = {}\n", self.failsafe));
        out.push_str(&format!("frame_rate = {}\n", self.frame_rate));
        out.push_str(&format!("show_frame_rate = {}\n", self.show_frame_rate));
        out
    }



    /// Write preferences to `path`, creating the directory if needed.
    ///
    /// Returns the reason on failure so the settings panel can show it. A
    /// failure here must never be fatal: the choice still applies for this
    /// session, it simply will not be remembered.
    pub fn save(&self, path: &Path) -> Result<(), String> {
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
            }
        }
        // Owner-only. This file is not just a theme choice: it records
        // `vault_dir`, which names an encrypted volume, and `vault_hidden`,
        // which is the answer to whether that volume has a hidden one inside
        // it. Somebody reading this learns that a hidden volume exists, which
        // is the single thing a hidden volume is for concealing, and they
        // learn where to look.
        veilvoice_crypto::privatefile::write_owner_only(path, self.to_text().as_bytes())
            .map_err(|e| e.to_string())
    }

}


fn parse_bool(value: &str) -> Option<bool> {
    match value.to_ascii_lowercase().as_str() {
        "true" | "yes" | "on" | "1" => Some(true),
        "false" | "no" | "off" | "0" => Some(false),
        _ => None,
    }
}



/// Whether movement is allowed, and how much.
///
/// Resolved once per frame from three inputs, in order of authority:
///
/// 1. **The operating system's reduce-motion setting.** Someone who has told
///    their system they do not want movement has answered this already.
/// 2. **`VEILVOICE_NO_ANIMATION`**, so a machine that struggles with animation
///    can be fixed without opening the interface that is struggling.
/// 3. **The preference**, which is on by default.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Motion {
    /// Whether anything may move at all.
    pub enabled: bool,
    /// Whether the header mark may animate.
    pub icon: bool,
    /// Whether the system asked for reduced motion, so the UI can say that is
    /// why the toggle is doing nothing rather than appearing broken.
    pub system_reduced: bool,
}


impl Motion {

    /// Resolve for this frame.
    pub fn resolve(prefs: &Prefs, system_reduced_motion: bool) -> Self {
        let env_off = std::env::var_os("VEILVOICE_NO_ANIMATION")
            .map(|v| v != "0" && !v.is_empty())
            .unwrap_or(false);
        let enabled = prefs.animations && !env_off && !system_reduced_motion;
        Self {
            enabled,
            icon: enabled && prefs.animated_icon,
            system_reduced: system_reduced_motion,
        }
    }



    /// A duration scaled by whether motion is allowed.
    ///
    /// Returns zero when it is not, so a caller can pass this straight to an
    /// easing function and get an instant result rather than having to branch
    /// at every call site. That matters: a branch nobody wrote is how a stray
    /// animation survives the toggle.
    pub fn secs(&self, wanted: f32) -> f32 {
        if self.enabled {
            wanted
        } else {
            0.0
        }
    }

}

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

    #[test]
    fn the_defaults_are_the_documented_ones() {
        let d = Prefs::default();
        assert_eq!(d.theme, "tokyo-night");
        assert!(d.animations, "animations are on by default, as specified");
        assert!(d.animated_icon, "the animated icon is on by default");
        assert!(
            !d.configured,
            "a fresh install has not answered the first run"
        );
    }

    #[test]
    fn it_round_trips_through_its_text_format() {
        let prefs = Prefs {
            theme: "gruvbox".into(),
            animations: false,
            animated_icon: false,
            configured: true,
            toured_tabs: String::new(),
            acceleration: true,
            hide_install_tab: true,
            always_group: true,
            seal_with_app_lock: false,
            vault_dir: String::new(),
            vault_tool: String::new(),
            vault_hidden: String::new(),
            autolock: true,
            autolock_after: 8 * 3_600,
            autolock_floor: 30,
            autolock_ceiling: 7 * 86_400,
            notify_style: "alert".to_string(),
            failsafe: "warn".to_string(),
            live_monitor: "toolbar".to_string(),
            frame_rate: 144,
            show_frame_rate: true,
            recovered_from_corrupt_file: false,
        };
        let back = Prefs::parse(&prefs.to_text());
        assert_eq!(back, prefs);
    }

    #[test]
    fn a_hand_edited_frame_rate_is_clamped_and_zero_is_left_alone() {
        // The file is plain text on purpose and says so, so somebody will
        // edit it. One frame a second and ten thousand are both answers the
        // window must not act on.
        assert_eq!(
            Prefs::parse("frame_rate = 1\n").frame_rate,
            crate::pace::DISPLAY_FLOOR
        );
        assert_eq!(
            Prefs::parse("frame_rate = 10000\n").frame_rate,
            crate::pace::DISPLAY_CEILING
        );
        assert_eq!(
            Prefs::parse("frame_rate = 0\n").frame_rate,
            0,
            "zero is the display"
        );
        assert_eq!(Prefs::parse("frame_rate = 144\n").frame_rate, 144);
        // Nonsense leaves the default in place rather than being stored.
        assert_eq!(
            Prefs::parse("frame_rate = fast\n").frame_rate,
            Prefs::default().frame_rate
        );
    }

    #[test]
    fn every_boolean_spelling_people_actually_type_is_accepted() {
        for on in ["true", "yes", "on", "1", "TRUE", "Yes"] {
            assert!(
                Prefs::parse(&format!("animations = {on}")).animations,
                "{on} should mean on"
            );
        }
        for off in ["false", "no", "off", "0", "FALSE", "Off"] {
            assert!(
                !Prefs::parse(&format!("animations = {off}")).animations,
                "{off} should mean off"
            );
        }
    }

    /// A settings file must never be able to stop the app starting.
    #[test]
    fn hostile_and_broken_files_fall_back_to_the_defaults() {
        for text in [
            "",
            "\0\0\0\0",
            "theme",
            "= = = =",
            "theme = ",
            "theme = ../../etc/passwd",
            "theme = <script>alert(1)</script>",
            "animations = perhaps",
            &"a".repeat(100_000),
            "[section]\nkey: value",
        ] {
            let prefs = Prefs::parse(text);
            // Whatever it said, the result is usable.
            assert!(
                crate::theme::by_id(&prefs.theme).is_some(),
                "parsing {:?} left an unknown theme: {}",
                &text[..text.len().min(30)],
                prefs.theme
            );
        }
    }

    /// A file whose every line was rejected should say so, so the settings
    /// panel can explain why the defaults are in force.
    #[test]
    fn a_wholly_unreadable_file_is_reported_rather_than_hidden() {
        assert!(Prefs::parse("nonsense\nmore nonsense").recovered_from_corrupt_file);
        assert!(!Prefs::parse("").recovered_from_corrupt_file);
        assert!(!Prefs::parse("# just a comment").recovered_from_corrupt_file);
        assert!(!Prefs::parse("theme = nord").recovered_from_corrupt_file);
    }

    /// A newer build's keys must not break an older one.
    #[test]
    fn unknown_keys_are_ignored_not_fatal() {
        let prefs = Prefs::parse("theme = nord\nsomething_new = 42\nanimations = false");
        assert_eq!(prefs.theme, "nord");
        assert!(!prefs.animations);
        assert!(!prefs.recovered_from_corrupt_file);
    }

    #[test]
    fn a_missing_file_is_not_an_error() {
        let prefs = Prefs::load(std::path::Path::new("no-such-settings-file-anywhere.conf"));
        assert_eq!(prefs, Prefs::default());
    }

    #[test]
    fn saving_and_loading_round_trips_through_a_real_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("settings.conf");
        let prefs = Prefs {
            theme: "dracula".into(),
            animations: false,
            animated_icon: true,
            configured: true,
            toured_tabs: String::new(),
            notify_style: "overlay".into(),
            failsafe: "close".into(),
            live_monitor: "toolbar".into(),
            acceleration: true,
            hide_install_tab: false,
            always_group: false,
            seal_with_app_lock: true,
            vault_dir: "/media/veracrypt1".into(),
            vault_tool: "veracrypt".into(),
            vault_hidden: "none".into(),
            autolock: true,
            autolock_after: 3_600,
            autolock_floor: 60,
            autolock_ceiling: 7 * 86_400,
            frame_rate: 0,
            show_frame_rate: false,
            recovered_from_corrupt_file: false,
        };
        prefs.save(&path).unwrap();
        assert_eq!(Prefs::load(&path), prefs);

        // And readable only by this account. The file records `vault_dir` and
        // `vault_hidden`: where an encrypted volume is, and whether it has a
        // hidden one inside it. The existence of a hidden volume is the single
        // thing a hidden volume conceals, and this was written 0644.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
            assert_eq!(
                mode, 0o600,
                "settings are {mode:o}, so anyone here can read where the vault is"
            );
        }
    }

    /// The system's reduce-motion setting outranks the preference. Someone who
    /// asked their OS for less movement has already answered.
    #[test]
    fn the_system_setting_wins_over_the_preference() {
        let on = Prefs {
            animations: true,
            animated_icon: true,
            ..Default::default()
        };
        let motion = Motion::resolve(&on, true);
        assert!(!motion.enabled, "the system asked for reduced motion");
        assert!(!motion.icon);
        assert!(motion.system_reduced, "the UI must be able to say why");
        assert_eq!(motion.secs(0.4), 0.0);

        let motion = Motion::resolve(&on, false);
        assert!(motion.enabled);
        assert!(motion.icon);
        assert_eq!(motion.secs(0.4), 0.4);
    }

    #[test]
    fn the_icon_can_be_stilled_without_stilling_everything_else() {
        let prefs = Prefs {
            animations: true,
            animated_icon: false,
            ..Default::default()
        };
        let motion = Motion::resolve(&prefs, false);
        assert!(motion.enabled, "other animation is unaffected");
        assert!(!motion.icon);
    }

    #[test]
    fn the_settings_file_sits_beside_the_app_lock() {
        if let (Some(prefs), Some(lock)) = (default_path(), veilvoice_crypto::lock::default_path())
        {
            assert_eq!(prefs.parent(), lock.parent());
            assert!(prefs.ends_with("settings.conf"));
        }
    }
}