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] 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.