//! 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 //! `/tools//` 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 { crate::settings::data_dir() } fn tools_dir(tool: Tool) -> Option { Some(base_dir()?.join("tools").join(tool.package())) } fn install_dir(tool: Tool, version: &str) -> Option { 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 { Some(entry_in(&install_dir(tool, tool.version())?, tool)) } /// The folder the pinned hub is installed in. pub fn hub_package_dir() -> Option { 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 { 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 { 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::>().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 = 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 { 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(¤t)); 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"]); } }