crates/veilvoice-setup/src/install.rs

what this file is for · veilvoice-setup · 594 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
//! Put this program somewhere the system can find it.
//!
//! Reached as `veilvoice install` on the command line and as the setup tab
//! in the desktop application. Both call the functions below; neither has a
//! copy of them. See the crate documentation for why that mattered enough to
//! move this file out of the binary it used to live in.
//!
//! # Portable is the default, and installing is the exception
//!
//! VeilVoice runs from wherever it is unpacked. Nothing has to be installed,
//! nothing is written outside the folder unless the user does something that
//! writes outside the folder, and deleting the folder removes it. That is the
//! posture this project has always had and it is not being given up.
//!
//! This exists because "runs from anywhere" and "I would like to type
//! `veilvoice` in a terminal" are both reasonable, and the second needs three
//! things a portable folder cannot provide: a stable location, an entry on
//! `PATH`, and a way for the operating system to list and remove it.
//!
//! # No administrator, and nothing outside the user's own account
//!
//! Everything here is per-user: `%LOCALAPPDATA%` on Windows,
//! `~/.local` on everything else, and on Windows the `HKCU` registry rather
//! than `HKLM`. No elevation is requested, nothing is written to a system
//! directory, and no service is created.
//!
//! That is a deliberate limit rather than an oversight. A per-user install can
//! be undone by the user who made it, needs no privilege to audit, and cannot
//! break anybody else's account. A machine-wide install would need
//! administrator rights, and the reason to ask for those has to be better than
//! "so the program is on everyone's PATH".
//!
//! # Every change is reversible, and `uninstall` reverses exactly these
//!
//! | What | Where | Undone by |
//! |---|---|---|
//! | The binaries | `<prefix>/VeilVoice` | removing that directory |
//! | `PATH` entry | `HKCU\Environment`, or a shell profile line | removing just that entry |
//! | Uninstall entry | `HKCU\...\Uninstall\VeilVoice` | deleting that key |
//!
//! The `PATH` edit is the one that can damage something, so it is the one
//! handled most carefully: the existing value is read, the entry is appended
//! only if absent, and removal takes out that entry and nothing else. A tool
//! that overwrites `PATH` wholesale has broken a machine, and doing it during
//! an *uninstall* is worse -- that is the moment somebody is least inclined to
//! check.
//!
//! # Why the registry through `reg.exe`
//!
//! The same reason `veilvoice-watch` reads it that way: this workspace carries
//! `#![forbid(unsafe_code)]` in every crate, and the Win32 registry API needs
//! `unsafe` FFI. Shelling out to a system tool keeps that guarantee and costs a
//! subprocess on an operation that runs once. `reg.exe` is resolved by absolute
//! path -- resolving it by name would search the working directory first, which
//! is finding F-13.
//!
//! # In plain words
//!
//! Copies VeilVoice somewhere your system can find it, and adds that place to your
//! path so typing `veilvoice` works.
//!
//! It installs for you alone and needs no administrator rights. It also registers
//! with the system's own list of installed programs, so removing it works the way
//! removing anything else does.
//!
//! Running VeilVoice straight out of a folder is a perfectly good way to use it,
//! and the setup screen says so rather than treating portable as something
//! missing.

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


/// The name of the directory and the uninstall entry.
///
/// Windows-only: elsewhere the prefix follows the XDG convention and is
/// lower-case, so this constant has no reader.
#[cfg(windows)]
pub const NAME: &str = "VeilVoice";



/// Files that make up an installation, if they are beside the running binary.
///
/// The two the release publishes. A third, `veilvoice-verify`, existed until
/// 0.1.18 and is now inside both, so looking for it here would only ever find
/// a stale copy left behind by an older install.
const PROGRAMS: &[&str] = &["veilvoice", "veilvoice-gui"];



/// `reg.exe`, by absolute path.
///
/// Never by bare name: Windows searches the current directory before most of
/// `PATH`, so running this from a folder containing `reg.exe` would run that
/// instead. This is the program that edits `PATH`, so it is a poor place to be
/// relaxed about which binary is doing it.
#[cfg(windows)]
fn reg_exe() -> PathBuf {
    let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
    PathBuf::from(format!(r"{root}\System32\reg.exe"))
}



