diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index da2d9ca..7e893c9 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -1936,9 +1936,14 @@ impl Launcher { return Ok(()); } self.log(app, "info", format!("Installing the web client's dependencies in {}", web.display())); + let node_dir = crate::nodejs::find(); let status = tauri::async_runtime::spawn_blocking(move || { let mut cmd = std::process::Command::new("cmd"); cmd.arg("/c").arg("npm").arg("install").current_dir(&web); + // Found, not assumed on PATH: right after ensure_node installed it, it is not. + if let Some(dir) = &node_dir { + cmd.env("PATH", crate::nodejs::path_with(dir)); + } #[cfg(windows)] stack::give_console(&mut cmd); cmd.status() @@ -1952,7 +1957,56 @@ impl Launcher { Ok(()) } + /// Node.js for this run's web client, installed through winget when this PC has none. + /// + /// A dev drive moves between PCs, and the web client needs Node.js on whichever one it + /// lands on - without it the launch failed with npm's bare "exit code: 1". winget runs in + /// a console of its own: the install shows its progress there, Windows asks for + /// permission, and a failure (no network, usually) leaves its reason on screen. + async fn ensure_node(&self, app: &AppHandle) -> Result<(), String> { + if crate::nodejs::find().is_some() { + return Ok(()); + } + self.log( + app, + "warn", + format!( + "Node.js is not installed, and the web client runs on it: installing Node.js LTS with winget ({}). Windows may ask for permission.", + crate::nodejs::WINGET_ID + ), + ); + let status = tauri::async_runtime::spawn_blocking(|| { + let mut cmd = crate::nodejs::winget_command(); + #[cfg(windows)] + stack::give_console(&mut cmd); + cmd.status() + }) + .await + .map_err(|e| format!("could not run winget: {e}"))?; + // Judged by what is on disk afterwards, not by winget's exit code: "already installed" + // and "no newer version" are non-zero too, and both leave a working Node.js. + if let Some(dir) = crate::nodejs::find() { + self.log(app, "info", format!("Node.js is installed in {}.", dir.display())); + return Ok(()); + } + let manual = "Install Node.js LTS from https://nodejs.org, then launch again."; + Err(match status { + Err(e) => format!("Node.js is not installed, and winget could not be started ({e}). {manual}"), + Ok(s) => format!( + "Node.js is not installed, and installing it with winget did not work ({s}); its window says why - no network is the usual reason. {manual}" + ), + }) + } + async fn run_stack(&self, app: &AppHandle, config: StackConfig) { + // Before the build too, which runs `npm install` for the web client. + if config.runs_web() { + if let Err(message) = self.ensure_node(app).await { + self.log(app, "error", message.clone()); + self.finish(app, Some(message)).await; + return; + } + } // Asked for, and only in dev: a Launch that quietly took twenty minutes to compile // would be a worse surprise than a stale binary. if config.dev_engine.is_some() && self.inner.lock().await.settings.dev_build { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ba602b7..bbe6869 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod install; mod launcher; mod localdb; mod market; +mod nodejs; mod prune; mod settings; mod site; diff --git a/src-tauri/src/nodejs.rs b/src-tauri/src/nodejs.rs new file mode 100644 index 0000000..e655a8e --- /dev/null +++ b/src-tauri/src/nodejs.rs @@ -0,0 +1,133 @@ +//! Node.js for dev mode's web client: where it is, and installing it when it is not. +//! +//! The web client runs under `npm run dev` in the checkout's `_WEB`, so a dev PC without +//! Node.js failed its launch with nothing but "stopped while starting (exit code: 1)": npm +//! was not there to say anything. Seen on a fresh PC, and again when the dev drive moved to +//! another one. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The winget package: Node.js LTS, which the web client's Vite accepts (18, or 20 and up). +pub const WINGET_ID: &str = "OpenJS.NodeJS.LTS"; + +/// Folders that may hold npm, in the order they are tried: this process's PATH, then where +/// the Node.js installer puts it. The second is what makes an install usable at once: the +/// installer adds its folder to the SYSTEM PATH, which a process already running - this +/// launcher - never sees until it is restarted. +fn candidates(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Vec { + let mut dirs: Vec = path_var.map(|p| std::env::split_paths(p).collect()).unwrap_or_default(); + if let Some(program_files) = program_files { + dirs.push(Path::new(program_files).join("nodejs")); + } + dirs +} + +/// The folder holding both `node.exe` and `npm.cmd`, from the given PATH and Program Files. +/// Both, because npm is a script node runs: an `npm.cmd` left beside no `node.exe` (a +/// half-removed install) fails exactly like no npm at all. +pub fn find_in(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Option { + candidates(path_var, program_files) + .into_iter() + .find(|dir| dir.join("node.exe").is_file() && dir.join("npm.cmd").is_file()) +} + +/// Node.js on this PC, if any. +pub fn find() -> Option { + find_in(std::env::var_os("PATH").as_deref(), std::env::var_os("ProgramFiles").as_deref()) +} + +/// A PATH for a child that runs npm: `node_dir` first, then this process's own. +pub fn path_with(node_dir: &Path) -> OsString { + let rest = std::env::var_os("PATH").unwrap_or_default(); + let dirs = std::iter::once(node_dir.to_path_buf()).chain(std::env::split_paths(&rest)); + // Only fails on a folder name holding the separator itself, which no real PATH entry has. + std::env::join_paths(dirs).unwrap_or(rest) +} + +/// `winget install` for Node.js LTS. Through `cmd` because winget is an app-execution alias +/// in WindowsApps, which cmd resolves the way a developer's own prompt would. No `--silent` +/// on purpose: the console it runs in is the only place its progress and its reason for +/// failing (no network, no winget source) are ever shown. +pub fn winget_command() -> Command { + let mut cmd = Command::new("cmd"); + cmd.args([ + "/c", + "winget", + "install", + "--id", + WINGET_ID, + "--exact", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ]); + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A throwaway folder, optionally holding the two files a Node.js install has. + fn folder(with_node: bool, with_npm: bool) -> PathBuf { + let dir = std::env::temp_dir().join(format!("odian-nodejs-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + if with_node { + std::fs::write(dir.join("node.exe"), b"").unwrap(); + } + if with_npm { + std::fs::write(dir.join("npm.cmd"), b"").unwrap(); + } + dir + } + + #[test] + fn node_is_found_on_path_first() { + let (on_path, program_files) = (folder(true, true), folder(false, false)); + std::fs::create_dir_all(program_files.join("nodejs")).unwrap(); + let path = std::env::join_paths([folder(false, false), on_path.clone()]).unwrap(); + assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(on_path)); + } + + #[test] + fn a_fresh_install_is_found_where_the_installer_puts_it() { + // What winget has just done: Program Files\nodejs exists, and this process's PATH - + // read when the launcher started - does not name it. + let program_files = folder(false, false); + let nodejs = program_files.join("nodejs"); + std::fs::create_dir_all(&nodejs).unwrap(); + std::fs::write(nodejs.join("node.exe"), b"").unwrap(); + std::fs::write(nodejs.join("npm.cmd"), b"").unwrap(); + let path = std::env::join_paths([folder(false, false)]).unwrap(); + assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(nodejs)); + } + + #[test] + fn npm_without_node_is_no_node() { + let half = folder(false, true); + let path = std::env::join_paths([half]).unwrap(); + assert_eq!(find_in(Some(&path), None), None); + assert_eq!(find_in(None, None), None); + } + + #[test] + fn the_child_path_puts_node_first() { + let dir = PathBuf::from(r"C:\Program Files\nodejs"); + let path = path_with(&dir); + assert_eq!(std::env::split_paths(&path).next(), Some(dir)); + } + + #[test] + fn winget_installs_node_lts_without_asking_on_the_console() { + let cmd = winget_command(); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(&args[..3], ["/c", "winget", "install"]); + assert!(args.windows(2).any(|w| w == ["--id", WINGET_ID]), "{args:?}"); + // Unanswered, these two prompts wait on the console forever on a first run. + assert!(args.iter().any(|a| a == "--accept-package-agreements")); + assert!(args.iter().any(|a| a == "--accept-source-agreements")); + } +} diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 183f82e..985dfd9 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -613,9 +613,11 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin /// /// Its own function so the argument list is testable: a wrong flag here would only show as /// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself -/// instead of passing it to Vite. -fn web_command(dir: &Path) -> Command { +/// instead of passing it to Vite. `node_dir` goes first on its PATH: right after the +/// launcher installed Node.js, this process's own PATH does not name it yet. +fn web_command(dir: &Path, node_dir: &Path) -> Command { let mut web = Command::new("cmd"); + web.env("PATH", crate::nodejs::path_with(node_dir)); // --strictPort because without it Vite quietly takes the next free port, and the address // the launcher opens - and prints - would then be somebody else's server. web @@ -659,7 +661,16 @@ async fn start_web( format!("{} has no node_modules yet. Tick \"Build it first, every launch\" in Settings, which installs them, or run `npm install` there once.", dir.display()), )); } - let mut web = web_command(&dir); + // The launcher installs Node.js before a web run starts (Launcher::ensure_node), so this + // only guards a PC where that was refused - and says so, instead of npm's bare exit 1. + let Some(node_dir) = crate::nodejs::find() else { + return Err(failed( + emit, + Component::Web, + format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID), + )); + }; + let mut web = web_command(&dir, &node_dir); give_console(&mut web); let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?; // A cold start compiles Cesium, so this is patient where the others are not. @@ -1401,7 +1412,7 @@ mod tests { // Checked as ARGUMENTS rather than by reading the binary: these are short literals that // a release build merges away, so "is the flag in the .exe" answers nothing. A wrong // flag here would surface only as a failed launch. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]); // The bare `--` is load-bearing: without it npm eats the flags instead of passing them on. @@ -1418,7 +1429,7 @@ mod tests { fn the_web_draws_its_map_from_this_stacks_tile_server() { // Offline, the public tile host is unreachable; this stack's tile server still has // the PC's installed tiles. _WEB's vite.config.ts reads this variable. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); let tiles = cmd .get_envs() .find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL") @@ -1427,6 +1438,20 @@ mod tests { assert_eq!(tiles.as_deref(), Some("http://127.0.0.1:18081")); } + #[test] + fn npm_finds_the_node_the_launcher_found() { + // Right after the launcher installs Node.js, its own PATH does not name it yet; npm + // runs with the found folder first, so the launch goes on without a restart. + let node = Path::new(r"C:\Program Files\nodejs"); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node); + let path = cmd + .get_envs() + .find(|(key, _)| *key == "PATH") + .and_then(|(_, value)| value) + .expect("web_command sets PATH"); + assert_eq!(std::env::split_paths(path).next().as_deref(), Some(node)); + } + #[test] fn a_query_value_cannot_end_its_own_parameter() { // The address is built here, but the operator name comes from a text field.