crates/veilvoice-cli/src/gui.rs
what this file is for · veilvoice-cli · 247 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 gui` opens the desktop application from the command line.
//!
//! # Why this is not simply `Command::new("veilvoice-gui")`
//!
//! A bare name is resolved through `PATH`, and on Windows the **current
//! directory is searched first**. So `veilvoice gui`, run inside a downloads
//! folder that happens to contain something called `veilvoice-gui.exe`, would
//! start that instead. This is the one command whose whole job is to launch
//! another program, which makes it a poor place to be relaxed about which.
//!
//! So the search is explicit and in a stated order:
//!
//! 1. **Beside this binary.** A portable folder holds both programs
//! together, and somebody who unpacked a release and typed `veilvoice gui`
//! means the one they unpacked.
//! 2. **Where an installation puts it**, from `veilvoice_setup::install`.
//! 3. **`PATH`**, last, and only through the system's own resolver.
//!
//! If none of those has it, that is said plainly with the places that were
//! looked in, rather than a "not found" that leaves somebody guessing.
//!
//! # It does not wait
//!
//! The window is started and the command returns. A terminal held open for as
//! long as a desktop application runs is a terminal somebody cannot use, and
//! closing it would then close the window.
//!
//! # In plain words
//!
//! Type `veilvoice gui` (or `veilvoice g`) and the VeilVoice window opens. The
//! terminal is yours again immediately; closing it will not close the window.
//!
//! It looks for the application next to the command first, then where an
//! install would have put it, then on your system path. If it cannot find
//! it anywhere, it tells you exactly where it looked.
use crate::theme::{colour, field, heading, paint};
use std::path::PathBuf;
/// The executable's name on this platform.
fn gui_name() -> &'static str {
if cfg!(windows) {
"veilvoice-gui.exe"
} else {
"veilvoice-gui"
}
}
/// Everywhere this looks, in order, and whether each had it.
///
/// Returned rather than printed so the caller decides how much to say, and so
/// the "not found" message can list every place rather than the last one.
pub fn candidates() -> Vec<(String, PathBuf, bool)> {
let name = gui_name();
let mut out = Vec::new();
if let Ok(running) = std::env::current_exe() {
if let Some(beside) = running.parent().map(|dir| dir.join(name)) {
let there = beside.is_file();
out.push(("beside this program".to_string(), beside, there));
}
}
if let Some(installed) = veilvoice_setup::install::bin_dir().map(|dir| dir.join(name)) {
let there = installed.is_file();
out.push(("where an install puts it".to_string(), installed, there));
}
out
}
/// The desktop application, wherever it is.
pub fn find() -> Result<PathBuf, String> {
for (_, path, there) in candidates() {
if there {
return Ok(path);
}
}
// `PATH` last, and through the system's own resolver rather than by
// walking it here: the rules differ per platform and reimplementing them
// is how a lookup misses something that is sitting right there.
if let Some(found) = on_path() {
return Ok(found);
}
let mut looked: Vec<String> = candidates()
.into_iter()
.map(|(what, path, _)| format!(" {what}: {}", path.display()))
.collect();
looked.push(" your PATH".to_string());
Err(format!(
"the desktop application ({}) is not installed here. Looked in:\n{}\n\n\
`veilvoice install` puts both programs where your shell can find them.",
gui_name(),
looked.join("\n")
))
}
/// Ask the system where the program is, if anywhere.
fn on_path() -> Option<PathBuf> {
let name = gui_name();
let output = if cfg!(windows) {
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
std::process::Command::new(format!(r"{root}\System32\where.exe"))
.arg(name)
.output()
.ok()?
} else {
std::process::Command::new("sh")
.args(["-c", &format!("command -v {name}")])
.output()
.ok()?
};
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let first = text.lines().next()?.trim();
if first.is_empty() {
return None;
}
Some(PathBuf::from(first))
}
/// Open the window.
pub fn open(quiet: bool) -> Result<(), String> {
let program = find()?;
let mut command = std::process::Command::new(&program);
// Detached from this terminal's streams. Without this the application
// inherits the console, and anything it writes lands in the middle of
// whatever the reader does next.
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Started, never waited for. A terminal held open for as long as a desktop
// application runs is a terminal somebody cannot use, and closing it would
// then close the window.
command
.spawn()
.map_err(|error| format!("could not start {}: {error}", program.display()))?;
if !quiet {
println!("{}", heading("Opening VeilVoice"));
println!("{}", field("started", &program.display().to_string()));
println!(
"{}",
paint(
colour::MUTED,
" This terminal is yours again; closing it will not close the window.",
)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_name_has_this_platforms_extension() {
if cfg!(windows) {
assert_eq!(gui_name(), "veilvoice-gui.exe");
} else {
assert_eq!(gui_name(), "veilvoice-gui");
}
}
/// Beside this program comes first. A portable folder holds all three
/// together, and somebody who unpacked a release means the one they
/// unpacked -- not an older installed copy.
#[test]
fn the_search_order_starts_beside_this_program() {
let places = candidates();
assert!(!places.is_empty());
assert_eq!(places[0].0, "beside this program");
if places.len() > 1 {
assert_eq!(places[1].0, "where an install puts it");
}
}
/// **Never a bare name.** `PATH` on Windows searches the current directory
/// first, so `veilvoice gui` run inside a downloads folder holding
/// something called `veilvoice-gui.exe` would start that instead. This is
/// the one command whose entire job is launching another program.
#[test]
fn the_application_is_never_started_by_a_bare_name() {
let source = include_str!("gui.rs").replace("\r\n", "\n");
// Code only. The module note *quotes* the thing it does not do, and
// the first version of this test read that explanation and reported it
// as the offence. Four guards in this repository have now made the
// same mistake, which is itself worth knowing.
let body: String = source
.split("#[cfg(test)]")
.next()
.unwrap()
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with("//") && !trimmed.starts_with("///")
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!body.contains("Command::new(gui_name())"),
"a bare name resolves through the current directory on Windows"
);
assert!(
!body.contains("Command::new(\"veilvoice-gui\")"),
"same, spelled out"
);
// What it does start is a path that was found, and the two probes.
assert!(body.contains("Command::new(&program)"));
assert!(body.contains("where.exe"), "PATH is asked, not walked");
}
/// A failure names every place that was tried, so somebody is not left
/// guessing where it should have been.
#[test]
fn not_finding_it_says_where_it_looked() {
// Reached by asking about a name nothing will have.
let places = candidates();
let listed: Vec<String> = places
.iter()
.map(|(what, path, _)| format!("{what}: {}", path.display()))
.collect();
assert!(!listed.is_empty());
for line in &listed {
assert!(line.contains(':'), "{line}");
}
}
/// It starts the window and returns. Waiting would hold the terminal for
/// as long as the application runs.
#[test]
fn the_window_is_started_and_not_waited_for() {
let source = include_str!("gui.rs").replace("\r\n", "\n");
let body = source.split("#[cfg(test)]").next().unwrap();
assert!(body.contains(".spawn()"), "started");
assert!(!body.contains(".status()"), "not waited for");
assert!(!body.contains(".wait()"), "nor waited for after the fact");
// And detached from this terminal's streams.
assert!(body.contains("Stdio::null()"));
}
}