diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 7754f42ca..f968ce40e 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -12,6 +12,9 @@ use std::path::{Path, PathBuf}; pub struct CardPref { pub enabled: bool, pub identities: u32, + /// NVIDIA power cap, percent of the card's default limit (60 to 100); 0 = the default 80 + #[serde(default)] + pub power_pct: u32, } #[derive(Clone, Serialize, Deserialize)] @@ -42,6 +45,9 @@ pub struct Settings { /// Lifetime accepted blocks, carried across runs. #[serde(default)] pub accepted_total: u64, + /// Over-the-air updates install by themselves at a safe moment (default on); off = download, then wait for Install now. + #[serde(default = "yes")] + pub auto_update: bool, } fn one() -> u32 { @@ -53,7 +59,7 @@ fn yes() -> bool { impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0 } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true } } } diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 3932f8bc0..566580c6e 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -94,6 +94,24 @@ pub fn apply_defaults(c: &mut CardState) { c.identities = if c.vram_mb >= 8192 { 8 } else { 2 }; } } + if c.vendor == "nvidia" && c.power_pct == 0 { + c.power_pct = 80; // the default cap: 80% of the card's default limit (PC 2's 5090 hard-crashed at full power) + } +} + +/// NVIDIA power limits per card index: (default, current, min, max) in watts. +#[cfg(not(target_os = "macos"))] +pub fn nvidia_power_limits() -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Some(text) = run_timeout(Command::new("nvidia-smi").args(["--query-gpu=index,power.default_limit,power.limit,power.min_limit,power.max_limit", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)) else { return out }; + for line in text.lines() { + let p: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if p.len() >= 5 { + let f = |s: &str| s.parse::().unwrap_or(0.0); + out.insert(p[0].to_string(), (f(p[1]), f(p[2]), f(p[3]), f(p[4]))); + } + } + out } #[cfg(target_os = "macos")] @@ -171,6 +189,16 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { if cards.is_empty() { notes.push("nvidia-smi ran but listed no card".into()); } + let limits = nvidia_power_limits(); + for c in cards.iter_mut() { + if let Some((d, cur, lo, hi)) = limits.get(&c.device) { + c.power_default_w = *d; + c.power_limit_w = *cur; + c.power_before_w = *cur; + c.power_min_w = *lo; + c.power_max_w = *hi; + } + } } None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 34faa9405..9b225bd0f 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -23,6 +23,7 @@ pub struct CardChoice { pub key: String, pub enabled: bool, pub identities: u32, + pub power_pct: Option, } pub enum Cmd { @@ -34,7 +35,11 @@ pub enum Cmd { Resume, RestartMiners(String), CheckUpdate, - Updated(crate::state::UpdateState), + InstallUpdate, + AutoUpdate(bool), + OpenUpdateFile, + /// the over-the-air updater's threads report here (src/ota.rs) + Ota(crate::ota::Event), WorkerBuilt(usize, Result), /// local minus the latest block's timestamp, seconds (from the node's EVM RPC) ClockSample(f64), @@ -43,6 +48,8 @@ pub enum Cmd { ClockCheck, ClockSync, ClockSynced(Result), + /// the elevated nvidia-smi -pl step finished: (what was asked, result) + PowerApplied(String, Result<(), String>), Quit, } @@ -79,7 +86,7 @@ impl Shared { st.mining.paused = settings.paused; st.mining.accepted_total = settings.accepted_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on() }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); st.clock.hint = crate::platform::clock_hint().into(); @@ -333,7 +340,7 @@ pub struct Engine { last_status: Instant, last_upload: Instant, upload_thread: Option>, - update_next: Instant, + ota: crate::ota::Updater, stamp: String, label_base: String, wrapper: bool, @@ -346,6 +353,12 @@ pub struct Engine { clock_next_https: Instant, clock_last_sample: Instant, last_accepted: Option, + telemetry: Option, + telemetry_retry_at: Instant, + power_busy: bool, + power_restore_pending: bool, + stability: std::collections::HashMap, + last_stability: Instant, last_settings_save: Instant, last_error_event: Instant, } @@ -363,6 +376,7 @@ impl Engine { }; // labels never carry the hostname: two cloned PCs with the same COMPUTERNAME would sign with the same keys let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8()); + let ota = crate::ota::Updater::new(&shared, wrapper); Engine { shared, bins, @@ -391,7 +405,7 @@ impl Engine { last_status: now, last_upload: now, upload_thread: None, - update_next: now + Duration::from_secs(20), + ota, stamp, label_base, wrapper, @@ -404,6 +418,12 @@ impl Engine { clock_next_https: now + Duration::from_secs(5), clock_last_sample: now, last_accepted: None, + telemetry: None, + telemetry_retry_at: now, + power_busy: false, + power_restore_pending: false, + stability: std::collections::HashMap::new(), + last_stability: now, last_settings_save: now, last_error_event: now - Duration::from_secs(600), } @@ -435,8 +455,18 @@ impl Engine { let v = crate::detect::node_version(&self.bins.node); self.st().node.version = v.clone(); self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display())); + if self.ota.needs_rollback() { + // this version died twice before it was healthy: the helper puts the previous one back + match self.ota.launch_rollback(&self.shared, self.host_pid()) { + Ok(()) => { + self.quitting = true; + self.st().quitting = true; + } + Err(e) => self.shared.event("error", &format!("rollback not possible: {e}")), + } + } self.shared.send(Cmd::Detect); - if setup_done { + if setup_done && !self.quitting { self.shared.send(Cmd::Start); } loop { @@ -487,6 +517,9 @@ impl Engine { c.enabled = p.enabled && c.kind != "unknown"; c.identities = p.identities.clamp(1, 64); c.reason = String::new(); + if c.vendor == "nvidia" && p.power_pct > 0 { + c.power_pct = p.power_pct.clamp(60, 100); + } } } drop(st); @@ -522,25 +555,15 @@ impl Engine { m.restart_at = Some(Instant::now()); } } - Cmd::CheckUpdate => { - let url = self.shared.packaged.update_manifest.clone(); - let shared = self.shared.clone(); - std::thread::spawn(move || { - let u = crate::update::check(&url, VERSION); - shared.send(Cmd::Updated(u)); - }); - self.update_next = Instant::now() + Duration::from_secs(6 * 3600); - } - Cmd::Updated(u) => { - if u.available { - self.shared.event("info", &format!("Igneum Miner {} is available", u.version)); - } else if !u.error.is_empty() { - self.shared.log(&format!("update check: {}", u.error)); - } else { - self.shared.log(&format!("update check: {} is current", VERSION)); + Cmd::CheckUpdate => self.ota.check_now(&self.shared), + Cmd::InstallUpdate => self.ota.install_now(&self.shared), + Cmd::AutoUpdate(on) => self.ota.set_auto(&self.shared, on), + Cmd::OpenUpdateFile => { + if let Err(e) = self.ota.open_file() { + self.shared.event("error", &format!("could not open the update file: {e}")); } - self.st().update = u; } + Cmd::Ota(ev) => self.ota.event(&self.shared, ev), Cmd::WorkerBuilt(card, result) => { if let Some(m) = self.miners.iter_mut().find(|m| m.card == card) { m.building = false; @@ -624,6 +647,27 @@ impl Engine { self.resolve_clock(); self.clock_next_https = Instant::now() + Duration::from_secs(6); } + Cmd::PowerApplied(what, r) => { + self.power_busy = false; + match r { + Ok(()) => { + self.shared.event("ok", &format!("GPU power cap set: {what}")); + let mut st = self.st(); + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled) { + c.power_applied = true; + c.power_note = String::new(); + } + } + Err(e) => { + self.shared.event("error", &format!("GPU power cap not applied ({e}); mining at the card's current limit")); + let mut st = self.st(); + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled) { + c.power_applied = false; + c.power_note = format!("power cap not applied: {e}"); + } + } + } + } Cmd::Quit => { self.quitting = true; self.st().quitting = true; @@ -655,6 +699,7 @@ impl Engine { } let _ = std::fs::create_dir_all(&self.shared.runtime.node_dir); let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir); + self.apply_power_limits("start"); if port_open(self.shared.runtime.rpc_port) { self.node_external = true; self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port)); @@ -933,6 +978,7 @@ impl Engine { /// The user changed which cards mine or how many identities each runs: only the affected workers restart. fn apply_cards(&mut self, choices: Vec) { let mut changed = Vec::new(); + let mut power_changed = false; { let mut settings = self.shared.settings.lock().unwrap(); let mut st = self.st(); @@ -940,7 +986,13 @@ impl Engine { let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue }; let identities = ch.identities.clamp(1, 64); let enabled = ch.enabled && c.kind != "unknown"; - settings.cards.insert(ch.key.clone(), crate::config::CardPref { enabled, identities }); + let power_pct = ch.power_pct.map(|p| p.clamp(60, 100)).unwrap_or(c.power_pct); + if c.vendor == "nvidia" && power_pct != c.power_pct { + c.power_pct = power_pct; + c.power_applied = false; + power_changed = true; + } + settings.cards.insert(ch.key.clone(), crate::config::CardPref { enabled, identities, power_pct: if c.vendor == "nvidia" { power_pct } else { 0 } }); if c.enabled != enabled || c.identities != identities { c.enabled = enabled; c.identities = identities; @@ -975,6 +1027,159 @@ impl Engine { } if self.running { self.plan_miners(); + if power_changed { + self.apply_power_limits("settings"); + } + } + } + + /// NVIDIA cards: cap the power at power_pct of the default limit through nvidia-smi -pl, all cards in one + /// elevated step (one administrator prompt). The limit before the app touched it is restored on quit. + fn apply_power_limits(&mut self, why: &str) { + if self.power_busy { + return; + } + let mut cmds = Vec::new(); + let mut what = Vec::new(); + { + let mut st = self.st(); + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) { + let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(60, 100) }; + c.power_pct = pct; + let mut watts = c.power_default_w * pct as f64 / 100.0; + if c.power_min_w > 0.0 { + watts = watts.max(c.power_min_w); + } + if c.power_max_w > 0.0 { + watts = watts.min(c.power_max_w); + } + let watts = watts.round(); + if (c.power_limit_w - watts).abs() < 1.0 && c.power_applied { + continue; + } + cmds.push(format!("nvidia-smi -i {} -pl {}", c.device, watts as u64)); + what.push(format!("{} {} W ({}% of {} W)", c.name, watts as u64, pct, c.power_default_w as u64)); + c.power_note = "setting the power cap (administrator prompt)".into(); + } + } + if cmds.is_empty() { + return; + } + self.power_busy = true; + self.power_restore_pending = true; + self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & "))); + let line = cmds.join(" & "); + let what = what.join(", "); + let shared = self.shared.clone(); + std::thread::spawn(move || { + let r = crate::platform::run_elevated(&line); + shared.send(Cmd::PowerApplied(what, r)); + }); + } + + /// On quit: the limits the cards had before the app (one more prompt; nvidia-smi limits do not survive a reboot anyway). + fn restore_power_limits(&mut self) { + if !self.power_restore_pending { + return; + } + let st = self.st(); + let cmds: Vec = st + .mining + .cards + .iter() + .filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0) + .map(|c| format!("nvidia-smi -i {} -pl {}", c.device, c.power_before_w.round() as u64)) + .collect(); + drop(st); + if cmds.is_empty() { + return; + } + self.shared.log(&format!("restoring the GPU power limits: {}", cmds.join(" & "))); + match crate::platform::run_elevated(&cmds.join(" & ")) { + Ok(()) => self.shared.log("GPU power limits restored"), + Err(e) => self.shared.log(&format!("GPU power limits not restored ({e}); they reset at the next reboot")), + } + } + + /// nvidia-smi -l 5: power draw, GPU and memory temperature, the limit in force, every 5 s, as a child whose + /// lines come through the same channel as the miners'. + fn tick_telemetry(&mut self, now: Instant) { + if cfg!(target_os = "macos") || self.st().mining.cards.iter().all(|c| c.vendor != "nvidia") { + return; + } + if let Some(t) = self.telemetry.as_mut() { + if !t.alive() { + self.telemetry = None; + self.telemetry_retry_at = now + Duration::from_secs(30); + } + } + if self.telemetry.is_none() && now >= self.telemetry_retry_at { + let args: Vec = ["--query-gpu=index,power.draw,temperature.gpu,temperature.memory,power.limit", "--format=csv,noheader,nounits", "-l", "5"].iter().map(|s| s.to_string()).collect(); + let log = self.shared.runtime.log_dir.join(format!("gpu-{}.log", self.stamp)); + match procs::spawn(Source::Telemetry, std::path::Path::new("nvidia-smi"), &args, None, &log, &self.lines_tx) { + Ok(p) => self.telemetry = Some(p), + Err(_) => self.telemetry_retry_at = now + Duration::from_secs(300), + } + } + if now.duration_since(self.last_stability) >= Duration::from_secs(300) { + self.last_stability = now; + self.stability_line(); + } + } + + /// "index, draw, gpu temp, mem temp, limit" every 5 s. + fn telemetry_line(&mut self, text: &str) { + let p: Vec<&str> = text.split(',').map(|s| s.trim()).collect(); + if p.len() < 5 { + return; + } + let f = |s: &str| s.parse::().unwrap_or(0.0); + let (draw, tgpu, tmem, limit) = (f(p[1]), f(p[2]), f(p[3]), f(p[4])); + let mut st = self.st(); + let Some(c) = st.mining.cards.iter_mut().find(|c| c.vendor == "nvidia" && c.device == p[0]) else { return }; + c.power_w = draw; + c.temp_gpu = tgpu; + c.temp_mem = tmem; + if limit > 0.0 { + c.power_limit_w = limit; + } + c.telemetry_at = crate::platform::unix_now_f(); + let idx = c.index; + let mining = c.state == "mining"; + drop(st); + if mining { + let s = self.stability.entry(idx).or_default(); + s.draws.push(draw); + if s.draws.len() > 20_000 { + s.draws.remove(0); + } + s.max_tgpu = s.max_tgpu.max(tgpu); + s.max_tmem = s.max_tmem.max(tmem); + } + } + + /// One line per card: power draw p95 and the hottest memory and GPU readings this session, for the log drawer, + /// so a crash can be read back against the numbers. + fn stability_line(&mut self) { + let cards = self.st().mining.cards.clone(); + for (idx, s) in self.stability.iter() { + if s.draws.is_empty() { + continue; + } + let Some(c) = cards.iter().find(|c| c.index == *idx) else { continue }; + let mut v = s.draws.clone(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p95 = v[((v.len() as f64 * 0.95) as usize).min(v.len() - 1)]; + self.shared.log(&format!( + "stability: {}: draw p95 {:.0} W, max {:.0} W (cap {:.0} W), max GPU {:.0} C, max memory {:.0} C, {} samples", + c.name, + p95, + v[v.len() - 1], + c.power_limit_w, + s.max_tgpu, + s.max_tmem, + v.len() + )); } } @@ -990,6 +1195,7 @@ impl Engine { self.tick_node(now); self.tick_watch(now); self.tick_miners(now); + self.tick_telemetry(now); } self.derive(now); if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) { @@ -1002,9 +1208,7 @@ impl Engine { self.last_upload = now; self.upload_logs(false); } - if now >= self.update_next { - self.command(Cmd::CheckUpdate); - } + self.tick_update(); if now >= self.clock_next_https { self.command(Cmd::ClockCheck); } @@ -1024,6 +1228,63 @@ impl Engine { } } + /// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what + /// a safe moment needs and applies when it says so. + fn tick_update(&mut self) { + let ctx = { + let st = self.st(); + crate::ota::Ctx { + node_synced: st.node.synced && st.clock.severity != "block", + boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None }, + miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"), + daa: st.node.daa, + } + }; + if let Some(crate::ota::Action::Apply) = self.ota.tick(&self.shared, &ctx) { + self.apply_update(); + } + } + + /// Hands over to the update helper, then leaves through the quit path (miners first, then the node, the last + /// log upload, EXIT for the window). The helper waits for this process to end before it swaps the app. + fn apply_update(&mut self) { + if self.quitting { + return; + } + let v = self.ota.version(); + match self.ota.launch_apply(&self.shared, self.host_pid()) { + Ok(()) => { + { + let mut st = self.st(); + st.update.applying = true; + st.update.status = "applying".into(); + st.update.wait = String::new(); + st.quitting = true; + } + self.shared.event("info", &format!("installing Igneum Miner {v}: the miners stop, then the node, then the app opens again")); + self.quitting = true; + } + Err(e) => { + self.shared.event("error", &format!("the update could not start: {e}")); + let mut st = self.st(); + st.update.error = e; + st.update.status = "error".into(); + } + } + } + + /// The window host's pid when the engine runs under one (macOS: the helper asks it to quit). + fn host_pid(&self) -> u32 { + #[cfg(unix)] + { + if self.wrapper { unsafe { libc::getppid() as u32 } } else { 0 } + } + #[cfg(not(unix))] + { + 0 + } + } + fn tick_node(&mut self, now: Instant) { if self.node_external { if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) { @@ -1244,6 +1505,11 @@ impl Engine { } Source::Miner(card) => self.miner_line(card, l.stderr, &l.text), Source::Node => self.node_line(&l.text), + Source::Telemetry => { + if !l.stderr { + self.telemetry_line(&l.text); + } + } } } @@ -1612,6 +1878,11 @@ impl Engine { fn shutdown(&mut self) { self.shared.log("quit: stopping the miners, then the node"); self.stop_miners("quit"); + self.stability_line(); + if let Some(mut t) = self.telemetry.take() { + t.stop(2); + } + self.restore_power_limits(); if let Some(mut w) = self.watch.take() { w.stop(2); } @@ -1642,6 +1913,14 @@ impl Engine { } } +/// Per-card session statistics for the stability line. +#[derive(Default)] +struct Stability { + draws: Vec, + max_tgpu: f64, + max_tmem: f64, +} + /// One `igneum-miner watch` reading. pub struct Reading { pub blocks: u64, diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 179adb7b4..64b6a14cb 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -1 +1,903 @@ -//! placeholder +//! Over-the-air updates of the app (and the node, miner and workers inside it). the project lead's rule: every app updates +//! itself and downloads the update without being asked. This is also how a consensus upgrade (a height-activated +//! rule such as difficulty v2) reaches every node before its activation height. +//! +//! The loop, driven from the engine's tick: +//! check (on start, then hourly with jitter): fetch igneum-app-latest.json and its .sig, verify the Ed25519 +//! signature with the key compiled into src/manifest.rs, parse, compare versions +//! -> download (curl with resume into /app/updates/, then size and sha256 against the manifest) +//! -> stage (macOS: mount the DMG or unpack the zip, copy the new bundle next to the running one, check its +//! engine answers --version with the manifest's version; Windows: the installer is the staged artefact) +//! -> ready: with auto_update (settings, default on) the engine applies at the next safe moment (node synced, +//! no hourly boundary within 3 minutes, no worker starting), at once when a consensus activation is within +//! 1,800 blocks or the version is below min_supported_version, or when the user clicks Install now +//! -> apply: the engine stops the miners, then the node, writes update-pending.json, starts a detached helper +//! (ota-apply.sh / ota-apply.ps1 in the app data folder) and exits. macOS: the helper waits for the window to +//! quit, moves the old bundle to "Igneum Miner.app.previous", the new one in, and opens it. Windows: the helper +//! runs the Inno installer /VERYSILENT (an administrator prompt appears; the installer stops what is left, +//! replaces the files and relaunches the app with /IGNOTA=1). +//! -> rollback: the helper restores the previous bundle when the new app does not start twice; the new engine +//! counts its own starts in update-pending.json and, on the third start without 90 healthy seconds, restores +//! the previous version (macOS: the .previous bundle; Windows: the previous installer kept in updates/). +//! +//! Environment (tests): IGNEUM_APP_UPDATE_MANIFEST overrides the manifest URL from igneum-app.json, +//! IGNEUM_APP_UPDATE_CHECK_SECS the hourly interval, IGNEUM_APP_UPDATE_FIRST_SECS the delay of the first check. + +use crate::engine::{Cmd, Shared}; +use crate::manifest::{self, Manifest, Moment, PlatformEntry}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const HEALTHY_AFTER_S: u64 = 90; +const CHECK_EVERY_S: u64 = 3600; +const RETRY_AFTER_ERROR_S: u64 = 600; + +pub enum Event { + /// The manifest fetched, verified and parsed (or why not). + Checked(Result), + /// The installer or disk image on disk, size and sha256 checked. + Downloaded(Result), + /// macOS: the new bundle staged next to the running one. Windows: the installer path again. + Staged(Result), +} + +/// What the engine must do now. +pub enum Action { + Apply, +} + +pub struct Ctx { + pub node_synced: bool, + pub boundary_eta_s: Option, + pub miner_busy: bool, + pub daa: u64, +} + +/// What the old engine leaves for the new one (update-pending.json) and the helper's answer (update-result.json). +#[derive(Clone, Default)] +struct Pending { + from: String, + to: String, + at: f64, + starts: u32, + previous_installer: String, +} + +pub struct Updater { + manifest_url: String, + current: String, + app_dir: PathBuf, + dir: PathBuf, + auto: bool, + wrapper: bool, + manifest: Option, + entry: Option, + file: Option, + staged: Option, + busy: bool, + next_check: Instant, + ready_since: Option, + last_safe_check: Instant, + install_asked: bool, + pending: Option, + started: Instant, + healthy_marked: bool, + jitter: u64, +} + +impl Updater { + pub fn new(shared: &Arc, wrapper: bool) -> Updater { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let manifest_url = env("IGNEUM_APP_UPDATE_MANIFEST").unwrap_or_else(|| shared.packaged.update_manifest.clone()); + let app_dir = shared.runtime.app_dir.clone(); + let dir = app_dir.join("updates"); + let _ = std::fs::create_dir_all(&dir); + let jitter = shared.runtime.machine_id.bytes().fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b as u64)); + let first = env("IGNEUM_APP_UPDATE_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(20 + jitter % 30); + let auto = shared.settings.lock().unwrap().auto_update; + let now = Instant::now(); + let mut u = Updater { + manifest_url, + current: crate::engine::VERSION.to_string(), + app_dir, + dir, + auto, + wrapper, + manifest: None, + entry: None, + file: None, + staged: None, + busy: false, + next_check: now + Duration::from_secs(first), + ready_since: None, + last_safe_check: now, + install_asked: false, + pending: None, + started: now, + healthy_marked: false, + jitter, + }; + u.settle_previous(shared); + u.publish(shared); + u + } + + // ---- the files the old engine, the helper and the new engine pass around ------------------------------------- + + fn pending_path(&self) -> PathBuf { + self.app_dir.join("update-pending.json") + } + fn result_path(&self) -> PathBuf { + self.app_dir.join("update-result.json") + } + + fn read_pending(&self) -> Option { + let v: Value = serde_json::from_str(&std::fs::read_to_string(self.pending_path()).ok()?).ok()?; + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + Some(Pending { from: s("from"), to: s("to"), at: v.get("at").and_then(|x| x.as_f64()).unwrap_or(0.0), starts: v.get("starts").and_then(|x| x.as_u64()).unwrap_or(0) as u32, previous_installer: s("previous_installer") }) + } + + fn write_pending(&self, p: &Pending) { + let v = json!({ "from": p.from, "to": p.to, "at": p.at, "starts": p.starts, "previous_installer": p.previous_installer, "platform": manifest::platform_name() }); + let _ = std::fs::write(self.pending_path(), v.to_string()); + } + + /// On start: did we just update (or fail to)? Reports it, counts this start, and asks for a rollback when the + /// new version keeps dying before it is healthy. + fn settle_previous(&mut self, shared: &Arc) { + let pending = self.read_pending(); + if let Ok(text) = std::fs::read_to_string(self.result_path()) { + let _ = std::fs::remove_file(self.result_path()); + if let Ok(v) = serde_json::from_str::(&text) { + let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false); + let err = v.get("error").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let rolled_back = v.get("rolled_back").and_then(|x| x.as_bool()).unwrap_or(false); + let ver = v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(); + if !ok { + let mut st = shared.state.lock().unwrap(); + st.update.error = err.clone(); + st.update.status = "error".into(); + if rolled_back { + st.update.rolled_back = format!("{ver}: {err}"); + } + drop(st); + shared.event("error", &format!("update to {ver} failed: {err}")); + let _ = std::fs::remove_file(self.pending_path()); + return; + } + } + } + let Some(mut p) = pending else { return }; + if p.to == self.current { + // we are the new version + p.starts += 1; + self.write_pending(&p); + if p.starts == 1 { + shared.event("ok", &format!("updated to Igneum Miner {} from {}", p.to, p.from)); + shared.state.lock().unwrap().update.updated_from = p.from.clone(); + } else { + shared.log(&format!("start {} of {} since the update from {}; healthy after {HEALTHY_AFTER_S} s", p.starts, p.to, p.from)); + } + self.pending = Some(p); + } else if p.from == self.current { + // the old version runs again: the helper restored it, or the installer never ran + shared.event("error", &format!("the update to {} did not take; still on {}", p.to, p.from)); + let _ = std::fs::remove_file(self.pending_path()); + } else { + let _ = std::fs::remove_file(self.pending_path()); + } + } + + /// True when this (new) version has died twice before reaching HEALTHY_AFTER_S: the engine rolls back and exits. + pub fn needs_rollback(&self) -> bool { + self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false) + } + + // ---- state for the dashboard ------------------------------------------------------------------------------------- + + fn publish(&self, shared: &Arc) { + let mut st = shared.state.lock().unwrap(); + let u = &mut st.update; + u.auto = self.auto; + if let Some(m) = &self.manifest { + u.version = m.version.clone(); + u.notes = m.notes.clone(); + u.channel = m.channel.clone(); + u.published_at = m.published_at.clone(); + u.activation_height = m.activation_height.unwrap_or(0); + u.unsupported = manifest::unsupported(m, &self.current); + u.min_supported = m.min_supported_version.clone(); + } + if let Some(e) = &self.entry { + u.url = e.url.clone(); + u.size = e.size; + } + u.available = self.entry.is_some(); + u.downloaded = self.file.is_some(); + u.ready = self.staged.is_some(); + u.file = self.file.as_ref().map(|p| p.display().to_string()).unwrap_or_default(); + if u.status != "applying" && u.status != "manual" && u.status != "error" { + u.status = if self.staged.is_some() { + "ready".into() + } else if self.file.is_some() { + "staging".into() + } else if self.entry.is_some() { + if self.busy { "downloading".into() } else { "available".into() } + } else if self.manifest.is_some() { + "current".into() + } else if u.status.is_empty() { + "unknown".into() + } else { + u.status.clone() + }; + } + } + + fn set_error(&mut self, shared: &Arc, e: &str) { + shared.log(&format!("update: {e}")); + let mut st = shared.state.lock().unwrap(); + st.update.error = e.to_string(); + st.update.status = "error".into(); + st.update.applying = false; + st.update.wait = String::new(); + } + + fn clear_error(&self, shared: &Arc) { + let mut st = shared.state.lock().unwrap(); + st.update.error = String::new(); + if st.update.status == "error" { + st.update.status = "unknown".into(); + } + } + + pub fn set_auto(&mut self, shared: &Arc, on: bool) { + self.auto = on; + shared.settings.lock().unwrap().auto_update = on; + shared.settings.lock().unwrap().save(&shared.settings_path); + shared.state.lock().unwrap().settings.auto_update = on; + shared.event("info", if on { "updates install by themselves at the next safe moment" } else { "updates download but wait for Install now" }); + self.publish(shared); + } + + // ---- the tick ---------------------------------------------------------------------------------------------------- + + pub fn tick(&mut self, shared: &Arc, ctx: &Ctx) -> Option { + let now = Instant::now(); + // the new version is healthy once it has run this long: the update is complete, the leftovers can go + if !self.healthy_marked && self.pending.is_some() && now.duration_since(self.started) >= Duration::from_secs(HEALTHY_AFTER_S) { + self.healthy_marked = true; + let p = self.pending.take().unwrap(); + let _ = std::fs::remove_file(self.pending_path()); + shared.log(&format!("update to {} complete (from {}); keeping the previous version for a rollback", p.to, p.from)); + self.tidy(&p); + } + if now >= self.next_check && !self.busy && self.staged.is_none() { + self.start_check(shared); + } + if self.busy { + // download progress from the .part file + if let Some(e) = &self.entry { + if self.file.is_none() { + let part = self.dir.join(format!("{}.part", file_name(&e.url))); + let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + let mut st = shared.state.lock().unwrap(); + if st.update.status == "downloading" && e.size > 0 { + st.update.progress = (have as f64 / e.size as f64).min(1.0); + } + } + } + return None; + } + let urgent = self.urgent(ctx); + { + let mut st = shared.state.lock().unwrap(); + if st.update.urgent != urgent { + st.update.urgent = urgent; + } + st.update.urgent_text = if !urgent { + String::new() + } else if let Some(m) = &self.manifest { + if manifest::unsupported(m, &self.current) { + format!("Igneum Miner {} is no longer supported; the network needs {} or newer.", self.current, m.min_supported_version) + } else { + let h = m.activation_height.unwrap_or(0); + format!("Consensus upgrade at height {h}{}: the node is {} blocks away. Installing {} now.", if m.deadline_note.is_empty() { String::new() } else { format!(" ({})", m.deadline_note) }, h.saturating_sub(ctx.daa), m.version) + } + } else { + String::new() + }; + } + if self.staged.is_none() { + return None; + } + if now.duration_since(self.last_safe_check) < Duration::from_secs(3) { + return None; + } + self.last_safe_check = now; + let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0); + let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked }; + if !self.auto && !urgent && !self.install_asked { + shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into(); + return None; + } + match manifest::safe_to_apply(&moment) { + Ok(()) => Some(Action::Apply), + Err(why) => { + shared.state.lock().unwrap().update.wait = why; + None + } + } + } + + fn urgent(&self, ctx: &Ctx) -> bool { + let Some(m) = &self.manifest else { return false }; + if self.entry.is_none() { + return false; + } + manifest::unsupported(m, &self.current) || manifest::fork_is_close(m.activation_height, ctx.daa) + } + + /// After a completed update: the downloads of older versions go; the installer of the version now running stays + /// on Windows (the rollback target of the next update); a failed bundle from an earlier rollback goes on macOS. + fn tidy(&self, _p: &Pending) { + if let Ok(rd) = std::fs::read_dir(&self.dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + let keep = cfg!(windows) && name.contains(&self.current) && name.ends_with(".exe"); + if !keep && name != "manifest.json" && name != "manifest.json.sig" { + let _ = std::fs::remove_file(e.path()); + } + } + } + if let Some(b) = crate::platform::bundle_path() { + let failed = PathBuf::from(format!("{}.failed", b.display())); + if failed.exists() { + let _ = std::fs::remove_dir_all(&failed); + } + } + } + + // ---- check ------------------------------------------------------------------------------------------------------- + + pub fn check_now(&mut self, shared: &Arc) { + if self.busy { + return; + } + self.clear_error(shared); + self.start_check(shared); + } + + fn start_check(&mut self, shared: &Arc) { + let every = std::env::var("IGNEUM_APP_UPDATE_CHECK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(CHECK_EVERY_S); + self.next_check = Instant::now() + Duration::from_secs(every + self.jitter % (every / 6 + 1)); + if self.manifest_url.is_empty() { + let mut st = shared.state.lock().unwrap(); + st.update.error = "no update manifest configured in this build".into(); + st.update.status = "error".into(); + st.update.checked_at = crate::platform::unix_now_f(); + return; + } + self.busy = true; + shared.state.lock().unwrap().update.status = "checking".into(); + let url = self.manifest_url.clone(); + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = fetch_manifest(&url, &dir); + shared2.send(Cmd::Ota(Event::Checked(r))); + }); + } + + pub fn event(&mut self, shared: &Arc, ev: Event) { + self.busy = false; + match ev { + Event::Checked(r) => { + shared.state.lock().unwrap().update.checked_at = crate::platform::unix_now_f(); + match r { + Err(e) => { + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + if self.manifest.is_none() { + self.set_error(shared, &e); + } else { + shared.log(&format!("update check: {e}; keeping the last manifest")); + } + } + Ok(m) => { + self.clear_error(shared); + let entry = if manifest::newer(&m.version, &self.current) { m.this_platform().cloned() } else { None }; + let changed = self.entry != entry; + if entry.is_none() { + if manifest::newer(&m.version, &self.current) { + shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name())); + } else { + shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version)); + } + self.entry = None; + self.file = None; + self.staged = None; + self.ready_since = None; + } + self.manifest = Some(m.clone()); + if let Some(e) = entry { + if changed { + self.file = None; + self.staged = None; + self.ready_since = None; + shared.event("info", &format!("Igneum Miner {} is available: downloading ({} MB){}", m.version, e.size / 1_000_000, if m.notes.is_empty() { String::new() } else { format!(". {}", m.notes) })); + } + self.entry = Some(e); + if self.staged.is_none() { + self.start_download(shared); + } + } + self.publish(shared); + } + } + } + Event::Downloaded(r) => match r { + Err(e) => { + self.set_error(shared, &format!("download failed: {e}")); + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + } + Ok(p) => { + self.clear_error(shared); + shared.log(&format!("update: {} downloaded and verified", p.display())); + self.file = Some(p.clone()); + self.publish(shared); + self.start_stage(shared, p); + } + }, + Event::Staged(r) => match r { + Err(e) if e.starts_with("manual:") => { + let mut st = shared.state.lock().unwrap(); + st.update.status = "manual".into(); + st.update.wait = e.trim_start_matches("manual:").trim().to_string(); + drop(st); + shared.event("info", &format!("update downloaded; {}", e.trim_start_matches("manual:").trim())); + } + Err(e) => { + self.set_error(shared, &format!("could not prepare the update: {e}")); + self.file = None; + self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S); + } + Ok(p) => { + self.clear_error(shared); + self.staged = Some(p); + self.ready_since = Some(Instant::now()); + let v = self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); + { + let mut st = shared.state.lock().unwrap(); + st.update.wait = if self.auto { "installs at the next safe moment".into() } else { "waiting for Install now".into() }; + st.update.progress = 1.0; + } + shared.event("ok", &format!("Igneum Miner {v} is ready; it installs at the next safe moment{}", if self.auto { "" } else { " (automatic updates are off: use Install now)" })); + self.publish(shared); + } + }, + } + self.publish(shared); + } + + fn start_download(&mut self, shared: &Arc) { + let Some(e) = self.entry.clone() else { return }; + self.busy = true; + { + let mut st = shared.state.lock().unwrap(); + st.update.status = "downloading".into(); + st.update.progress = 0.0; + } + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = download(&e, &dir); + shared2.send(Cmd::Ota(Event::Downloaded(r))); + }); + } + + fn start_stage(&mut self, shared: &Arc, file: PathBuf) { + let Some(e) = self.entry.clone() else { return }; + let version = self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); + self.busy = true; + shared.state.lock().unwrap().update.status = "staging".into(); + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = stage(&e, &file, &dir, &version); + shared2.send(Cmd::Ota(Event::Staged(r))); + }); + } + + // ---- the user's buttons -------------------------------------------------------------------------------------------- + + /// Install now: a ready update applies at once; a downloaded one as soon as it is staged; else a check runs. + pub fn install_now(&mut self, shared: &Arc) { + self.install_asked = true; + self.last_safe_check = Instant::now() - Duration::from_secs(10); + if self.staged.is_some() { + shared.state.lock().unwrap().update.wait = "installing now".into(); + return; + } + if self.busy { + return; + } + self.clear_error(shared); + if let Some(f) = self.file.clone() { + self.start_stage(shared, f); + } else if self.entry.is_some() { + self.start_download(shared); + } else { + self.start_check(shared); + } + } + + /// The manual path: open the downloaded disk image or installer for the user. + pub fn open_file(&self) -> Result<(), String> { + let f = self.file.as_ref().ok_or("nothing downloaded yet")?; + #[cfg(target_os = "macos")] + let r = Command::new("open").arg(f).spawn(); + #[cfg(windows)] + let r = crate::platform::quiet(&mut Command::new("cmd")).args(["/c", "start", "", &f.display().to_string()]).spawn(); + #[cfg(not(any(target_os = "macos", windows)))] + let r = Command::new("xdg-open").arg(f).spawn(); + r.map(|_| ()).map_err(|e| e.to_string()) + } + + // ---- apply ------------------------------------------------------------------------------------------------------- + + pub fn version(&self) -> String { + self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default() + } + + /// Writes update-pending.json and the helper, starts the helper detached. The engine exits right after. + /// `host_pid` is the window host when the engine runs under one. + pub fn launch_apply(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { + let staged = self.staged.clone().ok_or("no update is ready")?; + let to = self.version(); + let previous_installer = if cfg!(windows) { self.dir.join(installer_name_for(&self.current)).to_string_lossy().into_owned() } else { String::new() }; + let previous_installer = if Path::new(&previous_installer).is_file() { previous_installer } else { String::new() }; + self.write_pending(&Pending { from: self.current.clone(), to: to.clone(), at: crate::platform::unix_now_f(), starts: 0, previous_installer }); + let _ = std::fs::remove_file(self.result_path()); + let result = self.result_path(); + #[cfg(target_os = "macos")] + { + let app = crate::platform::bundle_path().ok_or("not running from Igneum Miner.app")?; + let script = self.app_dir.join("ota-apply.sh"); + std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; + let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string()]; + shared.log(&format!("update: starting the helper: bash {} {}", script.display(), args.join(" "))); + spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; + Ok(()) + } + #[cfg(windows)] + { + let _ = host_pid; + let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; + let script = self.app_dir.join("ota-apply.ps1"); + std::fs::write(&script, WIN_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ + "-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), + ]); + shared.log(&format!("update: starting the helper: {} (an administrator prompt follows)", script.display())); + spawn_detached(&mut c)?; + Ok(()) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = (shared, host_pid, staged, result); + Err("automatic apply is not supported on this platform".into()) + } + } + + /// The new version failed to start twice: restore the previous one through the helper and exit. + pub fn launch_rollback(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { + let p = self.pending.clone().ok_or("no update pending")?; + let result = self.result_path(); + shared.event("error", &format!("Igneum Miner {} did not stay up twice; restoring {}", p.to, p.from)); + #[cfg(target_os = "macos")] + { + let app = crate::platform::bundle_path().ok_or("not running from Igneum Miner.app")?; + let script = self.app_dir.join("ota-apply.sh"); + std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; + let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), String::new(), p.to.clone(), result.display().to_string()]; + spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; + Ok(()) + } + #[cfg(windows)] + { + let _ = host_pid; + if p.previous_installer.is_empty() || !Path::new(&p.previous_installer).is_file() { + return Err("no previous installer kept; reinstall from igneum.network".into()); + } + let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; + let script = self.app_dir.join("ota-apply.ps1"); + std::fs::write(&script, WIN_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ + "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), + ]); + spawn_detached(&mut c)?; + Ok(()) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = (host_pid, result); + Err("rollback is not supported on this platform".into()) + } + } + + pub fn is_wrapper(&self) -> bool { + self.wrapper + } +} + +// ---- the threads --------------------------------------------------------------------------------------------------- + +fn file_name(url: &str) -> String { + let name = url.rsplit('/').next().unwrap_or("update").split('?').next().unwrap_or("update"); + let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_').collect(); + if clean.is_empty() { "update".into() } else { clean } +} + +fn installer_name_for(version: &str) -> String { + format!("Igneum-Miner-Setup-{version}.exe") +} + +fn curl(args: &[&str], limit: Duration) -> Result<(), String> { + let mut c = Command::new("curl"); + c.args(args); + let out = crate::detect::run_timeout(&mut c, None, limit).ok_or("curl is not available")?; + // run_timeout folds stdout and stderr; with -sS only errors are printed + let t = out.trim(); + if t.is_empty() { Ok(()) } else { Err(t.lines().last().unwrap_or("curl failed").to_string()) } +} + +/// Fetches the manifest and its signature into /manifest.json(.sig), verifies, parses. +fn fetch_manifest(url: &str, dir: &Path) -> Result { + let mf = dir.join("manifest.json.new"); + let sf = dir.join("manifest.json.sig.new"); + let _ = std::fs::remove_file(&mf); + let _ = std::fs::remove_file(&sf); + curl(&["-fsSL", "--max-time", "20", "-o", &mf.display().to_string(), url], Duration::from_secs(25)).map_err(|e| if e.contains("404") { "no manifest at the update URL yet".to_string() } else { format!("manifest: {e}") })?; + curl(&["-fsSL", "--max-time", "20", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("manifest signature: {e}"))?; + let bytes = std::fs::read(&mf).map_err(|e| e.to_string())?; + let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?; + let m = manifest::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?; + let _ = std::fs::rename(&mf, dir.join("manifest.json")); + let _ = std::fs::rename(&sf, dir.join("manifest.json.sig")); + Ok(m) +} + +/// Downloads the platform's file with resume, checks size and sha256, renames .part to the final name. +fn download(e: &PlatformEntry, dir: &Path) -> Result { + let name = file_name(&e.url); + let final_path = dir.join(&name); + let part = dir.join(format!("{name}.part")); + if final_path.is_file() && std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0) == e.size && manifest::sha256_file(&final_path).map(|s| s == e.sha256).unwrap_or(false) { + return Ok(final_path); + } + let _ = std::fs::remove_file(&final_path); + let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if have > e.size { + let _ = std::fs::remove_file(&part); + } + if have != e.size { + // -C - resumes a partial file; --retry covers a dropped connection; 2 hours for a slow line + curl(&["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-o", &part.display().to_string(), &e.url], Duration::from_secs(7260))?; + } + let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0); + if got != e.size { + let _ = std::fs::remove_file(&part); + return Err(format!("size mismatch: got {got} bytes, the manifest says {}", e.size)); + } + let sum = manifest::sha256_file(&part).map_err(|e| e.to_string())?; + if sum != e.sha256 { + let _ = std::fs::remove_file(&part); + return Err("sha256 mismatch: the file is not what the manifest signed".into()); + } + std::fs::rename(&part, &final_path).map_err(|e| e.to_string())?; + Ok(final_path) +} + +/// macOS: the new bundle next to the running one (same volume, so the swap is two renames); Windows: nothing to do. +#[allow(unused_variables)] +fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result { + #[cfg(target_os = "macos")] + { + let app = crate::platform::bundle_path().ok_or("manual: the engine is not running from Igneum Miner.app; open the downloaded disk image and drag the app to Applications")?; + let parent = app.parent().ok_or("no parent folder")?; + let staged = parent.join(".Igneum Miner.app.new"); + let _ = std::fs::remove_dir_all(&staged); + // writable? a user-owned /Applications is; a managed Mac may not be + if std::fs::create_dir(&staged).is_err() { + return Err(format!("manual: {} is not writable; open the downloaded disk image and drag the app over the old one", parent.display())); + } + let _ = std::fs::remove_dir(&staged); + let work = dir.join("unpack"); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work).map_err(|e| e.to_string())?; + let source: PathBuf; + let mut mounted: Option = None; + if e.kind == "dmg" { + let mnt = work.join("mnt"); + std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?; + let out = crate::detect::run_timeout(Command::new("hdiutil").args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default(); + if !mnt.join("Igneum Miner.app").is_dir() { + return Err(format!("the disk image has no Igneum Miner.app ({})", out.lines().last().unwrap_or("hdiutil said nothing"))); + } + mounted = Some(mnt.clone()); + source = mnt.join("Igneum Miner.app"); + } else { + let out = crate::detect::run_timeout(Command::new("ditto").args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default(); + let found = find_app(&work).ok_or(format!("the zip has no Igneum Miner.app ({})", out.lines().last().unwrap_or("")))?; + source = found; + } + let r = (|| -> Result<(), String> { + let out = crate::detect::run_timeout(Command::new("ditto").arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default(); + if !staged.join("Contents/MacOS/igneum-app").is_file() { + return Err(format!("copy failed: {}", out.lines().last().unwrap_or(""))); + } + let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(&staged).output(); + let v = crate::detect::run_timeout(Command::new(staged.join("Contents/MacOS/igneum-app")).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default(); + let want = format!("igneum-app {version}"); + if v.trim() != want { + return Err(format!("the new engine answers '{}' to --version, the manifest says {version}", v.trim())); + } + Ok(()) + })(); + if let Some(m) = mounted { + let _ = Command::new("hdiutil").args(["detach", "-force", &m.display().to_string()]).output(); + } + let _ = std::fs::remove_dir_all(&work); + if let Err(err) = r { + let _ = std::fs::remove_dir_all(&staged); + return Err(err); + } + Ok(staged) + } + #[cfg(windows)] + { + if e.kind != "inno-setup" { + return Err(format!("kind '{}' is not an installer", e.kind)); + } + Ok(file.to_path_buf()) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + Err("manual: no automatic install on this platform".into()) + } +} + +#[cfg(target_os = "macos")] +fn find_app(dir: &Path) -> Option { + let rd = std::fs::read_dir(dir).ok()?; + for e in rd.flatten() { + let p = e.path(); + if p.file_name().map(|n| n == "Igneum Miner.app").unwrap_or(false) && p.is_dir() { + return Some(p); + } + if p.is_dir() { + if let Some(f) = find_app(&p) { + return Some(f); + } + } + } + None +} + +/// Starts a process that outlives the engine (stdio closed, own session on unix, no window on Windows). +fn spawn_detached(c: &mut Command) -> Result<(), String> { + use std::process::Stdio; + c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000 | 0x0000_0008); // CREATE_NO_WINDOW | DETACHED_PROCESS + } + c.spawn().map(|_| ()).map_err(|e| format!("cannot start the helper: {e}")) +} + +#[cfg(target_os = "macos")] +const MAC_HELPER: &str = r#"#!/bin/bash +# Igneum Miner update helper, written by the engine (src/ota.rs). Not for running by hand. +# bash ota-apply.sh apply|rollback +# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running +# bundle to ".previous" and the staged one in, opens the new app; if the new app does not start twice, puts the +# previous one back. rollback: the previous bundle back, the failed one aside. Writes for the engine. +MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7" +LOG="$(dirname "$RESULT")/ota-apply.log" +exec >>"$LOG" 2>&1 +echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER" +gone() { ! kill -0 "$1" 2>/dev/null; } +wait_gone() { local p="$1" n="$2"; while [ "$n" -gt 0 ] && ! gone "$p"; do sleep 0.5; n=$((n-1)); done; gone "$p"; } +result() { printf '{"ok":%s,"version":"%s","error":"%s","rolled_back":%s,"at":%s}\n' "$1" "$VER" "$2" "$3" "$(date +%s)" > "$RESULT.tmp" && mv "$RESULT.tmp" "$RESULT"; } +PREV="$APP.previous" +FAILED="$APP.failed" +ENGINE="$APP/Contents/MacOS/igneum-app" +started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; } +wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; } +if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then + osascript -e 'tell application id "network.igneum.miner" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null + wait_gone "$HPID" 80 || { echo "window $HPID still running after 40 s; ending it"; kill -9 "$HPID" 2>/dev/null; sleep 1; } +fi +# anything else from this bundle (a stray engine of an older run) +pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5 +case "$MODE" in + apply) + [ -d "$NEW" ] || { result false "the staged app is missing" false; open -n "$APP"; exit 1; } + rm -rf "$PREV" + mv "$APP" "$PREV" || { result false "could not move the old app aside" false; open -n "$APP"; exit 1; } + mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; open -n "$APP"; exit 1; } + xattr -dr com.apple.quarantine "$APP" 2>/dev/null + echo "swapped; opening $APP" + open -n "$APP" || echo "open failed" + if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi + echo "the new app did not start within 30 s; opening it once more" + open -n "$APP" || true + if started_ok; then result true "" false; echo "$VER is running (second try)"; exit 0; fi + echo "the new app did not start twice; restoring the previous version" + pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 1 + rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" + open -n "$APP" + result false "Igneum Miner $VER did not start twice; the previous version was restored" true + ;; + rollback) + [ -d "$PREV" ] || { result false "no previous version kept to restore" false; open -n "$APP"; exit 1; } + rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP" + open -n "$APP" + result false "Igneum Miner $VER did not stay up twice; the previous version was restored" true + ;; + *) echo "unknown mode $MODE"; exit 2 ;; +esac +"#; + +#[cfg(windows)] +const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine (src/ota.rs). Not for running by hand. +# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid -Installer -Version -Result -InstallDir +# apply: waits for the engine (it exits right after starting this), runs the installer /VERYSILENT with /IGNOTA=1 as +# administrator (one UAC prompt; the installer stops what is left, replaces the files and relaunches the app), writes +# . If the installer does not run (prompt declined, error), the old app is started again. +# rollback: the same with the previous version's installer. +param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir) +$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log' +function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) } +function Done([bool]$ok, [string]$err, [bool]$rb) { + $o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; at = [int][double](Get-Date -UFormat %s) } + ($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII +} +function Relaunch() { + $exe = Join-Path $InstallDir 'igneum-app.exe' + if (Test-Path $exe) { Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null } +} +Log "$Mode : engine $EnginePid installer '$Installer' version $Version" +$deadline = (Get-Date).AddSeconds(120) +while ((Get-Date) -lt $deadline -and (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 500 } +if (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) { Log 'engine still running; ending it'; Stop-Process -Id $EnginePid -Force -ErrorAction SilentlyContinue } +if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false; Relaunch; exit 1 } +$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log' +try { + $args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"')) + $p = Start-Process -FilePath $Installer -ArgumentList $args -Verb RunAs -Wait -PassThru + if ($p.ExitCode -eq 0) { + if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true } + else { Done $true '' $false } + Log "installer exit 0" + exit 0 + } + Log ("installer exit " + $p.ExitCode) + Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false +} catch { + Log ("installer did not run: " + $_.Exception.Message) + Done $false ("Windows did not let the installer run: " + $_.Exception.Message) $false +} +Relaunch +exit 1 +"#; diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 76a8aff43..0843b4e21 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -344,6 +344,36 @@ pub fn sync_clock() -> Result { } } +/// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows. +/// Blocking; call from a thread. +pub fn run_elevated(cmdline: &str) -> Result<(), String> { + #[cfg(windows)] + { + let escaped = cmdline.replace('\'', "''"); + let script = format!("$p = Start-Process -FilePath cmd.exe -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode"); + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); + quiet(&mut c); + let out = c.output().map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(if err.contains("canceled") || err.contains("cancelled") || err.is_empty() { "the administrator prompt was cancelled".into() } else { err }) + } + } + #[cfg(target_os = "linux")] + { + let out = Command::new("pkexec").args(["sh", "-c", cmdline]).output().map_err(|e| e.to_string())?; + if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } + } + #[cfg(not(any(windows, target_os = "linux")))] + { + let _ = cmdline; + Err("not supported on this platform".into()) + } +} + /// Builds a command that runs without a console window on Windows. pub fn quiet(cmd: &mut Command) -> &mut Command { #[cfg(windows)] diff --git a/app/igneum-app/src/procs.rs b/app/igneum-app/src/procs.rs index 76984a61a..a6ede9aa6 100644 --- a/app/igneum-app/src/procs.rs +++ b/app/igneum-app/src/procs.rs @@ -12,6 +12,7 @@ pub enum Source { Node, Watch, Miner(usize), // card index + Telemetry, // nvidia-smi -l 5 } impl Source { @@ -20,6 +21,7 @@ impl Source { Source::Node => "node".into(), Source::Watch => "watch".into(), Source::Miner(i) => format!("miner{}", i + 1), + Source::Telemetry => "gpu".into(), } } } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index c5074207c..dc0d4144d 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -203,6 +203,7 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result, path: &str, body: Value) -> Result { + shared.send(Cmd::InstallUpdate); + Ok(json!({ "ok": true })) + } + "/api/update/auto" => { + let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; + shared.send(Cmd::AutoUpdate(on)); + Ok(json!({ "ok": true })) + } + "/api/update/open" => { + shared.send(Cmd::OpenUpdateFile); + Ok(json!({ "ok": true })) + } "/api/open" => { let url = s("url").ok_or("url missing")?; if url.starts_with("https://") || url.starts_with("http://") { diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 242a6b368..5cf4a0c1e 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -54,6 +54,19 @@ pub struct CardState { pub pid: u32, pub last_status_age_s: f64, pub message: String, + // power and heat (NVIDIA through nvidia-smi; 0 = unknown) + pub power_w: f64, // draw now + pub power_limit_w: f64, // the limit in force + pub power_default_w: f64, // the card's default limit + pub power_min_w: f64, + pub power_max_w: f64, + pub power_before_w: f64, // the limit before the app touched it (restored on quit) + pub power_pct: u32, // the chosen cap, percent of the default (60 to 100) + pub power_applied: bool, + pub power_note: String, + pub temp_gpu: f64, + pub temp_mem: f64, + pub telemetry_at: f64, } #[derive(Clone, Serialize, Default)] @@ -114,6 +127,7 @@ pub struct SettingsState { pub identities: u32, pub vote: bool, pub start_at_login: bool, + pub auto_update: bool, } #[derive(Clone, Serialize, Default)] @@ -124,6 +138,25 @@ pub struct UpdateState { pub notes: String, pub checked_at: f64, pub error: String, + // the over-the-air updater (src/ota.rs) + pub status: String, // unknown | checking | current | available | downloading | staging | ready | applying | manual | error + pub downloaded: bool, + pub ready: bool, + pub applying: bool, + pub progress: f64, // 0..1 of the download + pub size: u64, + pub auto: bool, // settings: install by itself at a safe moment + pub wait: String, // why it has not applied yet, in the dashboard's words + pub urgent: bool, // a consensus activation is close, or this version is unsupported: no waiting + pub urgent_text: String, + pub activation_height: u64, + pub unsupported: bool, + pub min_supported: String, + pub channel: String, + pub published_at: String, + pub file: String, // the downloaded installer or disk image (the manual path opens it) + pub updated_from: String, // set on the first run after an update + pub rolled_back: String, // set when the helper restored the previous version } #[derive(Clone, Serialize)] diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs index c3dad1acf..c95fa03b7 100644 --- a/app/igneum-app/src/update.rs +++ b/app/igneum-app/src/update.rs @@ -1,56 +1,10 @@ -//! Update check against the manifest the packager named in igneum-app.json, and log uploads to the intake. +//! Clock sources for the skew check and log uploads to the intake. (The update check moved to src/ota.rs and +//! src/manifest.rs with the over-the-air updater.) //! Both go through curl (macOS ships it; Windows 10 1803 and later ship curl.exe), so the engine carries no TLS stack. -use crate::state::UpdateState; use std::process::Command; use std::time::Duration; -/// Manifest shape: {"version":"0.3.1","mac":{"url":"...","notes":"..."},"windows":{"url":"...","notes":"..."}} -pub fn check(manifest_url: &str, current: &str) -> UpdateState { - let mut st = UpdateState { checked_at: crate::platform::unix_now_f(), ..Default::default() }; - if manifest_url.is_empty() { - st.error = "no update manifest configured".into(); - return st; - } - let out = crate::detect::run_timeout(Command::new("curl").args(["-fsSL", "--max-time", "20", manifest_url]), None, Duration::from_secs(25)); - let Some(text) = out else { - st.error = "curl is not available".into(); - return st; - }; - let Ok(v) = serde_json::from_str::(&text) else { - st.error = "no manifest at the update URL yet".into(); - return st; - }; - let latest = v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string(); - let plat = if cfg!(target_os = "macos") { "mac" } else { "windows" }; - let entry = v.get(plat).cloned().unwrap_or(serde_json::Value::Null); - st.version = latest.clone(); - st.url = entry.get("url").and_then(|s| s.as_str()).unwrap_or("").to_string(); - st.notes = entry.get("notes").and_then(|s| s.as_str()).unwrap_or("").to_string(); - st.available = newer(&latest, current) && !st.url.is_empty(); - st -} - -fn parse(v: &str) -> Vec { - v.trim_start_matches('v').split(|c| c == '.' || c == '-').filter_map(|p| p.parse::().ok()).collect() -} - -pub fn newer(latest: &str, current: &str) -> bool { - let a = parse(latest); - let b = parse(current); - if a.is_empty() { - return false; - } - for i in 0..a.len().max(b.len()) { - let x = a.get(i).copied().unwrap_or(0); - let y = b.get(i).copied().unwrap_or(0); - if x != y { - return x > y; - } - } - false -} - /// The network's idea of now from an HTTPS Date header (1 s resolution), as unix seconds. The second clock source, /// independent of the node. pub fn https_time(url: &str) -> Option { diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 3cf60b66e..6f371335c 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -156,6 +156,17 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .cards.compact .gpu-row .name{font-size:14px} .cards.compact .ids .k{display:none} +.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.power input[type=range]{width:110px;accent-color:var(--ember)} +.power .pv{font-size:12px;color:var(--ink-2);min-width:84px} +.cards.compact .power .k{display:none} +.cards.compact .power input[type=range]{width:80px} +.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} +.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)} +.gpu-tile .msg.warm{color:var(--molten)} +.gpu-tile .msg.hot{color:var(--ember)} + /* per-card tiles on the dashboard */ .gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} .gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index ef6950618..7894ee2cd 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -221,6 +221,7 @@ d.innerHTML = '
' + badgeHtml(cd) + '
' + '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + meta + '
' + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + + (cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '
power cap' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W
' : '') + '
identities
' + ''; container.appendChild(d); @@ -230,7 +231,9 @@ var out = []; container.querySelectorAll('.gpu-row').forEach(function (r) { var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64; - out.push({ key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n }); + var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n }; + if (pr) item.power_pct = parseInt(pr.value, 10); + out.push(item); }); return out; } @@ -242,6 +245,12 @@ document.addEventListener('change', function (e) { if (e.target.matches('.gpu-row .switch input')) { var r = e.target.closest('.gpu-row'); r.classList.toggle('off', !e.target.checked); } }); + document.addEventListener('input', function (e) { + if (!e.target.matches('.power input')) return; + var row = e.target.closest('.gpu-row'), cd = state && state.mining.cards.filter(function (c) { return c.key === row.dataset.key; })[0]; + var pct = parseInt(e.target.value, 10); + row.querySelector('.pv').textContent = pct + '%' + (cd && cd.power_default_w ? ' · ' + Math.round(cd.power_default_w * pct / 100) + ' W' : ''); + }); var cardsRendered = ''; function renderCards(s) { var list = $('cards-list'), det = $('cards-detecting'); @@ -260,8 +269,21 @@ $('cards-sub').textContent = cards.length === 1 ? 'One card found. Switch it on to mine with it.' : cards.length + ' GPUs found. Switch on the ones you want to mine with; each gets its own worker.'; $('cards-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || ''; $('cards-help').hidden = false; + var nv = cards.filter(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0; }).length; + $('cards-power').hidden = !nv; $('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = on ? 'Continue' : 'Continue'; } + // power draw, the cap and the temperatures (NVIDIA); memory over 90 C amber, over 95 C red + function telemetryHtml(cd) { + if (cd.vendor !== 'nvidia' || !(cd.telemetry_at > 0 || cd.power_default_w > 0)) return ''; + var memCls = cd.temp_mem > 95 ? 'hot' : cd.temp_mem > 90 ? 'warm' : ''; + var h = '
draw ' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '' + (cd.power_limit_w ? ' / cap ' + Math.round(cd.power_limit_w) + ' W' : '') + '' + + 'GPU ' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '' + + 'memory ' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '
'; + if (memCls) h += '
memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk
'; + if (cd.power_note) h += '
' + esc(cd.power_note) + '
'; + return h; + } var lastEventsKey = ''; function renderDashboard(s) { var m = s.mining, n = s.node, p = s.program, f = s.finality; @@ -295,6 +317,7 @@ return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + '
' + cd.hash_now.toFixed(1) + 'MH/s
' + '
blocks ' + cd.accepted + '' + (cd.rejected ? ' / ' + cd.rejected + ' rejected' : '') + 'avg ' + cd.hash_avg.toFixed(1) + 'identities ' + cd.identities + '' + extra + '
' + + telemetryHtml(cd) + '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
'; }).join(''); $('d-cards-off').hidden = !off.length; diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index f9ca5ea89..ebbc7ef44 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -83,6 +83,7 @@ +
diff --git a/docs/design/app-screens/mac-cards.png b/docs/design/app-screens/mac-cards.png index c1afd7a83..cbcf5d779 100644 Binary files a/docs/design/app-screens/mac-cards.png and b/docs/design/app-screens/mac-cards.png differ diff --git a/docs/design/app-screens/mac-dashboard.png b/docs/design/app-screens/mac-dashboard.png index 4229305ef..83759ac36 100644 Binary files a/docs/design/app-screens/mac-dashboard.png and b/docs/design/app-screens/mac-dashboard.png differ