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.