diff --git a/app/igneum-common/src/fetch.rs b/app/igneum-common/src/fetch.rs index 70e7a0f6d..0f25a4968 100644 --- a/app/igneum-common/src/fetch.rs +++ b/app/igneum-common/src/fetch.rs @@ -1,7 +1,7 @@ //! The over-the-air update's network side, as the miner does it (app/igneum-app/src/ota.rs): the manifest and its //! signature fetched with curl (macOS ships it; Windows 10 1803 and later ship curl.exe, so the engine carries no TLS -//! stack), verified before parsing; the installer or disk image downloaded next to the manifest and checked against -//! the manifest's sha256 and size. +//! stack), verified before parsing; the installer or disk image downloaded next to the manifest with resume (a +//! dropped line continues the .part file) and checked against the manifest's sha256 and size. use crate::manifest::{self, Manifest, PlatformEntry}; use std::path::{Path, PathBuf}; @@ -13,7 +13,8 @@ pub fn curl(args: &[&str], limit: Duration) -> Result<(), String> { c.args(args); let out = crate::run::run_timeout(&mut c, None, limit).ok_or("curl is not available")?; let code = out.lines().last().unwrap_or("").trim().to_string(); - if code.starts_with("200") { + // 206: a resumed download (-C -) answers partial content + if code.starts_with("200") || code.starts_with("206") { Ok(()) } else { Err(format!("http {}", if code.is_empty() { "no answer".to_string() } else { code })) @@ -40,12 +41,29 @@ pub fn fetch_manifest(url: &str, dir: &Path) -> Result { Ok(parsed) } +/// The file name a download keeps: the last path segment of the url, cleaned to [A-Za-z0-9.-_] (the miner's rule). pub fn file_name(url: &str) -> String { - url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download").to_string() + let name = url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download"); + let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_').collect(); + if clean.is_empty() { "download".into() } else { clean } } -/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already) -/// and checks size and sha256. Returns the path. +/// The .part file a download in progress writes next to the final name. +pub fn part_path(e: &PlatformEntry, dir: &Path) -> PathBuf { + dir.join(format!("{}.part", file_name(&e.url))) +} + +/// How much of the platform entry is on disk right now, 0..1 (the dashboard's progress bar). +pub fn progress(e: &PlatformEntry, dir: &Path) -> f64 { + if e.size == 0 { + return 0.0; + } + let have = std::fs::metadata(part_path(e, dir)).map(|m| m.len()).unwrap_or(0); + (have as f64 / e.size as f64).min(1.0) +} + +/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already), +/// resuming a .part file from an earlier try, and checks size and sha256. Returns the path. pub fn download(e: &PlatformEntry, dir: &Path) -> Result { let dest = dir.join(file_name(&e.url)); let ok = |p: &Path| -> bool { @@ -54,18 +72,36 @@ pub fn download(e: &PlatformEntry, dir: &Path) -> Result { if ok(&dest) { return Ok(dest); } - let tmp = dir.join(format!("{}.part", file_name(&e.url))); - curl_get(&e.url, &tmp, Duration::from_secs(1800))?; - let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + let _ = std::fs::remove_file(&dest); + let part = part_path(e, dir); + let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if have > e.size { + let _ = std::fs::remove_file(&part); + } + if have != e.size { + // -C - resumes a partial file; --retry covers a dropped connection; 2 hours for a slow line + curl(&["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-o", &part.display().to_string(), "-w", "%{http_code}", &e.url], Duration::from_secs(7260))?; + } + let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); if size != e.size { - let _ = std::fs::remove_file(&tmp); + let _ = std::fs::remove_file(&part); return Err(format!("the download is {size} bytes, the manifest says {}", e.size)); } - let sum = manifest::sha256_file(&tmp).map_err(|e| e.to_string())?; + let sum = manifest::sha256_file(&part).map_err(|e| e.to_string())?; if sum != e.sha256 { - let _ = std::fs::remove_file(&tmp); + let _ = std::fs::remove_file(&part); return Err("the download's sha256 does not match the manifest".into()); } - std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?; + std::fs::rename(&part, &dest).map_err(|e| e.to_string())?; Ok(dest) } + +#[cfg(test)] +mod tests { + #[test] + fn file_names_are_cleaned() { + assert_eq!(super::file_name("https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"), "Igneum-Wallet-0.1.1.dmg"); + assert_eq!(super::file_name("https://x/y/Setup%20.exe?x=1"), "Setup20.exe"); + assert_eq!(super::file_name("https://x/"), "download"); + } +} diff --git a/app/igneum-common/src/lib.rs b/app/igneum-common/src/lib.rs index 4a9683860..91adeefe0 100644 --- a/app/igneum-common/src/lib.rs +++ b/app/igneum-common/src/lib.rs @@ -6,7 +6,9 @@ //! - `platform`: directories, file permissions, opening a URL, start at login, keep awake, terminate, quarantine //! - `keys`: secp256k1 key, EVM address, EIP-55 checksum, the miner's plain `wallet.json` format //! - `manifest`: the signed over-the-air update manifest, byte-identical to app/igneum-app/src/manifest.rs -//! - `fetch`: the manifest fetch, the download and its sha256 check (through curl, like the miner) +//! - `fetch`: the manifest fetch, the download (resumed, as the miner's) and its sha256 check (through curl) +//! - `ota`: the apply side of an over-the-air update: the staged bundle, the detached helper that swaps and relaunches, +//! the pending/result files; from app/igneum-app/src/ota.rs with the app's names from `AppId` //! - `http`: the 127.0.0.1 dashboard server primitives (request parsing, the token path, the same-origin guard) and a //! small JSON-over-HTTP client for a node's Ethereum RPC on 127.0.0.1 //! - `run`: a command with a time limit @@ -17,6 +19,7 @@ pub mod fetch; pub mod http; pub mod keys; pub mod manifest; +pub mod ota; pub mod platform; pub mod run; @@ -31,7 +34,10 @@ pub struct AppId { pub host_exe: &'static str, /// The sub-folder of the shared data root this app writes under: "app" (the miner, as before) or "wallet". pub data_sub: &'static str, + /// The engine binary next to the window host: "igneum-app" or "igneum-wallet" (".exe" on Windows). The update + /// helper names it when it checks that the new app started. + pub engine_exe: &'static str, } -pub const MINER: AppId = AppId { name: "Igneum Miner", bundle: "network.igneum.miner", host_exe: "Igneum Miner.exe", data_sub: "app" }; -pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet" }; +pub const MINER: AppId = AppId { name: "Igneum Miner", bundle: "network.igneum.miner", host_exe: "Igneum Miner.exe", data_sub: "app", engine_exe: "igneum-app" }; +pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet", engine_exe: "igneum-wallet" }; diff --git a/app/igneum-common/src/ota.rs b/app/igneum-common/src/ota.rs new file mode 100644 index 000000000..8dd51c3fe --- /dev/null +++ b/app/igneum-common/src/ota.rs @@ -0,0 +1,524 @@ +//! The apply side of an over-the-air update, shared by both apps. Taken from app/igneum-app/src/ota.rs (the miner's +//! updater, 4 October 2026; the miner keeps its own copy until it moves to this crate) with the app's names filled in +//! from `AppId`: the staged bundle, the detached helper that swaps it in and relaunches, and the pending/result files +//! the old engine, the helper and the new engine pass around. The manifest check and the download live in +//! `crate::fetch`; when to apply is each app's own business (the miner waits for a safe mining moment, the wallet for +//! no send in flight). +//! +//! macOS: `stage` mounts the disk image (or unpacks the zip), copies the bundle next to the running one as +//! "..app.new" (same volume, so the swap is two renames) and checks the new engine answers --version with the +//! manifest's version. `launch_apply` re-hashes the download and the staged bundle, writes update-pending.json and +//! ota-apply.sh, starts the helper detached and returns `Launch::QuitNow`: the engine leaves through its quit path. +//! The helper waits for the engine, asks the window to quit (by bundle id), moves the old bundle to +//! ".app.previous", the staged one in, opens the new app, and puts the previous one back when the new app does +//! not start twice. The new engine counts its starts in update-pending.json; on the third start without +//! `HEALTHY_AFTER_S` healthy seconds it asks for `launch_rollback`. +//! Windows: the staged artefact is the Inno installer. `launch_apply` starts ota-apply.ps1 detached (CREATE_NO_WINDOW, +//! commit 0d123b3), which runs the installer /VERYSILENT first while the engine keeps running; the installer stops the +//! engine itself (api/quit) and relaunches the app with /IGNOTA=1. An unanswered administrator prompt comes back as +//! `deferred` in update-result.json. The wallet has never run this path (5 October 2026): untested there. + +#![allow(dead_code)] + +use crate::manifest::{self, PlatformEntry}; +use crate::AppId; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// A new version is healthy once it has run this long; the update is then complete and the leftovers go. +pub const HEALTHY_AFTER_S: u64 = 90; + +/// What launch_apply started. +#[derive(Debug, PartialEq)] +pub enum Launch { + /// macOS: the helper waits for this engine to exit; the engine leaves through its quit path now. + QuitNow, + /// Windows: the installer runs first while the engine keeps running; the installer stops the engine itself + /// once it is allowed to run. The engine stays up and watches update-result.json for a deferral. + InstallerRunning, +} + +/// What the old engine leaves for the new one (update-pending.json). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Pending { + pub from: String, + pub to: String, + pub at: f64, + pub starts: u32, + pub previous_installer: String, +} + +/// The helper's verdict (update-result.json). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HelperResult { + pub ok: bool, + pub version: String, + pub error: String, + pub rolled_back: bool, + pub deferred: bool, +} + +pub fn pending_path(app_dir: &Path) -> PathBuf { + app_dir.join("update-pending.json") +} + +pub fn result_path(app_dir: &Path) -> PathBuf { + app_dir.join("update-result.json") +} + +pub fn read_pending(app_dir: &Path) -> Option { + parse_pending(&std::fs::read_to_string(pending_path(app_dir)).ok()?) +} + +pub fn parse_pending(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + Some(Pending { from: s("from"), to: s("to"), at: v.get("at").and_then(|x| x.as_f64()).unwrap_or(0.0), starts: v.get("starts").and_then(|x| x.as_u64()).unwrap_or(0) as u32, previous_installer: s("previous_installer") }) +} + +pub fn write_pending(app_dir: &Path, p: &Pending) { + let v = json!({ "from": p.from, "to": p.to, "at": p.at, "starts": p.starts, "previous_installer": p.previous_installer, "platform": manifest::platform_name() }); + let _ = std::fs::write(pending_path(app_dir), v.to_string()); +} + +pub fn read_result(app_dir: &Path) -> Option { + parse_result(&std::fs::read_to_string(result_path(app_dir)).ok()?) +} + +pub fn parse_result(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + let b = |k: &str| v.get(k).and_then(|x| x.as_bool()).unwrap_or(false); + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + Some(HelperResult { ok: b("ok"), version: s("version"), error: s("error"), rolled_back: b("rolled_back"), deferred: b("deferred") }) +} + +/// "Igneum-Wallet-Setup-0.1.1.exe": the installer name the Windows packaging gives a version. +pub fn installer_name_for(app: AppId, version: &str) -> String { + format!("{}-Setup-{version}.exe", app.name.replace(' ', "-")) +} + +/// The staged bundle's path next to the running one (macOS). +pub fn staged_path(app: AppId, bundle: &Path) -> Option { + bundle.parent().map(|p| p.join(format!(".{}.app.new", app.name))) +} + +/// Starts a process that outlives the engine (stdio closed, own session on unix, no window on Windows). +pub fn spawn_detached(c: &mut Command) -> Result<(), String> { + use std::process::Stdio; + c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000 | 0x0000_0008); // CREATE_NO_WINDOW | DETACHED_PROCESS + } + c.spawn().map(|_| ()).map_err(|e| format!("cannot start the helper: {e}")) +} + +/// The engine's own environment for the helper's relaunch (a test run on a private devnet or a scratch data folder): +/// every variable whose name starts with one of `prefixes`, written to /ota-relaunch.env. "" when there is +/// none, and the helper opens the bundle through LaunchServices. +pub fn write_env_file(app_dir: &Path, prefixes: &[&str]) -> String { + let vars: Vec = std::env::vars().filter(|(k, _)| prefixes.iter().any(|p| k.starts_with(p))).map(|(k, v)| format!("{k}={v}")).collect(); + if vars.is_empty() { + return String::new(); + } + let p = app_dir.join("ota-relaunch.env"); + if std::fs::write(&p, vars.join("\n") + "\n").is_ok() { p.display().to_string() } else { String::new() } +} + +/// macOS: the new bundle next to the running one (same volume, so the swap is two renames); Windows: the installer +/// is the staged artefact. Err("manual: ...") when the engine cannot swap itself (not in a bundle, a read-only +/// Applications folder): the window then offers the download instead. +#[allow(unused_variables)] +pub fn stage(app: AppId, e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result { + #[cfg(target_os = "macos")] + { + let bundle_name = format!("{}.app", app.name); + let bundle = crate::platform::bundle_path().ok_or(format!("manual: the engine is not running from {bundle_name}; open the downloaded disk image and drag the app to Applications"))?; + let parent = bundle.parent().ok_or("no parent folder")?; + let staged = staged_path(app, &bundle).ok_or("no parent folder")?; + let _ = std::fs::remove_dir_all(&staged); + // writable? a user-owned /Applications is; a managed Mac may not be + if std::fs::create_dir(&staged).is_err() { + return Err(format!("manual: {} is not writable; open the downloaded disk image and drag the app over the old one", parent.display())); + } + let _ = std::fs::remove_dir(&staged); + let work = dir.join("unpack"); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work).map_err(|e| e.to_string())?; + let source: PathBuf; + let mut mounted: Option = None; + if e.kind == "dmg" { + let mnt = work.join("mnt"); + std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?; + let out = crate::run::run_timeout(Command::new(crate::platform::tool("hdiutil")).args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default(); + if !mnt.join(&bundle_name).is_dir() { + return Err(format!("the disk image has no {bundle_name} ({})", out.lines().last().unwrap_or("hdiutil said nothing"))); + } + mounted = Some(mnt.clone()); + source = mnt.join(&bundle_name); + } else { + let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default(); + source = find_app(&work, &bundle_name).ok_or(format!("the zip has no {bundle_name} ({})", out.lines().last().unwrap_or("")))?; + } + let engine = staged.join("Contents/MacOS").join(app.engine_exe); + let r = (|| -> Result<(), String> { + let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default(); + if !engine.is_file() { + return Err(format!("copy failed: {}", out.lines().last().unwrap_or(""))); + } + // the quarantine flag comes off only after the file this bundle came from verified again, now + let again = manifest::sha256_file(file).map_err(|e| e.to_string())?; + if again != e.sha256 { + return Err("the download changed while it was being unpacked; discarded".into()); + } + let _ = Command::new(crate::platform::tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(&staged).output(); + let v = crate::run::run_timeout(Command::new(&engine).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default(); + let want = format!("{} {version}", app.engine_exe); + if v.trim() != want { + return Err(format!("the new engine answers '{}' to --version, the manifest says {version}", v.trim())); + } + Ok(()) + })(); + if let Some(m) = mounted { + let _ = Command::new(crate::platform::tool("hdiutil")).args(["detach", "-force", &m.display().to_string()]).output(); + } + let _ = std::fs::remove_dir_all(&work); + if let Err(err) = r { + let _ = std::fs::remove_dir_all(&staged); + return Err(err); + } + Ok(staged) + } + #[cfg(windows)] + { + if e.kind != "inno-setup" { + return Err(format!("kind '{}' is not an installer", e.kind)); + } + Ok(file.to_path_buf()) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + Err("manual: no automatic install on this platform".into()) + } +} + +fn find_app(dir: &Path, bundle_name: &str) -> Option { + let rd = std::fs::read_dir(dir).ok()?; + for e in rd.flatten() { + let p = e.path(); + if p.file_name().map(|n| n == bundle_name).unwrap_or(false) && p.is_dir() { + return Some(p); + } + if p.is_dir() { + if let Some(f) = find_app(&p, bundle_name) { + return Some(f); + } + } + } + None +} + +/// Windows: an install under Program Files was made by an administrator installer. +pub fn under_program_files(dir: &Path) -> bool { + let d = dir.to_string_lossy().to_ascii_lowercase(); + ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"].iter().filter_map(|k| std::env::var(k).ok()).any(|pf| !pf.is_empty() && d.starts_with(&pf.to_ascii_lowercase())) +} + +/// Everything launch_apply needs. `staged_digest` is manifest::digest_dir of the staged bundle at stage time (macOS); +/// `sha256` the manifest's for the installer (Windows); `env_file` from write_env_file or "". +pub struct Apply<'a> { + pub app: AppId, + pub app_dir: &'a Path, + pub current: &'a str, + pub version: &'a str, + pub staged: &'a Path, + pub staged_digest: &'a str, + pub sha256: &'a str, + pub host_pid: u32, + pub env_file: String, + /// Windows: the installer of the version now running, kept in updates/ as the rollback target ("" when none) + pub previous_installer: String, +} + +/// Writes update-pending.json and the helper, starts the helper detached. The caller verified the download and the +/// staged bundle a moment ago (sha256 and digest_dir); the helper checks the digest once more before the swap. +pub fn launch_apply(a: &Apply) -> Result { + write_pending(a.app_dir, &Pending { from: a.current.to_string(), to: a.version.to_string(), at: crate::platform::unix_now_f(), starts: 0, previous_installer: a.previous_installer.clone() }); + let _ = std::fs::remove_file(result_path(a.app_dir)); + let result = result_path(a.app_dir); + #[cfg(target_os = "macos")] + { + let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", a.app.name))?; + if a.staged_digest.is_empty() { + return Err("no digest for the staged app".into()); + } + let script = a.app_dir.join("ota-apply.sh"); + std::fs::write(&script, mac_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?; + let args = ["apply".to_string(), std::process::id().to_string(), a.host_pid.to_string(), bundle.display().to_string(), a.staged.display().to_string(), a.version.to_string(), result.display().to_string(), a.env_file.clone(), a.staged_digest.to_string()]; + spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?; + Ok(Launch::QuitNow) + } + #[cfg(windows)] + { + let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; + let script = a.app_dir.join("ota-apply.ps1"); + std::fs::write(&script, win_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?; + let mut c = Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ + "-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &a.staged.display().to_string(), "-Version", a.version, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", a.sha256, + ]); + spawn_detached(&mut c)?; + Ok(Launch::InstallerRunning) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = result; + Err("automatic apply is not supported on this platform".into()) + } +} + +/// The new version failed to start twice: the helper restores the previous one (macOS: the .previous bundle; +/// Windows: the previous installer kept in updates/). The caller exits afterwards. +pub fn launch_rollback(app: AppId, app_dir: &Path, p: &Pending, host_pid: u32, env_file: String) -> Result<(), String> { + let result = result_path(app_dir); + #[cfg(target_os = "macos")] + { + let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", app.name))?; + let script = app_dir.join("ota-apply.sh"); + std::fs::write(&script, mac_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?; + let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), bundle.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file, String::new()]; + spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?; + Ok(()) + } + #[cfg(windows)] + { + let _ = (host_pid, env_file); + if p.previous_installer.is_empty() || !Path::new(&p.previous_installer).is_file() { + return Err("no previous installer kept; reinstall from igneum.network".into()); + } + let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; + let script = app_dir.join("ota-apply.ps1"); + std::fs::write(&script, win_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?; + let sha = manifest::sha256_file(Path::new(&p.previous_installer)).unwrap_or_default(); + let mut c = Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ + "-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), + ]); + spawn_detached(&mut c)?; + Ok(()) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = (app, p, host_pid, env_file, result); + Err("rollback is not supported on this platform".into()) + } +} + +/// The macOS helper with this app's names filled in. +pub fn mac_helper(app: AppId) -> String { + fill(MAC_HELPER, app) +} + +/// The Windows helper with this app's names filled in. +pub fn win_helper(app: AppId) -> String { + fill(WIN_HELPER, app) +} + +fn fill(template: &str, app: AppId) -> String { + template.replace("@APP_NAME@", app.name).replace("@ENGINE@", app.engine_exe).replace("@BUNDLE@", app.bundle) +} + +const MAC_HELPER: &str = r#"#!/bin/bash +# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand. +# bash ota-apply.sh apply|rollback [env file] [digest] +# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running +# bundle to ".previous" and the staged one in, opens the new app; if the new app does not start twice, puts the +# previous one back. rollback: the previous bundle back, the failed one aside. Writes for the engine. +MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}"; DIGEST="${9:-}" +LOG="$(dirname "$RESULT")/ota-apply.log" +exec >>"$LOG" 2>&1 +echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER" +gone() { ! kill -0 "$1" 2>/dev/null; } +wait_gone() { local p="$1" n="$2"; while [ "$n" -gt 0 ] && ! gone "$p"; do sleep 0.5; n=$((n-1)); done; gone "$p"; } +result() { printf '{"ok":%s,"version":"%s","error":"%s","rolled_back":%s,"at":%s}\n' "$1" "$VER" "$2" "$3" "$(date +%s)" > "$RESULT.tmp" && mv "$RESULT.tmp" "$RESULT"; } +PREV="$APP.previous" +FAILED="$APP.failed" +ENGINE="$APP/Contents/MacOS/@ENGINE@" +# the same digest the engine computed when it staged the bundle (manifest.rs digest_dir): every regular file, +# byte-sorted relative path, "path\nsha256\n" per file, sha256 of the whole +digest_dir() { (cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1; } +started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; } +# a test run carries its environment to the relaunch (open -n cannot); IGNEUM_OTA_RELAUNCH_ENGINE=1 in that file runs +# the engine alone (no window, a scratch test); a normal run goes through LaunchServices +launch() { + if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then + (set -a; . "$ENVF"; set +a; if [ "${IGNEUM_OTA_RELAUNCH_ENGINE:-}" = 1 ]; then /usr/bin/nohup "$ENGINE" --no-open >/dev/null 2>&1 & else /usr/bin/nohup "$APP/Contents/MacOS/@APP_NAME@" >/dev/null 2>&1 & fi) + else + /usr/bin/open -n "$APP" + fi +} +wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; } +if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then + /usr/bin/osascript -e 'tell application id "@BUNDLE@" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null + wait_gone "$HPID" 80 || { echo "window $HPID still running after 40 s; ending it"; kill -9 "$HPID" 2>/dev/null; sleep 1; } +fi +# anything else from this bundle (a stray engine of an older run) +pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5 +case "$MODE" in + apply) + [ -d "$NEW" ] || { result false "the staged app is missing" false; launch; exit 1; } + if [ -n "$DIGEST" ]; then + have="$(digest_dir "$NEW")" + if [ "$have" != "$DIGEST" ]; then echo "digest mismatch: staged $have, verified $DIGEST"; rm -rf "$NEW"; result false "the staged app changed since it was verified; not installed" false; launch; exit 1; fi + echo "staged bundle digest verified" + else + echo "no digest given; not installing an unverified bundle"; result false "no digest for the staged app" false; launch; exit 1 + fi + rm -rf "$PREV" + mv "$APP" "$PREV" || { result false "could not move the old app aside" false; launch; exit 1; } + mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; launch; exit 1; } + /usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null # only a bundle whose digest just verified + echo "swapped; opening $APP" + launch || echo "open failed" + if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi + echo "the new app did not start within 30 s; opening it once more" + launch || true + if started_ok; then result true "" false; echo "$VER is running (second try)"; exit 0; fi + echo "the new app did not start twice; restoring the previous version" + pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 1 + rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" + launch + result false "@APP_NAME@ $VER did not start twice; the previous version was restored" true + ;; + rollback) + [ -d "$PREV" ] || { result false "no previous version kept to restore" false; launch; exit 1; } + rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" + launch + result false "@APP_NAME@ $VER did not stay up twice; the previous version was restored" true + ;; + *) echo "unknown mode $MODE"; exit 2 ;; +esac +"#; + +const WIN_HELPER: &str = r#"# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand. +# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid -Installer -Version -Result -InstallDir -Sha256 +# The installer runs FIRST, while the engine keeps running (4 October 2026: two unattended PCs sat stopped at an +# administrator prompt nobody could click). A per-user installer (PrivilegesRequired=lowest) needs no prompt; an older +# administrator installer raises one through ShellExecute. Only when the installer actually runs does its +# PrepareToInstall step stop the engine (api/quit), replace the files and relaunch the app (/IGNOTA=1). A declined, +# timed-out or unanswered prompt leaves the engine running: the result says deferred:true. The old app is relaunched +# only when the engine is gone and the install did not happen. +param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '') +$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log' +function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) } +function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred) { + $o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s) } + ($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII +} +function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) } +function Relaunch() { + if (EngineAlive) { return } + $exe = Join-Path $InstallDir '@ENGINE@.exe' + if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null } +} +Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps running until the installer runs)" +if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 } +# the installer is hashed again right before it runs +if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 } +$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower() +if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false; exit 1 } +Log 'installer sha256 verified' +$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log' +$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"')) +try { + # no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or + # timed-out prompt comes back here as an exception with the engine still running + $p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru + if ($p.ExitCode -eq 0) { + if ($Mode -eq 'rollback') { Done $false "@APP_NAME@ $Version did not stay up twice; the previous version was reinstalled" $true $false } + else { Done $true '' $false $false } + Log 'installer exit 0' + exit 0 + } + Log ("installer exit " + $p.ExitCode) + Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false + Relaunch + exit 1 +} catch { + $msg = $_.Exception.Message + Log ("installer did not run: " + $msg) + Log 'OTA: waiting for administrator approval; the engine keeps running; the update waits for the next time someone is at this PC' + Done $false ("waiting for administrator approval (" + $msg + ")") $false $true + Relaunch + exit 1 +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helpers_carry_the_apps_names_and_no_placeholder() { + for app in [crate::MINER, crate::WALLET] { + for text in [mac_helper(app), win_helper(app)] { + for ph in ["@APP_NAME@", "@ENGINE@", "@BUNDLE@"] { + assert!(!text.contains(ph), "{ph} was left in the helper for {}", app.name); + } + assert!(text.contains(app.name)); + } + let m = mac_helper(app); + assert!(m.contains(&format!("ENGINE=\"$APP/Contents/MacOS/{}\"", app.engine_exe))); + assert!(m.contains(&format!("tell application id \"{}\" to quit", app.bundle))); + assert!(m.contains(&format!("\"$APP/Contents/MacOS/{}\"", app.name))); + assert!(win_helper(app).contains(&format!("'{}.exe'", app.engine_exe))); + } + assert!(mac_helper(crate::WALLET).contains("Igneum Wallet $VER did not start twice")); + assert!(!mac_helper(crate::WALLET).contains("Miner")); + } + + #[test] + fn pending_and_result_round_trip() { + let dir = std::env::temp_dir().join(format!("igneum-ota-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = Pending { from: "0.1.0".into(), to: "0.1.1".into(), at: 1.5, starts: 2, previous_installer: String::new() }; + write_pending(&dir, &p); + assert_eq!(read_pending(&dir), Some(p)); + assert!(std::fs::read_to_string(pending_path(&dir)).unwrap().contains(&format!("\"platform\":\"{}\"", manifest::platform_name()))); + assert_eq!(parse_pending("nope"), None); + let r = parse_result(r#"{"ok":false,"version":"0.1.1","error":"did not start","rolled_back":true,"at":1}"#).unwrap(); + assert_eq!(r, HelperResult { ok: false, version: "0.1.1".into(), error: "did not start".into(), rolled_back: true, deferred: false }); + assert!(parse_result(r#"{"ok":true,"version":"0.1.1","error":"","rolled_back":false,"deferred":true}"#).unwrap().deferred); + assert_eq!(read_result(&dir), None); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn names() { + assert_eq!(installer_name_for(crate::WALLET, "0.1.1"), "Igneum-Wallet-Setup-0.1.1.exe"); + assert_eq!(installer_name_for(crate::MINER, "0.3.5"), "Igneum-Miner-Setup-0.3.5.exe"); + assert_eq!(staged_path(crate::WALLET, Path::new("/Applications/Igneum Wallet.app")).unwrap(), PathBuf::from("/Applications/.Igneum Wallet.app.new")); + } + + #[test] + fn env_file_takes_only_the_prefixes() { + let dir = std::env::temp_dir().join(format!("igneum-ota-env-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("IGNEUM_OTA_TEST_X", "1"); + let p = write_env_file(&dir, &["IGNEUM_OTA_TEST_"]); + let text = std::fs::read_to_string(&p).unwrap(); + assert!(text.contains("IGNEUM_OTA_TEST_X=1")); + assert!(!text.contains("PATH=")); + assert_eq!(write_env_file(&dir, &["NO_SUCH_PREFIX_ZZ_"]), ""); + std::env::remove_var("IGNEUM_OTA_TEST_X"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index 8e799b773..c519e31b8 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.0" +version = "0.1.1" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index fe73bb7a7..811eeb3fc 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.0" +version = "0.1.1" edition = "2021" description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1" license = "MIT" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md new file mode 100644 index 000000000..5d740e2e6 --- /dev/null +++ b/app/igneum-wallet/README.md @@ -0,0 +1,72 @@ +# Igneum Wallet + +Desktop wallet on the miner's bones: a Rust engine (`src/`) that keeps the key encrypted, signs, reads a node and +verifies finality certificates, plus a window served on 127.0.0.1 (`ui/`). macOS host: `app/mac/IgneumWallet.swift`; +packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet.iss`. Shared code with the miner: +`app/igneum-common`. + +## Versions + +| Version | Date | What | +|---|---|---| +| 0.1.0 | 4 Oct 2026 | first DMG; built before `/coin.png` existed, so the coin on the home screen is blank; updates download and offer "Open the download" only | +| 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) | + +## Over-the-air updates (src/updater.rs, igneum-common/src/{fetch,ota}.rs) + +The signed manifest `igneum-wallet-latest.json` (+ `.sig`, the miner's Ed25519 key) is published with +`packaging/ota/publish-manifest.sh --product wallet --version --mac packaging/mac/dist/Igneum-Wallet-.dmg --notes "..." --deploy`. +The engine checks it 25 s after start, then hourly (10 minutes after an error), and from Settings > Check for updates. + +States (`state.update.status`, what the banner says): + +| State | Meaning | +|---|---| +| off | the build has no manifest URL | +| unknown | not checked yet | +| checking | fetching and verifying the manifest | +| current | this is the latest version | +| available | a newer version has a build for this platform; the download starts at once | +| downloading | curl with resume into `/updates/`; size and sha256 checked against the manifest | +| staging | macOS: the DMG mounted, the bundle copied next to the running one as `.Igneum Wallet.app.new`, its engine asked `--version`, the bundle digested; Windows: the installer is the staged artefact | +| ready | waits for the safe moment (below); "Install now" applies at once; "Later" hides the banner for that version only | +| applying | the download and the staged bundle re-verified, `update-pending.json` written, the helper started; macOS: the engine quits and the helper swaps the bundle and opens the new app | +| deferred | Windows only: the installer's administrator prompt was not answered; retried in 6 hours or on Install now | +| manual | the engine cannot swap itself (not in a bundle, Applications not writable): "Open the download" | +| error | what failed, in `state.update.error`; a version whose apply failed is never re-applied by itself | + +The setting "Install updates by itself when nothing is being sent" (`settings.json: auto_update`) defaults to on. +The safe moment is no send in flight: no `/api/send` running, no quote given in the last 180 s (a confirm screen may +be open), no sent transaction still waiting for its block, no create flow half way. A version below the manifest's +`min_supported_version` installs at once. + +Rollback: the macOS helper puts `Igneum Wallet.app.previous` back when the new app does not start twice. The new +engine counts its starts in `update-pending.json`; on the third start without 90 healthy seconds it restores the +previous version (never below `min_supported_version`). The window shows "Updated from X" on the first run after an +update and "Rolled back: ..." after a restore. + +Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA%\igneum\wallet\`): `updates/` +(manifest, download), `update-pending.json`, `update-result.json`, `ota-apply.sh` or `ota-apply.ps1`, +`ota-apply.log`, `failed-versions.json`, `ota-relaunch.env` (test runs only). + +### Verified (5 October 2026) + +- Unit tests: manifest parse, version comparison, plan, safe moment (`cargo test` in `app/igneum-wallet`); helper + templates, pending/result files, env file, digest recipe (`cargo test` in `app/igneum-common`). +- End to end on this Mac with a scratch copy of the 0.1.1 bundle against a 0.1.2 test manifest served from + 127.0.0.1 (`publish-manifest.sh --dest --base-url http://127.0.0.1:`; the engine run with + `IGNEUM_APP_DATA`, `IGNEUM_WALLET_UPDATE_MANIFEST`, `IGNEUM_WALLET_UPDATE_FIRST_SECS=3`, `IGNEUM_OTA_RELAUNCH_ENGINE=1` + so the helper relaunches the engine alone): check, download, stage, apply, swap, relaunch as 0.1.2, + "updated to Igneum Wallet 0.1.2 from 0.1.1". + +### Untested + +- The Windows path (installer first, `/IGNOTA=1`, deferral on an unanswered prompt): copied from the miner's, never + run for the wallet. Needs the wallet installer on the GitHub runner and a PC. +- The relaunch through LaunchServices (`open -n`) with the real window host, and the host quitting by bundle id + (`network.igneum.wallet`): the scratch test relaunches the engine alone. The first real run is 0.1.1 -> 0.1.2 on + the project lead's Mac. +- The rollback paths (the helper's "did not start twice", the engine's third-start restore) and `deferred`. +- A version below `min_supported_version` (no wallet manifest has set one). +- Code signatures: bundles are signed ad hoc by the packaging script; the updater verifies the manifest's sha256 and + the staged bundle's digest, not a Developer ID signature (the miner does the same). diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index a19d4873f..829980ba0 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -41,6 +41,15 @@ impl Settings { pub fn load(p: &std::path::Path) -> Settings { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } + pub fn save(&self, p: &std::path::Path) { + if let Some(d) = p.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Ok(t) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(p, t); + igneum_common::platform::lock_permissions(p, false); + } + } } pub struct Paths { @@ -56,6 +65,10 @@ pub enum Cmd { Refresh, CheckUpdate, OpenUpdate, + InstallUpdate, + AutoUpdate(bool), + /// the over-the-air updater's threads report here (src/updater.rs) + Ota(crate::updater::Event), /// a transaction this wallet just sent: watch it from the first tick Sent(Entry), } @@ -79,7 +92,6 @@ pub struct Shared { pub token: String, pub state: Mutex, pub rings: Mutex, - #[allow(dead_code)] // read by the window through state; kept for the next settings pub settings: Mutex, pub paths: Paths, pub packaged: Packaged, @@ -98,6 +110,18 @@ pub struct Shared { pub evm: Mutex>, pub verified: Mutex>, pub history: Mutex>, + /// /api/send calls running right now (the updater waits for zero) + sends: std::sync::atomic::AtomicU32, + /// when the last quote was given: a confirm screen may be open for QUOTE_HOLDS_S after it + last_quote: Mutex>, +} + +/// Counts one /api/send from entry to exit, whatever the outcome. +struct SendGuard<'a>(&'a Shared); +impl Drop for SendGuard<'_> { + fn drop(&mut self) { + self.0.sends.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } } impl Shared { @@ -145,9 +169,25 @@ impl Shared { evm: Mutex::new(None), verified: Mutex::new(None), history: Mutex::new(None), + sends: std::sync::atomic::AtomicU32::new(0), + last_quote: Mutex::new(None), } } + /// A send is in flight: /api/send is running, or a quote was given in the last QUOTE_HOLDS_S (the confirm + /// screen may be open). The engine adds "a sent transaction not yet in a block" from its watch list. + pub fn send_in_flight(&self) -> bool { + if self.sends.load(std::sync::atomic::Ordering::SeqCst) > 0 { + return true; + } + self.last_quote.lock().unwrap().map(|t| t.elapsed() < Duration::from_secs(crate::updater::QUOTE_HOLDS_S)).unwrap_or(false) + } + + /// The create flow is half way: the 24 words are on the screen, waiting for the confirmation. + pub fn creating(&self) -> bool { + self.pending_words.lock().unwrap().is_some() + } + /// IGNEUM-WALLET version= machine= platform= node=: the first line of the log, as the miner's /// IGNEUM-APP header, so the console parses either. pub fn header(&self) -> String { @@ -419,11 +459,14 @@ impl Shared { if total > balance { return Err(format!("not enough IGN: {} needed with the fee, {} in the wallet", crate::evm::ign(total, 6), crate::evm::ign(balance, 6))); } + *self.last_quote.lock().unwrap() = Some(Instant::now()); Ok(Quote { to, value: value.to_string(), gas, base_fee: base.to_string(), tip: tip.to_string(), max_fee: max_fee.to_string(), fee_max: fee_max.to_string(), total_max: total.to_string(), chain_id, nonce }) } /// Signs and sends what the window confirmed (the quote it was shown, verbatim). pub fn send_tx(&self, q: &Quote) -> Result { + self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _guard = SendGuard(self); let to = q.to.to_ascii_lowercase(); if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { return Err("refused: bad or zero address".into()); @@ -506,14 +549,23 @@ pub struct Engine { impl Engine { pub fn new(shared: Arc, rx: Receiver, wrapper: bool) -> Engine { - let url = std::env::var("IGNEUM_WALLET_UPDATE_MANIFEST").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| shared.packaged.update_manifest.clone()); - let updater = crate::updater::Updater::new(url, VERSION, &shared.paths.app_dir); + let updater = crate::updater::Updater::new(&shared); let now = Instant::now(); Engine { shared, rx, wrapper, grpc: None, own: None, own_plan: None, updater, last_source_try: now - Duration::from_secs(60), last_poll: now - Duration::from_secs(60), last_finality: now - Duration::from_secs(60), last_scan: now - Duration::from_secs(60), last_state_line: now, chain_name: String::new(), watch: vec![], scan_done_once: false } } pub fn run(mut self) { self.shared.log(&self.shared.header()); + if self.updater.needs_rollback() { + // this version died twice before it was healthy: the helper puts the previous one back + match self.updater.launch_rollback(&self.shared, self.host_pid()) { + Ok(()) => { + self.quit(); + return; + } + Err(e) => self.shared.event("error", &format!("rollback not possible: {e}")), + } + } loop { while let Ok(c) = self.rx.try_recv() { match c { @@ -525,7 +577,10 @@ impl Engine { self.last_poll = Instant::now() - Duration::from_secs(60); self.last_scan = Instant::now() - Duration::from_secs(60); } - Cmd::CheckUpdate => self.check_update(), + Cmd::CheckUpdate => self.updater.check_now(&self.shared), + Cmd::InstallUpdate => self.updater.install_now(&self.shared), + Cmd::AutoUpdate(on) => self.updater.set_auto(&self.shared, on), + Cmd::Ota(ev) => self.updater.event(&self.shared, ev), Cmd::OpenUpdate => { if let Err(e) = self.updater.open_file() { self.shared.event("error", &format!("could not open the download: {e}")); @@ -555,8 +610,11 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } - if self.updater.due() { - self.check_update(); + let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; + if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { + if self.apply_update() { + return; + } } if self.wrapper && self.last_state_line.elapsed() >= Duration::from_secs(2) { self.last_state_line = Instant::now(); @@ -567,6 +625,59 @@ impl Engine { } } + /// Hands over to the update helper. macOS: the engine then leaves through the quit path (the node stops, EXIT for + /// the window) and returns true; the helper waits for this process to end before it swaps the app. Windows: the + /// installer runs first and stops this engine itself; false, the loop goes on. + fn apply_update(&mut self) -> bool { + let v = self.updater.version(); + match self.updater.launch_apply(&self.shared, self.host_pid()) { + Ok(igneum_common::ota::Launch::QuitNow) => { + { + let mut st = self.shared.state.lock().unwrap(); + st.update.applying = true; + st.update.status = "applying".into(); + st.update.wait = String::new(); + } + self.shared.event("info", &format!("installing Igneum Wallet {v}: the app closes and opens again by itself")); + if self.wrapper { + println!("STATE {}", self.shared.wrapper_state()); + let _ = std::io::stdout().flush(); + } + self.quit(); + true + } + Ok(igneum_common::ota::Launch::InstallerRunning) => { + { + let mut st = self.shared.state.lock().unwrap(); + st.update.applying = true; + st.update.status = "applying".into(); + st.update.wait = "the installer is starting; if Windows asks for permission the wallet keeps running until it is given".into(); + } + self.shared.event("info", &format!("installing Igneum Wallet {v}: the installer runs first, then the app opens again")); + false + } + Err(e) => { + self.shared.event("error", &format!("the update could not start: {e}")); + let mut st = self.shared.state.lock().unwrap(); + st.update.error = e; + st.update.status = "error".into(); + false + } + } + } + + /// The window host's pid when the engine runs under one (macOS: the helper asks it to quit). + fn host_pid(&self) -> u32 { + #[cfg(unix)] + { + if self.wrapper { unsafe { libc::getppid() as u32 } } else { 0 } + } + #[cfg(not(unix))] + { + 0 + } + } + fn quit(&mut self) { self.shared.state.lock().unwrap().quitting = true; self.shared.lock(); @@ -923,24 +1034,4 @@ impl Engine { } } } - - fn check_update(&mut self) { - if self.updater.url.is_empty() { - return; - } - self.shared.state.lock().unwrap().update.status = "checking".into(); - let r = self.updater.check(); - let mut st = self.shared.state.lock().unwrap(); - st.update.status = self.updater.status.clone(); - st.update.error = self.updater.error.clone(); - st.update.checked_at = self.updater.checked_at; - st.update.version = self.updater.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); - st.update.notes = self.updater.manifest.as_ref().map(|m| m.notes.clone()).unwrap_or_default(); - st.update.file = self.updater.file.as_ref().map(|f| f.display().to_string()).unwrap_or_default(); - drop(st); - match r { - Ok(m) => self.shared.log(&format!("update check: {m}")), - Err(e) => self.shared.log(&format!("update check failed: {e}")), - } - } } diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index 08abcc1bd..14d617786 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -145,6 +145,15 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + shared.send(Cmd::InstallUpdate); + Ok(json!({ "ok": true })) + } + "/api/update/auto" => { + let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; + shared.send(Cmd::AutoUpdate(on)); + Ok(json!({ "ok": true })) + } "/api/open" => { let url = s("url").ok_or("url missing")?; if url.starts_with("https://") || url.starts_with("http://") { diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 67b9dd22a..165a91fca 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -33,20 +33,37 @@ pub struct FinalityView { pub message: String, } +/// The over-the-air updater (src/updater.rs), as the miner's. #[derive(Clone, Serialize, Default)] pub struct UpdateState { - pub status: String, // unknown | checking | current | available | downloading | ready | error | off + pub status: String, // off | unknown | checking | current | available | downloading | staging | ready | applying | deferred | manual | error pub version: String, + pub url: String, pub notes: String, - pub file: String, + pub file: String, // the downloaded disk image or installer (the manual path opens it) pub error: String, pub checked_at: f64, + pub available: bool, + pub downloaded: bool, + pub ready: bool, + pub applying: bool, + pub progress: f64, // 0..1 of the download + pub size: u64, + pub auto: bool, // settings: install by itself when nothing is being sent + pub wait: String, // why it has not applied yet, in the window's words + pub urgent: bool, // this version is below min_supported_version: no waiting + pub urgent_text: String, + pub unsupported: bool, + pub min_supported: String, + pub updated_from: String, // set on the first run after an update + pub rolled_back: String, // set when the helper restored the previous version } #[derive(Clone, Serialize, Default)] pub struct SettingsState { pub start_at_login: bool, pub network: String, + pub auto_update: bool, } #[derive(Clone, Serialize)] diff --git a/app/igneum-wallet/src/updater.rs b/app/igneum-wallet/src/updater.rs index e38fd90a9..77d6de29d 100644 --- a/app/igneum-wallet/src/updater.rs +++ b/app/igneum-wallet/src/updater.rs @@ -1,84 +1,617 @@ -//! Over-the-air updates for the wallet, the first cut: the signed manifest (`igneum-wallet-latest.json`, the same -//! Ed25519 key as the miner's, igneum_common::manifest) checked an hour apart, the disk image or installer downloaded -//! and checked against the manifest's sha256, then "Install now" opens it. No unattended swap yet: the wallet has no -//! safe-moment logic to borrow from the miner (nothing mines here) and the macOS swap helper lives in the miner's -//! src/ota.rs; that is the follow-up. +//! Over-the-air updates for the wallet, v2 (5 October 2026): unattended, on the miner's bones (app/igneum-app/src/ota.rs) +//! with the shared parts in igneum_common::{fetch, ota}. the project lead's rule: every app updates itself and downloads the +//! update without being asked. +//! +//! The loop, driven from the engine's tick: +//! check (25 s after start, then hourly; 10 minutes after an error): fetch igneum-wallet-latest.json and its .sig, +//! verify the Ed25519 signature with the key compiled into igneum_common::manifest, parse, compare versions +//! -> download (curl with resume into /updates/, then size and sha256 against the manifest) +//! -> stage (macOS: mount the DMG, copy the bundle next to the running one, check its engine answers --version with +//! the manifest's version, digest the staged bundle; Windows: the installer is the staged artefact) +//! -> ready: with the setting "install updates by itself" (default on) the engine applies at the next safe moment, +//! which for a wallet is simply no send in flight (no /api/send running, no quote shown in the last 3 minutes, no +//! sent transaction still waiting for its block, no create flow half way); at once when the user clicks Install +//! now or the version is below min_supported_version +//! -> apply: the engine re-hashes the download and the staged bundle, writes update-pending.json, starts the +//! detached helper and exits (macOS: the helper swaps /Applications/Igneum Wallet.app and opens the new one; +//! Windows: the installer runs first and stops the engine itself; untested for the wallet) +//! -> rollback: the helper restores the previous bundle when the new app does not start twice; the new engine +//! counts its starts and, on the third start without 90 healthy seconds, restores the previous version. +//! "Later" is the window's: it hides the banner for that version; the engine still installs at the safe moment. +//! +//! Environment (tests): IGNEUM_WALLET_UPDATE_MANIFEST overrides the manifest URL from igneum-wallet.json, +//! IGNEUM_WALLET_UPDATE_CHECK_SECS the hourly interval, IGNEUM_WALLET_UPDATE_FIRST_SECS the delay of the first check. +use crate::engine::{Cmd, Shared}; use igneum_common::fetch; use igneum_common::manifest::{self, Manifest, PlatformEntry}; +use igneum_common::ota::{self, Launch, Pending}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::{Duration, Instant}; +const CHECK_EVERY_S: u64 = 3600; +const FIRST_CHECK_S: u64 = 25; +const RETRY_AFTER_ERROR_S: u64 = 600; +/// A quote shown on the confirm screen counts as a send in flight for this long. +pub const QUOTE_HOLDS_S: u64 = 180; +/// The environment the helper carries to a relaunch (test runs); a normal run has none of these set. +const ENV_PREFIXES: &[&str] = &["IGNEUM_APP_", "IGNEUM_WALLET_", "IGNEUM_OTA_"]; + +pub enum Event { + /// The manifest fetched, verified and parsed (or why not). + Checked(Result), + /// The disk image or installer on disk, size and sha256 checked. + Downloaded(Result), + /// macOS: the new bundle staged next to the running one. Windows: the installer path again. + Staged(Result), +} + +/// What the engine must do now. +#[derive(Debug, PartialEq)] +pub enum Action { + Apply, +} + +/// What the engine knows when it asks whether now is a safe moment. +#[derive(Clone, Debug, Default)] +pub struct Ctx { + /// A send is in flight: /api/send running, a quote on the confirm screen, or a sent transaction not yet in a block. + pub send_in_flight: bool, + /// The create flow is half way (the 24 words are on the screen, waiting for the confirmation). + pub creating: bool, +} + +/// What the manifest means for this install. +#[derive(Debug, PartialEq)] +pub enum Plan { + /// This version is the latest (or newer than the manifest). + Current, + /// A newer version is published without a build for this platform yet. + NoBuild(String), + /// A newer version with a build for this platform. + Update(PlatformEntry), +} + +pub fn plan(m: &Manifest, current: &str) -> Plan { + if !manifest::newer(&m.version, current) { + return Plan::Current; + } + match m.this_platform() { + Some(e) => Plan::Update(e.clone()), + None => Plan::NoBuild(m.version.clone()), + } +} + +/// Ok when a ready update may be applied now; Err carries the reason to wait, in the words the window shows. +pub fn safe_to_apply(ctx: &Ctx, auto: bool, install_asked: bool, urgent: bool, failed_before: bool) -> Result<(), String> { + if urgent || install_asked { + return Ok(()); + } + if !auto { + return Err("waiting for Install now (automatic updates are off)".into()); + } + if failed_before { + return Err("this version failed to install before; it waits for Install now".into()); + } + if ctx.send_in_flight { + return Err("a send is in flight; installing after it".into()); + } + if ctx.creating { + return Err("a wallet is being created; installing after it".into()); + } + Ok(()) +} + pub struct Updater { pub url: String, pub current: String, - pub dir: PathBuf, - pub next: Instant, - pub manifest: Option, - pub entry: Option, - pub file: Option, - pub status: String, - pub error: String, - pub checked_at: f64, + app_dir: PathBuf, + dir: PathBuf, + auto: bool, + manifest: Option, + entry: Option, + file: Option, + staged: Option, + staged_digest: String, + busy: bool, + next_check: Instant, + ready_since: Option, + install_asked: bool, + pending: Option, + started: Instant, + healthy_marked: bool, + /// versions whose apply failed or that were rolled back: never re-applied by themselves + failed_versions: Vec, + /// the last manifest's min_supported_version, kept across restarts (updates/manifest.json): the rollback floor + min_supported: String, + /// Windows: the installer was started and the engine is still up (it stops us when it may run) + apply_launched: Option, + /// Windows: the administrator prompt was not answered; no automatic retry before this + deferred_until: Option, } impl Updater { - pub fn new(url: String, current: &str, app_dir: &Path) -> Updater { + pub fn new(shared: &Arc) -> Updater { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let url = env("IGNEUM_WALLET_UPDATE_MANIFEST").unwrap_or_else(|| shared.packaged.update_manifest.clone()); + let app_dir = shared.paths.app_dir.clone(); let dir = app_dir.join("updates"); let _ = std::fs::create_dir_all(&dir); - let status = if url.is_empty() { "off" } else { "unknown" }; - Updater { url, current: current.to_string(), dir, next: Instant::now() + Duration::from_secs(25), manifest: None, entry: None, file: None, status: status.into(), error: String::new(), checked_at: 0.0 } - } - - pub fn due(&self) -> bool { - !self.url.is_empty() && Instant::now() >= self.next - } - - /// One check: manifest, newer?, download. Blocking; the engine calls it from its own thread. - pub fn check(&mut self) -> Result { - self.next = Instant::now() + Duration::from_secs(3600); - self.checked_at = igneum_common::platform::unix_now_f(); - self.status = "checking".into(); - let m = match fetch::fetch_manifest(&self.url, &self.dir) { - Ok(m) => m, - Err(e) => { - self.status = "error".into(); - self.error = e.clone(); - self.next = Instant::now() + Duration::from_secs(600); - return Err(e); - } + let first = env("IGNEUM_WALLET_UPDATE_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(FIRST_CHECK_S); + let auto = shared.settings.lock().unwrap().auto_update; + let now = Instant::now(); + let mut u = Updater { + url, + current: crate::engine::VERSION.to_string(), + app_dir, + dir, + auto, + manifest: None, + entry: None, + file: None, + staged: None, + staged_digest: String::new(), + busy: false, + next_check: now + Duration::from_secs(first), + ready_since: None, + install_asked: false, + pending: None, + started: now, + healthy_marked: false, + failed_versions: Vec::new(), + min_supported: String::new(), + apply_launched: None, + deferred_until: None, }; - self.error.clear(); - if !manifest::newer(&m.version, &self.current) { - self.status = "current".into(); - self.manifest = Some(m); - return Ok("current".into()); + u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::>(&t).ok()).unwrap_or_default(); + if let Ok(text) = std::fs::read_to_string(u.dir.join("manifest.json")) { + if let Ok(m) = manifest::parse(&text) { + u.min_supported = m.min_supported_version.clone(); + } } - let Some(e) = m.this_platform().cloned() else { - self.status = "current".into(); - self.manifest = Some(m); - return Ok("no build for this platform yet".into()); - }; - self.status = "downloading".into(); - self.entry = Some(e.clone()); - let v = m.version.clone(); - self.manifest = Some(m); - match fetch::download(&e, &self.dir) { - Ok(p) => { - self.file = Some(p); - self.status = "ready".into(); - Ok(format!("{v} downloaded and checked")) + { + let mut st = shared.state.lock().unwrap(); + st.update.status = if u.url.is_empty() { "off".into() } else { "unknown".into() }; + st.settings.auto_update = auto; + } + u.settle_previous(shared); + u.publish(shared); + u + } + + fn failed_path(&self) -> PathBuf { + self.app_dir.join("failed-versions.json") + } + + fn remember_failed(&mut self, shared: &Arc, ver: &str) { + if ver.is_empty() || self.failed_versions.iter().any(|v| v == ver) { + return; + } + self.failed_versions.push(ver.to_string()); + let _ = std::fs::write(self.failed_path(), serde_json::to_string(&self.failed_versions).unwrap_or_default()); + shared.log(&format!("update: {ver} is marked failed; it will not be applied by itself again (Install now still can)")); + } + + /// On start: did we just update (or fail to)? Reports it, counts this start, and asks for a rollback when the + /// new version keeps dying before it is healthy. + fn settle_previous(&mut self, shared: &Arc) { + let pending = ota::read_pending(&self.app_dir); + if let Some(r) = ota::read_result(&self.app_dir) { + let _ = std::fs::remove_file(ota::result_path(&self.app_dir)); + if !r.ok && r.deferred { + shared.log(&format!("OTA: the update to {} was deferred before this start ({}); it tries again", r.version, r.error)); + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + } else if !r.ok { + { + let mut st = shared.state.lock().unwrap(); + st.update.error = r.error.clone(); + st.update.status = "error".into(); + if r.rolled_back { + st.update.rolled_back = format!("{}: {}", r.version, r.error); + } + } + shared.event("error", &format!("update to {} failed: {}", r.version, r.error)); + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + self.remember_failed(shared, &r.version); + return; } - Err(err) => { - self.status = "error".into(); - self.error = err.clone(); - Err(err) + } + let Some(mut p) = pending else { return }; + if p.to == self.current { + // we are the new version + p.starts += 1; + ota::write_pending(&self.app_dir, &p); + if p.starts == 1 { + shared.event("ok", &format!("updated to Igneum Wallet {} from {}", p.to, p.from)); + shared.state.lock().unwrap().update.updated_from = p.from.clone(); + } else { + shared.log(&format!("start {} of {} since the update from {}; healthy after {} s", p.starts, p.to, p.from, ota::HEALTHY_AFTER_S)); + } + self.pending = Some(p); + } else if p.from == self.current { + // the old version runs again: the helper restored it, or the installer never ran + shared.event("error", &format!("the update to {} did not take; still on {}", p.to, p.from)); + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + self.remember_failed(shared, &p.to); + } else { + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + } + } + + /// True when this (new) version has died twice before reaching HEALTHY_AFTER_S: the engine rolls back and exits. + pub fn needs_rollback(&self) -> bool { + self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false) + } + + // ---- state for the window ------------------------------------------------------------------------------------ + + fn publish(&self, shared: &Arc) { + let mut st = shared.state.lock().unwrap(); + let u = &mut st.update; + u.auto = self.auto; + if let Some(m) = &self.manifest { + u.version = m.version.clone(); + u.notes = m.notes.clone(); + u.unsupported = manifest::unsupported(m, &self.current); + u.min_supported = m.min_supported_version.clone(); + } + if let Some(e) = &self.entry { + u.url = e.url.clone(); + u.size = e.size; + } + u.available = self.entry.is_some(); + u.downloaded = self.file.is_some(); + u.ready = self.staged.is_some(); + u.file = self.file.as_ref().map(|p| p.display().to_string()).unwrap_or_default(); + if u.status != "applying" && u.status != "manual" && u.status != "error" && u.status != "deferred" && u.status != "off" { + u.status = if self.staged.is_some() { + "ready".into() + } else if self.file.is_some() { + "staging".into() + } else if self.entry.is_some() { + if self.busy { "downloading".into() } else { "available".into() } + } else if self.manifest.is_some() { + "current".into() + } else if u.status.is_empty() { + "unknown".into() + } else { + u.status.clone() + }; + } + } + + fn set_error(&mut self, shared: &Arc, e: &str) { + shared.log(&format!("update: {e}")); + let mut st = shared.state.lock().unwrap(); + st.update.error = e.to_string(); + st.update.status = "error".into(); + st.update.applying = false; + st.update.wait = String::new(); + } + + fn clear_error(&self, shared: &Arc) { + let mut st = shared.state.lock().unwrap(); + st.update.error = String::new(); + if st.update.status == "error" { + st.update.status = "unknown".into(); + } + } + + pub fn set_auto(&mut self, shared: &Arc, on: bool) { + self.auto = on; + { + let mut s = shared.settings.lock().unwrap(); + s.auto_update = on; + s.save(&shared.paths.settings); + } + shared.state.lock().unwrap().settings.auto_update = on; + shared.event("info", if on { "updates install by themselves when nothing is being sent" } else { "updates download but wait for Install now" }); + self.publish(shared); + } + + // ---- the tick ------------------------------------------------------------------------------------------------ + + pub fn tick(&mut self, shared: &Arc, ctx: &Ctx) -> Option { + let now = Instant::now(); + // the new version is healthy once it has run this long: the update is complete, the leftovers can go + if !self.healthy_marked && self.pending.is_some() && now.duration_since(self.started) >= Duration::from_secs(ota::HEALTHY_AFTER_S) { + self.healthy_marked = true; + let p = self.pending.take().unwrap(); + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + let _ = std::fs::remove_file(ota::result_path(&self.app_dir)); // the helper's "ok" lands after this engine started + shared.log(&format!("update to {} complete (from {}); keeping the previous version for a rollback", p.to, p.from)); + self.tidy(); + } + if now >= self.next_check && !self.busy && self.staged.is_none() { + self.start_check(shared); + } + if self.busy { + if let (Some(e), None) = (&self.entry, &self.file) { + let mut st = shared.state.lock().unwrap(); + if st.update.status == "downloading" { + st.update.progress = fetch::progress(e, &self.dir); + } + } + return None; + } + let urgent = self.urgent(); + { + let mut st = shared.state.lock().unwrap(); + st.update.urgent = urgent; + st.update.urgent_text = if urgent { + format!("Igneum Wallet {} is no longer supported; the network needs {} or newer.", self.current, self.min_supported) + } else { + String::new() + }; + } + if self.staged.is_none() { + return None; + } + // Windows: the installer was started with the engine still running; it stops us when it may run. Until then + // watch for the helper's verdict (an unanswered administrator prompt). + if let Some(t) = self.apply_launched { + match ota::read_result(&self.app_dir) { + Some(r) if !r.ok => { + let _ = std::fs::remove_file(ota::result_path(&self.app_dir)); + self.defer(shared, &r.error); + } + Some(_) => {} // the installer is in: it stops this engine any moment now + None if now.duration_since(t) >= Duration::from_secs(15 * 60) => self.defer(shared, "no answer from the installer in 15 minutes"), + None => {} + } + return None; + } + if let Some(u) = self.deferred_until { + if now < u && !self.install_asked { + return None; + } + self.deferred_until = None; + shared.state.lock().unwrap().update.status = "ready".into(); + } + let v = self.version(); + let failed_before = self.failed_versions.iter().any(|f| f == &v); + match safe_to_apply(ctx, self.auto, self.install_asked, urgent, failed_before) { + Ok(()) => Some(Action::Apply), + Err(why) => { + shared.state.lock().unwrap().update.wait = why; + None } } } + /// Windows: the installer could not run (the administrator prompt was declined, timed out, or nobody was there). + fn defer(&mut self, shared: &Arc, err: &str) { + self.apply_launched = None; + self.install_asked = false; + self.deferred_until = Some(Instant::now() + Duration::from_secs(6 * 3600)); + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + let v = self.version(); + { + let mut st = shared.state.lock().unwrap(); + st.update.applying = false; + st.update.status = "deferred".into(); + st.update.wait = "waits for the next time someone is at this PC (Windows asks for permission)".into(); + } + shared.event("info", &format!("OTA: administrator approval not given for Igneum Wallet {v} ({err}); the update waits for the next time someone is at this PC")); + } + + fn urgent(&self) -> bool { + match &self.manifest { + Some(m) => self.entry.is_some() && manifest::unsupported(m, &self.current), + None => false, + } + } + + /// After a completed update: the downloads of older versions go; the installer of the version now running stays + /// on Windows (the rollback target of the next update); a failed bundle from an earlier rollback goes on macOS. + fn tidy(&self) { + if let Ok(rd) = std::fs::read_dir(&self.dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + let keep = cfg!(windows) && name.contains(&self.current) && name.ends_with(".exe"); + if !keep && name != "manifest.json" && name != "manifest.json.sig" { + let _ = std::fs::remove_file(e.path()); + } + } + } + if let Some(b) = igneum_common::platform::bundle_path() { + let failed = PathBuf::from(format!("{}.failed", b.display())); + if failed.exists() { + let _ = std::fs::remove_dir_all(&failed); + } + } + } + + // ---- check --------------------------------------------------------------------------------------------------- + + pub fn check_now(&mut self, shared: &Arc) { + if self.busy { + return; + } + self.clear_error(shared); + self.start_check(shared); + } + + fn start_check(&mut self, shared: &Arc) { + let every = std::env::var("IGNEUM_WALLET_UPDATE_CHECK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(CHECK_EVERY_S); + self.next_check = Instant::now() + Duration::from_secs(every); + if self.url.is_empty() { + let mut st = shared.state.lock().unwrap(); + st.update.status = "off".into(); + st.update.checked_at = igneum_common::platform::unix_now_f(); + return; + } + self.busy = true; + shared.state.lock().unwrap().update.status = "checking".into(); + let url = self.url.clone(); + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = fetch::fetch_manifest(&url, &dir); + shared2.send(Cmd::Ota(Event::Checked(r))); + }); + } + + pub fn event(&mut self, shared: &Arc, ev: Event) { + self.busy = false; + match ev { + Event::Checked(r) => { + shared.state.lock().unwrap().update.checked_at = igneum_common::platform::unix_now_f(); + match r { + Err(e) => { + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + if self.manifest.is_none() { + self.set_error(shared, &e); + } else { + shared.log(&format!("update check: {e}; keeping the last manifest")); + } + } + Ok(m) => { + self.clear_error(shared); + let entry = match plan(&m, &self.current) { + Plan::Update(e) => Some(e), + Plan::NoBuild(v) => { + shared.log(&format!("update check: {v} is published but has no {} build yet", manifest::platform_name())); + None + } + Plan::Current => { + shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version)); + None + } + }; + let changed = self.entry != entry; + if entry.is_none() { + self.entry = None; + self.file = None; + self.staged = None; + self.ready_since = None; + } + self.manifest = Some(m.clone()); + self.min_supported = m.min_supported_version.clone(); + if let Some(e) = entry { + if changed { + self.file = None; + self.staged = None; + self.ready_since = None; + shared.event("info", &format!("Igneum Wallet {} is available: downloading ({} MB){}", m.version, e.size / 1_000_000, if m.notes.is_empty() { String::new() } else { format!(". {}", m.notes) })); + } + self.entry = Some(e); + if self.staged.is_none() { + self.start_download(shared); + } + } + } + } + } + Event::Downloaded(r) => match r { + Err(e) => { + self.set_error(shared, &format!("download failed: {e}")); + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + } + Ok(p) => { + self.clear_error(shared); + shared.log(&format!("update: {} downloaded and verified", p.display())); + self.file = Some(p.clone()); + self.publish(shared); + self.start_stage(shared, p); + } + }, + Event::Staged(r) => match r { + Err(e) if e.starts_with("manual:") => { + let why = e.trim_start_matches("manual:").trim().to_string(); + { + let mut st = shared.state.lock().unwrap(); + st.update.status = "manual".into(); + st.update.wait = why.clone(); + } + shared.event("info", &format!("update downloaded; {why}")); + } + Err(e) => { + self.set_error(shared, &format!("could not prepare the update: {e}")); + self.file = None; + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + } + Ok(p) => { + self.clear_error(shared); + #[cfg(target_os = "macos")] + { + self.staged_digest = manifest::digest_dir(&p).unwrap_or_default(); + shared.log(&format!("update: staged bundle digest {}", self.staged_digest)); + } + self.staged = Some(p); + self.ready_since = Some(Instant::now()); + let v = self.version(); + { + let mut st = shared.state.lock().unwrap(); + st.update.wait = if self.auto { "installs as soon as nothing is being sent".into() } else { "waiting for Install now".into() }; + st.update.progress = 1.0; + } + shared.event("ok", &format!("Igneum Wallet {v} is ready; {}", if self.auto { "it installs as soon as nothing is being sent" } else { "automatic updates are off, so it waits for Install now" })); + } + }, + } + self.publish(shared); + } + + fn start_download(&mut self, shared: &Arc) { + let Some(e) = self.entry.clone() else { return }; + self.busy = true; + { + let mut st = shared.state.lock().unwrap(); + st.update.status = "downloading".into(); + st.update.progress = 0.0; + } + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = fetch::download(&e, &dir); + shared2.send(Cmd::Ota(Event::Downloaded(r))); + }); + } + + fn start_stage(&mut self, shared: &Arc, file: PathBuf) { + let Some(e) = self.entry.clone() else { return }; + let version = self.version(); + self.busy = true; + shared.state.lock().unwrap().update.status = "staging".into(); + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = ota::stage(crate::engine::APP, &e, &file, &dir, &version); + shared2.send(Cmd::Ota(Event::Staged(r))); + }); + } + + // ---- the user's buttons ---------------------------------------------------------------------------------------- + + /// Install now: a ready update applies at once; a downloaded one as soon as it is staged; else a check runs. + pub fn install_now(&mut self, shared: &Arc) { + self.install_asked = true; + self.deferred_until = None; + if self.apply_launched.is_some() { + return; // the installer is already up (its prompt may be waiting on the screen) + } + if self.staged.is_some() { + shared.state.lock().unwrap().update.wait = "installing now".into(); + return; + } + if self.busy { + return; + } + self.clear_error(shared); + if let Some(f) = self.file.clone() { + self.start_stage(shared, f); + } else if self.entry.is_some() { + self.start_download(shared); + } else { + self.start_check(shared); + } + } + + /// The manual path: open the downloaded disk image or installer for the user. pub fn open_file(&self) -> Result<(), String> { - let f = self.file.as_ref().ok_or("nothing downloaded")?; + let f = self.file.as_ref().ok_or("nothing downloaded yet")?; #[cfg(target_os = "macos")] let r = std::process::Command::new(igneum_common::platform::tool("open")).arg(f).spawn(); #[cfg(windows)] @@ -87,4 +620,132 @@ impl Updater { let r = std::process::Command::new("xdg-open").arg(f).spawn(); r.map(|_| ()).map_err(|e| e.to_string()) } + + // ---- apply --------------------------------------------------------------------------------------------------- + + pub fn version(&self) -> String { + self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default() + } + + /// Re-verifies the download and the staged bundle, writes update-pending.json, starts the helper. macOS: the + /// engine exits right after (Launch::QuitNow). Windows: the installer runs first (Launch::InstallerRunning). + pub fn launch_apply(&mut self, shared: &Arc, host_pid: u32) -> Result { + let staged = self.staged.clone().ok_or("no update is ready")?; + let to = self.version(); + let entry = self.entry.clone().ok_or("no manifest entry")?; + if let Some(f) = &self.file { + let sum = manifest::sha256_file(f).map_err(|e| format!("cannot hash the download: {e}"))?; + if sum != entry.sha256 { + self.staged = None; + self.file = None; + return Err("the downloaded file no longer matches the manifest's sha256; it is discarded".into()); + } + } + #[cfg(target_os = "macos")] + { + let d = manifest::digest_dir(&staged).map_err(|e| format!("cannot digest the staged app: {e}"))?; + if d != self.staged_digest || d.is_empty() { + let _ = std::fs::remove_dir_all(&staged); + self.staged = None; + return Err("the staged app changed since it was verified; it is discarded".into()); + } + } + let previous_installer = if cfg!(windows) { self.dir.join(ota::installer_name_for(crate::engine::APP, &self.current)).to_string_lossy().into_owned() } else { String::new() }; + let previous_installer = if Path::new(&previous_installer).is_file() { previous_installer } else { String::new() }; + let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES); + let a = ota::Apply { app: crate::engine::APP, app_dir: &self.app_dir, current: &self.current, version: &to, staged: &staged, staged_digest: &self.staged_digest, sha256: &entry.sha256, host_pid, env_file, previous_installer }; + shared.log(&format!("update: starting the helper for {to} (host pid {host_pid}, staged {})", staged.display())); + let launched = ota::launch_apply(&a)?; + if launched == Launch::InstallerRunning { + self.apply_launched = Some(Instant::now()); + } + Ok(launched) + } + + /// The new version failed to start twice: restore the previous one through the helper and exit. + pub fn launch_rollback(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { + let p = self.pending.clone().ok_or("no update pending")?; + // never below the network's minimum; this version stays and is marked failed so it is not re-applied + if !self.min_supported.is_empty() && manifest::newer(&self.min_supported, &p.from) { + let _ = std::fs::remove_file(ota::pending_path(&self.app_dir)); + self.pending = None; + return Err(format!("not rolling back to {}: the network needs {} or newer; staying on {}", p.from, self.min_supported, p.to)); + } + self.remember_failed(shared, &p.to); + shared.event("error", &format!("Igneum Wallet {} did not stay up twice; restoring {}", p.to, p.from)); + let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES); + ota::launch_rollback(crate::engine::APP, &self.app_dir, &p, host_pid, env_file) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What packaging/ota/publish-manifest.sh --product wallet writes (canonical JSON, sorted keys, no whitespace). + const WALLET_MANIFEST: &str = r#"{"channel":"devnet","consensus":{"activation_height":null,"deadline_note":""},"min_supported_version":"","notes":"coin on the home screen, updates install by themselves","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":19479807,"url":"https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"}},"published_at":"2026-10-05T09:00:00Z","version":"0.1.1"}"#; + + #[test] + fn the_wallet_manifest_parses() { + let m = manifest::parse(WALLET_MANIFEST).unwrap(); + assert_eq!(m.version, "0.1.1"); + assert_eq!(m.channel, "devnet"); + assert_eq!(m.activation_height, None); + assert!(m.min_supported_version.is_empty()); + let mac = m.mac.as_ref().unwrap(); + assert_eq!(mac.kind, "dmg"); + assert_eq!(mac.size, 19479807); + assert!(mac.url.ends_with("/Igneum-Wallet-0.1.1.dmg")); + assert!(m.windows.is_none()); + assert!(m.notes.contains("coin")); + } + + #[test] + fn versions_the_wallet_will_see() { + assert!(manifest::newer("0.1.1", "0.1.0")); + assert!(manifest::newer("0.1.10", "0.1.9")); + assert!(manifest::newer("0.2.0", "0.1.11")); + assert!(!manifest::newer("0.1.0", "0.1.0")); + assert!(!manifest::newer("0.1.0", "0.1.1")); + assert!(manifest::newer("0.1.1", "0.1.1-rc1")); + assert!(!manifest::newer("latest", "0.1.0")); + assert!(!manifest::newer("", "0.1.0")); + } + + #[test] + fn the_plan_follows_the_version_and_the_platform() { + let m = manifest::parse(WALLET_MANIFEST).unwrap(); + match plan(&m, "0.1.0") { + Plan::Update(e) if manifest::platform_name() == "mac" => assert_eq!(e.size, 19479807), + Plan::NoBuild(v) if manifest::platform_name() != "mac" => assert_eq!(v, "0.1.1"), + other => panic!("unexpected plan {other:?}"), + } + assert_eq!(plan(&m, "0.1.1"), Plan::Current); + assert_eq!(plan(&m, "0.2.0"), Plan::Current); + // a newer version without any platform entry: nothing to download + let none = manifest::parse(r#"{"version":"0.1.2","platforms":{}}"#).unwrap(); + assert_eq!(plan(&none, "0.1.1"), Plan::NoBuild("0.1.2".into())); + // a tampered manifest fails before any plan is made + assert!(manifest::verify_and_parse(WALLET_MANIFEST.as_bytes(), &"00".repeat(64), manifest::OTA_PUBLIC_KEY_HEX).is_err()); + } + + #[test] + fn safe_moments() { + let quiet = Ctx { send_in_flight: false, creating: false }; + let sending = Ctx { send_in_flight: true, creating: false }; + let creating = Ctx { send_in_flight: false, creating: true }; + assert!(safe_to_apply(&quiet, true, false, false, false).is_ok()); + assert_eq!(safe_to_apply(&sending, true, false, false, false).unwrap_err(), "a send is in flight; installing after it"); + assert!(safe_to_apply(&creating, true, false, false, false).unwrap_err().contains("being created")); + // automatic updates off: only Install now (or an unsupported version) applies + assert!(safe_to_apply(&quiet, false, false, false, false).unwrap_err().contains("Install now")); + assert!(safe_to_apply(&quiet, false, true, false, false).is_ok()); + assert!(safe_to_apply(&quiet, false, false, true, false).is_ok()); + // Install now and an unsupported version beat a send in flight + assert!(safe_to_apply(&sending, true, true, false, false).is_ok()); + assert!(safe_to_apply(&sending, true, false, true, false).is_ok()); + // a version that failed before waits for Install now + assert!(safe_to_apply(&quiet, true, false, false, true).unwrap_err().contains("failed")); + assert!(safe_to_apply(&quiet, true, true, false, true).is_ok()); + } } diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index 7a106bb59..a3fc74455 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -47,11 +47,7 @@ function render() { if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } $('pill-text').textContent = pillText; $('pill').className = pillCls; $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} ยท nothing is bought or sold`; - // update banner - const u = s.update; - $('update-banner').hidden = !(u.status === 'ready' && !sessionStorage.getItem('update-later-' + u.version)); - $('update-text').textContent = `Igneum Wallet ${u.version} is downloaded and checked.${u.notes ? ' ' + u.notes : ''}`; - $('update-note').textContent = u.status === 'off' ? 'updates are off in this build' : u.status === 'ready' ? `${u.version} downloaded` : u.status === 'error' ? u.error : u.status === 'current' ? 'up to date' : u.status; + renderUpdate(s); // unlock $('unlock-address').textContent = s.display; // home @@ -251,9 +247,60 @@ $('export-show').onclick = async () => { $('export-copy').onclick = () => copy($('export-key').textContent, 'private key'); $('export-hide').onclick = () => { $('export-out').hidden = true; $('export-key').textContent = ''; }; $('start-login').onchange = async ev => { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('update-check').onclick = () => post('/api/update/check'); + +// ---- over-the-air updates (src/updater.rs): one banner, the settings line ---- +function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } +function updateLine(u) { + const v = 'Igneum Wallet ' + u.version; + if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; + switch (u.status) { + case 'available': return { text: v + ' is available. Downloading it.' }; + case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; + case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; + case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; + case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; + case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; + case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; + case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; + default: return null; + } +} +function updateNote(u) { + const l = updateLine(u), parts = []; + if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); + if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); + if (u.status === 'off') parts.push('Updates are off in this build.'); + else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); + else if (u.status === 'checking') parts.push('Checking.'); + else if (u.status === 'current') parts.push('This is the latest version.'); + else if (u.error) parts.push(u.error); + else parts.push('Not checked yet.'); + return parts.join(' '); +} +function renderUpdate(s) { + const u = s.update, b = $('update-banner'), l = updateLine(u); + const key = u.status + ':' + u.version; + const show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); + if (show) { + $('update-text').textContent = l.text; + b.classList.toggle('urgent', !!l.urgent); + $('update-install').hidden = !l.install || u.applying; + $('update-open').hidden = !l.open; + $('update-later').hidden = !!l.urgent || !!u.applying; + $('update-prog').hidden = !l.prog; + $('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + } + b.hidden = !show; + $('update-note').textContent = updateNote(u); + $('update-install-s').hidden = !(l && l.install) || u.applying; + if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; +} +$('update-check').onclick = () => { post('/api/update/check'); toast('checking for updates'); }; $('update-open').onclick = () => post('/api/update/open'); -$('update-later').onclick = () => { sessionStorage.setItem('update-later-' + state.update.version, '1'); $('update-banner').hidden = true; }; +$('update-install').onclick = () => { post('/api/update/install'); toast('installing now'); }; +$('update-install-s').onclick = () => { post('/api/update/install'); toast('installing now'); }; +$('update-later').onclick = () => { sessionStorage.setItem('update-later', state.update.status + ':' + state.update.version); $('update-banner').hidden = true; }; +$('auto-update').onchange = async ev => { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; $('remove-go').onclick = async () => { $('remove-err').textContent = ''; if (!confirm('Remove this wallet from this machine? Only your 24 words or the key bring it back.')) return; diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index 538e4ec18..e0bbbb9ec 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -26,8 +26,10 @@
@@ -240,8 +242,9 @@

