crates/veilvoice-gui/src/reduced_motion.rs

what this file is for · veilvoice-gui · 348 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
//! Whether the operating system has been asked to reduce motion.
//!
//! # Why this exists rather than an egui call
//!
//! egui does not surface the platform's accessibility preference, so it has to
//! be read here. The website gets this for free -- CSS has
//! `prefers-reduced-motion` and the browser answers it -- and it would be odd
//! for the desktop app to be the one front-end that ignores the setting.
//!
//! # Read once, at startup
//!
//! Every platform answers this through a subprocess, and a subprocess per
//! frame would be indefensible in a paint loop. It is read once when the app
//! starts and cached for the session. Someone who changes the setting while
//! VeilVoice is open sees it on the next launch, which is the same behaviour
//! most applications have.
//!
//! # Absolute paths, always
//!
//! `Command::new("defaults")` is a *search*, and on Windows that search
//! includes the current working directory -- which is precisely the defect
//! (F-13) this project fixed in `veilvoice-watch` and `veilvoice-guard`. Every
//! tool here is named by absolute path, and an unfound tool answers "I do not
//! know" rather than falling back to a search.
//!
//! # When it cannot tell
//!
//! [`Query::Unknown`] means the platform was not asked or did not answer, and
//! the caller treats that as "no reduction requested" -- because defaulting to
//! *off* would silently disable animation for everybody on a platform this
//! cannot read, which is a worse failure than missing the preference for the
//! few who set it. The settings panel only claims the system asked for reduced
//! motion when it actually saw it say so.
//!
//! # In plain words
//!
//! Asks the operating system whether you have said you would rather things did not
//! animate.
//!
//! Some people get motion sickness from moving interfaces, and every system has a
//! setting for it. Honouring it is not decoration: an application that animates
//! regardless is one those people cannot comfortably use.
//!
//! When the answer cannot be determined, animation stays on, and the setting can
//! be overridden by hand either way.

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

/// Spawn without a console window.
///
/// On Windows a `Command` for a console program creates a console, and when the
/// parent is a GUI process with none of its own, Windows opens a **window** for
/// it -- appearing and vanishing as the child runs. That is what "a cmd prompt
/// flashing randomly" was: once at startup, and again on every poll.
///
/// `CREATE_NO_WINDOW` suppresses it. `creation_flags` is a **safe** API, so
/// this costs nothing against `#![forbid(unsafe_code)]`.
///
/// Every `Command::new` in this crate goes through here, and a test asserts it
/// -- because "no console window appeared" is not observable from a test, which
/// is exactly why the defect shipped.
// `mut` is only needed where the body below is compiled in. Everywhere
// else the parameter is moved straight through, and `-D warnings` in CI
// rejects the unused `mut` -- which only the Linux runner can see.

#[cfg_attr(not(windows), allow(unused_mut))]
fn no_window(mut command: Command) -> Command {
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        command.creation_flags(CREATE_NO_WINDOW);
    }
    command
}



/// What the platform said.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Query {
    /// The system asked for reduced motion.
    Reduce,
    /// The system is happy with animation.
    Allow,
    /// Not asked, or no usable answer.
    Unknown,
}


impl Query {

    /// Whether to treat this as a request to reduce motion.
    ///
    /// `Unknown` is *not* a reduction: see the module note.
    pub fn reduces(self) -> bool {
        matches!(self, Query::Reduce)
    }

}