/// Where an installation goes, for this user only.
pub fn prefix() -> Option<PathBuf> {
    #[cfg(windows)]
    {
        std::env::var_os("LOCALAPPDATA").map(|base| PathBuf::from(base).join("Programs").join(NAME))
    }
    #[cfg(not(windows))]
    {
        std::env::var_os("HOME").map(|home| {
            PathBuf::from(home)
                .join(".local")
                .join("share")
                .join("veilvoice")
        })
    }
}



/// The directory a `PATH` entry should point at.
pub fn bin_dir() -> Option<PathBuf> {
    #[cfg(windows)]
    {
        prefix()
    }
    #[cfg(not(windows))]
    {
        std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local").join("bin"))
    }
}



/// What an installation currently looks like.
///
/// Read by [`status`], which changes nothing. Every field is a separate fact
/// on purpose: "something is installed" and "you are running the installed
/// copy" are different, and a front end that conflates them tells somebody
/// editing a portable folder that their changes took effect.
pub struct Status {
    /// Where an installation would go, or does. `None` when this system
    /// offers no per-user program directory at all.
    pub prefix: Option<PathBuf>,
    /// A VeilVoice command line exists under [`Status::prefix`].
    pub installed: bool,
    /// The install directory is on **this process's** `PATH` -- which is what
    /// "will typing `veilvoice` work in the terminal I already have open"
    /// actually depends on.
    pub on_path: bool,
    /// The binary that is running right now, as the operating system reports
    /// it.
    pub running_from: Option<PathBuf>,
    /// True when the running binary is the installed one rather than a
    /// portable copy. Worth telling the user: "installed" and "you are running
    /// the installed one" are different facts.
    pub running_installed: bool,
}



/// Read the current state without changing anything.
pub fn status() -> Status {
    let prefix = prefix();
    let running = std::env::current_exe().ok();
    let installed = prefix
        .as_ref()
        .map(|p| p.join(exe_name("veilvoice")).exists())
        .unwrap_or(false);
    let running_installed = match (&prefix, &running) {
        (Some(p), Some(r)) => r.parent().map(|d| d == p.as_path()).unwrap_or(false),
        _ => false,
    };
    Status {
        on_path: prefix.as_ref().map(|p| path_contains(p)).unwrap_or(false),
        prefix,
        installed,
        running_from: running,
        running_installed,
    }
}



/// A program's file name on this platform.
fn exe_name(stem: &str) -> String {
    if cfg!(windows) {
        format!("{stem}.exe")
    } else {
        stem.to_string()
    }
}



/// Is `dir` already on this user's `PATH`?
///
/// Reads the *current process* environment, which is what "will typing
/// `veilvoice` work in this terminal" actually depends on. A registry value
/// that a new terminal would pick up is a different question, and the report
/// says which one it answered.
fn path_contains(dir: &Path) -> bool {
    let Some(path) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&path).any(|entry| entry == dir)
}



/// Copy the binaries into place. Returns what was copied.
fn copy_programs(into: &Path) -> Result<Vec<String>, String> {
    let running =
        std::env::current_exe().map_err(|e| format!("cannot find this program on disk: {e}"))?;
    let source = running
        .parent()
        .ok_or_else(|| "this program has no parent directory".to_string())?;

    std::fs::create_dir_all(into).map_err(|e| format!("cannot create {}: {e}", into.display()))?;

    let mut copied = Vec::new();
    for stem in PROGRAMS {
        let name = exe_name(stem);
        let from = source.join(&name);
        if !from.exists() {
            // A portable folder may hold only one of the two: several
            // platforms publish a command-line archive with no window in it.
            // Copying what is there and saying so is more useful than refusing
            // because the GUI was not unpacked.
            continue;
        }
        let to = into.join(&name);
        if from == to {
            return Err(format!(
                "this program is already running from {} -- nothing to install",
                into.display()
            ));
        }
        std::fs::copy(&from, &to)
            .map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;
        copied.push(name);
    }
    if copied.is_empty() {
        return Err("found none of the VeilVoice programs beside this one".to_string());
    }
    Ok(copied)
}



