diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 82d8b8a9..72cc64a0 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -4,9 +4,17 @@ //! the log intake). Neither is in the UI source. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +/// What the user chose for one GPU (keyed by vendor, device index and name). #[derive(Clone, Serialize, Deserialize, Default)] +pub struct CardPref { + pub enabled: bool, + pub identities: u32, +} + +#[derive(Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default)] pub setup_done: bool, @@ -19,8 +27,11 @@ pub struct Settings { /// The one-time "save your key" sheet was confirmed. #[serde(default)] pub key_saved: bool, + /// Legacy single value (0.3.0 first cut); per-card counts live in `cards`. #[serde(default = "one")] pub identities: u32, + #[serde(default)] + pub cards: HashMap, #[serde(default = "yes")] pub vote: bool, #[serde(default)] @@ -37,6 +48,12 @@ fn yes() -> bool { true } +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(), vote: true, paused: false, accepted_total: 0 } + } +} + impl Settings { pub fn load(path: &Path) -> Settings { std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 701f3d0a..3932f8bc 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -56,6 +56,7 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState { CardState { index, + key: format!("{vendor}:{device}:{name}"), name: name.to_string(), vendor: vendor.into(), worker: worker.into(), @@ -67,6 +68,34 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi } } +/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete). +#[allow(dead_code)] +pub fn looks_integrated(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"]; + integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") +} + +/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off +/// (1 identity), Apple silicon on with 1. +pub fn apply_defaults(c: &mut CardState) { + match c.kind.as_str() { + "apple" => { + c.enabled = true; + c.identities = 1; + } + "integrated" => { + c.enabled = false; + c.identities = 1; + c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into(); + } + _ => { + c.enabled = true; + c.identities = if c.vram_mb >= 8192 { 8 } else { 2 }; + } + } +} + #[cfg(target_os = "macos")] pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { let mut cards = Vec::new(); @@ -104,7 +133,14 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { if !prepare { notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into()); } - cards.push(card(0, &name, "apple", "Metal", &detail, "")); + let mut c = card(0, &name, "apple", "Metal", &detail, ""); + c.kind = "apple".into(); + c.path = "prebuilt".into(); + if let Some(mem) = run_timeout(Command::new("sysctl").args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { + c.vram_mb = mem.trim().parse::().map(|b| b / (1024 * 1024)).unwrap_or(0); + } + apply_defaults(&mut c); + cards.push(c); cards } @@ -118,13 +154,17 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { for line in out.lines() { let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() { - let detail = parts.get(2).map(|m| m.replace("MiB", "MB")).unwrap_or_default(); - let detail = detail.trim().parse::().map(|_| detail.clone()).unwrap_or(detail); + let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::().ok()).map(|v| v as u64).unwrap_or(0); + let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() }; let worker_ok = bins.cuda.is_some(); let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]); + c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() }; + c.vram_mb = mem_mb; + c.path = if worker_ok { "prebuilt".into() } else { "build".into() }; if !worker_ok { - c.message = "CUDA worker not installed; built from source on first run (needs CUDA Toolkit and Visual Studio)".into(); + c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into(); } + apply_defaults(&mut c); cards.push(c); } } @@ -157,6 +197,9 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string(); let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx); c.device = idx.to_string(); + c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() }; + c.path = "prebuilt".into(); + apply_defaults(&mut c); cards.push(c); } } @@ -169,8 +212,9 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" }; let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0"); + c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() }; c.enabled = false; - c.message = "seen by Windows, but no worker can drive it yet".into(); + c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into(); cards.push(c); } } @@ -200,7 +244,10 @@ pub fn find_bins() -> Result { let p = dir.join(format!("{n}{ext}")); if p.exists() { Some(p) } else { None } }; - return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda: opt("igneum-worker-cuda"), opencl: opt("igneum-worker-opencl"), dir: dir.clone() }); + // the prebuilt CUDA worker needs NVIDIA's nvrtc64_*_0.dll next to it (as igneum-common.ps1 checks) + let nvrtc = std::fs::read_dir(dir).ok().map(|rd| rd.flatten().any(|e| { let n = e.file_name().to_string_lossy().to_ascii_lowercase(); n.starts_with("nvrtc64_") && n.ends_with("_0.dll") })).unwrap_or(false); + let cuda = opt("igneum-worker-cuda").filter(|_| nvrtc || cfg!(not(windows))); + return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), dir: dir.clone() }); } } Err(format!("igneumd and igneum-miner were not found next to the app (looked in {})", candidates.iter().map(|c| c.display().to_string()).collect::>().join(", "))) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index aa642340..010fe679 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -18,8 +18,16 @@ use std::time::{Duration, Instant}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); const POW_EPOCH_BLOCKS: u64 = 3_600; +#[derive(Clone)] +pub struct CardChoice { + pub key: String, + pub enabled: bool, + pub identities: u32, +} + pub enum Cmd { Detect, + ApplyCards(Vec), Detected(Vec, Vec), Start, Pause, @@ -276,6 +284,7 @@ struct MinerSlot { restarts: u32, building: bool, needs_rebuild: bool, + prepared: bool, // the pack is exported (and the worker built) for the next start last_status: Option, error_at: Option, } @@ -436,19 +445,24 @@ impl Engine { for n in ¬es { self.shared.log(&format!("detection: {n}")); } + let prefs = self.shared.settings.lock().unwrap().cards.clone(); let mut st = self.st(); st.detecting = false; st.detect_message = notes.join(". "); - let identities = st.settings.identities; st.mining.cards = cards; for c in st.mining.cards.iter_mut() { - c.identities = identities; + if let Some(p) = prefs.get(&c.key) { + c.enabled = p.enabled && c.kind != "unknown"; + c.identities = p.identities.clamp(1, 64); + c.reason = String::new(); + } } drop(st); if self.running { self.plan_miners(); } } + Cmd::ApplyCards(choices) => self.apply_cards(choices), Cmd::Start => self.start(), Cmd::Pause => { if !self.running { @@ -471,10 +485,6 @@ impl Engine { } Cmd::RestartMiners(why) => { self.shared.event("info", &format!("settings changed ({why}); the miners restart")); - let ids = self.st().settings.identities; - for c in self.st().mining.cards.iter_mut() { - c.identities = ids; - } self.stop_miners("settings changed"); for m in self.miners.iter_mut() { m.restart_at = Some(Instant::now()); @@ -504,10 +514,13 @@ impl Engine { m.building = false; match result { Ok(p) => { + if p != m.worker { + self.shared.event("build", "GPU worker built from source"); + } m.worker = p; m.needs_rebuild = false; + m.prepared = true; m.restart_at = Some(Instant::now()); - self.shared.event("build", "GPU worker built from source"); } Err(e) => { self.shared.event("error", &format!("worker build failed: {e}")); @@ -672,6 +685,7 @@ impl Engine { restarts: 0, building: false, needs_rebuild, + prepared: false, last_status: None, error_at: None, }); @@ -701,9 +715,9 @@ impl Engine { "--evm-address".into(), s.address.clone(), ]; - if s.identities > 1 { + if card.identities > 1 { a.push("--identities".into()); - a.push(s.identities.to_string()); + a.push(card.identities.to_string()); } if !s.vote { a.push("--no-vote".into()); @@ -715,16 +729,29 @@ impl Engine { a.push(r.network.clone()); } if card.worker != "Metal" { - // ahead-of-time workers build the next program from the pack the miner writes here + // The miner runs with the app data folder as its cwd: the pack paths stay relative (the miner splits + // --worker-args on spaces, and %LOCALAPPDATA% may carry a space in the user name). The prebuilt workers + // take the exported pack with --pack and build the next program from --prepare-packs; a worker built + // from source has the program compiled in and exits 42 at the boundary instead. a.push("--prepare-packs".into()); - a.push(r.app_dir.join("packs").join("prepare").display().to_string()); + a.push("packs\\prepare".into()); if card.worker == "OpenCL" { a.push("--job-nonces".into()); a.push("2097152".into()); } + let mut wargs = String::new(); if !card.device.is_empty() { + wargs.push_str(&format!("--device {}", card.device)); + } + if slot.worker.file_name().map(|f| f.to_string_lossy().starts_with("igneum-worker-")).unwrap_or(false) { + if !wargs.is_empty() { + wargs.push(' '); + } + wargs.push_str("--pack packs\\devnet"); + } + if !wargs.is_empty() { a.push("--worker-args".into()); - a.push(format!("--device {}", card.device)); + a.push(wargs); } } a @@ -742,7 +769,8 @@ impl Engine { self.miners[i].starts += 1; let seg = if self.miners[i].starts > 1 { format!("-r{}", self.miners[i].starts) } else { String::new() }; let log = self.shared.runtime.log_dir.join(format!("miner-{}-{}{seg}.log", self.miners[i].label, self.stamp)); - let cwd = self.miners[i].worker.parent().map(|p| p.to_path_buf()); + let cwd = if card.worker == "Metal" { None } else { Some(self.shared.runtime.app_dir.clone()) }; + self.miners[i].prepared = false; match procs::spawn(Source::Miner(card_idx), &self.bins.miner, &args, cwd.as_deref(), &log, &self.lines_tx) { Ok(p) => { self.shared.log(&format!("miner {} started (pid {}): {}", self.miners[i].label, p.pid(), p.cmdline)); @@ -794,24 +822,75 @@ impl Engine { st.mining.state = if st.mining.paused { "paused".into() } else if self.running { "waiting".into() } else { "stopped".into() }; } - /// Windows without a prebuilt worker: export the pack from the node and build the CUDA worker from source - /// (today's launcher path). The worker then has no prepare support and is rebuilt at every hour boundary. - fn build_worker(&mut self, i: usize) { + /// Ahead-of-time workers (Windows): export this hour's program pack from the node for the prebuilt worker, or, + /// with no prebuilt worker, build one from source (today's launcher path; it then has no prepare support and is + /// rebuilt at every hour boundary). Runs off the supervisor thread; WorkerBuilt starts the miner. + fn prepare_worker(&mut self, i: usize) { let card_idx = self.miners[i].card; self.miners[i].building = true; + let build = self.miners[i].needs_rebuild; if let Some(c) = self.st().mining.cards.get_mut(card_idx) { c.state = "starting".into(); - c.message = "building the GPU worker from source (about a minute)".into(); + c.message = if build { "building the GPU worker from source (about a minute)".into() } else { "exporting this hour's program".into() }; } let shared = self.shared.clone(); let bins = self.bins.clone(); let vendor = self.st().mining.cards.get(card_idx).map(|c| c.vendor.clone()).unwrap_or_default(); + let worker = self.miners[i].worker.clone(); std::thread::spawn(move || { - let r = build_worker_from_source(&shared, &bins, &vendor); + let r = if build { build_worker_from_source(&shared, &bins, &vendor) } else { export_pack(&shared, &bins).map(|_| worker) }; shared.send(Cmd::WorkerBuilt(card_idx, r)); }); } + /// The user changed which cards mine or how many identities each runs: only the affected workers restart. + fn apply_cards(&mut self, choices: Vec) { + let mut changed = Vec::new(); + { + let mut settings = self.shared.settings.lock().unwrap(); + let mut st = self.st(); + for ch in &choices { + 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 }); + if c.enabled != enabled || c.identities != identities { + c.enabled = enabled; + c.identities = identities; + c.reason = String::new(); + if !enabled { + c.state = "off".into(); + c.hash_now = 0.0; + c.message = String::new(); + } + changed.push((c.index, c.name.clone(), enabled, identities)); + } + } + settings.save(&self.shared.settings_path); + } + for (idx, name, enabled, identities) in changed { + if let Some(pos) = self.miners.iter().position(|m| m.card == idx) { + if let Some(mut p) = self.miners[pos].proc.take() { + p.write_stdin("quit\n"); + p.stop(8); + } + if enabled { + self.miners[pos].restart_at = Some(Instant::now()); + self.miners[pos].prepared = false; + self.shared.event("info", &format!("{name}: {identities} identit{}; its worker restarts", if identities == 1 { "y" } else { "ies" })); + } else { + self.miners.remove(pos); + self.shared.event("info", &format!("{name}: switched off")); + } + } else if enabled { + self.shared.event("ok", &format!("{name}: switched on, {identities} identit{}", if identities == 1 { "y" } else { "ies" })); + } + } + if self.running { + self.plan_miners(); + } + } + // ---- the tick ---------------------------------------------------------------------------------------------- fn tick(&mut self) { @@ -985,8 +1064,9 @@ impl Engine { } if let Some(at) = self.miners[i].restart_at { if now >= at { - if self.miners[i].needs_rebuild { - self.build_worker(i); + let aot = self.st().mining.cards.get(card_idx).map(|c| c.worker != "Metal").unwrap_or(false); + if (aot || self.miners[i].needs_rebuild) && !self.miners[i].prepared { + self.prepare_worker(i); } else { self.start_miner(i); } @@ -1067,7 +1147,6 @@ impl Engine { } Source::Miner(card) => self.miner_line(card, l.stderr, &l.text), Source::Node => {} - Source::Engine => {} } } @@ -1373,6 +1452,16 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { (if m <= 2 { y + 1 } else { y }, m, d) } +/// Exports this hour's program pack from the node to \packs\devnet (the prebuilt workers read it with --pack). +#[allow(unused_variables)] +fn export_pack(shared: &Arc, bins: &Bins) -> Result<(), String> { + let pack = shared.runtime.app_dir.join("packs").join("devnet"); + let _ = std::fs::create_dir_all(&pack); + let out = crate::detect::run_timeout(std::process::Command::new(&bins.miner).args(["export-pack", &shared.runtime.rpc_url(), &pack.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default(); + shared.log(&format!("export-pack: {}", out.lines().last().unwrap_or("no output"))); + if pack.join("seeds.txt").exists() { Ok(()) } else { Err("export-pack wrote no seeds.txt (is the node reachable?)".into()) } +} + /// Today's Windows path when no prebuilt worker ships: export the program pack, run proto-cuda\build.bat (or /// proto-opencl\build.bat) in the MSVC environment, and use the exe it writes. Untested from the Mac. #[allow(unused_variables)] diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 71b3f2d4..2a743e5f 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -3,7 +3,9 @@ //! --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its stdin. Without a host //! (--open) the dashboard opens in the default browser. //! -//! igneum-app [--wrapper | --open | --no-open] [--print-url] +//! igneum-app [--wrapper | --open | --no-open | --launch] [--print-url] +//! --launch is what the Start Menu entry runs on Windows: it hands over to "Igneum Miner.exe" (the window host) when +//! that is installed next to the engine, else runs the engine with the dashboard in the default browser. //! //! Environment (tests): IGNEUM_APP_NETWORK devnet|simnet, IGNEUM_APP_RPC_PORT, IGNEUM_APP_P2P_PORT, IGNEUM_APP_PEERS //! (comma list, empty for none), IGNEUM_APP_UNSYNCED=1, IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_APP_NODE_DIR. @@ -31,6 +33,19 @@ fn main() { println!("igneum-app {}", engine::VERSION); return; } + if args.iter().any(|a| a == "--launch") { + if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) { + let host = dir.join("Igneum Miner.exe"); + if host.exists() { + let mut c = std::process::Command::new(&host); + c.current_dir(&dir); + if c.spawn().is_ok() { + return; + } + } + } + // no window host: the engine runs on its own and the dashboard opens in the default browser + } platform::clear_quarantine(); let runtime = config::Runtime::from_env(); let _ = std::fs::create_dir_all(&runtime.app_dir); diff --git a/app/igneum-app/src/procs.rs b/app/igneum-app/src/procs.rs index f077db63..76984a61 100644 --- a/app/igneum-app/src/procs.rs +++ b/app/igneum-app/src/procs.rs @@ -12,7 +12,6 @@ pub enum Source { Node, Watch, Miner(usize), // card index - Engine, } impl Source { @@ -21,7 +20,6 @@ impl Source { Source::Node => "node".into(), Source::Watch => "watch".into(), Source::Miner(i) => format!("miner{}", i + 1), - Source::Engine => "app".into(), } } } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index e9c59a5f..3d6c77ea 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -194,6 +194,21 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + let list = body.get("cards").and_then(|v| v.as_array()).ok_or("cards missing")?; + let choices: Vec = list + .iter() + .filter_map(|c| { + Some(crate::engine::CardChoice { + 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, + }) + }) + .collect(); + shared.send(Cmd::ApplyCards(choices)); + Ok(json!({ "ok": true })) + } "/api/settings" => { let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32); let vote = body.get("vote").and_then(|v| v.as_bool()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 583114fc..e7d3c177 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -26,6 +26,11 @@ pub struct NodeState { #[derive(Clone, Serialize, Default)] pub struct CardState { pub index: usize, + pub key: String, // stable id for the saved preference: vendor:device:name + pub kind: String, // apple | discrete | integrated | external | unknown + pub vram_mb: u64, // 0 when unknown + pub reason: String, // why it is off by default, if it is + pub path: String, // prebuilt | build (how the worker comes to be) pub name: String, pub vendor: String, // apple | nvidia | amd | other pub worker: String, // Metal | CUDA | OpenCL diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 88b79742..b4ec6220 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -118,6 +118,52 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card @keyframes draw{to{stroke-dashoffset:0}} .gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} +/* GPU rows (first run and settings) */ +.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0} +.gpu-row.off{opacity:.72} +.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu-row .badge.apple{color:var(--bone)} +.gpu-row .badge.nvidia{color:#8BE37A} +.gpu-row .badge.amd{color:var(--ember)} +.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} +.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500} +.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)} +.kind.external{color:var(--bone)} +.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} +.gpu-row .meta b{color:var(--ink-2);font-weight:500} +.gpu-row .reason{font-size:12px;color:var(--ash)} +.gpu-row .msg{font-size:12px;color:var(--ember)} +.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} +.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px} +.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} +.ids button:hover{border-color:var(--ash)} +.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text} +.ids input:focus{outline:none;border-color:var(--ember)} +.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} +.gpu-row.off .ids{opacity:.4;pointer-events:none} +.gpu-row .switch{padding:0;flex:0 0 auto} +.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px} +.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} +.cards.compact .gpu-row .name{font-size:14px} +.cards.compact .ids .k{display:none} + +/* 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} +.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} +.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.gpu-tile.idle .h{color:var(--ash)} +.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} +.gpu-tile .m b{color:var(--ink-2);font-weight:500} +.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +.gpu-tile .st.mining{color:var(--molten)} +.gpu-tile .st.bad{color:var(--ember)} +.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} +.gpu-tile .msg{font-size:12px;color:var(--ash)} +.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)} + /* address options */ .options{display:flex;flex-direction:column;gap:12px;margin-top:6px} .option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 5b22ead4..929db1a5 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -48,7 +48,10 @@ api('api/phase', { phase: p }); } $('btn-begin').addEventListener('click', function () { show('cards'); }); - $('btn-cards-next').addEventListener('click', function () { show('address'); }); + $('btn-cards-next').addEventListener('click', function () { + var choices = readCardRows($('cards-list')); + if (choices.length) api('api/cards', { cards: choices }).then(function () { show('address'); }); else show('address'); + }); $('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); }); $('btn-address-back').addEventListener('click', function () { show('cards'); }); @@ -93,7 +96,7 @@ function fillSettings() { if (!state) return; $('s-address').textContent = state.address.display || 'not set'; - $('s-identities').value = state.settings.identities; + renderCardRows($('s-cards'), state.mining.cards); $('s-vote').checked = state.settings.vote; $('s-login').checked = state.settings.start_at_login; $('s-version').textContent = 'Igneum Miner ' + state.version; @@ -112,9 +115,9 @@ $('s-reveal').addEventListener('click', function () { api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; } else toast(r.error || 'no key'); }); }); - $('s-identities-save').addEventListener('click', function () { - var n = parseInt($('s-identities').value, 10); if (!(n >= 1 && n <= 64)) { toast('1 to 64'); return; } - api('api/settings', { identities: n }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); + $('s-cards-save').addEventListener('click', function () { + var choices = readCardRows($('s-cards')); + api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast('Applied; only the changed workers restart'); }); }); $('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); }); $('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); }); @@ -196,29 +199,63 @@ requestAnimationFrame(frame); // ---------- render ---------- + // ---------- GPU rows: one per card, with a switch and an identities stepper ---------- + function badgeHtml(cd) { + if (cd.vendor === 'apple') return ''; + return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU'; + } + function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } + function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); } + function renderCardRows(container, cards) { + container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); }); + cards.forEach(function (cd) { + var d = document.createElement('div'); + d.className = 'gpu-row' + (cd.enabled ? '' : ' off'); + d.dataset.key = cd.key; + var meta = 'worker ' + esc(cd.worker) + '' + (vramText(cd) ? '' + esc(vramText(cd)) + '' : '') + (cd.detail && cd.kind !== 'apple' ? '' + esc(cd.detail) + '' : '') + (cd.detail && cd.kind === 'apple' ? '' + esc(cd.detail.split(',')[0]) + '' : '') + (cd.device ? 'device ' + esc(cd.device) + '' : ''); + d.innerHTML = '
' + badgeHtml(cd) + '
' + + '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + meta + '
' + + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + + '
identities
' + + ''; + container.appendChild(d); + }); + } + function readCardRows(container) { + 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 }); + }); + return out; + } + document.addEventListener('click', function (e) { + var b = e.target.closest('.ids button'); if (!b) return; + var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; + v = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); inp.value = v; + }); + 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); } + }); + var cardsRendered = ''; function renderCards(s) { var list = $('cards-list'), det = $('cards-detecting'); - if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics card to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics card to report in.'; return; } + if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; } det.hidden = true; var cards = s.mining.cards; - list.querySelectorAll('.gpu').forEach(function (n) { n.remove(); }); + var sig = cards.map(function (c) { return c.key; }).join('|'); + if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); } if (!cards.length) { $('cards-sub').textContent = 'No GPU this app can drive was found.'; $('cards-note').textContent = (s.detect_message || '') + ' The node can still run on its own; mining needs a supported card.'; - $('cards-note').hidden = false; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining'; + $('cards-note').hidden = false; $('cards-help').hidden = true; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining'; return; } - $('cards-sub').textContent = cards.length === 1 ? 'One card found. The worker owns it while you mine.' : cards.length + ' cards found. One worker each.'; + var on = cards.filter(function (c) { return c.enabled; }).length; + $('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 || ''; - $('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue'; - cards.forEach(function (cd) { - var d = document.createElement('div'); - d.className = 'card gpu' + (cd.enabled ? '' : ' off'); - var badge = cd.vendor === 'apple' ? '' : cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU'; - var badgeHtml = cd.vendor === 'apple' ? '' : badge; - d.innerHTML = '
' + badgeHtml + '
' + esc(cd.name) + '
worker ' + esc(cd.worker) + '' + (cd.detail ? '' + esc(cd.detail) + '' : '') + (cd.device ? 'device ' + esc(cd.device) + '' : '') + '
' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + (cd.enabled ? '' : '') + '
'; - list.appendChild(d); - }); + $('cards-help').hidden = false; + $('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = on ? 'Continue' : 'Continue'; } var lastEventsKey = ''; function renderDashboard(s) { @@ -238,16 +275,22 @@ $('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length; $('d-found-60').textContent = m.found.length; $('d-chain-blocks').textContent = compact(n.blocks); - // cards table - $('d-cards-eyebrow').textContent = m.cards.length + ' worker' + (m.cards.length === 1 ? '' : 's'); + // per-card tiles (enabled cards), the rest in one muted line + var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; }); + $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPUs' : ''); var tb = $('d-cards'); - if (!m.cards.length) tb.innerHTML = 'No GPU worker. The node runs on its own.'; - else tb.innerHTML = m.cards.map(function (cd) { + if (!on.length) tb.innerHTML = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; + else tb.innerHTML = on.map(function (cd) { var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting') ? 'bad' : ''; var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state; - var sub = cd.message ? '' + esc(cd.message) + '' : (cd.ids.length ? 'id ' + esc(cd.ids.join(' ')) + (cd.prepared ? ' · next program ready' : '') + '' : ''); - return '' + esc(cd.name) + (cd.identities > 1 ? ' x' + cd.identities + '' : '') + '' + esc(cd.worker) + '' + cd.hash_now.toFixed(1) + '' + cd.hash_avg.toFixed(1) + '' + cd.accepted + (cd.rejected ? ' / ' + cd.rejected + '' : '') + '' + esc(word) + '' + sub + ''; + var extra = cd.ids.length ? 'id ' + esc(cd.ids.join(' ')) + '' : ''; + return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + + '
' + cd.hash_now.toFixed(1) + 'MH/s
' + + '
blocks ' + cd.accepted + '' + (cd.rejected ? ' / ' + cd.rejected + ' rejected' : '') + 'avg ' + cd.hash_avg.toFixed(1) + 'identities ' + cd.identities + '' + extra + '
' + + '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
'; }).join(''); + $('d-cards-off').hidden = !off.length; + $('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : ''; // node card $('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : ''); $('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers; @@ -296,7 +339,8 @@ if (first) { first = false; var start = forced || (s.setup_done ? 'dashboard' : 'welcome'); - if (start === 'key') { show('address'); showKeySheet({ address: s.address.value || '0x0000000000000000000000000000000000000000', display: s.address.display || '0x0000000000000000000000000000000000000000', private_key: '0x' + '0'.repeat(64), wallet_file: s.address.wallet_file || (s.node_dir.replace(/[^\/\\]+$/, '') + 'app/wallet.json') }); } + if (start === 'settings') { show('dashboard'); setTimeout(function () { fillSettings(); $('settings').hidden = false; }, 300); } + else if (start === 'key') { show('address'); showKeySheet({ address: s.address.value || '0x0000000000000000000000000000000000000000', display: s.address.display || '0x0000000000000000000000000000000000000000', private_key: '0x' + '0'.repeat(64), wallet_file: s.address.wallet_file || (s.node_dir.replace(/[^\/\\]+$/, '') + 'app/wallet.json') }); } else show(start); if (start === 'cards') renderCards(s); } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 15146da9..d2480158 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -67,7 +67,7 @@
step 1 of 2

Your GPU

-

Asking the graphics card to report in.

+

Asking the graphics cards to report in.

@@ -78,6 +78,7 @@
+
@@ -155,12 +156,8 @@

Cards

1 worker
-
- - - -
CardWorkerMH/s nowMH/s avgBlocksState
No card yet.
-
+
No card yet.
+
@@ -233,12 +230,9 @@
-
identities per card
-
- - -
-

One worker owns the card; identities take turns on it. 1 is right for most cards. Changing this restarts the miner.

+
cards
+
+

Switch a card on or off, set its identities. Only that card's worker restarts; the node keeps running.

diff --git a/app/windows/BUILD-APP.bat b/app/windows/BUILD-APP.bat new file mode 100644 index 00000000..c9f1c9f4 --- /dev/null +++ b/app/windows/BUILD-APP.bat @@ -0,0 +1,61 @@ +@echo off +rem Builds "Igneum Miner.exe" (the WebView2 window host) on a Windows PC. Double-click this file. +rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe) and the internet on the first run +rem (the WebView2 SDK comes from NuGet). The output lands in dist\ and, when the extracted payload folder +rem (igneum-windows-app, with igneum-app.exe) is next to this folder or its parent, is copied into it, so +rem packaging\windows\BUILD-INSTALLER.bat ships it. Without this exe the installer still works: the Start Menu entry +rem runs igneum-app.exe --launch, which opens the dashboard in the default browser. +setlocal EnableDelayedExpansion +cd /d "%~dp0" +set "SDKVER=1.0.2903.40" +set "SDKURL=https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/%SDKVER%" +if not exist build mkdir build +if not exist dist mkdir dist + +rem ---- 1. the MSVC environment ---- +set "VCVARS=" +for %%d in ("C:\Program Files\Microsoft Visual Studio" "C:\Program Files (x86)\Microsoft Visual Studio") do ( + for /f "delims=" %%f in ('dir /s /b "%%~d\vcvarsall.bat" 2^>nul') do set "VCVARS=%%f" +) +if "%VCVARS%"=="" ( + echo Visual Studio with the MSVC v143 x64 component was not found ^(no vcvarsall.bat under Program Files\Microsoft Visual Studio^). + pause + exit /b 1 +) +echo [build] MSVC: "%VCVARS%" +call "%VCVARS%" x64 >nul 2>&1 +where cl.exe >nul 2>nul || (echo cl.exe is not on PATH after vcvarsall; open a "x64 Native Tools" prompt and run this file from there. & pause & exit /b 1) + +rem ---- 2. the WebView2 SDK (NuGet package, a zip) ---- +if not exist "build\webview2\build\native\include\WebView2.h" ( + echo [build] downloading the WebView2 SDK %SDKVER% + powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%SDKURL%' -OutFile 'build\webview2.zip' -UseBasicParsing; if (Test-Path 'build\webview2') { Remove-Item -Recurse -Force 'build\webview2' }; Expand-Archive -Path 'build\webview2.zip' -DestinationPath 'build\webview2' -Force" + if not exist "build\webview2\build\native\include\WebView2.h" (echo the SDK did not unpack; see build\webview2 & pause & exit /b 1) +) + +rem ---- 3. the art: brand\icons in the repo layout, or an art\ folder next to this file ---- +set "ART=" +if exist "..\..\brand\icons\igneum.ico" set "ART=..\..\brand\icons" +if exist "art\igneum.ico" set "ART=art" +if exist "..\brand\icons\igneum.ico" set "ART=..\brand\icons" +if "%ART%"=="" (echo igneum.ico was not found ^(brand\icons or an art\ folder^). & pause & exit /b 1) + +rem ---- 4. compile ---- +echo [build] rc +rc.exe /nologo /i "%ART%" /fo build\host.res host.rc || (pause & exit /b 1) +echo [build] cl +cl.exe /nologo /O2 /MT /EHsc /W3 /std:c++17 /DUNICODE /D_UNICODE /I "build\webview2\build\native\include" /Fo"build\\" host.cpp build\host.res ^ + /link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Miner.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^ + user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib || (pause & exit /b 1) +echo [build] done: dist\Igneum Miner.exe + +rem ---- 5. into the payload, when it is here ---- +for %%p in ("igneum-windows-app" "..\igneum-windows-app" "..\..\igneum-windows-app" "..\..\packaging\windows\igneum-windows-app") do ( + if exist "%%~p\igneum-app.exe" ( + copy /y "dist\Igneum Miner.exe" "%%~p\" >nul + echo [build] copied into %%~p + ) +) +echo. +echo Next: packaging\windows\BUILD-INSTALLER.bat builds the Setup exe with this host inside. +pause diff --git a/app/windows/host.cpp b/app/windows/host.cpp new file mode 100644 index 00000000..fa376055 --- /dev/null +++ b/app/windows/host.cpp @@ -0,0 +1,390 @@ +// Igneum Miner for Windows: the window around the engine. One C++ file, built on the PC by BUILD-APP.bat +// (MSVC, the WebView2 SDK from NuGet, static loader). It starts igneum-app.exe --wrapper next to it, reads +// "URL ..." / "STATE {...}" / "EXIT" from its stdout, shows the URL in a WebView2 control, keeps a tray icon with the +// state, and on quit writes "quit" to the engine's stdin and waits for EXIT (miners stop first, then the node). +// Closing the window hides it to the tray; the miner keeps running. If the WebView2 runtime is missing the dashboard +// opens in the default browser instead and the window says so. 4 October 2026. +// +// Untested on a real PC at the time of writing (written on a Mac): BUILD-APP.bat is the first run. + +#define WIN32_LEAN_AND_MEAN +#define UNICODE +#define _UNICODE +#include +#include +#include +#include +#include +#include +#include +#include "WebView2.h" + +using namespace Microsoft::WRL; + +#define WM_ENGINE_LINE (WM_APP + 1) +#define WM_TRAY (WM_APP + 2) +#define ID_TRAY_OPEN 1001 +#define ID_TRAY_PAUSE 1002 +#define ID_TRAY_QUIT 1003 +#define ID_QUIT_TIMER 7 +#define IDI_APP 1 + +static HWND g_hwnd = nullptr; +static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr; +static ComPtr g_controller; +static ComPtr g_webview; +static std::wstring g_url, g_status = L"starting the engine"; +static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false; +static NOTIFYICONDATAW g_nid = {}; +static std::wstring g_trayTitle = L"Igneum Miner"; +static ULONGLONG g_quitStarted = 0; + +static std::wstring widen(const std::string& s) { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); + std::wstring w(n, 0); + MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n); + return w; +} + +static std::wstring exeDir() { + wchar_t buf[MAX_PATH]; + GetModuleFileNameW(nullptr, buf, MAX_PATH); + std::wstring p(buf); + size_t i = p.find_last_of(L"\\/"); + return i == std::wstring::npos ? L"." : p.substr(0, i); +} + +static void sendEngine(const char* line) { + if (!g_engineIn) return; + DWORD w = 0; + WriteFile(g_engineIn, line, (DWORD)strlen(line), &w, nullptr); + WriteFile(g_engineIn, "\n", 1, &w, nullptr); +} + +static void setTray(const std::wstring& tip) { + g_trayTitle = tip; + wcsncpy_s(g_nid.szTip, tip.c_str(), _TRUNCATE); + Shell_NotifyIconW(NIM_MODIFY, &g_nid); +} + +static void repaintStatus() { + InvalidateRect(g_hwnd, nullptr, TRUE); +} + +// A tiny JSON number/string/bool reader for the STATE line (flat object, known keys). +static std::string jsonField(const std::string& j, const char* key) { + std::string k = std::string("\"") + key + "\":"; + size_t i = j.find(k); + if (i == std::string::npos) return ""; + i += k.size(); + while (i < j.size() && j[i] == ' ') i++; + if (i < j.size() && j[i] == '"') { + size_t e = j.find('"', i + 1); + return e == std::string::npos ? "" : j.substr(i + 1, e - i - 1); + } + size_t e = i; + while (e < j.size() && j[e] != ',' && j[e] != '}') e++; + return j.substr(i, e - i); +} + +static void applyState(const std::string& j) { + std::string mining = jsonField(j, "mining"), node = jsonField(j, "node"), phase = jsonField(j, "phase"); + g_paused = jsonField(j, "paused") == "true"; + double hash = atof(jsonField(j, "hash_total").c_str()); + std::string blocks = jsonField(j, "blocks"), accepted = jsonField(j, "accepted_total"); + wchar_t tip[128]; + if (jsonField(j, "quitting") == "true") swprintf_s(tip, L"Igneum Miner: stopping"); + else if (phase != "dashboard") swprintf_s(tip, L"Igneum Miner: set up"); + else if (mining == "mining") swprintf_s(tip, L"Igneum Miner: %.1f MH/s, %S blocks found, node %S", hash, accepted.c_str(), node.c_str()); + else if (mining == "paused") swprintf_s(tip, L"Igneum Miner: paused, node %S", node.c_str()); + else swprintf_s(tip, L"Igneum Miner: %S, node %S (%S blocks)", mining.c_str(), node.c_str(), blocks.c_str()); + setTray(tip); +} + +static void openInBrowser(const std::wstring& url) { + ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL); +} + +static void navigate() { + if (g_webview && !g_url.empty()) g_webview->Navigate((g_url + L"?host=windows").c_str()); +} + +static void initWebView() { + std::wstring data = L""; + wchar_t* local = nullptr; + size_t len = 0; + if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") == 0 && local) { data = std::wstring(local) + L"\\igneum\\webview2"; free(local); } + HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, data.empty() ? nullptr : data.c_str(), nullptr, + Callback([](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { + if (FAILED(result) || !env) { + g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window."; + repaintStatus(); + if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; } + return S_OK; + } + env->CreateCoreWebView2Controller(g_hwnd, Callback([](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(result) || !controller) { + g_status = L"The app window could not start WebView2. The dashboard opens in your browser."; + repaintStatus(); + if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; } + return S_OK; + } + g_controller = controller; + g_controller->get_CoreWebView2(&g_webview); + g_webviewOk = true; + ComPtr settings; + if (g_webview && SUCCEEDED(g_webview->get_Settings(&settings)) && settings) { + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_IsStatusBarEnabled(FALSE); + settings->put_AreDevToolsEnabled(FALSE); + } + // links off 127.0.0.1 open in the default browser + g_webview->add_NewWindowRequested(Callback([](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { openInBrowser(uri); CoTaskMemFree(uri); } + args->put_Handled(TRUE); + return S_OK; + }).Get(), nullptr); + g_webview->add_NavigationStarting(Callback([](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + std::wstring u(uri); + CoTaskMemFree(uri); + if (u.rfind(L"http", 0) == 0 && u.find(L"127.0.0.1") == std::wstring::npos) { args->put_Cancel(TRUE); openInBrowser(u); } + } + return S_OK; + }).Get(), nullptr); + RECT rc; GetClientRect(g_hwnd, &rc); + g_controller->put_Bounds(rc); + COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 }; + ComPtr c2; + if (SUCCEEDED(g_controller.As(&c2)) && c2) c2->put_DefaultBackgroundColor(dark); + g_status = L""; + repaintStatus(); + navigate(); + return S_OK; + }).Get()); + return S_OK; + }).Get()); + if (FAILED(hr)) { + g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window."; + repaintStatus(); + if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; } + } +} + +static bool startEngine() { + SECURITY_ATTRIBUTES sa = { sizeof(sa), nullptr, TRUE }; + HANDLE outR, outW, inR, inW; + if (!CreatePipe(&outR, &outW, &sa, 0) || !CreatePipe(&inR, &inW, &sa, 0)) return false; + SetHandleInformation(outR, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(inW, HANDLE_FLAG_INHERIT, 0); + STARTUPINFOW si = { sizeof(si) }; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = outW; + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + si.hStdInput = inR; + PROCESS_INFORMATION pi = {}; + std::wstring exe = exeDir() + L"\\igneum-app.exe"; + std::wstring cmd = L"\"" + exe + L"\" --wrapper"; + std::vector buf(cmd.begin(), cmd.end()); + buf.push_back(0); + BOOL ok = CreateProcessW(exe.c_str(), buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, exeDir().c_str(), &si, &pi); + CloseHandle(outW); + CloseHandle(inR); + if (!ok) { CloseHandle(outR); CloseHandle(inW); return false; } + CloseHandle(pi.hThread); + g_engine = pi.hProcess; + g_engineIn = inW; + g_engineOut = outR; + std::thread([] { + std::string acc; + char chunk[4096]; + DWORD n; + while (ReadFile(g_engineOut, chunk, sizeof(chunk), &n, nullptr) && n > 0) { + acc.append(chunk, n); + size_t nl; + while ((nl = acc.find('\n')) != std::string::npos) { + std::string line = acc.substr(0, nl); + acc.erase(0, nl + 1); + if (!line.empty() && line.back() == '\r') line.pop_back(); + PostMessageW(g_hwnd, WM_ENGINE_LINE, 0, (LPARAM) new std::string(line)); + } + } + PostMessageW(g_hwnd, WM_ENGINE_LINE, 1, 0); + }).detach(); + return true; +} + +static void showTrayMenu() { + HMENU m = CreatePopupMenu(); + AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Miner"); + AppendMenuW(m, MF_STRING, ID_TRAY_PAUSE, g_paused ? L"Resume mining" : L"Pause mining"); + AppendMenuW(m, MF_SEPARATOR, 0, nullptr); + AppendMenuW(m, MF_STRING | MF_GRAYED, 0, g_trayTitle.c_str()); + AppendMenuW(m, MF_SEPARATOR, 0, nullptr); + AppendMenuW(m, MF_STRING, ID_TRAY_QUIT, L"Quit Igneum Miner"); + POINT pt; GetCursorPos(&pt); + SetForegroundWindow(g_hwnd); + TrackPopupMenu(m, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, pt.x, pt.y, 0, g_hwnd, nullptr); + DestroyMenu(m); +} + +static void beginQuit() { + if (g_quitting) return; + g_quitting = true; + g_quitStarted = GetTickCount64(); + g_status = L"Stopping: the miner first, then the node"; + setTray(L"Igneum Miner: stopping"); + if (g_webview) g_webview->ExecuteScript(L"document.getElementById('pill-text').textContent='stopping'", nullptr); + if (g_engine && !g_exited) { + sendEngine("quit"); + SetTimer(g_hwnd, ID_QUIT_TIMER, 500, nullptr); + } else { + DestroyWindow(g_hwnd); + } +} + +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_SIZE: + if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); } + return 0; + case WM_ENGINE_LINE: { + if (wp == 1) { + g_exited = true; + if (g_quitting) { DestroyWindow(hwnd); return 0; } + g_status = L"The engine stopped. Close this window and open Igneum Miner again."; + setTray(L"Igneum Miner: stopped"); + repaintStatus(); + return 0; + } + std::string* line = (std::string*)lp; + if (line->rfind("URL ", 0) == 0) { + g_url = widen(line->substr(4)); + if (g_webviewOk) navigate(); + else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; } + } else if (line->rfind("STATE ", 0) == 0) { + applyState(line->substr(6)); + } else if (line->rfind("FATAL ", 0) == 0) { + g_status = widen(line->substr(6)); + repaintStatus(); + MessageBoxW(hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR); + } else if (*line == "EXIT") { + g_exited = true; + if (g_quitting) DestroyWindow(hwnd); + } + delete line; + return 0; + } + case WM_TIMER: + if (wp == ID_QUIT_TIMER) { + DWORD code = 0; + bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE); + if (gone || g_exited || GetTickCount64() - g_quitStarted > 45000) { + if (!gone && g_engine) TerminateProcess(g_engine, 1); + KillTimer(hwnd, ID_QUIT_TIMER); + DestroyWindow(hwnd); + } + } + return 0; + case WM_TRAY: + if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); } + else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu(); + return 0; + case WM_COMMAND: + switch (LOWORD(wp)) { + case ID_TRAY_OPEN: ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); return 0; + case ID_TRAY_PAUSE: sendEngine(g_paused ? "resume" : "pause"); return 0; + case ID_TRAY_QUIT: beginQuit(); return 0; + } + return 0; + case WM_CLOSE: + // hide to the tray; the miner keeps running + ShowWindow(hwnd, SW_HIDE); + if (!g_hintShown) { + g_nid.uFlags |= NIF_INFO; + wcscpy_s(g_nid.szInfoTitle, L"Igneum Miner keeps mining"); + wcscpy_s(g_nid.szInfo, L"The window is in the tray. Right-click the icon to pause or quit."); + g_nid.dwInfoFlags = NIIF_INFO; + Shell_NotifyIconW(NIM_MODIFY, &g_nid); + g_nid.uFlags &= ~NIF_INFO; + g_hintShown = true; + } + return 0; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + RECT rc; GetClientRect(hwnd, &rc); + HBRUSH bg = CreateSolidBrush(RGB(12, 12, 14)); + FillRect(dc, &rc, bg); + DeleteObject(bg); + if (!g_status.empty()) { + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, RGB(154, 154, 158)); + HFONT f = CreateFontW(-15, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI"); + HFONT old = (HFONT)SelectObject(dc, f); + RECT tr = rc; tr.left += 40; tr.right -= 40; + DrawTextW(dc, g_status.c_str(), -1, &tr, DT_CENTER | DT_VCENTER | DT_WORDBREAK | DT_NOPREFIX | (g_webviewOk ? DT_BOTTOM : DT_VCENTER)); + SelectObject(dc, old); + DeleteObject(f); + } + EndPaint(hwnd, &ps); + return 0; + } + case WM_DESTROY: + Shell_NotifyIconW(NIM_DELETE, &g_nid); + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { + HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumMinerWindow"); + if (GetLastError() == ERROR_ALREADY_EXISTS) { + HWND other = FindWindowW(L"IgneumMinerWindow", nullptr); + if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); } + return 0; + } + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + WNDCLASSW wc = {}; + wc.lpfnWndProc = WndProc; + wc.hInstance = hInst; + wc.lpszClassName = L"IgneumMinerWindow"; + wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP)); + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14)); + RegisterClassW(&wc); + int w = 1120, h = 820; + int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2; + g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr); + ShowWindow(g_hwnd, SW_SHOW); + + g_nid.cbSize = sizeof(g_nid); + g_nid.hWnd = g_hwnd; + g_nid.uID = 1; + g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + g_nid.uCallbackMessage = WM_TRAY; + g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_APP), IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR); + wcscpy_s(g_nid.szTip, L"Igneum Miner: starting"); + Shell_NotifyIconW(NIM_ADD, &g_nid); + + if (!startEngine()) { + g_status = L"igneum-app.exe is missing next to this program. Run the installer again."; + repaintStatus(); + MessageBoxW(g_hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR); + } + initWebView(); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if (g_engine) { CloseHandle(g_engine); } + CloseHandle(once); + CoUninitialize(); + return 0; +} diff --git a/app/windows/host.rc b/app/windows/host.rc new file mode 100644 index 00000000..055c7f5d --- /dev/null +++ b/app/windows/host.rc @@ -0,0 +1,34 @@ +// Resources for Igneum Miner.exe (the window host): the coin icon and the version block. +// Compiled by BUILD-APP.bat with rc.exe; the icon path is relative to brand\icons (passed with /i). +#include + +1 ICON "igneum.ico" + +1 VERSIONINFO +FILEVERSION 0,3,0,0 +PRODUCTVERSION 0,3,0,0 +FILEFLAGSMASK 0x3fL +FILEFLAGS 0x0L +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "CompanyName", "Igneum" + VALUE "FileDescription", "Igneum Miner" + VALUE "FileVersion", "0.3.0" + VALUE "InternalName", "Igneum Miner" + VALUE "LegalCopyright", "Igneum. Nothing is bought or sold." + VALUE "OriginalFilename", "Igneum Miner.exe" + VALUE "ProductName", "Igneum Miner" + VALUE "ProductVersion", "0.3.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/docs/design/app-screens/mac-cards.png b/docs/design/app-screens/mac-cards.png index 6055280a..c1afd7a8 100644 Binary files a/docs/design/app-screens/mac-cards.png and b/docs/design/app-screens/mac-cards.png differ diff --git a/docs/design/app-screens/mac-dashboard.png b/docs/design/app-screens/mac-dashboard.png index 49e828f9..2e319310 100644 Binary files a/docs/design/app-screens/mac-dashboard.png and b/docs/design/app-screens/mac-dashboard.png differ diff --git a/docs/design/app-screens/mac-settings.png b/docs/design/app-screens/mac-settings.png new file mode 100644 index 00000000..0e5b8121 Binary files /dev/null and b/docs/design/app-screens/mac-settings.png differ diff --git a/packaging/mac/README.md b/packaging/mac/README.md index 39c6d20b..7a13850a 100644 --- a/packaging/mac/README.md +++ b/packaging/mac/README.md @@ -1,123 +1,78 @@ # Igneum Miner for Mac (packaging/mac) -A DMG a miner can open on an Apple silicon Mac to run a node and the Metal miner, and show up on igneum.network/live. -Built 3 October 2026 from the binaries already compiled in this repo; nothing is compiled here except the Metal worker -(one `swiftc` call, about 7 s). Renamed from "Igneum Devnet" to "Igneum Miner" the same evening (bundle id -network.igneum.miner, version 0.1.0); the devnet is still the only network and its name appears in the status lines, -never in a window title. Version 0.2.0 (4 October 2026) is the devnet v4 build: a new chain from genesis, so the data -folder moved to `devnet-v4`; the miner takes an EVM payout address (`--evm-address`, `PAYOUT_EVM`), runs every identity -through one process (`--identities`), votes on finality by default (`VOTE=0` turns it off) and hot-swaps the hourly -program through the worker's `prepare` command. +A DMG a miner opens on an Apple silicon Mac: drag the app to Applications, open it, answer two questions, mine. +Version 0.3.0 (4 October 2026) is the app: a branded window (no Terminal), the engine that runs the node and the miner, +a menu-bar item, settings, an update check. 0.1.0 and 0.2.0 (3 and 4 October 2026) were the Terminal launchers; the +0.2.0 run script's command lines live on inside the engine (`app/igneum-app/src/engine.rs`). ## Build packaging/mac/build-dmg.sh -Output: `packaging/mac/dist/Igneum-Miner-0.2.0.dmg` (about 16 MB, lzfse). The script takes -`vendor/igneum-node/target-integration/release/igneumd` and `igneum-miner` (the devnet-v4 integration build of -`vendor/igneum-node-v4` at dc749905, `NODE=` and `MINER=` override) and rebuilds the Metal worker from -`proto-metal/main.swift` (the hot-swap source) with `-target arm64-apple-macos11`, so it loads on any Apple silicon macOS -and not only on the build machine's (the committed `proto-metal/igneum-bench-hotswap` says minos 16.0; `REBUILD_WORKER=0` -ships it as is, `WORKER=` names another one). It copies the three into the bundle, strips the copies and signs them ad -hoc again (strip invalidates the linker signature and arm64 macOS refuses an unsigned binary), and checks that each copy -still runs, that the worker says `prepare 1` and that the miner is the v4 one (`--evm-address` in its usage). The -originals are untouched. The icon is `brand/icons/igneum.icns` and the DMG art `brand/icons/dmg-background.tiff` -(both from `python3 brand/icons/make-icons.py`). The image is laid out by `dmgbuild` (`pip3 install dmgbuild`, settings in -`dmg/settings.py`: volume "Igneum Miner", hidden `.VolumeIcon.icns` with the custom-icon flag, background, icon slots app -(165,130), Applications (495,130), README and Stop command on the row below, no Finder scripting needed). Without dmgbuild -the script builds a plain hdiutil image and says so. `build/` and `dist/` are ignored by git. +Output: `packaging/mac/dist/Igneum-Miner-0.3.0.dmg` (about 20 MB, lzfse). The script compiles the engine +(`app/igneum-app`, `cargo build --release`, nice 19 and 4 jobs because the devnet mines on this Mac), the window +(`app/mac/IgneumMiner.swift`, `swiftc -target arm64-apple-macos11`) and the Metal worker (`proto-metal/main.swift`, +same target), takes `igneumd` and `igneum-miner` from `vendor/igneum-node/target-integration/release` (the devnet-v4 +integration build; `NODE=`, `MINER=`, `WORKER=`, `ENGINE=` override), strips and ad-hoc signs the copies, checks each +one runs, writes `igneum-app.json` (below) and lays the image out with `dmgbuild` (`pip3 install dmgbuild`; without it a +plain hdiutil image). The icon is `brand/icons/igneum.icns`, the DMG art `brand/icons/dmg-background.tiff` +(`python3 brand/icons/make-icons.py`). `build/` and `dist/` are ignored by git. + +## The bundle + + Igneum Miner.app/Contents/MacOS/Igneum Miner the window: WKWebView + NSStatusItem (app/mac/IgneumMiner.swift) + Igneum Miner.app/Contents/MacOS/igneum-app the engine (app/igneum-app) + Igneum Miner.app/Contents/Resources/bin/igneumd, igneum-miner, igneum-bench + Igneum Miner.app/Contents/Resources/igneum-app.json packaged configuration (packaged-config.sh writes it) + Igneum Miner.app/Contents/Resources/igneum.icns + Igneum Miner.app/Contents/Info.plist app/Info.plist (CFBundleVersion = build stamp, LSMinimumSystemVersion 11.0) + +The window starts `igneum-app --wrapper`, reads `URL ...` and `STATE {...}` lines from its stdout, shows the URL in the +web view and keeps the menu-bar title (hash rate, paused, syncing). Quit writes `quit` to the engine's stdin and waits +for `EXIT` (up to 45 s): the miners stop first (8 s grace), then the node (30 s), then the last log upload. Closing +the window hides it; the app lives on in the menu bar and the Dock. The engine serves the dashboard on +`127.0.0.1:/t/<32-hex token>/`; nothing without the token answers. Links off 127.0.0.1 open in the +default browser. + +`igneum-app.json` (never in the UI source): `update_manifest` = `https://dl.igneum.network/dl//igneum-app-latest.json` +with the token read from `~/.config/igneum/dl-token` at build time (missing file = update check off), the log intake +URL and key (the key only authorises uploads, as the 0.2.0 launchers shipped it), the live page. The manifest the +engine expects: `{"version":"0.3.1","mac":{"url":"...dmg","notes":"..."},"windows":{"url":"...exe","notes":"..."}}`; +a newer version shows a banner with a Download button (opens the URL in the browser). Checked 20 s after start and +every 6 h, and from Settings. + +Gatekeeper: the app is unsigned (ad hoc). Right-click > Open the first time. The engine strips +`com.apple.quarantine` from the bundle's Contents on start, so the binaries inside are not refused one by one; that +needs a writable volume, hence "drag to Applications first". + +## What the engine does (app/igneum-app) + +Order: binaries next to the app > `igneum-app.json` > settings (`~/Library/Application Support/Igneum/app/settings.json`) +> GPU detection (the Metal worker's `ready` line gives the device name; system_profiler the cores) > first run: cards +(switch, identities) and the rewards address (a secp256k1 key made here, `app/wallet.json` mode 0600, shown once; or a +pasted 0x address) > `igneumd --devnet --appdir ~/Library/Application Support/Igneum/devnet-v4 --rpclisten 127.0.0.1:26610 +--listen 0.0.0.0:26611 --addpeer 188.245.5.161:26611 --addpeer 192.168.68.64:26611 --nodnsseed --disable-upnp --nologfiles +--yes` (a node already answering on 26610 is used as is and never stopped) > `igneum-miner watch 1000000000` as one +long-lived reader (a line every 10 s: blocks, headers, daa, peers, difficulty, blue, synced) > synced (the node's own +flag with a peer, else the 0.2.0 rule) > per enabled card `igneum-miner mine grpc://127.0.0.1:26610 1 100000000 +mac- --worker igneum-bench --status-secs 30 --exit-on-seed-change --evm-address [--identities N] +[--no-vote] --payout-label mac-` > the loop: STATUS lines into hash rate (the v4 `now=` interval rate) and +counts, ACCEPTED into blocks and the strip, `worker: prepared` into "next program compiled and ready", LOCK/VOTE into +finality, a status line every 30 s, uploads every 60 s, restarts (exit 42 at once; crashes 5 to 60 s; the node 5 to +60 s with the miners held until it is synced again). Pause stops the miners and keeps the node. Settings restart only +the affected worker. + +Tests: `IGNEUM_APP_NETWORK=devnet IGNEUM_APP_DEVNET_SUFFIX=7 IGNEUM_APP_RPC_PORT=29400 IGNEUM_APP_P2P_PORT=29401 +IGNEUM_APP_PEERS= IGNEUM_APP_UNSYNCED=1 IGNEUM_APP_DATA= IGNEUM_APP_LOGS= igneum-app --print-url --no-open` +runs a private devnet (its own genesis, no peers) with real mining; the window binary takes the same environment. +Screenshots: `"Igneum Miner" --snapshot out.png --url ?screen=welcome|cards|address|key|dashboard|settings` +(docs/design/app-screens). ## What is on the DMG | Item | What it is | |---|---| -| `Igneum Miner.app` | the launcher bundle (below) | -| `README.txt` | 10 lines for the miner (`dmg/README.txt`), with the seed line | -| `Stop Igneum Miner.command` | stops a copy whose window was closed without Ctrl+C (`app/Stop Igneum Miner.command`) | +| `Igneum Miner.app` | the app | +| `README.txt` | 8 lines for the miner (`dmg/README.txt`), with the seed line | +| `Stop Igneum Miner.command` | asks a running copy to stop through its API, then ends leftovers (`app/Stop Igneum Miner.command`) | | `Applications` | symlink, for the drag | - -## The app bundle - - Igneum Miner.app/Contents/MacOS/Igneum Miner app/launcher.sh: removes quarantine from Resources, opens the script in Terminal - Igneum Miner.app/Contents/Resources/igneum-miner.sh the run script (app/igneum-miner.sh); sets the Terminal title "Igneum Miner" - Igneum Miner.app/Contents/Resources/bin/igneumd, igneum-miner, igneum-bench - Igneum Miner.app/Contents/Resources/igneum.icns - Igneum Miner.app/Contents/Info.plist app/Info.plist (CFBundleVersion = build stamp, LSMinimumSystemVersion 11.0) - -Why a shell launcher and not an AppleScript applet: `open -a Terminal