Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ee09ae8b633206c8a7ab438eb4b0ee57362de000)
10 KiB
The interface over the air: smaller UI changes without a new version
7 October 2026, 10:5x UK. the project lead: "can we make smaller UI based updates over the air without a new version being
needed?" Branch ui-ota, worktree ../igneum-wt-ui-ota, off the miner-ui-5 tip e9fe1106; the shipper takes it into
0.3.20. The dashboard (app/igneum-app/ui: index.html, app.js, app.css, live-dag.js, proof-core.js, the fonts, the
mark) is embedded in the engine binary today; from 0.3.20 a signed bundle of the same folder can replace it between
app versions. The engine, the node, the miner and the workers never move this way: a bundle is files the engine
serves on 127.0.0.1, nothing it runs.
1. What a miner sees
| Where | What |
|---|---|
| Nothing, usually | The hourly manifest check finds an interface bundle, downloads about 400 KB, checks it, swaps it in; the open window reloads itself the next second it is idle (no input focused, no sheet or update card open, not mid-setup). |
| Settings > Interface | "Interface 1.0.1, over the air, 7 Oct 2026" or "Interface 1.0.0, built in"; the help line says what is pending, refused, held back or skipped. |
| Settings > Interface, the switch | "Use the built-in interface": the embedded dashboard serves even when a bundle is active; off again takes the bundle at the next load. |
| Activity | "interface 1.0.1 is published: downloading (412 KB)", "interface 1.0.1 installed; the window takes it at its next load", or "interface 1.0.1 was refused: ... The built-in interface serves". |
2. The manifest's interface channel
The signed manifest (igneum-app-latest.json, src/manifest.rs) gains one object, covered by the manifest's own signature like every other field:
"ui": { "version": "1.0.1", "sha256": "<64 hex>", "size": 412345,
"url": "https://dl.igneum.network/dl/<token>/ui/igneum-ui-1.0.1.tar.gz",
"min_engine": "0.3.20", "signature": "<128 hex>" }
| Field | Rule |
|---|---|
| version | the interface's own three-part line (1.0.0 is the one embedded in 0.3.20; ui/VERSION in the tree) |
| sha256, size | of the tar.gz; both checked after the download |
| url | https on the downloads host, under ui/; a bundle is never fetched from anywhere the manifest did not name |
| min_engine | the lowest app version that may serve it; an engine below it ignores the entry and says so in its log |
| signature | Ed25519 by the release key (the public half pinned in src/manifest.rs) over igneum-ui\n<version>\n<sha256>\n<min_engine>\n (manifest::ui_sign_bytes); the bundle stays verifiable on its own, and the shipper's point stands: the manifest signature already covers it, this one is the belt to that brace |
The kill switch is the manifest without a ui object (publish-manifest.sh --no-ui): every engine retires its bundle
at the next check and the built-in interface serves. A bundle that fails any check is marked bad on that machine
and never applied again; the next version is a new chance.
3. The engine (src/uiota.rs, src/ota.rs, src/server.rs)
| Step | What happens |
|---|---|
| decide | uiota::decide: skip when the built-in interface is chosen, when min_engine is newer than the engine, when the version was marked bad, when it is the active one, or when it is the embedded one |
| download | curl with resume into <app data>/ui/<version>.tar.gz; size and sha256 against the entry; the entry's signature against the pinned key |
| unpack | the system tar into <app data>/ui/<version>.new; index.html, app.js and app.css must be there; the bundle's VERSION must say the manifest's version; renamed to <app data>/ui/<version> |
| swap | <app data>/ui/current.json (written to .tmp, renamed) names the version; the server reads the pointer through Shared::ui_dir and serves the bundle's files for the fixed names in uiota::SERVED (nothing else is read from the folder; a file the bundle lacks falls back to the embedded one); the embedded files stay in the binary |
| reload | state.ui.active_version changes; the page compares it with the version it loaded (View.shouldReload) and reloads when idle |
| confirm | the first page load from an unconfirmed bundle starts a 10 s wait; the page POSTs /api/ui/health after its first paint (a window.error before that posts the error instead); a ping confirms, an error or silence rolls back |
| roll back | the pointer goes back to "embedded", the version lands in <app data>/ui/bad.json, one Activity line, one log line (ui: <version> marked bad: <why>) |
| state | state.ui {embedded_version, active_version, source, installed_at, confirmed, published_version, busy, error, bad, builtin, note}; settings.ui_builtin |
Log lines the shipper reads back: ui bundle <version> active (installed <unix>), ui bundle <version> confirmed by the page, ui: <version> marked bad: <why>, ui: no ui object in the manifest; interface <version> retired.
4. Security notes
- Same origin: the bundle is served by this engine on 127.0.0.1 under the per-launch token path, exactly as the
embedded files are; the page's fetches, the dashboard's POST rule (
from_dashboard) and the token are unchanged. - No remote scripts: the bundle is a copy of the tree's ui folder; index.html loads only its own files; the
test
ui-ota.test.mjsfails on a<script src="http...">. The engine never evaluates anything from the bundle; it serves bytes. - The signature is the only trust: the manifest's Ed25519 signature (the pinned release key) covers the entry, the entry's own signature covers version, hash and engine floor, and the hash covers the bytes. No bundle is applied on a hash alone, and the URL is never trusted beyond "where to fetch".
- Fixed names: only the fifteen served names are ever read from a bundle folder (
uiota::SERVED); a path is never built from a request. - The kill switch: removing the
uiobject, or publishing a bundle whose min_engine is above every engine. - A bundle cannot brick a window: no index.html, a parse error in app.js, a JS error on first paint or a page that never answers all roll back within 10 s of the first load, and the version is never retried on that machine.
- The private key stays in
~/.config/igneum/ota-signing-key; tools/ui-ota/publish.mjs calls igneum-ota-sign and never reads or prints the key; tools/ci/no-secrets-check.sh keeps the name out of the tree.
5. The publisher (tools/ui-ota/publish.mjs, packaging/ota/publish-manifest.sh --ui)
The one-line operator recipe, staged first as every cut is (the live folder changes only in the deploy step):
IGNEUM_DLSITE=<scratch copy of the downloads folder> node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --dry-run
node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --deploy --public
node tools/ui-ota/publish.mjs --verify
Order rule (the shipper, 7 October 2026): the 0.3.19 app, live since 10:23Z, ignores the ui object, so 1.0.1
publishes with min_engine 0.3.20 only after 0.3.20 is on the manifest; an earlier publish is harmless (every 0.3.19
engine skips it) and pointless. The kill switch (--no-ui) sits in the shipper's runbook beside the manifest's own.
What it does: writes ui/VERSION from --version, packs the fifteen served files in sorted order with one fixed mtime
(the same tree gives the same bytes; the self-test checks it), hashes, signs the entry through
igneum-ota-sign sign-ui, copies the tar into the folder's ui/, writes ui.json and calls
publish-manifest.sh --version <the folder's app version> --ui ui.json, which verifies the entry's signature and the
bundle's hash in the folder before it signs the manifest. --dry-run stops after the pack and the entry (nothing
signed, nothing written to any downloads folder). --verify reads the live manifest, checks the bundle in the
folder's dl//ui and dl/public/ui against ui.sha256 and the entry's signature, and exits 1 on any miss: the
read-back the shipper asked for. --no-ui on publish-manifest.sh withdraws the channel.
6. Tests and gates
| Test | Where |
|---|---|
| a good bundle installs, the pointer swaps atomically | uiota::tests::a_good_bundle_installs_and_the_pointer_swaps_atomically |
| a bad signature, a tampered hash, a wrong size are refused before anything is unpacked | uiota::tests::a_bad_signature_is_refused_before_anything_is_unpacked |
| a too-new min_engine is ignored, and every other skip | uiota::tests::the_decision_covers_every_reason_to_skip |
| a broken bundle (no index.html) is refused and leaves nothing behind; a renamed bundle is refused by its VERSION | uiota::tests::a_broken_bundle_..., a_renamed_bundle_... |
| the health wait rolls back to the embedded interface and marks the version bad; a first-paint error too; a ping confirms | uiota::tests::the_health_wait_rolls_back_... |
| the ui entry parses, every bad field fails, the entry's signature breaks on any change | manifest::tests::the_ui_entry_parses_and_every_bad_field_fails |
| the Settings words, the reload rule, the health ping and no remote script | ui/ui-ota.test.mjs (on the one gate) |
| the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not | node tools/ui-ota/publish.mjs --self-test (on the one gate; the sign half runs where the signer is built) |
7. Per tier
Every tier the same: the bundle is about 400 KB (the fonts are most of it), one download an hour at most, no
restart, no mining pause; a machine without a window (a rig, a pool box) swaps the pointer and confirms at the next
window open; Windows, Linux and macOS alike (the system tar on all three); a second engine (--sweep) never updates,
so it never swaps an interface either.
8. Owed
| What | Who |
|---|---|
| The first real publish (1.0.1) once 0.3.20 is on every platform, with the read-back line in the release notes | the shipper |
A CI check that the live manifest's ui.sha256 equals the tar in dl/public (publish.mjs --verify is the hand form; CI has no dlsite folder) |
the shipper's post-deploy step |
| Captures of Settings > Interface in both states for the site | the app lane, after its rebase |