One-click miner app: Rust engine (node, miner and worker supervisor with a local dashboard), Mac WKWebView window with a menu-bar item, design screenshots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-04 10:55:05 +01:00
parent 33dba682f0
commit a1a33cbaec
33 changed files with 4534 additions and 0 deletions

1
app/igneum-app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

394
app/igneum-app/Cargo.lock generated Normal file
View file

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

27
app/igneum-app/Cargo.toml Normal file
View file

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

View file

@ -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::<Packaged>(&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<String>,
pub unsynced_mining: bool,
/// A private devnet (--devnet-suffix=N): its own genesis, never peers with the shared devnet. Tests.
pub devnet_suffix: Option<u32>,
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 Josh'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)
}
}

View file

@ -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<std::path::PathBuf>,
pub cuda: Option<std::path::PathBuf>,
pub opencl: Option<std::path::PathBuf>,
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<String> {
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<String>) -> Vec<CardState> {
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::<serde_json::Value>(&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::<u64>() {
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<String>) -> Vec<CardState> {
let mut cards: Vec<CardState> = 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::<f64>().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<Bins, String> {
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::<Vec<_>>().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()
}

1436
app/igneum-app/src/engine.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -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<Wallet> {
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");
}
}

116
app/igneum-app/src/main.rs Normal file
View file

@ -0,0 +1,116 @@
//! Igneum Miner engine. Supervises igneumd, igneum-miner and the GPU workers and serves the dashboard on
//! 127.0.0.1:<random port>/t/<token>/. 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<String> = 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)
}

View file

@ -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-<host>, nvidia-<pc>).
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<std::process::Child>,
}
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<PathBuf> {
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<String> {
#[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!(" <string>{}</string>\n", a.replace('&', "&amp;").replace('<', "&lt;")))
.collect();
let plist = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>network.igneum.miner</string>\n <key>ProgramArguments</key>\n <array>\n{args} </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\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::<Vec<_>>().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
}

120
app/igneum-app/src/procs.rs Normal file
View file

@ -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<i32>,
}
/// 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<Line>) -> std::io::Result<Proc> {
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<dyn std::io::Read + Send>), false), (child.stderr.take().map(|s| Box::new(s) as Box<dyn std::io::Read + Send>), 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<i32> {
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
}
}

View file

