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>
1
app/igneum-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
394
app/igneum-app/Cargo.lock
generated
Normal 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
|
|
@ -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
|
||||
127
app/igneum-app/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
213
app/igneum-app/src/detect.rs
Normal 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
94
app/igneum-app/src/keys.rs
Normal 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
|
|
@ -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)
|
||||
}
|
||||
295
app/igneum-app/src/platform.rs
Normal 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('&', "&").replace('<', "<")))
|
||||
.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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
223
app/igneum-app/src/server.rs
Normal 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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
86
app/igneum-app/src/update.rs
Normal 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
|
|
@ -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
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); }
|
||||
function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||||
function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; }
|
||||
function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
|
||||
function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; }
|
||||
function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }
|
||||
var toastT = null;
|
||||
function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); }
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast('Copied'); }, function () { fallbackCopy(text); }); }
|
||||
else fallbackCopy(text);
|
||||
}
|
||||
function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast('Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('[data-copy]'); if (!b) return;
|
||||
copyText($(b.getAttribute('data-copy')).textContent);
|
||||
});
|
||||
|
||||
// ---------- screens ----------
|
||||
function show(p) {
|
||||
phase = p;
|
||||
document.body.setAttribute('data-phase', p);
|
||||
var dash = p === 'dashboard';
|
||||
$('bottom').hidden = !dash;
|
||||
$('btn-settings').hidden = !dash;
|
||||
document.body.classList.toggle('has-bottom', dash);
|
||||
if (!dash) document.body.classList.remove('drawer-open');
|
||||
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
|
||||
api('api/phase', { phase: p });
|
||||
}
|
||||
$('btn-begin').addEventListener('click', function () { show('cards'); });
|
||||
$('btn-cards-next').addEventListener('click', function () { show('address'); });
|
||||
$('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); });
|
||||
$('btn-address-back').addEventListener('click', function () { show('cards'); });
|
||||
|
||||
// address options
|
||||
var opts = document.querySelectorAll('.option');
|
||||
function pickOption(el) { opts.forEach(function (o) { o.classList.toggle('on', o === el); }); el.querySelector('input').checked = true; if (el.id === 'opt-paste') $('addr-input').focus(); }
|
||||
opts.forEach(function (o) { o.addEventListener('click', function (e) { if (e.target.tagName !== 'INPUT' || e.target.type === 'radio') pickOption(o); }); });
|
||||
$('addr-input').addEventListener('focus', function () { pickOption($('opt-paste')); });
|
||||
function validAddr(a) { return /^0x[0-9a-fA-F]{40}$/.test((a || '').trim()); }
|
||||
$('btn-start').addEventListener('click', function () {
|
||||
var mode = document.querySelector('input[name=mode]:checked').value;
|
||||
var body = { mode: mode };
|
||||
if (mode === 'paste') {
|
||||
var a = $('addr-input').value.trim();
|
||||
if (!validAddr(a)) { $('addr-err').hidden = false; return; }
|
||||
$('addr-err').hidden = true; body.address = a;
|
||||
}
|
||||
$('btn-start').disabled = true;
|
||||
api('api/setup', body).then(function (r) {
|
||||
$('btn-start').disabled = false;
|
||||
if (!r.ok) { $('addr-err').textContent = r.error || 'could not save'; $('addr-err').hidden = false; return; }
|
||||
if (mode === 'generate' && r.private_key) { showKeySheet(r); }
|
||||
else { api('api/start', {}).then(function () { show('dashboard'); }); }
|
||||
}).catch(function () { $('btn-start').disabled = false; toast('The engine did not answer'); });
|
||||
});
|
||||
function showKeySheet(r) {
|
||||
$('key-address').textContent = r.display || r.address;
|
||||
$('key-private').textContent = r.private_key;
|
||||
$('key-file').textContent = r.wallet_file || '';
|
||||
$('key-ack').checked = false; $('btn-key-done').disabled = true;
|
||||
$('sheet-key').hidden = false; keyShownThisRun = true;
|
||||
}
|
||||
$('key-ack').addEventListener('change', function () { $('btn-key-done').disabled = !this.checked; });
|
||||
$('btn-key-done').addEventListener('click', function () {
|
||||
api('api/key/saved', {}).then(function () { return api('api/start', {}); }).then(function () { $('sheet-key').hidden = true; $('key-private').textContent = ''; show('dashboard'); });
|
||||
});
|
||||
|
||||
// ---------- settings ----------
|
||||
$('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; });
|
||||
$('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; });
|
||||
$('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); });
|
||||
function fillSettings() {
|
||||
if (!state) return;
|
||||
$('s-address').textContent = state.address.display || 'not set';
|
||||
$('s-identities').value = state.settings.identities;
|
||||
$('s-vote').checked = state.settings.vote;
|
||||
$('s-login').checked = state.settings.start_at_login;
|
||||
$('s-version').textContent = 'Igneum Miner ' + state.version;
|
||||
$('s-node-dir').textContent = 'chain data: ' + state.node_dir;
|
||||
$('s-log-dir').textContent = 'logs: ' + state.log_dir;
|
||||
$('s-reveal').hidden = state.address.source !== 'generated';
|
||||
$('s-live').hidden = !state.live_page;
|
||||
var u = state.update;
|
||||
$('s-update-note').textContent = u.available ? ('Version ' + u.version + ' is available.') : (u.error ? u.error : (u.checked_at ? 'This is the latest version.' : ''));
|
||||
}
|
||||
$('s-address-save').addEventListener('click', function () {
|
||||
var a = $('s-address-input').value.trim();
|
||||
if (!validAddr(a)) { $('s-address-err').textContent = 'An address is 0x followed by 40 hex characters.'; $('s-address-err').hidden = false; return; }
|
||||
api('api/settings', { address: a }).then(function (r) { if (r.ok) { $('s-address-err').hidden = true; $('s-address-input').value = ''; toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } });
|
||||
});
|
||||
$('s-reveal').addEventListener('click', function () {
|
||||
api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; } else toast(r.error || 'no key'); });
|
||||
});
|
||||
$('s-identities-save').addEventListener('click', function () {
|
||||
var n = parseInt($('s-identities').value, 10); if (!(n >= 1 && n <= 64)) { toast('1 to 64'); return; }
|
||||
api('api/settings', { identities: n }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); });
|
||||
});
|
||||
$('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
|
||||
$('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); });
|
||||
$('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
|
||||
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
|
||||
$('update-get').addEventListener('click', function () { if (state && state.update.url) api('api/open', { url: state.update.url }); });
|
||||
$('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; });
|
||||
|
||||
// ---------- bottom bar ----------
|
||||
$('btn-pause').addEventListener('click', function () {
|
||||
if (!state) return;
|
||||
api(state.mining.paused ? 'api/resume' : 'api/pause', {});
|
||||
});
|
||||
$('btn-logs').addEventListener('click', function () { document.body.classList.toggle('drawer-open'); if (document.body.classList.contains('drawer-open')) pollLog(); });
|
||||
$('btn-quit').addEventListener('click', function () {
|
||||
if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return;
|
||||
api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true;
|
||||
});
|
||||
|
||||
// ---------- log drawer ----------
|
||||
var logAfter = 0, logFilter = '', logLines = [];
|
||||
document.querySelectorAll('.chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('.chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); renderLog(); }); });
|
||||
function renderLog() {
|
||||
var el = $('log'), follow = $('log-follow').checked;
|
||||
var lines = logLines.filter(function (l) { return !logFilter || l.src.indexOf(logFilter) === 0; }).slice(-600);
|
||||
el.innerHTML = lines.map(function (l) { return '<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();
|
||||
})();
|
||||
BIN
app/igneum-app/ui/fonts/IBMPlexMono-400.woff2
Normal file
BIN
app/igneum-app/ui/fonts/IBMPlexMono-500.woff2
Normal file
BIN
app/igneum-app/ui/fonts/IBMPlexSans-400.woff2
Normal file
BIN
app/igneum-app/ui/fonts/IBMPlexSans-500.woff2
Normal file
BIN
app/igneum-app/ui/fonts/IBMPlexSans-600.woff2
Normal file
BIN
app/igneum-app/ui/fonts/Unbounded-500.woff2
Normal file
BIN
app/igneum-app/ui/fonts/Unbounded-700.woff2
Normal file
BIN
app/igneum-app/ui/fonts/Unbounded-900.woff2
Normal file
294
app/igneum-app/ui/index.html
Normal 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 · 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">×</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>
|
||||
1
app/igneum-app/ui/mark.svg
Normal 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
|
|
@ -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()
|
||||
BIN
docs/design/app-screens/mac-address-800.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/design/app-screens/mac-address.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
docs/design/app-screens/mac-cards.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/design/app-screens/mac-dashboard-800.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/design/app-screens/mac-dashboard.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
docs/design/app-screens/mac-key.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/design/app-screens/mac-welcome.png
Normal file
|
After Width: | Height: | Size: 315 KiB |