diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 123a438c4..1fc053b14 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 4b7d7dcaa..f8df19c70 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 c0a1f8ba4..a35a761c3 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 000000000..dbf7a5d27 --- /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 86ed1d99b..bdab32432 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