crates/veilvoice-watch/src/lib.rs

what this file is for · veilvoice-watch · 423 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
//! # veilvoice-watch
//!
//! Find out which applications are using your microphone and camera, right now.
//!
//! ## Why this belongs in a voice-privacy tool
//!
//! VeilVoice protects the audio you choose to send. This answers a different
//! and more basic question: *is something listening that you did not choose?*
//! A de-identified voice on a call is worth very little if a second program is
//! recording the raw microphone at the same time.
//!
//! Operating systems have grown indicators for this, the orange dot and the
//! taskbar icon, but they are small, easily missed, and tell you only that
//! *something* is active, rarely what. This reports the process, its PID and
//! how long it has held the device.
//!
//! ## What it can actually see, per platform
//!
//! Detection is honest about its limits, because a monitor that quietly sees
//! nothing is worse than no monitor at all, because it produces false confidence.
//! [`support`] reports what the current platform can do before you rely on it.
//!
//! | Platform | Microphone | Camera | How |
//! |---|---|---|---|
//! | Windows | ✅ | ✅ | The same `CapabilityAccessManager` records the OS privacy indicator uses |
//! | Linux | ✅ | ✅ | `/proc/*/fd` handles open on `/dev/snd/pcm*` and `/dev/video*` |
//! | macOS | ❌ | ❌ | No public API exposes it; anything claiming otherwise on macOS is guessing |
//!
//! On Linux you see every process you have permission to inspect. Without root
//! that means your own; other users' processes are invisible, and that is a
//! kernel permission boundary rather than something this crate can work around.
//!
//! # In plain words
//!
//! This tells you when something is using your microphone or camera.
//!
//! Not what it is doing with them -- just that a program has them open, and which
//! program. That is worth knowing before you start talking, and it is the kind of
//! thing an operating system knows and does not always show you.
//!
//! It cannot see everything. Some ways of getting at a microphone do not go past
//! the place this reads.
#![forbid(unsafe_code)]
#![warn(missing_docs)]

use std::fmt;
use std::time::SystemTime;

// The other things this machine is doing that a person being veiled would want
// to know about. Each was a crate of its own; each is a module here, because
// nobody takes "the process list" on its own, and the answer to "what else is
// running" is the whole set rather than any one of them.
pub mod appctl;
pub mod capture;
pub mod drivers;
pub mod input;
pub mod privilege;
pub mod proc;

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "windows")]
mod windows;


/// Crate version string, surfaced in the About panel.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");



/// The kind of device being used.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum DeviceKind {
    /// A microphone or other audio capture device.
    Microphone,
    /// A camera or other video capture device.
    Camera,
}


impl fmt::Display for DeviceKind {

    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Microphone => "microphone",
            Self::Camera => "camera",
        })
    }

}


/// One application holding one device.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeviceUse {
    /// Which kind of device.
    pub kind: DeviceKind,
    /// A readable name for the application.
    pub app: String,
    /// Full path to the executable, where it could be determined.
    pub path: Option<String>,
    /// Process ID, where the platform exposes one.
    ///
    /// Windows reports usage per *application* rather than per process, so this
    /// is often `None` there even though the app is definitely active.
    pub pid: Option<u32>,
    /// When the application started using the device, if known.
    pub since: Option<SystemTime>,
    /// The specific device node or endpoint, where known.
    pub device: Option<String>,
}


impl DeviceUse {

    /// A stable key for comparing two scans, so an app is not reported as
    /// having stopped and restarted when nothing changed.
    pub fn key(&self) -> (DeviceKind, String, Option<u32>) {
        (self.kind, self.app.to_lowercase(), self.pid)
    }



    /// How long this application has held the device.
    pub fn held_for(&self) -> Option<std::time::Duration> {
        self.since
            .and_then(|t| SystemTime::now().duration_since(t).ok())
    }

}


/// What detection is possible here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Support {
    /// Whether microphone use can be detected.
    pub microphone: bool,
    /// Whether camera use can be detected.
    pub camera: bool,
    /// How it works, or why it does not, in one sentence for the user.
    pub explanation: &'static str,
}



