OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 2cf7e4b

0.0.51: settings can show an update is waiting, and ask for a restart
There was nothing anywhere in the surface that could say an update had been downloaded, or apply one. favela-android has had this and favela-surface never did — the whole mechanism (`shellState`, `parseShellMessage`, the state file) was already copied into the Android shell and nothing on either side used it. ⛔ RENDERED FROM A FILE THE SHELL WROTE, and supplied through `ctx`, never read from here. `test/router-import.test.js` asserts this file touches no environment — "an embedder has no env to read" — and it caught me doing exactly that: my first version read `process.env.FAVELA_SHELL_STATE` and went red. The shell knows where its state lives; the router must not learn what a worklet is. On a desktop nothing supplies it and the section correctly does not render at all. ⛔ STAGED-AND-DIFFERENT is the only state that means "an update is waiting". Staged and IDENTICAL is the ordinary case — the node re-verified the bundle it is already running — and calling that "update available" is precisely what makes a restart button lie. ⛔ THE CONTROLS FOLLOW THE SHELL'S CAPABILITY. `ota` and `pair` are separate flags because the page arrives by OTA and the shell by APK, so "can service update buttons" is answered by whatever shell is actually running. A page that renders a control the shell beneath it cannot honour is worse than one that renders nothing, so without `ota` the buttons are absent and it says why. The bridge posts a VERB and nothing else; the other side allow-lists three names. No op passed through to a handler map. 181/181 tests, 584/584 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2cf7e4ba63c861a09226e27ba6b2e5a54be4c4a3
by plankatron <fleeky@gmail.com> · 2026-09-10T14:47:22.000Z

Changed files (3) +64 -7

