crates/veilvoice-cli/src/capture.rs
what this file is for · veilvoice-cli · 339 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 capture` -- which screen recorders are running, and which of them
//! you have said you meant to run.
//!
//! The command-line front end to [`veilvoice_watch::capture`]. That crate holds the
//! table, the allowlist and the honest account of the three things it cannot
//! do; this file decides where the allowlist lives and prints the result.
//!
//! # Where the allowlist lives
//!
//! ```text
//! <config>/veilvoice/capture/allow.txt
//! ```
//!
//! Beside everything else this program keeps. Plain text, nothing secret in it:
//! an allowlist is a note to yourself about which notifications you have
//! already read.
//!
//! # The exit code
//!
//! `veilvoice capture check` exits non-zero when something **not allowed** is
//! running, so it can be a step in a script that refuses to start recording
//! something sensitive while a recorder is open. Allowing a program is
//! precisely how you tell that script you meant it.
//!
//! It exits zero when the listing itself failed, and says so on the way past.
//! A check that cannot see is not a check that passed, but neither is it a
//! reason to fail a script. The difference is in the words, and the words are
//! printed.
//!
//! # In plain words
//!
//! Tells you which screen recorders are running, and lets you say you meant to
//! start one so it stops being mentioned.
//!
//! It cannot tell whether a program is actually recording, only that it is open,
//! and it says so. A meeting application being open is not somebody watching your
//! screen.
use crate::theme::{colour, err, field, heading, ok, paint, warn};
use std::path::PathBuf;
use veilvoice_watch::capture::{programs, Allowlist, Report};
/// Where the allowlist is kept.
///
/// Derived from the app lock's location rather than resolved again, so there is
/// one answer to "where does VeilVoice keep things".
pub fn capture_dir() -> Option<PathBuf> {
veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("").join("capture"))
}
/// Where the screen-recorder allowlist is kept for this user.
fn allow_path() -> Result<PathBuf, String> {
Ok(capture_dir()
.ok_or_else(|| {
"this platform did not say where to keep configuration (no APPDATA, \
XDG_CONFIG_HOME or HOME), so there is nowhere to keep an allowlist"
.to_string()
})?
.join("allow.txt"))
}
/// The allowlist, or an empty one when none has been written yet.
fn load() -> Result<Allowlist, String> {
let path = allow_path()?;
Allowlist::load(&path).map_err(|error| format!("{}: {error}", path.display()))
}
/// Write the allowlist back, readable by its owner and nobody else.
fn save(allowlist: &Allowlist) -> Result<(), String> {
let path = allow_path()?;
allowlist
.save(&path)
.map_err(|error| format!("could not write {}: {error}", path.display()))
}
/// What is running, what is allowed, and what this cannot see.
pub fn status() -> Result<(), String> {
println!("{}", heading("Screen capture"));
let allowlist = load()?;
let report = Report::take(&allowlist);
for problem in &report.problems {
println!("{}", err(&format!("could not look properly: {problem}")));
}
if report.is_empty() {
println!(
"{}",
field("running now", "none of the programs this build knows")
);
} else {
println!();
for sighting in report.all() {
let line = sighting.describe();
if sighting.allowed {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
} else {
println!("{}", warn(&line));
}
}
println!();
}
println!("{}", field("allowed", &allowlist.len().to_string()));
for key in allowlist.keys() {
let name = programs::by_key(key)
.map(|program| program.name)
.unwrap_or(key);
println!("{}", field(" ", &format!("{key} -- {name}")));
}
println!();
println!("{}", paint(colour::YELLOW, "WHAT THIS IS WORTH"));
for line in crate::sentry::wrap(veilvoice_watch::capture::SCOPE, 72) {
println!(" {line}");
}
println!();
println!(" veilvoice capture allow <KEY> stop notifying about one");
println!(" veilvoice capture deny <KEY> start again");
println!(" veilvoice capture list every program this build knows");
Ok(())
}
/// Every program in the table, whether it is running or not.
/// Where to point a calling program so your voice goes through VeilVoice.
///
/// Prints the route, which program to change and where, and -- as plainly as
/// the rest -- the two things it does not do.
pub fn calls() -> Result<(), String> {
use veilvoice_watch::capture::comms;
println!("{}", heading("Talking through VeilVoice"));
println!();
println!(" your microphone -> veilvoice live -> a virtual audio cable");
println!(" |");
println!(" v");
println!(" the calling program, with");
println!(" the cable as its microphone");
println!();
// The cable this machine actually has, named, so the instructions can be
// followed rather than translated.
#[cfg(feature = "live")]
let cable = veilvoice_audio::devices::find_virtual_cable().map(|device| device.name);
#[cfg(not(feature = "live"))]
let cable: Option<String> = None;
match &cable {
Some(name) => println!("{}", field("this machine's cable", name)),
None => {
println!(
"{}",
warn("no virtual audio cable was found on this machine")
);
println!(
"{}",
paint(
colour::MUTED,
" `veilvoice companions` will install VB-CABLE, BlackHole or PipeWire."
)
);
}
}
let (found, problems) = comms::running();
println!();
println!("{}", heading("Programs found running"));
if found.is_empty() {
println!(
"{}",
paint(colour::MUTED, " none of the ones this build knows")
);
}
for comm in &found {
println!("{}", field(comm.name, comm.where_to_look));
}
for problem in &problems {
println!("{}", warn(problem));
}
println!();
println!(
"{}",
heading("Every program this build knows where to look in")
);
for comm in comms::COMMS {
println!("{}", field(comm.name, comm.where_to_look));
}
println!();
for line in crate::sentry::wrap(comms::ANY_PROGRAM, 72) {
println!(" {line}");
}
println!();
println!("{}", paint(colour::YELLOW, "WHAT THIS DOES NOT DO"));
for line in crate::sentry::wrap(comms::INCOMING, 72) {
println!(" {line}");
}
println!();
for line in crate::sentry::wrap(comms::NO_INTERCEPTION, 72) {
println!(" {line}");
}
Ok(())
}
/// `veilvoice capture list`: the screen recorders this build knows how to
/// name.
///
/// Prints the limit with the list, because a program not on it is not reported
/// and a reader who does not know that would take an empty result for an
/// all-clear.
pub fn list() -> Result<(), String> {
println!("{}", heading("Programs this build knows"));
println!(" Anything not on this list is not reported. The list will never be");
println!(" complete, and an empty report is not evidence that nothing is");
println!(" recording.");
println!();
for program in programs::ALL {
println!("{}", paint(colour::BLUE, program.name));
println!("{}", field("key", program.key));
println!("{}", field("what", program.what));
println!(
"{}",
field(
"kind",
match program.purpose {
programs::Purpose::Recorder => "recording the screen is what it does",
programs::Purpose::Capable =>
"can share a screen, which is not the same as doing it",
}
)
);
if program.processes.is_empty() {
println!(
"{}",
field("matches", "nothing, on purpose -- see the reference page")
);
}
println!();
}
Ok(())
}
/// Stop notifying about one program.
pub fn allow(key: &str) -> Result<(), String> {
println!("{}", heading("Allow a recorder"));
let mut allowlist = load()?;
allowlist.allow(key).map_err(|error| error.to_string())?;
save(&allowlist)?;
let name = programs::by_key(key)
.map(|program| program.name)
.unwrap_or(key);
println!("{}", ok(&format!("{name} will not raise a notification")));
println!();
println!(" It still appears in `veilvoice capture status`. Allowed means muted,");
println!(" not hidden -- a setting that removed it from the interface entirely");
println!(" would be a setting for lying to yourself.");
Ok(())
}
/// Start notifying about one program again.
pub fn deny(key: &str) -> Result<(), String> {
println!("{}", heading("Stop allowing a recorder"));
let mut allowlist = load()?;
if !allowlist.allows(key) {
println!("{}", warn(&format!("{key} was not allowed")));
return Ok(());
}
allowlist.deny(key);
save(&allowlist)?;
println!("{}", ok(&format!("{key} will raise a notification again")));
Ok(())
}
/// Look now, and let the exit code answer.
///
/// Returns `true` when something not allowed is running.
pub fn check() -> Result<bool, String> {
let allowlist = load()?;
let report = Report::take(&allowlist);
for problem in &report.problems {
// Printed, and deliberately not turned into a failure. A check that
// could not see is not a check that passed, and it is not a reason to
// fail somebody's script either. The sentence is the honest part.
println!("{}", err(&format!("could not look properly: {problem}")));
}
let unallowed = report.worth_saying();
if unallowed.is_empty() {
println!("{}", ok("nothing unallowed is running"));
return Ok(false);
}
for sighting in &unallowed {
println!("{}", warn(&sighting.describe()));
}
println!();
println!(" If you meant to run that, `veilvoice capture allow <KEY>` stops");
println!(" this asking again.");
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_capture_directory_sits_beside_the_app_lock() {
let Some(dir) = capture_dir() else {
return; // a platform with nowhere to keep configuration
};
assert!(dir.ends_with("capture"), "{}", dir.display());
let lock = veilvoice_crypto::lock::default_path().unwrap();
assert_eq!(dir.parent(), lock.parent());
}
/// Every state directory this program keeps must be its own, or two
/// features share a folder and each other's filenames.
#[test]
fn every_state_directory_is_distinct() {
let dirs: Vec<PathBuf> = [
capture_dir(),
crate::sentry::state_dir(),
crate::policy::policy_dir(),
]
.into_iter()
.flatten()
.collect();
let mut seen = dirs.clone();
seen.sort();
seen.dedup();
assert_eq!(seen.len(), dirs.len(), "two features share a directory");
}
/// Both listings must render without touching the allowlist on disk.
#[test]
fn the_program_list_prints_every_entry() {
list().expect("listing the table needs no configuration directory");
}
}