diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 7e893c9..6dcab82 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -304,6 +304,50 @@ fn holds_a_version(root: &Path) -> bool { /// The launcher's update feed on the download page (written by package_launcher.ps1). const LAUNCHER_FEED: &str = "launcher-update.json"; +/// The same, for a launcher that runs from an ODIAN drive and replaces its own exe. +const PORTABLE_FEED: &str = "launcher-portable.json"; + +/// Is `offered` a later version than `running`? Plain numeric compare, field by field. +fn newer(offered: &str, running: &str) -> bool { + fn parts(v: &str) -> Vec { + v.split(['.', '-']).map_while(|p| p.parse().ok()).collect() + } + let (a, b) = (parts(offered), parts(running)); + for i in 0..a.len().max(b.len()) { + let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); + if x != y { + return x > y; + } + } + false +} + +/// Put `bytes` where this executable is, moving the running one aside. +/// +/// The `.old` copy is what makes this safe to do to yourself: if the new exe never starts, +/// the previous one is still on the drive next to it. `prune_old_exe` removes it at the +/// next start, which only happens once the new one has run. +fn swap_exe(bytes: &[u8]) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| format!("cannot find this launcher: {e}"))?; + let old = exe.with_extension("old.exe"); + let fresh = exe.with_extension("new.exe"); + let _ = std::fs::remove_file(&old); + std::fs::write(&fresh, bytes).map_err(|e| format!("cannot write {}: {e}", fresh.display()))?; + std::fs::rename(&exe, &old).map_err(|e| format!("cannot move the running launcher aside: {e}"))?; + if let Err(e) = std::fs::rename(&fresh, &exe) { + let _ = std::fs::rename(&old, &exe); // put it back rather than leave the drive with none + return Err(format!("cannot put the new launcher in place: {e}")); + } + Ok(()) +} + +/// Delete the exe a portable update moved aside. Called at start: reaching here at all is +/// the new launcher proving it runs. +pub fn prune_old_exe() { + if let Ok(exe) = std::env::current_exe() { + let _ = std::fs::remove_file(exe.with_extension("old.exe")); + } +} /// Append one line to `/launcher.log`, keeping one older file past 2 MB. Best effort: /// a log that cannot be written never stops the launcher. @@ -1611,12 +1655,79 @@ impl Launcher { /// run its installer, which restarts the launcher. Runs first at start; `updating_self` /// keeps anything from launching or installing meanwhile (the window reads "checking"). /// Offline, with no page configured, or with anything wrong, this launcher carries on. + /// Update a launcher that runs from an ODIAN drive, by replacing its own exe. + /// + /// The feed is `launcher-portable.json`: a small document signed with the release key, the + /// same key and the same `signing_payload` rule as a pack manifest, naming the raw exe and + /// its sha256. Both are checked before anything is swapped - the signature says the DHP + /// meant to publish this, the hash says the bytes arrived whole. + /// + /// Windows will not let a running exe be overwritten, but it WILL let it be renamed, so + /// the old one is moved aside and the new one takes its name. The old copy is deleted at + /// the next start, which is to say once the new one has proved it runs. + async fn update_portable(&self, app: &AppHandle) { + let mcp_url = self.inner.lock().await.settings.mcp_url.clone(); + if mcp_url.is_empty() { + return; + } + let url = format!("{}/download/{PORTABLE_FEED}", mcp_url.trim_end_matches('/')); + let Ok(response) = self.http.get(&url).timeout(Duration::from_secs(20)).send().await else { + return; // offline is the normal case away from the office + }; + if !response.status().is_success() { + return; // a site that publishes no portable build simply has nothing to offer + } + let Ok(feed) = response.json::().await else { return }; + if let Err(e) = odian_packs::signing::verify(&feed, odian_packs::bundled_public_key()) { + self.log(app, "warn", format!("The portable launcher feed is not signed by the ODIAN release key: {e}")); + return; + } + let (Some(version), Some(file), Some(want_sha)) = ( + feed.get("version").and_then(|v| v.as_str()), + feed.get("file").and_then(|v| v.as_str()), + feed.get("sha256").and_then(|v| v.as_str()), + ) else { + return; + }; + if !newer(version, env!("CARGO_PKG_VERSION")) { + return; + } + // A file name from the feed must never escape the download folder. + if file.contains(['/', '\\']) || file.contains("..") { + self.log(app, "warn", format!("The portable launcher feed names a suspect file: {file}")); + return; + } + self.log(app, "info", format!("Updating this drive's launcher to {version}…")); + let bytes = match self.http.get(format!("{}/download/{file}", mcp_url.trim_end_matches('/'))) + .timeout(Duration::from_secs(300)).send().await + { + Ok(r) if r.status().is_success() => match r.bytes().await { + Ok(b) => b, + Err(e) => return self.log(app, "warn", format!("The new launcher did not download: {e}")), + }, + _ => return self.log(app, "warn", "The new launcher did not download."), + }; + let got = odian_packs::hashing::sha256_hex(&bytes); + if got != want_sha { + self.log(app, "warn", "The new launcher failed its checksum and was not installed."); + return; + } + if let Err(e) = swap_exe(&bytes) { + self.log(app, "warn", format!("Could not replace this drive's launcher: {e}")); + return; + } + self.log(app, "info", format!("This drive's launcher is now {version}. Restarting…")); + app.restart(); + } + pub async fn update_self(&self, app: &AppHandle) { use tauri_plugin_updater::UpdaterExt; - // A launcher that ships on an ODIAN drive is part of the drive: installing a newer one - // would put it on this PC instead, beside an install it does not carry. + // A launcher that ships on an ODIAN drive cannot take the ordinary update: that runs the + // NSIS setup, which installs onto whatever PC the drive is plugged into - leaving a + // launcher behind on a borrowed machine, pointed at an install that PC does not have. + // The drive's copy replaces its own exe instead, in place, from a signed raw build. if self.inner.lock().await.portable { - self.log(app, "info", "This launcher runs from its ODIAN drive: it is updated with the drive."); + self.update_portable(app).await; return; } let mcp_url = self.inner.lock().await.settings.mcp_url.clone(); @@ -2542,6 +2653,25 @@ mod tests { std::fs::remove_dir_all(&web).ok(); } + #[test] + fn a_drive_only_takes_a_genuinely_later_launcher() { + // Numeric, field by field. Text order would read 0.1.9 as later than 0.1.10 and pin + // every drive on the older build for the rest of the 0.1.x line. + assert!(newer("0.1.35", "0.1.34")); + assert!(newer("0.1.10", "0.1.9"), "ten is after nine"); + assert!(!newer("0.1.9", "0.1.10")); + assert!(!newer("0.1.34", "0.1.34"), "the same version is not an update"); + assert!(!newer("0.1.33", "0.1.34"), "never walk backwards"); + assert!(newer("0.2.0", "0.1.99")); + assert!(newer("1.0.0", "0.9.9")); + // A suffixed tag compares on its numbers; nothing after them counts. + assert!(newer("0.1.35-dhp", "0.1.34")); + assert!(!newer("0.1.34-dhp", "0.1.34")); + // Nonsense from a feed must not read as an update. + assert!(!newer("", "0.1.34")); + assert!(!newer("latest", "0.1.34")); + } + #[test] fn only_another_operators_name_is_a_user_swap() { assert_eq!(swapped_user("m2op", Some("jimmy")), Some("m2op")); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bbe6869..beb4cf9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,9 @@ pub fn run() { } app.manage(launcher::Launcher::load(app.handle())?); + // Reaching here is the proof a portable update worked, so the exe it moved aside can + // go. Before anything else: it costs one remove of a file that is usually absent. + launcher::prune_old_exe(); // First a newer launcher, if the download page has one (it installs and restarts // this one), then a look at the site; the window shows "checking" meanwhile. let handle = app.handle().clone(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fb16bdf..fa1a704 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ODIAN Launcher", - "version": "0.1.34", + "version": "0.1.36", "identifier": "fr.odian.launcher", "build": { "frontendDist": "../dist",