lib/pages.js +52 -2
……@@ -1428,7 +1428,57 @@ export function reachSettings (reach) {
14281428 '<button>save</button></form>'
14291429}
14301430
1431export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null) {
1431
1432// ── app updates ──────────────────────────────────────────────────────────────────────────────────
1433//
1434// ⛔ RENDERED FROM A FILE THE SHELL WROTE, not from anything this surface knows. favela-surface has no
1435// idea whether it is inside an Android worklet or on a desktop, and must not learn: the shell writes
1436// `app-update.json`, the page reads it, and neither knows the other's process model.
1437//
1438// ⛔ AND THE CONTROLS FOLLOW THE SHELL'S CAPABILITY, not our own optimism. `ota` and `pair` are
1439// separate flags for a reason recorded in shell-handoff.js: the page arrives by OTA and the shell by
1440// APK, so "can service update buttons" is answered by whatever shell is actually running. A page that
1441// renders a control the shell beneath it cannot honour is worse than one that renders nothing.
1442export function updateSettings (u) {
1443 if (!u) return ''
1444 const short = (h) => (h ? esc(String(h).slice(0, 12)) + '…' : '<span class="who">unknown</span>')
1445 const checked = u.checkedAt ? '<p class="who">Last checked ' + esc(u.checkedAt) + '</p>' : ''
1446
1447 // ⛔ STAGED AND DIFFERENT is the only state that means "an update is waiting". Staged and IDENTICAL
1448 // is the ordinary case — the node re-verified the bundle it is already running — and calling that
1449 // "update available" is what makes a restart button lie.
1450 const waiting = u.staged && u.running && u.staged !== u.running
1451 const body = waiting
1452 ? '<p class="warn">An update is downloaded and will apply on the next start.</p>' +
1453 '<p class="who">running ' + short(u.running) + ' &rarr; staged ' + short(u.staged) + '</p>' +
1454 (u.ota ? '<p><button id="app-restart">restart now</button></p>' : '<p class="who">This app cannot restart itself; close and reopen it.</p>')
1455 : '<p>Up to date.</p><p class="who">running ' + short(u.running) + '</p>'
1456
1457 return '<h2>App updates</h2>' + body + checked +
1458 (u.ota ? '<p><button id="app-check">check now</button> <span id="app-msg" class="who"></span></p>' : '') +
1459 (u.ota ? '<script type="module">\n' + UPDATE_JS + '\n</script>' : '')
1460}
1461
1462// ⛔ THE ONLY THING THIS POSTS IS A VERB. `parseShellMessage` on the other side is an allow-list of
1463// three names and nothing else — no op passed through to a handler map, which is how a WebView bridge
1464// turns into a remote-call surface for anything that reaches the page.
1465const UPDATE_JS = `
1466const post = (op) => {
1467 try { window.ReactNativeWebView.postMessage(JSON.stringify({ t: 'app-update', op })) }
1468 catch { const m = document.getElementById('app-msg'); if (m) m.textContent = 'this app cannot do that' }
1469}
1470const check = document.getElementById('app-check')
1471if (check) check.addEventListener('click', (e) => {
1472 e.preventDefault()
1473 const m = document.getElementById('app-msg')
1474 if (m) m.textContent = 'checking…'
1475 post('check')
1476})
1477const restart = document.getElementById('app-restart')
1478if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') })
1479`
1480
1481export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null) {
14321482 const p = profile || {}
14331483 const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
14341484 const shown = p.avatar
……@@ -1439,7 +1489,7 @@ export function settings (profile, message, handle = null, namespace = null, tok
14391489 const shownBanner = p.banner
14401490 ? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>'
14411491 : ''
1442 return layout('settings', note + reachSettings(reach) +
1492 return layout('settings', note + updateSettings(update) + reachSettings(reach) +
14431493 '<h1>Settings</h1>' + shown +
14441494 '<form method="post" action="/settings" enctype="multipart/form-data">' +
14451495 '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
lib/router.js +11 -4
……@@ -81,7 +81,7 @@ const body = (req) => new Promise((resolve) => {
8181 * `profiles` like every other byline — the same path, so settings can never show a handle the
8282 * timeline would not, or the other way round.
8383 */
84async function settingsPage (client, message, token = null, theme = null) {
84async function settingsPage (client, message, token = null, theme = null, appUpdate = null) {
8585 const me = await client.call('whoami')
8686 const mine = (await client.call('profiles'))[me.feed] || {}
8787 const ns = await client.call('namespace')
……@@ -94,7 +94,14 @@ async function settingsPage (client, message, token = null, theme = null) {
9494 // Passing null makes the section absent rather than showing every network as off, which would be
9595 // a claim this surface cannot support.
9696 const reach = await client.call('reach').catch(() => null)
97 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach)
97 // ⛔ SUPPLIED BY THE EMBEDDER, never read from here. `test/router-import.test.js` asserts this file
98 // touches no environment — "an embedder has no env to read" — and it is right: a shell writes
99 // `app-update.json`, its own entrypoint knows where, and this file must not learn what a worklet is.
100 // Absent on every desktop, where there is no shell and no APK to update, so the section does not
101 // render at all rather than being fabricated from what this surface can see.
102 let update = null
103 try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ }
104 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update)
98105}
99106
100107/**
……@@ -658,7 +665,7 @@ export async function route (req, res, ctx) {
658665 }
659666
660667 if (req.method === 'GET' && path === '/settings') {
661 return send(res, await settingsPage(client, url.searchParams.get('m'), null, themeFrom(req)))
668 return send(res, await settingsPage(client, url.searchParams.get('m'), null, themeFrom(req), ctx.appUpdate))
662669 }
663670
664671 // ── the namespace ───────────────────────────────────────────────────────────────────────────
……@@ -748,7 +755,7 @@ export async function route (req, res, ctx) {
748755 } catch (err) {
749756 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
750757 }
751 return send(res, await settingsPage(client, null, minted.token, themeFrom(req)))
758 return send(res, await settingsPage(client, null, minted.token, themeFrom(req), ctx.appUpdate))
752759 }
753760
754761 if (req.method === 'POST' && path === '/namespace/admit') {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.50",
3 "version": "0.0.51",
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",