From 8debc7262a73e196b1ff63a71fae8ab682319cb0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:28:03 +0000 Subject: [PATCH] ui-ota (0.3.20): Settings > Interface, the health ping, the first-paint error and the gentle reload "Interface , over the air, " or "built in", with the help line for pending, refused, held back and skipped; the "Use the built-in interface" switch (POST /api/ui/builtin). The page posts /api/ui/health once after its first paint, or the first script error it catches before that; when the served interface changes under it, the page reloads itself only when idle (no input focused, no sheet or update card open, on the dashboard). ui-ota.test.mjs covers the words, the reload rule, the ping and that the page loads no remote script. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit b6a0fd0d75cda15d8d09fcfd08c09cbadf403c90) --- app/igneum-app/ui/app.js | 48 +++++++++++++++++++++++++++++- app/igneum-app/ui/index.html | 7 +++++ app/igneum-app/ui/ui-ota.test.mjs | 49 +++++++++++++++++++++++++++++++ tools/ui-mock/server.mjs | 3 ++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 app/igneum-app/ui/ui-ota.test.mjs diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 96b73cb3..9a43a8f3 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -941,9 +941,36 @@ var View = (function () { } function profileWords(on) { return on ? 'Your address page on igneum.network is public: your key ids, blocks in the window, weight rank, signing presence and card model are shown. The site reads it from the chain and from this switch.' : 'Off: igneum.network/address keeps your page unlisted (noindex). Nothing about this machine is published.'; } // the confirmations' words (in place, never a dialog) + // ui-ota (src/uiota.rs): Settings > Interface. u = state.ui. {line, eyebrow, help} + function interfaceWords(u) { + u = u || {}; + var ver = u.active_version || u.embedded_version || ''; + if (!ver) return { line: '', eyebrow: '', help: '' }; + var ota = u.source === 'ota'; + var when = ota && u.installed_at > 0 ? dateWords(u.installed_at) : ''; + var line = 'Interface ' + ver + (ota ? ', over the air' + (when ? ', ' + when : '') : ', built in'); + var help = ''; + if (ota && !u.confirmed) help = 'Waiting for this window to confirm the new interface.'; + else if (u.error) help = 'The last interface over the air was refused: ' + u.error + ' The built-in one serves.'; + else if (u.builtin && u.published_version && u.published_version !== u.embedded_version) help = 'Interface ' + u.published_version + ' is published over the air; switch this off to take it.'; + else if (u.busy) help = 'Interface ' + u.published_version + ' is downloading.'; + else if (u.note) help = cap(u.note) + '.'; + else if (ota) help = 'Built in: ' + (u.embedded_version || '') + '.'; + return { line: line, eyebrow: ota ? 'over the air' : 'built in', help: help }; + } + function dateWords(unix) { var d = new Date(unix * 1000); return d.getDate() + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getFullYear(); } + // the page reloads itself when the served interface changed under it and nobody is mid-task: + // loaded = the version this page came up with, u = state.ui, ctx = {typing, sheetOpen, phase} + function shouldReload(loaded, u, ctx) { + if (!loaded || !u || !u.active_version || u.active_version === loaded) return false; + ctx = ctx || {}; + if (ctx.typing || ctx.sheetOpen) return false; + if (ctx.phase && ctx.phase !== 'dashboard') return false; + return true; + } function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { 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, 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, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords }; + return { 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, 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, 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, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords }; })(); /* ---------- 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 @@ -2081,6 +2108,9 @@ if (typeof document !== 'undefined') (function () { setText('s-node-dir', 'chain data: ' + s.node_dir); setText('s-log-dir', 'log files: ' + s.log_dir); $('s-live').hidden = !s.live_page; + var iw = View.interfaceWords(s.ui); + setText('s-ui-line', iw.line || 'Interface'); setText('s-ui-eyebrow', iw.eyebrow || 'built in'); setText('s-ui-help', iw.help || 'Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.'); + sw('s-ui-builtin', st.ui_builtin); var u = s.update || {}; setText('s-version', 'Igneum Miner ' + s.version); setText('s-update-note', settingsUpdateNote(u)); @@ -2299,6 +2329,20 @@ if (typeof document !== 'undefined') (function () { if (phase === 'dashboard') renderPage(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } } + // ---------- ui-ota: the health ping after the first paint, the first-paint error, the gentle reload ---------- + var uiLoaded = '', uiPinged = false, uiFirstError = ''; + window.addEventListener('error', function (e) { if (!uiPinged && !uiFirstError) { uiFirstError = String(e && e.message || 'script error'); api('api/ui/health', { error: uiFirstError }).catch(function () { }); } }); + function uiHealth(s) { + if (uiPinged) return; + uiPinged = true; + uiLoaded = (s.ui && s.ui.active_version) || ''; + if (!uiFirstError) api('api/ui/health', {}).catch(function () { }); + } + function uiReloadIfChanged(s) { + var typing = !!(document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA' || document.activeElement.tagName === 'SELECT')); + if (View.shouldReload(uiLoaded, s.ui, { typing: typing, sheetOpen: !$('sheet-key').hidden || !$('upd').hidden, phase: phase })) { uiLoaded = ''; location.reload(); } + } + $('s-ui-builtin').addEventListener('change', function () { var on = this.checked; api('api/ui/builtin', { on: on }).then(function (r) { if (r.ok) toast(on ? 'The built-in interface serves from the next load' : 'The interface over the air serves again'); else { toast(r.error || 'could not change'); $('s-ui-builtin').checked = !on; } }); }); var failures = 0, first = true, pollTick = 0; function poll() { // hidden windows poll every 5 s (the pill and the tray text still need a recent state), visible ones every second @@ -2317,6 +2361,8 @@ if (typeof document !== 'undefined') (function () { } render(s); pollChain(false); + uiHealth(s); + uiReloadIfChanged(s); perf.render += performance.now() - a; perf.polls++; if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); } }).catch(function () { diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 43c7283f..55590a61 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -477,6 +477,13 @@ +
+

Interface

built in
+

Interface 1.0.0, built in

+

Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.

+ +
+
diff --git a/app/igneum-app/ui/ui-ota.test.mjs b/app/igneum-app/ui/ui-ota.test.mjs new file mode 100644 index 00000000..a19a9747 --- /dev/null +++ b/app/igneum-app/ui/ui-ota.test.mjs @@ -0,0 +1,49 @@ +// node --test app/igneum-app/ui/ui-ota.test.mjs (no dependencies; the pre-push gate runs it) +// The interface over the air (docs/plans/ui-ota.md): Settings > Interface's words and the gentle reload rule. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(here, 'app.js'), 'utf8'); +const mod = { exports: {} }; +new Function('module', src)(mod); +const V = mod.exports.View; + +test('the embedded interface says built in; an over-the-air one says so with its date', () => { + assert.deepEqual(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true }), { line: 'Interface 1.0.0, built in', eyebrow: 'built in', help: '' }); + const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', installed_at: Date.UTC(2026, 9, 7, 12) / 1000, confirmed: true }); + assert.equal(w.line, 'Interface 1.0.1, over the air, 7 Oct 2026'); + assert.equal(w.eyebrow, 'over the air'); + assert.equal(w.help, 'Built in: 1.0.0.'); + assert.equal(V.interfaceWords({}).line, ''); + assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.0', 'the embedded interface version is three-part and in ui/VERSION'); +}); + +test('the help line says what is pending, refused, held back by the switch, or skipped', () => { + assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', confirmed: false }).help, 'Waiting for this window to confirm the new interface.'); + assert.ok(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, error: 'the page did not answer within 10 s of loading it.' }).help.startsWith('The last interface over the air was refused:')); + assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, builtin: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is published over the air; switch this off to take it.'); + assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, busy: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is downloading.'); + assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, note: 'interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19)' }).help, 'Interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19).'); +}); + +test('the page reloads only when the served interface changed and nobody is mid-task', () => { + const u = { active_version: '1.0.1' }; + assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard' }), true); + assert.equal(V.shouldReload('1.0.1', u, { phase: 'dashboard' }), false, 'same version: nothing'); + assert.equal(V.shouldReload('', u, { phase: 'dashboard' }), false, 'a page that never pinged does not reload'); + assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', typing: true }), false); + assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', sheetOpen: true }), false); + assert.equal(V.shouldReload('1.0.0', u, { phase: 'address' }), false, 'never mid-setup'); + assert.equal(V.shouldReload('1.0.0', null, { phase: 'dashboard' }), false); +}); + +test('the page pings health once after its first paint and reports a first-paint error (the DOM block carries both)', () => { + assert.ok(src.includes("api('api/ui/health', {})"), 'the health ping'); + assert.ok(src.includes("api('api/ui/health', { error: uiFirstError })"), 'the first-paint error'); + assert.ok(src.indexOf("window.addEventListener('error'") < src.indexOf('function poll()'), 'the error listener is installed before the first poll'); + assert.ok(!/]+src=["']https?:/.test(readFileSync(join(here, 'index.html'), 'utf8')), 'no remote script in the page'); +}); diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index 0bc7e7ad..89382649 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -161,6 +161,9 @@ function stateNow() { s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b); s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length; if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40); + // ui-ota: the interface state (Settings > Interface); ?ui=ota shows an over-the-air bundle active + s.ui = { embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', installed_at: 0, confirmed: true, published_version: '', busy: false, error: '', bad: [], builtin: false, note: '' }; + s.settings = { ...s.settings, ui_builtin: false }; s.display_name = 'studio-mac-pro-with-a-long-name'; s.host = s.display_name;