This machine

+
-
+

Remove this wallet from this machine

diff --git a/packaging/README-ship.md b/packaging/README-ship.md index 685c040fa..7023ec4b9 100644 --- a/packaging/README-ship.md +++ b/packaging/README-ship.md @@ -84,3 +84,17 @@ from the repository root (the project's root directory is `site`), but the norma (`igneum-relay`) and the downloads folder (`igneum-dl`) are the other two projects in the `igneum` team; both deploy by CLI from their own folders (`relay/README.md`, `packaging/ota/README.md`). CLAUDE.md still says the site sits in the [other-business] team and deploys with `--scope [other-business]` from `site/`: that was true on 3 October and is not now. + +## Igneum Wallet (5 October 2026) + +The wallet has no ship script yet; three commands cut a Mac version, each under the build lock where it builds: + + tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh # version from app/igneum-wallet/Cargo.toml + packaging/ota/publish-manifest.sh --product wallet --version --mac packaging/mac/dist/Igneum-Wallet-.dmg --notes "..." --deploy + cp packaging/mac/dist/Igneum-Wallet-.dmg ~/Desktop/ # the hand-install copy + +The manifest is `igneum-wallet-latest.json` next to the miner's, same key. Installed wallets (0.1.1 and later) swap +themselves in: states, the safe moment and what is still untested are in `app/igneum-wallet/README.md`. A 0.1.0 +wallet only downloads the DMG and offers "Open the download". `build-wallet-dmg.sh` refuses to wipe a work folder an +app is running from; build with `BUILD=` then. Windows: the wallet installer is built by the runner +from `packaging/windows/Igneum-Wallet.iss` and its over-the-air path is untested. diff --git a/packaging/mac/build-wallet-dmg.sh b/packaging/mac/build-wallet-dmg.sh index 5b21cff10..b7a70a75f 100755 --- a/packaging/mac/build-wallet-dmg.sh +++ b/packaging/mac/build-wallet-dmg.sh @@ -14,6 +14,9 @@ # packaging/mac/build-wallet-dmg.sh build (the version is app/igneum-wallet/Cargo.toml's; VERSION= overrides it) # packaging/ota/publish-manifest.sh --product wallet --version --mac dist/Igneum-Wallet-.dmg --notes "..." # NODE= ENGINE= use other binaries +# BUILD= the work folder (default packaging/mac/build-wallet); the script refuses to +# wipe a folder an app is running from (5 October 2026: the staged bundle had +# been opened by hand and was still running) # Runs under the build lock: tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" @@ -22,7 +25,7 @@ VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-wall NODE="${NODE:-$ROOT/vendor/igneum-node/target-integration/release/igneumd}" ENGINE="${ENGINE:-}" ICONS="$ROOT/brand/icons" -BUILD="$HERE/build-wallet" +BUILD="${BUILD:-$HERE/build-wallet}" DIST="$HERE/dist" DMG="$DIST/Igneum-Wallet-$VERSION.dmg" STAGE="$BUILD/dmg" @@ -36,6 +39,9 @@ file "$NODE" | grep -q 'arm64' || { echo "$NODE is not an arm64 binary"; exit 1; for f in igneum.icns igneum-volume.icns; do [ -f "$ICONS/$f" ] || { echo "no $ICONS/$f; run: python3 brand/icons/make-icons.py"; exit 1; }; done command -v swiftc >/dev/null 2>&1 || { echo "swiftc is needed for the window (xcode-select --install)"; exit 1; } +if pgrep -f "$BUILD/" >/dev/null 2>&1; then + echo "something is running from $BUILD (pgrep -f \"$BUILD/\"); quit it or build with BUILD="; exit 1 +fi rm -rf "$BUILD" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/bin" "$DIST" "$BUILD/window"