From 2920265b1dd3ad738adf27c6246a6afe0f400fef Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:36:38 +0000 Subject: [PATCH] The network step (0.3.23, main's design with two corrections, 7 October 2026): a fourth first-run screen before the address step and a Settings card, two cards, Devnet 3 (igneum-devnet-3, chain id 4463; staging: the chain may reset, coins have no value) and igneum-testnet-1 (the public test chain: coins have no value, resets are announced); a fresh install selects the manifest's default_network (Devnet 3 until the go), the testnet card reads not yet open and is refused until the manifest's testnet_open names it open, a manifest naming the testnet default before the open is refused by the parser; an existing install keeps the network it runs and sees the step as the Settings card; nothing switches a running miner but the user's click and the confirm that names what resets (the node's data and the mining state; the key and the address stay); the choice lives in settings.network, read before the runtime so igneum-testnet-1 starts the node with --testnet --netsuffix=1 and the three seeds as --addpeer (no DNS seeders compiled; no override file on the testnet), taking effect at the next start; api/network, Cmd::Network, config::network_switch with its tests, manifest default_network_rules, the Rust round trips, two view tests known-failed first Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/config.rs | 64 +++++++++++++++++++++++++++++++-- app/igneum-app/src/engine.rs | 41 +++++++++++++++++++-- app/igneum-app/src/main.rs | 7 ++-- app/igneum-app/src/manifest.rs | 30 ++++++++++++++++ app/igneum-app/src/ota.rs | 6 ++++ app/igneum-app/src/server.rs | 7 ++++ app/igneum-app/src/state.rs | 10 ++++++ app/igneum-app/ui/app.css | 14 +++++++- app/igneum-app/ui/app.js | 50 +++++++++++++++++++++++--- app/igneum-app/ui/index.html | 36 ++++++++++++++++--- app/igneum-app/ui/view.test.mjs | 43 ++++++++++++++++++++++ 11 files changed, 292 insertions(+), 16 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 761979d8..5d5b6ba3 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -121,6 +121,10 @@ pub struct Settings { /// idle offset of the card sensor above the room (0 = the default). #[serde(default)] pub region: String, + /// the network step (0.3.23): the chain this machine runs, chosen at first run or in Settings ("devnet-3" | + /// "testnet-1"; "" = not chosen, the package's own network). Read at start; a change takes effect at the next start. + #[serde(default)] + pub network: String, /// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which /// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here. #[serde(default)] @@ -180,13 +184,38 @@ fn balanced() -> String { fn nineteen() -> f64 { 19.0 } +/// The network step's rule (0.3.23): a switch is refused when the network is unknown, when it names the testnet while the +/// manifest has not opened it, and when the engine runs another network and the user has not confirmed what resets. +pub fn network_switch(want: &str, running: &str, testnet_open: bool, confirmed: bool) -> Result<(), String> { + if !["devnet-3", "testnet-1"].contains(&want) { + return Err(format!("'{want}' is not a network this app knows")); + } + if want == "testnet-1" && !testnet_open { + return Err("igneum-testnet-1 is not yet open: the choice waits for the manifest to open it".into()); + } + if network_name(want) != running && !confirmed { + return Err("switching the network needs the confirm: the node's data and the mining state reset at the next start".into()); + } + Ok(()) +} +/// The chain's name for a choice ("" = the package's own, read as Devnet 3 from 0.3.22). +pub fn network_name(choice: &str) -> &'static str { + match choice { + "testnet-1" => "igneum-testnet-1", + _ => "igneum-devnet-3", + } +} +/// The seeds of igneum-testnet-1 (docs/plans/testnet-go.md: p2p 26811, chain id 4462). The node compiles no DNS seeders for +/// the testnet (the node lane, 7 October 2026), so the engine passes these as --addpeer. +pub const TESTNET_SEEDS: [&str; 3] = ["seed1.testnet.igneum.network:26811", "seed2.testnet.igneum.network:26811", "seed3.testnet.igneum.network:26811"]; + fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false } } } @@ -416,8 +445,16 @@ impl Runtime { /// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins. pub fn from_env_with(packaged_suffix: Option, packaged_peers: Option<&[String]>) -> Runtime { + Self::from_env_with_choice(packaged_suffix, packaged_peers, "") + } + /// The network step: `choice` is settings.network ("testnet-1" turns the runtime to the testnet object, `--testnet + /// --netsuffix=1` with the three seeds as peers; "devnet-3" or "" keeps the package's network); the environment wins inside. + pub fn from_env_with_choice(packaged_suffix: Option, packaged_peers: Option<&[String]>, choice: &str) -> 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 testnet = choice == "testnet-1"; + let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| if testnet { "testnet".into() } else { "devnet".into() }); + let packaged_peers: Option<&[String]> = if testnet { None } else { packaged_peers }; + let packaged_suffix = if testnet { None } else { packaged_suffix }; 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") { @@ -426,6 +463,7 @@ impl Runtime { // the hands move off the project lead's Mac to igneum-build-1), then the project lead's Mac on the house LAN (devnet only) Err(_) if packaged_peers.map(|p| !p.is_empty()).unwrap_or(false) => packaged_peers.unwrap().to_vec(), Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()], + Err(_) if network == "testnet" => TESTNET_SEEDS.iter().map(|s| s.to_string()).collect(), Err(_) => vec![], }; let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false); @@ -459,6 +497,28 @@ impl Runtime { #[cfg(test)] mod tests { + #[test] + fn network_switch_rules() { + // the network step: unknown refused; the testnet refused until open; another network needs the confirm; the same network is fine + assert!(super::network_switch("mainnet", "igneum-devnet-3", true, true).is_err()); + assert!(super::network_switch("testnet-1", "igneum-devnet-3", false, true).unwrap_err().contains("not yet open")); + assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, false).unwrap_err().contains("needs the confirm")); + assert!(super::network_switch("testnet-1", "igneum-devnet-3", true, true).is_ok()); + assert!(super::network_switch("devnet-3", "igneum-devnet-3", false, false).is_ok(), "the network already running needs no confirm"); + assert_eq!(super::network_name(""), "igneum-devnet-3"); + assert_eq!(super::network_name("testnet-1"), "igneum-testnet-1"); + // a fresh settings file carries no choice; an old file without the field reads none too, so it keeps the package's network + assert_eq!(super::Settings::default().network, ""); + let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap(); + assert_eq!(old.network, ""); + let chosen: super::Settings = serde_json::from_str(r#"{"network":"testnet-1"}"#).unwrap(); + assert_eq!(chosen.network, "testnet-1"); + let r = super::Runtime::from_env_with_choice(Some(3), None, "testnet-1"); + assert_eq!(r.network, "testnet"); + assert_eq!(r.devnet_suffix, None); + assert!(r.peers.iter().any(|p| p.starts_with("seed1.testnet.igneum.network"))); + } + #[test] fn currency_round_trip() { // currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as "" diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 2e230394..bce74fb6 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -104,6 +104,8 @@ pub enum Cmd { TuneCardGoal(String, String), /// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step) Region(Option, Option, Option), + /// the network step: the chosen network ("devnet-3" | "testnet-1") and whether the user confirmed what resets + Network(String, bool), /// Ember Heat: the switch, the set point, the schedule text with the window's clock offset, a typed room reading Heat(HeatPatch), /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the @@ -175,6 +177,8 @@ impl Shared { } let mut st = State { version: VERSION.into(), ..Default::default() }; st.network = runtime.network.clone(); + st.network_name = if runtime.network == "testnet" { "igneum-testnet-1".into() } else if runtime.devnet_suffix == Some(3) { "igneum-devnet-3".into() } else if runtime.network == "devnet" && runtime.devnet_suffix.is_none() { "igneum-devnet-v4".into() } else { format!("igneum-{}-{}", runtime.network, runtime.devnet_suffix.unwrap_or(0)) }; + st.network_pending = if !settings.network.is_empty() && crate::config::network_name(&settings.network) != st.network_name { settings.network.clone() } else { String::new() }; st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) }; st.host = runtime.host.clone(); st.display_name = if settings.display_name.is_empty() { runtime.host.clone() } else { settings.display_name.clone() }; @@ -190,7 +194,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), network: settings.network.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead }; st.heat = heat_state_off(settings.heat_on, settings.heat_set_c); st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); @@ -1559,6 +1563,30 @@ impl Engine { drop(st); self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" })); } + Cmd::Network(want, confirmed) => { + let (running, open) = { let st = self.st(); (st.network_name.clone(), st.update.testnet_open) }; + match crate::config::network_switch(&want, &running, open, confirmed) { + Err(why) => { + self.shared.event("warn", &format!("network: {why}")); + self.st().network_error = why; + } + Ok(()) => { + { + let mut s = self.shared.settings.lock().unwrap(); + s.network = want.clone(); + } + self.shared.save_settings(); + let pending = if crate::config::network_name(&want) != running { want.clone() } else { String::new() }; + { + let mut st = self.st(); + st.network_error.clear(); + st.settings.network = want.clone(); + st.network_pending = pending.clone(); + } + self.shared.event("info", &format!("network: {}{}", crate::config::network_name(&want), if pending.is_empty() { " (already running)" } else { ": takes effect at the next start" })); + } + } + } Cmd::Region(region, price, currency) => { let (r, p, c) = { let mut s = self.shared.settings.lock().unwrap(); @@ -2019,11 +2047,18 @@ impl Engine { if let Some(n) = r.devnet_suffix { a.push(format!("--devnet-suffix={n}")); } + if r.network == "testnet" { + // the node lane, 7 October 2026: `--testnet` alone takes suffix 10 and exits; igneum-testnet-1 is --netsuffix=1 + a.push("--netsuffix=1".into()); + } for p in &r.peers { a.push(format!("--addpeer={p}")); } - if let Some(path) = self.node_override_file() { - a.push(format!("--override-params-file={}", path.display())); + if r.network != "testnet" { + // the testnet object refuses an override file (the node lane, 7 October 2026) + if let Some(path) = self.node_override_file() { + a.push(format!("--override-params-file={}", path.display())); + } } a.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string())); if r.unsynced_mining { diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 7e55a060..f15447b1 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -157,8 +157,11 @@ fn main() { let packaged = config::Packaged::load(&candidates).with_env_overrides(); // Devnet 3 (7 October 2026): the package names the network its node joins; the runtime read above knew only the // environment, so it is read again with the package's suffix and peers (the environment still wins inside) - let runtime = if packaged.node_devnet_suffix.is_some() || packaged.node_peers.is_some() { - config::Runtime::from_env_with(packaged.node_devnet_suffix, packaged.node_peers.as_deref()) + // the network step (0.3.23): the saved choice is read first (the settings file lives in /app whatever the + // network), so settings.network can turn the runtime to the testnet object before the node starts + let chosen_network = config::Settings::load(&runtime.app_dir.join("settings.json")).network; + let runtime = if packaged.node_devnet_suffix.is_some() || packaged.node_peers.is_some() || !chosen_network.is_empty() { + config::Runtime::from_env_with_choice(packaged.node_devnet_suffix, packaged.node_peers.as_deref(), &chosen_network) } else { runtime }; diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 460c9120..08bb8746 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -69,6 +69,10 @@ pub struct Manifest { pub tuning: Option, /// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published pub ui: Option, + /// the network step (0.3.23): the network a FRESH install selects by default ("devnet-3" | "testnet-1"; "" = the + /// package's own), and whether igneum-testnet-1 is open; a manifest naming the testnet default before the open is refused + pub default_network: String, + pub testnet_open: bool, /// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and /// written as is to /drivers.json; None = no table published, the rows say nothing about drivers pub drivers: Option, @@ -187,7 +191,17 @@ pub fn parse(text: &str) -> Result { Ok(Some(e)) }; let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null); + let default_network = s(&v, "default_network"); + let testnet_open = v.get("testnet_open").and_then(|b| b.as_bool()).unwrap_or(false); + if !default_network.is_empty() && !["devnet-3", "testnet-1"].contains(&default_network.as_str()) { + return Err(format!("default_network '{default_network}' is not a network this app knows")); + } + if default_network == "testnet-1" && !testnet_open { + return Err("default_network names the testnet before it is open (testnet_open is not true)".into()); + } Ok(Manifest { + default_network, + testnet_open, version, published_at: s(&v, "published_at"), channel: s(&v, "channel"), @@ -535,6 +549,22 @@ mod tests { (sk, pk) } + #[test] + fn default_network_rules() { + // the network step: absent = the package's own; devnet-3 fine; the testnet default only once the manifest opens it + let m = parse(SAMPLE).unwrap(); + assert_eq!(m.default_network, ""); + assert!(!m.testnet_open); + let d = parse(r#"{"version":"0.3.23","default_network":"devnet-3"}"#).unwrap(); + assert_eq!(d.default_network, "devnet-3"); + let e = parse(r#"{"version":"0.3.23","default_network":"testnet-1"}"#).unwrap_err(); + assert!(e.contains("before it is open"), "{e}"); + let t = parse(r#"{"version":"0.3.23","default_network":"testnet-1","testnet_open":true}"#).unwrap(); + assert_eq!(t.default_network, "testnet-1"); + assert!(t.testnet_open); + assert!(parse(r#"{"version":"0.3.23","default_network":"mainnet"}"#).unwrap_err().contains("not a network")); + } + #[test] fn parses_manifest() { let m = parse(SAMPLE).unwrap(); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index a35a761c..a3a126b6 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -754,6 +754,12 @@ impl Updater { self.write_tuning(shared, &m); self.write_drivers(shared, &m); self.ui_change = Some(m.ui.clone()); + { + // the network step: the manifest's default for a fresh install and the testnet's open flag + let mut st = shared.state.lock().unwrap(); + st.update.default_network = m.default_network.clone(); + st.update.testnet_open = m.testnet_open; + } if let Some(e) = entry { if changed { self.file = None; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 28baeee3..b124fd80 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -313,6 +313,13 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + // the network step: {network: "devnet-3" | "testnet-1", confirm: bool}; the engine applies config::network_switch + let want = body.get("network").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let confirm = body.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false); + shared.send(Cmd::Network(want, confirm)); + Ok(json!({ "ok": true })) + } "/api/region" => { // Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched) let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 0a368dab..d60fd270 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -365,6 +365,8 @@ pub struct SettingsState { pub region: String, /// currency-21: the ISO 4217 override from the settings file ("" = follow the region) pub currency: String, + /// the network step: the chosen network ("" = the package's own) + pub network: String, pub heat_on: bool, pub heat_set_c: f64, pub heat_schedule: String, @@ -506,6 +508,9 @@ pub struct UpdateState { pub min_supported: String, pub channel: String, pub published_at: String, + /// the network step: the manifest's default for a fresh install and whether the testnet is open + pub default_network: String, + pub testnet_open: bool, pub file: String, // the downloaded installer or disk image (the manual path opens it) pub updated_from: String, // set on the first run after an update pub rolled_back: String, // set when the helper restored the previous version @@ -533,6 +538,11 @@ pub struct State { pub phase: String, // welcome | cards | address | dashboard pub setup_done: bool, pub network: String, + /// the network step: the chain this engine runs by name (igneum-devnet-3, igneum-testnet-1, igneum-devnet-v4) and + /// the one chosen for the next start when it differs ("" = none) + pub network_name: String, + pub network_pending: String, + pub network_error: String, pub chain: String, pub host: String, // the hostname, a friendly label only pub display_name: String, // the user's name for this machine (settings), defaults to the hostname diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 76244e79..27805a21 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -856,6 +856,18 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} #bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis} .help.ask-cur{color:var(--ember)} +/* the network step (0.3.23): two cards, the chosen one in ember, the one running marked, a closed one dimmed */ +.net-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s-3);margin:var(--s-3) 0} +.net-card{display:flex;flex-direction:column;gap:4px;text-align:left;padding:14px 16px;border-radius:12px;border:1px solid var(--line-2);background:var(--row);color:var(--bone);cursor:pointer;min-width:0} +.net-card:hover{border-color:var(--ash)} +.net-card[aria-checked="true"]{border-color:var(--ember);box-shadow:inset 0 0 0 1px var(--ember)} +.net-card[disabled]{opacity:.55;cursor:default} +.net-card .nc-name{font-family:var(--head);font-weight:700;font-size:var(--t-md)} +.net-card .nc-line{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;color:var(--ash)} +.net-card .nc-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.4} +.net-card .nc-cur{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten)} +@media (max-width:720px){.net-cards{grid-template-columns:1fr}} + /* scaling-21 (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same @@ -873,4 +885,4 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} #page-overview #dag-live{height:184px} #page-overview .legend{margin-top:8px} #page-overview .wait-card{padding:12px 20px} -#page-overview .node-card{padding-top:18px} +#page-overview .node-card{padding-top:18px} \ No newline at end of file diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 1204cdfb..9fc61652 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -305,6 +305,25 @@ var View = (function () { // the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); } + // ---------- the network step (0.3.23) ---------- + // Two cards. A fresh install selects the manifest's default_network (Devnet 3 until the go); the testnet card reads + // "not yet open" and is refused until the manifest names it open; an existing install keeps the network it runs. + var NETWORKS = [ + { id: 'devnet-3', label: 'Devnet 3', name: 'igneum-devnet-3', chain: 4463, sub: 'staging: the chain may reset, coins have no value' }, + { id: 'testnet-1', label: 'igneum-testnet-1', name: 'igneum-testnet-1', chain: 4462, sub: 'the public test chain: coins have no value, resets are announced' } + ]; + function networkIdOf(name) { for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].name === name) return NETWORKS[i].id; return 'devnet-3'; } + function networkWords(s) { + s = s || {}; var st = s.settings || {}, u = s.update || {}, open = !!u.testnet_open, current = networkIdOf(s.network_name || ''), pending = s.network_pending || ''; + var chosen = st.network || '', def = (u.default_network === 'testnet-1' && open) ? 'testnet-1' : 'devnet-3'; + var selected = s.setup_done ? (pending || current) : (chosen || def); + var cards = NETWORKS.map(function (n) { var isOpen = n.id !== 'testnet-1' || open; return { id: n.id, label: n.label, name: n.name, line: n.id === 'devnet-3' ? 'igneum-devnet-3, chain id 4463' : (open ? 'chain id 4462' : 'chain id 4462 · not yet open'), sub: n.sub, open: isOpen, selected: n.id === selected, current: !!s.setup_done && n.id === current }; }); + var pend = NETWORKS.filter(function (n) { return n.id === pending; })[0]; + var line = pend ? 'Switching to ' + pend.name + ' at the next start: quit Igneum Miner and open it again.' : s.setup_done ? 'This machine runs ' + (s.network_name || 'igneum-devnet-3') + '.' : ''; + return { cards: cards, selected: selected, current: current, pending: pending, line: line, error: s.network_error || '' }; + } + function networkAsk(want, runningName) { var n = NETWORKS.filter(function (x) { return x.id === want; })[0]; return 'Switch this machine to ' + (n ? n.name : want) + '? The node’s data and the mining state on ' + runningName + ' reset at the next start. Your key and your address stay.'; } + // ---------- vendor marks (gpu-logos, 7 October 2026) ---------- // One simplified monochrome glyph per GPU vendor, drawn by hand from the public mark's geometry (never a copied logo // file, never a raster): a nominative use that names the hardware. Each glyph is under 2 KB and takes currentColor, @@ -1277,7 +1296,7 @@ var View = (function () { } function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords }; + return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1398,6 +1417,7 @@ if (typeof document !== 'undefined') (function () { if (!dash && drawerOpen()) setDrawer(false); if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } // the region step; ?region= preselects one for screenshots + if (p === 'network' && state) renderNetworkStep(state); if (p === 'region') { fillRegions($('region-select')); fillCurrencies($('region-currency'), regionNow() || View.regionFromLocale(lang)); var rc = params.get('region') || regionNow() || View.regionFromLocale(lang); $('region-select').value = rc; $('region-currency').value = currencyNowCode(); var ask0 = View.currencyAsk(View.currencyChoice({ region: rc, currency: currencyNowCode() }, lang)); setText('region-currency-ask', ask0); setHidden('region-currency-ask', !ask0); regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); } if (dash) { showPage(page); mountDag(); } api('api/phase', { phase: p }); @@ -1459,6 +1479,27 @@ if (typeof document !== 'undefined') (function () { if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); } } function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; } + // ---------- the network step (0.3.23): the cards on the first-run step and in Settings ---------- + var netPick = ''; + function renderNetCards(el, w, pick) { + el.innerHTML = w.cards.map(function (c) { var on = (pick || w.selected) === c.id; return ''; }).join(''); + } + function renderNetworkStep(s) { + var w = View.networkWords(s); renderNetCards($('network-cards'), w, netPick); + var note = w.error || (w.cards[1].open ? '' : 'igneum-testnet-1 opens when the network does; Devnet 3 is the chain to mine today.'); + setText('network-note', note); setHidden('network-note', !note); + } + $('network-cards').addEventListener('click', function (e) { var b = e.target.closest('.net-card'); if (!b || b.disabled) return; netPick = b.getAttribute('data-net'); if (state) renderNetworkStep(state); }); + $('btn-network-back').addEventListener('click', function () { show('region'); }); + $('btn-network-next').addEventListener('click', function () { var w = View.networkWords(state || {}), pick = netPick || w.selected; api('api/network', { network: pick, confirm: true }).then(function () { show('address'); }).catch(function () { show('address'); }); }); + function renderNetworkCard(s) { + var w = View.networkWords(s); renderNetCards($('s-network-cards'), w, ''); + setText('s-network-line', w.line); setText('s-network-eyebrow', w.pending ? 'next start' : 'running'); + setText('s-network-note', w.error); setHidden('s-network-note', !w.error); + } + $('s-network-cards').addEventListener('click', function (e) { var b = e.target.closest('.net-card'); if (!b || b.disabled || !state) return; var want = b.getAttribute('data-net'), w = View.networkWords(state); if (want === w.selected) return; $('ask-network-text').textContent = View.networkAsk(want, state.network_name || 'igneum-devnet-3'); $('ask-network').dataset.want = want; $('ask-network').hidden = false; }); + $('ask-network-no').addEventListener('click', function () { $('ask-network').hidden = true; }); + $('ask-network-yes').addEventListener('click', function () { var want = $('ask-network').dataset.want; $('ask-network').hidden = true; api('api/network', { network: want, confirm: true }).then(function (r) { if (r.ok) toast('Network saved: it takes effect at the next start'); }); }); function saveRegion(code, price, cur) { store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null); if (cur !== undefined) store('igneum.currency', cur || null); var body = { region: code, price_pence: price }; if (cur !== undefined) body.currency = cur || ''; @@ -1474,14 +1515,14 @@ if (typeof document !== 'undefined') (function () { $('region-select').addEventListener('change', function () { regionWords(this.value, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), true); }); $('region-currency').addEventListener('change', function () { saveCurrency(this, $('region-price'), $('region-unit'), $('region-note'), $('region-select')); var a = View.currencyAsk(View.currencyChoice({ region: $('region-select').value, currency: this.value }, lang)); setText('region-currency-ask', a); setHidden('region-currency-ask', !a); }); $('btn-region-back').addEventListener('click', function () { show('cards'); }); - $('btn-region-skip').addEventListener('click', function () { show('address'); }); + $('btn-region-skip').addEventListener('click', function () { show('network'); }); $('btn-region-next').addEventListener('click', function () { var code = $('region-select').value, p = parseFloat($('region-price').value) * View.priceFactor(View.currencyNow().code); if (!(p >= 0)) p = 0; - saveRegion(code, p, $('region-currency').value).then(function () { show('address'); }).catch(function () { show('address'); }); + saveRegion(code, p, $('region-currency').value).then(function () { show('network'); }).catch(function () { show('network'); }); }); $('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); }); - $('btn-address-back').addEventListener('click', function () { show('cards'); }); + $('btn-address-back').addEventListener('click', function () { show('network'); }); // address options var opts = document.querySelectorAll('.option'); @@ -2498,6 +2539,7 @@ if (typeof document !== 'undefined') (function () { setText('s-profile-text', View.profileWords(!!st.profile_public)); setHidden('s-climb-row', st.tune_climb === undefined); setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : '')); + renderNetworkCard(s); fillRegions($('s-region')); var rc = regionNow(); if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 5d27fb6f..43f097ad 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -104,7 +104,7 @@
-
step 1 of 3
+
step 1 of 4

Your graphics card

Asking the graphics cards to report in.

@@ -129,7 +129,7 @@
-
step 2 of 3
+
step 2 of 4

Your electricity

Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.

@@ -157,10 +157,26 @@
- + +
+
+
step 3 of 4
+

Which network

+

The chain this machine mines. You can change it later in Settings; a change takes effect at the next start.

+
+ +
+ + +
+
+
+ +
-
step 3 of 3
+
step 4 of 4

Where should rewards go?

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

@@ -527,6 +543,18 @@
+
+

Network

+

+
+ + +
+

This machine

diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 4b9797ae..f5c4c269 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -1055,3 +1055,46 @@ test('a locale with no currency row falls back to USD with the override prompt v assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), ''); assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step'); }); + +// ---------- the network step (0.3.23, main's design with two corrections, 7 October 2026) ---------- +// A fourth first-run screen and a Settings card: two cards, Devnet 3 and igneum-testnet-1. The fresh-install default comes +// from the manifest's default_network (Devnet 3 until the go); the testnet card reads "not yet open" and is refused until the +// manifest names it open; an existing install keeps its network; nothing switches a running miner without the user's click +// and the confirm that names what resets. +test('network words: the manifest default selects the card on a fresh install, the testnet card is refused until open, an existing install keeps its network (known-failed on 0.3.23)', () => { + const fresh = { setup_done: false, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'devnet-3', testnet_open: false } }; + const w = V.networkWords(fresh); + assert.deepEqual(w.cards.map((c) => c.id), ['devnet-3', 'testnet-1']); + assert.equal(w.selected, 'devnet-3', 'the manifest default'); + const dn3 = w.cards[0], tn = w.cards[1]; + assert.equal(dn3.label, 'Devnet 3'); assert.equal(dn3.line, 'igneum-devnet-3, chain id 4463'); assert.equal(dn3.sub, 'staging: the chain may reset, coins have no value'); assert.equal(dn3.open, true); + assert.equal(tn.label, 'igneum-testnet-1'); assert.equal(tn.line, 'chain id 4462 · not yet open'); assert.equal(tn.sub, 'the public test chain: coins have no value, resets are announced'); assert.equal(tn.open, false); + assert.equal(V.networkWords({ ...fresh, update: { default_network: '', testnet_open: false } }).selected, 'devnet-3', 'no manifest read yet: Devnet 3'); + const open = V.networkWords({ ...fresh, update: { default_network: 'testnet-1', testnet_open: true } }); + assert.equal(open.selected, 'testnet-1'); assert.equal(open.cards[1].line, 'chain id 4462'); assert.equal(open.cards[1].open, true); + // an existing install keeps what it runs; the card it runs is marked current + const old = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: '', settings: { network: '' }, update: { default_network: 'testnet-1', testnet_open: true } }); + assert.equal(old.selected, 'devnet-3'); assert.equal(old.current, 'devnet-3'); assert.equal(old.cards[0].current, true); + assert.equal(old.line, 'This machine runs igneum-devnet-3.'); + // the pending state after a confirmed switch + const pend = V.networkWords({ setup_done: true, network_name: 'igneum-devnet-3', network_pending: 'testnet-1', settings: { network: 'testnet-1' }, update: { default_network: 'testnet-1', testnet_open: true } }); + assert.equal(pend.line, 'Switching to igneum-testnet-1 at the next start: quit Igneum Miner and open it again.'); + for (const s of [w, old, pend]) for (const c of s.cards) assert.equal(/—|–/.test(c.label + c.line + c.sub), false); +}); + +test('the switch is refused without the confirm, and the confirm names what resets; the testnet choice is refused while not open', () => { + assert.equal(V.networkAsk('testnet-1', 'igneum-devnet-3'), 'Switch this machine to igneum-testnet-1? The node’s data and the mining state on igneum-devnet-3 reset at the next start. Your key and your address stay.'); + assert.equal(V.networkAsk('devnet-3', 'igneum-testnet-1'), 'Switch this machine to igneum-devnet-3? The node’s data and the mining state on igneum-testnet-1 reset at the next start. Your key and your address stay.'); + // the Rust rule the server applies (config::network_switch): mirrored here by its source text + const cfg = rustSrc('config.rs'); + assert.match(cfg, /pub fn network_switch\(want: &str, running: &str, testnet_open: bool, confirmed: bool\) -> Result<\(\), String>/); + assert.match(cfg, /fn network_switch_rules\(\)/, 'the Rust test exists'); + assert.match(rustSrc('manifest.rs'), /default_network names the testnet before it is open/, 'a manifest with the testnet default before the open is refused'); + assert.match(rustSrc('manifest.rs'), /fn default_network_rules\(\)/, 'the manifest test exists'); + assert.match(rustSrc('server.rs'), /"\/api\/network" =>/); + // the markup: the step sits before the address step, four steps now; the Settings card exists + const idx = pageHtml; + assert.ok(idx.indexOf('id="screen-network"') > idx.indexOf('id="screen-region"') && idx.indexOf('id="screen-network"') < idx.indexOf('id="screen-address"'), 'the network step comes after the region step and before the address step'); + assert.equal((idx.match(/step [1-4] of 4/g) || []).length, 4, 'four steps'); + assert.ok(idx.includes('id="s-network-cards"') && idx.includes('id="ask-network"'), 'the Settings card and its confirm'); +});