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:
parent
cbe927e390
commit
d4cb2d2078
14 changed files with 1360 additions and 77 deletions
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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://") {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">−</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;
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in a new issue