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 <noreply@anthropic.com>
This commit is contained in:
parent
85eb90eaa9
commit
810af2c39b
5 changed files with 256 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
216
app/igneum-app/src/rights.rs
Normal file
216
app/igneum-app/src/rights.rs
Normal file
|
|
@ -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 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)"),
|
||||
];
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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<bool, String> {
|
||||
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::<Vec<_>>());
|
||||
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::<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(&["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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue