From 2cb0b2f757005d513746ce4b892db700c79231b2 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:54:53 +0000 Subject: [PATCH 1/2] 0.3.22: every right taken once at install, never at runtime (the project lead, 7 October 2026: "all the rights need to be done on install, and then on update if anything new"): src/rights.rs with the rights list (the Power Helper task, the boot task, the firewall rules for the node and the pool miner), the installed manifest rights.json, the one elevated script, and the prompt model; the installer's [Run] step igneum-app.exe --rights on every install (silent ones too) asks for administrator rights only when a right is missing; at runtime Power control is a plain toggle (the Power Helper task does the work) and a missing right is a notice ("run the installer again"), the firewall first-run prompt gone; tests known-failed first: a fresh install then Power control on shows one prompt at install and none after (the old way: two), an update with no new right shows none, an update with a new right shows exactly one Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 11 ++ app/igneum-app/src/main.rs | 15 ++ app/igneum-app/src/ota.rs | 9 ++ app/igneum-app/src/rights.rs | 216 +++++++++++++++++++++++++++++ packaging/windows/Igneum-Miner.iss | 5 + 5 files changed, 256 insertions(+) create mode 100644 app/igneum-app/src/rights.rs diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 123a438c..1fc053b1 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1412,6 +1412,12 @@ impl Engine { self.shared.event("info", if on && allowed { "Ember Tune on: every card is tuned for MH per watt once after install, then weekly" } else if on { "Ember Tune on: AMD cards are tuned; NVIDIA cards measure only until Power control is on in Settings" } else { "Ember Tune off; Tune now on a card still runs one" }); } Cmd::PowerControl(on) => { + if on && cfg!(windows) && !self.power_task_registered() { + // the right was not taken at install: say so, never ask (the project lead, 7 October 2026) + let note = crate::rights::missing_note("power-helper-task"); + self.shared.event("info", &format!("Power control: {note}")); + self.st().settings.power_note = note; + } { let mut s = self.shared.settings.lock().unwrap(); s.power_control = on; @@ -2654,6 +2660,11 @@ impl Engine { /// Power control (config.rs power_control): may the engine ask for administrator rights for the cap or the sweep? /// The elevated PC sweep job (--sweep) sets caps directly and counts as allowed. fn elevation_allowed(&self) -> bool { + // 0.3.22 (src/rights.rs): on Windows the engine never raises a prompt; Power control works through the Power Helper task + // the installer's rights step registered, and without that task the switch is a notice, not a prompt + if cfg!(windows) && self.power_task != Some(true) { + return false; + } elevation_allowed(self.shared.settings.lock().unwrap().power_control, self.shared.runtime.sweep_only) } diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 4b7d7dca..f8df19c7 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -51,6 +51,7 @@ mod drivertable; mod drivers; mod bootcheck; mod boot; +mod rights; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; @@ -69,6 +70,20 @@ fn main() { println!("igneum-app {}", engine::VERSION); return; } + if args.iter().any(|a| a == "--rights") { + // the installer's one elevated step (src/rights.rs): compares the installed rights manifest with this build's list, + // asks for administrator rights once when something is missing, else exits at once; exit 0 either way (an install + // never fails on a declined prompt: the app runs, the missing right is a notice) + let exe = std::env::current_exe().unwrap_or_default(); + let install_dir = exe.parent().map(|d| d.to_path_buf()).unwrap_or_default(); + let runtime = config::Runtime::from_env(); + match rights::install(&exe, &install_dir, &crate::platform::fixed_data_root(), &runtime.app_dir, engine::VERSION) { + Ok(true) => println!("rights: set up (one administrator approval)"), + Ok(false) => println!("rights: nothing new to set up"), + Err(e) => println!("rights: not set up ({e}); the app runs, and the missing rights are notices in Settings"), + } + return; + } if args.iter().any(|a| a == "--power-helper") { // the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands // from /app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index c0a1f8ba..a35a761c 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -1082,6 +1082,15 @@ fn firewall_first_run(shared: &Arc) { if flag.exists() { return; } + // 0.3.22: the rule is the installer's rights step (src/rights.rs); the app never prompts at runtime. A manifest that + // holds the right ends it here; a manifest that lacks it (or none: an install before 0.3.22) is one log line. + if crate::rights::held(&shared.runtime.app_dir, "firewall-node") { + let _ = std::fs::write(&flag, json!({ "source": "rights", "at": crate::platform::unix_now() }).to_string()); + return; + } + shared.log(&format!("firewall: inbound rule for igneumd.exe {}", crate::rights::missing_note("firewall-node"))); + return; + #[allow(unreachable_code)] 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()); diff --git a/app/igneum-app/src/rights.rs b/app/igneum-app/src/rights.rs new file mode 100644 index 00000000..dbf7a5d2 --- /dev/null +++ b/app/igneum-app/src/rights.rs @@ -0,0 +1,216 @@ +//! Every right the app will ever need is taken ONCE, at install, by one elevated step; the app never prompts at runtime; +//! an update asks again only when the list of rights grew (the project lead, 7 October 2026: "all the 'rights' need to be done on +//! install, and then on update if anything new"). +//! +//! Windows: the installer's [Run] entry `igneum-app.exe --rights` (every install, silent ones included) compares the +//! installed rights manifest (`/app/rights.json`: the version and the list the elevated step completed) with +//! this build's RIGHTS; when a right is missing it writes rights.ps1 and runs it elevated once (the one UAC prompt: the +//! Power Helper task for clock and power control, the boot task, the inbound firewall rules for the node and the pool +//! miner), then writes the manifest; when nothing is missing it exits at once with no prompt. At runtime: Power control +//! is a plain toggle (the Power Helper task does the work with no prompt); a right the manifest lacks is a notice +//! ("run the installer again"), never a prompt. The prompt counts are a function here, tested with the known-failed +//! shapes first (before 0.3.22: a fresh install then Power control on prompted; the firewall rule prompted on the first +//! run; the boot task was registered at the engine's start). + +use std::path::{Path, PathBuf}; + +/// The rights this build needs, in the order the elevated step takes them. An id never changes meaning; a new need is +/// a new id (that is what makes an update ask once). +pub const RIGHTS: &[(&str, &str)] = &[ + ("power-helper-task", "the Igneum Power Helper task: the clock and power limits of NVIDIA cards with no prompt (src/powertask.rs)"), + ("boot-task", "the Igneum Miner (boot) task: the engine starts at boot with nobody logged on (src/boot.rs)"), + ("firewall-node", "the inbound firewall rule for igneumd.exe (other nodes can dial in)"), + ("firewall-miner", "the inbound firewall rule for igneum-miner.exe (a pool's stratum port)"), +]; + +pub const MANIFEST_FILE: &str = "rights.json"; +pub const SCRIPT_FILE: &str = "rights.ps1"; + +/// The manifest the elevated step leaves: which rights hold, from which version, when. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Manifest { + pub version: String, + pub rights: Vec, + pub at: u64, +} + +impl Manifest { + pub fn parse(text: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some(Manifest { + version: v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(), + rights: v.get("rights").and_then(|x| x.as_array()).map(|a| a.iter().filter_map(|r| r.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(), + at: v.get("at").and_then(|x| x.as_u64()).unwrap_or(0), + }) + } + pub fn to_json(&self) -> String { + serde_json::json!({ "version": self.version, "rights": self.rights, "at": self.at, "format": "igneum-rights-1" }).to_string() + } + pub fn load(app_dir: &Path) -> Option { + std::fs::read_to_string(app_dir.join(MANIFEST_FILE)).ok().and_then(|t| Manifest::parse(&t)) + } + pub fn save(&self, app_dir: &Path) -> std::io::Result<()> { + std::fs::write(app_dir.join(MANIFEST_FILE), self.to_json()) + } +} + +/// The ids this build wants that the installed manifest does not hold (every id when there is no manifest). +pub fn missing(installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> Vec { + let have: Vec<&str> = installed.map(|m| m.rights.iter().map(|s| s.as_str()).collect()).unwrap_or_default(); + wanted.iter().map(|(id, _)| *id).filter(|id| !have.contains(id)).map(|s| s.to_string()).collect() +} + +/// What happens to the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Event { + /// the installer's --rights step (a fresh install or an update) + Install, + /// Power control switched on in Settings + PowerControlOn, + /// the engine's first run (the firewall rule, before 0.3.22) + FirstRun, +} + +/// 0.3.22: how many administrator prompts an event raises. Only the install step asks, and only when a right is missing. +pub fn prompts(event: Event, installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> u32 { + match event { + Event::Install => if missing(installed, wanted).is_empty() { 0 } else { 1 }, + Event::PowerControlOn | Event::FirstRun => 0, + } +} + +/// Before 0.3.22, for the record: the installer asked nothing; the first run asked for the firewall rule; Power control on +/// asked when the Power Helper task was not registered yet. +pub fn legacy_prompts(event: Event, power_task_registered: bool) -> u32 { + match event { + Event::Install => 0, + Event::FirstRun => 1, + Event::PowerControlOn => if power_task_registered { 0 } else { 1 }, + } +} + +fn ps_quote(s: &str) -> String { + s.replace('\'', "''") +} + +/// The one elevated script: every right in RIGHTS, idempotent (a rule is removed before it is added, a task is +/// registered with -Force). `exe` is the installed igneum-app.exe, `install_dir` its folder, `data_root` the user's +/// data root for the boot task. +pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String { + let node = ps_quote(&install_dir.join("igneumd.exe").display().to_string()); + let miner = ps_quote(&install_dir.join("igneum-miner.exe").display().to_string()); + let mut s = String::from("$ErrorActionPreference = 'Continue'\r\n# Igneum rights, one elevated step (src/rights.rs). Not for running by hand.\r\n"); + s.push_str(&crate::powertask::register_script(exe).replace("exit 0\r\n", "")); + s.push_str(&crate::boot::register_script(exe, data_root).replace("exit 0\r\n", "")); + for (name, prog) in [("Igneum Miner node", node), ("Igneum Miner pool", miner)] { + s.push_str(&format!( + "& netsh.exe advfirewall firewall delete rule name='{name}' | Out-Null\r\n\ + & netsh.exe advfirewall firewall add rule name='{name}' dir=in action=allow enable=yes profile=private,domain protocol=TCP program='{prog}' | Out-Null\r\n" + )); + } + s.push_str("exit 0\r\n"); + s +} + +/// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted). +pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result { + let installed = Manifest::load(app_dir); + let need = missing(installed.as_ref(), RIGHTS); + if need.is_empty() { + return Ok(false); + } + let _ = std::fs::create_dir_all(app_dir); + let path: PathBuf = app_dir.join(SCRIPT_FILE); + std::fs::write(&path, [b"\xEF\xBB\xBF".as_slice(), script(exe, install_dir, data_root).as_bytes()].concat()).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + #[cfg(windows)] + { + let line = format!("\"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", crate::platform::tool("powershell").display(), path.display()); + crate::platform::run_elevated(&line)?; + } + #[cfg(not(windows))] + { + return Err("the rights step is Windows only".into()); + } + #[allow(unreachable_code)] + { + let m = Manifest { version: version.to_string(), rights: RIGHTS.iter().map(|(id, _)| id.to_string()).collect(), at: crate::platform::unix_now() }; + m.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?; + Ok(true) + } +} + +/// Does the installed manifest hold this right? (The runtime's question before it would have prompted.) +pub fn held(app_dir: &Path, id: &str) -> bool { + Manifest::load(app_dir).map(|m| m.rights.iter().any(|r| r == id)).unwrap_or(false) +} + +/// The sentence the dashboard shows for a right the manifest lacks. +pub fn missing_note(id: &str) -> String { + let what = RIGHTS.iter().find(|(i, _)| *i == id).map(|(_, d)| *d).unwrap_or(id); + format!("not set up on this PC ({what}); run the Igneum Miner installer again: it asks for administrator rights once and sets everything up") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(rights: &[&str]) -> Manifest { + Manifest { version: "0.3.22".into(), rights: rights.iter().map(|s| s.to_string()).collect(), at: 1 } + } + + /// Known-failed first: before 0.3.22 a fresh install asked nothing and then the first run and Power control on each + /// asked (two prompts on the way to a tuned card); 0.3.22 asks once at install and never again. + #[test] + fn a_fresh_install_then_power_control_on_shows_one_prompt_at_install_and_none_after() { + assert_eq!(legacy_prompts(Event::Install, false) + legacy_prompts(Event::FirstRun, false) + legacy_prompts(Event::PowerControlOn, false), 2, "the old way: two prompts"); + assert_eq!(prompts(Event::Install, None, RIGHTS), 1, "the one prompt, at install"); + let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); + assert_eq!(prompts(Event::FirstRun, Some(&installed), RIGHTS), 0); + assert_eq!(prompts(Event::PowerControlOn, Some(&installed), RIGHTS), 0); + } + + #[test] + fn an_update_with_no_new_right_shows_no_prompt() { + let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); + assert_eq!(missing(Some(&installed), RIGHTS), Vec::::new()); + assert_eq!(prompts(Event::Install, Some(&installed), RIGHTS), 0); + } + + #[test] + fn an_update_with_a_new_right_shows_exactly_one_prompt() { + let installed = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner"]); + let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("driver-install-task", "the driver installer's task")]).collect(); + assert_eq!(missing(Some(&installed), &grown), vec!["driver-install-task".to_string()]); + assert_eq!(prompts(Event::Install, Some(&installed), &grown), 1); + // and a manifest from an older build that lacks two rights still asks exactly once + let older = m(&["power-helper-task"]); + assert_eq!(missing(Some(&older), RIGHTS).len(), 3); + assert_eq!(prompts(Event::Install, Some(&older), RIGHTS), 1); + } + + #[test] + fn the_manifest_round_trips_and_a_bad_file_reads_as_none() { + let a = m(&["power-helper-task", "boot-task"]); + assert_eq!(Manifest::parse(&a.to_json()), Some(a.clone())); + assert!(a.to_json().contains("\"format\":\"igneum-rights-1\"")); + assert_eq!(Manifest::parse("not json"), None); + assert_eq!(Manifest::parse("{}"), Some(Manifest::default())); + } + + #[test] + fn the_one_script_takes_every_right_and_is_idempotent() { + let s = script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\p\\Igneum Miner"), Path::new("C:\\u\\igneum")); + assert!(s.contains("-TaskName 'Igneum Power Helper'"), "the Power Helper task"); + assert!(s.contains("-TaskName 'Igneum Miner (boot)'"), "the boot task"); + // the join's separator is the test host's (the box runs this on Linux), so the path is read by its file name + assert!(s.contains("advfirewall firewall add rule name='Igneum Miner node'") && s.contains("Igneum Miner") && s.contains("igneumd.exe'"), "{s}"); + assert!(s.contains("advfirewall firewall add rule name='Igneum Miner pool'") && s.contains("igneum-miner.exe'")); + assert!(s.contains("firewall delete rule name='Igneum Miner node'"), "the rule is replaced, never doubled"); + assert_eq!(s.matches("exit 0").count(), 1, "one exit at the end, the sub-scripts' own stripped"); + assert!(!s.contains("Start-Process") && !s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once"); // console: a test string, not a spawn + for (id, _) in RIGHTS { + assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "ids are stable lowercase words: {id}"); + } + assert!(missing_note("firewall-node").contains("run the Igneum Miner installer again")); + } +} diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index 86ed1d99..bdab3243 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -81,6 +81,11 @@ 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] +; 0.3.22 (the project lead, 7 October 2026: "all the rights need to be done on install, and then on update if anything new"): the app's own +; rights step runs on every install, silent ones included; it compares the installed rights manifest with this build's list and +; asks for administrator rights ONCE only when a right is missing (the Power Helper task, the boot task, the firewall rules), +; else exits at once. The app never prompts at runtime (src/rights.rs). +Filename: "{app}\igneum-app.exe"; Parameters: "--rights"; Flags: waituntilterminated runasoriginaluser ; 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 From 765940f2f30dfbed2328afebb6ace106fa8867db Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:07:25 +0000 Subject: [PATCH 2/2] Rights step: asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment), else "rights: deferred" and the next interactive start asks once (the project lead, 7 October 2026: no PC job may need a click); the WebView2 runtime as the right webview2-runtime@ with the host loader's minimum in app/windows/version.h (IGNEUM_WEBVIEW2_MIN 109.0.1518.78, read at build time), the elevated step reading the Edge WebView2 client key (HKLM WOW6432Node and HKCU) and running the bundled evergreen bootstrapper silently when absent or below it (make-payload.sh carries the bootstrapper only when it matches packaging/windows/webview2.sha256); the driver lane's right driver-install-task in the list; tests known-failed first (a job's install defers, a session-less one defers, the person at the PC asks once; absent or older runtime installs, equal or newer does not, a raised minimum is exactly one new right) Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/main.rs | 1 + app/igneum-app/src/rights.rs | 193 ++++++++++++++++++++++++++++-- app/windows/version.h | 4 + packaging/windows/make-payload.sh | 8 ++ packaging/windows/webview2.sha256 | 3 + 5 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 packaging/windows/webview2.sha256 diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index f8df19c7..733d7c8e 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -80,6 +80,7 @@ fn main() { match rights::install(&exe, &install_dir, &crate::platform::fixed_data_root(), &runtime.app_dir, engine::VERSION) { Ok(true) => println!("rights: set up (one administrator approval)"), Ok(false) => println!("rights: nothing new to set up"), + Err(e) if e.starts_with("rights: deferred") => println!("{e}"), Err(e) => println!("rights: not set up ({e}); the app runs, and the missing rights are notices in Settings"), } return; diff --git a/app/igneum-app/src/rights.rs b/app/igneum-app/src/rights.rs index dbf7a5d2..76bd01ff 100644 --- a/app/igneum-app/src/rights.rs +++ b/app/igneum-app/src/rights.rs @@ -2,6 +2,13 @@ //! an update asks again only when the list of rights grew (the project lead, 7 October 2026: "all the 'rights' need to be done on //! install, and then on update if anything new"). //! +//! The step asks only in an interactive session that is not a job's (SESSIONNAME set, no IGNEUM_JOB_* in the environment): +//! a PC job may never need a click, so a silent install from a job logs "rights: deferred" and the next interactive start +//! of the app asks once. The WebView2 runtime is a right too, "webview2-runtime@" with the host loader's minimum +//! (app/windows/version.h IGNEUM_WEBVIEW2_MIN): the elevated step reads the Edge WebView2 client key (HKLM WOW6432Node and +//! HKCU) and runs the bundled evergreen bootstrapper silently when the runtime is absent or below ; a raised minimum is +//! a new id, so that update asks once. +//! //! Windows: the installer's [Run] entry `igneum-app.exe --rights` (every install, silent ones included) compares the //! installed rights manifest (`/app/rights.json`: the version and the list the elevated step completed) with //! this build's RIGHTS; when a right is missing it writes rights.ps1 and runs it elevated once (the one UAC prompt: the @@ -21,8 +28,72 @@ pub const RIGHTS: &[(&str, &str)] = &[ ("boot-task", "the Igneum Miner (boot) task: the engine starts at boot with nobody logged on (src/boot.rs)"), ("firewall-node", "the inbound firewall rule for igneumd.exe (other nodes can dial in)"), ("firewall-miner", "the inbound firewall rule for igneum-miner.exe (a pool's stratum port)"), + (WEBVIEW2_RIGHT, "the WebView2 runtime the window needs, at or above the host loader's minimum (installed silently from the bundled evergreen bootstrapper when absent or older)"), + // the driver lane's right (branch driver-hold-22, src/driverinstall.rs): the same Power Helper task with a two-hour run limit, so a + // vendor's driver installs unattended through it; at the merge the Power Helper line of script() becomes driverinstall::register_script + ("driver-install-task", "the Igneum Power Helper task with a two-hour run limit: a vendor's driver installs unattended through it, the vendor's cards held, no prompt (src/driverinstall.rs)"), ]; +/// The host loader's minimum WebView2 runtime (app/windows/version.h IGNEUM_WEBVIEW2_MIN), read at build time so the two +/// never drift; the right's id carries it. +pub const WEBVIEW2_MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h")); +pub const WEBVIEW2_RIGHT: &str = const_format_webview2_right(); +/// The bundled bootstrapper in the install folder and its pinned sha file (packaging/windows/webview2.sha256). +pub const WEBVIEW2_BOOTSTRAPPER: &str = "MicrosoftEdgeWebview2Setup.exe"; +pub const WEBVIEW2_KEY: &str = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + +const fn webview2_min_from_header(h: &str) -> &str { + // the line `#define IGNEUM_WEBVIEW2_MIN "a.b.c.d"`: the text between the quotes + let b = h.as_bytes(); + let key = b"IGNEUM_WEBVIEW2_MIN \""; + let mut i = 0; + while i + key.len() < b.len() { + let mut j = 0; + while j < key.len() && b[i + j] == key[j] { + j += 1; + } + if j == key.len() { + let start = i + key.len(); + let mut end = start; + while end < b.len() && b[end] != b'"' { + end += 1; + } + // SAFETY of the slice: start and end sit on ASCII bytes of a str literal + match h.split_at(start).1.split_at(end - start).0 { + s => return s, + } + } + i += 1; + } + "0" +} +const fn const_format_webview2_right() -> &'static str { + // "webview2-runtime@" + WEBVIEW2_MIN, built once at compile time + const PREFIX: &str = "webview2-runtime@"; + const MIN: &str = webview2_min_from_header(include_str!("../../windows/version.h")); + const LEN: usize = PREFIX.len() + MIN.len(); + const BUF: [u8; LEN] = { + let mut out = [0u8; LEN]; + let p = PREFIX.as_bytes(); + let m = MIN.as_bytes(); + let mut i = 0; + while i < p.len() { + out[i] = p[i]; + i += 1; + } + let mut j = 0; + while j < m.len() { + out[p.len() + j] = m[j]; + j += 1; + } + out + }; + match std::str::from_utf8(&BUF) { + Ok(s) => s, + Err(_) => "webview2-runtime@0", + } +} + pub const MANIFEST_FILE: &str = "rights.json"; pub const SCRIPT_FILE: &str = "rights.ps1"; @@ -60,6 +131,47 @@ pub fn missing(installed: Option<&Manifest>, wanted: &[(&str, &str)]) -> Vec, job_env: bool) -> bool { + crate::boot::interactive_session(session_name) && !job_env +} + +/// Is this process inside a remote job (the runner's IGNEUM_JOB_* environment)? +pub fn in_job_env() -> bool { + std::env::vars().any(|(k, _)| k.starts_with("IGNEUM_JOB_")) +} + +/// The outcome of the install step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + /// nothing missing: no prompt + Nothing, + /// a right is missing and this session may ask: one prompt + Asked, + /// a right is missing but this is a job's or a session-less run: no prompt, the next interactive start asks + Deferred, +} + +pub fn step(installed: Option<&Manifest>, wanted: &[(&str, &str)], session_name: Option<&str>, job_env: bool) -> Step { + if missing(installed, wanted).is_empty() { + Step::Nothing + } else if may_ask(session_name, job_env) { + Step::Asked + } else { + Step::Deferred + } +} + +/// The WebView2 version a registry read gave against the minimum: true when the runtime must be installed. +pub fn webview2_needs_install(installed: Option<&str>, min: &str) -> bool { + let parse = |v: &str| -> Vec { v.trim().split('.').map(|p| p.trim().parse::().unwrap_or(0)).collect() }; + match installed.map(|v| v.trim()).filter(|v| !v.is_empty()) { + None => true, + Some(v) => parse(v) < parse(min), + } +} + /// What happens to the user. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Event { @@ -108,16 +220,40 @@ pub fn script(exe: &Path, install_dir: &Path, data_root: &Path) -> String { & netsh.exe advfirewall firewall add rule name='{name}' dir=in action=allow enable=yes profile=private,domain protocol=TCP program='{prog}' | Out-Null\r\n" )); } + s.push_str(&webview2_script(install_dir)); s.push_str("exit 0\r\n"); s } +/// The WebView2 part of the elevated script: the client key's pv (HKLM WOW6432Node, then HKCU), the bundled bootstrapper +/// /silent /install when absent or below the minimum (exit 0 required), the version read back, one log line either way. +pub fn webview2_script(install_dir: &Path) -> String { + let boot = ps_quote(&install_dir.join(WEBVIEW2_BOOTSTRAPPER).display().to_string()); + let log = ps_quote(&install_dir.join("rights.log").display().to_string()); + format!( + "function WV2 {{ foreach ($k in @('HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{key}', 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{key}')) {{ $v = (Get-ItemProperty $k -ErrorAction SilentlyContinue).pv; if ($v) {{ return \"$v\" }} }}; return '' }}\r\n\ + $wvMin = '{min}'\r\n\ + $wvHave = WV2\r\n\ + $wvNeed = (-not $wvHave) -or ([version]$wvHave -lt [version]$wvMin)\r\n\ + if ($wvNeed) {{\r\n\ + \x20 if (Test-Path '{boot}') {{ $wp = Start-Process -FilePath '{boot}' -ArgumentList @('/silent', '/install') -Wait -PassThru -WindowStyle Hidden; Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', installed from the bundled bootstrapper, exit ' + $wp.ExitCode + ', now ' + (WV2)) }}\r\n\ + \x20 else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: was ' + $wvHave + ', below ' + $wvMin + ', and no bootstrapper beside the app; the window opens the dashboard in the browser until the runtime is installed') }}\r\n\ + }} else {{ Add-Content -Path '{log}' -Value ((Get-Date -Format s) + ' webview2: ' + $wvHave + ' ok (minimum ' + $wvMin + ')') }}\r\n", + key = WEBVIEW2_KEY, + min = WEBVIEW2_MIN + ) +} + /// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted). pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result { let installed = Manifest::load(app_dir); - let need = missing(installed.as_ref(), RIGHTS); - if need.is_empty() { - return Ok(false); + match step(installed.as_ref(), RIGHTS, std::env::var("SESSIONNAME").ok().as_deref(), in_job_env()) { + Step::Nothing => return Ok(false), + Step::Deferred => { + // a job's or a session-less install never raises a prompt (the project lead, 7 October 2026); the next interactive start asks once + return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", missing(installed.as_ref(), RIGHTS).len(), missing(installed.as_ref(), RIGHTS).join(", "))); + } + Step::Asked => {} } let _ = std::fs::create_dir_all(app_dir); let path: PathBuf = app_dir.join(SCRIPT_FILE); @@ -169,6 +305,45 @@ mod tests { assert_eq!(prompts(Event::PowerControlOn, Some(&installed), RIGHTS), 0); } + /// the project lead's rule of the same night: no PC job may need a click. Known-failed first: 3fbf4280's step asked on every install with + /// a missing right, a job's silent install included. + #[test] + fn the_rights_step_asks_only_in_an_interactive_session_that_is_not_a_jobs() { + assert_eq!(step(None, RIGHTS, Some("Console"), true), Step::Deferred, "a job's install (IGNEUM_JOB_* set): never a prompt"); + assert_eq!(step(None, RIGHTS, None, false), Step::Deferred, "no interactive session: never a prompt"); + assert_eq!(step(None, RIGHTS, Some("Console"), false), Step::Asked, "the person at the PC: one prompt"); + let all = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); + assert_eq!(step(Some(&all), RIGHTS, Some("Console"), false), Step::Nothing); + assert_eq!(step(Some(&all), RIGHTS, None, true), Step::Nothing, "nothing missing: nothing, wherever it runs"); + assert!(may_ask(Some("RDP-Tcp#2"), false) && !may_ask(Some("Console"), true) && !may_ask(None, false)); + } + + /// The WebView2 runtime as a right (packaging's step, named 7 October 2026). Known-failed first: before it the runtime + /// missing meant the window said "install the runtime from microsoft.com" and nothing installed it. + #[test] + fn the_webview2_runtime_is_a_right_with_the_minimum_in_its_id() { + assert_eq!(WEBVIEW2_MIN, "109.0.1518.78", "read from app/windows/version.h at build time"); + assert_eq!(WEBVIEW2_RIGHT, "webview2-runtime@109.0.1518.78"); + assert!(RIGHTS.iter().any(|(id, _)| *id == WEBVIEW2_RIGHT)); + // absent runtime: one prompt at install; present at or above the minimum: the right still has to be taken once + // (the manifest records it), but the script installs nothing (its own check); a raised minimum is a new id + let old = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner", "webview2-runtime@100.0.0.0", "driver-install-task"]); + assert_eq!(missing(Some(&old), RIGHTS), vec![WEBVIEW2_RIGHT.to_string()], "a raised minimum is exactly one new right"); + assert_eq!(prompts(Event::Install, Some(&old), RIGHTS), 1); + let now = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); + assert_eq!(prompts(Event::Install, Some(&now), RIGHTS), 0, "the same minimum: no prompt"); + assert!(webview2_needs_install(None, WEBVIEW2_MIN), "absent: install"); + assert!(webview2_needs_install(Some(""), WEBVIEW2_MIN)); + assert!(webview2_needs_install(Some("108.0.1462.76"), WEBVIEW2_MIN), "below: install"); + assert!(!webview2_needs_install(Some("109.0.1518.78"), WEBVIEW2_MIN), "equal: nothing"); + assert!(!webview2_needs_install(Some("154.0.4258.62"), WEBVIEW2_MIN), "PC 2's runtime: nothing"); + let s = webview2_script(Path::new("C:\\p\\Igneum Miner")); + assert!(s.contains("EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}") && s.contains("HKCU:"), "both registry forms"); + assert!(s.contains("MicrosoftEdgeWebview2Setup.exe") && s.contains("'/silent', '/install'") && s.contains("-Wait -PassThru -WindowStyle Hidden")); + assert!(s.contains("$wvMin = '109.0.1518.78'") && s.contains("[version]$wvHave -lt [version]$wvMin")); + assert!(s.contains("webview2: ") && s.contains(" ok (minimum "), "one log line either way"); + } + #[test] fn an_update_with_no_new_right_shows_no_prompt() { let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); @@ -178,13 +353,13 @@ mod tests { #[test] fn an_update_with_a_new_right_shows_exactly_one_prompt() { - let installed = m(&["power-helper-task", "boot-task", "firewall-node", "firewall-miner"]); - let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("driver-install-task", "the driver installer's task")]).collect(); - assert_eq!(missing(Some(&installed), &grown), vec!["driver-install-task".to_string()]); + let installed = m(&RIGHTS.iter().map(|(i, _)| *i).collect::>()); + let grown: Vec<(&str, &str)> = RIGHTS.iter().cloned().chain([("example-new-right", "a right a later build needs")]).collect(); + assert_eq!(missing(Some(&installed), &grown), vec!["example-new-right".to_string()]); assert_eq!(prompts(Event::Install, Some(&installed), &grown), 1); // and a manifest from an older build that lacks two rights still asks exactly once let older = m(&["power-helper-task"]); - assert_eq!(missing(Some(&older), RIGHTS).len(), 3); + assert_eq!(missing(Some(&older), RIGHTS).len(), RIGHTS.len() - 1); assert_eq!(prompts(Event::Install, Some(&older), RIGHTS), 1); } @@ -207,9 +382,9 @@ mod tests { assert!(s.contains("advfirewall firewall add rule name='Igneum Miner pool'") && s.contains("igneum-miner.exe'")); assert!(s.contains("firewall delete rule name='Igneum Miner node'"), "the rule is replaced, never doubled"); assert_eq!(s.matches("exit 0").count(), 1, "one exit at the end, the sub-scripts' own stripped"); - assert!(!s.contains("Start-Process") && !s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once"); // console: a test string, not a spawn + assert!(!s.contains("RunAs"), "the script itself never elevates: the step that runs it does, once (the bootstrapper's hidden run is a plain run)"); // console: a test string, not a spawn for (id, _) in RIGHTS { - assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "ids are stable lowercase words: {id}"); + assert!(!id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '@' || c == '.'), "ids are stable lowercase words, with an @version suffix where the minimum is part of the right: {id}"); } assert!(missing_note("firewall-node").contains("run the Igneum Miner installer again")); } diff --git a/app/windows/version.h b/app/windows/version.h index 37997f85..e4ef0723 100644 --- a/app/windows/version.h +++ b/app/windows/version.h @@ -5,4 +5,8 @@ #define IGNEUM_HOST_VERSION_H #define IGNEUM_HOST_VERSION_STR "0.3.22" #define IGNEUM_HOST_VERSION_RC 0,3,22,0 +// The WebView2 runtime the host's loader needs at least (the SDK 1.0.2903.40 loader's minimum, from its release note at the +// cut); the installer's rights step (src/rights.rs) installs the evergreen runtime when the PC's is absent or below it, and a +// raised minimum is a new right the next update asks once for. +#define IGNEUM_WEBVIEW2_MIN "109.0.1518.78" #endif diff --git a/packaging/windows/make-payload.sh b/packaging/windows/make-payload.sh index a1a82fe7..d5eb86de 100755 --- a/packaging/windows/make-payload.sh +++ b/packaging/windows/make-payload.sh @@ -84,6 +84,14 @@ cp "$ROOT/proto-opencl/host.c" "$ROOT/proto-opencl/build.bat" "$ROOT/proto-openc # the window host sources, built on the PC or by CI; the built host when BUILD-APP.bat already ran here cp "$ROOT/app/windows/host.cpp" "$ROOT/app/windows/host.rc" "$ROOT/app/windows/version.h" "$ROOT/app/windows/BUILD-APP.bat" "$STAGE/app/windows/" mkdir -p "$STAGE/app/windows/art" && cp "$ROOT/brand/icons/igneum.ico" "$STAGE/app/windows/art/" +# the WebView2 evergreen bootstrapper (the rights step installs the runtime when absent or below the host loader's minimum): +# rides when it sits in app/windows/dist and its sha256 is the one pinned in packaging/windows/webview2.sha256 +if [ -f "$ROOT/app/windows/dist/MicrosoftEdgeWebview2Setup.exe" ]; then + WANT="$(grep -v '^#' "$ROOT/packaging/windows/webview2.sha256" 2>/dev/null | tr -d '[:space:]')" + HAVE="$( (shasum -a 256 "$ROOT/app/windows/dist/MicrosoftEdgeWebview2Setup.exe" 2>/dev/null || sha256sum "$ROOT/app/windows/dist/MicrosoftEdgeWebview2Setup.exe") | cut -d' ' -f1)" + [ -n "$WANT" ] && [ "$WANT" = "$HAVE" ] || { echo "make-payload: MicrosoftEdgeWebview2Setup.exe is not the pinned bootstrapper (packaging/windows/webview2.sha256)" >&2; exit 1; } + cp "$ROOT/app/windows/dist/MicrosoftEdgeWebview2Setup.exe" "$STAGE/"; echo "webview2 bootstrapper: pinned, rides" +fi if [ -f "$ROOT/app/windows/dist/Igneum Miner.exe" ]; then # the host gate (0.3.22): the version resource equals this payload's version, no mingw-w64 signature, and the sha pinned # in packaging/windows/host.sha256 when that file exists (PC 2, 7 October 2026: a 0.3.22.0 mingw host in a 0.3.21 installer diff --git a/packaging/windows/webview2.sha256 b/packaging/windows/webview2.sha256 new file mode 100644 index 00000000..baeb37a5 --- /dev/null +++ b/packaging/windows/webview2.sha256 @@ -0,0 +1,3 @@ +# The sha256 of MicrosoftEdgeWebview2Setup.exe (the evergreen bootstrapper, from developer.microsoft.com/microsoft-edge/webview2) the +# payload may carry; make-payload.sh refuses any other. The shipper writes the line when it fetches the bootstrapper at a cut; empty +# (this state) means no bootstrapper rides and the rights step logs the missing-runtime line instead of installing.