/// Report what this platform can detect.
///
/// Check this before showing a monitor. Presenting an empty list as "nothing is
/// listening" on a platform that cannot tell is a false assurance, and this is
/// exactly the kind of tool where that matters.
pub fn support() -> Support {
    #[cfg(target_os = "windows")]
    {
        Support {
            microphone: true,
            camera: true,
            explanation: "Reads the same CapabilityAccessManager records that drive \
                          the Windows privacy indicator.",
        }
    }
    #[cfg(target_os = "linux")]
    {
        Support {
            microphone: true,
            camera: true,
            explanation: "Inspects open file handles under /proc. Without root this \
                          sees your own processes only.",
        }
    }
    #[cfg(not(any(target_os = "windows", target_os = "linux")))]
    {
        Support {
            microphone: false,
            camera: false,
            explanation: "This platform exposes no public interface for which \
                          application is using the microphone or camera, so nothing \
                          is reported rather than something guessed.",
        }
    }
}



/// Everything that can go wrong here.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
    /// The platform provides no way to answer the question.
    Unsupported,
    /// The system refused access to the information.
    Io(std::io::Error),
}


impl From<std::io::Error> for Error {

    fn from(e: std::io::Error) -> Self {
        Self::Io(e)
    }

}

impl fmt::Display for Error {

    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Unsupported => f.write_str(support().explanation),
            Self::Io(e) => write!(f, "could not read system state: {e}"),
        }
    }

}

impl std::error::Error for Error {}


/// Take one snapshot of what is currently using the microphone and camera.
///
/// Returns an empty list when nothing is active, which is only meaningful if
/// [`support`] says this platform can tell.
pub fn scan() -> Result<Vec<DeviceUse>, Error> {
    #[cfg(target_os = "windows")]
    {
        windows::scan()
    }
    #[cfg(target_os = "linux")]
    {
        linux::scan()
    }
    #[cfg(not(any(target_os = "windows", target_os = "linux")))]
    {
        Err(Error::Unsupported)
    }
}



/// A change between two scans.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Change {
    /// An application began using a device.
    Started(DeviceUse),
    /// An application stopped using a device.
    Stopped(DeviceUse),
}


impl Change {

    /// A one-line alert suitable for a notification or an overlay.
    pub fn alert(&self) -> String {
        match self {
            Self::Started(u) => format!("{} is now using your {}", u.describe(), u.kind),
            Self::Stopped(u) => format!("{} stopped using your {}", u.describe(), u.kind),
        }
    }

}

impl DeviceUse {

    /// `name (pid 1234)`, or just the name when there is no PID.
    pub fn describe(&self) -> String {
        match self.pid {
            Some(pid) => format!("{} (PID {pid})", self.app),
            None => self.app.clone(),
        }
    }

}


/// Watches for changes between scans.
///
/// Holds the previous snapshot and reports what appeared or disappeared, so a
/// caller can raise an alert on transitions rather than repeating a list.
#[derive(Debug, Default)]
pub struct Monitor {
    previous: Vec<DeviceUse>,
}


impl Monitor {

    /// A monitor that has not yet seen anything.
    pub fn new() -> Self {
        Self::default()
    }



    /// The most recent snapshot.
    pub fn current(&self) -> &[DeviceUse] {
        &self.previous
    }



    /// Scan, and report what changed since the previous call.
    ///
    /// The first call reports everything already active as `Started`. That is
    /// deliberate: something that was already recording when the monitor opened
    /// is precisely what the user needs to be told about.
    pub fn poll(&mut self) -> Result<Vec<Change>, Error> {
        self.diff(scan()?)
    }



