This commit is contained in:
Kelorastus
2026-09-29 14:57:33 +02:00
parent b46b81ac12
commit afbb597044
3 changed files with 137 additions and 4 deletions
+133 -3
View File
@@ -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). /// The launcher's update feed on the download page (written by package_launcher.ps1).
const LAUNCHER_FEED: &str = "launcher-update.json"; 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<u64> {
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 `<dir>/launcher.log`, keeping one older file past 2 MB. Best effort: /// Append one line to `<dir>/launcher.log`, keeping one older file past 2 MB. Best effort:
/// a log that cannot be written never stops the launcher. /// 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` /// run its installer, which restarts the launcher. Runs first at start; `updating_self`
/// keeps anything from launching or installing meanwhile (the window reads "checking"). /// keeps anything from launching or installing meanwhile (the window reads "checking").
/// Offline, with no page configured, or with anything wrong, this launcher carries on. /// 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::<serde_json::Value>().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) { pub async fn update_self(&self, app: &AppHandle) {
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
// A launcher that ships on an ODIAN drive is part of the drive: installing a newer one // A launcher that ships on an ODIAN drive cannot take the ordinary update: that runs the
// would put it on this PC instead, beside an install it does not carry. // 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 { 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; return;
} }
let mcp_url = self.inner.lock().await.settings.mcp_url.clone(); let mcp_url = self.inner.lock().await.settings.mcp_url.clone();
@@ -2542,6 +2653,25 @@ mod tests {
std::fs::remove_dir_all(&web).ok(); 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] #[test]
fn only_another_operators_name_is_a_user_swap() { fn only_another_operators_name_is_a_user_swap() {
assert_eq!(swapped_user("m2op", Some("jimmy")), Some("m2op")); assert_eq!(swapped_user("m2op", Some("jimmy")), Some("m2op"));
+3
View File
@@ -36,6 +36,9 @@ pub fn run() {
} }
app.manage(launcher::Launcher::load(app.handle())?); 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 // 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. // this one), then a look at the site; the window shows "checking" meanwhile.
let handle = app.handle().clone(); let handle = app.handle().clone();
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher", "productName": "ODIAN Launcher",
"version": "0.1.34", "version": "0.1.36",
"identifier": "fr.odian.launcher", "identifier": "fr.odian.launcher",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",