OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 2ccab10

0.0.6: update controls the phone can actually reach, and a resolver on Android
Two things a handset boot measured tonight said were missing. Both are plumbing that already exists on every other node and had simply never crossed into the app. 1. THE UPDATE THE PHONE CANNOT APPLY Staging and applying are deliberately separate — applying to a running node means swapping code under itself — so an update discovered during boot N can only run at boot N+1. With a 6-hour poll and no way to restart the node, "current" is a state the device could only reach by accident. Measured on ZY22BN27XP: it was running a bundle whose replacement had been staged for over half an hour, with no reachable way to apply it. - lib/ota.js returns `checkNow`. pgh-runtime 0.1.4 has had a PUBLIC checkNow() since it shipped (index.js:107); we threw the handle away, which is the entire reason the app had no update control. lib/node.js keeps it now. - `updateStatus` / `checkForUpdate` verbs. checkForUpdate STAGES and says so — it does not claim to have updated anything, because it hasn't. - restartNode() in app/worklet.js: stopNode + startNode, which re-runs chooseBundle, which is what makes "restart" and "apply the update" one button. Only this side can do it — the worklet cannot start a different worklet. - stopNode now removes every subscription it added. stop+start is a supported pair now, and a leftover AppState listener would be a second voice deciding the suspend policy, holding a terminated worklet's lifecycle. ⛔ currentTreeHash IS FINALLY PASSED. Without it pgh-runtime's "nothing to do if we already run this tree" test (index.js:123) can never fire, so every boot logged `downloading … / staged … — active on next start` naming the bundle it was ALREADY RUNNING. No bytes moved (installTree short-circuits on hasTree), but that log is what a whole evening's wrong conclusion was read off — it is why the OTA path was written up as costing "two restarts minimum" when a real release costs one. 2. DNS TXT ON ANDROID favela-web logs `no nameserver in /etc/resolv.conf — DNS TXT handle verification is OFF` on the phone, because Android has no resolv.conf: netd owns resolution and `net.dns1` has been empty since Android 8 (confirmed on the device). So the bare-dns crash was fixed and the CAPABILITY never arrived — every custom-domain handle renders handle.invalid there, which by our own measurement is most of the interesting identities on the network. FavelaNodeModule.dnsServers() reads ConnectivityManager's list for the active network. No new permission — ACCESS_NETWORK_STATE is already declared. These are the OS's own resolvers, not a third party's: hardcoding 1.1.1.1 would hand a stranger the list of every identity this device looks up, which favela-web's resolver construction explicitly refuses to do. THE HANDOFF IS A FILE, NOT argv (lib/shell-handoff.js) The obvious way to pass the running bundle's hash to the worklet is a second argv element. It is a trap: the shell ships in the APK, the bundle arrives by OTA, and lib/node.js reads storage as `Bare.argv[argv.length - 1]` — so a new shell passing [storage, hash] to an OLDER bundle hands it the hash as its storage path, and the node boots into an empty directory. A file is additive in both directions: an old bundle never reads it, a new bundle finds it missing and says so. <storage>/shell.json shell → node { ota, bundle, dns } <storage>/app-update.json node → page { ota, running, staged, checkedAt } `ota` is a CAPABILITY CLAIM: it means this shell has a message handler that can service the settings page's buttons. The page renders them only when it is true, so a new bundle inside an old shell shows no controls rather than dead ones. `running` comes from the shell and never from the OTA store — the store knows what is STAGED, and deriving "running" from it is exactly how the log came to announce staging the bundle already in memory. The state file is written BEFORE the OTA attempt, so a build without pgh-runtime still offers the restart — which is also the fix for a stale session and a wedged node. parseShellMessage allow-lists two verbs rather than forwarding an op name to a handler map: this is a string from a web page reaching a function that restarts the node, and "the page is ours" is not a property enforced in App.jsx. Suite green (10 files, 30 new assertions in test/shell.test.mjs). Not verified on a handset: no build was made tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2ccab1018197ff8b05fb7d7be78a09ff0cc5c752
by plankatron <fleeky@gmail.com> · 2026-08-25T02:22:58.000Z

Changed files (9) +419 -10