@ -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/<token>/...`); 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<Shared>) -> std::io::Result<u16> {
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<u8>,
}
fn read_request(stream: &mut TcpStream) -> Option<Req> {
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<String> {
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<Shared>) {
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<Shared>, path: &str, body: Value) -> Result<Value, String> {
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()),
}
}

184
app/igneum-app/src/state.rs Normal file
View file

@ -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<String>, // 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<CardState>,
pub hash_total: f64,
pub accepted_total: u64, // lifetime
pub accepted_session: u64,
pub rejected_session: u64,
pub found: Vec<f64>, // 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<Event>,
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<Event>,
pub log: VecDeque<LogLine>,
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<LogLine> {
let mut out: Vec<LogLine> = self.log.iter().filter(|l| l.seq > after).cloned().collect();
if out.len() > limit {
out = out.split_off(out.len() - limit);
}
out
}
}

View file

@ -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::<serde_json::Value>(&text) else {
st.error = "no manifest at the update URL yet".into();
return st;
};
let latest = v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();
let plat = if cfg!(target_os = "macos") { "mac" } else { "windows" };
let entry = v.get(plat).cloned().unwrap_or(serde_json::Value::Null);
st.version = latest.clone();
st.url = entry.get("url").and_then(|s| s.as_str()).unwrap_or("").to_string();
st.notes = entry.get("notes").and_then(|s| s.as_str()).unwrap_or("").to_string();
st.available = newer(&latest, current) && !st.url.is_empty();
st
}
fn parse(v: &str) -> Vec<u64> {
v.trim_start_matches('v').split(|c| c == '.' || c == '-').filter_map(|p| p.parse::<u64>().ok()).collect()
}
pub fn newer(latest: &str, current: &str) -> bool {
let a = parse(latest);
let b = parse(current);
if a.is_empty() {
return false;
}
for i in 0..a.len().max(b.len()) {
let x = a.get(i).copied().unwrap_or(0);
let y = b.get(i).copied().unwrap_or(0);
if x != y {
return x > y;
}
}
false
}
/// 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)
}

250
app/igneum-app/ui/app.css Normal file
View file

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

311
app/igneum-app/ui/app.js Normal file
View file

@ -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=<name> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 '<span class="src ' + esc(l.src) + '">' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + '</span> ' + (l.err ? '<span class="e">' : '') + esc(l.text) + (l.err ? '</span>' : ''); }).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' ? '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>' : badge;
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml + '</div><div><div class="name">' + esc(cd.name) + '</div><div class="meta"><span>worker <b>' + esc(cd.worker) + '</b></span>' + (cd.detail ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '') + '</div>' + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div><div class="tick">' + (cd.enabled ? '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#0C0C0E" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>' : '') + '</div>';
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 = '<tr><td colspan="6" class="empty">No GPU worker. The node runs on its own.</td></tr>';
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 ? '<span class="sub">' + esc(cd.message) + '</span>' : (cd.ids.length ? '<span class="sub">id ' + esc(cd.ids.join(' ')) + (cd.prepared ? ' · next program ready' : '') + '</span>' : '');
return '<tr><td>' + esc(cd.name) + (cd.identities > 1 ? ' <span class="dim mono">x' + cd.identities + '</span>' : '') + '</td><td class="mono">' + esc(cd.worker) + '</td><td class="n">' + cd.hash_now.toFixed(1) + '</td><td class="n">' + cd.hash_avg.toFixed(1) + '</td><td class="n">' + cd.accepted + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + '</span>' : '') + '</td><td><span class="st ' + cls + '"><i></i>' + esc(word) + '</span>' + sub + '</td></tr>';
}).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 '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(e.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet.</div>';
}
// 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();
})();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,294 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Igneum Miner</title>
<meta name="color-scheme" content="dark">
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body class="phase-welcome" data-phase="welcome">
<header class="top">
<div class="brand">
<img src="mark.svg" width="30" height="30" alt="">
<span class="word">IGNEUM</span><span class="miner">MINER</span>
</div>
<div class="top-right">
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
</button>
</div>
</header>
<div class="banner" id="update-banner" hidden>
<span><b id="update-version">A new version</b> of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-get">Download</button>
<button class="btn small ghost" id="update-later">Later</button>
</div>
<main id="main">
<!-- 1. welcome -->
<section class="screen" id="screen-welcome">
<div class="hero">
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
<div class="eyebrow ember">devnet v4 &middot; nothing is bought or sold</div>
<h1>Igneum Miner</h1>
<p class="lead">Mined by GPUs. Proven by fire. This app runs a node on this machine and mines Igneum with your graphics card.</p>
<div class="three">
<div class="tile">
<div class="k">01</div>
<div class="t">Runs a node</div>
<div class="s">Syncs the chain from the seed node, usually under a minute.</div>
</div>
<div class="tile">
<div class="k">02</div>
<div class="t">Mines on your GPU</div>
<div class="s">A new program every hour. The next one compiles while you mine.</div>
</div>
<div class="tile">
<div class="k">03</div>
<div class="t">Pays your address</div>
<div class="s">Block rewards go to an EVM address you choose or one made here.</div>
</div>
</div>
<div class="cta">
<button class="btn primary big" id="btn-begin">Get started</button>
</div>
<p class="seedline mono">Nobody from Igneum will ever ask for your seed.</p>
</div>
</section>
<!-- 2. cards -->
<section class="screen" id="screen-cards">
<div class="step">
<div class="eyebrow">step 1 of 2</div>
<h2>Your GPU</h2>
<p class="sub" id="cards-sub">Asking the graphics card to report in.</p>
<div class="cards" id="cards-list">
<div class="card detecting" id="cards-detecting">
<div class="spinner"></div>
<div>
<div class="t">Detecting</div>
<div class="s mono" id="detect-text">loading the Metal worker</div>
</div>
</div>
</div>
<p class="note" id="cards-note" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
</div>
</div>
</section>
<!-- 3. address -->
<section class="screen" id="screen-address">
<div class="step">
<div class="eyebrow">step 2 of 2</div>
<h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one EVM address. Pick one way.</p>
<div class="options">
<label class="option on" id="opt-generate">
<input type="radio" name="mode" value="generate" checked>
<span class="radio"></span>
<span class="body">
<span class="t">Make me an address <span class="tag">recommended</span></span>
<span class="s">A secp256k1 key is made on this machine and stored in the app folder with the file locked to your user. You see the key once, to save it.</span>
</span>
</label>
<label class="option" id="opt-paste">
<input type="radio" name="mode" value="paste">
<span class="radio"></span>
<span class="body">
<span class="t">Use my own address</span>
<span class="s">Paste an EVM address you control. 0x followed by 40 hex characters.</span>
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
<span class="err" id="addr-err" hidden>That is not an EVM address. 0x followed by 40 hex characters.</span>
</span>
</label>
</div>
<div class="cta">
<button class="btn primary big" id="btn-start">Start mining</button>
<button class="btn ghost" id="btn-address-back">Back</button>
</div>
</div>
</section>
<!-- 4. dashboard -->
<section class="screen" id="screen-dashboard">
<div class="strip">
<div class="cell big ember">
<div class="k">hash rate</div>
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
<div class="s" id="d-hash-sub">waiting for the worker</div>
</div>
<div class="cell big">
<div class="k">blocks found</div>
<div class="v" id="d-blocks">0</div>
<div class="s" id="d-blocks-sub">accepted by the node</div>
</div>
<div class="cell big" id="d-node-cell">
<div class="k">node</div>
<div class="v state" id="d-node">starting</div>
<div class="s" id="d-node-sub">opening the database</div>
</div>
<div class="cell big">
<div class="k">next program</div>
<div class="v" id="d-eta">--:--</div>
<div class="s" id="d-eta-sub">waiting for the node</div>
</div>
</div>
<div class="grid">
<div class="col-main">
<div class="card">
<div class="card-head">
<div class="eyebrow"><span class="dot small"></span>your blocks</div>
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
</div>
<div class="card">
<div class="card-head"><h3>Cards</h3><div class="eyebrow" id="d-cards-eyebrow">1 worker</div></div>
<div class="tbl">
<table>
<thead><tr><th>Card</th><th>Worker</th><th class="n">MH/s now</th><th class="n">MH/s avg</th><th class="n">Blocks</th><th>State</th></tr></thead>
<tbody id="d-cards"><tr><td colspan="6" class="empty">No card yet.</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-head"><h3>Node</h3><div class="eyebrow" id="d-node-net">devnet v4</div></div>
<div class="kv">
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span></div>
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span></div>
<div><span class="k">daa score</span><span class="v mono" id="n-daa">0</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
</div>
<p class="note" id="n-note"></p>
</div>
<div class="card">
<div class="card-head"><h3>Finality</h3><div class="eyebrow">miner-only</div></div>
<div class="kv">
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span></div>
<div><span class="k">age</span><span class="v mono" id="f-age">n/a</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span></div>
</div>
<p class="note" id="f-note">Locks appear once the miner votes on checkpoints.</p>
</div>
<div class="card">
<div class="card-head"><h3>Events</h3><div class="eyebrow">newest first</div></div>
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
</div>
</div>
</div>
</section>
</main>
<!-- the key sheet, shown once -->
<div class="sheet-wrap" id="sheet-key" hidden>
<div class="sheet">
<div class="eyebrow ember">shown once</div>
<h2>Save your key</h2>
<p class="sub">This key controls the rewards address. Keep a copy somewhere safe. Anyone with the key can spend what the address holds.</p>
<div class="field">
<div class="k">rewards address</div>
<div class="box mono"><span id="key-address"></span><button class="btn tiny" data-copy="key-address">Copy</button></div>
</div>
<div class="field">
<div class="k">private key</div>
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
<div class="cta">
<button class="btn primary big" id="btn-key-done" disabled>Start mining</button>
</div>
</div>
</div>
<!-- settings -->
<div class="panel-wrap" id="settings" hidden>
<aside class="panel">
<div class="panel-head"><h3>Settings</h3><button class="icon-btn" id="btn-settings-close" aria-label="Close">&times;</button></div>
<div class="panel-body">
<div class="field">
<div class="k">rewards address</div>
<div class="box mono"><span id="s-address"></span><button class="btn tiny" data-copy="s-address">Copy</button></div>
<div class="row">
<input type="text" class="addr-input mono" id="s-address-input" placeholder="paste a new address" spellcheck="false" autocomplete="off">
<button class="btn small" id="s-address-save">Change</button>
</div>
<div class="err" id="s-address-err" hidden></div>
<button class="btn small ghost" id="s-reveal" hidden>Show my key</button>
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
</div>
<div class="field">
<div class="k">identities per card</div>
<div class="row">
<input type="number" min="1" max="64" id="s-identities" class="num mono">
<button class="btn small" id="s-identities-save">Apply</button>
</div>
<p class="note">One worker owns the card; identities take turns on it. 1 is right for most cards. Changing this restarts the miner.</p>
</div>
<div class="field">
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
</div>
<div class="field">
<div class="k">version</div>
<div class="row between"><span class="mono" id="s-version"></span><button class="btn small" id="s-update">Check for updates</button></div>
<p class="note" id="s-update-note"></p>
</div>
<div class="field">
<div class="k">folders</div>
<p class="note mono small" id="s-node-dir"></p>
<p class="note mono small" id="s-log-dir"></p>
</div>
<div class="field">
<button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button>
</div>
</div>
</aside>
</div>
<!-- the log drawer -->
<div class="drawer" id="drawer">
<div class="drawer-head">
<div class="chips" id="log-chips">
<button class="chip on" data-src="">all</button>
<button class="chip" data-src="app">app</button>
<button class="chip" data-src="node">node</button>
<button class="chip" data-src="miner">miner</button>
<button class="chip" data-src="watch">watch</button>
</div>
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
</div>
<pre class="log" id="log"></pre>
</div>
<footer class="bottom" id="bottom" hidden>
<div class="left">
<button class="btn small" id="btn-pause">Pause</button>
<button class="btn small ghost" id="btn-logs">Logs</button>
</div>
<div class="mid mono" id="foot-status"></div>
<div class="right">
<span class="mono dim" id="foot-version"></span>
<button class="btn small ghost danger" id="btn-quit">Quit</button>
</div>
</footer>
<div class="toast" id="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg>

After

Width:  |  Height:  |  Size: 214 B

362
app/mac/IgneumMiner.swift Normal file
View file

@ -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 <out.png> --url <dashboard 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..<nl)
buffer.removeSubrange(0...nl)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
if line.hasPrefix("URL ") {
let u = String(line.dropFirst(4)).trimmingCharacters(in: .whitespaces)
if let url = URL(string: u + "?host=mac") {
self.url = url
placeholder.isHidden = true
web.load(URLRequest(url: url))
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("FATAL ") {
fail(String(line.dropFirst(6)))
} else if line == "EXIT" {
exited = true
if quitting { NSApp.reply(toApplicationShouldTerminate: true) }
}
}
}
func applyState(_ json: String) {
guard let data = json.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
let mining = o["mining"] as? String ?? ""
let node = o["node"] as? String ?? ""
let hash = o["hash_total"] as? Double ?? 0
let blocks = o["blocks"] as? Double ?? 0
let accepted = o["accepted_total"] as? Double ?? 0
let peers = o["peers"] as? Double ?? 0
paused = o["paused"] as? Bool ?? false
let phase = o["phase"] as? String ?? ""
var title = ""
if o["quitting"] as? Bool == true { title = "stopping" }
else if phase != "dashboard" { title = "" }
else if mining == "mining" { title = String(format: "%.1f MH/s", hash) }
else if mining == "paused" { title = "paused" }
else if node == "syncing" || node == "starting" { title = "syncing" }
else { title = mining }
status.button?.title = title.isEmpty ? "" : " " + title
menuPause.title = paused ? "Resume mining" : "Pause mining"
let nf = NumberFormatter(); nf.numberStyle = .decimal
menuNode.title = "Node: \(node), \(nf.string(from: NSNumber(value: blocks)) ?? "0") blocks, \(Int(peers)) peers"
menuBlocks.title = "Blocks found: \(nf.string(from: NSNumber(value: accepted)) ?? "0")"
}
func engineEnded() {
exited = true
if quitting { NSApp.reply(toApplicationShouldTerminate: true); return }
placeholder.stringValue = "The engine stopped. Quit and open Igneum Miner again."
placeholder.isHidden = false
status?.button?.title = " stopped"
}
func fail(_ text: String) {
placeholder.stringValue = text
placeholder.isHidden = false
let a = NSAlert()
a.messageText = "Igneum Miner"
a.informativeText = text
a.runModal()
}
@objc func showWindow() {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc func togglePause() {
send(paused ? "resume" : "pause")
}
func send(_ cmd: String) {
if let d = (cmd + "\n").data(using: .utf8) { engineIn?.write(d) }
}
// ---- quit: miners first, then the node ----
func applicationShouldTerminate(_ sender: NSApplication) -> 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB