Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ee09ae8b633206c8a7ab438eb4b0ee57362de000)
125 lines
10 KiB
Markdown
125 lines
10 KiB
Markdown
# 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.mjs` fails 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 `ui` object, 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/<token>/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 |
|