diff --git a/app/igneum-app/.gitignore b/app/igneum-app/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/app/igneum-app/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock new file mode 100644 index 00000000..3806753e --- /dev/null +++ b/app/igneum-app/Cargo.lock @@ -0,0 +1,394 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "elliptic-curve", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "igneum-app" +version = "0.3.0" +dependencies = [ + "getrandom", + "k256", + "libc", + "serde", + "serde_json", + "sha3", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml new file mode 100644 index 00000000..cc6803fd --- /dev/null +++ b/app/igneum-app/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "igneum-app" +version = "0.3.0" +edition = "2021" +description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1" +license = "MIT" +publish = false + +[[bin]] +name = "igneum-app" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] } +sha3 = { version = "0.10", default-features = false } +getrandom = "0.2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[profile.release] +opt-level = 2 +lto = "thin" +codegen-units = 4 +strip = true diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs new file mode 100644 index 00000000..82d8b8a9 --- /dev/null +++ b/app/igneum-app/src/config.rs @@ -0,0 +1,127 @@ +//! Two configuration files. +//! `settings.json` in the app data directory: what the user chose (address, identities, voting, setup done). +//! `igneum-app.json` next to the binary (macOS: Contents/Resources): what the packager wrote (the update manifest URL, +//! the log intake). Neither is in the UI source. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct Settings { + #[serde(default)] + pub setup_done: bool, + /// Lower-case 0x address every identity pays to. + #[serde(default)] + pub address: String, + /// "generated" (the wallet file next to this) or "pasted". + #[serde(default)] + pub address_source: String, + /// The one-time "save your key" sheet was confirmed. + #[serde(default)] + pub key_saved: bool, + #[serde(default = "one")] + pub identities: u32, + #[serde(default = "yes")] + pub vote: bool, + #[serde(default)] + pub paused: bool, + /// Lifetime accepted blocks, carried across runs. + #[serde(default)] + pub accepted_total: u64, +} + +fn one() -> u32 { + 1 +} +fn yes() -> bool { + true +} + +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() + } + pub fn save(&self, path: &Path) { + if let Some(d) = path.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Ok(t) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, t); + } + } +} + +/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature. +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct Packaged { + #[serde(default)] + pub update_manifest: String, + #[serde(default)] + pub log_intake_url: String, + #[serde(default)] + pub log_intake_key: String, + #[serde(default)] + pub live_page: String, + #[serde(default)] + pub download_page: String, +} + +impl Packaged { + pub fn load(candidates: &[PathBuf]) -> Packaged { + for c in candidates { + if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&t).ok()) { + return p; + } + } + Packaged::default() + } +} + +/// Where the engine runs: network, ports, peers, directories. Defaults are the devnet v4 of today's launchers; +/// the environment overrides them (tests run a private simnet on 29400+). +#[derive(Clone, Serialize)] +pub struct Runtime { + pub network: String, // devnet | simnet + pub rpc_port: u16, + pub p2p_port: u16, + pub peers: Vec, + pub unsynced_mining: bool, + /// A private devnet (--devnet-suffix=N): its own genesis, never peers with the shared devnet. Tests. + pub devnet_suffix: Option, + pub node_dir: PathBuf, + pub app_dir: PathBuf, + pub log_dir: PathBuf, + pub status_secs: u32, + pub host: String, +} + +impl Runtime { + pub fn from_env() -> Runtime { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| "devnet".into()); + let rpc_port = env("IGNEUM_APP_RPC_PORT").and_then(|v| v.parse().ok()).unwrap_or(26610); + let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611); + let peers = match std::env::var("IGNEUM_APP_PEERS") { + Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(), + // the public seed node first, then the project lead's Mac on the house LAN (devnet only) + Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()], + Err(_) => vec![], + }; + let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false); + let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok()); + let root = crate::platform::data_root(); + let node_dir = match env("IGNEUM_APP_NODE_DIR") { + Some(d) => PathBuf::from(d), + None if network == "devnet" && devnet_suffix.is_none() => root.join("devnet-v4"), + None if network == "devnet" => root.join(format!("devnet-{}", devnet_suffix.unwrap_or(0))), + None => root.join(format!("{network}-app")), + }; + let app_dir = root.join("app"); + let log_dir = crate::platform::log_root(); + let status_secs = env("IGNEUM_APP_STATUS_SECS").and_then(|v| v.parse().ok()).unwrap_or(30); + Runtime { network, rpc_port, p2p_port, peers, unsynced_mining, devnet_suffix, node_dir, app_dir, log_dir, status_secs, host: crate::platform::host_label() } + } + pub fn rpc_url(&self) -> String { + format!("grpc://127.0.0.1:{}", self.rpc_port) + } +} diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs new file mode 100644 index 00000000..701f3d0a --- /dev/null +++ b/app/igneum-app/src/detect.rs @@ -0,0 +1,213 @@ +//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core +//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest +//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs. + +use crate::state::CardState; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +pub struct Bins { + pub node: std::path::PathBuf, + pub miner: std::path::PathBuf, + pub metal: Option, + pub cuda: Option, + pub opencl: Option, + pub dir: std::path::PathBuf, +} + +/// Runs a command with a time limit; returns stdout (and stderr appended) or None. +pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.stdin(if stdin_text.is_some() { Stdio::piped() } else { Stdio::null() }); + crate::platform::quiet(cmd); + let mut child = cmd.spawn().ok()?; + if let (Some(text), Some(mut stdin)) = (stdin_text, child.stdin.take()) { + let _ = stdin.write_all(text.as_bytes()); + drop(stdin); + } + let out = child.stdout.take()?; + let err = child.stderr.take()?; + let reader = std::thread::spawn(move || { + let mut s = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s); + let mut e = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(err), &mut e); + (s, e) + }); + let deadline = Instant::now() + limit; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + break; + } + } + } + let (s, e) = reader.join().ok()?; + Some(if e.is_empty() { s } else { format!("{s}\n{e}") }) +} + +fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState { + CardState { + index, + name: name.to_string(), + vendor: vendor.into(), + worker: worker.into(), + detail: detail.into(), + device: device.into(), + enabled: true, + state: "off".into(), + ..Default::default() + } +} + +#[cfg(target_os = "macos")] +pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { + let mut cards = Vec::new(); + let Some(metal) = bins.metal.as_ref() else { + notes.push("the Metal worker (igneum-bench) is missing from the app".into()); + return cards; + }; + // the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1" + let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default(); + let ready = out.lines().find(|l| l.starts_with("ready ")); + let Some(ready) = ready else { + notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output"))); + return cards; + }; + let fields: Vec<&str> = ready.split_whitespace().collect(); + let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into()); + let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1"); + // cores and memory from system_profiler (a second or two); optional + let mut detail = String::new(); + if let Some(sp) = run_timeout(Command::new("system_profiler").args(["SPDisplaysDataType", "-json"]), None, Duration::from_secs(8)) { + if let Ok(v) = serde_json::from_str::(&sp) { + if let Some(g) = v.get("SPDisplaysDataType").and_then(|a| a.as_array()).and_then(|a| a.first()) { + if let Some(c) = g.get("sppci_cores").and_then(|c| c.as_str()) { + detail = format!("{c} GPU cores"); + } + } + } + } + if let Some(mem) = run_timeout(Command::new("sysctl").args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { + if let Ok(b) = mem.trim().parse::() { + let gb = b / (1024 * 1024 * 1024); + detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; + } + } + 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, "")); + cards +} + +#[cfg(not(target_os = "macos"))] +pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { + let mut cards: Vec = Vec::new(); + // NVIDIA: nvidia-smi ships with the driver + let smi = run_timeout(Command::new("nvidia-smi").args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10)); + match smi { + Some(out) => { + 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 worker_ok = bins.cuda.is_some(); + let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]); + if !worker_ok { + c.message = "CUDA worker not installed; built from source on first run (needs CUDA Toolkit and Visual Studio)".into(); + } + cards.push(c); + } + } + if cards.is_empty() { + notes.push("nvidia-smi ran but listed no card".into()); + } + } + None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), + } + // OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped) + if let Some(cl) = bins.opencl.as_ref() { + if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) { + let lines: Vec<&str> = out.lines().collect(); + for (i, line) in lines.iter().enumerate() { + let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim(); + if !t.starts_with('[') { + continue; + } + let Some(close) = t.find(']') else { continue }; + let idx = &t[1..close]; + let rest = &t[close + 1..]; + let name = rest.split(" |").next().unwrap_or("").trim(); + let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or(""); + let is_gpu = info.starts_with("GPU"); + let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim(); + if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") { + continue; + } + let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" }; + 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(); + cards.push(c); + } + } + } else if cards.is_empty() { + notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); + } + if cards.is_empty() { + // last resort: the names Windows knows, so the screen can at least say what is in the PC + if let Some(out) = run_timeout(Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) { + 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.enabled = false; + c.message = "seen by Windows, but no worker can drive it yet".into(); + cards.push(c); + } + } + } + cards +} + +/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle). +pub fn find_bins() -> Result { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let here = exe.parent().ok_or("no exe dir")?.to_path_buf(); + let mut candidates = vec![]; + if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") { + candidates.push(std::path::PathBuf::from(d)); + } + candidates.push(here.clone()); + candidates.push(here.join("bin")); + if let Some(c) = here.parent() { + candidates.push(c.join("Resources").join("bin")); + } + let ext = if cfg!(windows) { ".exe" } else { "" }; + for dir in &candidates { + let node = dir.join(format!("igneumd{ext}")); + let miner = dir.join(format!("igneum-miner{ext}")); + if node.exists() && miner.exists() { + let opt = |n: &str| { + 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() }); + } + } + 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(", "))) +} + +pub fn node_version(node: &Path) -> String { + run_timeout(Command::new(node).arg("--version"), None, Duration::from_secs(10)) + .and_then(|o| o.lines().next().map(|l| l.trim().to_string())) + .unwrap_or_default() +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs new file mode 100644 index 00000000..aa642340 --- /dev/null +++ b/app/igneum-app/src/engine.rs @@ -0,0 +1,1436 @@ +//! The supervisor: starts the node, waits until it is synced, starts one miner per card, reads every line they print +//! into the state the dashboard shows, restarts what crashes, and stops everything in order (miners first, then the +//! node) on quit. The command lines are the ones today's launchers use (packaging/mac/app/igneum-miner.sh and +//! proto-cuda/windows-app/igneum-common.ps1). + +use crate::config::{Packaged, Runtime, Settings}; +use crate::detect::Bins; +use crate::keys; +use crate::procs::{self, Line, Proc, Source}; +use crate::state::{CardState, Rings, State}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +const POW_EPOCH_BLOCKS: u64 = 3_600; + +pub enum Cmd { + Detect, + Detected(Vec, Vec), + Start, + Pause, + Resume, + RestartMiners(String), + CheckUpdate, + Updated(crate::state::UpdateState), + WorkerBuilt(usize, Result), + Quit, +} + +pub struct Shared { + pub token: String, + pub state: Mutex, + pub rings: Mutex, + pub settings: Mutex, + pub settings_path: PathBuf, + pub wallet_path: PathBuf, + pub runtime: Runtime, + pub packaged: Packaged, + cmd_tx: Mutex>, + pub started: Instant, + engine_log: Mutex>, +} + +impl Shared { + pub fn new(token: String, runtime: Runtime, packaged: Packaged, settings: Settings, cmd_tx: Sender, engine_log: Option) -> Shared { + let settings_path = runtime.app_dir.join("settings.json"); + let wallet_path = runtime.app_dir.join("wallet.json"); + let mut st = State { version: VERSION.into(), ..Default::default() }; + st.network = runtime.network.clone(); + st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) }; + st.host = runtime.host.clone(); + st.node_dir = runtime.node_dir.display().to_string(); + st.log_dir = runtime.log_dir.display().to_string(); + st.setup_done = settings.setup_done; + st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() }; + st.node.state = "stopped".into(); + st.mining.state = "idle".into(); + 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.live_page = packaged.live_page.clone(); + st.finality.message = "waiting for the miner".into(); + st.program.message = "waiting for the node".into(); + Shared { + token, + state: Mutex::new(st), + rings: Mutex::new(Rings::new()), + settings: Mutex::new(settings), + settings_path, + wallet_path, + runtime, + packaged, + cmd_tx: Mutex::new(cmd_tx), + started: Instant::now(), + engine_log: Mutex::new(engine_log), + } + } + + pub fn send(&self, c: Cmd) { + let _ = self.cmd_tx.lock().unwrap().send(c); + } + + pub fn log(&self, text: &str) { + let stamp = crate::platform::unix_now_f(); + if let Some(f) = self.engine_log.lock().unwrap().as_mut() { + let _ = writeln!(f, "{stamp:.0} {text}"); + } + self.rings.lock().unwrap().log("app", false, text); + } + + pub fn event(&self, kind: &str, text: &str) { + self.rings.lock().unwrap().event(kind, text); + self.log(&format!("[{kind}] {text}")); + } + + pub fn state_json(&self) -> Value { + let mut st = self.state.lock().unwrap().clone(); + st.now = crate::platform::unix_now_f(); + st.uptime_s = self.started.elapsed().as_secs(); + st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); + serde_json::to_value(st).unwrap_or(json!({})) + } + + /// A short state for the window host (menu bar, tray). + pub fn wrapper_state(&self) -> Value { + let st = self.state.lock().unwrap(); + json!({ + "phase": st.phase, "mining": st.mining.state, "paused": st.mining.paused, + "hash_total": st.mining.hash_total, "accepted_total": st.mining.accepted_total, + "node": st.node.state, "blocks": st.node.blocks, "peers": st.node.peers, "quitting": st.quitting, + "update": st.update.available, + }) + } + + fn save_settings(&self) { + self.settings.lock().unwrap().save(&self.settings_path); + } + + /// First-run setup: the payout address (a generated key, or one the user pasted) and the identity count. + pub fn setup(&self, mode: &str, address: Option<&str>, identities: Option) -> Result { + let mut out = json!({ "ok": true }); + { + let mut s = self.settings.lock().unwrap(); + if let Some(n) = identities { + s.identities = n; + } + match mode { + "paste" => { + let a = address.ok_or("address missing")?.trim().to_string(); + if !keys::valid_address(&a) { + return Err("an address is 0x followed by 40 hex characters".into()); + } + s.address = a.to_ascii_lowercase(); + s.address_source = "pasted".into(); + s.key_saved = true; + } + _ => { + let wallet = match keys::load(&self.wallet_path) { + Some(w) => w, + None => { + let w = keys::generate(); + keys::save(&self.wallet_path, &w).map_err(|e| format!("could not write the wallet file: {e}"))?; + w + } + }; + s.address = wallet.address.clone(); + s.address_source = "generated".into(); + s.key_saved = false; + out["private_key"] = json!(wallet.private_key); + } + } + s.save(&self.settings_path); + out["address"] = json!(s.address); + out["display"] = json!(keys::checksum(&s.address)); + out["source"] = json!(s.address_source); + out["wallet_file"] = json!(self.wallet_path.display().to_string()); + let mut st = self.state.lock().unwrap(); + st.address = address_state(&s, &self.wallet_path); + st.settings.identities = s.identities; + } + self.event("ok", &format!("rewards go to {}", self.state.lock().unwrap().address.display)); + Ok(out) + } + + pub fn key_saved(&self) { + self.settings.lock().unwrap().key_saved = true; + self.save_settings(); + self.state.lock().unwrap().address.key_saved = true; + } + + pub fn reveal_key(&self) -> Result { + let w = keys::load(&self.wallet_path).ok_or("no wallet file: the address was pasted, not made here")?; + self.log("the private key was shown in the window (settings > reveal)"); + Ok(json!({ "ok": true, "address": w.address, "display": keys::checksum(&w.address), "private_key": w.private_key, "wallet_file": self.wallet_path.display().to_string() })) + } + + pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>) -> Result { + let mut restart = Vec::new(); + { + let mut s = self.settings.lock().unwrap(); + if let Some(n) = identities { + if n != s.identities { + s.identities = n; + restart.push(format!("{n} identities")); + } + } + if let Some(v) = vote { + if v != s.vote { + s.vote = v; + restart.push(if v { "voting on".into() } else { "voting off".into() }); + } + } + if let Some(a) = address { + let a = a.trim().to_ascii_lowercase(); + if !a.is_empty() && a != s.address { + if !keys::valid_address(&a) { + return Err("an address is 0x followed by 40 hex characters".into()); + } + let generated = keys::load(&self.wallet_path).map(|w| w.address == a).unwrap_or(false); + s.address = a.clone(); + s.address_source = if generated { "generated".into() } else { "pasted".into() }; + s.key_saved = true; + restart.push(format!("rewards to {}", keys::checksum(&a))); + } + } + s.save(&self.settings_path); + let mut st = self.state.lock().unwrap(); + st.settings.identities = s.identities; + st.settings.vote = s.vote; + st.address = address_state(&s, &self.wallet_path); + } + if let Some(on) = login { + crate::platform::set_start_at_login(on)?; + self.state.lock().unwrap().settings.start_at_login = crate::platform::start_at_login_is_on(); + self.event("info", if on { "starts at login from now on" } else { "no longer starts at login" }); + } + if !restart.is_empty() { + self.send(Cmd::RestartMiners(restart.join(", "))); + } + Ok(json!({ "ok": true, "restart": !restart.is_empty() })) + } +} + +fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::AddressState { + crate::state::AddressState { + value: s.address.clone(), + display: if s.address.is_empty() { String::new() } else { keys::checksum(&s.address) }, + source: s.address_source.clone(), + key_saved: s.key_saved, + wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() }, + } +} + +/// One value out of `key=value` tokens of a log line. +fn kv<'a>(line: &'a str, key: &str) -> Option<&'a str> { + let pat = format!(" {key}="); + let i = line.find(&pat)? + pat.len(); + let rest = &line[i..]; + let end = rest.find(|c: char| c == ' ' || c == ',' || c == ')' || c == ';').unwrap_or(rest.len()); + Some(&rest[..end]) +} + +fn kv_u64(line: &str, key: &str) -> Option { + kv(line, key)?.parse().ok() +} + +fn kv_f64(line: &str, key: &str) -> Option { + kv(line, key)?.trim_end_matches('s').parse().ok() +} + +fn fmt_uptime(s: u64) -> String { + format!("{:02}:{:02}:{:02}", s / 3600, s % 3600 / 60, s % 60) +} + +fn port_open(port: u16) -> bool { + std::net::TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_millis(800)).is_ok() +} + +fn jitter_secs(seed: u64) -> u64 { + // 5 to 60 s, from the clock: enough spread for a restart, no rng needed + 5 + (seed.wrapping_mul(2654435761) >> 7) % 56 +} + +struct MinerSlot { + card: usize, + label: String, + proc: Option, + worker: PathBuf, + restart_at: Option, + starts: u32, + restarts: u32, + building: bool, + needs_rebuild: bool, + last_status: Option, + error_at: Option, +} + +pub struct Engine { + shared: Arc, + bins: Bins, + rx: Receiver, + lines_rx: Receiver, + lines_tx: Sender, + node: Option, + node_external: bool, + node_restart_at: Option, + node_started_at: Instant, + node_starts: u32, + node_restarts: u32, + node_log: Option, + node_last_reading: Option, + sync_prev: Option, + sync_stable_since: Option, + last_sync_check: Instant, + no_peer_warned: bool, + watch: Option, + watch_retry_at: Instant, + miners: Vec, + running: bool, + quitting: bool, + keep_awake: Option, + last_awake: Instant, + last_status: Instant, + last_upload: Instant, + upload_thread: Option>, + update_next: Instant, + stamp: String, + label_base: String, + wrapper: bool, + last_state_print: Instant, + detected: bool, + last_settings_save: Instant, + last_error_event: Instant, +} + +impl Engine { + pub fn new(shared: Arc, bins: Bins, rx: Receiver, wrapper: bool) -> Engine { + let (lines_tx, lines_rx) = channel(); + let now = Instant::now(); + let stamp = { + let t = crate::platform::unix_now(); + // yyyymmdd-hhmmss in UTC, without a date crate + let days = t / 86400; + let (y, m, d) = civil_from_days(days as i64); + format!("{y:04}{m:02}{d:02}-{:02}{:02}{:02}", t % 86400 / 3600, t % 3600 / 60, t % 60) + }; + let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.host); + Engine { + shared, + bins, + rx, + lines_rx, + lines_tx, + node: None, + node_external: false, + node_restart_at: None, + node_started_at: now, + node_starts: 0, + node_restarts: 0, + node_log: None, + node_last_reading: None, + sync_prev: None, + sync_stable_since: None, + last_sync_check: now, + no_peer_warned: false, + watch: None, + watch_retry_at: now, + miners: Vec::new(), + running: false, + quitting: false, + keep_awake: None, + last_awake: now, + last_status: now, + last_upload: now, + upload_thread: None, + update_next: now + Duration::from_secs(20), + stamp, + label_base, + wrapper, + last_state_print: now, + detected: false, + last_settings_save: now, + last_error_event: now - Duration::from_secs(600), + } + } + + fn st(&self) -> std::sync::MutexGuard<'_, State> { + self.shared.state.lock().unwrap() + } + + pub fn run(mut self) { + let setup_done = self.shared.settings.lock().unwrap().setup_done; + self.shared.log(&format!( + "Igneum Miner {} on {}, {} (run {}-{})", + VERSION, + self.shared.runtime.host, + self.st().chain, + self.label_base, + self.stamp + )); + self.shared.log("Nobody from Igneum will ever ask for your seed. Nothing is bought or sold on this network."); + self.shared.log(&format!( + "node: RPC 127.0.0.1:{}, p2p 0.0.0.0:{}, peers {}, data {}", + self.shared.runtime.rpc_port, + self.shared.runtime.p2p_port, + if self.shared.runtime.peers.is_empty() { "none".to_string() } else { self.shared.runtime.peers.join(" ") }, + self.shared.runtime.node_dir.display() + )); + 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())); + self.shared.send(Cmd::Detect); + if setup_done { + self.shared.send(Cmd::Start); + } + loop { + while let Ok(c) = self.rx.try_recv() { + self.command(c); + } + while let Ok(l) = self.lines_rx.try_recv() { + self.line(l); + } + if self.quitting { + self.shutdown(); + return; + } + self.tick(); + std::thread::sleep(Duration::from_millis(250)); + } + } + + fn command(&mut self, c: Cmd) { + match c { + Cmd::Detect => { + if self.detected { + return; + } + self.st().detecting = true; + let bins = self.bins.clone(); + let shared = self.shared.clone(); + std::thread::spawn(move || { + let mut notes = Vec::new(); + let cards = crate::detect::detect(&bins, &mut notes); + shared.send(Cmd::Detected(cards, notes)); + }); + } + Cmd::Detected(cards, notes) => { + self.detected = true; + let names: Vec = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect(); + self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") })); + for n in ¬es { + self.shared.log(&format!("detection: {n}")); + } + 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; + } + drop(st); + if self.running { + self.plan_miners(); + } + } + Cmd::Start => self.start(), + Cmd::Pause => { + if !self.running { + return; + } + self.shared.settings.lock().unwrap().paused = true; + self.shared.save_settings(); + self.st().mining.paused = true; + self.stop_miners("paused"); + self.shared.event("info", "mining paused; the node keeps running"); + } + Cmd::Resume => { + self.shared.settings.lock().unwrap().paused = false; + self.shared.save_settings(); + self.st().mining.paused = false; + self.shared.event("ok", "mining resumed"); + if !self.running { + self.start(); + } + } + 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()); + } + } + 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)); + } + self.st().update = u; + } + Cmd::WorkerBuilt(card, result) => { + if let Some(m) = self.miners.iter_mut().find(|m| m.card == card) { + m.building = false; + match result { + Ok(p) => { + m.worker = p; + m.needs_rebuild = false; + 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}")); + m.restart_at = Some(Instant::now() + Duration::from_secs(300)); + if let Some(c) = self.st().mining.cards.get_mut(card) { + c.state = "failed".into(); + c.message = e; + } + } + } + } + } + Cmd::Quit => { + self.quitting = true; + self.st().quitting = true; + } + } + } + + // ---- start, node ------------------------------------------------------------------------------------------ + + fn start(&mut self) { + if self.running { + return; + } + self.running = true; + { + let mut s = self.shared.settings.lock().unwrap(); + s.setup_done = true; + s.save(&self.shared.settings_path); + } + { + let mut st = self.st(); + st.setup_done = true; + st.phase = "dashboard".into(); + st.mining.state = if st.mining.paused { "paused".into() } else { "waiting".into() }; + } + if self.keep_awake.is_none() { + self.keep_awake = Some(crate::platform::KeepAwake::start()); + self.shared.log("keeping the machine awake while the app runs"); + } + let _ = std::fs::create_dir_all(&self.shared.runtime.node_dir); + let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir); + 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)); + let mut st = self.st(); + st.node.state = "syncing".into(); + st.node.message = "external node".into(); + } else { + self.start_node(); + } + self.plan_miners(); + } + + fn node_args(&self) -> Vec { + let r = &self.shared.runtime; + let mut a = vec![ + format!("--{}", r.network), + format!("--appdir={}", r.node_dir.display()), + format!("--rpclisten=127.0.0.1:{}", r.rpc_port), + format!("--listen=0.0.0.0:{}", r.p2p_port), + ]; + if let Some(n) = r.devnet_suffix { + a.push(format!("--devnet-suffix={n}")); + } + for p in &r.peers { + a.push(format!("--addpeer={p}")); + } + a.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string())); + if r.unsynced_mining { + a.push("--enable-unsynced-mining".into()); + } + a + } + + fn start_node(&mut self) { + self.node_starts += 1; + let seg = if self.node_starts > 1 { format!("-r{}", self.node_starts) } else { String::new() }; + let log = self.shared.runtime.log_dir.join(format!("node-{}{seg}.log", self.stamp)); + let args = self.node_args(); + match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx) { + Ok(p) => { + self.shared.log(&format!("igneumd started (pid {}): {}", p.pid(), p.cmdline)); + let mut st = self.st(); + st.node.pid = p.pid(); + st.node.state = "starting".into(); + st.node.starts = self.node_starts; + st.node.message = "opening the database".into(); + st.node.restart_in_s = 0; + drop(st); + self.node = Some(p); + self.node_log = Some(log); + self.node_started_at = Instant::now(); + self.node_restart_at = None; + self.sync_prev = None; + self.sync_stable_since = None; + self.node_last_reading = None; + self.shared.event(if self.node_starts == 1 { "ok" } else { "info" }, if self.node_starts == 1 { "node started" } else { "node restarted" }); + } + Err(e) => { + self.shared.event("error", &format!("igneumd could not start: {e}")); + let mut st = self.st(); + st.node.state = "failed".into(); + st.node.message = e.to_string(); + } + } + } + + fn stop_node(&mut self) { + if let Some(mut n) = self.node.take() { + self.shared.log("stopping the node"); + let code = n.stop(30); + self.shared.log(&format!("node stopped (exit {code:?})")); + } + self.st().node.state = "stopped".into(); + } + + fn start_watch(&mut self) { + let args = vec!["watch".to_string(), "1000000000".to_string(), self.shared.runtime.rpc_url()]; + let log = self.shared.runtime.log_dir.join(format!("watch-{}.log", self.stamp)); + if let Ok(p) = procs::spawn(Source::Watch, &self.bins.miner, &args, None, &log, &self.lines_tx) { + self.watch = Some(p); + } + self.watch_retry_at = Instant::now() + Duration::from_secs(4); + } + + // ---- miners ------------------------------------------------------------------------------------------------ + + /// One slot per enabled card. Called once the cards are known; re-planned after a settings change. + fn plan_miners(&mut self) { + let cards = self.st().mining.cards.clone(); + let mut per_vendor = std::collections::HashMap::::new(); + for c in cards.iter().filter(|c| c.enabled) { + *per_vendor.entry(c.vendor.clone()).or_default() += 1; + } + let mut seen = std::collections::HashMap::::new(); + for c in cards.iter() { + if !c.enabled || self.miners.iter().any(|m| m.card == c.index) { + continue; + } + let n = seen.entry(c.vendor.clone()).or_default(); + *n += 1; + let prefix = match c.vendor.as_str() { + "apple" => "mac".to_string(), + v => v.to_string(), + }; + let label = if per_vendor.get(&c.vendor).copied().unwrap_or(1) > 1 { format!("{prefix}-{}-{n}", self.shared.runtime.host) } else { format!("{prefix}-{}", self.shared.runtime.host) }; + let worker = match c.worker.as_str() { + "Metal" => self.bins.metal.clone(), + "CUDA" => self.bins.cuda.clone(), + _ => self.bins.opencl.clone(), + }; + let needs_rebuild = worker.is_none(); + self.miners.push(MinerSlot { + card: c.index, + label, + proc: None, + worker: worker.unwrap_or_default(), + restart_at: Some(Instant::now()), + starts: 0, + restarts: 0, + building: false, + needs_rebuild, + last_status: None, + error_at: None, + }); + } + if self.miners.is_empty() { + let mut st = self.st(); + if !cards.is_empty() || self.detected { + st.mining.state = "idle".into(); + } + } + } + + fn miner_args(&self, slot: &MinerSlot, card: &CardState) -> Vec { + let s = self.shared.settings.lock().unwrap(); + let r = &self.shared.runtime; + let mut a = vec![ + "mine".to_string(), + r.rpc_url(), + "1".into(), + "100000000".into(), + slot.label.clone(), + "--worker".into(), + slot.worker.display().to_string(), + "--status-secs".into(), + r.status_secs.to_string(), + "--exit-on-seed-change".into(), + "--evm-address".into(), + s.address.clone(), + ]; + if s.identities > 1 { + a.push("--identities".into()); + a.push(s.identities.to_string()); + } + if !s.vote { + a.push("--no-vote".into()); + } + a.push("--payout-label".into()); + a.push(slot.label.clone()); + if r.network != "devnet" { + a.push("--network".into()); + a.push(r.network.clone()); + } + if card.worker != "Metal" { + // ahead-of-time workers build the next program from the pack the miner writes here + a.push("--prepare-packs".into()); + a.push(r.app_dir.join("packs").join("prepare").display().to_string()); + if card.worker == "OpenCL" { + a.push("--job-nonces".into()); + a.push("2097152".into()); + } + if !card.device.is_empty() { + a.push("--worker-args".into()); + a.push(format!("--device {}", card.device)); + } + } + a + } + + fn start_miner(&mut self, i: usize) { + let card_idx = self.miners[i].card; + let Some(card) = self.st().mining.cards.get(card_idx).cloned() else { return }; + if self.shared.settings.lock().unwrap().address.is_empty() { + self.shared.event("error", "no payout address; open settings and set one"); + self.miners[i].restart_at = Some(Instant::now() + Duration::from_secs(60)); + return; + } + let args = self.miner_args(&self.miners[i], &card); + 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()); + 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)); + let mut st = self.st(); + if let Some(c) = st.mining.cards.get_mut(card_idx) { + c.pid = p.pid(); + c.state = "starting".into(); + c.ready = false; + c.prepared = false; + c.restart_in_s = 0; + c.message = "worker loading the program".into(); + c.ids.clear(); + } + st.mining.state = if st.mining.state == "paused" { st.mining.state.clone() } else { "mining".into() }; + drop(st); + self.miners[i].proc = Some(p); + self.miners[i].restart_at = None; + self.miners[i].last_status = None; + } + Err(e) => { + self.shared.event("error", &format!("the miner could not start: {e}")); + self.miners[i].restart_at = Some(Instant::now() + Duration::from_secs(30)); + } + } + } + + fn stop_miners(&mut self, why: &str) { + let any = self.miners.iter().any(|m| m.proc.is_some()); + if any { + self.shared.log(&format!("stopping the miners ({why})")); + } + for m in self.miners.iter_mut() { + if let Some(mut p) = m.proc.take() { + p.write_stdin("quit\n"); + p.stop(8); + } + m.restart_at = None; + } + let mut st = self.st(); + let paused = st.mining.paused; + for c in st.mining.cards.iter_mut() { + if c.enabled { + c.state = if paused { "off".into() } else { "waiting".into() }; + c.hash_now = 0.0; + c.pid = 0; + } + } + st.mining.hash_total = 0.0; + 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) { + let card_idx = self.miners[i].card; + self.miners[i].building = true; + 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(); + } + 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(); + std::thread::spawn(move || { + let r = build_worker_from_source(&shared, &bins, &vendor); + shared.send(Cmd::WorkerBuilt(card_idx, r)); + }); + } + + // ---- the tick ---------------------------------------------------------------------------------------------- + + fn tick(&mut self) { + let now = Instant::now(); + if now.duration_since(self.last_awake) >= Duration::from_secs(60) { + self.last_awake = now; + crate::platform::keep_awake_tick(); + } + if self.running { + self.tick_node(now); + self.tick_watch(now); + self.tick_miners(now); + } + self.derive(now); + if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) { + self.last_status = now; + if self.running { + self.status_line(); + } + } + if now.duration_since(self.last_upload) >= Duration::from_secs(60) { + self.last_upload = now; + self.upload_logs(false); + } + if now >= self.update_next { + self.command(Cmd::CheckUpdate); + } + if self.wrapper && now.duration_since(self.last_state_print) >= Duration::from_secs(3) { + self.last_state_print = now; + println!("STATE {}", self.shared.wrapper_state()); + let _ = std::io::stdout().flush(); + } + if now.duration_since(self.last_settings_save) >= Duration::from_secs(120) { + self.last_settings_save = now; + self.shared.save_settings(); + } + } + + 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) { + self.last_sync_check = now; + let mut st = self.st(); + st.node.state = "stopped".into(); + st.node.message = "the external node went away".into(); + } + return; + } + if let Some(n) = self.node.as_mut() { + if !n.alive() { + let code = n.exit_code.unwrap_or(-1); + let ran = n.started.elapsed().as_secs(); + let tail = tail_of(&n.log_path, 5); + self.node = None; + if ran < 5 && self.node_starts == 1 { + // out at once: a port in use or an older database; say so and try once more, later + self.shared.event("error", &format!("igneumd exited at once (code {code}). {}", tail.last().cloned().unwrap_or_default())); + } else { + self.shared.event("error", &format!("igneumd exited with code {code} after {ran} s; restarting")); + } + for l in &tail { + self.shared.log(&format!(" node: {l}")); + } + self.node_restarts += 1; + let delay = jitter_secs(crate::platform::unix_now()); + self.node_restart_at = Some(now + Duration::from_secs(delay)); + let mut st = self.st(); + st.node.state = "restarting".into(); + st.node.synced = false; + st.node.restarts = self.node_restarts; + st.node.message = format!("restart in {delay} s"); + drop(st); + let any = self.miners.iter().any(|m| m.proc.is_some()); + if any { + self.shared.log("stopping the miners until the node is back and synced (their connection died with it)"); + self.stop_miners("node restart"); + } + } + } + if let Some(at) = self.node_restart_at { + if now >= at { + self.start_node(); + } else { + self.st().node.restart_in_s = (at - now).as_secs(); + } + } + } + + fn tick_watch(&mut self, now: Instant) { + let node_up = self.node_external || self.node.is_some(); + if let Some(w) = self.watch.as_mut() { + if !w.alive() { + self.watch = None; + self.watch_retry_at = now + Duration::from_secs(3); + } + } + if self.watch.is_none() && node_up && now >= self.watch_retry_at { + self.start_watch(); + } + // no reading in a while: the node is away + if let Some(t) = self.node_last_reading { + if now.duration_since(t) > Duration::from_secs(45) { + let mut st = self.st(); + if st.node.state == "synced" { + st.node.state = "syncing".into(); + st.node.synced = false; + st.node.message = "no reading from the node for 45 s".into(); + } + } + } else if node_up && !self.node_external && self.node_started_at.elapsed() > Duration::from_secs(60) { + let mut st = self.st(); + if st.node.state == "starting" { + st.node.message = "no answer from the RPC yet (still opening its database?)".into(); + } + } + } + + fn tick_miners(&mut self, now: Instant) { + let synced = self.st().node.synced; + let paused = self.st().mining.paused; + for i in 0..self.miners.len() { + let card_idx = self.miners[i].card; + if let Some(p) = self.miners[i].proc.as_mut() { + if !p.alive() { + let code = p.exit_code.unwrap_or(-1); + let tail = tail_of(&p.log_path, 3); + self.miners[i].proc = None; + if code == 42 { + self.shared.event("build", "the hourly program changed; the worker restarts on the new one"); + if self.bins_worker_missing(card_idx) { + self.miners[i].needs_rebuild = true; + } + self.miners[i].restart_at = Some(now); + } else { + self.miners[i].restarts += 1; + let delay = jitter_secs(crate::platform::unix_now() + i as u64); + self.miners[i].restart_at = Some(now + Duration::from_secs(delay)); + self.shared.event("error", &format!("miner {} exited with code {code}; restarting in {delay} s", self.miners[i].label)); + for l in &tail { + self.shared.log(&format!(" miner: {l}")); + } + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.state = "restarting".into(); + c.restarts = self.miners[i].restarts; + c.hash_now = 0.0; + c.message = tail.last().cloned().unwrap_or_default(); + } + } + } else if let Some(t) = self.miners[i].last_status { + if now.duration_since(t) > Duration::from_secs(self.shared.runtime.status_secs as u64 * 4) { + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + if c.state == "mining" { + c.message = "no status from the miner for a while".into(); + } + } + } + } + continue; + } + if paused || !synced { + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + if !paused && c.state != "failed" { + c.state = "waiting".into(); + c.message = "waiting for the node to sync".into(); + } + } + continue; + } + if self.miners[i].building { + continue; + } + if let Some(at) = self.miners[i].restart_at { + if now >= at { + if self.miners[i].needs_rebuild { + self.build_worker(i); + } else { + self.start_miner(i); + } + } else if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.restart_in_s = (at - now).as_secs(); + } + } + } + } + + fn bins_worker_missing(&self, card_idx: usize) -> bool { + let st = self.st(); + match st.mining.cards.get(card_idx).map(|c| c.worker.as_str()) { + Some("Metal") => self.bins.metal.is_none(), + Some("CUDA") => self.bins.cuda.is_none(), + Some(_) => self.bins.opencl.is_none(), + None => false, + } + } + + /// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word. + fn derive(&mut self, now: Instant) { + let unix = crate::platform::unix_now_f(); + let mut st = self.st(); + st.node.last_reading_age_s = self.node_last_reading.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(-1.0); + let mut total = 0.0; + for c in st.mining.cards.iter() { + if c.state == "mining" { + total += c.hash_now; + } + } + st.mining.hash_total = total; + let cut = unix - 3600.0; + st.mining.found.retain(|t| *t > cut); + if st.node.daa > 0 { + let computed = (st.node.daa / POW_EPOCH_BLOCKS + 1) * POW_EPOCH_BLOCKS; + let boundary = if st.program.boundary_daa > st.node.daa { st.program.boundary_daa } else { computed }; + st.program.boundary_daa = boundary; + st.program.eta_s = boundary as i64 - st.node.daa as i64; + st.program.epoch_index = st.node.daa / POW_EPOCH_BLOCKS; + st.program.message = if st.program.prepared { + "next program compiled and ready".into() + } else if st.program.prepare_sent { + "next program compiling".into() + } else { + "next program not known yet".into() + }; + } + if st.finality.last_lock > 0 { + st.finality.age_s = unix - st.finality.last_lock_at; + st.finality.message = String::new(); + } + if self.running { + let mining_now = st.mining.cards.iter().any(|c| c.state == "mining"); + let any_slot = !self.miners.is_empty(); + st.mining.state = if st.mining.paused { + "paused".into() + } else if mining_now { + "mining".into() + } else if !any_slot { + if self.detected { "idle".into() } else { "waiting".into() } + } else { + "waiting".into() + }; + } + } + + // ---- lines ------------------------------------------------------------------------------------------------- + + fn line(&mut self, l: Line) { + let tag = l.src.tag(); + self.shared.rings.lock().unwrap().log(&tag, l.stderr, &l.text); + match l.src { + Source::Watch => { + if l.text.contains(" node1 blocks=") { + self.watch_line(&l.text); + } + } + Source::Miner(card) => self.miner_line(card, l.stderr, &l.text), + Source::Node => {} + Source::Engine => {} + } + } + + fn watch_line(&mut self, text: &str) { + let now = Instant::now(); + self.node_last_reading = Some(now); + let blocks = kv_u64(text, "blocks").unwrap_or(0); + let headers = kv_u64(text, "headers").unwrap_or(0); + let daa = kv_u64(text, "daa").unwrap_or(0); + let peers = kv_u64(text, "peers").unwrap_or(0); + let tips = kv_u64(text, "tips").unwrap_or(0); + let blue = kv_u64(text, "blue").unwrap_or(0); + let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0); + let flag = kv(text, "synced").map(|s| s.to_string()); + let was_synced = self.st().node.synced; + // the rule of today's launchers: the node's own flag when it carries one, else peers > 0, blocks caught up + // with the headers and the count moving (or stable for 60 s); a private test node with unsynced mining + // counts as synced once it answers + let synced = if self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty() { + true // a private test node answering its RPC (genesis alone reads as 0 blocks) + } else if let Some(f) = flag.as_deref() { + f == "true" && peers > 0 + } else if peers > 0 && blocks >= headers && blocks > 1 { + let stable = self.sync_stable_since.get_or_insert(now); + (self.sync_prev.map(|p| blocks > p).unwrap_or(false)) || now.duration_since(*stable) >= Duration::from_secs(60) + } else { + self.sync_stable_since = None; + false + }; + self.sync_prev = Some(blocks); + { + let mut st = self.st(); + st.node.blocks = blocks; + st.node.headers = headers; + st.node.daa = daa; + st.node.peers = peers; + st.node.tips = tips; + st.node.blue = blue; + st.node.difficulty = difficulty; + st.node.synced = synced; + st.node.state = if synced { "synced".into() } else { "syncing".into() }; + st.node.message = if synced { + String::new() + } else if peers == 0 { + "waiting for a peer".into() + } else { + format!("{blocks} of {headers} blocks") + }; + } + if synced && !was_synced { + self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)")); + for m in self.miners.iter_mut() { + if m.proc.is_none() && m.restart_at.is_none() { + m.restart_at = Some(now); + } + } + } + if !synced && !self.no_peer_warned && peers == 0 && self.node_started_at.elapsed() >= Duration::from_secs(90) && !self.shared.runtime.peers.is_empty() { + self.no_peer_warned = true; + self.shared.event("error", &format!("no peer after 90 s: is the seed node reachable ({})? The node keeps retrying", self.shared.runtime.peers.join(", "))); + } + } + + fn miner_line(&mut self, card: usize, stderr: bool, text: &str) { + let Some(i) = self.miners.iter().position(|m| m.card == card) else { return }; + let label = self.miners[i].label.clone(); + let card_name = self.st().mining.cards.get(card).map(|c| c.name.clone()).unwrap_or_else(|| label.clone()); + if stderr { + if text.contains(" rejected nonce=") { + let mut st = self.st(); + st.mining.rejected_session += 1; + if let Some(c) = st.mining.cards.get_mut(card) { + c.rejected += 1; + } + } else if text.starts_with("template error") || text.contains(" template error") { + if let Some(c) = self.st().mining.cards.get_mut(card) { + c.message = "the node is not answering; the miner retries".into(); + } + } else if text.contains("WORKER MISMATCH") || text.contains("worker error") || text.contains("worker exited") || text.contains("panicked") || text.contains("CUDA error") || text.contains("submit error") { + let now = Instant::now(); + if now.duration_since(self.last_error_event) >= Duration::from_secs(30) { + self.last_error_event = now; + self.shared.event("error", &format!("{card_name}: {}", short(text, 160))); + } + self.miners[i].error_at = Some(now); + if let Some(c) = self.st().mining.cards.get_mut(card) { + c.message = short(text, 160); + } + } + return; + } + if text.contains(" ACCEPTED block") { + let unix = crate::platform::unix_now_f(); + let total = { + let mut s = self.shared.settings.lock().unwrap(); + s.accepted_total += 1; + s.accepted_total + }; + let mut st = self.st(); + st.mining.accepted_session += 1; + st.mining.accepted_total = total; + st.mining.found.push(unix); + if let Some(c) = st.mining.cards.get_mut(card) { + c.accepted += 1; + } + drop(st); + self.shared.event("block", &format!("block accepted by the node ({card_name})")); + } else if text.contains(" STATUS '") { + self.miners[i].last_status = Some(Instant::now()); + let avg = kv_f64(text, "hash").unwrap_or(0.0); + // the v4 miner's interval rate: "now=12.34 MH/s wall (...)"; older miners: the average + let now_rate = kv_f64(text, "now").unwrap_or(avg); + let mut st = self.st(); + if let Some(c) = st.mining.cards.get_mut(card) { + c.hash_avg = avg; + c.hash_now = now_rate; + c.template_age_s = kv_f64(text, "template_age").unwrap_or(0.0); + c.synced = kv(text, "synced") == Some("true"); + c.last_status_age_s = 0.0; + if c.state == "starting" || c.state == "ready" { + c.state = "mining".into(); + } + if c.state == "mining" { + c.message = String::new(); + } + } + } else if text.contains(" worker: ready ") { + let prepare = text.contains(" prepare 1"); + let mut st = self.st(); + if let Some(c) = st.mining.cards.get_mut(card) { + c.ready = true; + c.prepare = prepare; + c.state = "mining".into(); + c.message = String::new(); + } + drop(st); + if self.miners[i].starts == 1 { + self.shared.event("ok", &format!("{card_name} worker ready{}", if prepare { ", hot swap at the hour boundary" } else { ", restart at the hour boundary" })); + } + } else if text.contains(" worker: prepared ") { + let mut st = self.st(); + st.program.prepared = true; + if let Some(c) = st.mining.cards.get_mut(card) { + c.prepared = true; + } + drop(st); + self.shared.event("build", "next hourly program compiled and ready"); + } else if text.contains(" PREPARE sent for ") { + let mut st = self.st(); + st.program.prepare_sent = true; + st.program.prepared = false; + if let Some(b) = text.split("boundary at ").nth(1).and_then(|r| r.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse::().ok()) { + st.program.boundary_daa = b; + } + } else if text.contains(" SEED CHANGE at daa ") { + let mut st = self.st(); + st.program.prepared = false; + st.program.prepare_sent = false; + st.program.boundary_daa = 0; + for c in st.mining.cards.iter_mut() { + c.prepared = false; + } + drop(st); + let detail = text.rsplit("): ").next().unwrap_or("").to_string(); + self.shared.event("build", &format!("hourly program changed: {}", if detail.is_empty() { "new program".to_string() } else { detail })); + } else if text.contains(" vote_key_hash=") { + if let Some(h) = kv(text, "vote_key_hash") { + let short_id: String = h.chars().take(8).collect(); + let mut st = self.st(); + if let Some(c) = st.mining.cards.get_mut(card) { + if !c.ids.contains(&short_id) { + c.ids.push(short_id); + } + } + } + } else if text.contains(" LOCK checkpoint ") { + if let Some(n) = text.split(" LOCK checkpoint ").nth(1).and_then(|r| r.split_whitespace().next()).and_then(|n| n.parse::().ok()) { + let mut st = self.st(); + if n > st.finality.last_lock { + st.finality.last_lock = n; + st.finality.last_lock_at = crate::platform::unix_now_f(); + st.finality.age_s = 0.0; + } + } + } else if text.contains(" VOTE index=") { + self.st().finality.votes += 1; + } else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") { + self.st().program.prepared = false; + self.shared.event("error", &format!("{card_name}: the worker could not compile the next program; it compiles at the boundary")); + } else if text.contains("exiting with code 42") { + // expected; the exit handler restarts the miner + } + } + + // ---- status, uploads, shutdown --------------------------------------------------------------------------- + + fn status_line(&mut self) { + let st = self.st(); + let node_txt = match st.node.state.as_str() { + "synced" | "syncing" => format!("node {} blocks, {} peers, {}", st.node.blocks, st.node.peers, st.node.state), + s => format!("node {s}"), + }; + let miner_txt = if st.mining.cards.is_empty() { + "no GPU miner".to_string() + } else { + format!("accepted {} blocks ({} this run), {:.2} MH/s, {}", st.mining.accepted_total, st.mining.accepted_session, st.mining.hash_total, st.mining.state) + }; + let up = fmt_uptime(self.shared.started.elapsed().as_secs()); + drop(st); + self.shared.log(&format!("status: {miner_txt} | {node_txt} | up {up}")); + } + + fn upload_logs(&mut self, wait: bool) { + let p = &self.shared.packaged; + if p.log_intake_url.is_empty() || p.log_intake_key.is_empty() || !self.running { + return; + } + if let Some(t) = self.upload_thread.take() { + if t.is_finished() || wait { + let _ = t.join(); + } else { + self.upload_thread = Some(t); + return; + } + } + let mut files: Vec<(String, PathBuf)> = Vec::new(); + files.push((self.label_base.clone(), self.shared.runtime.log_dir.join(format!("app-{}.log", self.stamp)))); + if let Some(n) = &self.node_log { + files.push((format!("nodelog-{}", self.label_base), n.clone())); + } + for m in &self.miners { + if let Some(pr) = &m.proc { + files.push((format!("miner-{}", m.label), pr.log_path.clone())); + } + } + let (url, key, machine, run_id) = (p.log_intake_url.clone(), p.log_intake_key.clone(), self.shared.runtime.host.clone(), format!("{}-{}", self.label_base, self.stamp)); + let t = std::thread::spawn(move || { + for (label, path) in files { + crate::update::upload_log(&url, &key, &label, &machine, &run_id, &path); + } + }); + if wait { + let _ = t.join(); + } else { + self.upload_thread = Some(t); + } + } + + fn shutdown(&mut self) { + self.shared.log("quit: stopping the miners, then the node"); + self.stop_miners("quit"); + if let Some(mut w) = self.watch.take() { + w.stop(2); + } + self.stop_node(); + if let Some(mut k) = self.keep_awake.take() { + k.stop(); + } + let st = self.st(); + let summary = format!( + "SUMMARY after {}: node started {} time(s), restarts {}, {} accepted blocks this run ({} lifetime); data stays in {}", + fmt_uptime(self.shared.started.elapsed().as_secs()), + self.node_starts, + self.node_restarts, + st.mining.accepted_session, + st.mining.accepted_total, + self.shared.runtime.node_dir.display() + ); + drop(st); + self.shared.log(&summary); + self.shared.save_settings(); + self.upload_logs(true); + self.shared.log("stopped"); + if self.wrapper { + println!("STATE {}", json!({ "phase": "quit", "quitting": true })); + println!("EXIT"); + let _ = std::io::stdout().flush(); + } + } +} + +fn short(s: &str, n: usize) -> String { + if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::()) } +} + +fn tail_of(path: &std::path::Path, n: usize) -> Vec { + let Ok(text) = std::fs::read_to_string(path) else { return vec![] }; + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + lines.iter().rev().take(n).rev().map(|s| s.to_string()).collect() +} + +/// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// 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)] +fn build_worker_from_source(shared: &Arc, bins: &Bins, vendor: &str) -> Result { + #[cfg(not(windows))] + { + Err("no GPU worker is installed".into()) + } + #[cfg(windows)] + { + use std::process::Command; + let pack = shared.runtime.app_dir.join("packs").join("devnet"); + let _ = std::fs::create_dir_all(&pack); + let out = crate::detect::run_timeout(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(""))); + if !pack.join("seeds.txt").exists() { + return Err("export-pack wrote no seeds.txt (is the node reachable?)".into()); + } + let (sub, exe, cmd) = if vendor == "nvidia" { + ("proto-cuda", "igneum-bench-cuda-devnet.exe", "build.bat devnet sm_120") + } else { + ("proto-opencl", "igneum-bench-cl-devnet.exe", "build.bat devnet") + }; + let dir = bins.dir.join(sub); + if !dir.join("build.bat").exists() { + return Err(format!("{sub}\\build.bat is not installed; the prebuilt worker is missing too")); + } + // the pack where build.bat expects it + let dest = dir.join("packs").join("devnet"); + let _ = std::fs::create_dir_all(&dest); + if let Ok(rd) = std::fs::read_dir(&pack) { + for e in rd.flatten() { + let _ = std::fs::copy(e.path(), dest.join(e.file_name())); + } + } + let mut vcvars = None; + for pf in ["C:\\Program Files\\Microsoft Visual Studio", "C:\\Program Files (x86)\\Microsoft Visual Studio"] { + if let Ok(years) = std::fs::read_dir(pf) { + for y in years.flatten() { + if let Ok(eds) = std::fs::read_dir(y.path()) { + for e in eds.flatten() { + let p = e.path().join("VC").join("Auxiliary").join("Build").join("vcvarsall.bat"); + if p.exists() { + vcvars = Some(p); + } + } + } + } + } + } + let vc = vcvars.ok_or("Visual Studio with the MSVC v143 x64 component is not installed (vcvarsall.bat not found)")?; + let line = format!("\"{}\" x64 -vcvars_ver=14.30 >nul 2>&1 && cd /d \"{}\" && {}", vc.display(), dir.display(), cmd); + shared.log(&format!("building the {sub} worker: cmd /s /c \"{line}\"")); + let out = crate::detect::run_timeout(Command::new("cmd").args(["/s", "/c", &format!("\"{line}\"")]), None, Duration::from_secs(900)).unwrap_or_default(); + for l in out.lines().rev().take(8).collect::>().into_iter().rev() { + shared.log(&format!(" build: {l}")); + } + let p = dir.join(exe); + if p.exists() { Ok(p) } else { Err(format!("{exe} was not written; see the log")) } + } +} diff --git a/app/igneum-app/src/keys.rs b/app/igneum-app/src/keys.rs new file mode 100644 index 00000000..48b9ec01 --- /dev/null +++ b/app/igneum-app/src/keys.rs @@ -0,0 +1,94 @@ +//! The payout key: a secp256k1 private key made on this machine, its EVM address, and the wallet file. +//! The wallet lives in the app data directory (`wallet.json`) with the permissions locked to the user: +//! 0600 on macOS and Linux, an icacls grant to the signed-in user alone on Windows. + +use k256::elliptic_curve::sec1::ToEncodedPoint; +use k256::SecretKey; +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; +use std::path::Path; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Wallet { + pub address: String, // 0x + 40 lower-case hex + pub private_key: String, // 0x + 64 hex, secp256k1 + pub created: u64, // unix seconds +} + +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// The EVM address of a secp256k1 private key: keccak256 of the uncompressed public key (without the 0x04 prefix), last 20 bytes. +pub fn address_of(sk: &SecretKey) -> String { + let pk = sk.public_key(); + let point = pk.to_encoded_point(false); + let bytes = point.as_bytes(); + let h = Keccak256::digest(&bytes[1..]); + format!("0x{}", hex(&h[12..])) +} + +/// A fresh key from the operating system's randomness. +pub fn generate() -> Wallet { + loop { + let mut raw = [0u8; 32]; + getrandom::getrandom(&mut raw).expect("os randomness"); + if let Ok(sk) = SecretKey::from_slice(&raw) { + let address = address_of(&sk); + return Wallet { address, private_key: format!("0x{}", hex(&raw)), created: crate::platform::unix_now() }; + } + } +} + +/// EIP-55 mixed-case form of a lower-case address, for display. +pub fn checksum(addr: &str) -> String { + let body = addr.trim_start_matches("0x").to_ascii_lowercase(); + let h = Keccak256::digest(body.as_bytes()); + let mut out = String::with_capacity(42); + out.push_str("0x"); + for (i, c) in body.chars().enumerate() { + let nibble = (h[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0xf; + if c.is_ascii_alphabetic() && nibble >= 8 { + out.push(c.to_ascii_uppercase()); + } else { + out.push(c); + } + } + out +} + +/// True for 0x followed by 40 hex characters. +pub fn valid_address(s: &str) -> bool { + let s = s.trim(); + s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()) +} + +pub fn save(path: &Path, w: &Wallet) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + crate::platform::lock_permissions(dir, true); + } + let text = serde_json::to_string_pretty(w).expect("wallet json"); + std::fs::write(path, text)?; + crate::platform::lock_permissions(path, false); + Ok(()) +} + +pub fn load(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_vector() { + // The well-known test key 0x01: address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf + let mut raw = [0u8; 32]; + raw[31] = 1; + let sk = SecretKey::from_slice(&raw).unwrap(); + assert_eq!(checksum(&address_of(&sk)), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs new file mode 100644 index 00000000..71b3f2d4 --- /dev/null +++ b/app/igneum-app/src/main.rs @@ -0,0 +1,116 @@ +//! Igneum Miner engine. Supervises igneumd, igneum-miner and the GPU workers and serves the dashboard on +//! 127.0.0.1:/t//. The window host (macOS: the Swift app, Windows: the WebView2 host) starts it with +//! --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] +//! +//! 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. +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] + +mod config; +mod detect; +mod engine; +mod keys; +mod platform; +mod procs; +mod server; +mod state; +mod update; + +use std::io::{BufRead, Write}; +use std::sync::mpsc::channel; +use std::sync::Arc; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let wrapper = args.iter().any(|a| a == "--wrapper"); + let no_open = wrapper || args.iter().any(|a| a == "--no-open"); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("igneum-app {}", engine::VERSION); + return; + } + platform::clear_quarantine(); + let runtime = config::Runtime::from_env(); + let _ = std::fs::create_dir_all(&runtime.app_dir); + let _ = std::fs::create_dir_all(&runtime.log_dir); + platform::lock_permissions(&runtime.app_dir, true); + + let bins = match detect::find_bins() { + Ok(b) => b, + Err(e) => { + eprintln!("{e}"); + if wrapper { + println!("FATAL {e}"); + } + std::process::exit(1); + } + }; + let mut candidates = vec![bins.dir.join("igneum-app.json")]; + if let Ok(exe) = std::env::current_exe() { + if let Some(d) = exe.parent() { + candidates.push(d.join("igneum-app.json")); + if let Some(c) = d.parent() { + candidates.push(c.join("Resources").join("igneum-app.json")); + } + } + } + let packaged = config::Packaged::load(&candidates); + let settings = config::Settings::load(&runtime.app_dir.join("settings.json")); + + // the per-launch token: 32 hex characters from the OS + let mut raw = [0u8; 16]; + getrandom::getrandom(&mut raw).expect("os randomness"); + let token = keys::hex(&raw); + + let stamp_file = runtime.log_dir.join(format!("app-{}.log", stamp_now())); + let engine_log = std::fs::File::create(&stamp_file).ok(); + let (tx, rx) = channel(); + let shared = Arc::new(engine::Shared::new(token.clone(), runtime, packaged, settings, tx, engine_log)); + let port = match server::start(shared.clone()) { + Ok(p) => p, + Err(e) => { + eprintln!("cannot listen on 127.0.0.1: {e}"); + std::process::exit(1); + } + }; + let url = format!("http://127.0.0.1:{port}/t/{token}/"); + // the URL file lets a second launch or a support script find the window; user-only permissions + let url_file = shared.runtime.app_dir.join("app.url"); + let _ = std::fs::write(&url_file, &url); + platform::lock_permissions(&url_file, false); + shared.log(&format!("dashboard at {url} (log {})", stamp_file.display())); + if wrapper || args.iter().any(|a| a == "--print-url") { + println!("URL {url}"); + let _ = std::io::stdout().flush(); + } + if !no_open { + platform::open_url(&url); + } + // the host's commands on stdin; stdin closing in wrapper mode means the host is gone + { + let shared = shared.clone(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let Ok(l) = line else { break }; + match l.trim() { + "quit" => shared.send(engine::Cmd::Quit), + "pause" => shared.send(engine::Cmd::Pause), + "resume" => shared.send(engine::Cmd::Resume), + _ => {} + } + } + if wrapper { + shared.send(engine::Cmd::Quit); + } + }); + } + engine::Engine::new(shared, bins, rx, wrapper).run(); +} + +fn stamp_now() -> String { + let t = platform::unix_now(); + format!("{}-{:02}{:02}{:02}", t / 86400, t % 86400 / 3600, t % 3600 / 60, t % 60) +} diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs new file mode 100644 index 00000000..39a6293f --- /dev/null +++ b/app/igneum-app/src/platform.rs @@ -0,0 +1,295 @@ +//! What differs per operating system: directories, file permissions, keeping the machine awake, opening a URL, +//! starting at login, and stopping a child process gracefully. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn unix_now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +pub fn unix_now_f() -> f64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) +} + +fn home() -> PathBuf { + #[cfg(windows)] + { + if let Some(p) = std::env::var_os("USERPROFILE") { + return PathBuf::from(p); + } + } + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")) +} + +/// The root the app writes under. macOS: ~/Library/Application Support/Igneum. Windows: %LOCALAPPDATA%\igneum +/// (the same folder today's launchers use, so an existing devnet-v4 database is reused). +pub fn data_root() -> PathBuf { + if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") { + return PathBuf::from(p); + } + #[cfg(target_os = "macos")] + { + home().join("Library").join("Application Support").join("Igneum") + } + #[cfg(windows)] + { + std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Local")).join("igneum") + } + #[cfg(not(any(target_os = "macos", windows)))] + { + home().join(".igneum") + } +} + +pub fn log_root() -> PathBuf { + if let Some(p) = std::env::var_os("IGNEUM_APP_LOGS") { + return PathBuf::from(p); + } + #[cfg(target_os = "macos")] + { + home().join("Library").join("Logs").join("Igneum") + } + #[cfg(not(target_os = "macos"))] + { + data_root().join("logs") + } +} + +/// The machine's short name, cleaned to [A-Za-z0-9-], for the miner labels (mac-, nvidia-). +pub fn host_label() -> String { + let raw = { + #[cfg(target_os = "macos")] + { + Command::new("scutil").args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok()) + } + #[cfg(windows)] + { + std::env::var("COMPUTERNAME").ok() + } + #[cfg(not(any(target_os = "macos", windows)))] + { + std::fs::read_to_string("/etc/hostname").ok() + } + }; + let raw = raw.unwrap_or_default(); + let cleaned: String = raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); + let cleaned = cleaned.trim_matches('-').to_string(); + if cleaned.is_empty() { + if cfg!(windows) { "pc".into() } else { "mac".into() } + } else { + cleaned + } +} + +/// Restricts a file (or directory) to the current user. +pub fn lock_permissions(path: &Path, dir: bool) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(if dir { 0o700 } else { 0o600 })); + } + #[cfg(windows)] + { + let _ = dir; + let user = std::env::var("USERNAME").unwrap_or_default(); + if !user.is_empty() { + let _ = Command::new("icacls") + .arg(path) + .args(["/inheritance:r", "/grant:r", &format!("{user}:F")]) + .output(); + } + } +} + +/// Opens a URL in the default browser (the fallback when no window host runs). +pub fn open_url(url: &str) { + #[cfg(target_os = "macos")] + let _ = Command::new("open").arg(url).spawn(); + #[cfg(windows)] + let _ = Command::new("cmd").args(["/c", "start", "", url]).spawn(); + #[cfg(not(any(target_os = "macos", windows)))] + let _ = Command::new("xdg-open").arg(url).spawn(); +} + +/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state, +/// which must be refreshed (call `keep_awake_tick` every minute). +pub struct KeepAwake { + #[cfg(target_os = "macos")] + child: Option, +} + +impl KeepAwake { + pub fn start() -> KeepAwake { + #[cfg(target_os = "macos")] + { + let child = Command::new("caffeinate").args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok(); + KeepAwake { child } + } + #[cfg(not(target_os = "macos"))] + { + keep_awake_tick(); + KeepAwake {} + } + } + pub fn stop(&mut self) { + #[cfg(target_os = "macos")] + if let Some(c) = self.child.as_mut() { + let _ = c.kill(); + let _ = c.wait(); + } + #[cfg(windows)] + unsafe { + SetThreadExecutionState(ES_CONTINUOUS); + } + } +} + +#[cfg(windows)] +const ES_CONTINUOUS: u32 = 0x8000_0000; +#[cfg(windows)] +const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001; +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + fn SetThreadExecutionState(flags: u32) -> u32; +} + +pub fn keep_awake_tick() { + #[cfg(windows)] + unsafe { + SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED); + } +} + +/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through +/// std (what today's launcher does with taskkill /F). +pub fn terminate(child: &mut std::process::Child) { + #[cfg(unix)] + unsafe { + libc::kill(child.id() as i32, libc::SIGTERM); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } +} + +/// The app bundle on macOS (Igneum Miner.app) when the engine runs from inside one. +pub fn bundle_path() -> Option { + let exe = std::env::current_exe().ok()?; + let macos = exe.parent()?; + let contents = macos.parent()?; + if macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" { + contents.parent().map(|p| p.to_path_buf()) + } else { + None + } +} + +/// What a login item should run: the bundle (macOS) or the window host / the engine (Windows). +fn login_command() -> Vec { + #[cfg(target_os = "macos")] + { + if let Some(b) = bundle_path() { + return vec!["/usr/bin/open".into(), "-a".into(), b.to_string_lossy().into_owned()]; + } + } + let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(); + #[cfg(windows)] + { + 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() { + return vec![host.to_string_lossy().into_owned()]; + } + } + } + vec![exe] +} + +#[cfg(target_os = "macos")] +fn launch_agent_path() -> PathBuf { + home().join("Library").join("LaunchAgents").join("network.igneum.miner.plist") +} + +pub fn set_start_at_login(on: bool) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let path = launch_agent_path(); + if on { + let args: String = login_command() + .iter() + .map(|a| format!(" {}\n", a.replace('&', "&").replace('<', "<"))) + .collect(); + let plist = format!( + "\n\n\n\n Label\n network.igneum.miner\n ProgramArguments\n \n{args} \n RunAtLoad\n \n\n\n" + ); + if let Some(d) = path.parent() { + std::fs::create_dir_all(d).map_err(|e| e.to_string())?; + } + std::fs::write(&path, plist).map_err(|e| e.to_string())?; + } else if path.exists() { + std::fs::remove_file(&path).map_err(|e| e.to_string())?; + } + Ok(()) + } + #[cfg(windows)] + { + let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; + let out = if on { + let cmd = login_command().iter().map(|a| format!("\"{a}\"")).collect::>().join(" "); + Command::new("reg").args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output() + } else { + Command::new("reg").args(["delete", key, "/v", "Igneum Miner", "/f"]).output() + }; + match out { + Ok(o) if o.status.success() || !on => Ok(()), + Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()), + Err(e) => Err(e.to_string()), + } + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = on; + Err("start at login is not supported on this platform".into()) + } +} + +pub fn start_at_login_is_on() -> bool { + #[cfg(target_os = "macos")] + { + launch_agent_path().exists() + } + #[cfg(windows)] + { + Command::new("reg") + .args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Igneum Miner"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + #[cfg(not(any(target_os = "macos", windows)))] + { + false + } +} + +/// Removes the quarantine flag from the app's own files (macOS): after Gatekeeper lets the app through, each binary +/// inside would still be checked on its first exec. Best effort; only works on a writable volume. +pub fn clear_quarantine() { + #[cfg(target_os = "macos")] + if let Some(b) = bundle_path() { + let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output(); + } +} + +/// Builds a command that runs without a console window on Windows. +pub fn quiet(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd +} diff --git a/app/igneum-app/src/procs.rs b/app/igneum-app/src/procs.rs new file mode 100644 index 00000000..f077db63 --- /dev/null +++ b/app/igneum-app/src/procs.rs @@ -0,0 +1,120 @@ +//! Child processes with their output read line by line into one channel, and written to a log file each. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Source { + Node, + Watch, + Miner(usize), // card index + Engine, +} + +impl Source { + pub fn tag(&self) -> String { + match self { + Source::Node => "node".into(), + Source::Watch => "watch".into(), + Source::Miner(i) => format!("miner{}", i + 1), + Source::Engine => "app".into(), + } + } +} + +pub struct Line { + pub src: Source, + pub stderr: bool, + pub text: String, +} + +pub struct Proc { + pub child: Child, + pub started: Instant, + pub log_path: PathBuf, + pub cmdline: String, + pub exit_code: Option, +} + +/// Starts a program with stdout and stderr piped; every line goes to `tx` and to the log file. +pub fn spawn(src: Source, exe: &Path, args: &[String], cwd: Option<&Path>, log_path: &Path, tx: &Sender) -> std::io::Result { + let mut cmd = Command::new(exe); + cmd.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + if let Some(d) = cwd { + cmd.current_dir(d); + } + crate::platform::quiet(&mut cmd); + let mut child = cmd.spawn()?; + let file = Arc::new(Mutex::new(std::fs::File::create(log_path).ok())); + let cmdline = format!("{} {}", exe.display(), args.join(" ")); + if let Some(f) = file.lock().unwrap().as_mut() { + let _ = writeln!(f, "# {cmdline}"); + } + for (stream, is_err) in [(child.stdout.take().map(|s| Box::new(s) as Box), false), (child.stderr.take().map(|s| Box::new(s) as Box), true)] { + let Some(stream) = stream else { continue }; + let tx = tx.clone(); + let file = file.clone(); + std::thread::spawn(move || { + let reader = BufReader::new(stream); + for line in reader.split(b'\n') { + let Ok(bytes) = line else { break }; + let text = String::from_utf8_lossy(&bytes).trim_end_matches('\r').to_string(); + if let Some(f) = file.lock().unwrap().as_mut() { + let _ = writeln!(f, "{}{}", if is_err { "! " } else { "" }, text); + } + if tx.send(Line { src, stderr: is_err, text }).is_err() { + break; + } + } + }); + } + Ok(Proc { child, started: Instant::now(), log_path: log_path.to_path_buf(), cmdline, exit_code: None }) +} + +impl Proc { + pub fn pid(&self) -> u32 { + self.child.id() + } + /// Polls the process; records the exit code once it has ended. + pub fn alive(&mut self) -> bool { + if self.exit_code.is_some() { + return false; + } + match self.child.try_wait() { + Ok(Some(st)) => { + self.exit_code = Some(st.code().unwrap_or(-1)); + false + } + Ok(None) => true, + Err(_) => false, + } + } + pub fn write_stdin(&mut self, text: &str) { + if let Some(s) = self.child.stdin.as_mut() { + let _ = s.write_all(text.as_bytes()); + let _ = s.flush(); + } + } + /// Graceful stop: a terminate signal, then up to `grace_secs`, then a kill. + pub fn stop(&mut self, grace_secs: u64) -> Option { + if !self.alive() { + return self.exit_code; + } + crate::platform::terminate(&mut self.child); + let deadline = Instant::now() + std::time::Duration::from_secs(grace_secs); + while Instant::now() < deadline { + if !self.alive() { + return self.exit_code; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + self.exit_code = Some(-9); + self.exit_code + } +} diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs new file mode 100644 index 00000000..e9c59a5f --- /dev/null +++ b/app/igneum-app/src/server.rs @@ -0,0 +1,223 @@ +//! The local web server: static dashboard files from the binary and a JSON API, on 127.0.0.1 with a random port. +//! Every path carries the per-launch token (`/t//...`); anything else is a 404, so another local user cannot +//! drive the miner. Plain HTTP/1.1, one thread per connection, Connection: close. + +use crate::engine::{Cmd, Shared}; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +const INDEX: &str = include_str!("../ui/index.html"); +const CSS: &str = include_str!("../ui/app.css"); +const JS: &str = include_str!("../ui/app.js"); +const MARK: &str = include_str!("../ui/mark.svg"); +const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); +const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2"); +const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2"); +const FONT_SANS_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-400.woff2"); +const FONT_SANS_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-500.woff2"); +const FONT_SANS_600: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-600.woff2"); +const FONT_UNB_500: &[u8] = include_bytes!("../ui/fonts/Unbounded-500.woff2"); +const FONT_UNB_700: &[u8] = include_bytes!("../ui/fonts/Unbounded-700.woff2"); +const FONT_UNB_900: &[u8] = include_bytes!("../ui/fonts/Unbounded-900.woff2"); + +pub fn start(shared: Arc) -> std::io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + std::thread::spawn(move || { + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let shared = shared.clone(); + std::thread::spawn(move || handle(stream, shared)); + } + }); + Ok(port) +} + +struct Req { + method: String, + path: String, + query: String, + body: Vec, +} + +fn read_request(stream: &mut TcpStream) -> Option { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + let mut content_length = 0usize; + loop { + let mut h = String::new(); + reader.read_line(&mut h).ok()?; + let h = h.trim_end(); + if h.is_empty() { + break; + } + if let Some((k, v)) = h.split_once(':') { + if k.eq_ignore_ascii_case("content-length") { + content_length = v.trim().parse().unwrap_or(0); + } + } + } + if content_length > 1 << 20 { + return None; + } + let mut body = vec![0u8; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).ok()?; + } + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (target, String::new()), + }; + Some(Req { method, path, query, body }) +} + +fn respond(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], cache: bool) { + let reason = match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + _ => "Error", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n\r\n", + body.len(), + if cache { "public, max-age=86400" } else { "no-store" } + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); +} + +fn json_resp(stream: &mut TcpStream, status: u16, v: Value) { + respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false); +} + +fn query_param(q: &str, key: &str) -> Option { + q.split('&').find_map(|kv| { + let (k, v) = kv.split_once('=')?; + if k == key { Some(v.to_string()) } else { None } + }) +} + +fn handle(mut stream: TcpStream, shared: Arc) { + let Some(req) = read_request(&mut stream) else { return }; + let prefix = format!("/t/{}", shared.token); + if req.path == "/" || req.path == format!("/t/{}", shared.token) { + // a bare root or the token without a slash: redirect-free hint, keep it a 404 for the root (no token leak) + if req.path == "/" { + respond(&mut stream, 404, "text/plain", b"Igneum Miner: open the app window.", false); + return; + } + } + let Some(rest) = req.path.strip_prefix(&prefix) else { + respond(&mut stream, 404, "text/plain", b"not found", false); + return; + }; + let rest = if rest.is_empty() { "/" } else { rest }; + match (req.method.as_str(), rest) { + ("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false), + ("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false), + ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), + ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), + ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), + ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), + ("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true), + ("GET", "/fonts/IBMPlexSans-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_400, true), + ("GET", "/fonts/IBMPlexSans-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_500, true), + ("GET", "/fonts/IBMPlexSans-600.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_600, true), + ("GET", "/fonts/Unbounded-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_500, true), + ("GET", "/fonts/Unbounded-700.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_700, true), + ("GET", "/fonts/Unbounded-900.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_900, true), + ("GET", "/api/state") => { + let v = shared.state_json(); + json_resp(&mut stream, 200, v); + } + ("GET", "/api/log") => { + let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); + let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000); + let lines = shared.rings.lock().unwrap().since(after, limit); + json_resp(&mut stream, 200, json!({ "lines": lines })); + } + ("POST", p) => { + let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) }; + let out = api_post(&shared, p, body); + match out { + Ok(v) => json_resp(&mut stream, 200, v), + Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })), + } + } + _ => respond(&mut stream, 404, "text/plain", b"not found", false), + } +} + +fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string()); + match path { + "/api/detect" => { + shared.send(Cmd::Detect); + Ok(json!({ "ok": true })) + } + "/api/phase" => { + let phase = s("phase").ok_or("phase missing")?; + shared.state.lock().unwrap().phase = phase; + Ok(json!({ "ok": true })) + } + "/api/setup" => { + let mode = s("mode").unwrap_or_else(|| "generate".into()); + let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32); + let out = shared.setup(&mode, s("address").as_deref(), identities)?; + Ok(out) + } + "/api/key/saved" => { + shared.key_saved(); + Ok(json!({ "ok": true })) + } + "/api/key/reveal" => shared.reveal_key(), + "/api/start" => { + shared.send(Cmd::Start); + Ok(json!({ "ok": true })) + } + "/api/pause" => { + shared.send(Cmd::Pause); + Ok(json!({ "ok": true })) + } + "/api/resume" => { + shared.send(Cmd::Resume); + 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()); + let login = body.get("start_at_login").and_then(|v| v.as_bool()); + let address = s("address"); + shared.apply_settings(identities, vote, login, address.as_deref()) + } + "/api/update/check" => { + shared.send(Cmd::CheckUpdate); + Ok(json!({ "ok": true })) + } + "/api/open" => { + let url = s("url").ok_or("url missing")?; + if url.starts_with("https://") || url.starts_with("http://") { + crate::platform::open_url(&url); + Ok(json!({ "ok": true })) + } else { + Err("only http(s) links open".into()) + } + } + "/api/quit" => { + shared.send(Cmd::Quit); + Ok(json!({ "ok": true })) + } + _ => Err("unknown api".into()), + } +} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs new file mode 100644 index 00000000..583114fc --- /dev/null +++ b/app/igneum-app/src/state.rs @@ -0,0 +1,184 @@ +//! The engine's state as the dashboard and the window host read it (`GET /api/state`), plus the event and log rings. + +use serde::Serialize; +use std::collections::VecDeque; + +#[derive(Clone, Serialize, Default)] +pub struct NodeState { + pub state: String, // stopped | starting | syncing | synced | restarting | failed + pub blocks: u64, + pub headers: u64, + pub daa: u64, + pub blue: u64, + pub peers: u64, + pub tips: u64, + pub difficulty: f64, + pub synced: bool, + pub starts: u32, + pub restarts: u32, + pub restart_in_s: u64, + pub pid: u32, + pub version: String, + pub last_reading_age_s: f64, + pub message: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct CardState { + pub index: usize, + pub name: String, + pub vendor: String, // apple | nvidia | amd | other + pub worker: String, // Metal | CUDA | OpenCL + pub detail: String, // memory, cores + pub device: String, // the worker's --device value (Windows) + pub enabled: bool, + pub state: String, // off | waiting | starting | ready | mining | restarting | failed + pub hash_now: f64, // MH/s, the last interval + pub hash_avg: f64, // MH/s since the start + pub accepted: u64, + pub rejected: u64, + pub identities: u32, + pub ids: Vec, // vote key hash short ids + pub ready: bool, + pub prepare: bool, + pub prepared: bool, + pub template_age_s: f64, + pub synced: bool, + pub restarts: u32, + pub restart_in_s: u64, + pub pid: u32, + pub last_status_age_s: f64, + pub message: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct MiningState { + pub state: String, // idle | waiting | mining | paused | stopped + pub paused: bool, + pub cards: Vec, + pub hash_total: f64, + pub accepted_total: u64, // lifetime + pub accepted_session: u64, + pub rejected_session: u64, + pub found: Vec, // unix times of the accepted blocks, last hour +} + +#[derive(Clone, Serialize, Default)] +pub struct ProgramState { + pub boundary_daa: u64, + pub eta_s: i64, + pub epoch_index: u64, + pub prepared: bool, + pub prepare_sent: bool, + pub message: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct FinalityState { + pub last_lock: u64, + pub last_lock_at: f64, + pub age_s: f64, + pub votes: u64, + pub message: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct AddressState { + pub value: String, + pub display: String, + pub source: String, + pub key_saved: bool, + pub wallet_file: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct SettingsState { + pub identities: u32, + pub vote: bool, + pub start_at_login: bool, +} + +#[derive(Clone, Serialize, Default)] +pub struct UpdateState { + pub available: bool, + pub version: String, + pub url: String, + pub notes: String, + pub checked_at: f64, + pub error: String, +} + +#[derive(Clone, Serialize)] +pub struct Event { + pub t: f64, + pub kind: String, // ok | info | block | build | error + pub text: String, +} + +#[derive(Clone, Serialize)] +pub struct LogLine { + pub seq: u64, + pub t: f64, + pub src: String, + pub err: bool, + pub text: String, +} + +#[derive(Clone, Serialize, Default)] +pub struct State { + pub version: String, + pub phase: String, // welcome | cards | address | dashboard + pub setup_done: bool, + pub network: String, + pub chain: String, + pub host: String, + pub node_dir: String, + pub log_dir: String, + pub detecting: bool, + pub detect_message: String, + pub node: NodeState, + pub mining: MiningState, + pub program: ProgramState, + pub finality: FinalityState, + pub address: AddressState, + pub settings: SettingsState, + pub update: UpdateState, + pub events: Vec, + pub uptime_s: u64, + pub now: f64, + pub quitting: bool, + pub live_page: String, +} + +/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer. +pub struct Rings { + pub events: VecDeque, + pub log: VecDeque, + pub seq: u64, +} + +impl Rings { + pub fn new() -> Rings { + Rings { events: VecDeque::new(), log: VecDeque::new(), seq: 0 } + } + pub fn event(&mut self, kind: &str, text: &str) { + self.events.push_front(Event { t: crate::platform::unix_now_f(), kind: kind.into(), text: text.into() }); + while self.events.len() > 40 { + self.events.pop_back(); + } + } + pub fn log(&mut self, src: &str, err: bool, text: &str) { + self.seq += 1; + self.log.push_back(LogLine { seq: self.seq, t: crate::platform::unix_now_f(), src: src.into(), err, text: text.into() }); + while self.log.len() > 3000 { + self.log.pop_front(); + } + } + pub fn since(&self, after: u64, limit: usize) -> Vec { + let mut out: Vec = self.log.iter().filter(|l| l.seq > after).cloned().collect(); + if out.len() > limit { + out = out.split_off(out.len() - limit); + } + out + } +} diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs new file mode 100644 index 00000000..1a2f2c86 --- /dev/null +++ b/app/igneum-app/src/update.rs @@ -0,0 +1,86 @@ +//! Update check against the manifest the packager named in igneum-app.json, and log uploads to the intake. +//! Both go through curl (macOS ships it; Windows 10 1803 and later ship curl.exe), so the engine carries no TLS stack. + +use crate::state::UpdateState; +use std::process::Command; +use std::time::Duration; + +/// Manifest shape: {"version":"0.3.1","mac":{"url":"...","notes":"..."},"windows":{"url":"...","notes":"..."}} +pub fn check(manifest_url: &str, current: &str) -> UpdateState { + let mut st = UpdateState { checked_at: crate::platform::unix_now_f(), ..Default::default() }; + if manifest_url.is_empty() { + st.error = "no update manifest configured".into(); + return st; + } + let out = crate::detect::run_timeout(Command::new("curl").args(["-fsSL", "--max-time", "20", manifest_url]), None, Duration::from_secs(25)); + let Some(text) = out else { + st.error = "curl is not available".into(); + return st; + }; + let Ok(v) = serde_json::from_str::(&text) else { + st.error = "no manifest at the update URL yet".into(); + return st; + }; + let latest = v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string(); + let plat = if cfg!(target_os = "macos") { "mac" } else { "windows" }; + let entry = v.get(plat).cloned().unwrap_or(serde_json::Value::Null); + st.version = latest.clone(); + st.url = entry.get("url").and_then(|s| s.as_str()).unwrap_or("").to_string(); + st.notes = entry.get("notes").and_then(|s| s.as_str()).unwrap_or("").to_string(); + st.available = newer(&latest, current) && !st.url.is_empty(); + st +} + +fn parse(v: &str) -> Vec { + v.trim_start_matches('v').split(|c| c == '.' || c == '-').filter_map(|p| p.parse::().ok()).collect() +} + +pub fn newer(latest: &str, current: &str) -> bool { + let a = parse(latest); + let b = parse(current); + if a.is_empty() { + return false; + } + for i in 0..a.len().max(b.len()) { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + if x != y { + return x > y; + } + } + false +} + +/// Posts the last 256 KB of a log file as {label, machine, run_id, lines}, as site/api/log.mjs expects. +pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str, path: &std::path::Path) -> bool { + if url.is_empty() || key.is_empty() { + return false; + } + let Ok(mut data) = std::fs::read(path) else { return false }; + if data.is_empty() { + return false; + } + if data.len() > 262_144 { + data = data.split_off(data.len() - 262_144); + } + let text = String::from_utf8_lossy(&data).into_owned(); + let body = serde_json::json!({ "label": label, "machine": machine, "run_id": run_id, "lines": text }); + let tmp = std::env::temp_dir().join(format!("igneum-upload-{}-{}.json", std::process::id(), label)); + if std::fs::write(&tmp, body.to_string()).is_err() { + return false; + } + let out = crate::detect::run_timeout( + Command::new("curl").args([ + "-sS", "--max-time", "60", "-X", "POST", url, + "-H", "Content-Type: application/json", + "-H", &format!("x-igneum-key: {key}"), + "--data-binary", &format!("@{}", tmp.display()), + "-o", if cfg!(windows) { "NUL" } else { "/dev/null" }, + "-w", "%{http_code}", + ]), + None, + Duration::from_secs(65), + ); + let _ = std::fs::remove_file(&tmp); + out.map(|o| o.trim().starts_with("200")).unwrap_or(false) +} diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css new file mode 100644 index 00000000..88b79742 --- /dev/null +++ b/app/igneum-app/ui/app.css @@ -0,0 +1,250 @@ +/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash; + Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */ +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')} +@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexSans-500.woff2) format('woff2')} +@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(fonts/IBMPlexSans-600.woff2) format('woff2')} +@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/Unbounded-500.woff2) format('woff2')} +@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')} +@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')} + +:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; +--gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px; +--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; +--top:60px;--bottom:52px} +*{box-sizing:border-box} +html,body{height:100%} +body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none} +.mono,code,pre{font-family:var(--mono)} +.dim{color:var(--ash)} +h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance} +h1{font-weight:900;font-size:52px;letter-spacing:-.01em} +h2{font-weight:700;font-size:32px} +h3{font-weight:700;font-size:16px} +p{margin:0} +a{color:inherit} +button{font:inherit;color:inherit} +.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px} +.eyebrow.ember{color:var(--ember)} +[hidden]{display:none !important} + +/* buttons */ +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease;white-space:nowrap} +.btn:hover{transform:translateY(-1px);border-color:var(--ash)} +.btn:active{transform:none} +.btn:disabled{opacity:.4;cursor:default;transform:none} +.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)} +.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B} +.btn.big{min-height:52px;padding:12px 28px;font-size:16px} +.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px} +.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;border-radius:7px;font-family:var(--mono);font-weight:500} +.btn.ghost{border-color:transparent;color:var(--ink-2)} +.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)} +.btn.danger:hover{color:var(--ember);border-color:var(--ember)} +.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} +.icon-btn:hover{color:var(--bone);border-color:var(--ash)} +:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} + +/* top bar */ +.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.top button,.top .pill{-webkit-app-region:no-drag} +body.mac .top{padding-left:92px} +.brand{display:flex;align-items:center;gap:10px} +.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em} +.brand .miner{font-family:var(--mono);font-size:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px} +.top-right{display:flex;align-items:center;gap:12px} +.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite)} +.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)} +.pill.warn{color:var(--ember)} +.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px} +.dot.small{width:7px;height:7px;flex-basis:7px} +.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite} +.warn .dot{background:var(--ember);animation:none} +@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}} +@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}} + +/* update banner */ +.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} + +/* screens */ +main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} +body.has-bottom main{bottom:var(--bottom)} +body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} +body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} +@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.screen{animation:none}} + +/* welcome */ +.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:18px;padding:40px 0 48px} +.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} +.coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite} +.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} +@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} +.lead{font-size:18px;color:var(--ink-2);max-width:54ch} +.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} +.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} +.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)} +.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25} +.tile .s{font-size:13px;color:var(--ash);line-height:1.45} +.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px} +.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em} + +/* steps */ +.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px} +.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch} +.step .cta{justify-content:flex-start;margin-top:8px} +.note{font-size:13px;color:var(--ash);line-height:1.5} +.note.small{font-size:12px;word-break:break-all} +.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px} +.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} +.card.detecting{display:flex;align-items:center;gap:18px} +.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px} +.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px} +.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} +@keyframes spin{to{transform:rotate(360deg)}} +.gpu{display:flex;align-items:center;gap:18px} +.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu .badge.apple{color:var(--bone)} +.gpu .badge.nvidia{color:#8BE37A} +.gpu .badge.amd{color:var(--ember)} +.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2} +.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} +.gpu .meta b{color:var(--ink-2);font-weight:500} +.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} +.gpu.off .tick{background:var(--line-2)} +.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} +@keyframes draw{to{stroke-dashoffset:0}} +.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} + +/* 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} +.option:hover{border-color:var(--line-2)} +.option.on{border-color:var(--ember);background:#1A1614} +.option input[type=radio]{position:absolute;opacity:0;width:0;height:0} +.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative} +.option.on .radio{border-color:var(--ember)} +.option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)} +.option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1} +.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px} +.option .s{font-size:14px;color:var(--ash);line-height:1.5} +.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px} +.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text} +.addr-input:focus{outline:none;border-color:var(--ember)} +.option:not(.on) .addr-input{display:none} +.err{font-size:13px;color:var(--ember)} + +/* dashboard */ +#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} +body[data-phase="dashboard"] #screen-dashboard{display:flex} +.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} +.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} +.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px} +.cell.ember .v{color:var(--ember)} +.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.cell .v.state{text-transform:capitalize;font-size:22px} +.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cell.ok .v.state{color:var(--molten)} +.cell.bad .v.state{color:var(--ember)} +.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} +.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} +.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)} +.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} +#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)} +.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)} +.legend span{display:inline-flex;align-items:center;gap:8px} +.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} +.sw.ember{background:var(--ember);border-color:var(--ember)} +.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} +.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} +.tbl{overflow-x:auto} +table{border-collapse:collapse;width:100%;font-size:13px} +th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} +th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} +td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} +tr:last-child td{border-bottom:0} +td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +td .st.mining{color:var(--molten)} +td .st.bad{color:var(--ember)} +td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} +td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px} +.empty{color:var(--ash);font-size:13px;padding:10px 0} +.kv{display:flex;flex-direction:column} +.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)} +.kv>div:last-child{border-bottom:0} +.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} +.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.card .note{margin-top:10px} +.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto} +.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} +.feed>div:last-child{border-bottom:0} +.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px} +.feed .k{color:var(--molten);margin-right:6px} +.feed .k.error{color:var(--ember)} +.feed .k.block{color:var(--ember)} +.feed .k.build{color:var(--ink-2)} + +/* sheet (the key) */ +.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} +.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} +.sheet .sub{font-size:15px;color:var(--ink-2)} +.field{display:flex;flex-direction:column;gap:8px;margin-top:6px} +.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:13px;word-break:break-all;user-select:text;-webkit-user-select:text} +.box span{flex:1;min-width:0} +.box.key{color:var(--molten)} +.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px} +.check.small{font-size:12px;color:var(--ash)} +.check input{width:18px;height:18px;accent-color:var(--ember)} +.sheet .cta{justify-content:flex-start} + +/* settings panel */ +.panel-wrap{justify-content:flex-end;padding:0} +.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} +@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} +.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)} +.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px} +.row{display:flex;gap:10px;align-items:center} +.row.between{justify-content:space-between} +.row .addr-input{margin-top:0} +.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text} +.num:focus{outline:none;border-color:var(--ember)} +.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0} +.switch input{position:absolute;opacity:0;width:0;height:0} +.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease} +.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} +.switch input:checked+.track{background:var(--ember)} +.switch input:checked+.track::after{transform:translateX(18px)} + +/* bottom bar */ +.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} +.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px} +.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1} +.bottom .right .mono{font-size:12px} + +/* log drawer */ +.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column} +body.drawer-open .drawer{height:260px} +.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto} +.chips{display:flex;gap:6px} +.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer} +.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)} +.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} +.log .src{color:var(--ash)} +.log .src.node{color:#7FA7C9} +.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)} +.log .src.app{color:var(--ember)} +.log .e{color:var(--ember)} + +.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)} + +@media (max-width:860px){ + .strip{grid-template-columns:repeat(2,minmax(0,1fr))} + .grid{grid-template-columns:1fr} + .three{grid-template-columns:1fr} + h1{font-size:40px} +} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js new file mode 100644 index 00000000..5b22ead4 --- /dev/null +++ b/app/igneum-app/ui/app.js @@ -0,0 +1,311 @@ +/* Igneum Miner dashboard. Talks to the engine on the same origin: api/state every second, api/log while the + drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), dashboard. ?screen= forces + a screen (screenshots). Everything is relative to the token path the page was opened on. */ +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var dpr = Math.min(2, window.devicePixelRatio || 1); + var state = null, phase = 'welcome', forced = null, cardsSeen = false, keyShownThisRun = false; + var params = new URLSearchParams(location.search); + if (params.get('screen')) forced = params.get('screen'); + if (params.get('host') === 'mac') document.body.classList.add('mac'); + + // ---------- helpers ---------- + function api(path, body) { + var opt = body === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; + return fetch(path, opt).then(function (r) { return r.json(); }); + } + function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } + function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); } + function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } + function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; } + function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); } + function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; } + function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } + var toastT = null; + function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); } + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast('Copied'); }, function () { fallbackCopy(text); }); } + else fallbackCopy(text); + } + function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast('Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } + document.addEventListener('click', function (e) { + var b = e.target.closest('[data-copy]'); if (!b) return; + copyText($(b.getAttribute('data-copy')).textContent); + }); + + // ---------- screens ---------- + function show(p) { + phase = p; + document.body.setAttribute('data-phase', p); + var dash = p === 'dashboard'; + $('bottom').hidden = !dash; + $('btn-settings').hidden = !dash; + document.body.classList.toggle('has-bottom', dash); + if (!dash) document.body.classList.remove('drawer-open'); + if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } + api('api/phase', { phase: p }); + } + $('btn-begin').addEventListener('click', function () { show('cards'); }); + $('btn-cards-next').addEventListener('click', function () { 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'); }); + + // address options + var opts = document.querySelectorAll('.option'); + function pickOption(el) { opts.forEach(function (o) { o.classList.toggle('on', o === el); }); el.querySelector('input').checked = true; if (el.id === 'opt-paste') $('addr-input').focus(); } + opts.forEach(function (o) { o.addEventListener('click', function (e) { if (e.target.tagName !== 'INPUT' || e.target.type === 'radio') pickOption(o); }); }); + $('addr-input').addEventListener('focus', function () { pickOption($('opt-paste')); }); + function validAddr(a) { return /^0x[0-9a-fA-F]{40}$/.test((a || '').trim()); } + $('btn-start').addEventListener('click', function () { + var mode = document.querySelector('input[name=mode]:checked').value; + var body = { mode: mode }; + if (mode === 'paste') { + var a = $('addr-input').value.trim(); + if (!validAddr(a)) { $('addr-err').hidden = false; return; } + $('addr-err').hidden = true; body.address = a; + } + $('btn-start').disabled = true; + api('api/setup', body).then(function (r) { + $('btn-start').disabled = false; + if (!r.ok) { $('addr-err').textContent = r.error || 'could not save'; $('addr-err').hidden = false; return; } + if (mode === 'generate' && r.private_key) { showKeySheet(r); } + else { api('api/start', {}).then(function () { show('dashboard'); }); } + }).catch(function () { $('btn-start').disabled = false; toast('The engine did not answer'); }); + }); + function showKeySheet(r) { + $('key-address').textContent = r.display || r.address; + $('key-private').textContent = r.private_key; + $('key-file').textContent = r.wallet_file || ''; + $('key-ack').checked = false; $('btn-key-done').disabled = true; + $('sheet-key').hidden = false; keyShownThisRun = true; + } + $('key-ack').addEventListener('change', function () { $('btn-key-done').disabled = !this.checked; }); + $('btn-key-done').addEventListener('click', function () { + api('api/key/saved', {}).then(function () { return api('api/start', {}); }).then(function () { $('sheet-key').hidden = true; $('key-private').textContent = ''; show('dashboard'); }); + }); + + // ---------- settings ---------- + $('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; }); + $('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; }); + $('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); }); + function fillSettings() { + if (!state) return; + $('s-address').textContent = state.address.display || 'not set'; + $('s-identities').value = state.settings.identities; + $('s-vote').checked = state.settings.vote; + $('s-login').checked = state.settings.start_at_login; + $('s-version').textContent = 'Igneum Miner ' + state.version; + $('s-node-dir').textContent = 'chain data: ' + state.node_dir; + $('s-log-dir').textContent = 'logs: ' + state.log_dir; + $('s-reveal').hidden = state.address.source !== 'generated'; + $('s-live').hidden = !state.live_page; + var u = state.update; + $('s-update-note').textContent = u.available ? ('Version ' + u.version + ' is available.') : (u.error ? u.error : (u.checked_at ? 'This is the latest version.' : '')); + } + $('s-address-save').addEventListener('click', function () { + var a = $('s-address-input').value.trim(); + if (!validAddr(a)) { $('s-address-err').textContent = 'An address is 0x followed by 40 hex characters.'; $('s-address-err').hidden = false; return; } + api('api/settings', { address: a }).then(function (r) { if (r.ok) { $('s-address-err').hidden = true; $('s-address-input').value = ''; toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } }); + }); + $('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-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; } }); }); + $('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); }); + $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); + $('update-get').addEventListener('click', function () { if (state && state.update.url) api('api/open', { url: state.update.url }); }); + $('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; }); + + // ---------- bottom bar ---------- + $('btn-pause').addEventListener('click', function () { + if (!state) return; + api(state.mining.paused ? 'api/resume' : 'api/pause', {}); + }); + $('btn-logs').addEventListener('click', function () { document.body.classList.toggle('drawer-open'); if (document.body.classList.contains('drawer-open')) pollLog(); }); + $('btn-quit').addEventListener('click', function () { + if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return; + api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true; + }); + + // ---------- log drawer ---------- + var logAfter = 0, logFilter = '', logLines = []; + document.querySelectorAll('.chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('.chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); renderLog(); }); }); + function renderLog() { + var el = $('log'), follow = $('log-follow').checked; + var lines = logLines.filter(function (l) { return !logFilter || l.src.indexOf(logFilter) === 0; }).slice(-600); + el.innerHTML = lines.map(function (l) { return '' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + ' ' + (l.err ? '' : '') + esc(l.text) + (l.err ? '' : ''); }).join('\n'); + if (follow) el.scrollTop = el.scrollHeight; + } + function pollLog() { + if (!document.body.classList.contains('drawer-open')) return; + api('api/log?after=' + logAfter + '&limit=400').then(function (r) { + if (r.lines && r.lines.length) { logLines = logLines.concat(r.lines).slice(-3000); logAfter = r.lines[r.lines.length - 1].seq; renderLog(); } + }).catch(function () { }); + } + setInterval(pollLog, 1500); + + // ---------- the blocks strip (your blocks over the last 10 minutes) ---------- + var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, seen = {}, born = {}; + function size() { W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); } + window.addEventListener('resize', function () { size(); }); + function rrect(px, py, w, h, r) { x.beginPath(); x.moveTo(px + r, py); x.lineTo(px + w - r, py); x.quadraticCurveTo(px + w, py, px + w, py + r); x.lineTo(px + w, py + h - r); x.quadraticCurveTo(px + w, py + h, px + w - r, py + h); x.lineTo(px + r, py + h); x.quadraticCurveTo(px, py + h, px, py + h - r); x.lineTo(px, py + r); x.quadraticCurveTo(px, py, px + r, py); x.closePath(); } + function draw(now) { + if (!W || !H) size(); + if (!W) return; + x.clearRect(0, 0, W, H); + var span = 600, S = 18, pad = 10, right = W - 26, left = pad; + var pxs = (right - left) / span; + var nowU = state ? (state.now + (performance.now() - stateAt) / 1000) : Date.now() / 1000; + // minute lines + x.font = '500 10px IBM Plex Mono, monospace'; x.textAlign = 'center'; + for (var m = 0; m <= 10; m++) { + var t = nowU - m * 60, px = right - (nowU - t) * pxs; + x.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; x.lineWidth = 1; x.setLineDash(m === 0 ? [] : [3, 6]); + x.beginPath(); x.moveTo(px, 14); x.lineTo(px, H - 18); x.stroke(); x.setLineDash([]); + if (px > 30) { x.fillStyle = 'rgba(154,154,158,0.8)'; x.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } + } + var found = state ? state.mining.found : []; + var rail = H * 0.5; + x.strokeStyle = 'rgba(58,58,66,0.9)'; x.lineWidth = 1; x.beginPath(); x.moveTo(left, rail); x.lineTo(right, rail); x.stroke(); + var shown = 0; + found.forEach(function (t, i) { + var age = nowU - t; if (age < 0 || age > span) return; shown++; + var key = String(t); + if (!born[key]) born[key] = now; + var fl = Math.max(0, 1 - (now - born[key]) / 1200); + var px = right - age * pxs, py = rail + ((i % 3) - 1) * 22; + if (fl > 0) { var g = x.createRadialGradient(px, py, 0, px, py, S * 2.2); g.addColorStop(0, 'rgba(255,179,92,' + (0.6 * fl) + ')'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.beginPath(); x.arc(px, py, S * 2.2, 0, Math.PI * 2); x.fill(); } + x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 5); x.fill(); + if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 6); x.stroke(); } + }); + if (!shown) { + x.fillStyle = 'rgba(154,154,158,0.9)'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center'; + var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: your blocks appear here as the node accepts them' : state.mining.state === 'paused' ? 'paused' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync'; + x.fillText(msg, W / 2, rail - 14); + } + } + var stateAt = performance.now(); + function frame(now) { if (phase === 'dashboard') draw(now); if (!reduce) requestAnimationFrame(frame); else setTimeout(function () { frame(performance.now()); }, 1000); } + requestAnimationFrame(frame); + + // ---------- render ---------- + 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; } + det.hidden = true; + var cards = s.mining.cards; + list.querySelectorAll('.gpu').forEach(function (n) { n.remove(); }); + 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'; + return; + } + $('cards-sub').textContent = cards.length === 1 ? 'One card found. The worker owns it while you mine.' : cards.length + ' cards found. One worker each.'; + $('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); + }); + } + var lastEventsKey = ''; + function renderDashboard(s) { + var m = s.mining, n = s.node, p = s.program, f = s.finality; + $('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1); + var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length; + $('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state; + $('d-blocks').textContent = withCommas(m.accepted_total); + $('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''); + var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state; + $('d-node').textContent = nodeWord; + $('d-node-sub').textContent = n.state === 'synced' ? ('height ' + withCommas(n.blocks) + ', ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's')) : n.state === 'syncing' ? (n.message || (withCommas(n.blocks) + ' blocks')) : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s') : (n.message || ''); + var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped'); + if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } + else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; } + var nowU = s.now; + $('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'); + 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) { + 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 + ''; + }).join(''); + // 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; + $('n-daa').textContent = withCommas(n.daa); $('n-diff').textContent = compact(n.difficulty); $('n-tips').textContent = n.tips; + $('n-note').textContent = n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : ''); + // finality + if (f.last_lock > 0) { $('f-lock').textContent = '#' + withCommas(f.last_lock); $('f-age').textContent = rel(f.age_s); $('f-note').textContent = 'Checkpoints lock at 2/3 of the 30-day weight. Votes are sent by this miner.'; } + else { $('f-lock').textContent = 'none yet'; $('f-age').textContent = 'n/a'; $('f-note').textContent = f.message || 'Locks appear once the miner votes on checkpoints.'; } + $('f-votes').textContent = withCommas(f.votes); + // events + var key = s.events.length ? s.events[0].t + ':' + s.events.length : ''; + if (key !== lastEventsKey) { + lastEventsKey = key; + $('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '
' + esc(e.kind) + '' + esc(e.text) + '' + clock(e.t) + '
'; }).join('') : '
No events yet.
'; + } + // bottom + $('btn-pause').textContent = m.paused ? 'Resume' : 'Pause'; + $('foot-status').textContent = (s.quitting ? 'stopping: miners first, then the node' : (m.state === 'mining' ? 'mining' : m.state) + ' · node ' + nodeWord + ' · up ' + uptime(s.uptime_s) + (s.address.display ? ' · rewards to ' + s.address.display.slice(0, 8) + '…' + s.address.display.slice(-4) : '')); + $('foot-version').textContent = 'v' + s.version; + } + function renderPill(s) { + var pill = $('pill'), text; + pill.classList.remove('on', 'warn'); + if (s.quitting) text = 'stopping'; + else if (!s.setup_done) text = s.detecting ? 'detecting' : 'set up'; + else if (s.mining.state === 'mining') { text = s.mining.hash_total.toFixed(1) + ' MH/s'; pill.classList.add('on'); } + else if (s.mining.state === 'paused') text = 'paused'; + else if (s.node.state === 'syncing' || s.node.state === 'starting') text = 'node ' + s.node.state; + else if (s.node.state === 'failed' || s.node.state === 'restarting') { text = 'node ' + s.node.state; pill.classList.add('warn'); } + else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); } + else text = s.mining.state; + $('pill-text').textContent = text; + } + function render(s) { + state = s; stateAt = performance.now(); + renderPill(s); + if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; } + if (phase === 'cards') renderCards(s); + if (phase === 'dashboard') renderDashboard(s); + if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } + } + var failures = 0, first = true; + function poll() { + api('api/state').then(function (s) { + failures = 0; + 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') }); } + else show(start); + if (start === 'cards') renderCards(s); + } + render(s); + }).catch(function () { + failures++; + if (failures >= 4) { $('pill').classList.add('warn'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; } + }); + } + poll(); setInterval(poll, 1000); + size(); +})(); diff --git a/app/igneum-app/ui/fonts/IBMPlexMono-400.woff2 b/app/igneum-app/ui/fonts/IBMPlexMono-400.woff2 new file mode 100644 index 00000000..52b6c759 Binary files /dev/null and b/app/igneum-app/ui/fonts/IBMPlexMono-400.woff2 differ diff --git a/app/igneum-app/ui/fonts/IBMPlexMono-500.woff2 b/app/igneum-app/ui/fonts/IBMPlexMono-500.woff2 new file mode 100644 index 00000000..3308bce2 Binary files /dev/null and b/app/igneum-app/ui/fonts/IBMPlexMono-500.woff2 differ diff --git a/app/igneum-app/ui/fonts/IBMPlexSans-400.woff2 b/app/igneum-app/ui/fonts/IBMPlexSans-400.woff2 new file mode 100644 index 00000000..e5ebabbd Binary files /dev/null and b/app/igneum-app/ui/fonts/IBMPlexSans-400.woff2 differ diff --git a/app/igneum-app/ui/fonts/IBMPlexSans-500.woff2 b/app/igneum-app/ui/fonts/IBMPlexSans-500.woff2 new file mode 100644 index 00000000..e5ebabbd Binary files /dev/null and b/app/igneum-app/ui/fonts/IBMPlexSans-500.woff2 differ diff --git a/app/igneum-app/ui/fonts/IBMPlexSans-600.woff2 b/app/igneum-app/ui/fonts/IBMPlexSans-600.woff2 new file mode 100644 index 00000000..e5ebabbd Binary files /dev/null and b/app/igneum-app/ui/fonts/IBMPlexSans-600.woff2 differ diff --git a/app/igneum-app/ui/fonts/Unbounded-500.woff2 b/app/igneum-app/ui/fonts/Unbounded-500.woff2 new file mode 100644 index 00000000..96d8ff5f Binary files /dev/null and b/app/igneum-app/ui/fonts/Unbounded-500.woff2 differ diff --git a/app/igneum-app/ui/fonts/Unbounded-700.woff2 b/app/igneum-app/ui/fonts/Unbounded-700.woff2 new file mode 100644 index 00000000..96d8ff5f Binary files /dev/null and b/app/igneum-app/ui/fonts/Unbounded-700.woff2 differ diff --git a/app/igneum-app/ui/fonts/Unbounded-900.woff2 b/app/igneum-app/ui/fonts/Unbounded-900.woff2 new file mode 100644 index 00000000..96d8ff5f Binary files /dev/null and b/app/igneum-app/ui/fonts/Unbounded-900.woff2 differ diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html new file mode 100644 index 00000000..15146da9 --- /dev/null +++ b/app/igneum-app/ui/index.html @@ -0,0 +1,294 @@ + + + + + +Igneum Miner + + + + + + +
+
+ + IGNEUMMINER +
+
+
starting
+ +
+
+ + + +
+ + +
+
+
+
devnet v4 · nothing is bought or sold
+

