| … | … | @@ -1,9 +1,12 @@ |
| 1 | 1 | package to.favela.android |
| 2 | 2 | |
| 3 | import android.content.Context | |
| 3 | 4 | import android.content.Intent |
| 5 | import android.net.ConnectivityManager | |
| 4 | 6 | import android.os.Handler |
| 5 | 7 | import android.os.Looper |
| 6 | 8 | import androidx.core.content.ContextCompat |
| 9 | import com.facebook.react.bridge.Arguments | |
| 7 | 10 | import com.facebook.react.bridge.Promise |
| 8 | 11 | import com.facebook.react.bridge.ReactApplicationContext |
| 9 | 12 | import com.facebook.react.bridge.ReactContextBaseJavaModule |
| … | … | @@ -69,6 +72,45 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB |
| 69 | 72 | } |
| 70 | 73 | } |
| 71 | 74 | |
| 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 | ||
| 72 | 114 | /** Drop the notification. Used on app teardown; the Stop action goes the other way, via the service. */ |
| 73 | 115 | @ReactMethod |
| 74 | 116 | fun stop(promise: Promise) { |
| … | … | @@ -16,6 +16,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 16 | 16 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native' |
| 17 | 17 | import { WebView } from 'react-native-webview' |
| 18 | 18 | import { startNode, stopNode } from './worklet' |
| 19 | import { parseShellMessage } from '../lib/shell-handoff' | |
| 19 | 20 | import Pair from './Pair' |
| 20 | 21 | import { Center } from './Screens' |
| 21 | 22 | |
| … | … | @@ -56,6 +57,33 @@ export default function App () { |
| 56 | 57 | } catch (e) { setError(e.message) } |
| 57 | 58 | }, [boot, client]) |
| 58 | 59 | |
| 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 | ||
| 59 | 87 | if (error) return <Center><Text style={s.err}>{error}</Text></Center> |
| 60 | 88 | // The splash is not decoration: a cold boot takes ~40s (corestore, swarm, appview index, then the |
| 61 | 89 | // 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 () { |
| 72 | 100 | return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} /> |
| 73 | 101 | } |
| 74 | 102 | |
| 75 | return <Web status={status} client={client} onPair={() => setPairing(true)} /> | |
| 103 | return <Web status={status} client={client} onPair={() => setPairing(true)} onShellMessage={onShellMessage} /> | |
| 76 | 104 | } |
| 77 | 105 | |
| 78 | function Web ({ status, client, onPair }) { | |
| 106 | function Web ({ status, client, onPair, onShellMessage }) { | |
| 79 | 107 | const ref = useRef(null) |
| 80 | 108 | const [canBack, setCanBack] = useState(false) |
| 81 | 109 | const [failed, setFailed] = useState(null) |
| … | … | @@ -148,6 +176,11 @@ function Web ({ status, client, onPair }) { |
| 148 | 176 | // in a real browser is a later refinement, and refusing is the safe default in the meantime. |
| 149 | 177 | onShouldStartLoadWithRequest={(r) => r.url.startsWith(status.favelaUrl)} |
| 150 | 178 | 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 | }} | |
| 151 | 184 | onLoadEnd={() => { loaded.current = true }} |
| 152 | 185 | // ⛔ A FAILED REQUEST IS NOT A FAILED APP. These fire for the MAIN FRAME, which includes the POST |
| 153 | 186 | // a compose submits — so a single 500 from one write tore down the entire rendered UI and replaced |
| … | … | @@ -13,10 +13,14 @@ import { createLifecycle } from '../lib/lifecycle' |
| 13 | 13 | import { chooseBundle } from '../lib/choose-bundle' |
| 14 | 14 | import { describeNode } from '../lib/describe' |
| 15 | 15 | import { encodeUtf8, createUtf8Decoder } from '../lib/utf8' |
| 16 | import { encodeShell, SHELL_FILE } from '../lib/shell-handoff' | |
| 16 | 17 | |
| 17 | 18 | let started = null |
| 18 | 19 | let starting = null |
| 19 | 20 | let 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. | |
| 23 | let subs = [] | |
| 20 | 24 | |
| 21 | 25 | // NodeService, as lib/lifecycle.js wants to see it. The permission request lives here rather than in the |
| 22 | 26 | // policy because it is a UI event: from Android 13 a foreground service still RUNS without |
| … | … | @@ -38,6 +42,24 @@ const service = { |
| 38 | 42 | } |
| 39 | 43 | } |
| 40 | 44 | |
| 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. | |
| 54 | async 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 | ||
| 41 | 63 | // Replace the notification's text once the node can answer for itself. Never speculatively: the shell |
| 42 | 64 | // does not know what is being served until it asks, and the shade is the one surface a user reads |
| 43 | 65 | // without opening anything. |
| … | … | @@ -101,6 +123,7 @@ async function boot () { |
| 101 | 123 | // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the |
| 102 | 124 | // APK's own bundle, which is the one known to work because it shipped with this binary. |
| 103 | 125 | let bundlePath = dest |
| 126 | let bundleHashChosen = null | |
| 104 | 127 | try { |
| 105 | 128 | const bdir = RNFS.DocumentDirectoryPath + '/bundles' |
| 106 | 129 | const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } } |
| … | … | @@ -123,6 +146,7 @@ async function boot () { |
| 123 | 146 | if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) { |
| 124 | 147 | await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8') |
| 125 | 148 | bundlePath = bdir + '/' + choice.file |
| 149 | bundleHashChosen = choice.hash | |
| 126 | 150 | console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')') |
| 127 | 151 | } |
| 128 | 152 | } catch (e) { |
| … | … | @@ -137,6 +161,29 @@ async function boot () { |
| 137 | 161 | console.log('[worklet] bundle ' + wantSize + ' bytes at ' + dest + |
| 138 | 162 | (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms') |
| 139 | 163 | |
| 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 | ||
| 140 | 187 | // Path + null source => NativeBareKit.startFile: the runtime reads it, not us. |
| 141 | 188 | worklet.start(bundlePath, source, [storage]) |
| 142 | 189 | console.log('[worklet] start() returned') |
| … | … | @@ -150,15 +197,15 @@ async function boot () { |
| 150 | 197 | lifecycle = createLifecycle({ worklet, service, log: (m) => console.log('[worklet] ' + m) }) |
| 151 | 198 | const foreground = await lifecycle.start() |
| 152 | 199 | 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) })) | |
| 154 | 201 | // The Stop action on the notification. The service drops the exemption and goes away; the node is NOT |
| 155 | 202 | // torn down — it keeps running while the app is open and suspends once it is backgrounded, which is |
| 156 | 203 | // what "stop replicating in the background" means. Tearing it down here would make one button mean two |
| 157 | 204 | // things. |
| 158 | DeviceEventEmitter.addListener('favela-node-service-stopped', () => { | |
| 205 | subs.push(DeviceEventEmitter.addListener('favela-node-service-stopped', () => { | |
| 159 | 206 | console.log('[worklet] foreground service stopped — the node will suspend when the app is not on screen') |
| 160 | 207 | lifecycle.onServiceStopped() |
| 161 | }) | |
| 208 | })) | |
| 162 | 209 | |
| 163 | 210 | const { IPC } = worklet |
| 164 | 211 | |
| … | … | @@ -195,19 +242,40 @@ async function boot () { |
| 195 | 242 | } |
| 196 | 243 | |
| 197 | 244 | /** |
| 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. | |
| 199 | 247 | * |
| 200 | 248 | * ⚠ Backgrounding must NOT stop the node: that is the whole point of running a peer on the phone, and |
| 201 | 249 | * spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Backgrounding is |
| 202 | 250 | * 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. | |
| 203 | 256 | */ |
| 204 | 257 | export function stopNode () { |
| 205 | 258 | if (!started) return |
| 206 | 259 | // The notification goes first: it says the node is replicating in the background, and for the moment |
| 207 | 260 | // between terminate() and the service being told, it would be claiming something untrue. |
| 208 | 261 | if (lifecycle) { lifecycle.stop().catch(() => {}); lifecycle = null } |
| 262 | for (const sub of subs) { try { sub.remove() } catch {} } | |
| 263 | subs = [] | |
| 209 | 264 | try { started.client.close('app teardown') } catch {} |
| 210 | 265 | try { started.worklet.terminate() } catch {} |
| 211 | 266 | started = null |
| 212 | 267 | starting = null |
| 213 | 268 | } |
| 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 | */ | |
| 278 | export async function restartNode () { | |
| 279 | stopNode() | |
| 280 | return startNode() | |
| 281 | } |
| … | … | @@ -130,6 +130,10 @@ export class WorkletClient { |
| 130 | 130 | /** Mint a fresh favela-web session. Used when /auth/handoff refuses a token the server has forgotten. */ |
| 131 | 131 | resession () { return this.request('resession') } |
| 132 | 132 | 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') } | |
| 133 | 137 | // The ported surface. Thin by design — they exist so a screen never types an op string, not to add |
| 134 | 138 | // logic; every one of them maps to an appview query or a device write in the worklet. |
| 135 | 139 | compose (params) { return this.request('compose', params) } |
| … | … | @@ -21,6 +21,7 @@ import path from 'bare-path' |
| 21 | 21 | import b4a from 'b4a' |
| 22 | 22 | import { createUtf8Decoder } from './utf8.js' |
| 23 | 23 | import { rosterEnv } from './roster.js' |
| 24 | import { parseShell, shellState, SHELL_FILE, STATE_FILE } from './shell-handoff.js' | |
| 24 | 25 | |
| 25 | 26 | const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null |
| 26 | 27 | |
| … | … | @@ -53,6 +54,16 @@ const dirs = { |
| 53 | 54 | overlay: path.join(storage, 'hpp', 'atproto-repo') |
| 54 | 55 | } |
| 55 | 56 | |
| 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. | |
| 63 | const shell = (() => { | |
| 64 | try { return parseShell(fs.readFileSync(path.join(storage, SHELL_FILE), 'utf8')) } catch { return parseShell(null) } | |
| 65 | })() | |
| 66 | ||
| 56 | 67 | // ⛔ ENV BEFORE ANY IMPORT THAT READS IT. favela-web's libs capture process.env at module top level, so |
| 57 | 68 | // their config freezes on first import. Everything below is therefore assigned here and every favela |
| 58 | 69 | // import in boot() is DYNAMIC. |
| … | … | @@ -64,6 +75,16 @@ function prepareEnv () { |
| 64 | 75 | process.env.PEER_CONFIG = path.join(dirs.hpp, 'config.json') |
| 65 | 76 | // A phone is storage-constrained and killed constantly; bound the inbound lens by default. |
| 66 | 77 | 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) | |
| 67 | 88 | } |
| 68 | 89 | |
| 69 | 90 | // 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 |
| 73 | 94 | // No `appview` here any more — favela-web owns the one index on this node. See the note where the |
| 74 | 95 | // worklet's second one used to be built. |
| 75 | 96 | const 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. | |
| 99 | let ota = null | |
| 100 | let _lastCheck = null | |
| 76 | 101 | |
| 77 | 102 | // BOOT TIMING. "The app takes a very long time to start" has been a standing complaint with no number |
| 78 | 103 | // attached, and every guess about the cause (bundle size? swarm join? replication?) is unfalsifiable |
| … | … | @@ -281,16 +306,45 @@ async function boot () { |
| 281 | 306 | state.ready = true |
| 282 | 307 | send({ t: 'ready' }) |
| 283 | 308 | |
| 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 | ||
| 284 | 315 | // OTA for this bundle. Deferred in the pack (--defer pgh-runtime) and dynamically imported, so a |
| 285 | 316 | // 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. | |
| 286 | 325 | try { |
| 287 | 326 | 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() | |
| 289 | 329 | } catch (e) { |
| 290 | 330 | log('ota unavailable (non-fatal)', { error: (e && e.message) || String(e) }) |
| 291 | 331 | } |
| 292 | 332 | } |
| 293 | 333 | |
| 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. | |
| 340 | function 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 | ||
| 294 | 348 | // ---- the IPC surface (spec §3) --------------------------------------------------------------------- |
| 295 | 349 | // ── talking to our own favela-web ───────────────────────────────────────────────────────────────── |
| 296 | 350 | // |
| … | … | @@ -376,6 +430,34 @@ const handlers = { |
| 376 | 430 | const r = await signInToFavela() |
| 377 | 431 | return { ok: !!r.ok, favelaSession: state.favelaSession, favelaUrl: state.favelaUrl, reason: r.reason || null } |
| 378 | 432 | }, |
| 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 | }, | |
| 379 | 461 | async status () { |
| 380 | 462 | // favelaSession is the session COOKIE VALUE, handed to the UI so its fetches can carry |
| 381 | 463 | // `Cookie: favela_session=…`. HttpOnly is a browser protection against document-script access; there |
| … | … | @@ -283,5 +283,9 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 283 | 283 | |
| 284 | 284 | await runtime.start() |
| 285 | 285 | 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() } | |
| 287 | 291 | } |
| … | … | @@ -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 | ||
| 18 | export const SHELL_FILE = 'shell.json' | |
| 19 | export const STATE_FILE = 'app-update.json' | |
| 20 | ||
| 21 | const 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. | |
| 30 | function isIpv4 (s) { | |
| 31 | const parts = s.split('.') | |
| 32 | return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) | |
| 33 | } | |
| 34 | function 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 | } | |
| 45 | const 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 | */ | |
| 56 | export 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. */ | |
| 68 | export 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 | */ | |
| 85 | export 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 | */ | |
| 103 | export 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 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.0.5", | |
| 3 | "version": "0.0.6", | |
| 4 | 4 | "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.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "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", | |
| 13 | 13 | "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))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs" |
| 15 | 15 | }, |
| … | … | @@ -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. | |
| 7 | import { parseShell, encodeShell, shellState, parseShellMessage, SHELL_FILE, STATE_FILE } from '../lib/shell-handoff.js' | |
| 8 | ||
| 9 | let pass = 0 | |
| 10 | const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ } | |
| 11 | const 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. | |
| 15 | const none = parseShell(null) | |
| 16 | ok(none.ota === false && none.bundle === null && none.dns.length === 0, 'no handoff => no capability, no bundle, no resolver') | |
| 17 | ok(parseShell('{ this is not json').ota === false, 'a corrupt handoff reads as no handoff, never throws') | |
| 18 | ok(parseShell('null').ota === false, 'JSON null reads as no handoff') | |
| 19 | ||
| 20 | const full = parseShell(encodeShell({ ota: true, bundle: HASH, dns: ['192.168.1.1', '8.8.8.8'] })) | |
| 21 | ok(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. | |
| 26 | ok(parseShell(JSON.stringify({ bundle: 'apk' })).bundle === null, 'a non-hash bundle is dropped') | |
| 27 | ok(parseShell(JSON.stringify({ bundle: 123 })).bundle === null, 'a numeric bundle is dropped') | |
| 28 | ok(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. | |
| 32 | ok(parseShell(JSON.stringify({ dns: ['dns.example.com'] })).dns.length === 0, 'a hostname is not an address') | |
| 33 | ok(parseShell(JSON.stringify({ dns: ['8.8.8.8', '', null, 42, '1.1.1.1'] })).dns.length === 2, 'junk entries are dropped, good ones kept') | |
| 34 | ok(parseShell(JSON.stringify({ dns: '8.8.8.8' })).dns.length === 0, 'a bare string is not a list') | |
| 35 | ok(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') | |
| 36 | ok(parseShell(JSON.stringify({ dns: ['999.1.1.1'] })).dns.length === 0, 'an octet over 255 is not an address') | |
| 37 | ok(parseShell(JSON.stringify({ dns: ['::ffff:8.8.8.8'] })).dns.length === 1, 'an IPv4-mapped IPv6 address is one') | |
| 38 | ok(parseShell(JSON.stringify({ dns: ['2606:4700:4700::1111'] })).dns.length === 1, 'IPv6 is an address') | |
| 39 | ok(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. | |
| 44 | const st = shellState({ shell: full, staged: HASH, checkedAt: '2026-08-24T21:41:31.000Z' }) | |
| 45 | ok(st.running === HASH && st.staged === HASH, 'running and staged are reported independently') | |
| 46 | ok(st.ota === true, 'the page renders its buttons only on the shell capability') | |
| 47 | ok(shellState({ shell: none, staged: HASH }).ota === false, 'an old shell gets no buttons even when an update is staged') | |
| 48 | ok(shellState({}).running === null && shellState({}).staged === null, 'no shell, no state — never a guess') | |
| 49 | ok(shellState({ shell: full, staged: 'nonsense' }).staged === null, 'a malformed staged marker is not shown as an update') | |
| 50 | ok(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. | |
| 55 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'check' })).op === 'check', 'check is accepted') | |
| 56 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'restart' })).op === 'restart', 'restart is accepted') | |
| 57 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'compose' })) === null, 'an unknown op is refused, not forwarded') | |
| 58 | ok(parseShellMessage(JSON.stringify({ t: 'other', op: 'restart' })) === null, 'another message type is not an update command') | |
| 59 | ok(parseShellMessage('restart') === null, 'a bare string is not a command') | |
| 60 | ok(parseShellMessage('{"t":"app-update"') === null, 'malformed JSON is refused, never throws') | |
| 61 | ok(parseShellMessage(null) === null, 'null is refused') | |
| 62 | ok(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 ───────────────────────────── | |
| 65 | ok(SHELL_FILE === 'shell.json' && STATE_FILE === 'app-update.json', 'the handoff filenames are stable') | |
| 66 | ||
| 67 | console.log('\n' + pass + ' assertions passed') |