android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +42 -0
……@@ -1,9 +1,12 @@
11package to.favela.android
22
3import android.content.Context
34import android.content.Intent
5import android.net.ConnectivityManager
46import android.os.Handler
57import android.os.Looper
68import androidx.core.content.ContextCompat
9import com.facebook.react.bridge.Arguments
710import com.facebook.react.bridge.Promise
811import com.facebook.react.bridge.ReactApplicationContext
912import com.facebook.react.bridge.ReactContextBaseJavaModule
……@@ -69,6 +72,45 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
6972 }
7073 }
7174
75 /**
76 * The DNS servers for the ACTIVE network.
77 *
78 * ⛔ THE ONLY SOURCE ON ANDROID. There is no /etc/resolv.conf — netd owns resolution — and the legacy
79 * `net.dns1` system property has been empty since Android 8. The node needs these because atproto
80 * handles are domain names and, measured 2026-08-24, every custom-domain handle verifies ONLY over DNS
81 * TXT: without a resolver the phone renders most identities as handle.invalid while the desktop
82 * resolves them fine.
83 *
84 * These are the OS's own resolvers for the network we are on, which is the point — the alternative
85 * (hardcoding a public resolver) would hand a third party the list of every identity this device looks
86 * up, which favela-web's resolver construction explicitly refuses to do.
87 *
88 * Needs no new permission: ACCESS_NETWORK_STATE is already declared for the foreground service.
89 *
90 * Resolves an EMPTY LIST rather than rejecting when there is no network or the lookup fails. "No
91 * resolver right now" is an ordinary state on a phone (airplane mode, mid-handover) and the node
92 * degrades to HTTPS-only verification; a rejection here would surface as a boot failure.
93 */
94 @ReactMethod
95 fun dnsServers(promise: Promise) {
96 val out = Arguments.createArray()
97 try {
98 val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
99 val network = cm?.activeNetwork
100 val props = if (network != null) cm.getLinkProperties(network) else null
101 for (addr in props?.dnsServers.orEmpty()) {
102 // hostAddress is the numeric form. A link-local IPv6 resolver carries a %scope suffix that
103 // means nothing to a socket opened elsewhere, so it is dropped rather than passed on.
104 val s = addr.hostAddress ?: continue
105 if (s.contains('%')) continue
106 out.pushString(s)
107 }
108 } catch (_: Throwable) {
109 // Fall through with whatever was collected — see the note above on why this never rejects.
110 }
111 promise.resolve(out)
112 }
113
72114 /** Drop the notification. Used on app teardown; the Stop action goes the other way, via the service. */
73115 @ReactMethod
74116 fun stop(promise: Promise) {
app/App.jsx +35 -2
……@@ -16,6 +16,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'
1616import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
1717import { WebView } from 'react-native-webview'
1818import { startNode, stopNode } from './worklet'
19import { parseShellMessage } from '../lib/shell-handoff'
1920import Pair from './Pair'
2021import { Center } from './Screens'
2122
……@@ -56,6 +57,33 @@ export default function App () {
5657 } catch (e) { setError(e.message) }
5758 }, [boot, client])
5859
60 // THE SETTINGS PAGE'S UPDATE BUTTONS. favela-web renders them (render.js appUpdateSection) and posts
61 // here, because neither half can do this alone: the worklet owns the OTA runtime, and only this side
62 // owns the RUNTIME ITSELF and can start a different bundle.
63 //
64 // ⛔ TWO VERBS, NOT A DISPATCHER. parseShellMessage allow-lists them rather than passing an op name
65 // through to a handler map — this is a string from a web page reaching a function that restarts the
66 // node, and the fact that the page is ours today is not a property enforced anywhere in this file.
67 //
68 // `reload` is how the page learns the result: the worklet writes the state to disk and favela-web
69 // renders it server-side, so re-fetching the page IS the update. No reply channel, no second copy of
70 // those three fields.
71 const onShellMessage = useCallback(async (data, reload) => {
72 const msg = parseShellMessage(data)
73 if (!msg) return
74 try {
75 if (msg.op === 'check') {
76 if (client) await client.checkForUpdate()
77 reload()
78 } else if (msg.op === 'restart') {
79 // Not `reload` — the whole UI comes down with the node, and boot() puts the splash back with the
80 // real progress lines. A restart takes ~30s on a handset and pretending otherwise reads as a hang.
81 stopNode()
82 await boot()
83 }
84 } catch (e) { setError(e.message) }
85 }, [boot, client])
86
5987 if (error) return <Center><Text style={s.err}>{error}</Text></Center>
6088 // The splash is not decoration: a cold boot takes ~40s (corestore, swarm, appview index, then the
6189 // server), and the worklet reports each stage. A WebView pointed at the server before it listens shows
……@@ -72,10 +100,10 @@ export default function App () {
72100 return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} />
73101 }
74102
75 return <Web status={status} client={client} onPair={() => setPairing(true)} />
103 return <Web status={status} client={client} onPair={() => setPairing(true)} onShellMessage={onShellMessage} />
76104}
77105
78function Web ({ status, client, onPair }) {
106function Web ({ status, client, onPair, onShellMessage }) {
79107 const ref = useRef(null)
80108 const [canBack, setCanBack] = useState(false)
81109 const [failed, setFailed] = useState(null)
……@@ -148,6 +176,11 @@ function Web ({ status, client, onPair }) {
148176 // in a real browser is a later refinement, and refusing is the safe default in the meantime.
149177 onShouldStartLoadWithRequest={(r) => r.url.startsWith(status.favelaUrl)}
150178 onNavigationStateChange={(n) => setCanBack(n.canGoBack)}
179 // The settings page's App-updates section (favela-web render.js) is the only sender. Handled in
180 // App, not here, because a restart tears down the client this component was handed.
181 onMessage={(e) => {
182 if (onShellMessage) onShellMessage(e && e.nativeEvent && e.nativeEvent.data, () => { if (ref.current) ref.current.reload() })
183 }}
151184 onLoadEnd={() => { loaded.current = true }}
152185 // ⛔ A FAILED REQUEST IS NOT A FAILED APP. These fire for the MAIN FRAME, which includes the POST
153186 // a compose submits — so a single 500 from one write tore down the entire rendered UI and replaced
app/worklet.js +72 -4
……@@ -13,10 +13,14 @@ import { createLifecycle } from '../lib/lifecycle'
1313import { chooseBundle } from '../lib/choose-bundle'
1414import { describeNode } from '../lib/describe'
1515import { encodeUtf8, createUtf8Decoder } from '../lib/utf8'
16import { encodeShell, SHELL_FILE } from '../lib/shell-handoff'
1617
1718let started = null
1819let starting = null
1920let lifecycle = null
21// Removed on teardown, because boot() runs AGAIN on restart — leaving them attached would give a
22// twice-restarted app three AppState listeners driving one suspend policy.
23let subs = []
2024
2125// NodeService, as lib/lifecycle.js wants to see it. The permission request lives here rather than in the
2226// policy because it is a UI event: from Android 13 a foreground service still RUNS without
……@@ -38,6 +42,24 @@ const service = {
3842 }
3943}
4044
45// The OS's DNS servers for the ACTIVE network, which is the only place they exist on Android: there is
46// no /etc/resolv.conf, `getprop net.dns1` has been empty since Android 8, and netd does not expose the
47// list to an app except through ConnectivityManager. Needs no new permission — ACCESS_NETWORK_STATE is
48// already in the manifest for the foreground service.
49//
50// ⚠ A BOOT-TIME SNAPSHOT. Roaming to another network changes the answer and the node will not notice,
51// because favela-web captures its resolver at import. That matches how every other favela node behaves
52// (they read resolv.conf once, at start), so it is a known limit rather than a regression — the fix is a
53// resolver that can be re-pointed, which is a change on the favela-web side.
54async function dnsServers () {
55 const native = NativeModules.FavelaNode
56 if (!native || typeof native.dnsServers !== 'function') return []
57 try {
58 const list = await native.dnsServers()
59 return Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s) : []
60 } catch { return [] }
61}
62
4163// Replace the notification's text once the node can answer for itself. Never speculatively: the shell
4264// does not know what is being served until it asks, and the shade is the one surface a user reads
4365// without opening anything.
……@@ -101,6 +123,7 @@ async function boot () {
101123 // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the
102124 // APK's own bundle, which is the one known to work because it shipped with this binary.
103125 let bundlePath = dest
126 let bundleHashChosen = null
104127 try {
105128 const bdir = RNFS.DocumentDirectoryPath + '/bundles'
106129 const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } }
……@@ -123,6 +146,7 @@ async function boot () {
123146 if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) {
124147 await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8')
125148 bundlePath = bdir + '/' + choice.file
149 bundleHashChosen = choice.hash
126150 console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
127151 }
128152 } catch (e) {
……@@ -137,6 +161,29 @@ async function boot () {
137161 console.log('[worklet] bundle ' + wantSize + ' bytes at ' + dest +
138162 (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms')
139163
164 // ── WHAT THIS SIDE KNOWS AND THE NODE CANNOT ─────────────────────────────────────────────────────
165 // Three facts, written where the node will look for them (lib/shell-handoff.js explains why a file and
166 // not argv — a positional argument would be read as the storage path by any older bundle):
167 //
168 // bundle WHICH bundle is executing. The OTA store knows what is STAGED; only this side knows what
169 // was started, and without it every boot "staged" the bundle it was already running.
170 // dns the OS's resolvers for the active network. Android has no /etc/resolv.conf, so without
171 // this the node turns DNS TXT handle verification off and most handles render invalid.
172 // ota that THIS shell can service the settings page's update buttons. The shell ships in the
173 // APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell;
174 // the page renders those controls only when this says they will do something.
175 //
176 // Written before every start and never appended to, so it always describes the CURRENT boot. Failure
177 // is non-fatal: the node boots with less knowledge, not not at all.
178 const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null)
179 try {
180 const dns = await dnsServers()
181 await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, bundle: bundleHash, dns }), 'utf8')
182 console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length)
183 } catch (e) {
184 console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
185 }
186
140187 // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
141188 worklet.start(bundlePath, source, [storage])
142189 console.log('[worklet] start() returned')
……@@ -150,15 +197,15 @@ async function boot () {
150197 lifecycle = createLifecycle({ worklet, service, log: (m) => console.log('[worklet] ' + m) })
151198 const foreground = await lifecycle.start()
152199 console.log('[worklet] background replication ' + (foreground ? 'ON (foreground service)' : 'OFF — the node will suspend when the app is not on screen'))
153 AppState.addEventListener('change', (state) => { lifecycle.onAppState(state) })
200 subs.push(AppState.addEventListener('change', (state) => { lifecycle.onAppState(state) }))
154201 // The Stop action on the notification. The service drops the exemption and goes away; the node is NOT
155202 // torn down — it keeps running while the app is open and suspends once it is backgrounded, which is
156203 // what "stop replicating in the background" means. Tearing it down here would make one button mean two
157204 // things.
158 DeviceEventEmitter.addListener('favela-node-service-stopped', () => {
205 subs.push(DeviceEventEmitter.addListener('favela-node-service-stopped', () => {
159206 console.log('[worklet] foreground service stopped — the node will suspend when the app is not on screen')
160207 lifecycle.onServiceStopped()
161 })
208 }))
162209
163210 const { IPC } = worklet
164211
……@@ -195,19 +242,40 @@ async function boot () {
195242}
196243
197244/**
198 * Stop the node. Called when the app is being torn down for real — NOT on backgrounding.
245 * Stop the node. Called when the app is being torn down for real, and to RESTART it — NOT on
246 * backgrounding.
199247 *
200248 * ⚠ Backgrounding must NOT stop the node: that is the whole point of running a peer on the phone, and
201249 * spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Backgrounding is
202250 * handled by lib/lifecycle.js + NodeService (spec/architecture.md §7), never here.
251 *
252 * ⛔ EVERY SUBSCRIPTION IS DROPPED. stopNode + startNode is now a supported pair (it is how a staged
253 * bundle is applied and how a wedged node is recovered), so anything this file attached must come off:
254 * a listener left behind would still be holding the TERMINATED worklet's lifecycle policy, and the
255 * second boot's listener would be the second voice deciding when to suspend.
203256 */
204257export function stopNode () {
205258 if (!started) return
206259 // The notification goes first: it says the node is replicating in the background, and for the moment
207260 // between terminate() and the service being told, it would be claiming something untrue.
208261 if (lifecycle) { lifecycle.stop().catch(() => {}); lifecycle = null }
262 for (const sub of subs) { try { sub.remove() } catch {} }
263 subs = []
209264 try { started.client.close('app teardown') } catch {}
210265 try { started.worklet.terminate() } catch {}
211266 started = null
212267 starting = null
213268}
269
270/**
271 * Stop the node and start it again — the ONLY way a staged bundle becomes the running one.
272 *
273 * Applying an update to a running node would mean swapping code under itself, so lib/ota.js stages and
274 * stops. This is the other half, and it lives here because only this side owns the runtime: the worklet
275 * cannot start a different worklet. On the way back up, chooseBundle picks whatever is staged, which is
276 * what makes "restart" and "apply the update" the same button.
277 */
278export async function restartNode () {
279 stopNode()
280 return startNode()
281}
lib/ipc-client.js +4 -0
……@@ -130,6 +130,10 @@ export class WorkletClient {
130130 /** Mint a fresh favela-web session. Used when /auth/handoff refuses a token the server has forgotten. */
131131 resession () { return this.request('resession') }
132132 collections () { return this.request('collections') }
133 // App updates. `checkForUpdate` stages at most — the restart that runs a staged bundle is the shell's
134 // (app/worklet.js), because only the side that owns the runtime can start a different one.
135 updateStatus () { return this.request('updateStatus') }
136 checkForUpdate () { return this.request('checkForUpdate') }
133137 // The ported surface. Thin by design — they exist so a screen never types an op string, not to add
134138 // logic; every one of them maps to an appview query or a device write in the worklet.
135139 compose (params) { return this.request('compose', params) }
lib/node.js +83 -1
……@@ -21,6 +21,7 @@ import path from 'bare-path'
2121import b4a from 'b4a'
2222import { createUtf8Decoder } from './utf8.js'
2323import { rosterEnv } from './roster.js'
24import { parseShell, shellState, SHELL_FILE, STATE_FILE } from './shell-handoff.js'
2425
2526const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
2627
……@@ -53,6 +54,16 @@ const dirs = {
5354 overlay: path.join(storage, 'hpp', 'atproto-repo')
5455}
5556
57// What the native shell handed us for THIS boot: which bundle it started, whether it can service the
58// settings page's update buttons, and the OS's DNS servers. Read once, here, because prepareEnv turns
59// two of the three into env that favela-web freezes at import (see below).
60//
61// Absent on an old shell — every field then reads as "unknown", which is the honest answer and disables
62// exactly the features that depend on it. See lib/shell-handoff.js for why this is a file, not argv.
63const shell = (() => {
64 try { return parseShell(fs.readFileSync(path.join(storage, SHELL_FILE), 'utf8')) } catch { return parseShell(null) }
65})()
66
5667// ⛔ ENV BEFORE ANY IMPORT THAT READS IT. favela-web's libs capture process.env at module top level, so
5768// their config freezes on first import. Everything below is therefore assigned here and every favela
5869// import in boot() is DYNAMIC.
……@@ -64,6 +75,16 @@ function prepareEnv () {
6475 process.env.PEER_CONFIG = path.join(dirs.hpp, 'config.json')
6576 // A phone is storage-constrained and killed constantly; bound the inbound lens by default.
6677 process.env.FAVELA_RETENTION_BUDGET = process.env.FAVELA_RETENTION_BUDGET || String(64 * 1024 * 1024)
78 // ⛔ ANDROID HAS NO /etc/resolv.conf. favela-web finds no nameserver there and turns DNS TXT handle
79 // verification OFF — which on a handset means every custom-domain handle renders handle.invalid,
80 // measured 2026-08-24. netd owns resolution on Android and `getprop net.dns1` has been empty since
81 // Android 8, so the only source is ConnectivityManager, which only the native side can ask. It does,
82 // and passes the answer through shell.json. This is the OS's own resolver for the active network —
83 // not a third-party one, which favela-web deliberately refuses to default to.
84 if (shell.dns.length) process.env.FAVELA_NAMESERVERS = shell.dns.join(',')
85 // Where the settings page reads the update state from. Set unconditionally so the file's ABSENCE (a
86 // node that never wrote one) and an `ota:false` inside it are distinguishable.
87 process.env.FAVELA_SHELL_STATE = path.join(storage, STATE_FILE)
6788}
6889
6990// Env-overridable so the suite can bind an ephemeral port. Hardcoding it meant test/ipc.test.mjs —
……@@ -73,6 +94,10 @@ const FAVELA_PORT = Number(process.env.FAVELA_PORT) || 4041
7394// No `appview` here any more — favela-web owns the one index on this node. See the note where the
7495// worklet's second one used to be built.
7596const state = { hpp: null, ready: false, favelaUrl: null, favelaSession: null }
97// The OTA runtime handle and the last time a check completed. Module-scoped because the IPC surface
98// needs both long after boot() has returned.
99let ota = null
100let _lastCheck = null
76101
77102// BOOT TIMING. "The app takes a very long time to start" has been a standing complaint with no number
78103// attached, and every guess about the cause (bundle size? swarm join? replication?) is unfalsifiable
……@@ -281,16 +306,45 @@ async function boot () {
281306 state.ready = true
282307 send({ t: 'ready' })
283308
309 // ⛔ BEFORE THE OTA ATTEMPT, NOT AFTER IT. The settings section is gated on this file existing, and it
310 // carries the RESTART button — which is also the fix for a stale session and for a node that has
311 // wedged. Writing it only on a successful startOta would withdraw the recovery control from exactly
312 // the builds that most need it (pgh-runtime is deferred in the pack and may not be there at all).
313 writeUpdateState()
314
284315 // OTA for this bundle. Deferred in the pack (--defer pgh-runtime) and dynamically imported, so a
285316 // build without it still runs: an update path that can break the node is worse than no update path.
317 //
318 // ⛔ THE HANDLE IS KEPT NOW. It used to be dropped on the floor, which is why there was no way to ask
319 // for a check: pgh-runtime's `checkNow()` is public and was simply unreachable from here.
320 //
321 // And `currentTreeHash` is finally passed. Without it pgh-runtime's "nothing to do if we already run
322 // this tree" test can never fire, so every boot logged `downloading … / staged … — active on next
323 // start` naming the bundle it was already running. No bytes moved (installTree short-circuits on
324 // hasTree), but that log is what a whole evening's wrong conclusion was read off.
286325 try {
287326 const { startOta } = await import('./ota.js')
288 await startOta({ storage: dirs.root, say: (m) => log(m) })
327 ota = await startOta({ storage: dirs.root, say: (m) => log(m), currentTreeHash: shell.bundle })
328 writeUpdateState()
289329 } catch (e) {
290330 log('ota unavailable (non-fatal)', { error: (e && e.message) || String(e) })
291331 }
292332}
293333
334// The settings page's three fields, on disk where favela-web can render them SERVER-side. Kept as a file
335// rather than an IPC reply because the page is HTML from favela-web, not React Native — a reply channel
336// would mean a second source of truth for the same three fields.
337//
338// Never throws: this is a status file. A node that cannot write it still runs, and the page then shows
339// what it last knew.
340function writeUpdateState (checkedAt = _lastCheck) {
341 try {
342 let staged = null
343 try { staged = (JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8')) || {}).treeHash } catch {}
344 fs.writeFileSync(process.env.FAVELA_SHELL_STATE, JSON.stringify(shellState({ shell, staged, checkedAt })))
345 } catch { /* status only */ }
346}
347
294348// ---- the IPC surface (spec §3) ---------------------------------------------------------------------
295349// ── talking to our own favela-web ─────────────────────────────────────────────────────────────────
296350//
……@@ -376,6 +430,34 @@ const handlers = {
376430 const r = await signInToFavela()
377431 return { ok: !!r.ok, favelaSession: state.favelaSession, favelaUrl: state.favelaUrl, reason: r.reason || null }
378432 },
433
434 // ── app updates ──────────────────────────────────────────────────────────────────────────────────
435 // What is running, what is staged, when we last looked. The same three fields the settings page reads
436 // from disk; this verb exists for the shell and the suite, which have no HTML to read.
437 async updateStatus () {
438 let staged = null
439 try { staged = (JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8')) || {}).treeHash } catch {}
440 return { ...shellState({ shell, staged, checkedAt: _lastCheck }), available: !!ota }
441 },
442
443 // Ask NOW instead of waiting up to six hours.
444 //
445 // ⛔ IT STILL DOES NOT APPLY ANYTHING. Staging and applying are separate on purpose (applying to a
446 // running node means swapping code under itself), so this downloads and stages; the restart that runs
447 // it belongs to the shell, which owns the runtime. Saying "updated" here would be the same false
448 // report the OTA log used to make.
449 async checkForUpdate () {
450 if (!ota || typeof ota.checkNow !== 'function') return { ok: false, reason: 'ota unavailable in this build' }
451 try {
452 await ota.checkNow()
453 _lastCheck = new Date().toISOString()
454 writeUpdateState()
455 return { ok: true, ...(await handlers.updateStatus()) }
456 } catch (e) {
457 // A failed check is a network fact, not a broken node — report it and leave the state alone.
458 return { ok: false, reason: (e && e.message) || String(e) }
459 }
460 },
379461 async status () {
380462 // favelaSession is the session COOKIE VALUE, handed to the UI so its fetches can carry
381463 // `Cookie: favela_session=…`. HttpOnly is a browser protection against document-script access; there
lib/ota.js +5 -1
……@@ -283,5 +283,9 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
283283
284284 await runtime.start()
285285 say('ota: watching ' + base + '/releases/' + repo + ' every ' + Math.round(interval / 3600000) + 'h')
286 return { stop: () => runtime.stop && runtime.stop(), store }
286 // ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
287 // so the interval is almost never when the user is actually looking — the one moment a check is worth
288 // making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
289 // just stops throwing the handle away, which is the whole reason the app had no update control.
290 return { stop: () => runtime.stop && runtime.stop(), store, checkNow: () => runtime.checkNow() }
287291}
lib/shell-handoff.js +109 -0
……@@ -0,0 +1,109 @@
1// What the native shell tells the node at boot, and what the node tells the settings page back.
2//
3// ⛔ WHY A FILE AND NOT argv. The obvious way to hand the running bundle's hash to the worklet is a
4// second argv element. It is a trap: the SHELL (the APK) and the BUNDLE (arrives by OTA) version
5// independently, and lib/node.js reads storage as `Bare.argv[argv.length - 1]`. A new shell passing
6// `[storage, hash]` to an OLD bundle would hand it the hash as its storage path — a node booting into
7// an empty directory, which is the worst failure this app has. A file is additive in both directions:
8// an old bundle never reads it, a new bundle finds it missing and says so.
9//
10// Two files, two directions, both tiny:
11//
12// <storage>/shell.json shell → node. { ota, bundle, dns }
13// <storage>/app-update.json node → page. { ota, running, staged, checkedAt }
14//
15// Nothing here does I/O: the callers own that (RNFS on one side, bare-fs on the other), which is what
16// makes both shapes testable with no handset.
17
18export const SHELL_FILE = 'shell.json'
19export const STATE_FILE = 'app-update.json'
20
21const isHash = (v) => typeof v === 'string' && /^[0-9a-f]{16,128}$/i.test(v)
22
23// Addresses only — this list becomes FAVELA_NAMESERVERS and is handed to connect(). A hostname would
24// need a resolver to resolve, which is the thing we are obtaining, and `8.8.8.8:53` is a connect() to a
25// host that does not exist. A character-class test admits both, so the families are parsed apart.
26//
27// ⚠ Deliberately duplicated from favela-web's lib/dns-txt.js rather than imported: this module is also
28// loaded by the React Native side, which does not bundle favela-web, and a cross-package import here
29// would make the app shell depend on the worklet's dependency graph to open a settings screen.
30function isIpv4 (s) {
31 const parts = s.split('.')
32 return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
33}
34function isIpv6 (s) {
35 if (!s.includes(':')) return false
36 if ((s.match(/::/g) || []).length > 1) return false
37 const parts = s.split(':')
38 if (parts.length > 8) return false
39 return parts.every((p, i) => {
40 if (p === '') return true
41 if (i === parts.length - 1 && p.includes('.')) return isIpv4(p)
42 return /^[0-9a-fA-F]{1,4}$/.test(p)
43 })
44}
45const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 && (isIpv4(v) || isIpv6(v))
46
47/**
48 * Read what the shell said. Tolerant by construction: every field is optional and a malformed file is
49 * the same as no file, because a node that refuses to boot over its own handoff blob is unrecoverable
50 * from a user's phone.
51 *
52 * `ota` is a CAPABILITY CLAIM, not a preference: it means "this shell has a message handler that can
53 * service check/restart". The settings page renders its buttons only when it is true, so an old shell
54 * running a new bundle shows no controls rather than dead ones.
55 */
56export function parseShell (text) {
57 let raw = null
58 try { raw = typeof text === 'string' ? JSON.parse(text) : text } catch { raw = null }
59 if (!raw || typeof raw !== 'object') return { ota: false, bundle: null, dns: [] }
60 return {
61 ota: raw.ota === true,
62 bundle: isHash(raw.bundle) ? String(raw.bundle) : null,
63 dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : []
64 }
65}
66
67/** The shell's side of the same shape. Written before every worklet start, never appended to. */
68export function encodeShell ({ ota = false, bundle = null, dns = [] } = {}) {
69 return JSON.stringify({
70 ota: ota === true,
71 bundle: isHash(bundle) ? String(bundle) : null,
72 dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : []
73 })
74}
75
76/**
77 * What the settings page renders.
78 *
79 * ⛔ `running` COMES FROM THE SHELL, not from the OTA store. The store knows what is STAGED; only the
80 * side that chose a file and started the runtime knows what is EXECUTING. Deriving "running" from
81 * current.json is how the OTA log came to announce it had staged the bundle it was already running.
82 *
83 * `checkedAt` is an ISO string because favela-web's relTime() parses one.
84 */
85export function shellState ({ shell = null, staged = null, checkedAt = null } = {}) {
86 const s = shell || { ota: false, bundle: null }
87 return {
88 ota: s.ota === true,
89 running: isHash(s.bundle) ? String(s.bundle) : null,
90 staged: isHash(staged) ? String(staged) : null,
91 checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
92 }
93}
94
95/**
96 * A message from the settings page, arriving through the WebView bridge.
97 *
98 * The page is served by our own loopback server and the WebView refuses off-origin navigation, but this
99 * still parses UNTRUSTED input: it is a string from a web page reaching a function that can restart the
100 * node. So it is an allow-list of two verbs and nothing else — no pass-through of an op name to a
101 * handler map, which is how a bridge like this turns into a remote-call surface.
102 */
103export function parseShellMessage (data) {
104 let raw = null
105 try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null }
106 if (!raw || typeof raw !== 'object' || raw.t !== 'app-update') return null
107 if (raw.op === 'check' || raw.op === 'restart') return { op: raw.op }
108 return null
109}
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.0.5",
3 "version": "0.0.6",
44 "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs"
1515 },
test/shell.test.mjs +67 -0
……@@ -0,0 +1,67 @@
1// The shell↔node handoff and the settings page's message bridge.
2// Run: bare test/shell.test.mjs
3//
4// Both shapes exist because the SHELL and the BUNDLE version independently: the shell ships in an APK,
5// the bundle arrives by OTA, and either can be older than the other. Every test below is a case where
6// getting that wrong breaks a handset that cannot be reached with adb.
7import { parseShell, encodeShell, shellState, parseShellMessage, SHELL_FILE, STATE_FILE } from '../lib/shell-handoff.js'
8
9let pass = 0
10const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
11const HASH = 'eaa75a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647'
12
13// ── the shell's handoff ──────────────────────────────────────────────────────────────────────────
14// An OLD shell writes no file at all. The node must boot, and must not claim to know things it does not.
15const none = parseShell(null)
16ok(none.ota === false && none.bundle === null && none.dns.length === 0, 'no handoff => no capability, no bundle, no resolver')
17ok(parseShell('{ this is not json').ota === false, 'a corrupt handoff reads as no handoff, never throws')
18ok(parseShell('null').ota === false, 'JSON null reads as no handoff')
19
20const full = parseShell(encodeShell({ ota: true, bundle: HASH, dns: ['192.168.1.1', '8.8.8.8'] }))
21ok(full.ota === true && full.bundle === HASH && full.dns.length === 2, 'a full handoff round-trips')
22
23// ⛔ The bundle field decides what pgh-runtime compares a release against. A non-hash slipping through
24// would be compared against a release treeHash and never match — silently restoring the bug where every
25// boot re-stages the bundle it is already running.
26ok(parseShell(JSON.stringify({ bundle: 'apk' })).bundle === null, 'a non-hash bundle is dropped')
27ok(parseShell(JSON.stringify({ bundle: 123 })).bundle === null, 'a numeric bundle is dropped')
28ok(parseShell(encodeShell({ bundle: HASH.toUpperCase() })).bundle === HASH.toUpperCase(), 'hex case is preserved, not normalised away')
29
30// ⛔ The dns list is handed to connect(). A hostname there would need a resolver to resolve — the very
31// thing being obtained — and anything else is an injection surface into a socket call.
32ok(parseShell(JSON.stringify({ dns: ['dns.example.com'] })).dns.length === 0, 'a hostname is not an address')
33ok(parseShell(JSON.stringify({ dns: ['8.8.8.8', '', null, 42, '1.1.1.1'] })).dns.length === 2, 'junk entries are dropped, good ones kept')
34ok(parseShell(JSON.stringify({ dns: '8.8.8.8' })).dns.length === 0, 'a bare string is not a list')
35ok(parseShell(JSON.stringify({ dns: ['8.8.8.8:53'] })).dns.length === 0, 'a port is not part of an address — connect() takes the host alone')
36ok(parseShell(JSON.stringify({ dns: ['999.1.1.1'] })).dns.length === 0, 'an octet over 255 is not an address')
37ok(parseShell(JSON.stringify({ dns: ['::ffff:8.8.8.8'] })).dns.length === 1, 'an IPv4-mapped IPv6 address is one')
38ok(parseShell(JSON.stringify({ dns: ['2606:4700:4700::1111'] })).dns.length === 1, 'IPv6 is an address')
39ok(encodeShell({ ota: 'yes' }).includes('"ota":false'), 'ota is a boolean claim, not a truthy value')
40
41// ── what the settings page renders ───────────────────────────────────────────────────────────────
42// ⛔ `running` comes from the SHELL. The OTA store only knows what is staged; deriving "running" from it
43// is exactly how the OTA log came to announce it had staged the bundle it was already executing.
44const st = shellState({ shell: full, staged: HASH, checkedAt: '2026-08-24T21:41:31.000Z' })
45ok(st.running === HASH && st.staged === HASH, 'running and staged are reported independently')
46ok(st.ota === true, 'the page renders its buttons only on the shell capability')
47ok(shellState({ shell: none, staged: HASH }).ota === false, 'an old shell gets no buttons even when an update is staged')
48ok(shellState({}).running === null && shellState({}).staged === null, 'no shell, no state — never a guess')
49ok(shellState({ shell: full, staged: 'nonsense' }).staged === null, 'a malformed staged marker is not shown as an update')
50ok(shellState({ shell: full, checkedAt: 12345 }).checkedAt === null, 'checkedAt is an ISO string or nothing (favela-web parses it)')
51
52// ── the bridge ───────────────────────────────────────────────────────────────────────────────────
53// This parses a string from a WEB PAGE that reaches a function which restarts the node. Two verbs,
54// allow-listed: no op name is ever passed through to a handler map.
55ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'check' })).op === 'check', 'check is accepted')
56ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'restart' })).op === 'restart', 'restart is accepted')
57ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'compose' })) === null, 'an unknown op is refused, not forwarded')
58ok(parseShellMessage(JSON.stringify({ t: 'other', op: 'restart' })) === null, 'another message type is not an update command')
59ok(parseShellMessage('restart') === null, 'a bare string is not a command')
60ok(parseShellMessage('{"t":"app-update"') === null, 'malformed JSON is refused, never throws')
61ok(parseShellMessage(null) === null, 'null is refused')
62ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: ['check'] })) === null, 'a non-string op is refused')
63
64// ── the two filenames are a contract with favela-web and the RN side ─────────────────────────────
65ok(SHELL_FILE === 'shell.json' && STATE_FILE === 'app-update.json', 'the handoff filenames are stable')
66
67console.log('\n' + pass + ' assertions passed')