diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs index 32c26df46..6d337338c 100644 --- a/app/igneum-app/src/bin/ota-sign.rs +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -7,6 +7,9 @@ //! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint //! igneum-ota-sign fingerprint //! igneum-ota-sign sha256 the file's sha256 and size, for the manifest +//! igneum-ota-sign sign-ui the interface entry's own signature +//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key +//! igneum-ota-sign verify-ui //! igneum-ota-sign sign-jobs the remote-jobs file (src/jobs.rs), same key //! igneum-ota-sign verify-jobs //! igneum-ota-sign envelope-jobs prints igneum-jobs.signed.json: @@ -101,6 +104,26 @@ fn main() { let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0); println!("{sum} {size}"); } + Some("sign-ui") if args.len() == 5 => { + let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex")); + let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes")); + let sk = SigningKey::from_bytes(&seed); + if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() { + die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1"); + } + if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) { + die("the sha256 is 64 hex characters"); + } + println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes())); + } + Some("verify-ui") if args.len() == 6 => { + let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) }; + let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() }; + match manifest::verify_ui_entry(&e, &pk) { + Ok(()) => println!("verifies"), + Err(e) => die(&e), + } + } Some("sign-jobs") if args.len() == 3 => { let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex")); let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes")); diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 770a3c7a5..566ee3736 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -133,6 +133,10 @@ pub struct Settings { /// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model). #[serde(default)] pub profile_public: bool, + /// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an + /// over-the-air interface bundle is active. Default off. + #[serde(default)] + pub ui_builtin: bool, } fn one() -> u32 { @@ -147,7 +151,7 @@ fn yes() -> bool { 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, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: 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, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index bdbcd7b25..21e12e753 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -94,6 +94,12 @@ pub enum Cmd { RestartNode(String), /// quit, with its source (the log names it: C35, 5 October 2026, two unexplained quits) Quit(&'static str), + /// ui-ota (src/uiota.rs): the download thread's result; the server served a bundle's index.html (its version); + /// the page's health ping (None) or its first-paint error; Settings > Use the built-in interface + UiOta(crate::uiota::Event), + UiPageLoaded(String), + UiHealth(Option), + UiBuiltin(bool), } pub struct Shared { @@ -106,6 +112,8 @@ pub struct Shared { pub runtime: Runtime, pub packaged: Packaged, cmd_tx: Mutex>, + /// ui-ota: the folder the server serves the dashboard from (None = the files embedded in the binary) + ui_dir: Mutex>, pub started: Instant, engine_log: Mutex>, /// the app log's path: the one the uploader sends (main.rs names it; the engine must not guess the stamp) @@ -141,7 +149,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, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public }; + 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, 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 }; 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.finality.message = "waiting for the miner".into(); @@ -158,6 +166,7 @@ impl Shared { runtime, packaged, cmd_tx: Mutex::new(cmd_tx), + ui_dir: Mutex::new(None), started: Instant::now(), engine_log: Mutex::new(engine_log), log_path, @@ -193,6 +202,22 @@ impl Shared { self.port.load(std::sync::atomic::Ordering::Relaxed) } + /// A Shared over a scratch folder for the unit tests that need one (src/uiota.rs): no node, no window, a channel + /// nobody reads (send drops the command), the defaults everywhere else. + #[cfg(test)] + pub fn for_tests(dir: PathBuf) -> Arc { + let _ = std::fs::create_dir_all(&dir); + let (tx, _rx) = channel(); + let runtime = crate::config::Runtime { network: "devnet".into(), rpc_port: 0, p2p_port: 0, peers: vec![], unsynced_mining: false, devnet_suffix: None, node_dir: dir.join("node"), app_dir: dir.clone(), log_dir: dir.join("logs"), status_secs: 30, sweep_only: true, host: "test".into(), machine_id: "0123456789abcdef".into() }; + Arc::new(Shared::new("t".into(), runtime, crate::config::Packaged::default(), Settings::default(), tx, None, dir.join("app.log"))) + } + /// ui-ota: what the server serves the dashboard from; None = the embedded files + pub fn ui_dir(&self) -> Option { + self.ui_dir.lock().unwrap().clone() + } + pub fn set_ui_dir(&self, d: Option) { + *self.ui_dir.lock().unwrap() = d; + } pub fn send(&self, c: Cmd) { let _ = self.cmd_tx.lock().unwrap().send(c); } @@ -607,6 +632,8 @@ pub struct Engine { last_upload: Instant, upload_thread: Option>, ota: crate::ota::Updater, + /// the interface over the air (src/uiota.rs) + uiota: crate::uiota::UiOta, jobs: crate::jobrun::Jobs, /// a remote job has the miners stopped; they restart when the runner lets go job_hold: bool, @@ -692,6 +719,7 @@ impl Engine { // labels never carry the hostname: two cloned PCs with the same COMPUTERNAME would sign with the same keys let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8()); let ota = crate::ota::Updater::new(&shared); + let uiota = crate::uiota::UiOta::new(&shared); let jobs = crate::jobrun::Jobs::new(&shared); Engine { shared, @@ -732,6 +760,7 @@ impl Engine { last_upload: now, upload_thread: None, ota, + uiota, jobs, job_hold: false, stamp, @@ -962,6 +991,10 @@ impl Engine { } } Cmd::Ota(ev) => self.ota.event(&self.shared, ev), + Cmd::UiOta(ev) => self.uiota.event(&self.shared, ev), + Cmd::UiPageLoaded(v) => self.uiota.page_loaded(&v, Instant::now()), + Cmd::UiHealth(err) => self.uiota.health(&self.shared, err.as_deref()), + Cmd::UiBuiltin(on) => self.uiota.set_builtin(&self.shared, on), Cmd::Job(ev) => { if let Some(a) = self.jobs.event(&self.shared, ev) { self.job_action(a); @@ -3254,6 +3287,10 @@ impl Engine { self.apply_update(); } } + if let Some(ui) = self.ota.take_ui_change() { + self.uiota.consider(&self.shared, ui.as_ref(), VERSION); + } + self.uiota.tick(&self.shared, Instant::now()); if let Some(p) = self.ota.take_override_change() { self.shared.log(&format!("consensus override changed ({}); the node restarts with it at a safe moment", p.display())); self.node_override_restart = true; diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index f2a40b61d..4fd4c3d8a 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -24,6 +24,7 @@ mod server; mod state; mod manifest; mod ota; +mod uiota; mod update; mod jobs; mod jobrun; diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 15b883324..4925d9aa5 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -12,8 +12,13 @@ //! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} }, //! "min_supported_version": "0.3.0", "notes": "one line", //! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} }, -//! "tuning": { "updated": "...", "cards": { "": { "variant": "u2", "race": true, "candidates": [..] } } } +//! "tuning": { "updated": "...", "cards": { "": { "variant": "u2", "race": true, "candidates": [..] } } }, +//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz", +//! "min_engine": "0.3.19", "signature": "<128 hex>" } //! } +//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves +//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same +//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch. //! A platform that is missing is not updated (the Windows build lands later than the Mac one). //! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it //! to /tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE). @@ -59,6 +64,34 @@ pub struct Manifest { /// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries /// it), written as is to /tuning.json for the GPU workers; signed with the rest of the manifest. pub tuning: Option, + /// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published + pub ui: Option, +} + +/// One published interface bundle (the manifest's `ui` object). +#[derive(Clone, Debug, PartialEq, Default)] +pub struct UiEntry { + pub version: String, + pub sha256: String, + pub size: u64, + pub url: String, + /// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored + pub min_engine: String, + /// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex + pub signature: String, +} + +/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine +/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download. +pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec { + format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes() +} + +/// The entry's own signature against the release key (the whole manifest's signature is checked before this). +pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> { + let key = public_key(pub_hex)?; + let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?; + key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string()) } impl Manifest { @@ -168,6 +201,32 @@ pub fn parse(text: &str) -> Result { Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()), _ => None, }, + ui: match v.get("ui") { + Some(u) if u.is_object() => { + let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() }; + if parse_version(&e.version).is_none() { + return Err(format!("ui: version '{}' is not a version", e.version)); + } + if parse_version(&e.min_engine).is_none() { + return Err(format!("ui: min_engine '{}' is not a version", e.min_engine)); + } + if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") { + return Err("ui: the url is not https".into()); + } + if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("ui: sha256 is not 64 hex characters".into()); + } + if e.size == 0 { + return Err("ui: size is missing".into()); + } + if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("ui: signature is not 128 hex characters".into()); + } + Some(e) + } + Some(u) if !u.is_null() => return Err("ui must be an object".into()), + _ => None, + }, }) } @@ -389,6 +448,36 @@ mod tests { assert_eq!(ours, theirs); } + #[test] + fn the_ui_entry_parses_and_every_bad_field_fails() { + use ed25519_dalek::{Signer, SigningKey}; + let sk = SigningKey::from_bytes(&[3u8; 32]); + let pk = hex_encode(sk.verifying_key().as_bytes()); + let sha = "ab".repeat(32); + let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes()); + let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } }); + let m = parse(&base.to_string()).unwrap(); + let u = m.ui.clone().unwrap(); + assert_eq!(u.version, "1.0.1"); + assert_eq!(u.min_engine, "0.3.19"); + assert!(verify_ui_entry(&u, &pk).is_ok()); + let mut t = u.clone(); + t.sha256 = "00".repeat(32); + assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature"); + let mut t = u.clone(); + t.min_engine = "0.3.0".into(); + assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too"); + assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err()); + assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch"); + for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] { + let mut b = base.clone(); + b["ui"][k] = v; + assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail"); + } + assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err()); + assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n"); + } + #[test] fn tuning_parses() { let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap(); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index d1e669f89..096c14516 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -131,6 +131,9 @@ pub struct Updater { live_next: Instant, live_busy: bool, live_api: String, + /// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check + /// (Some(None) = the channel was withdrawn: the kill switch) + ui_change: Option>, } impl Updater { @@ -178,6 +181,7 @@ impl Updater { live_next: now, live_busy: false, live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)), + ui_change: None, }; shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8())); #[cfg(windows)] @@ -273,6 +277,10 @@ impl Updater { } /// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare). + /// ui-ota: the interface entry of the last check, once (None until a check has run) + pub fn take_ui_change(&mut self) -> Option> { + self.ui_change.take() + } pub fn take_tuning_change(&mut self) -> Option { self.tuning_changed.take() } @@ -686,6 +694,7 @@ impl Updater { self.min_supported = m.min_supported_version.clone(); self.write_override(shared, &m); self.write_tuning(shared, &m); + self.ui_change = Some(m.ui.clone()); 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 75d0c1f6d..ea528f205 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -172,8 +172,26 @@ fn handle(mut stream: TcpStream, shared: Arc) { return; }; let rest = if rest.is_empty() { "/" } else { rest }; + // ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are + // fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one + if req.method == "GET" { + let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') }; + if crate::uiota::SERVED.contains(&rel) { + if let Some(dir) = shared.ui_dir() { + if let Ok(bytes) = std::fs::read(dir.join(rel)) { + if rel == "index.html" { + let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string(); + shared.send(Cmd::UiPageLoaded(v)); + } + respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/")); + return; + } + } + } + } match (req.method.as_str(), rest) { ("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false), + ("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false), ("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false), ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), @@ -316,6 +334,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + // ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs) + let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string()); + shared.send(Cmd::UiHealth(err)); + Ok(json!({ "ok": true })) + } + "/api/ui/builtin" => { + let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; + shared.send(Cmd::UiBuiltin(on)); + Ok(json!({ "ok": true })) + } "/api/update/auto" => { let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; shared.send(Cmd::AutoUpdate(on)); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index e746ae3ef..2952ee1c4 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -332,6 +332,32 @@ pub struct SettingsState { pub proof_verify_trust: bool, /// miner-ui-5: the public address profile opt-in (settings.profile_public) pub profile_public: bool, + /// ui-ota: "Use the built-in interface" (settings.ui_builtin) + pub ui_builtin: bool, +} + +/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface. +#[derive(Clone, Serialize, Default)] +pub struct UiState { + /// the version compiled into this engine (ui/VERSION) + pub embedded_version: String, + /// the version the server serves now (the bundle's, or the embedded one) + pub active_version: String, + /// embedded | ota + pub source: String, + /// unix s the active bundle was swapped in (0 for the embedded interface) + pub installed_at: f64, + /// the page confirmed the active bundle with its health ping (always true for the embedded interface) + pub confirmed: bool, + /// the version the manifest publishes now ("" when the channel is withdrawn) + pub published_version: String, + pub busy: bool, + pub error: String, + /// versions refused here (never applied again) + pub bad: Vec, + pub builtin: bool, + /// why the published version is not applied, when it is not + pub note: String, } /// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip. @@ -443,6 +469,7 @@ pub struct State { pub settings: SettingsState, pub dev_fee: DevFeeState, pub update: UpdateState, + pub ui: UiState, pub jobs: JobsState, pub events: Vec, pub uptime_s: u64, diff --git a/app/igneum-app/src/uiota.rs b/app/igneum-app/src/uiota.rs new file mode 100644 index 000000000..b46328d1d --- /dev/null +++ b/app/igneum-app/src/uiota.rs @@ -0,0 +1,533 @@ +//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the +//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version. +//! +//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over): +//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active +//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface +//! -> download (curl to /ui/.tar.gz, size and sha256 against the manifest, then the entry's own +//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too) +//! -> unpack with the system tar into /ui/.new, index.html, app.js and app.css must be there, +//! rename to /ui/ +//! -> swap: /ui/current.json names the version (written to .tmp and renamed: atomic); the server reads +//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load; +//! the open page sees state.ui.active_version change and reloads itself when idle +//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping +//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an +//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad +//! (/ui/bad.json): it is never applied again +//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the +//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded +//! files, and the signature is the only trust. + +use crate::engine::{Cmd, Shared}; +use crate::manifest::{self, UiEntry}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// The page's health ping must arrive this long after the first load of a new bundle. +pub const HEALTH_WAIT_S: u64 = 10; +/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION). +pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION"); +/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder. +pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"]; +/// What a bundle must carry to be swapped in. +const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"]; + +pub fn embedded_version() -> &'static str { + EMBEDDED_VERSION.trim() +} + +pub enum Event { + /// the download, the checks and the unpack finished: the bundle folder, or why not (with the version) + Installed(String, Result), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Decision { + Apply, + Skip(String), +} + +/// Whether a published entry is for this engine now (pure; the tests cover every branch). +pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision { + if builtin { + return Decision::Skip("the built-in interface is chosen in Settings".into()); + } + if manifest::newer(&e.min_engine, engine) { + return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine)); + } + if bad.iter().any(|b| b == &e.version) { + return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version)); + } + if active == Some(e.version.as_str()) { + return Decision::Skip(format!("interface {} is active", e.version)); + } + if e.version == embedded { + return Decision::Skip(format!("interface {} is the built-in one", e.version)); + } + Decision::Apply +} + +/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Current { + pub version: String, + pub installed_at: u64, + /// the page confirmed this bundle with a health ping + pub confirmed: bool, +} + +fn read_current(dir: &Path) -> Current { + let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null); + Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) } +} + +/// Writes the pointer atomically (the .tmp then the rename). +pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> { + let _ = std::fs::create_dir_all(dir); + let tmp = dir.join("current.json.tmp"); + std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string()) +} + +fn read_bad(dir: &Path) -> Vec { + std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() +} + +fn write_bad(dir: &Path, bad: &[String]) { + let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default()); +} + +/// The bundle folder for a version, if it holds what the server needs. +pub fn bundle_dir(dir: &Path, version: &str) -> Option { + if version == "embedded" || version.is_empty() { + return None; + } + let d = dir.join(version); + if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None } +} + +/// Size, sha256 and the entry's own signature, then the unpack into /: the folder on success. +/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own). +pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result { + let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?; + if size != e.size { + return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size)); + } + let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?; + if sum != e.sha256 { + return Err(format!("interface {}: sha256 mismatch", e.version)); + } + manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?; + let fresh = dir.join(format!("{}.new", e.version)); + let _ = std::fs::remove_dir_all(&fresh); + std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?; + let mut c = std::process::Command::new(crate::platform::tool("tar")); + c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]); + let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?; + // a bundle packed with a top-level folder: take it + let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) { + fresh.clone() + } else { + let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file())); + match inner { + Some(p) => p, + None => { + let _ = std::fs::remove_dir_all(&fresh); + return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing"))); + } + } + }; + let dest = dir.join(&e.version); + let _ = std::fs::remove_dir_all(&dest); + std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?; + let _ = std::fs::remove_dir_all(&fresh); + // the bundle's own VERSION must agree with the manifest (a renamed bundle is refused) + let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default(); + if v.trim() != e.version { + let _ = std::fs::remove_dir_all(&dest); + return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim())); + } + Ok(dest) +} + +/// The engine's side: what is active, what is pending, the health wait, the rollback. +pub struct UiOta { + dir: PathBuf, + current: Current, + bad: Vec, + builtin: bool, + busy: bool, + /// the manifest entry seen last (for the Settings line and the retry after an error) + entry: Option, + /// a bundle served to a page and not yet confirmed: (version, when the page loaded) + waiting: Option<(String, Instant)>, + error: String, + /// the whole download thread's last reason, kept for the state + last_skip: String, + health_wait: Duration, +} + +impl UiOta { + pub fn new(shared: &Arc) -> UiOta { + let dir = shared.runtime.app_dir.join("ui"); + let _ = std::fs::create_dir_all(&dir); + let builtin = shared.settings.lock().unwrap().ui_builtin; + let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) }; + // a pointer to a folder that is not there (a cleaned app data folder) falls back without a word + if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" { + shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version)); + u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true }; + let _ = write_current(&dir, &u.current); + } + u.apply_pointer(shared); + if u.current.version != "embedded" { + shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" })); + } + u.publish(shared); + u + } + + /// What the server serves: the bundle folder, or None for the embedded files. + fn apply_pointer(&self, shared: &Arc) { + let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) }; + shared.set_ui_dir(d); + } + + pub fn active_version(&self) -> &str { + &self.current.version + } + + /// Called with every verified manifest: the `ui` entry or None (the kill switch). + pub fn consider(&mut self, shared: &Arc, entry: Option<&UiEntry>, engine: &str) { + let Some(e) = entry else { + self.entry = None; + if self.current.version != "embedded" { + shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version())); + shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version)); + self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true }); + } + self.publish(shared); + return; + }; + self.entry = Some(e.clone()); + if self.busy { + return; + } + match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) { + Decision::Skip(why) => { + if why != self.last_skip { + shared.log(&format!("ui: {why}")); + self.last_skip = why; + } + } + Decision::Apply => { + self.last_skip = String::new(); + self.busy = true; + self.error = String::new(); + shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000)); + let e2 = e.clone(); + let dir = self.dir.clone(); + let shared2 = shared.clone(); + std::thread::spawn(move || { + let r = download_and_install(&e2, &dir); + shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r))); + }); + } + } + self.publish(shared); + } + + pub fn event(&mut self, shared: &Arc, ev: Event) { + self.busy = false; + match ev { + Event::Installed(version, Ok(_dir)) => { + shared.event("ok", &format!("interface {version} installed; the window takes it at its next load")); + self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false }); + shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at)); + } + Event::Installed(version, Err(e)) => { + self.mark_bad(shared, &version, &e); + } + } + self.publish(shared); + } + + fn set_current(&mut self, shared: &Arc, c: Current) { + self.current = c; + if let Err(e) = write_current(&self.dir, &self.current) { + shared.log(&format!("ui: could not write the pointer: {e}")); + } + self.waiting = None; + self.apply_pointer(shared); + } + + fn mark_bad(&mut self, shared: &Arc, version: &str, why: &str) { + if !self.bad.iter().any(|b| b == version) { + self.bad.push(version.to_string()); + write_bad(&self.dir, &self.bad); + } + self.error = why.to_string(); + shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves")); + shared.log(&format!("ui: {version} marked bad: {why}")); + if self.current.version == version { + self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true }); + } + } + + /// The server served index.html from a bundle: an unconfirmed one starts the health wait. + pub fn page_loaded(&mut self, version: &str, now: Instant) { + if version == self.current.version && !self.current.confirmed && self.waiting.is_none() { + self.waiting = Some((version.to_string(), now)); + } + } + + /// The page's ping after its first paint, or the error it caught before it. + pub fn health(&mut self, shared: &Arc, error: Option<&str>) { + let Some((version, _)) = self.waiting.clone() else { return }; + match error { + None => { + self.waiting = None; + self.current.confirmed = true; + let _ = write_current(&self.dir, &self.current); + shared.log(&format!("ui bundle {version} confirmed by the page")); + self.publish(shared); + } + Some(e) => { + self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::())); + self.publish(shared); + } + } + } + + /// The wait ran out: the page never confirmed the bundle. + pub fn tick(&mut self, shared: &Arc, now: Instant) { + if let Some((version, since)) = self.waiting.clone() { + if now.duration_since(since) >= self.health_wait { + self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs())); + self.publish(shared); + } + } + } + + pub fn set_builtin(&mut self, shared: &Arc, on: bool) { + self.builtin = on; + { + let mut s = shared.settings.lock().unwrap(); + s.ui_builtin = on; + s.save(&shared.settings_path); + } + shared.state.lock().unwrap().settings.ui_builtin = on; + self.waiting = None; + self.apply_pointer(shared); + shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" }); + self.publish(shared); + } + + /// Settings > Interface: what serves, from where, since when. + pub fn publish(&self, shared: &Arc) { + let mut st = shared.state.lock().unwrap(); + let u = &mut st.ui; + let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some(); + u.embedded_version = embedded_version().into(); + u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() }; + u.source = if ota_active { "ota".into() } else { "embedded".into() }; + u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 }; + u.confirmed = !ota_active || self.current.confirmed; + u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default(); + u.busy = self.busy; + u.error = self.error.clone(); + u.bad = self.bad.clone(); + u.builtin = self.builtin; + u.note = self.last_skip.clone(); + } +} + +/// The download thread: curl with resume into /.tar.gz, then install_bundle. +fn download_and_install(e: &UiEntry, dir: &Path) -> Result { + let _ = std::fs::create_dir_all(dir); + let file = dir.join(format!("{}.tar.gz", e.version)); + let part = dir.join(format!("{}.tar.gz.part", e.version)); + let mut c = std::process::Command::new(crate::platform::tool("curl")); + c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]); + let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?; + let t = out.trim(); + if !t.is_empty() && !part.is_file() { + return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed"))); + } + std::fs::rename(&part, &file).map_err(|er| er.to_string())?; + let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX); + if r.is_err() { + let _ = std::fs::remove_file(&file); + } + r +} + +/// The content type a served bundle file gets (the same list the embedded files use). +pub fn content_type(rel: &str) -> &'static str { + if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn entry(version: &str, min_engine: &str) -> UiEntry { + UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) } + } + + #[test] + fn the_decision_covers_every_reason_to_skip() { + let e = entry("1.0.1", "0.3.19"); + assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply); + assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply); + assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored"); + assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active"))); + assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one"))); + assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before"))); + assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings"))); + } + + /// A bundle folder, packed with the system tar, hashed and signed with a throwaway key. + fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) { + let src = root.join("src"); + let _ = std::fs::remove_dir_all(&src); + std::fs::create_dir_all(src.join("fonts")).unwrap(); + if with_index { + std::fs::write(src.join("index.html"), "x").unwrap(); + } + std::fs::write(src.join("app.js"), "var x = 1;").unwrap(); + std::fs::write(src.join("app.css"), "body{}").unwrap(); + std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap(); + std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap(); + let tar = root.join(format!("igneum-ui-{version}.tar.gz")); + let mut c = std::process::Command::new("tar"); + c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]); + let ok = crate::platform::quiet(&mut c).status().unwrap().success(); + assert!(ok, "tar packs the fixture"); + let sha = manifest::sha256_file(&tar).unwrap(); + let size = std::fs::metadata(&tar).unwrap().len(); + let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes()); + (UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar) + } + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn a_good_bundle_installs_and_the_pointer_swaps_atomically() { + let root = tmp("good"); + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = manifest::hex_encode(sk.verifying_key().as_bytes()); + let (e, tar) = make_bundle(&root, "1.0.1", true, &sk); + let dir = root.join("ui"); + let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs"); + assert_eq!(dest, dir.join("1.0.1")); + assert!(bundle_dir(&dir, "1.0.1").is_some()); + assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone"); + write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap(); + assert_eq!(read_current(&dir).version, "1.0.1"); + assert!(!dir.join("current.json.tmp").exists()); + assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8"); + assert_eq!(bundle_dir(&dir, "embedded"), None); + } + + #[test] + fn a_bad_signature_is_refused_before_anything_is_unpacked() { + let root = tmp("badsig"); + let sk = SigningKey::from_bytes(&[7u8; 32]); + let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes()); + let (e, tar) = make_bundle(&root, "1.0.2", true, &sk); + let dir = root.join("ui"); + let err = install_bundle(&e, &tar, &dir, &other).unwrap_err(); + assert!(err.contains("signature does not verify"), "{err}"); + assert!(!dir.join("1.0.2").exists()); + // a tampered hash is caught first + let mut t = e.clone(); + t.sha256 = "00".repeat(32); + let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err(); + assert!(err.contains("sha256 mismatch"), "{err}"); + // a wrong size too + let mut t = e.clone(); + t.size += 1; + assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes")); + } + + #[test] + fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() { + let root = tmp("broken"); + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = manifest::hex_encode(sk.verifying_key().as_bytes()); + let (e, tar) = make_bundle(&root, "1.0.3", false, &sk); + let dir = root.join("ui"); + let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err(); + assert!(err.contains("no index.html"), "{err}"); + assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists()); + } + + #[test] + fn a_renamed_bundle_is_refused_by_its_version_file() { + let root = tmp("renamed"); + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = manifest::hex_encode(sk.verifying_key().as_bytes()); + let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk); + e.version = "1.0.5".into(); + e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes()); + let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err(); + assert!(err.contains("VERSION file says '1.0.4'"), "{err}"); + } + + #[test] + fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() { + // the state machine without threads: a UiOta over a temp dir, driven by hand + let root = tmp("health"); + let dir = root.join("ui"); + std::fs::create_dir_all(dir.join("1.0.6")).unwrap(); + for f in REQUIRED { + std::fs::write(dir.join("1.0.6").join(f), "x").unwrap(); + } + write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap(); + let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) }; + let t0 = Instant::now(); + u.page_loaded("1.0.6", t0); + assert!(u.waiting.is_some()); + // a ping in time confirms + let shared = crate::engine::Shared::for_tests(root.join("data")); + u.health(&shared, None); + assert!(u.current.confirmed && u.waiting.is_none()); + assert_eq!(read_current(&dir).confirmed, true); + // a second bundle that never answers + std::fs::create_dir_all(dir.join("1.0.7")).unwrap(); + for f in REQUIRED { + std::fs::write(dir.join("1.0.7").join(f), "x").unwrap(); + } + u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false }); + u.page_loaded("1.0.7", t0); + u.tick(&shared, t0 + Duration::from_secs(9)); + assert_eq!(u.current.version, "1.0.7", "still waiting inside the window"); + u.tick(&shared, t0 + Duration::from_secs(10)); + assert_eq!(u.current.version, "embedded", "rolled back"); + assert_eq!(read_current(&dir).version, "embedded"); + assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]); + assert!(shared.ui_dir().is_none(), "the server serves the embedded files again"); + // a script error on first paint rolls back the same way + std::fs::create_dir_all(dir.join("1.0.8")).unwrap(); + for f in REQUIRED { + std::fs::write(dir.join("1.0.8").join(f), "x").unwrap(); + } + u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false }); + u.page_loaded("1.0.8", t0); + u.health(&shared, Some("TypeError: x is not a function")); + assert_eq!(u.current.version, "embedded"); + assert!(u.bad.contains(&"1.0.8".to_string())); + // and the decision never applies it again + let e = entry("1.0.8", "0.3.19"); + assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_))); + } +} diff --git a/app/igneum-app/ui/VERSION b/app/igneum-app/ui/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/app/igneum-app/ui/VERSION @@ -0,0 +1 @@ +1.0.0