Igneum Miner

+

Mined by GPUs. Proven by fire. This app runs a node on this machine and mines Igneum with your graphics card.

+
+
+
01
+
Runs a node
+
Syncs the chain from the seed node, usually under a minute.
+
+
+
02
+
Mines on your GPU
+
A new program every hour. The next one compiles while you mine.
+
+
+
03
+
Pays your address
+
Block rewards go to an EVM address you choose or one made here.
+
+
+
+ +
+

Nobody from Igneum will ever ask for your seed.

+
+
+ + +
+
+
step 1 of 2
+

Your GPU

+

Asking the graphics card to report in.

+
+
+
+
+
Detecting
+
loading the Metal worker
+
+
+
+ +
+ + +
+
+
+ + +
+
+
step 2 of 2
+

Where should rewards go?

+

Every block this machine finds pays one EVM address. Pick one way.

+
+ + +
+
+ + +
+
+
+ + +
+
+
+
hash rate
+
0.0MH/s
+
waiting for the worker
+
+
+
blocks found
+
0
+
accepted by the node
+
+
+
node
+
starting
+
opening the database
+
+
+
next program
+
--:--
+
waiting for the node
+
+
+ +
+
+
+
+
your blocks
+
last 10 min 0last hour 0chain 0
+
+ +
block this machine foundjust acceptedone minute
+
+
+

