Vectrum
Sign In
222 lines · 7.9 KB · Rust
//! Pinned installs of the two npm tools device support runs on, as T3 Code
//! has them: `expo-device-hub` streams simulator and emulator screens and
//! `agent-device` drives them. Each installs, after its consent step, into
//! `<data>/tools/<name>/<version>` and runs from there with Node, never
//! `npx`, so a device opened after a reboot doesn't depend on the registry.
//!
//! An install stages into a temporary sibling, writes a sentinel once npm
//! exits cleanly and then moves into place: npm extracts files before it
//! finishes, so an entry file alone doesn't prove a usable tree.

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

use anyhow::{Context as _, Result, anyhow, bail};

use super::ToolVersion;

pub const HUB_PACKAGE: &str = "expo-device-hub";
pub const HUB_VERSION: &str = "0.15.3";
pub const AGENT_PACKAGE: &str = "agent-device";
pub const AGENT_VERSION: &str = "0.21.25";

/// One install at a time, across both tools.
static INSTALL: Mutex<()> = Mutex::new(());

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tool {
    Hub,
    Agent,
}

impl Tool {
    fn package(self) -> &'static str {
        match self {
            Tool::Hub => HUB_PACKAGE,
            Tool::Agent => AGENT_PACKAGE,
        }
    }

    pub fn version(self) -> &'static str {
        match self {
            Tool::Hub => HUB_VERSION,
            Tool::Agent => AGENT_VERSION,
        }
    }

    fn entry(self) -> &'static [&'static str] {
        match self {
            Tool::Hub => &["dist", "server", "cli.mjs"],
            Tool::Agent => &["bin", "agent-device.mjs"],
        }
    }

    /// What the panel calls it while it installs.
    pub fn label(self) -> &'static str {
        match self {
            Tool::Hub => "device hub",
            Tool::Agent => "agent tools",
        }
    }
}

/// Where device support keeps its tools, state and launcher.
pub fn base_dir() -> Option<PathBuf> {
    crate::settings::data_dir()
}

fn tools_dir(tool: Tool) -> Option<PathBuf> {
    Some(base_dir()?.join("tools").join(tool.package()))
}

fn install_dir(tool: Tool, version: &str) -> Option<PathBuf> {
    Some(tools_dir(tool)?.join(version))
}

fn entry_in(dir: &Path, tool: Tool) -> PathBuf {
    let mut path = dir.join("node_modules").join(tool.package());
    for part in tool.entry() {
        path.push(part);
    }
    path
}

/// The entry script of the pinned version, installed or not.
pub fn entry_path(tool: Tool) -> Option<PathBuf> {
    Some(entry_in(&install_dir(tool, tool.version())?, tool))
}

/// The folder the pinned hub is installed in.
pub fn hub_package_dir() -> Option<PathBuf> {
    Some(install_dir(Tool::Hub, HUB_VERSION)?.join("node_modules").join(HUB_PACKAGE))
}

fn is_installed_at(dir: &Path, tool: Tool, version: &str) -> bool {
    entry_in(dir, tool).is_file()
        && std::fs::read_to_string(dir.join(".install-complete")).is_ok_and(|sentinel| sentinel.trim() == version)
}

pub fn is_installed(tool: Tool) -> bool {
    install_dir(tool, tool.version()).is_some_and(|dir| is_installed_at(&dir, tool, tool.version()))
}

/// Node, which runs both tools.
pub fn node() -> Option<PathBuf> {
    crate::browser::find_program("node")
}

/// The message the panel shows while a tool installs or updates.
pub fn install_message(tool: Tool) -> String {
    let versions = versions(tool, None);
    let previous = versions.installed.last().cloned();
    match previous {
        Some(previous) if !versions.installed.contains(&versions.required) => {
            format!("Updating {} from {previous} to {}…", tool.label(), versions.required)
        }
        _ => format!("Installing {} {}…", tool.label(), versions.required),
    }
}

