Igneum Miner: NVIDIA power cap by default (80% of the card's default limit through an elevated nvidia-smi -pl step at start, restored on quit, per-card slider 60 to 100%), nvidia-smi telemetry on the card tile (draw, cap, GPU and memory temperature with amber over 90 C and red over 95 C), a stability line every 5 min and at quit (draw p95, max temps) for crash correlation (PC 2's 5090 hard-crashed at full power)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 11:39:52 +00:00
parent cbe927e390
commit d4cb2d2078
14 changed files with 1360 additions and 77 deletions

View file

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

View file

@ -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<String, (f64, f64, f64, f64)> {
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::<f64>().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<String>) -> Vec<CardState> {
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()),
}

View file

@ -23,6 +23,7 @@ pub struct CardChoice {
pub key: String,
pub enabled: bool,
pub identities: u32,
pub power_pct: Option<u32>,
}
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<PathBuf, String>),
/// 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<String, String>),
/// 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<std::thread::JoinHandle<()>>,
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<Instant>,
telemetry: Option<Proc>,
telemetry_retry_at: Instant,
power_busy: bool,
power_restore_pending: bool,
stability: std::collections::HashMap<usize, Stability>,
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<CardChoice>) {
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<String> = 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<String> = ["--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::<f64>().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<f64>,
max_tgpu: f64,
max_tmem: f64,
}
/// One `igneum-miner watch` reading.
pub struct Reading {
pub blocks: u64,

View file

@ -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 data>/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<Manifest, String>),
/// The installer or disk image on disk, size and sha256 checked.
Downloaded(Result<PathBuf, String>),
/// macOS: the new bundle staged next to the running one. Windows: the installer path again.
Staged(Result<PathBuf, String>),
}
/// What the engine must do now.
pub enum Action {
Apply,
}
pub struct Ctx {
pub node_synced: bool,
pub boundary_eta_s: Option<i64>,
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<Manifest>,
entry: Option<PlatformEntry>,
file: Option<PathBuf>,
staged: Option<PathBuf>,
busy: bool,
next_check: Instant,
ready_since: Option<Instant>,
last_safe_check: Instant,
install_asked: bool,
pending: Option<Pending>,
started: Instant,
healthy_marked: bool,
jitter: u64,
}
impl Updater {
pub fn new(shared: &Arc<Shared>, 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<Pending> {
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<Shared>) {
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::<Value>(&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<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, 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<Shared>, ctx: &Ctx) -> Option<Action> {
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<Shared>) {
if self.busy {
return;
}
self.clear_error(shared);
self.start_check(shared);
}
fn start_check(&mut self, shared: &Arc<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, 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<Shared>) {
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<Shared>, 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<Shared>, 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 <updates>/manifest.json(.sig), verifies, parses.
fn fetch_manifest(url: &str, dir: &Path) -> Result<Manifest, String> {
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<PathBuf, String> {
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<PathBuf, String> {
#[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<PathBuf> = 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<PathBuf> {
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 <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json>
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
# bundle to "<app>.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 <result json> 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 <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder>
# 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
# <json>. 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
"#;

View file

@ -344,6 +344,36 @@ pub fn sync_clock() -> Result<String, String> {
}
}
/// 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)]

View file

@ -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(),
}
}
}

View file

@ -203,6 +203,7 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
key: c.get("key")?.as_str()?.to_string(),
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
identities: c.get("identities").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 64) as u32,
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(60, 100) as u32),
})
})
.collect();
@ -221,6 +222,19 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::CheckUpdate);
Ok(json!({ "ok": true }))
}
"/api/update/install" => {
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://") {

View file

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

View file

@ -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::<serde_json::Value>(&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<u64> {
v.trim_start_matches('v').split(|c| c == '.' || c == '-').filter_map(|p| p.parse::<u64>().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<f64> {

View file

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

View file

@ -221,6 +221,7 @@
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="60" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span></div>' : '') +
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">&minus;</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
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 = '<div class="m tele"><span>draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b>' + (cd.power_limit_w ? ' <span class="dim">/ cap ' + Math.round(cd.power_limit_w) + ' W</span>' : '') + '</span>' +
'<span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span>' +
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span></div>';
if (memCls) h += '<div class="msg ' + memCls + '">memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk</div>';
if (cd.power_note) h += '<div class="msg">' + esc(cd.power_note) + '</div>';
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 '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + '</div>' +
telemetryHtml(cd) +
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
}).join('');
$('d-cards-off').hidden = !off.length;

View file

@ -83,6 +83,7 @@
</div>
</div>
<p class="note" id="cards-note" hidden></p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card.</p>
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. Identities take turns on the card and each one votes and pays separately; 8 suits a big card, 2 a small one, 1 an integrated GPU.</p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 327 KiB