Cards

1 worker
+
+ + + +
CardWorkerMH/s nowMH/s avgBlocksState
No card yet.
+
+
+
+
+
+

Node

devnet v4
+
+
height0
+
headers0
+
peers0
+
daa score0
+
difficulty0
+
tips0
+
+

+
+
+

Finality

miner-only
+
+
last locknone yet
+
agen/a
+
votes sent0
+
+

Locks appear once the miner votes on checkpoints.

+
+
+

Events

newest first
+
No events yet.
+
+
+
+
+
+ + + + + + + + +
+
+
+ + + + + +
+ +
+

+
+ +
+
+ + +
+
+
+ + +
+
+ + + + + diff --git a/app/igneum-app/ui/mark.svg b/app/igneum-app/ui/mark.svg new file mode 100644 index 00000000..bce08ac8 --- /dev/null +++ b/app/igneum-app/ui/mark.svg @@ -0,0 +1 @@ + diff --git a/app/mac/IgneumMiner.swift b/app/mac/IgneumMiner.swift new file mode 100644 index 00000000..8f92680c --- /dev/null +++ b/app/mac/IgneumMiner.swift @@ -0,0 +1,362 @@ +// Igneum Miner for macOS: the window around the engine. One Swift file, compiled by packaging/mac/build-dmg.sh with +// swiftc -O -target arm64-apple-macos11 -o "Igneum Miner" IgneumMiner.swift -framework Cocoa -framework WebKit +// It starts Contents/MacOS/igneum-app --wrapper, reads "URL ..." and "STATE {...}" lines from its stdout, shows the URL +// in a WKWebView, keeps a menu-bar item with the state, and on quit writes "quit" to the engine's stdin and waits for +// its "EXIT" line (miners stop first, then the node). Closing the window hides it; the app lives on in the menu bar +// and the Dock. 4 October 2026. +// +// Snapshot mode, used to make the design screenshots without a browser: +// "Igneum Miner" --snapshot --url [--size 1120x780] + +import Cocoa +import WebKit + +let obsidian = NSColor(srgbRed: 12 / 255, green: 12 / 255, blue: 14 / 255, alpha: 1) + +func flameImage(size: CGFloat) -> NSImage { + // the brand mark's flame as a template image for the menu bar + let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { rect in + let s = rect.width / 100 + let outer = NSBezierPath() + let pts: [(CGFloat, CGFloat)] = [(50, 4), (74, 34), (67, 58), (80, 54), (61, 96), (39, 96), (20, 54), (33, 58), (26, 34)] + outer.move(to: NSPoint(x: pts[0].0 * s, y: pts[0].1 * s)) + for p in pts.dropFirst() { outer.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) } + outer.close() + let inner = NSBezierPath() + let ip: [(CGFloat, CGFloat)] = [(50, 42), (59, 58), (50, 82), (41, 58)] + inner.move(to: NSPoint(x: ip[0].0 * s, y: ip[0].1 * s)) + for p in ip.dropFirst() { inner.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) } + inner.close() + outer.append(inner) + outer.windingRule = .evenOdd + NSColor.black.setFill() + outer.fill() + return true + } + img.isTemplate = true + return img +} + +/// A clear strip over the top band of the web view: WKWebView swallows window drags, this view lets the window move. +final class DragStrip: NSView { + override var mouseDownCanMoveWindow: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } +} + +final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate { + var window: NSWindow! + var web: WKWebView! + var engine: Process? + var engineIn: FileHandle? + var status: NSStatusItem! + var menuOpen = NSMenuItem(title: "Open Igneum Miner", action: #selector(showWindow), keyEquivalent: "") + var menuPause = NSMenuItem(title: "Pause mining", action: #selector(togglePause), keyEquivalent: "") + var menuNode = NSMenuItem(title: "Node: starting", action: nil, keyEquivalent: "") + var menuBlocks = NSMenuItem(title: "Blocks found: 0", action: nil, keyEquivalent: "") + var url: URL? + var paused = false + var exited = false + var quitting = false + var buffer = Data() + var placeholder: NSTextField! + + // snapshot mode + var snapshotPath: String? + var snapshotURL: String? + var snapshotSize = NSSize(width: 1120, height: 780) + + func applicationDidFinishLaunching(_ note: Notification) { + NSApp.setActivationPolicy(snapshotPath == nil ? .regular : .accessory) + buildMenu() + buildWindow() + if let p = snapshotPath, let u = snapshotURL, let url = URL(string: u) { + self.url = url + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + web.load(URLRequest(url: url)) + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { self.snapshot(to: p) } + return + } + buildStatusItem() + startEngine() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func buildMenu() { + let main = NSMenu() + let appItem = NSMenuItem(); main.addItem(appItem) + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About Igneum Miner", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Hide Igneum Miner", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit Igneum Miner", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + let editItem = NSMenuItem(); main.addItem(editItem) + let edit = NSMenu(title: "Edit") + edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editItem.submenu = edit + let winItem = NSMenuItem(); main.addItem(winItem) + let win = NSMenu(title: "Window") + win.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") + win.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + winItem.submenu = win + NSApp.mainMenu = main + } + + func buildWindow() { + let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize + window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.title = "Igneum Miner" + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.backgroundColor = obsidian + window.minSize = NSSize(width: 900, height: 620) + window.center() + window.delegate = self + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + let conf = WKWebViewConfiguration() + web = WKWebView(frame: window.contentView!.bounds, configuration: conf) + web.autoresizingMask = [.width, .height] + web.navigationDelegate = self + web.uiDelegate = self + web.setValue(false, forKey: "drawsBackground") + web.customUserAgent = "IgneumMiner/0.3.0 (Macintosh)" + window.contentView?.addSubview(web) + // the brand band is draggable; the pill and the settings button on the right stay clickable + let strip = DragStrip(frame: NSRect(x: 0, y: size.height - 60, width: size.width - 300, height: 60)) + strip.autoresizingMask = [.width, .minYMargin] + window.contentView?.addSubview(strip) + placeholder = NSTextField(labelWithString: "Starting the engine") + placeholder.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium) + placeholder.textColor = NSColor(srgbRed: 154 / 255, green: 154 / 255, blue: 158 / 255, alpha: 1) + placeholder.alignment = .center + placeholder.frame = NSRect(x: 0, y: 18, width: size.width, height: 20) + placeholder.autoresizingMask = [.width, .maxYMargin] + placeholder.isHidden = snapshotPath != nil + window.contentView?.addSubview(placeholder) + } + + func buildStatusItem() { + status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + status.button?.image = flameImage(size: 18) + status.button?.imagePosition = .imageLeading + status.button?.title = "" + let menu = NSMenu() + menu.addItem(menuOpen) + menu.addItem(menuPause) + menu.addItem(.separator()) + menuNode.isEnabled = false + menuBlocks.isEnabled = false + menu.addItem(menuNode) + menu.addItem(menuBlocks) + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit Igneum Miner", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "") + menu.autoenablesItems = false + menuOpen.isEnabled = true + menuPause.isEnabled = true + status.menu = menu + } + + // ---- the engine ---- + func startEngine() { + let exe = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/igneum-app") + guard FileManager.default.isExecutableFile(atPath: exe.path) else { + fail("igneum-app is missing from the app bundle. Copy Igneum Miner from the disk image again.") + return + } + let p = Process() + p.executableURL = exe + p.arguments = ["--wrapper"] + let out = Pipe(), inp = Pipe() + p.standardOutput = out + p.standardInput = inp + p.standardError = FileHandle.standardError + engineIn = inp.fileHandleForWriting + out.fileHandleForReading.readabilityHandler = { h in + let d = h.availableData + if d.isEmpty { return } + DispatchQueue.main.async { self.feed(d) } + } + p.terminationHandler = { _ in + DispatchQueue.main.async { self.engineEnded() } + } + do { try p.run() } catch { + fail("The engine could not start: \(error.localizedDescription)") + return + } + engine = p + } + + func feed(_ d: Data) { + buffer.append(d) + while let nl = buffer.firstIndex(of: 10) { + let lineData = buffer.subdata(in: 0.. NSApplication.TerminateReply { + if snapshotPath != nil { return .terminateNow } + guard let e = engine, e.isRunning, !exited else { return .terminateNow } + quitting = true + status?.button?.title = " stopping" + web.evaluateJavaScript("document.getElementById('pill-text').textContent='stopping'", completionHandler: nil) + send("quit") + // 45 s is the engine's own worst case (8 s miners, 30 s node, the last upload); then force + DispatchQueue.main.asyncAfter(deadline: .now() + 45) { + if self.engine?.isRunning == true { self.engine?.terminate() } + NSApp.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showWindow() + return true + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + // the window hides; the miner keeps running in the menu bar + window.orderOut(nil) + return false + } + + // ---- links: anything off 127.0.0.1 opens in the default browser ---- + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let u = action.request.url, let scheme = u.scheme, scheme.hasPrefix("http"), u.host != "127.0.0.1" { + NSWorkspace.shared.open(u) + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + let a = NSAlert() + a.messageText = "Igneum Miner" + a.informativeText = message + a.addButton(withTitle: "Quit") + a.addButton(withTitle: "Cancel") + completionHandler(a.runModal() == .alertFirstButtonReturn) + } + + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler() + } + + // ---- snapshot ---- + func snapshot(to path: String) { + if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] { + web.evaluateJavaScript(js) { r, e in print("probe:", r ?? "nil", e?.localizedDescription ?? "") } + } + let conf = WKSnapshotConfiguration() + conf.rect = web.bounds + web.takeSnapshot(with: conf) { image, error in + defer { NSApp.terminate(nil) } + guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write("snapshot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!) + return + } + do { try png.write(to: URL(fileURLWithPath: path)); print("wrote \(path)") } catch { print("could not write \(path): \(error)") } + } + } +} + +let app = NSApplication.shared +let delegate = App() +var args = Array(CommandLine.arguments.dropFirst()) +var i = 0 +while i < args.count { + switch args[i] { + case "--snapshot": if i + 1 < args.count { delegate.snapshotPath = args[i + 1]; i += 1 } + case "--url": if i + 1 < args.count { delegate.snapshotURL = args[i + 1]; i += 1 } + case "--size": + if i + 1 < args.count { + let parts = args[i + 1].split(separator: "x").compactMap { Double($0) } + if parts.count == 2 { delegate.snapshotSize = NSSize(width: parts[0], height: parts[1]) } + i += 1 + } + default: break + } + i += 1 +} +app.delegate = delegate +app.run() diff --git a/docs/design/app-screens/mac-address-800.png b/docs/design/app-screens/mac-address-800.png new file mode 100644 index 00000000..42fef339 Binary files /dev/null and b/docs/design/app-screens/mac-address-800.png differ diff --git a/docs/design/app-screens/mac-address.png b/docs/design/app-screens/mac-address.png new file mode 100644 index 00000000..192f9175 Binary files /dev/null and b/docs/design/app-screens/mac-address.png differ diff --git a/docs/design/app-screens/mac-cards.png b/docs/design/app-screens/mac-cards.png new file mode 100644 index 00000000..6055280a Binary files /dev/null and b/docs/design/app-screens/mac-cards.png differ diff --git a/docs/design/app-screens/mac-dashboard-800.png b/docs/design/app-screens/mac-dashboard-800.png new file mode 100644 index 00000000..f24be314 Binary files /dev/null and b/docs/design/app-screens/mac-dashboard-800.png differ diff --git a/docs/design/app-screens/mac-dashboard.png b/docs/design/app-screens/mac-dashboard.png new file mode 100644 index 00000000..49e828f9 Binary files /dev/null and b/docs/design/app-screens/mac-dashboard.png differ diff --git a/docs/design/app-screens/mac-key.png b/docs/design/app-screens/mac-key.png new file mode 100644 index 00000000..397230d3 Binary files /dev/null and b/docs/design/app-screens/mac-key.png differ diff --git a/docs/design/app-screens/mac-welcome.png b/docs/design/app-screens/mac-welcome.png new file mode 100644 index 00000000..cb239ae5 Binary files /dev/null and b/docs/design/app-screens/mac-welcome.png differ