Igneum Miner 0.3.3: an unattended Windows miner is never stranded by an update (4 Oct 15:40 incident: both PCs stopped at the installer's UAC prompt). Per-user installer (PrivilegesRequired=lowest, %LOCALAPPDATA%\Programs, migration from Program Files offered only when someone is at the keyboard, firewall rule asked once on first run and mining without it); the Windows helper runs the installer FIRST with the engine still mining and only the installer's own stop step ends it, a declined or unanswered prompt leaves the machine mining with "OTA: waiting for administrator approval" / "administrator approval not given; waits for the next time someone is at this PC" in the log and the intake, retry on Install now, next start or 6 h; machines take turns (apply only in the minute = machine id8 mod 60) and hold while /api/live shows over 30% of identities gone in 10 minutes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 14:59:51 +00:00
parent b01e7a7b84
commit 8c31a5d04e
12 changed files with 388 additions and 68 deletions

View file

@ -219,7 +219,7 @@ dependencies = [
[[package]]
name = "igneum-app"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"ed25519-dalek",
"getrandom",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-app"
version = "0.3.2"
version = "0.3.3"
edition = "2021"
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
license = "MIT"

View file

@ -6,8 +6,8 @@
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION 0,3,2,0
PRODUCTVERSION 0,3,2,0
FILEVERSION 0,3,3,0
PRODUCTVERSION 0,3,3,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
@ -20,12 +20,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Miner engine"
VALUE "FileVersion", "0.3.2"
VALUE "FileVersion", "0.3.3"
VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.2"
VALUE "ProductVersion", "0.3.3"
END
END
BLOCK "VarFileInfo"

View file

@ -1506,7 +1506,7 @@ impl Engine {
}
let v = self.ota.version();
match self.ota.launch_apply(&self.shared, self.host_pid()) {
Ok(()) => {
Ok(crate::ota::Launch::QuitNow) => {
{
let mut st = self.st();
st.update.applying = true;
@ -1517,6 +1517,16 @@ impl Engine {
self.shared.event("info", &format!("installing Igneum Miner {v}: the miners stop, then the node, then the app opens again"));
self.quitting = true;
}
Ok(crate::ota::Launch::InstallerRunning) => {
// Windows: the installer stops this engine itself (api/quit) once it may run; until then we mine
{
let mut st = self.st();
st.update.applying = true;
st.update.status = "applying".into();
st.update.wait = "the installer is starting; if Windows asks for permission the miners keep running until it is given".into();
}
self.shared.event("info", &format!("installing Igneum Miner {v}: the installer runs first; the miners keep running until it is allowed to, then it stops them, then the node, and the app opens again"));
}
Err(e) => {
self.shared.event("error", &format!("the update could not start: {e}"));
let mut st = self.st();

View file

@ -293,6 +293,18 @@ pub struct Moment {
pub ready_for_s: u64,
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
pub urgent: bool,
/// This minute is the machine's own slot (slot_minute): machines take turns, two never restart together.
pub slot_ok: bool,
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
pub network_drop_pct: f64,
}
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
/// The minute of the hour in which this machine applies updates: the first 8 hex of the machine id modulo 60.
pub fn slot_minute(id8: &str) -> u64 {
u64::from_str_radix(id8.trim(), 16).unwrap_or(0) % 60
}
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
@ -300,6 +312,12 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
if m.urgent {
return Ok(());
}
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
}
if !m.slot_ok {
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
}
if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S {
return Ok(());
}
@ -453,7 +471,7 @@ mod tests {
#[test]
fn safe_moments() {
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false };
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
assert!(safe_to_apply(&base).is_ok());
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
@ -466,6 +484,24 @@ mod tests {
// patience: an unsynced node for 6 h applies anyway
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());
// the network guard: over 30% of identities gone in 10 minutes holds the update (we are the devnet)
assert!(safe_to_apply(&Moment { network_drop_pct: 30.0, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { network_drop_pct: 30.1, ..base.clone() }).unwrap_err().contains("lost 30%"));
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, urgent: true, ..base.clone() }).is_ok());
}
#[test]
fn slots() {
assert_eq!(slot_minute("1ccfe586"), 58); // PC 2
assert_eq!(slot_minute("00000000"), 0);
assert_eq!(slot_minute("0000003c"), 0);
assert_eq!(slot_minute("0000003b"), 59);
assert_eq!(slot_minute("zz"), 0);
}
#[test]

View file

@ -14,8 +14,11 @@
//! -> apply: the engine stops the miners, then the node, writes update-pending.json, starts a detached helper
//! (ota-apply.sh / ota-apply.ps1 in the app data folder) and exits. macOS: the helper waits for the window to
//! quit, moves the old bundle to "Igneum Miner.app.previous", the new one in, and opens it. Windows: the helper
//! runs the Inno installer /VERYSILENT (an administrator prompt appears; the installer stops what is left,
//! replaces the files and relaunches the app with /IGNOTA=1).
//! runs the per-user Inno installer /VERYSILENT FIRST, with the engine still mining; the installer stops the
//! engine itself (api/quit), replaces the files and relaunches the app with /IGNOTA=1. An administrator prompt
//! (an install still in Program Files) that nobody answers leaves the machine mining: the update is deferred.
//! Machines take turns: each applies only in its own minute of the hour (machine id modulo 60), and never while
//! the network lost over 30% of its identities in 10 minutes (/api/live).
//! -> rollback: the helper restores the previous bundle when the new app does not start twice; the new engine
//! counts its own starts in update-pending.json and, on the third start without 90 healthy seconds, restores
//! the previous version (macOS: the .previous bundle; Windows: the previous installer kept in updates/).
@ -42,6 +45,8 @@ pub enum Event {
Downloaded(Result<PathBuf, String>),
/// macOS: the new bundle staged next to the running one. Windows: the installer path again.
Staged(Result<PathBuf, String>),
/// The network's identity count from /api/live (state.miners_10m); None when the site did not answer.
Live(Option<u64>),
}
/// What the engine must do now.
@ -49,6 +54,16 @@ pub enum Action {
Apply,
}
/// What launch_apply started.
#[allow(dead_code)] // one variant per platform
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 mining; the installer stops the engine itself
/// (api/quit) once it is allowed to run. The engine stays up and watches update-result.json for a deferral.
InstallerRunning,
}
pub struct Ctx {
pub node_synced: bool,
pub boundary_eta_s: Option<i64>,
@ -94,6 +109,17 @@ pub struct Updater {
/// the consensus override file written from the manifest, when it changed since the last take
override_changed: Option<PathBuf>,
override_daa: u64,
/// Windows: the installer was started and the engine is still up (it stops us when it may run)
apply_launched: Option<Instant>,
/// the administrator prompt was not answered: no automatic retry before this (Install now still works)
deferred_until: Option<Instant>,
/// this machine's minute of the hour for applying (manifest::slot_minute of the machine id)
slot: u64,
/// identity counts from /api/live over the last 10 minutes, sampled while an update is ready
live_samples: Vec<(Instant, u64)>,
live_next: Instant,
live_busy: bool,
live_api: String,
}
impl Updater {
@ -131,7 +157,23 @@ impl Updater {
staged_digest: String::new(),
override_changed: None,
override_daa: 0,
apply_launched: None,
deferred_until: None,
slot: manifest::slot_minute(&shared.runtime.id8()),
live_samples: Vec::new(),
live_next: now,
live_busy: false,
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
};
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
#[cfg(windows)]
{
// the login entry follows the install folder (a per-user install replaces one in Program Files)
if crate::platform::start_at_login_is_on() {
let _ = crate::platform::set_start_at_login(true);
}
firewall_first_run(shared);
}
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();
// the cached manifest: the rollback floor and the consensus override are known before the first check
if let Ok(text) = std::fs::read_to_string(u.dir.join("manifest.json")) {
@ -226,8 +268,13 @@ impl Updater {
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
let err = v.get("error").and_then(|x| x.as_str()).unwrap_or("").to_string();
let rolled_back = v.get("rolled_back").and_then(|x| x.as_bool()).unwrap_or(false);
let deferred = v.get("deferred").and_then(|x| x.as_bool()).unwrap_or(false);
let ver = v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string();
if !ok {
if !ok && deferred {
// the installer never ran (nobody answered the administrator prompt): not a failure, it tries again
shared.log(&format!("OTA: the update to {ver} was deferred before this start ({err}); it tries again in this machine's slot"));
let _ = std::fs::remove_file(self.pending_path());
} else if !ok {
let mut st = shared.state.lock().unwrap();
st.update.error = err.clone();
st.update.status = "error".into();
@ -292,7 +339,7 @@ impl Updater {
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" {
if u.status != "applying" && u.status != "manual" && u.status != "error" && u.status != "deferred" {
u.status = if self.staged.is_some() {
"ready".into()
} else if self.file.is_some() {
@ -391,8 +438,47 @@ impl Updater {
return None;
}
self.last_safe_check = now;
// Windows: the installer was started with the engine still mining; it stops us when it may run. Until then
// watch for the helper's verdict (an unanswered administrator prompt), never the other way round.
if let Some(t) = self.apply_launched {
match self.read_result() {
Some((false, err, _)) => {
let _ = std::fs::remove_file(self.result_path());
self.defer(shared, &err);
}
Some((true, ..)) => {} // 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();
}
// the network guard: /api/live every 60 s while an update waits
if !self.live_api.is_empty() && !self.live_busy && now >= self.live_next {
self.live_next = now + Duration::from_secs(60);
self.live_busy = true;
let url = self.live_api.clone();
let shared2 = shared.clone();
std::thread::spawn(move || shared2.send(Cmd::Ota(Event::Live(fetch_live_identities(&url)))));
}
self.live_samples.retain(|(t, _)| now.duration_since(*t) <= Duration::from_secs(600));
let network_drop_pct = {
let max = self.live_samples.iter().map(|(_, n)| *n).max().unwrap_or(0);
match self.live_samples.last() {
Some((_, cur)) if max > 0 && self.live_samples.len() >= 2 => (max.saturating_sub(*cur)) as f64 * 100.0 / max as f64,
_ => 0.0,
}
};
let minute = (crate::platform::unix_now() / 60) % 60;
let slot_ok = minute == self.slot || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked };
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
if !self.auto && !urgent && !self.install_asked {
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
return None;
@ -405,12 +491,40 @@ impl Updater {
match manifest::safe_to_apply(&moment) {
Ok(()) => Some(Action::Apply),
Err(why) => {
let why = if why.contains("own minute") { format!("{why}: at :{:02} past the hour", self.slot) } else { why };
shared.state.lock().unwrap().update.wait = why;
None
}
}
}
/// The helper's verdict file: (ok, error, deferred).
fn read_result(&self) -> Option<(bool, String, bool)> {
let v: Value = serde_json::from_str(&std::fs::read_to_string(self.result_path()).ok()?).ok()?;
Some((
v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false),
v.get("error").and_then(|x| x.as_str()).unwrap_or("").to_string(),
v.get("deferred").and_then(|x| x.as_bool()).unwrap_or(false),
))
}
/// Windows: the installer could not run (the administrator prompt was declined, timed out, or nobody was there).
/// The engine never stopped, so mining goes on; the update waits for Install now, the next start, or 6 hours.
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(self.pending_path());
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); mining continues".into();
}
shared.event("info", &format!("OTA: administrator approval not given for Igneum Miner {v} ({err}); the update waits for the next time someone is at this PC; mining continues"));
}
fn urgent(&self, ctx: &Ctx) -> bool {
let Some(m) = &self.manifest else { return false };
if self.entry.is_none() {
@ -531,6 +645,13 @@ impl Updater {
self.start_stage(shared, p);
}
},
Event::Live(n) => {
self.live_busy = false;
if let Some(n) = n {
self.live_samples.push((Instant::now(), n));
}
return;
}
Event::Staged(r) => match r {
Err(e) if e.starts_with("manual:") => {
let mut st = shared.state.lock().unwrap();
@ -601,7 +722,11 @@ impl Updater {
/// 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;
self.last_safe_check = Instant::now() - Duration::from_secs(10);
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;
@ -649,9 +774,11 @@ impl Updater {
if std::fs::write(&p, vars.join("\n") + "\n").is_ok() { p.display().to_string() } else { String::new() }
}
/// Writes update-pending.json and the helper, starts the helper detached. The engine exits right after.
/// `host_pid` is the window host when the engine runs under one.
pub fn launch_apply(&mut self, shared: &Arc<Shared>, host_pid: u32) -> Result<(), String> {
/// Writes update-pending.json and the helper, starts the helper detached. macOS: the engine exits right after
/// (Launch::QuitNow). Windows: the installer runs first while the engine keeps mining and stops the engine itself
/// once it may run (Launch::InstallerRunning); an unanswered administrator prompt never strands the machine
/// (4 October 2026: two unattended PCs sat stopped at a prompt for an hour). `host_pid` is the window host.
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();
// R4.3.5: what is about to be swapped in is re-verified now, not only when it was downloaded
@ -687,7 +814,7 @@ impl Updater {
let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string(), env_file, self.staged_digest.clone()];
shared.log(&format!("update: starting the helper: bash {} {}", script.display(), args.join(" ")));
spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?;
Ok(())
Ok(Launch::QuitNow)
}
#[cfg(windows)]
{
@ -699,9 +826,14 @@ impl Updater {
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", &entry.sha256,
]);
shared.log(&format!("update: starting the helper: {} (an administrator prompt follows)", script.display()));
let per_user = !under_program_files(&install_dir);
shared.log(&format!("update: starting the installer first, the miners keep running: {} ({})", script.display(), if per_user { "per-user install, no administrator prompt" } else { "install in Program Files: Windows asks for administrator approval" }));
if !per_user {
shared.log(&format!("OTA: waiting for administrator approval for Igneum Miner {to}; mining continues until it is given"));
}
spawn_detached(&mut c)?;
Ok(())
self.apply_launched = Some(Instant::now());
Ok(Launch::InstallerRunning)
}
#[cfg(not(any(target_os = "macos", windows)))]
{
@ -759,6 +891,69 @@ impl Updater {
// ---- the threads ---------------------------------------------------------------------------------------------------
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
fn live_api_from(live_page: &str) -> String {
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
let host = rest.split('/').next().unwrap_or("");
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
}
/// The network's identity count over the last 10 minutes from /api/live (state.miners_10m).
fn fetch_live_identities(url: &str) -> Option<u64> {
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "10", url]), None, Duration::from_secs(12))?;
let v: Value = serde_json::from_str(out.trim()).ok()?;
v.get("state")?.get("miners_10m")?.as_u64()
}
/// Windows: an install under Program Files was made by the administrator installer (0.3.2 and earlier).
#[cfg(windows)]
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()))
}
/// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on
/// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot
/// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself.
#[cfg(windows)]
fn firewall_first_run(shared: &Arc<Shared>) {
let flag = shared.runtime.app_dir.join("firewall-rule.json");
if flag.exists() {
return;
}
let Some(install_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) else { return };
if under_program_files(&install_dir) {
let _ = std::fs::write(&flag, json!({ "source": "installer", "at": crate::platform::unix_now() }).to_string());
return;
}
let node = install_dir.join("igneumd.exe");
if !node.is_file() {
return;
}
let script = shared.runtime.app_dir.join("firewall-rule.ps1");
let text = format!(
"$rule = 'advfirewall firewall add rule name=\"Igneum Miner node\" dir=in action=allow enable=yes profile=private,domain protocol=TCP program=\"{}\"'\n$p = Start-Process -FilePath netsh.exe -ArgumentList $rule -Verb RunAs -Wait -PassThru -WindowStyle Hidden\nexit $p.ExitCode\n",
node.display()
);
if std::fs::write(&script, text).is_err() {
return;
}
let shared = shared.clone();
std::thread::spawn(move || {
shared.log("firewall: asking once for administrator approval of the inbound rule for igneumd.exe (mining does not wait for it)");
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script);
crate::platform::quiet(&mut c);
let ok = c.status().map(|s| s.success()).unwrap_or(false);
let _ = std::fs::write(&flag, json!({ "source": "first-run", "ok": ok, "at": crate::platform::unix_now() }).to_string());
if ok {
shared.log("firewall: inbound rule added for igneumd.exe");
} else {
shared.log("firewall: no inbound rule (administrator approval not given); the node dials out and mines without it, other nodes cannot dial in; not asked again");
}
});
}
fn file_name(url: &str) -> String {
let name = url.rsplit('/').next().unwrap_or("update").split('?').next().unwrap_or("update");
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_').collect();
@ -1001,48 +1196,56 @@ esac
#[cfg(windows)]
const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine (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>
# apply: waits for the engine (it exits right after starting this), runs the installer /VERYSILENT with /IGNOTA=1 as
# administrator (one UAC prompt; the installer stops what is left, replaces the files and relaunches the app), writes
# <json>. If the installer does not run (prompt declined, error), the old app is started again.
# rollback: the same with the previous version's installer.
# 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 mining (4 October 2026: two unattended PCs sat stopped at an
# administrator prompt nobody could click). A per-user installer (0.3.3 and later, 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: miners first, then the node), 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 and the engine shows "waits for the next time someone is at this PC". 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) {
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; at = [int][double](Get-Date -UFormat %s) }
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 'igneum-app.exe'
if (Test-Path $exe) { Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
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"
$deadline = (Get-Date).AddSeconds(120)
while ((Get-Date) -lt $deadline -and (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 500 }
if (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) { Log 'engine still running; ending it'; Stop-Process -Id $EnginePid -Force -ErrorAction SilentlyContinue }
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false; Relaunch; exit 1 }
# the installer is hashed again right before it runs as administrator (R4.3.5)
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false; Relaunch; exit 1 }
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining 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 (R4.3.5)
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; Relaunch; exit 1 }
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 {
$args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
$p = Start-Process -FilePath $Installer -ArgumentList $args -Verb RunAs -Wait -PassThru
# 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 mining
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
if ($p.ExitCode -eq 0) {
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true }
else { Done $true '' $false }
Log "installer exit 0"
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $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
Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false
Relaunch
exit 1
} catch {
Log ("installer did not run: " + $_.Exception.Message)
Done $false ("Windows did not let the installer run: " + $_.Exception.Message) $false
$msg = $_.Exception.Message
Log ("installer did not run: " + $msg)
Log 'OTA: waiting for administrator approval; the engine keeps mining; the update waits for the next time someone is at this PC'
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
Relaunch
exit 1
}
Relaunch
exit 1
"#;

View file

@ -434,7 +434,8 @@
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 at the next safe moment.'), install: true };
case 'applying': return { text: 'Installing ' + v + ': the miners stop, then the node, then the app opens again.' };
case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the miners stop, then the node, then the app opens again.') };
case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC. Mining continues.', 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;
@ -472,6 +473,7 @@
if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = 0.43; }
if (kind === 'waiting') { u.wait = 'hourly program boundary in 97 s; installing after it'; }
if (kind === 'applying') { u.status = 'applying'; u.applying = true; }
if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission); mining continues'; }
if (kind === 'urgent') { u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.1 now.'; }
if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; }
if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; }

View file

@ -3,6 +3,6 @@
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.3.2"
#define IGNEUM_HOST_VERSION_RC 0,3,2,0
#define IGNEUM_HOST_VERSION_STR "0.3.3"
#define IGNEUM_HOST_VERSION_RC 0,3,3,0
#endif

View file

@ -68,21 +68,44 @@ running bundle (same volume: the swap is two renames), run its engine with `--ve
version. When the folder is not writable the state is `manual`: the banner says so and offers "Open the download".
Windows: the installer is the staged file.
Safe moment (`manifest::safe_to_apply`): node synced, no hourly program boundary within 180 s (`program.eta_s`),
no worker starting. Urgent (fork within 1,800 blocks, or unsupported version, or Install now) skips the wait. A
ready update that found no safe moment for 6 hours applies anyway (an unsynced node mines nothing).
Safe moment (`manifest::safe_to_apply`): the network has not lost over 30% of its identities in 10 minutes, this
minute is the machine's slot, node synced, no hourly program boundary within 180 s (`program.eta_s`), no worker
starting. Urgent (fork within 1,800 blocks, or unsupported version, or Install now) skips the wait. A ready update
that found no safe moment for 6 hours applies anyway in its slot (an unsynced node mines nothing).
Apply. The engine writes `update-pending.json` (from, to, starts), starts the helper detached and leaves through its
normal quit path: miners first (8 s grace), then the node (30 s), the last log upload, `EXIT` for the window.
- macOS helper `ota-apply.sh`: waits for the engine, asks the window (`network.igneum.miner`) to quit, moves the
bundle to `Igneum Miner.app.previous`, the staged one in, strips quarantine, `open -n`. If the new engine is not
running after 30 s it opens once more; if that fails too it puts `.previous` back and reports.
- Windows helper `ota-apply.ps1`: waits for the engine, runs `Igneum-Miner-Setup-<v>.exe /VERYSILENT
/SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /IGNOTA=1 /LOG=...` as administrator (ONE UAC prompt: the
installer is `PrivilegesRequired=admin` because of Program Files and the firewall rule). The installer's
`PrepareToInstall` runs `stop-igneum.ps1` (ends the window and anything left), replaces the files, and the
`[Run]` entry on `/IGNOTA=1` relaunches `igneum-app.exe --launch` as the signed-in user. A declined prompt or a
non-zero exit relaunches the old app and reports the error in the banner (Install now retries).
- Windows helper `ota-apply.ps1` (0.3.3, after the 4 October incident below): runs `Igneum-Miner-Setup-<v>.exe
/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /IGNOTA=1 /LOG=...` FIRST, with the engine still
mining. The installer is per-user since 0.3.3 (`PrivilegesRequired=lowest`,
`{localappdata}\Programs\Igneum Miner`), so nothing asks for an administrator; its `PrepareToInstall` runs
`stop-igneum.ps1` (api/quit: miners first, then the node, then the window), replaces the files, and the `[Run]`
entry on `/IGNOTA=1` relaunches `igneum-app.exe --launch`. An older install in Program Files (0.3.2 and before)
still raises one UAC prompt through ShellExecute: declined, timed out or unanswered, the helper writes
`deferred:true`, the engine keeps mining, logs `OTA: waiting for administrator approval` / `OTA: administrator
approval not given ...; mining continues`, shows "waits for the next time someone is at this PC" and retries on
Install now, at the next start, or after 6 hours. The engine is never stopped before the installer is running.
- Machines take turns: an update applies only in the machine's own minute of the hour (`manifest::slot_minute`:
the first 8 hex of the machine id modulo 60; PC 2 = :58), and never while `/api/live` shows the network lost
over 30% of its identities (`state.miners_10m`) in the last 10 minutes. Urgent (fork close, unsupported) and
Install now skip both.
- Per-user install, migration: the data (`%LOCALAPPDATA%\igneum`: chain, wallet, settings) is the same folder for
both installs, nothing is copied. A hand-run installer offers to remove the Program Files copy (one
administrator prompt for its uninstaller); a silent OTA install leaves it and says nothing. The login entry
(HKCU Run) is rewritten to the new path on first start. The inbound firewall rule for `igneumd.exe` is requested
once on the first run of a per-user install (one prompt, in a thread; declined or unanswered = the node dials out
and mines without it, never asked again).
Field incident, 4 October 2026 15:40 BST: 0.3.2 published; both Windows PCs (0.3.1, nobody at either keyboard)
reached apply, stopped the miners and the node, then sat at the installer's UAC prompt. The chain fell to one
laptop. The 0.3.1 helper waits for the engine to exit before it runs the installer, so a prompt nobody answers
strands the machine; 0.3.3 inverts the order (installer first, engine stops only when the installer runs) and the
per-user installer removes the prompt altogether. The 0.3.2 engines still carry the old helper: their 0.3.3 update
raises the prompt once more (with 0.3.2's R4.3.6 rule the version is then marked failed, not retried); click Yes
once, or run the 0.3.3 installer by hand.
Rollback. The helper writes `update-result.json`; the new engine reads it on start and reports "updated to X from
Y" or the error. The new engine counts its starts in `update-pending.json` and deletes the file after 90 healthy

View file

@ -3,6 +3,18 @@
The Windows apply path could not be run from the Mac. It was reviewed against `packaging/windows/Igneum-Miner.iss`,
`stop-igneum.ps1` and `app/windows/host.cpp`; these steps run it for real. Allow 20 minutes.
## 0.3.3 (after the 4 October incident): what changed and what to check first
The installer is per-user (`%LOCALAPPDATA%\Programs\Igneum Miner`, no administrator prompt) and the updater runs
the installer BEFORE it stops anything. On a PC still on 0.3.2 (installed in Program Files) the 0.3.2 engine's old
helper stops the miners and raises the prompt once more for the 0.3.3 installer: click Yes when it appears, or run
`Igneum-Miner-Setup-0.3.3.exe` by hand (it stops the old app, installs per user, offers to remove the Program Files
copy with one administrator prompt, starts the new app). After that no update ever asks again.
Check on the first 0.3.3 start: Settings shows 0.3.3; the log has `update slot: minute 58 of every hour` (PC 2) and
`firewall: asking once for administrator approval ...` (answer Yes once, or ignore: mining does not wait);
`%LOCALAPPDATA%\igneum\app\firewall-rule.json` exists afterwards. The Start Menu entry opens the new copy.
## What is untested on Windows
- `ota-apply.ps1` end to end: the wait for the engine, `Start-Process -Verb RunAs` of the installer, the UAC prompt,
@ -11,6 +23,13 @@ The Windows apply path could not be run from the Mac. It was reviewed against `p
Manager cannot close it; `PrepareToInstall` (`stop-igneum.ps1`, `Stop-Process -Force` on "Igneum Miner") is what
ends it. Watch for an installer dialog about files in use.
- The rollback: the previous installer is kept in `updates/` only from the second OTA on; a first update has none.
- The deferral path end to end: `Start-Process` without `-Verb RunAs` on an administrator installer, the exception
on a declined or timed-out prompt, `deferred:true` in `update-result.json`, the engine's "OTA: administrator
approval not given" line and the banner, the 6-hour retry. (Only reachable while an install is still in Program
Files; a per-user install never prompts.)
- The per-user installer over a Program Files install: the stop script from the old folder, the "remove the older
copy?" question, the HKCU Run entry rewritten, two Start Menu entries until the old copy goes.
- The first-run firewall prompt and `firewall-rule.json`.
- `powershell` 5.1 parsing of the helper (`tools/ci/windows/check-ps51.ps1` cannot see it: it is a string in
`src/ota.rs`). The helper avoids `"$x: y"` and uses nothing newer than 5.1.

View file

@ -14,7 +14,6 @@
#define AppName "Igneum Miner"
#define Publisher "Igneum"
#define Url "https://igneum.network"
#define FirewallRule "Igneum Miner node"
[Setup]
AppId={{A4C1F0E2-6B8D-4E7A-9F31-2C5D8E7B9A01}
@ -30,7 +29,7 @@ VersionInfoVersion={#AppVersion}.0
VersionInfoCompany={#Publisher}
VersionInfoProductName={#AppName}
VersionInfoDescription={#AppName} Setup
DefaultDirName={autopf}\{#AppName}
DefaultDirName={localappdata}\Programs\{#AppName}
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
LicenseFile=LICENSE.txt
@ -46,7 +45,9 @@ Compression=lzma2/max
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
; Per user since 0.3.3 (4 October 2026): no administrator prompt, so the app's own updater can install unattended
; (two PCs sat stopped at a UAC prompt for an hour). The firewall rule moved to the app's first run (src/ota.rs).
PrivilegesRequired=lowest
MinVersion=10.0
CloseApplications=yes
RestartApplications=no
@ -60,7 +61,6 @@ english.FinishedHeadingLabel=Igneum Miner is installed
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
Name: "firewall"; Description: "Let other Igneum nodes connect to this PC (Windows Firewall rule for igneumd.exe on private networks)"; GroupDescription: "Network:"
;; No [Dirs] entry: {app} stays read-only for users (R4.3.3). The engine writes under %LOCALAPPDATA%\igneum, and
;; the fallback worker build copies the sources there first.
@ -68,6 +68,7 @@ Name: "firewall"; Description: "Let other Igneum nodes connect to this PC (Windo
[Files]
Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; Excludes: "*.log,*.seeds,*.DS_Store,packs\*,build\*,dist\*"
Source: "{#ArtDir}\igneum.ico"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#Payload}\stop-igneum.ps1"; Flags: dontcopy
Source: "wrappers\Stop Igneum Miner.cmd"; DestDir: "{app}"; Flags: ignoreversion
Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
@ -80,8 +81,7 @@ Name: "{group}\Uninstall Igneum Miner"; Filename: "{uninstallexe}"; IconFilename
Name: "{autodesktop}\Igneum Miner"; Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon
[Run]
Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""{#FirewallRule}"""; Flags: runhidden; Tasks: firewall
Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""{#FirewallRule}"" dir=in action=allow enable=yes profile=private,domain protocol=TCP program=""{app}\igneumd.exe"""; Flags: runhidden; Tasks: firewall; StatusMsg: "Adding the firewall rule for the node"
; The inbound firewall rule needs an administrator and is asked for once by the app on its first run (declined = the node dials out and mines without it).
; Started as the signed-in user, not as administrator (the data lands in that user's %LOCALAPPDATA%).
Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Description: "Start Igneum Miner now"; Flags: postinstall nowait skipifsilent runasoriginaluser
; The over-the-air updater (packaging/ota, src/ota.rs) runs this installer /VERYSILENT /IGNOTA=1 and the app must come back by itself.
@ -89,7 +89,6 @@ Filename: "{app}\igneum-app.exe"; Parameters: "--launch"; Flags: nowait runasori
[UninstallRun]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\stop-igneum.ps1"""; Flags: runhidden waituntilterminated; RunOnceId: "StopIgneum"
Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""{#FirewallRule}"""; Flags: runhidden; RunOnceId: "FirewallRule"
[UninstallDelete]
; The GPU workers built on this PC and the WebView2 cache are not in the install log; remove them with the folder.
@ -102,16 +101,41 @@ begin
Result := ExpandConstant('{param:IGNOTA|0}') = '1';
end;
// Stops a running copy before the files are replaced (an upgrade over a running miner).
// The 0.3.2-and-earlier install in Program Files (administrator), when this per-user install lands on top of it.
function OldAdminInstallDir: String;
begin
Result := ExpandConstant('{commonpf}\Igneum Miner');
if not FileExists(Result + '\igneum-app.exe') then
Result := '';
end;
// Stops a running copy before the files are replaced (an upgrade over a running miner): the old copy's own
// stop-igneum.ps1 when one is installed (Program Files or here), else ours from the payload. Then, when someone is at
// the keyboard, offers to remove the Program Files copy (its uninstaller needs one administrator prompt; the data in
// %LOCALAPPDATA%\igneum is the same for both, nothing to copy). A silent (over-the-air) install never asks.
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
StopScript: String;
StopScript, OldDir: String;
ResultCode: Integer;
begin
Result := '';
OldDir := OldAdminInstallDir;
StopScript := ExpandConstant('{app}\stop-igneum.ps1');
if (not FileExists(StopScript)) and (OldDir <> '') then
StopScript := OldDir + '\stop-igneum.ps1';
if not FileExists(StopScript) then
StopScript := ExpandConstant('{tmp}\stop-igneum.ps1');
if not FileExists(StopScript) then
ExtractTemporaryFile('stop-igneum.ps1');
if FileExists(StopScript) then
Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -File "' + StopScript + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if (OldDir <> '') and (not WizardSilent) and FileExists(OldDir + '\unins000.exe') then
begin
if MsgBox('Igneum Miner now installs in your user folder, so updates need no administrator prompt.' + #13#10#13#10 +
'An older copy is still in ' + OldDir + '. Remove it now? (one administrator prompt; your chain data, address and settings stay)',
mbConfirmation, MB_YESNO) = IDYES then
ShellExec('runas', OldDir + '\unins000.exe', '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
end;
procedure CurPageChanged(CurPageID: Integer);

View file

@ -63,8 +63,11 @@ prints the deploy command, or deploys with `--deploy`.
The installed app updates itself: `packaging/ota/README.md`. `fetch-ci-artifacts.sh` adds the installer it copies to
the signed manifest (`igneum-app-latest.json`), `--deploy` ships both, and every app downloads the installer within
the hour and runs it `/VERYSILENT /IGNOTA=1` at a safe moment (one UAC prompt; `Igneum-Miner.iss` has
`CloseApplications=yes` and a `[Run]` relaunch for that flag). PC 2 steps: `packaging/ota/TEST.md`. The console
the hour and runs it `/VERYSILENT /IGNOTA=1` in its own minute of the hour (`Igneum-Miner.iss` has
`CloseApplications=yes` and a `[Run]` relaunch for that flag). Since 0.3.3 the installer is per user
(`PrivilegesRequired=lowest`, `%LOCALAPPDATA%\Programs\Igneum Miner`): no administrator prompt, ever, for an
update (4 October 2026: two unattended PCs sat at a UAC prompt for an hour); the inbound firewall rule is asked for
once by the app on its first run. PC 2 steps: `packaging/ota/TEST.md`. The console
launcher packages (`proto-cuda/windows-app`, `igneum-windows-v4.zip`) are not auto-updated, by design: they are the
engineering path and are replaced by hand.