Merge boot-start-22 ffb8e270 into release-0.3.23 (the rights step at install, deferred in a job or session-less install; the WebView2 right webview2-runtime@109.0.1518.78)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 19:15:45 +00:00
commit d989f59f40
8 changed files with 447 additions and 0 deletions

View file

@ -1426,6 +1426,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;
@ -2674,6 +2680,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)
}

View file

@ -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,21 @@ 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) 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;
}
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 data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes

View file

@ -1082,6 +1082,15 @@ fn firewall_first_run(shared: &Arc<Shared>) {
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());

View file

@ -0,0 +1,391 @@
//! 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").
//!
//! 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@<min>" with <min> 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 <min>; 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 data>/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)"),
(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";
/// 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<String>,
pub at: u64,
}
impl Manifest {
pub fn parse(text: &str) -> Option<Manifest> {
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<Manifest> {
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<String> {
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()
}
/// Where the step runs: an interactive session that is not a job's may ask; anything else defers (the project lead, 7 October 2026:
/// no PC job may need a click).
pub fn may_ask(session_name: Option<&str>, 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<u64> { v.trim().split('.').map(|p| p.trim().parse::<u64>().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 {
/// 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(&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<bool, String> {
let installed = Manifest::load(app_dir);
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);
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::<Vec<_>>());
assert_eq!(prompts(Event::FirstRun, Some(&installed), RIGHTS), 0);
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
assert_eq!(missing(Some(&installed), RIGHTS), Vec::<String>::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(&RIGHTS.iter().map(|(i, _)| *i).collect::<Vec<_>>());
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(), RIGHTS.len() - 1);
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("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.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"));
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

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