/// Installs the pinned version unless it's there. Returns its entry script.
pub fn ensure(tool: Tool) -> Result<PathBuf> {
    let _guard = INSTALL.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    let version = tool.version();
    let dir = install_dir(tool, version).context("no data folder")?;
    if is_installed_at(&dir, tool, version) {
        return Ok(entry_in(&dir, tool));
    }
    let parent = dir.parent().context("no tools folder")?.to_path_buf();
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&parent).with_context(|| format!("preparing {}", parent.display()))?;
    let staging = parent.join(format!(".staging-{}", crate::remote::new_key()));
    std::fs::create_dir_all(&staging)?;
    let result = (|| {
        let spec = format!("{}@{version}", tool.package());
        let npm = crate::browser::find_program("npm").ok_or_else(|| anyhow!("npm was not found. Install Node.js 22 or newer."))?;
        let mut command = Command::new(&npm);
        command
            .args(["install", "--prefix"])
            .arg(&staging)
            .args(["--no-fund", "--no-audit", &spec])
            .stdin(Stdio::null());
        // npm runs Node scripts; it needs Node on PATH when the app has none.
        if let Some(bin) = npm.parent() {
            command.env("PATH", prepend_path(bin));
        }
        let output = command.output().with_context(|| format!("running {}", npm.display()))?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let tail: Vec<&str> = stderr.lines().rev().take(6).collect();
            bail!(
                "npm install {spec} failed ({}): {}",
                output.status,
                tail.into_iter().rev().collect::<Vec<_>>().join("\n")
            );
        }
        if !entry_in(&staging, tool).is_file() {
            bail!("npm installed {spec} without its entry point");
        }
        std::fs::write(staging.join(".install-complete"), format!("{version}\n"))?;
        if std::fs::rename(&staging, &dir).is_err() && !is_installed_at(&dir, tool, version) {
            bail!("could not move the {} install into place", tool.package());
        }
        Ok(entry_in(&dir, tool))
    })();
    let _ = std::fs::remove_dir_all(&staging);
    result
}

/// Which versions are installed, without installing anything.
pub fn versions(tool: Tool, running: Option<&str>) -> ToolVersion {
    let mut installed: Vec<String> = tools_dir(tool)
        .and_then(|dir| std::fs::read_dir(dir).ok())
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|entry| {
            let version = entry.file_name().to_string_lossy().to_string();
            let looks_like_version = version.split('.').take(3).all(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()));
            (looks_like_version && is_installed_at(&entry.path(), tool, &version)).then_some(version)
        })
        .collect();
    installed.sort_by(|a, b| compare_versions(a, b));
    ToolVersion { required: tool.version().to_string(), installed, running: running.map(str::to_string) }
}

/// Drops installs of other versions once the pinned one runs.
pub fn prune(tool: Tool) {
    let Some(dir) = tools_dir(tool) else { return };
    for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
        let name = entry.file_name().to_string_lossy().to_string();
        if name != tool.version() && !name.starts_with('.') {
            let _ = std::fs::remove_dir_all(entry.path());
        }
    }
}

fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
    let parts = |text: &str| -> Vec<u64> { text.split(['.', '-']).map(|part| part.parse().unwrap_or(0)).collect() };
    parts(a).cmp(&parts(b))
}

/// PATH with `dir` in front.
pub fn prepend_path(dir: &Path) -> std::ffi::OsString {
    let current = std::env::var_os("PATH").unwrap_or_default();
    let dirs = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&current));
    std::env::join_paths(dirs).unwrap_or(current)
}

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

    #[::core::prelude::v1::test]
    fn sorts_versions_by_number() {
        let mut versions = vec!["0.21.9", "0.21.25", "0.3.0"];
        versions.sort_by(|a, b| compare_versions(a, b));
        assert_eq!(versions, ["0.3.0", "0.21.9", "0.21.25"]);
    }
}