/// Add `dir` to the user's `PATH`, if it is not there already.
///
/// Reads the existing value and appends. Never writes a `PATH` it did not
/// first read: replacing that variable wholesale is how a tool breaks a
/// machine, and there is no undo.
#[cfg(windows)]
fn add_to_path(dir: &Path) -> Result<bool, String> {
    let wanted = dir.display().to_string();
    let joined = match read_user_path()? {
        UserPath::Absent => wanted.clone(),
        UserPath::Value(current) => {
            if current
                .split(';')
                .any(|entry| entry.trim().eq_ignore_ascii_case(&wanted))
            {
                return Ok(false);
            }
            if current.trim().is_empty() {
                wanted.clone()
            } else {
                format!("{};{}", current.trim_end_matches(';'), wanted)
            }
        }
    };
    let output = crate::command(reg_exe())
        .args([
            "add",
            r"HKCU\Environment",
            "/v",
            "PATH",
            "/t",
            "REG_EXPAND_SZ",
            "/d",
        ])
        .arg(&joined)
        .arg("/f")
        .output()
        .map_err(|e| format!("could not run reg.exe: {e}"))?;
    if !output.status.success() {
        return Err(format!(
            "could not update PATH: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    Ok(true)
}



#[cfg(windows)]
enum UserPath {
    /// Read successfully. This is the value to append to.
    Value(String),
    /// `reg` said the value does not exist. Creating it is safe.
    Absent,
}



/// Read this user's `PATH`, distinguishing "not set" from "could not tell".
///
/// The first version returned an empty string for both, and the caller treats
/// empty as "there is no PATH yet, write a fresh one" -- so a query that failed
/// for any reason would have replaced the user's entire `PATH` with a single
/// entry. The comment at the top of this file already said that was the thing
/// to avoid; the code did not implement it.
///
/// `reg query` exits non-zero for a missing value *and* for every other
/// failure, so the two are told apart by what it says. Anything not
/// recognisably "value does not exist" is an error, and an error refuses the
/// write rather than guessing.
#[cfg(windows)]
fn read_user_path() -> Result<UserPath, String> {
    let output = crate::command(reg_exe())
        .args(["query", r"HKCU\Environment", "/v", "PATH"])
        .output()
        .map_err(|e| format!("could not run reg.exe: {e}"))?;
    if !output.status.success() {
        let complaint = String::from_utf8_lossy(&output.stderr).to_lowercase();
        if complaint.contains("unable to find") {
            // Genuinely no value. Creating one is safe.
            return Ok(UserPath::Absent);
        }
        return Err(format!(
            "could not read your PATH ({}). Refusing to change it: writing a \
             PATH that could not first be read would replace whatever is there.",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    let text = String::from_utf8_lossy(&output.stdout);
    for line in text.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("PATH") {
            continue;
        }
        // `reg query` prints:  PATH    REG_EXPAND_SZ    <value>
        //
        // The value may itself contain spaces, so it is taken as everything
        // after the type rather than by splitting on whitespace -- a PATH is
        // full of `C:\Program Files\...` and splitting one on spaces is how a
        // tool corrupts it.
        let Some(at) = trimmed.find("REG_") else {
            continue;
        };
        let after = &trimmed[at..];
        let Some(space) = after.find(char::is_whitespace) else {
            continue;
        };
        return Ok(UserPath::Value(after[space..].trim().to_string()));
    }
    // `reg` succeeded and printed something this cannot parse. That is not
    // "there is no PATH" -- it is "I do not understand the answer", and the
    // difference is the whole point of this function.
    Err("could not parse the PATH value reg.exe printed. Refusing to change it.".to_string())
}



/// The Unix half: report that nothing was written, because nothing was.
///
/// Answering `Ok(false)` rather than doing it is the decision, and the body
/// says why. The caller prints the line for the person to add themselves.
#[cfg(not(windows))]
fn add_to_path(dir: &Path) -> Result<bool, String> {
    // On Unix the convention is a line in a shell profile, and rewriting
    // somebody's profile without asking is not this program's business. The
    // line is printed for them to add.
    let _ = dir;
    Ok(false)
}



/// Register with Add/Remove Programs, so the system can list and remove it.
#[cfg(windows)]
fn register_uninstall(prefix: &Path) -> Result<(), String> {
    let key = format!(r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\{NAME}");
    let exe = prefix.join(exe_name("veilvoice"));
    let entries: &[(&str, &str, String)] = &[
        ("DisplayName", "REG_SZ", NAME.to_string()),
        (
            "DisplayVersion",
            "REG_SZ",
            env!("CARGO_PKG_VERSION").to_string(),
        ),
        ("Publisher", "REG_SZ", "tilas01".to_string()),
        ("InstallLocation", "REG_SZ", prefix.display().to_string()),
        ("DisplayIcon", "REG_SZ", exe.display().to_string()),
        ("NoModify", "REG_DWORD", "1".to_string()),
        ("NoRepair", "REG_DWORD", "1".to_string()),
        (
            "UninstallString",
            "REG_SZ",
            format!("\"{}\" uninstall --yes", exe.display()),
        ),
    ];
    for (name, kind, value) in entries {
        let output = crate::command(reg_exe())
            .args(["add", &key, "/v", name, "/t", kind, "/d"])
            .arg(value)
            .arg("/f")
            .output()
            .map_err(|e| format!("could not run reg.exe: {e}"))?;
        if !output.status.success() {
            return Err(format!(
                "could not write the uninstall entry: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            ));
        }
    }
    Ok(())
}



/// The Unix half: nothing to register. There is no Add/Remove Programs here,
/// and `veilvoice uninstall` is the reversal on these platforms.
#[cfg(not(windows))]
fn register_uninstall(_prefix: &Path) -> Result<(), String> {
    Ok(())
}



/// Take this directory back out of the user's `PATH`, leaving the rest of it
/// exactly as it was.
///
/// The same care as [`add_to_path`], for the same reason: the variable is read
/// and rewritten rather than replaced, and a `PATH` that never had this
/// directory in it is left untouched rather than rewritten to itself.
#[cfg(windows)]
fn remove_from_path(dir: &Path) -> Result<bool, String> {
    let current = match read_user_path()? {
        // Nothing to remove from, and nothing to write.
        UserPath::Absent => return Ok(false),
        UserPath::Value(value) => value,
    };
    if current.trim().is_empty() {
        return Ok(false);
    }
    let wanted = dir.display().to_string();
    let kept: Vec<&str> = current
        .split(';')
        .filter(|entry| !entry.trim().eq_ignore_ascii_case(&wanted))
        .collect();
    if kept.len() == current.split(';').count() {
        return Ok(false);
    }
    // Only this entry is removed, and only from the value just read. An
    // uninstall that rewrites PATH from a template destroys whatever else the
    // user had, at the moment they are least likely to look.
    let joined = kept.join(";");
    let output = crate::command(reg_exe())
        .args([
            "add",
            r"HKCU\Environment",
            "/v",
            "PATH",
            "/t",
            "REG_EXPAND_SZ",
            "/d",
        ])
        .arg(&joined)
        .arg("/f")
        .output()
        .map_err(|e| format!("could not run reg.exe: {e}"))?;
    if !output.status.success() {
        return Err(format!(
            "could not update PATH: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    Ok(true)
}



/// The Unix half: nothing was written to a profile, so nothing is taken out.
#[cfg(not(windows))]
fn remove_from_path(_dir: &Path) -> Result<bool, String> {
    Ok(false)
}



/// Take the Add/Remove Programs entry away again.
///
/// A failure is ignored on purpose: the entry not being there is the outcome
/// wanted, and refusing to finish an uninstall because the registry key was
/// already gone would leave the person with a half-removed program.
#[cfg(windows)]
fn unregister_uninstall() -> Result<(), String> {
    let key = format!(r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\{NAME}");
    let _ = crate::command(reg_exe())
        .args(["delete", &key, "/f"])
        .output();
    Ok(())
}



/// The Unix half: nothing was registered, so nothing is unregistered.
#[cfg(not(windows))]
fn unregister_uninstall() -> Result<(), String> {
    Ok(())
}



/// Install for this user. Returns the lines to report.
pub fn install() -> Result<Vec<String>, String> {
    let prefix = prefix()
        .ok_or_else(|| "no per-user program directory could be found on this system".to_string())?;
    let mut report = Vec::new();

    let copied = copy_programs(&prefix)?;
    report.push(format!(
        "copied {} into {}",
        copied.join(", "),
        prefix.display()
    ));

    let dir = bin_dir().unwrap_or_else(|| prefix.clone());
    match add_to_path(&dir) {
        Ok(true) => report.push(format!("added {} to your PATH", dir.display())),
        Ok(false) => report.push(format!("{} was already on your PATH", dir.display())),
        Err(error) => report.push(format!("PATH was not changed: {error}")),
    }

    register_uninstall(&prefix)?;
    if cfg!(windows) {
        report.push("registered in Apps & features, so Windows can remove it".to_string());
    }
    Ok(report)
}



/// Remove what `install` added.
pub fn uninstall() -> Result<Vec<String>, String> {
    let prefix = prefix()
        .ok_or_else(|| "no per-user program directory could be found on this system".to_string())?;
    let mut report = Vec::new();

    let dir = bin_dir().unwrap_or_else(|| prefix.clone());
    match remove_from_path(&dir) {
        Ok(true) => report.push(format!("removed {} from your PATH", dir.display())),
        Ok(false) => report.push("PATH did not mention it".to_string()),
        Err(error) => report.push(format!("PATH was not changed: {error}")),
    }

    unregister_uninstall()?;

    // The directory goes last: if this binary is the installed one, it is
    // deleting itself, and Windows will not let it. Saying so is better than
    // failing halfway with the registry already cleaned up.
    let running = std::env::current_exe().ok();
    let running_here = running
        .as_ref()
        .and_then(|r| r.parent())
        .map(|d| d == prefix.as_path())
        .unwrap_or(false);
    if running_here {
        report.push(format!(
            "left {} in place: this program is running from it, and a running \
             program cannot delete itself. Remove that folder by hand.",
            prefix.display()
        ));
    } else if prefix.exists() {
        std::fs::remove_dir_all(&prefix)
            .map_err(|e| format!("could not remove {}: {e}", prefix.display()))?;
        report.push(format!("removed {}", prefix.display()));
    } else {
        report.push("nothing was installed".to_string());
    }
    Ok(report)
}


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

    #[test]
    fn a_prefix_is_found_on_this_platform() {
        assert!(
            prefix().is_some(),
            "no per-user program directory was resolved"
        );
    }

    #[test]
    fn status_reads_without_changing_anything() {
        // Called twice: the second must see exactly what the first did, or
        // reading the state is not free of side effects.
        let first = status();
        let second = status();
        assert_eq!(first.installed, second.installed);
        assert_eq!(first.on_path, second.on_path);
        assert_eq!(first.prefix, second.prefix);
    }

    #[test]
    fn the_executable_name_matches_the_platform() {
        let name = exe_name("veilvoice");
        if cfg!(windows) {
            assert_eq!(name, "veilvoice.exe");
        } else {
            assert_eq!(name, "veilvoice");
        }
    }

    #[test]
    fn path_membership_is_an_exact_directory_match() {
        // A prefix match would report `C:\Program Files\VeilVoiceOther` as
        // VeilVoice being installed, and a substring match is worse again.
        let path = std::env::var_os("PATH");
        if path.is_none() {
            return;
        }
        let nonsense = PathBuf::from("this-directory-is-not-on-anybody-s-path-42");
        assert!(!path_contains(&nonsense));
    }
}