    /// The comparison, split out so it can be tested without a real system.
    fn diff(&mut self, next: Vec<DeviceUse>) -> Result<Vec<Change>, Error> {
        let mut changes = Vec::new();

        for entry in &next {
            if !self.previous.iter().any(|old| old.key() == entry.key()) {
                changes.push(Change::Started(entry.clone()));
            }
        }
        for entry in &self.previous {
            if !next.iter().any(|new| new.key() == entry.key()) {
                changes.push(Change::Stopped(entry.clone()));
            }
        }

        self.previous = next;
        Ok(changes)
    }

}

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

    fn use_of(kind: DeviceKind, app: &str, pid: Option<u32>) -> DeviceUse {
        DeviceUse {
            kind,
            app: app.to_string(),
            path: None,
            pid,
            since: None,
            device: None,
        }
    }

    #[test]
    fn a_first_poll_reports_everything_already_active() {
        // Something already recording when the monitor opens is the single most
        // important thing to surface, not something to treat as the baseline.
        let mut monitor = Monitor::new();
        let changes = monitor
            .diff(vec![use_of(DeviceKind::Microphone, "zoom", Some(42))])
            .unwrap();
        assert_eq!(changes.len(), 1);
        assert!(matches!(changes[0], Change::Started(_)));
    }

    #[test]
    fn an_unchanged_list_reports_nothing() {
        let mut monitor = Monitor::new();
        let snapshot = vec![use_of(DeviceKind::Microphone, "zoom", Some(42))];
        monitor.diff(snapshot.clone()).unwrap();
        assert!(
            monitor.diff(snapshot).unwrap().is_empty(),
            "no change, no alert"
        );
    }

    #[test]
    fn starting_and_stopping_are_both_reported() {
        let mut monitor = Monitor::new();
        monitor
            .diff(vec![use_of(DeviceKind::Microphone, "zoom", Some(42))])
            .unwrap();

        let changes = monitor
            .diff(vec![use_of(DeviceKind::Camera, "obs", Some(7))])
            .unwrap();
        assert_eq!(changes.len(), 2);
        assert!(changes
            .iter()
            .any(|c| matches!(c, Change::Started(u) if u.app == "obs")));
        assert!(changes
            .iter()
            .any(|c| matches!(c, Change::Stopped(u) if u.app == "zoom")));
    }

    /// The same application on the microphone and on the camera is two
    /// separate facts, and losing one of them would hide a camera going live.
    #[test]
    fn the_same_app_on_two_devices_is_tracked_separately() {
        let mut monitor = Monitor::new();
        monitor
            .diff(vec![use_of(DeviceKind::Microphone, "zoom", Some(42))])
            .unwrap();
        let changes = monitor
            .diff(vec![
                use_of(DeviceKind::Microphone, "zoom", Some(42)),
                use_of(DeviceKind::Camera, "zoom", Some(42)),
            ])
            .unwrap();
        assert_eq!(changes.len(), 1);
        assert!(matches!(&changes[0], Change::Started(u) if u.kind == DeviceKind::Camera));
    }

    #[test]
    fn the_same_app_under_two_pids_is_tracked_separately() {
        let mut monitor = Monitor::new();
        monitor
            .diff(vec![use_of(DeviceKind::Microphone, "chrome", Some(1))])
            .unwrap();
        let changes = monitor
            .diff(vec![
                use_of(DeviceKind::Microphone, "chrome", Some(1)),
                use_of(DeviceKind::Microphone, "chrome", Some(2)),
            ])
            .unwrap();
        assert_eq!(changes.len(), 1);
        assert!(matches!(&changes[0], Change::Started(u) if u.pid == Some(2)));
    }

    #[test]
    fn alerts_name_the_app_the_device_and_the_pid() {
        let started = Change::Started(use_of(DeviceKind::Microphone, "zoom", Some(42)));
        let text = started.alert();
        assert!(text.contains("zoom"));
        assert!(text.contains("microphone"));
        assert!(text.contains("42"));

        let stopped = Change::Stopped(use_of(DeviceKind::Camera, "obs", None));
        assert!(stopped.alert().contains("stopped"));
        assert!(stopped.alert().contains("camera"));
    }

    #[test]
    fn support_is_reported_honestly_for_this_platform() {
        let s = support();
        assert!(!s.explanation.is_empty());
        if cfg!(any(target_os = "windows", target_os = "linux")) {
            assert!(s.microphone && s.camera);
        } else {
            assert!(
                !s.microphone && !s.camera,
                "a platform that cannot detect must not claim it can"
            );
        }
    }

    /// A real scan must never panic, whatever the machine looks like.
    #[test]
    fn scanning_this_machine_does_not_panic() {
        match scan() {
            Ok(list) => {
                for entry in list {
                    assert!(!entry.app.is_empty(), "an entry with no name is useless");
                }
            }
            Err(Error::Unsupported) => assert!(!support().microphone),
            Err(Error::Io(_)) => {}
        }
    }
}