OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 886a549

0.0.62: the update banner stops disagreeing with the shell
⛔ OBSERVED ON A HANDSET: "an update is ready — restart to apply", shown for ever, while restarting changed nothing. Reported as the app being stuck on 0.0.18. updateWaiting() re-implemented a decision it does not own: if (u.running) return u.staged !== u.running No build-time comparison on that branch, and no sight of failed.json — which lives on the shell side, where a page cannot read it. chooseBundle refuses a CONDEMNED bundle and refuses one the APK supersedes, so this said YES on exactly the bundles that can never be applied, and offered a restart that could not work and would not go away. Two rules for one question, and the visible one was wrong. The shell already runs chooseBundle at boot and now reports what it decided. This renders that verdict instead of deriving its own: a staged bundle the shell named as refused is never announced. A refusal naming a DIFFERENT hash does not suppress a genuinely new update, and a shell that sends no verdict keeps the old rules, so nothing changes for an older one. updateBlockedReason() and a second banner style, because going silent is not the fix either — it leaves someone watching a version number that never changes with nothing to read. "This update cannot be applied — the app was updated more recently than the staged bundle" is a sentence a person can act on, and it deliberately offers no restart button. Suite 199/199, 626/626. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
886a549c2e2d867f49f0c6457ea7585efb990e1d
by plankatron <fleeky@gmail.com> · 2026-09-11T01:31:06.000Z

Changed files (3) +85 -1

lib/pages.js +29 -0
……@@ -53,6 +53,13 @@ export function withTheme (html, theme) {
5353 */
5454export function updateWaiting (u) {
5555 if (!u || !u.staged) return false
56 // ⛔ THE SHELL'S VERDICT WINS, AND THIS IS WHY IT EXISTS. Observed on a handset: this notice shown
57 // for ever while restarting changed nothing. The rules below re-derive a decision this page does not
58 // own — chooseBundle also refuses a CONDEMNED bundle, and failed.json lives on the shell side where a
59 // page cannot see it, so no amount of build-time arithmetic here could ever have got that case right.
60 // The shell runs chooseBundle at boot and now reports what it decided; if it declined the bundle that
61 // is staged, no restart can help and offering one is a button that does nothing.
62 if (u.refused && u.refused.hash === u.staged) return false
5663 if (u.running) return u.staged !== u.running
5764 // ⛔ `running` NULL MEANS WE BOOTED THE APK'S OWN BUNDLE — and that does NOT automatically make a
5865 // staged bundle an update. `chooseBundle` prefers the ASSET when the app was installed more
……@@ -71,8 +78,30 @@ export function updateWaiting (u) {
7178 return true
7279}
7380
81/**
82 * Why a staged bundle will not be applied, or null when nothing is blocking.
83 *
84 * The counterpart to updateWaiting: a page that merely goes quiet leaves someone staring at a version
85 * number that never changes. Saying "this update needs a newer app" is a sentence they can act on.
86 */
87export function updateBlockedReason (u) {
88 if (!u || !u.staged) return null
89 if (u.refused && u.refused.hash === u.staged) return u.refused.reason || 'refused by the shell'
90 return null
91}
92
7493export function withUpdateBanner (html, update) {
7594 const u = update || null
95 // ⛔ A BLOCKED UPDATE STILL GETS A LINE, and it does NOT offer a restart. Going silent would leave
96 // someone watching a version number that never changes with nothing to read; the old behaviour —
97 // offering a restart that cannot work — was worse, but silence is not the fix. "This update needs a
98 // newer app" is a sentence a person can act on.
99 const blocked = updateBlockedReason(u)
100 if (blocked) {
101 const line = '<div class="update-bar update-bar-blocked"><a href="/settings">This update cannot be applied — ' +
102 esc(blocked) + '</a></div>'
103 return html.includes('<body>') ? html.replace('<body>', '<body>' + line) : line + html
104 }
76105 if (!u || !updateWaiting(u)) return html
77106 const bar = '<div class="update-bar"><a href="/settings">An update is ready — restart to apply</a></div>'
78107 // After the opening <body>, so it is the first thing in the document and cannot be pushed below
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.61",
3 "version": "0.0.62",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/pages.test.js +55 -0
……@@ -707,3 +707,58 @@ test('★ a staged bundle OLDER than the installed app is not an update', (t) =>
707707 t.ok(withUpdateBanner(page, { running: null, staged: 'bbbb' }).includes('update-bar'),
708708 'undated on both sides stays a candidate')
709709})
710
711// ── the update banner must not disagree with the shell ────────────────────────────────────────────
712//
713// ⛔ OBSERVED ON A HANDSET: "an update is ready, restart to apply", shown for ever, while restarting
714// changed nothing. This function had re-implemented a decision it does not own —
715// `if (u.running) return u.staged !== u.running` — with no build-time comparison and no sight of
716// failed.json, which lives on the shell side and which a page cannot read. So it answered YES on
717// exactly the bundles chooseBundle refuses, and offered a button that could never work.
718//
719// The shell already runs chooseBundle at boot and now reports its verdict. This renders that.
720import { updateWaiting, updateBlockedReason } from '../lib/pages.js'
721
722test('a bundle the shell REFUSED is never announced as waiting', (t) => {
723 const u = {
724 staged: 'a'.repeat(64),
725 running: 'b'.repeat(64),
726 refused: { hash: 'a'.repeat(64), reason: 'the app was updated more recently than the staged bundle' }
727 }
728 t.absent(updateWaiting(u), 'no banner for something that cannot be applied')
729 t.ok(updateBlockedReason(u).includes('more recently'), 'and the reason is available to show instead')
730})
731
732test('a refusal naming a DIFFERENT bundle does not suppress a real update', (t) => {
733 // An old refusal must not hide a newly staged bundle that will apply.
734 const u = {
735 staged: 'c'.repeat(64),
736 running: 'b'.repeat(64),
737 refused: { hash: 'a'.repeat(64), reason: 'stale' }
738 }
739 t.ok(updateWaiting(u), 'the new one is still announced')
740 t.is(updateBlockedReason(u), null)
741})
742
743test('with no verdict from the shell the old rules still apply', (t) => {
744 // An older shell sends no `refused`. Behaviour must not change for it.
745 t.ok(updateWaiting({ staged: 'a'.repeat(64), running: 'b'.repeat(64) }), 'differs from running')
746 t.absent(updateWaiting({ staged: 'a'.repeat(64), running: 'a'.repeat(64) }), 'same as running')
747 t.absent(updateWaiting({ staged: 'a'.repeat(64), stagedBuiltAt: 1000, builtAt: 9000 }), 'APK newer')
748 t.ok(updateWaiting({ staged: 'a'.repeat(64), stagedBuiltAt: 9000, builtAt: 1000 }), 'staged newer')
749})
750
751test('nothing staged is never waiting and never blocked', (t) => {
752 t.absent(updateWaiting({ staged: null, refused: { hash: 'a'.repeat(64), reason: 'x' } }))
753 t.is(updateBlockedReason({ staged: null }), null)
754})
755
756test('a blocked update gets a line that does NOT offer a restart', (t) => {
757 const u = {
758 staged: 'a'.repeat(64), running: 'b'.repeat(64),
759 refused: { hash: 'a'.repeat(64), reason: 'the app was updated more recently than the staged bundle' }
760 }
761 const html = withUpdateBanner('<body>page</body>', u)
762 t.ok(html.includes('cannot be applied'), 'says so')
763 t.absent(html.includes('restart to apply'), 'and never offers the button that does nothing')
764})