diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 3f000a294..c3cac9b2f 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "igneum-app" -version = "0.3.2" +version = "0.3.3" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index 173be2130..79c41398d 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -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" diff --git a/app/igneum-app/resources/igneum-app.rc b/app/igneum-app/resources/igneum-app.rc index 38b96fedc..b4d33aceb 100644 --- a/app/igneum-app/resources/igneum-app.rc +++ b/app/igneum-app/resources/igneum-app.rc @@ -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" diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index e0144038b..fe4b8e20c 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -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(); diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 3346bc661..e879657fa 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -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] diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 765e83ca0..d19febabe 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -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), /// macOS: the new bundle staged next to the running one. Windows: the installer path again. Staged(Result), + /// The network's identity count from /api/live (state.miners_10m); None when the site did not answer. + Live(Option), } /// 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, @@ -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, override_daa: u64, + /// Windows: the installer was started and the engine is still up (it stops us when it may run) + apply_launched: Option, + /// the administrator prompt was not answered: no automatic retry before this (Install now still works) + deferred_until: Option, + /// 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::>(&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, 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) { 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, 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, host_pid: u32) -> Result { 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 { + 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) { + 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 -Installer -Version -Result -InstallDir -# 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 -# . 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 -Installer -Version -Result -InstallDir -Sha256 +# 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 "#; diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 9e4a6e7c1..6c695b1d3 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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; } diff --git a/app/windows/version.h b/app/windows/version.h index f00852be4..4a30f3d8b 100644 --- a/app/windows/version.h +++ b/app/windows/version.h @@ -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 diff --git a/packaging/ota/README.md b/packaging/ota/README.md index ce9b926fe..c561c98ea 100644 --- a/packaging/ota/README.md +++ b/packaging/ota/README.md @@ -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-.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-.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 diff --git a/packaging/ota/TEST.md b/packaging/ota/TEST.md index 26da26826..57cbdfbd7 100644 --- a/packaging/ota/TEST.md +++ b/packaging/ota/TEST.md @@ -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. diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index 394857671..a48059cad 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -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); diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 977ffcc12..770760453 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -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.