/// Resolve a tool to an absolute path. Never searches `PATH`.
fn tool(directories: &[&str], name: &str) -> Option<PathBuf> {
    for directory in directories {
        if directory.is_empty() {
            continue;
        }
        let candidate = Path::new(directory).join(name);
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}



/// Ask the operating system. Called once, at startup.
pub fn query() -> Query {
    #[cfg(windows)]
    {
        windows_query()
    }
    #[cfg(target_os = "macos")]
    {
        macos_query()
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        unix_query()
    }
    #[cfg(not(any(windows, unix)))]
    {
        Query::Unknown
    }
}



/// Windows: "Show animations in Windows" lives in the `UserPreferencesMask`
/// under `HKCU\Control Panel\Desktop`. It is a little-endian bit field, and
/// **bit 1 of byte 0** is `CLIENTAREAANIMATION` -- set when animation is
/// wanted, clear when the user has switched it off.
#[cfg(windows)]
fn windows_query() -> Query {
    let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
    let Some(reg) = tool(
        &[&format!(r"{root}\System32"), &format!(r"{root}\Sysnative")],
        "reg.exe",
    ) else {
        return Query::Unknown;
    };

    let Ok(output) = no_window(Command::new(reg))
        .args([
            "query",
            r"HKCU\Control Panel\Desktop",
            "/v",
            "UserPreferencesMask",
        ])
        .output()
    else {
        return Query::Unknown;
    };
    if !output.status.success() {
        return Query::Unknown;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    parse_user_preferences_mask(&text)
}



/// Pull the mask out of `reg query` output and read the animation bit.
#[cfg(windows)]
fn parse_user_preferences_mask(text: &str) -> Query {
    let Some(line) = text.lines().find(|l| l.contains("UserPreferencesMask")) else {
        return Query::Unknown;
    };
    // `... REG_BINARY    9E3E078012000000`
    let Some(hex) = line.split_whitespace().last() else {
        return Query::Unknown;
    };
    if hex.len() < 2 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return Query::Unknown;
    }
    let Ok(first_byte) = u8::from_str_radix(&hex[..2], 16) else {
        return Query::Unknown;
    };
    // Bit 1 set means client-area animation is enabled.
    if first_byte & 0b10 != 0 {
        Query::Allow
    } else {
        Query::Reduce
    }
}



/// macOS: the Accessibility "Reduce motion" switch.
#[cfg(target_os = "macos")]
fn macos_query() -> Query {
    let Some(defaults) = tool(&["/usr/bin", "/bin"], "defaults") else {
        return Query::Unknown;
    };
    let Ok(output) = no_window(Command::new(defaults))
        .args(["read", "com.apple.universalaccess", "reduceMotion"])
        .output()
    else {
        return Query::Unknown;
    };
    if !output.status.success() {
        // The key is absent until the switch has been touched, and `defaults`
        // exits non-zero for that. Absent means not reduced.
        return Query::Allow;
    }
    match String::from_utf8_lossy(&output.stdout).trim() {
        "1" | "true" => Query::Reduce,
        "0" | "false" => Query::Allow,
        _ => Query::Unknown,
    }
}



/// Linux and the BSDs: GNOME's `enable-animations`, which the other major
/// desktops have largely adopted as the common key.
#[cfg(all(unix, not(target_os = "macos")))]
fn unix_query() -> Query {
    let Some(gsettings) = tool(&["/usr/bin", "/bin", "/usr/local/bin"], "gsettings") else {
        return Query::Unknown;
    };
    let Ok(output) = no_window(Command::new(gsettings))
        .args(["get", "org.gnome.desktop.interface", "enable-animations"])
        .output()
    else {
        return Query::Unknown;
    };
    if !output.status.success() {
        return Query::Unknown;
    }
    match String::from_utf8_lossy(&output.stdout).trim() {
        "false" => Query::Reduce,
        "true" => Query::Allow,
        _ => Query::Unknown,
    }
}


#[cfg(test)]
mod tests {

    /// Every subprocess in this file must be spawned through `no_window`.
    ///
    /// This reads the file's own source rather than exercising the behaviour,
    /// because "no console window appeared" cannot be observed from a test --
    /// which is precisely why the defect reached a release. A `Command::new`
    /// added later without the wrapper fails here rather than on a desktop.
    #[test]
    fn every_subprocess_is_spawned_without_a_console_window() {
        // A source-reading test, so the line endings have to be settled first.
        // F-72: these searched for "\n}\n" and passed on every machine
        // whose checkout uses LF. GitHub's Windows runners default to
        // core.autocrlf=true, so the file arrives with CRLF, the pattern
        // matches nothing, and three tests failed there and nowhere else --
        // including on the developer machine that had just run them.
        // Normalised here as well as pinned in .gitattributes: a test that
        // depends on a git setting is a test somebody will trip over.
        let source = include_str!("reduced_motion.rs").replace("\r\n", "\n");
        let mut bare = Vec::new();
        for (number, line) in source.lines().enumerate() {
            let trimmed = line.trim_start();
            if trimmed.starts_with("//") || trimmed.starts_with('*') {
                continue; // prose: this rule is discussed in the comments
            }
            if !line.contains("Command::new") || line.contains("no_window(") {
                continue;
            }
            bare.push(format!("line {}: {}", number + 1, trimmed));
        }
        assert!(
            bare.is_empty(),
            "these spawns bypass `no_window`, so each flashes a console window \
             on Windows:\n{}",
            bare.join("\n")
        );
    }
    use super::*;

    /// Whatever this machine says, it must say it without panicking and
    /// without hanging.
    #[test]
    fn the_platform_can_be_asked_without_incident() {
        let started = std::time::Instant::now();
        let answer = query();
        assert!(
            started.elapsed() < std::time::Duration::from_secs(10),
            "the probe took too long to be run at startup"
        );
        // Any of the three is a legitimate answer; the point is that we got one.
        assert!(matches!(
            answer,
            Query::Reduce | Query::Allow | Query::Unknown
        ));
    }

    /// "I do not know" must not switch animation off for everybody on a
    /// platform this cannot read.
    #[test]
    fn not_knowing_is_not_a_request_to_reduce() {
        assert!(!Query::Unknown.reduces());
        assert!(!Query::Allow.reduces());
        assert!(Query::Reduce.reduces());
    }

    #[test]
    fn a_missing_tool_yields_unknown_rather_than_a_search() {
        assert!(tool(&["/definitely/not/here"], "gsettings").is_none());
        assert!(tool(&[""], "reg.exe").is_none());
    }

    #[cfg(windows)]
    #[test]
    fn the_windows_mask_is_read_from_the_right_bit() {
        // Bit 1 of the first byte is CLIENTAREAANIMATION.
        let allow = "    UserPreferencesMask    REG_BINARY    9E3E078012000000";
        assert_eq!(parse_user_preferences_mask(allow), Query::Allow);

        // 0x9C has bit 1 clear: animation switched off.
        let reduce = "    UserPreferencesMask    REG_BINARY    9C3E078012000000";
        assert_eq!(parse_user_preferences_mask(reduce), Query::Reduce);

        // Anything unrecognisable is Unknown, never a guess.
        for bad in [
            "",
            "UserPreferencesMask",
            "    UserPreferencesMask    REG_BINARY    ",
            "    UserPreferencesMask    REG_BINARY    zzzz",
            "    SomethingElse    REG_BINARY    9E3E0780",
        ] {
            assert_eq!(
                parse_user_preferences_mask(bad),
                Query::Unknown,
                "{bad:?} should be Unknown"
            );
        }
    }

    /// The real registry value, on the machine running the test, must parse.
    #[cfg(windows)]
    #[test]
    fn the_real_windows_setting_parses() {
        // Not asserting *which* answer: that is a fact about this machine.
        // Asserting that we did not fall through to Unknown, which would mean
        // the parser no longer understands the format.
        let answer = windows_query();
        assert!(
            matches!(answer, Query::Allow | Query::Reduce),
            "could not read UserPreferencesMask on a real Windows install"
        );
    }
}