diff --git a/app/igneum-app/src/boot.rs b/app/igneum-app/src/boot.rs new file mode 100644 index 000000000..ce10bb48d --- /dev/null +++ b/app/igneum-app/src/boot.rs @@ -0,0 +1,349 @@ +//! The engine starts at boot without a logon (0.3.22, MF-11's second half). PC 2, 7 October 2026, 17:27 BST: a kernel +//! crash during the Intel driver install, the PC back at 17:28, and the per-user app then waited 67 minutes for a +//! logon with every card idle, because the only start was the Run key at logon and `--launch` always opened the window +//! host, which has no desktop before a logon. +//! +//! Windows, from 0.3.22: +//! - a per-user scheduled task `Igneum Miner (boot)` runs at system start under the user's own account with the S4U +//! logon (no password stored, no logon needed, limited run level): `igneum-app.exe --launch --data-root ` (the data root is spelled out because an S4U session may not load the profile); +//! registered by the engine at its start (unelevated, the user's own task) and again inside the one approved step +//! the Power Helper uses, for the account that cannot register it alone; +//! - `--launch` decides by the session: no interactive session (SESSIONNAME unset: session 0, the boot task, a +//! service) runs the engine headless (`--boot`: no window host, no browser); a logon session opens the window host +//! as before; +//! - the window host, at logon, starts `igneum-app.exe --wrapper` as always; that engine finds the headless engine's +//! app.url answering api/state and becomes a BRIDGE instead of a second engine: it prints the headless engine's +//! URL and STATE lines to the host and relays the host's stdin commands (quit, pause, resume, detect) to the +//! engine's API. The window closing (stdin gone) ends the bridge and leaves the engine mining; Quit in the tray +//! quits the engine. A headless engine that stops answering ends the bridge with EXIT, and the host (0.3.21) +//! starts `--wrapper` again, which then runs as a full engine. +//! The decisions are functions here so the tests drive them with the known-failed shape first. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// The boot task's name in the Windows Task Scheduler (per user). +pub const TASK_NAME: &str = "Igneum Miner (boot)"; +/// How often the bridge reads api/state for a STATE line, and how many misses in a row end it. +pub const BRIDGE_POLL_S: u64 = 3; +pub const BRIDGE_MISSES: u32 = 4; + +/// What `--launch` does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchMode { + /// start "Igneum Miner.exe" (the window host), which starts the engine + Host, + /// run the engine here, no window host, no browser (no interactive session, or no host next to the exe) + Headless, + /// run the engine here and open the dashboard in the default browser (a logon session without the window host) + Browser, +} + +/// The session a process runs in: `SESSIONNAME` is set in every interactive logon session ("Console", "RDP-Tcp#3") +/// and unset in session 0 (services, S4U scheduled tasks at boot). +pub fn interactive_session(session_name: Option<&str>) -> bool { + session_name.map(|s| !s.trim().is_empty()).unwrap_or(false) +} + +/// 0.3.22: the window host only when someone is logged on; headless otherwise. +pub fn launch_mode(session_name: Option<&str>, host_exists: bool) -> LaunchMode { + match (interactive_session(session_name), host_exists) { + (true, true) => LaunchMode::Host, + (true, false) => LaunchMode::Browser, + (false, _) => LaunchMode::Headless, + } +} + +/// Before 0.3.22, for the record: the host whenever it existed, whoever was or was not logged on. +pub fn legacy_launch_mode(host_exists: bool) -> LaunchMode { + if host_exists { LaunchMode::Host } else { LaunchMode::Browser } +} + +/// Does a closed stdin mean "the host went away" (quit)? Only for an engine a window host attached (`--wrapper`): a +/// headless engine (`--boot`, the boot task, a job's `--launch` with no session) owns itself and has no host to lose. +/// PC 2, 7 October 2026, 19:09 to 19:11 BST: four engines started a minute apart each quit 3 s after "node started" with +/// "the window host went away (stdin closed)", their parent having closed the pipe at once. +pub fn stdin_close_quits(wrapper: bool, headless: bool) -> bool { + wrapper && !headless +} + +// ---- the boot task --------------------------------------------------------------------------------------------------- + +fn ps_quote(s: &str) -> String { + s.replace('\'', "''") +} + +/// The PowerShell that registers the boot task: trigger at system start, principal the signed-in user with the S4U +/// logon (runs with nobody logged on, no password stored), limited run level, no time limit, one instance, hidden; +/// the action is the installed exe with `--launch --data-root `. +pub fn register_script(exe: &Path, data_root: &Path) -> String { + let exe_s = ps_quote(&exe.display().to_string()); + let dir = exe.parent().map(|d| ps_quote(&d.display().to_string())).unwrap_or_default(); + let root = ps_quote(&data_root.display().to_string()); + format!( + "$a = New-ScheduledTaskAction -Execute '{exe_s}' -Argument '--launch --data-root \"{root}\"' -WorkingDirectory '{dir}'\r\n\ + $t = New-ScheduledTaskTrigger -AtStartup\r\n\ + $t.Delay = 'PT30S'\r\n\ + $p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited\r\n\ + $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew -StartWhenAvailable -Hidden\r\n\ + Register-ScheduledTask -TaskName '{name}' -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null\r\n\ + exit 0\r\n", + name = TASK_NAME + ) +} + +/// The PowerShell that says whether the boot task is registered, enabled and its action's exe present (exit 0). +pub fn query_command() -> String { + format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}") +} + +/// The PowerShell that removes the task (an uninstall, or Start at login switched off). +pub fn remove_command() -> String { + format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0") +} + +fn powershell(command: &str, limit: Duration) -> Option<(bool, String)> { + let mut c = Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]); + crate::platform::quiet(&mut c); + let out = crate::detect::run_timeout(&mut c, None, limit)?; + // run_timeout folds the streams; the exit code is read again through a second probe when needed + Some((true, out)) +} + +/// Is the boot task registered (Windows only; false elsewhere)? +pub fn registered() -> bool { + if !cfg!(windows) { + return false; + } + let mut c = Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]); + crate::platform::quiet(&mut c); + c.status().map(|s| s.success()).unwrap_or(false) +} + +/// Registers the boot task unelevated (the user's own task). Ok(true) registered now, Ok(false) already there, +/// Err(why) when Windows refused (an account without the batch-logon right: the one approved step registers it). +pub fn ensure_registered(exe: &Path, data_root: &Path) -> Result { + if !cfg!(windows) { + return Ok(false); + } + if registered() { + return Ok(false); + } + let script = register_script(exe, data_root); + let (_, out) = powershell(&script, Duration::from_secs(60)).ok_or("powershell did not answer")?; + if registered() { + Ok(true) + } else { + Err(format!("the boot task was not registered: {}", out.lines().last().unwrap_or("no reason given").trim())) + } +} + +/// The installed exe the task's action names (never a scratch copy), as the Power Helper picks it. +pub fn task_exe(running: &Path) -> PathBuf { + crate::powertask::task_exe(running, &crate::powertask::install_candidates()) +} + +// ---- the bridge ------------------------------------------------------------------------------------------------------- + +/// The engine already running under the user's data root: its URL when app.url names one that answers api/state. +pub fn running_engine(app_dir: &Path) -> Option { + let url = std::fs::read_to_string(app_dir.join("app.url")).ok()?.trim().to_string(); + if !url.starts_with("http://127.0.0.1:") { + return None; + } + let state = api_get(&url, "api/state")?; + let v: serde_json::Value = serde_json::from_str(state.trim()).ok()?; + v.get("version").and_then(|x| x.as_str()).map(|_| url) +} + +fn api_get(url: &str, path: &str) -> Option { + crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "4", &format!("{url}{path}")]), None, Duration::from_secs(6)).filter(|o| !o.trim().is_empty()) +} + +fn api_post(url: &str, path: &str) -> bool { + crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "6", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}", &format!("{url}{path}")]), None, Duration::from_secs(8)).is_some() +} + +/// The host's stdin line as the engine's API path; None for a line the bridge does not relay. +pub fn command_path(line: &str) -> Option<&'static str> { + match line.trim() { + "quit" => Some("api/quit"), + "pause" => Some("api/pause"), + "resume" => Some("api/resume"), + "detect" => Some("api/detect"), + _ => None, + } +} + +/// The STATE line the host reads (engine.rs wrapper_state), built from the engine's api/state reply. +pub fn state_line(api_state: &serde_json::Value) -> String { + let g = |p: &[&str]| -> serde_json::Value { + let mut v = api_state; + for k in p { + v = match v.get(k) { + Some(x) => x, + None => return serde_json::Value::Null, + }; + } + v.clone() + }; + serde_json::json!({ + "phase": g(&["phase"]), "mining": g(&["mining", "state"]), "paused": g(&["mining", "paused"]), + "hash_total": g(&["mining", "hash_total"]), "accepted_total": g(&["mining", "accepted_total"]), + "node": g(&["node", "state"]), "blocks": g(&["node", "blocks"]), "peers": g(&["node", "peers"]), "quitting": g(&["quitting"]), + "update": g(&["update", "available"]), "bridge": true, + }) + .to_string() +} + +/// What the bridge does after one poll: carry on, or end (with EXIT when the engine is gone; silently when the host +/// closed its end, which leaves the engine mining). +#[derive(Debug, PartialEq, Eq)] +pub enum BridgeStep { + Continue, + EngineGone, + HostGone, +} + +pub fn bridge_step(misses: u32, stdin_closed: bool) -> BridgeStep { + if stdin_closed { + BridgeStep::HostGone + } else if misses >= BRIDGE_MISSES { + BridgeStep::EngineGone + } else { + BridgeStep::Continue + } +} + +/// Runs the bridge until the host or the engine goes. Returns the process exit code. +pub fn run_bridge(url: &str) -> i32 { + use std::io::{BufRead, Write}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + println!("URL {url}"); + let _ = std::io::stdout().flush(); + let closed = Arc::new(AtomicBool::new(false)); + { + let closed = closed.clone(); + let url = url.to_string(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let Ok(l) = line else { break }; + if let Some(p) = command_path(&l) { + let _ = api_post(&url, p); + } + } + closed.store(true, Ordering::Relaxed); + }); + } + let mut misses = 0u32; + loop { + match api_get(url, "api/state").and_then(|s| serde_json::from_str::(s.trim()).ok()) { + Some(v) => { + misses = 0; + println!("STATE {}", state_line(&v)); + let _ = std::io::stdout().flush(); + if v.get("quitting").and_then(|q| q.as_bool()).unwrap_or(false) { + // the engine is leaving (Quit from the tray relayed above, or its own update): the host wants EXIT + std::thread::sleep(Duration::from_secs(2)); + misses = BRIDGE_MISSES; + } + } + None => misses += 1, + } + match bridge_step(misses, closed.load(Ordering::Relaxed)) { + BridgeStep::Continue => std::thread::sleep(Duration::from_secs(BRIDGE_POLL_S)), + BridgeStep::EngineGone => { + println!("EXIT"); + let _ = std::io::stdout().flush(); + return 0; + } + BridgeStep::HostGone => return 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Known-failed first: before 0.3.22 a boot with nobody logged on opened the window host (no desktop, no engine); + /// PC 2 sat 67 minutes idle after its 17:28 BST self-reboot on 7 October 2026. + #[test] + fn before_0322_no_logon_meant_the_host_and_no_engine() { + assert_eq!(legacy_launch_mode(true), LaunchMode::Host, "the host regardless of the session"); + assert_eq!(launch_mode(None, true), LaunchMode::Headless, "0.3.22: no session, the engine runs headless"); + assert_eq!(launch_mode(Some(""), true), LaunchMode::Headless); + } + + /// Known-failed first: before 0.3.22 every `--wrapper` engine quit on a closed stdin, whoever had started it. + #[test] + fn a_headless_engine_never_reads_a_closed_stdin_as_the_host_leaving() { + assert!(stdin_close_quits(true, false), "the window host's own engine: the host left, the engine follows (by design)"); + assert!(!stdin_close_quits(true, true), "0.3.22: headless, even with --wrapper on the line, stays up"); + assert!(!stdin_close_quits(false, false), "an engine with no host never had a stdin to lose"); + assert!(!stdin_close_quits(false, true)); + } + + #[test] + fn a_logon_session_keeps_the_window_host_or_the_browser() { + assert_eq!(launch_mode(Some("Console"), true), LaunchMode::Host); + assert_eq!(launch_mode(Some("RDP-Tcp#3"), true), LaunchMode::Host); + assert_eq!(launch_mode(Some("Console"), false), LaunchMode::Browser); + assert!(interactive_session(Some("Console")) && !interactive_session(None)); + } + + #[test] + fn the_boot_task_runs_at_startup_as_the_user_without_a_logon_and_names_the_data_root() { + let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\u\\AppData\\Local\\igneum")); + assert!(s.contains("-Execute 'C:\\p\\Igneum Miner\\igneum-app.exe' -Argument '--launch --data-root \"C:\\u\\AppData\\Local\\igneum\"'"), "{s}"); + assert!(s.contains("New-ScheduledTaskTrigger -AtStartup"), "at system start, not at logon"); + assert!(s.contains("-LogonType S4U -RunLevel Limited"), "the user's own token with nobody logged on, never elevated"); + assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") && s.contains("-MultipleInstances IgnoreNew") && s.contains("-Hidden")); + assert!(s.contains(&format!("-TaskName '{TASK_NAME}'"))); + assert!(!s.contains("powershell.exe") && !s.contains("cmd /c"), "the action is the exe itself: no console window at boot"); + let q = query_command(); + assert!(q.contains("Test-Path") && q.contains("-ne 'Disabled'") && q.contains("exit 1"), "{q}"); + assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Miner (boot)'")); + } + + #[test] + fn the_bridge_relays_the_hosts_commands_and_nothing_else() { + assert_eq!(command_path("quit"), Some("api/quit")); + assert_eq!(command_path(" pause \r"), Some("api/pause")); + assert_eq!(command_path("resume"), Some("api/resume")); + assert_eq!(command_path("detect"), Some("api/detect")); + assert_eq!(command_path("elevated ok"), None, "the elevated answers belong to a real engine's host"); + assert_eq!(command_path("rm -rf"), None); + } + + #[test] + fn the_bridge_state_line_is_the_wrapper_state() { + let st: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22","phase":"dashboard","mining":{"state":"mining","paused":false,"hash_total":121.0,"accepted_total":95511},"node":{"state":"synced","blocks":165529,"peers":4},"quitting":false,"update":{"available":false}}"#).unwrap(); + let line: serde_json::Value = serde_json::from_str(&state_line(&st)).unwrap(); + assert_eq!(line["phase"], "dashboard"); + assert_eq!(line["mining"], "mining"); + assert_eq!(line["hash_total"], 121.0); + assert_eq!(line["accepted_total"], 95511); + assert_eq!(line["node"], "synced"); + assert_eq!(line["blocks"], 165529); + assert_eq!(line["quitting"], false); + assert_eq!(line["bridge"], true); + let partial: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22"}"#).unwrap(); + assert!(state_line(&partial).contains("\"phase\":null"), "a missing field is null, never a panic"); + } + + #[test] + fn the_bridge_ends_with_exit_when_the_engine_is_gone_and_silently_when_the_host_is() { + assert_eq!(bridge_step(0, false), BridgeStep::Continue); + assert_eq!(bridge_step(BRIDGE_MISSES - 1, false), BridgeStep::Continue); + assert_eq!(bridge_step(BRIDGE_MISSES, false), BridgeStep::EngineGone, "the host then starts a fresh --wrapper, which becomes a full engine"); + assert_eq!(bridge_step(0, true), BridgeStep::HostGone, "the window closed: the headless engine keeps mining"); + assert_eq!(bridge_step(BRIDGE_MISSES, true), BridgeStep::HostGone); + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 714b27d41..123a438c4 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2587,7 +2587,7 @@ impl Engine { let _ = std::fs::create_dir_all(&dir); let script = dir.join("register-power-task.ps1"); let installed: Vec = crate::powertask::install_candidates(); - match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) { + match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), [crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)), crate::boot::register_script(&crate::powertask::task_exe(&exe, &installed), &crate::platform::fixed_data_root()).replace("exit 0\r\n", "")].concat().as_bytes()].concat())) { Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()), _ => cmds.join(" & "), } diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 122374882..4b7d7dcaa 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -50,6 +50,7 @@ mod card; mod drivertable; mod drivers; mod bootcheck; +mod boot; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; @@ -57,7 +58,7 @@ use std::sync::Arc; fn main() { let args: Vec = std::env::args().skip(1).collect(); - let wrapper = args.iter().any(|a| a == "--wrapper"); + let wrapper = args.iter().any(|a| a == "--wrapper") && !args.iter().any(|a| a == "--boot"); let sweep = args.iter().any(|a| a == "--sweep"); if sweep { // the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep @@ -74,22 +75,45 @@ fn main() { let dir = powertask::helper_dir(); std::process::exit(powertask::run_helper(&dir)); } + // 0.3.22: `--data-root ` pins the data root (the boot task spells it out: an S4U session may not load the + // profile); `--boot` is the headless engine (no window host, no browser); `--launch` with no interactive session is + // `--boot` (src/boot.rs launch_mode: PC 2 waited 67 minutes for a logon on 7 October 2026) + if let Some(i) = args.iter().position(|a| a == "--data-root") { + if let Some(p) = args.get(i + 1) { + std::env::set_var("IGNEUM_APP_DATA", p); + } + } + let boot = args.iter().any(|a| a == "--boot"); + let mut no_open = no_open || boot; if args.iter().any(|a| a == "--launch") { - if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) { - let host = dir.join("Igneum Miner.exe"); - if host.exists() { - let mut c = std::process::Command::new(&host); - c.current_dir(&dir); - crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console) - if c.spawn().is_ok() { - return; + let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())); + let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe")); + let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false); + match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) { + boot::LaunchMode::Host => { + if let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) { + let mut c = std::process::Command::new(host); + c.current_dir(dir); + crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console) + if c.spawn().is_ok() { + return; + } } } + boot::LaunchMode::Headless => no_open = true, + boot::LaunchMode::Browser => {} } - // no window host: the engine runs on its own and the dashboard opens in the default browser + // no window host (or no logon): the engine runs on its own; the dashboard opens in the default browser only in a session } platform::clear_quarantine(); let runtime = config::Runtime::from_env(); + // 0.3.22: a window host's engine that finds the boot engine already running under this data root becomes the + // bridge to it (src/boot.rs) instead of a second engine on the same ports and folder + if wrapper && !sweep { + if let Some(url) = boot::running_engine(&runtime.app_dir) { + std::process::exit(boot::run_bridge(&url)); + } + } let _ = std::fs::create_dir_all(&runtime.app_dir); let _ = std::fs::create_dir_all(&runtime.log_dir); platform::lock_permissions(&runtime.app_dir, true); @@ -176,7 +200,8 @@ fn main() { _ => {} } } - if wrapper { + // 0.3.22: only an engine a host attached quits with the host (src/boot.rs stdin_close_quits) + if boot::stdin_close_quits(wrapper, boot) { shared.send(engine::Cmd::Quit("the window host went away (stdin closed)")); } }); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index d597c185f..c0a1f8ba4 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -199,6 +199,7 @@ impl Updater { // installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here) if !shared.runtime.sweep_only { firewall_first_run(shared); + boot_task_first_run(shared); } } u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::>(&t).ok()).unwrap_or_default(); @@ -1057,6 +1058,24 @@ fn under_program_files(dir: &Path) -> bool { /// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on /// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot /// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself. +/// Windows: the boot task (src/boot.rs) registered at every engine start when it is missing, in a thread, as the +/// user's own task (no prompt). An account Windows refuses gets it in the one approved step with the Power Helper. +#[cfg(windows)] +fn boot_task_first_run(shared: &Arc) { + let Some(exe) = std::env::current_exe().ok() else { return }; + let exe = crate::boot::task_exe(&exe); + if !exe.is_file() { + return; + } + let root = crate::platform::fixed_data_root(); + let shared = shared.clone(); + std::thread::spawn(move || match crate::boot::ensure_registered(&exe, &root) { + Ok(true) => shared.log(&format!("boot start: the task '{}' is registered: the engine starts at boot without a logon ({} --launch --data-root {})", crate::boot::TASK_NAME, exe.display(), root.display())), + Ok(false) => {} + Err(e) => shared.log(&format!("boot start: not registered ({e}); the app starts at logon only until Power control's one approved step registers it")), + }); +} + #[cfg(windows)] fn firewall_first_run(shared: &Arc) { let flag = shared.runtime.app_dir.join("firewall-rule.json"); @@ -1261,6 +1280,19 @@ mod return_tests { } } + /// 0.3.22: an update applied with nobody logged on (the boot engine) returns headless: the helper's Launch runs + /// `igneum-app.exe --launch`, which with no interactive session runs the engine itself, so the same sequence returns + /// the app without a logon (the known-failed form first: before 0.3.22 that launch opened the window host, which has + /// no desktop in session 0, and nothing mined until a logon) + #[test] + fn after_an_update_with_no_logon_the_relaunch_is_headless() { + assert_eq!(crate::boot::legacy_launch_mode(true), crate::boot::LaunchMode::Host); + assert_eq!(crate::boot::launch_mode(None, true), crate::boot::LaunchMode::Headless); + let steps = return_sequence(0, "0.3.22", true, |_| Some("0.3.22".into())); + assert_eq!(steps[0], ReturnStep::Launch, "the helper's launch is the same line; the session decides what it runs"); + assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. })); + } + /// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches. #[test] fn the_windows_helper_carries_every_step() { diff --git a/relay/playbooks/install-repair.ps1 b/relay/playbooks/install-repair.ps1 new file mode 100644 index 000000000..9b9520c1c --- /dev/null +++ b/relay/playbooks/install-repair.ps1 @@ -0,0 +1,53 @@ +# PC 2, 7 October 2026, 18:53:34 BST: a job cancel ended the process tree one second after the 0.3.21 installer had +# started over the running 0.3.20 app; nothing of the app is up and the install may be half-written. Through the relay +# agent (the one live channel): reads the install state (the installed exe's version, the kept installer and log under +# artefacts\), then starts Igneum Miner if the install is whole, else re-runs the kept installer silently (per-user, no +# prompt; /IGNOTA=2 so the installer itself opens nothing) and starts it; reads back the version an engine answers. +# Sends no quit, pause or resume to the app: a repair install's own stop step is the installer's. +$ErrorActionPreference = 'Continue' +function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) } +$install = Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner' +$appDir = Join-Path $env:LOCALAPPDATA 'igneum\app' +$art = Join-Path $env:LOCALAPPDATA 'igneum\artefacts' +$kept = Join-Path $art 'Igneum-Miner-Setup-0.3.21-take4.exe' +$klog = Join-Path $art 'install-0321-take4.log' +function App-Version { $f = Join-Path $appDir 'app.url'; if (-not (Test-Path $f)) { return '' }; try { $u = (Get-Content $f -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' } } +function Exe-Version([string]$p) { if (-not (Test-Path $p)) { return 'missing' }; try { $o = & $p --version 2>&1 | ForEach-Object { "$_" } | Select-Object -First 1; return "$o" } catch { return 'no answer' } } +Say ('RESULT state-before api ' + $(if ($v = App-Version) { $v } else { 'nothing answers' })) +foreach ($n in @('igneum-app.exe', 'Igneum Miner.exe', 'igneumd.exe', 'igneum-miner.exe', 'igneum-worker-cuda.exe', 'unins000.exe', 'igneum-app.json')) { $p = Join-Path $install $n; Say ('RESULT file ' + $n + ' ' + $(if (Test-Path $p) { (Get-Item $p).Length.ToString() + ' B ' + (Get-Item $p).LastWriteTime.ToUniversalTime().ToString('o') } else { 'MISSING' })) } +Say ('RESULT exe-version igneum-app.exe: ' + (Exe-Version (Join-Path $install 'igneum-app.exe'))) +Say ('RESULT exe-version Igneum Miner.exe: ' + (Exe-Version (Join-Path $install 'Igneum Miner.exe'))) +Say ('RESULT exe-version igneumd.exe: ' + (Exe-Version (Join-Path $install 'igneumd.exe'))) +foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd', 'igneum-miner', 'igneum-worker-cuda' -ErrorAction SilentlyContinue)) { Say ('RESULT process ' + $p.ProcessName + ' pid ' + $p.Id + ' since ' + $p.StartTime.ToString('o')) } +Say ('RESULT kept installer ' + $(if (Test-Path $kept) { (Get-Item $kept).Length.ToString() + ' B ' + (Get-FileHash $kept -Algorithm SHA256).Hash.ToLower().Substring(0, 12) } else { 'MISSING' })) +if (Test-Path $klog) { Say 'RESULT kept install log (last 12):'; Get-Content $klog -Tail 12 -ErrorAction SilentlyContinue | ForEach-Object { Say (' ' + $_) } } else { Say 'RESULT kept install log: missing' } +foreach ($n in @('update-pending.json', 'update-result.json', 'failed-versions.json')) { $p = Join-Path $appDir $n; if (Test-Path $p) { Say ('RESULT ' + $n + ': ' + ((Get-Content $p -Raw) -replace '\s+', ' ').Substring(0, [Math]::Min(300, ((Get-Content $p -Raw) -replace '\s+', ' ').Length))) } } +$ota = Join-Path $appDir 'ota-apply.log'; if (Test-Path $ota) { Say 'RESULT ota-apply.log (last 6):'; Get-Content $ota -Tail 6 | ForEach-Object { Say (' ' + $_) } } +# whole = the five exes present and igneum-app.exe answers --version with 0.3.21 +$ver = Exe-Version (Join-Path $install 'igneum-app.exe') +$whole = ($ver -match '^igneum-app 0\.3\.21') -and (Test-Path (Join-Path $install 'Igneum Miner.exe')) -and (Test-Path (Join-Path $install 'igneumd.exe')) -and (Test-Path (Join-Path $install 'igneum-miner.exe')) -and (Test-Path (Join-Path $install 'igneum-app.json')) +Say ('RESULT install-whole ' + $whole + ' (' + $ver + ')') +if (App-Version) { Say 'an engine answers already; nothing started'; exit 0 } +if (-not $whole) { + if (-not (Test-Path $kept)) { Say 'RESULT repair: the kept installer is missing and the install is not whole; a hand install is needed'; exit 4 } + $log2 = Join-Path $art 'install-0321-repair.log' + Say ('repairing: ' + $kept + ' /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /IGNOTA=2 /LOG=' + $log2) + # console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing + $p = Start-Process -FilePath $kept -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=2', ('/LOG="' + $log2 + '"')) -Wait -PassThru + Say ('RESULT repair-installer-exit ' + $p.ExitCode) + if (Test-Path $log2) { Get-Content $log2 -Tail 5 | ForEach-Object { Say (' ' + $_) } } + Say ('RESULT exe-version after repair: ' + (Exe-Version (Join-Path $install 'igneum-app.exe'))) +} +$exe = Join-Path $install 'igneum-app.exe' +if (-not (Test-Path $exe)) { Say 'RESULT start: igneum-app.exe missing after the repair'; exit 5 } +$t0 = Get-Date +# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own +Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $install | Out-Null +$deadline = (Get-Date).AddSeconds(120); $v = '' +while ((Get-Date) -lt $deadline) { $v = App-Version; if ($v) { break }; Start-Sleep -Seconds 3 } +if ($v) { Say ('RESULT started: app ' + $v + ' answered api/state after ' + [int]((Get-Date) - $t0).TotalSeconds + ' s'); exit 0 } +Say 'RESULT started: no engine answered api/state inside 120 s' +foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue)) { Say (' process ' + $p.ProcessName + ' pid ' + $p.Id) } +$alog = Get-ChildItem (Join-Path $env:LOCALAPPDATA 'igneum\logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +if ($alog) { Say (' newest app log ' + $alog.Name); Get-Content $alog.FullName -Tail 15 | ForEach-Object { Say (' ' + $_) } } +exit 6