OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / c765c5e

the update section answers: why am I not on the latest?
It printed two truncated tree hashes and a note, which answers nothing — so every time an update did not apply, finding out why took a cable, a laptop and somebody who knows the codebase. That is not a property a phone may have. Now it names VERSIONS; renders a refusal in chooseBundle's own words with NO restart button, because a restart cannot help and offering one is a lie; says 'up to date' when the staged bundle IS the running one; and shows ages rather than ISO timestamps. The hashes stay underneath — when two builds carry the same version number they are the only thing telling them apart, which is a state somebody created today by publishing one version twice. It also says when the phone is freezing the node. A frozen app has no swarm, so the release watch is never told anything, and from the outside that is indistinguishable from a broken updater. It was read as one, repeatedly, for a day. ⚠ batteryExempt is tri-state: null means nothing asked, and warning about a state nothing has measured is how a page teaches people to ignore it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c765c5e26597fb5e8bf67af5834bb8a239ba3bbd
by plankatron <fleeky@gmail.com> · 2026-09-11T20:30:38.000Z

Changed files (2) +172 -29

lib/pages.js +94 -29
……@@ -1543,42 +1543,105 @@ export function themeSettings (current) {
15431543// renders a control the shell beneath it cannot honour is worse than one that renders nothing.
15441544export function updateSettings (u) {
15451545 if (!u) return ''
1546 const short = (h) => (h ? esc(String(h).slice(0, 12)) + '…' : '<span class="who">unknown</span>')
1547 const checked = u.checkedAt ? '<p class="who">Last checked ' + esc(u.checkedAt) + '</p>' : ''
1548 // ⚠ The node's own last OTA line, verbatim. It says things no derived field can — "no swarm
1549 // fetcher", "downloading 0.0.6", "previously failed to boot" — and without it a device could only
1550 // report that nothing happened.
1551 const note = u.note ? '<p class="who">' + esc(u.note) + '</p>' : ''
1552 // ⛔ THE VERSION FIRST, because it is the only line here a person can compare against a release
1553 // page. Two tree hashes say something differs; they do not answer "is my update installed?", which
1554 // is the question anyone opening this section actually has.
1555 const built = u.builtAt ? ' <span class="who">built ' + esc(new Date(Number(u.builtAt)).toISOString()) + '</span>' : ''
1556 const version = u.version
1557 ? '<p><strong>Running ' + esc(u.version) + '</strong>' + built + '</p>'
1546
1547 // ⛔ THIS SECTION ANSWERS ONE QUESTION: why am I not on the latest? It used to print two truncated
1548 // tree hashes and a note, which answers nothing — so every time an update did not apply, the only
1549 // way to find out why was a cable, a laptop and somebody who knows the codebase. That is not a
1550 // property a phone may have.
1551 //
1552 // ⛔ SO IT NAMES VERSIONS, AND IT NAMES THE OBSTACLE. A refusal is rendered in the words chooseBundle
1553 // used; a staged bundle identical to the running one is said to be identical rather than offered as
1554 // an update; and a bundle waiting for a restart says so. The hashes stay, underneath, because when
1555 // the version numbers are equal they are the only thing that distinguishes two builds — which is a
1556 // real state and one somebody created today by publishing one version twice.
1557 const short = (h) => (h ? esc(String(h).slice(0, 12)) + '…' : 'none')
1558 const ver = (v) => (v ? esc(v) : 'unknown')
1559
1560 // ⛔ AN UNSTAMPED BUILD SAYS SO. Bundles older than the version stamp carry no version at all, and
1561 // printing "Running unknown" would read as a fault rather than as a fact about that build's age.
1562 const running = u.version
1563 ? '<p><strong>Running ' + ver(u.version) + '</strong>' +
1564 (u.builtAt ? ' <span class="who">built ' + esc(when(u.builtAt)) + '</span>' : '') + '</p>'
15581565 : '<p class="who">This build does not report its version — it predates the stamp.</p>'
15591566
1560 // ⛔ STAGED AND DIFFERENT is the only state that means "an update is waiting". Staged and IDENTICAL
1561 // is the ordinary case — the node re-verified the bundle it is already running — and calling that
1562 // "update available" is what makes a restart button lie.
1567 // ⛔ REFUSED IS ITS OWN STATE, and the most important one: something IS waiting and it cannot be
1568 // applied, so a restart button here would be a lie. chooseBundle's own sentence is used verbatim —
1569 // it knows why, and rewording it would be a second place to keep in step.
1570 if (u.refused && u.refused.reason) {
1571 return '<h2>App updates</h2>' + running +
1572 '<p class="warn">An update is downloaded but cannot be applied: ' + esc(u.refused.reason) + '</p>' +
1573 '<p class="who">Nothing to do from here — this usually needs a newer app, not another restart.</p>' +
1574 batteryLine(u) + facts(u, short) + checkedLine(u)
1575 }
1576
15631577 const waiting = updateWaiting(u)
1564 // ⛔ ALWAYS SHOWN, BOTH VALUES, IN EVERY STATE. This used to print `running` alone when it judged
1565 // itself up to date — so when that judgement was WRONG (it required `running`, which is null on a
1566 // fresh install) there was no way to see that a bundle had in fact been staged. A page that shows
1567 // its inputs only when it already agrees with you cannot be used to find out that it is wrong,
1568 // and several hours went into guessing at a state this line could have printed.
1569 const facts = '<p class="who">running ' + short(u.running) +
1570 ' · staged ' + short(u.staged) + '</p>'
1571
1572 const body = waiting
1573 ? '<p class="warn">An update is downloaded and will apply on the next start.</p>' + facts +
1574 (u.ota ? '<p><button id="app-restart">restart now</button></p>' : '<p class="who">This app cannot restart itself; close and reopen it.</p>')
1575 : '<p>Up to date.</p>' + facts
1576
1577 return '<h2>App updates</h2>' + version + body + checked + note +
1578 if (waiting) {
1579 return '<h2>App updates</h2>' + running +
1580 '<p class="warn">' + ver(u.stagedVersion) + ' is downloaded and will apply when the node restarts.</p>' +
1581 (u.ota
1582 ? '<p><button id="app-restart">restart now</button></p>'
1583 : '<p class="who">This app cannot restart itself; close and reopen it.</p>') +
1584 batteryLine(u) + facts(u, short) + checkedLine(u)
1585 }
1586
1587 // ⛔ "Up to date" ONLY when nothing is staged that differs. A staged bundle equal to the running one
1588 // is the ordinary case — the node re-verified what it is already running — and calling that an
1589 // update is exactly what made a restart button that did nothing.
1590 return '<h2>App updates</h2>' + running +
1591 '<p>Up to date.</p>' +
1592 batteryLine(u) + facts(u, short) + checkedLine(u)
1593}
1594
1595// ⚠ ALWAYS SHOWN, IN EVERY STATE. A page that shows its inputs only when it agrees with you cannot be
1596// used to discover that it is wrong — which cost several hours when `running` was null on a fresh
1597// install and the banner silently required it.
1598function facts (u, short) {
1599 return '<p class="who">running ' + short(u.running) + ' · staged ' + short(u.staged) +
1600 (u.stagedVersion ? ' (' + esc(u.stagedVersion) + ')' : '') + '</p>'
1601}
1602
1603/**
1604 * ⛔ THE REASON UPDATES ARRIVE LATE OR NOT AT ALL, said out loud.
1605 *
1606 * A frozen app has no swarm, so the release watch is never told anything. Measured on an SM-S922U:
1607 * Samsung's Freecess logs "freeze to.favela.surface", and an update published while the app was in
1608 * the background did not arrive until it was foregrounded some minutes later. From the outside that
1609 * is indistinguishable from a broken updater, and it was read as one — repeatedly, for a whole day.
1610 *
1611 * ⚠ Only shown when the shell actually ASKED and the answer was no. `null` means an older shell that
1612 * cannot ask, and warning somebody about a state nothing has measured is how a page teaches people to
1613 * ignore it.
1614 */
1615function batteryLine (u) {
1616 if (u.batteryExempt !== false) return ''
1617 return '<p class="warn">This phone is allowed to freeze the node in the background. While it is ' +
1618 'frozen it has no network, so updates and posts only arrive while you have the app open.</p>' +
1619 '<p><button id="app-battery">allow it to run in the background</button></p>'
1620}
1621
1622function checkedLine (u) {
1623 return (u.checkedAt ? '<p class="who">Last checked ' + esc(when(u.checkedAt)) + '</p>' : '') +
1624 (u.note ? '<p class="who">' + esc(u.note) + '</p>' : '') +
15781625 (u.ota ? '<p><button id="app-check">check now</button> <span id="app-msg" class="who"></span></p>' : '') +
15791626 (u.ota ? '<script type="module">\n' + UPDATE_JS + '\n</script>' : '')
15801627}
15811628
1629/**
1630 * A time somebody can read.
1631 *
1632 * ⚠ Relative, because "2026-09-11T19:52:39.148Z" is not an answer to "is this recent?" — which is the
1633 * only thing anyone asks of a timestamp on this page.
1634 */
1635function when (t) {
1636 const ms = typeof t === 'number' ? t : Date.parse(t)
1637 if (!ms) return String(t)
1638 const secs = Math.round((Date.now() - ms) / 1000)
1639 if (secs < 60) return 'just now'
1640 if (secs < 3600) return Math.round(secs / 60) + ' min ago'
1641 if (secs < 86400) return Math.round(secs / 3600) + ' hours ago'
1642 return new Date(ms).toISOString().slice(0, 10)
1643}
1644
15821645// ⛔ THE ONLY THING THIS POSTS IS A VERB. `parseShellMessage` on the other side is an allow-list of
15831646// three names and nothing else — no op passed through to a handler map, which is how a WebView bridge
15841647// turns into a remote-call surface for anything that reaches the page.
……@@ -1596,6 +1659,8 @@ if (check) check.addEventListener('click', (e) => {
15961659})
15971660const restart = document.getElementById('app-restart')
15981661if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') })
1662const battery = document.getElementById('app-battery')
1663if (battery) battery.addEventListener('click', (e) => { e.preventDefault(); post('battery') })
15991664`
16001665
16011666export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = [], did = null) {
test/pages.test.js +78 -0
……@@ -786,3 +786,81 @@ test('a refusal for a NEWER bundle is still shown', (t) => {
786786 }
787787 t.ok(updateBlockedReason(u).includes('failed to boot'), 'a new release that cannot run is worth saying')
788788})
789
790// ── the update section answers ONE question: why am I not on the latest? ──────────────────────────
791//
792// ⛔ IT USED TO ANSWER NOTHING. Two truncated tree hashes and a note. Every time an update failed to
793// apply, the only way to learn why was a cable, a laptop, and somebody who knows the codebase — which
794// is not a property a phone is allowed to have. These pin the three states a person can be in.
795test('★★ a waiting update names the version it will apply, not a hash', (t) => {
796 const h = updateSettings({
797 ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31'
798 })
799 t.ok(h.includes('Running 0.0.30'))
800 t.ok(/0\.0\.31 is downloaded/.test(h), 'the version that is waiting, by name')
801 t.ok(h.includes('app-restart'), 'and a way to apply it')
802})
803
804// ⛔ THE MOST IMPORTANT STATE. Something IS waiting and it CANNOT be applied — so offering a restart
805// here is a button that does nothing, which is exactly the trap this section kept falling into.
806test('⛔ a refused update says why, in chooseBundle\'s own words, and offers no restart', (t) => {
807 const h = updateSettings({
808 ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31',
809 refused: { hash: 'bbbb', reason: 'the staged bundle needs libbare-fs.4.9.0.so — in neither the APK nor the release' }
810 })
811 t.ok(/cannot be applied/.test(h))
812 t.ok(h.includes('libbare-fs.4.9.0.so'), 'the actual obstacle, verbatim')
813 // ⚠ The BUTTON, not the string: the inline script names both ids whether or not either is rendered.
814 t.absent(/<button id="app-restart">/.test(h), 'no restart button — it would do nothing')
815})
816
817test('★ a staged bundle identical to the running one is not an update', (t) => {
818 const h = updateSettings({ ota: true, version: '0.0.31', running: 'aaaa', staged: 'aaaa' })
819 t.ok(h.includes('Up to date.'))
820 t.absent(/<button id="app-restart">/.test(h), 'and nothing to press')
821})
822
823// ⚠ ALWAYS SHOWN. A page that prints its inputs only when it agrees with you cannot be used to find
824// out that it is wrong.
825test('⚠ the hashes are shown in every state, because equal versions can be different builds', (t) => {
826 for (const u of [
827 { ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31' },
828 { ota: true, version: '0.0.31', running: 'aaaa', staged: 'aaaa' },
829 { ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', refused: { reason: 'nope' } }
830 ]) {
831 const h = updateSettings(u)
832 t.ok(/running aaaa/.test(h), 'running hash shown')
833 t.ok(/staged/.test(h), 'staged hash shown')
834 }
835})
836
837test('⚠ timestamps read as ages, not ISO strings', (t) => {
838 const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', checkedAt: new Date().toISOString() })
839 t.ok(/just now|min ago/.test(h), 'a person can tell whether it is recent')
840 t.absent(/T\d\d:\d\d:\d\d\.\d\d\dZ/.test(h), 'no raw ISO timestamp')
841})
842
843// ⛔ THE REASON UPDATES ARRIVE LATE, SAID OUT LOUD. A frozen app has no swarm, so the release watch is
844// never told anything — and from the outside that is indistinguishable from a broken updater. It was
845// read as one, repeatedly, for a whole day.
846test('★★ a phone that freezes the node says so, and offers the fix', (t) => {
847 const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: false })
848 t.ok(/freeze the node in the background/.test(h), 'it names the actual cause')
849 t.ok(/only arrive while you have the app open/.test(h), 'and what that means for you')
850 t.ok(/<button id="app-battery">/.test(h), 'with a way to fix it')
851})
852
853// ⚠ null is "nobody asked" — an older shell. Warning about a state nothing has measured is how a page
854// teaches people to ignore its warnings.
855test('⚠ a shell that cannot ask shows no warning at all', (t) => {
856 for (const v of [undefined, null]) {
857 const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: v })
858 t.absent(/freeze the node/.test(h), 'silent when unknown: ' + String(v))
859 }
860})
861
862test('★ an exempt phone is not nagged', (t) => {
863 const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: true })
864 t.absent(/freeze the node/.test(h))
865 t.absent(/<button id="app-battery">/.test(h))
866})