Merge remote-tracking branch 'origin/main'
This commit is contained in:
+133
-3
@@ -317,6 +317,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<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:
|
||||
/// a log that cannot be written never stops the launcher.
|
||||
@@ -1630,12 +1674,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::<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) {
|
||||
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();
|
||||
@@ -2568,6 +2679,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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user