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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 19:36:38 +00:00
parent ecc8fb5443
commit 2920265b1d
11 changed files with 292 additions and 16 deletions

View file

@ -121,6 +121,10 @@ pub struct Settings {
/// idle offset of the card sensor above the room (0 = the default). /// idle offset of the card sensor above the room (0 = the default).
#[serde(default)] #[serde(default)]
pub region: String, 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 /// 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. /// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
#[serde(default)] #[serde(default)]
@ -180,13 +184,38 @@ fn balanced() -> String {
fn nineteen() -> f64 { fn nineteen() -> f64 {
19.0 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 { fn yes() -> bool {
true true
} }
impl Default for Settings { impl Default for Settings {
fn default() -> 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. /// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins.
pub fn from_env_with(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>) -> Runtime { pub fn from_env_with(packaged_suffix: Option<u32>, 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<u32>, packaged_peers: Option<&[String]>, choice: &str) -> Runtime {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); 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 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 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") { 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) // 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 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 == "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![], Err(_) => vec![],
}; };
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false); let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
@ -459,6 +497,28 @@ impl Runtime {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn currency_round_trip() { 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 "" // currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""

View file

@ -104,6 +104,8 @@ pub enum Cmd {
TuneCardGoal(String, String), TuneCardGoal(String, String),
/// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step) /// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step)
Region(Option<String>, Option<f64>, Option<String>), Region(Option<String>, Option<f64>, Option<String>),
/// 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 /// Ember Heat: the switch, the set point, the schedule text with the window's clock offset, a typed room reading
Heat(HeatPatch), Heat(HeatPatch),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// 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() }; let mut st = State { version: VERSION.into(), ..Default::default() };
st.network = runtime.network.clone(); 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.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) };
st.host = runtime.host.clone(); st.host = runtime.host.clone();
st.display_name = if settings.display_name.is_empty() { runtime.host.clone() } else { settings.display_name.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.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total; st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path); 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.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.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(); st.live_page = packaged.live_page.clone();
@ -1559,6 +1563,30 @@ impl Engine {
drop(st); 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 { "" })); 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) => { Cmd::Region(region, price, currency) => {
let (r, p, c) = { let (r, p, c) = {
let mut s = self.shared.settings.lock().unwrap(); let mut s = self.shared.settings.lock().unwrap();
@ -2019,11 +2047,18 @@ impl Engine {
if let Some(n) = r.devnet_suffix { if let Some(n) = r.devnet_suffix {
a.push(format!("--devnet-suffix={n}")); 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 { for p in &r.peers {
a.push(format!("--addpeer={p}")); a.push(format!("--addpeer={p}"));
} }
if let Some(path) = self.node_override_file() { if r.network != "testnet" {
a.push(format!("--override-params-file={}", path.display())); // 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())); a.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string()));
if r.unsynced_mining { if r.unsynced_mining {

View file

@ -157,8 +157,11 @@ fn main() {
let packaged = config::Packaged::load(&candidates).with_env_overrides(); 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 // 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) // 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() { // the network step (0.3.23): the saved choice is read first (the settings file lives in <data root>/app whatever the
config::Runtime::from_env_with(packaged.node_devnet_suffix, packaged.node_peers.as_deref()) // 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 { } else {
runtime runtime
}; };

View file

@ -69,6 +69,10 @@ pub struct Manifest {
pub tuning: Option<serde_json::Value>, pub tuning: Option<serde_json::Value>,
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published /// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
pub ui: Option<UiEntry>, pub ui: Option<UiEntry>,
/// 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 /// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and
/// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers /// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers
pub drivers: Option<serde_json::Value>, pub drivers: Option<serde_json::Value>,
@ -187,7 +191,17 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
Ok(Some(e)) Ok(Some(e))
}; };
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null); 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 { Ok(Manifest {
default_network,
testnet_open,
version, version,
published_at: s(&v, "published_at"), published_at: s(&v, "published_at"),
channel: s(&v, "channel"), channel: s(&v, "channel"),
@ -535,6 +549,22 @@ mod tests {
(sk, pk) (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] #[test]
fn parses_manifest() { fn parses_manifest() {
let m = parse(SAMPLE).unwrap(); let m = parse(SAMPLE).unwrap();

View file

@ -754,6 +754,12 @@ impl Updater {
self.write_tuning(shared, &m); self.write_tuning(shared, &m);
self.write_drivers(shared, &m); self.write_drivers(shared, &m);
self.ui_change = Some(m.ui.clone()); 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 let Some(e) = entry {
if changed { if changed {
self.file = None; self.file = None;

View file

@ -313,6 +313,13 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::TuneGoal(goal, price, climb)); shared.send(Cmd::TuneGoal(goal, price, climb));
Ok(json!({ "ok": true })) Ok(json!({ "ok": true }))
} }
"/api/network" => {
// 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" => { "/api/region" => {
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched) // 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()); let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());

View file

@ -365,6 +365,8 @@ pub struct SettingsState {
pub region: String, pub region: String,
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region) /// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
pub currency: String, pub currency: String,
/// the network step: the chosen network ("" = the package's own)
pub network: String,
pub heat_on: bool, pub heat_on: bool,
pub heat_set_c: f64, pub heat_set_c: f64,
pub heat_schedule: String, pub heat_schedule: String,
@ -506,6 +508,9 @@ pub struct UpdateState {
pub min_supported: String, pub min_supported: String,
pub channel: String, pub channel: String,
pub published_at: 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 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 updated_from: String, // set on the first run after an update
pub rolled_back: String, // set when the helper restored the previous version 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 phase: String, // welcome | cards | address | dashboard
pub setup_done: bool, pub setup_done: bool,
pub network: String, 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 chain: String,
pub host: String, // the hostname, a friendly label only 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 pub display_name: String, // the user's name for this machine (settings), defaults to the hostname

View file

@ -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} #bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.help.ask-cur{color:var(--ember)} .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 /* 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 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 card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same

View file

@ -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 // 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(' · '); } 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) ---------- // ---------- 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 // 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, // 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 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.'; } 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) ---------- /* ---------- 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 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 (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
// the region step; ?region=<code> preselects one for screenshots // the region step; ?region=<code> 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 (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(); } if (dash) { showPage(page); mountDag(); }
api('api/phase', { phase: p }); api('api/phase', { phase: p });
@ -1459,6 +1479,27 @@ if (typeof document !== 'undefined') (function () {
if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); } if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); }
} }
function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; } 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 '<button type="button" class="net-card" role="radio" data-net="' + esc(c.id) + '" aria-checked="' + (on ? 'true' : 'false') + '"' + (c.open ? '' : ' disabled') + '>' + (c.current ? '<span class="nc-cur">running now</span>' : '') + '<span class="nc-name">' + esc(c.label) + '</span><span class="nc-line">' + esc(c.line) + '</span><span class="nc-sub">' + esc(c.sub) + '</span></button>'; }).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) { 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); 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 || ''; 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-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); }); $('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-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 () { $('btn-region-next').addEventListener('click', function () {
var code = $('region-select').value, p = parseFloat($('region-price').value) * View.priceFactor(View.currencyNow().code); var code = $('region-select').value, p = parseFloat($('region-price').value) * View.priceFactor(View.currencyNow().code);
if (!(p >= 0)) p = 0; 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-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 // address options
var opts = document.querySelectorAll('.option'); var opts = document.querySelectorAll('.option');
@ -2498,6 +2539,7 @@ if (typeof document !== 'undefined') (function () {
setText('s-profile-text', View.profileWords(!!st.profile_public)); setText('s-profile-text', View.profileWords(!!st.profile_public));
setHidden('s-climb-row', st.tune_climb === undefined); setHidden('s-climb-row', st.tune_climb === undefined);
setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : '')); setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : ''));
renderNetworkCard(s);
fillRegions($('s-region')); fillRegions($('s-region'));
var rc = regionNow(); var rc = regionNow();
if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; } if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; }

View file

@ -104,7 +104,7 @@
<!-- 2. cards --> <!-- 2. cards -->
<section class="screen" id="screen-cards"> <section class="screen" id="screen-cards">
<div class="step"> <div class="step">
<div class="eyebrow">step 1 of 3</div> <div class="eyebrow">step 1 of 4</div>
<h2>Your graphics card</h2> <h2>Your graphics card</h2>
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p> <p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
<div class="cards" id="cards-list"> <div class="cards" id="cards-list">
@ -129,7 +129,7 @@
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line --> <!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
<section class="screen" id="screen-region"> <section class="screen" id="screen-region">
<div class="step"> <div class="step">
<div class="eyebrow">step 2 of 3</div> <div class="eyebrow">step 2 of 4</div>
<h2>Your electricity</h2> <h2>Your electricity</h2>
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p> <p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
<div class="field"> <div class="field">
@ -157,10 +157,26 @@
</div> </div>
</section> </section>
<!-- 4. address --> <!-- 4. the network (0.3.23): two cards; the fresh-install default comes from the manifest's default_network, the testnet
card reads "not yet open" and is refused until the manifest names it open -->
<section class="screen" id="screen-network">
<div class="step">
<div class="eyebrow">step 3 of 4</div>
<h2>Which network</h2>
<p class="sub">The chain this machine mines. You can change it later in Settings; a change takes effect at the next start.</p>
<div class="net-cards" id="network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="network-note" hidden></p>
<div class="cta">
<button class="btn primary" id="btn-network-next">Continue</button>
<button class="btn ghost" id="btn-network-back">Back</button>
</div>
</div>
</section>
<!-- 5. address -->
<section class="screen" id="screen-address"> <section class="screen" id="screen-address">
<div class="step"> <div class="step">
<div class="eyebrow">step 3 of 3</div> <div class="eyebrow">step 4 of 4</div>
<h2>Where should rewards go?</h2> <h2>Where should rewards go?</h2>
<p class="sub">Every block this machine finds pays one address. Pick one way.</p> <p class="sub">Every block this machine finds pays one address. Pick one way.</p>
<div class="options"> <div class="options">
@ -527,6 +543,18 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-head"><h3>Network</h3><span class="eyebrow" id="s-network-eyebrow"></span></div>
<p class="help" id="s-network-line"></p>
<div class="net-cards" id="s-network-cards" role="radiogroup" aria-label="Network"></div>
<p class="help ask-cur" id="s-network-note" hidden></p>
<div class="ask inline" id="ask-network" hidden>
<span class="ask-text" id="ask-network-text"></span>
<button class="btn small primary" id="ask-network-yes">Switch</button>
<button class="btn small ghost" id="ask-network-no">Keep</button>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-head"><h3>This machine</h3></div> <div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label> <label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>

View file

@ -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.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'); 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');
});