Igneum Wallet 0.1.1: the coin on the home screen, over-the-air updates v2 (unattended install)

The 0.1.0 DMG was built before server.rs served /coin.png, so the coin was blank; 0.1.1 ships the
route (brand/igneum-coin-1024.png, checked byte for byte through the engine).

Updates: the apply side of the miner's ota.rs moved into igneum-common/src/ota.rs with the app's
names from AppId (engine_exe added): stage next to the running bundle, digest, detached helper
that swaps and relaunches, pending/result files, rollback when the new app does not start twice.
fetch.rs downloads with resume and reports progress. The wallet's updater.rs runs check (25 s,
then hourly), download, stage, apply in threads; the safe moment is no send in flight (/api/send
running, a quote in the last 180 s, a sent transaction not yet in a block, a create flow half
way). Setting "Install updates by itself when nothing is being sent" (default on), banner with
Install now and Later, settings line with the states. Unit tests: manifest, versions, plan, safe
moment, helper templates, pending/result files.

build-wallet-dmg.sh takes BUILD= and refuses to wipe a work folder an app runs from. README with
the states and what is untested (Windows path, LaunchServices relaunch with the window host,
rollback). Verified end to end with a scratch 0.1.1 bundle against a 0.1.2 test manifest on
127.0.0.1: check, download, stage, swap, relaunch, "updated to Igneum Wallet 0.1.2 from 0.1.1".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 08:25:22 +00:00
parent b2e7b23f85
commit c2f6b68c02
14 changed files with 1601 additions and 115 deletions

View file

@ -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<Manifest, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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");
}
}

View file

@ -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" };

View file

@ -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>.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>.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<Pending> {
parse_pending(&std::fs::read_to_string(pending_path(app_dir)).ok()?)
}
pub fn parse_pending(text: &str) -> Option<Pending> {
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<HelperResult> {
parse_result(&std::fs::read_to_string(result_path(app_dir)).ok()?)
}
pub fn parse_result(text: &str) -> Option<HelperResult> {
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<PathBuf> {
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 <app dir>/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<String> = 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<PathBuf, String> {
#[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<PathBuf> = 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<PathBuf> {
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<Launch, String> {
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 <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json> [env file] [digest]
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
# bundle to "<app>.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 <result json> 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 <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex>
# 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);
}
}

View file

@ -1940,7 +1940,7 @@ dependencies = [
[[package]]
name = "igneum-wallet"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"argon2",
"bip32",

View file

@ -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"

View file

@ -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 <v> --mac packaging/mac/dist/Igneum-Wallet-<v>.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 `<wallet data>/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 <folder> --base-url http://127.0.0.1:<port>`; 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).

View file

@ -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<State>,
pub rings: Mutex<Rings>,
#[allow(dead_code)] // read by the window through state; kept for the next settings
pub settings: Mutex<Settings>,
pub paths: Paths,
pub packaged: Packaged,
@ -98,6 +110,18 @@ pub struct Shared {
pub evm: Mutex<Option<crate::evm::Evm>>,
pub verified: Mutex<Option<VerifiedCheckpoint>>,
pub history: Mutex<Option<History>>,
/// /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<Option<Instant>>,
}
/// 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=<v> machine=<id8> platform=<os> node=<source>: 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<Value, String> {
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<Shared>, rx: Receiver<Cmd>, 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}")),
}
}
}

View file

@ -145,6 +145,15 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::OpenUpdate);
Ok(json!({ "ok": true }))
}
"/api/update/install" => {
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://") {

View file

@ -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)]

View file

@ -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 <wallet data>/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<Manifest, String>),
/// The disk image or installer on disk, size and sha256 checked.
Downloaded(Result<PathBuf, String>),
/// macOS: the new bundle staged next to the running one. Windows: the installer path again.
Staged(Result<PathBuf, String>),
}
/// 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<Manifest>,
pub entry: Option<PlatformEntry>,
pub file: Option<PathBuf>,
pub status: String,
pub error: String,
pub checked_at: f64,
app_dir: PathBuf,
dir: PathBuf,
auto: bool,
manifest: Option<Manifest>,
entry: Option<PlatformEntry>,
file: Option<PathBuf>,
staged: Option<PathBuf>,
staged_digest: String,
busy: bool,
next_check: Instant,
ready_since: Option<Instant>,
install_asked: bool,
pending: Option<Pending>,
started: Instant,
healthy_marked: bool,
/// versions whose apply failed or that were rolled back: never re-applied by themselves
failed_versions: Vec<String>,
/// 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<Instant>,
/// Windows: the administrator prompt was not answered; no automatic retry before this
deferred_until: Option<Instant>,
}
impl Updater {
pub fn new(url: String, current: &str, app_dir: &Path) -> Updater {
pub fn new(shared: &Arc<Shared>) -> 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<String, String> {
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::<Vec<String>>(&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<Shared>, 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<Shared>) {
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<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, 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<Shared>, ctx: &Ctx) -> Option<Action> {
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<Shared>, 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<Shared>) {
if self.busy {
return;
}
self.clear_error(shared);
self.start_check(shared);
}
fn start_check(&mut self, shared: &Arc<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, host_pid: u32) -> Result<Launch, String> {
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<Shared>, 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());
}
}

View file

@ -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;

View file

@ -26,8 +26,10 @@
<div class="banner update" id="update-banner" hidden>
<span id="update-text">A new version of Igneum Wallet is ready.</span>
<button class="btn small primary" id="update-open">Open the download</button>
<button class="btn small primary" id="update-install" hidden>Install now</button>
<button class="btn small primary" id="update-open" hidden>Open the download</button>
<button class="btn small ghost" id="update-later">Later</button>
<div class="prog" id="update-prog" hidden><i></i></div>
</div>
<main id="main">
@ -240,8 +242,9 @@
<div class="card"><h3>This machine</h3>
<label class="switch"><input type="checkbox" id="start-login"><span>Start Igneum Wallet at login</span></label>
<label class="switch"><input type="checkbox" id="auto-update"><span>Install updates by itself when nothing is being sent</span></label>
<div class="kv" id="machine-kv"></div>
<div class="inline"><button class="btn small" id="update-check">Check for updates</button><span class="note" id="update-note"></span></div>
<div class="inline"><button class="btn small primary" id="update-install-s" hidden>Install now</button><button class="btn small" id="update-check">Check for updates</button><span class="note" id="update-note"></span></div>
</div>
<div class="card danger-card"><h3>Remove this wallet from this machine</h3>

View file

@ -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 <v> --mac packaging/mac/dist/Igneum-Wallet-<v>.dmg --notes "..." --deploy
cp packaging/mac/dist/Igneum-Wallet-<v>.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=<other folder>` then. Windows: the wallet installer is built by the runner
from `packaging/windows/Igneum-Wallet.iss` and its over-the-air path is untested.

View file

@ -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 <v> --mac dist/Igneum-Wallet-<v>.dmg --notes "..."
# NODE=<path> ENGINE=<path> use other binaries
# BUILD=<folder> 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=<another folder>"; exit 1
fi
rm -rf "$BUILD"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/bin" "$DIST" "$BUILD/window"