| … | … | @@ -61,9 +61,10 @@ export default function App () { |
| 61 | 61 | // here, because neither half can do this alone: the worklet owns the OTA runtime, and only this side |
| 62 | 62 | // owns the RUNTIME ITSELF and can start a different bundle. |
| 63 | 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. | |
| 64 | // ⛔ NAMED 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 functions that restart the node | |
| 66 | // and open a ceremony granting a signing key, and the fact that the page is ours today is not a | |
| 67 | // property enforced anywhere in this file. | |
| 67 | 68 | // |
| 68 | 69 | // `reload` is how the page learns the result: the worklet writes the state to disk and favela-web |
| 69 | 70 | // renders it server-side, so re-fetching the page IS the update. No reply channel, no second copy of |
| … | … | @@ -75,6 +76,11 @@ export default function App () { |
| 75 | 76 | if (msg.op === 'check') { |
| 76 | 77 | if (client) await client.checkForUpdate() |
| 77 | 78 | reload() |
| 79 | } else if (msg.op === 'pair') { | |
| 80 | // Pairing came OFF the page and back into settings, where favela-android's own note always said | |
| 81 | // it belonged. The native screen still owns the ceremony and its warning (app/Pair.jsx) — the | |
| 82 | // page only asks for it. | |
| 83 | setPairing(true) | |
| 78 | 84 | } else if (msg.op === 'restart') { |
| 79 | 85 | // Not `reload` — the whole UI comes down with the node, and boot() puts the splash back with the |
| 80 | 86 | // real progress lines. A restart takes ~30s on a handset and pretending otherwise reads as a hang. |
| … | … | @@ -90,20 +96,20 @@ export default function App () { |
| 90 | 96 | // a browser connection error, which reads as "the app is broken" rather than "it is still starting". |
| 91 | 97 | if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center> |
| 92 | 98 | |
| 93 | // ⚠ TEMPORARY, until pairing moves into favela-web (design §4 — hpp grows an `adopt` control op and | |
| 94 | // favela-web serves the screen, so every device gets pairing the same way). Until then this native | |
| 95 | // screen is the ONLY way to adopt an identity, and dropping it would make a fresh install unpairable. | |
| 96 | // `signing` is false exactly when there is no identity config at all (lib/node.js only boots hpp when | |
| 97 | // one exists), which is the fresh-install case this screen is for. | |
| 99 | // The ENTRY POINT moved into favela-web's settings (render.js pairSection, via the bridge above); the | |
| 100 | // ceremony SCREEN is still native, because its wording is part of the security model and it runs before | |
| 101 | // there is any identity to serve a page with. Auto-showing it stays: `signing` is false exactly when | |
| 102 | // there is no identity config at all (lib/node.js only boots hpp when one exists), which is the | |
| 103 | // fresh-install case — and a fresh install has no settings page to reach a button on. | |
| 98 | 104 | if (!status.favelaUrl) return <Center><Text style={s.err}>favela-web did not start — the UI has nothing to show</Text><Text style={s.dim}>{booting}</Text></Center> |
| 99 | 105 | if (!status.signing || pairing) { |
| 100 | 106 | return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} /> |
| 101 | 107 | } |
| 102 | 108 | |
| 103 | return <Web status={status} client={client} onPair={() => setPairing(true)} onShellMessage={onShellMessage} /> | |
| 109 | return <Web status={status} client={client} onShellMessage={onShellMessage} /> | |
| 104 | 110 | } |
| 105 | 111 | |
| 106 | function Web ({ status, client, onPair, onShellMessage }) { | |
| 112 | function Web ({ status, client, onShellMessage }) { | |
| 107 | 113 | const ref = useRef(null) |
| 108 | 114 | const [canBack, setCanBack] = useState(false) |
| 109 | 115 | const [failed, setFailed] = useState(null) |
| … | … | @@ -210,12 +216,12 @@ function Web ({ status, client, onPair, onShellMessage }) { |
| 210 | 216 | renderLoading={() => <Center><ActivityIndicator /></Center>} |
| 211 | 217 | style={s.fill} |
| 212 | 218 | /> |
| 213 | {/* The pairing affordance. Deliberately small and always present rather than hidden behind a state | |
| 214 | this shell cannot observe: the app has no way to know whether the room has admitted it (that | |
| 215 | lives in the worklet's StateBase), so gating the button on "not admitted" would need a new IPC | |
| 216 | verb to answer a question the ceremony is harmless to run regardless. Goes away when pairing | |
| 217 | moves into favela-web (design §4) and the web UI owns this screen like every other. */} | |
| 218 | {onPair ? <Pressable onPress={onPair} hitSlop={12} style={s.pair}><Text style={s.pairTxt}>pair</Text></Pressable> : null} | |
| 219 | {/* ⛔ THE FLOATING `pair` BUTTON IS GONE, and this is the "design §4" the old note here promised. | |
| 220 | It sat at left:10/bottom:14 — which is exactly where favela-web's bottom nav put its Home item | |
| 221 | once that shipped, so tapping Home opened the pairing screen instead. Reported on a handset | |
| 222 | 2026-08-25. It now lives in favela-web's settings page (render.js pairSection) and arrives | |
| 223 | through the same bridge as the update controls, gated on the shell announcing it can service | |
| 224 | it, so the web UI owns this screen like every other. */} | |
| 219 | 225 | </SafeAreaView> |
| 220 | 226 | ) |
| 221 | 227 | } |
| … | … | @@ -225,8 +231,4 @@ const s = StyleSheet.create({ |
| 225 | 231 | err: { color: '#b00020', padding: 16, textAlign: 'center' }, |
| 226 | 232 | dim: { color: '#666', padding: 8, textAlign: 'center' }, |
| 227 | 233 | retry: { color: '#2563eb', padding: 12, fontSize: 16 }, |
| 228 | // Bottom-LEFT: compose and the feed's own controls live bottom-right/centre in favela-web, and an | |
| 229 | // overlay that covers a button the page owns is worse than no overlay. | |
| 230 | pair: { position: 'absolute', left: 10, bottom: 14, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 14, backgroundColor: 'rgba(120,120,128,0.28)' }, | |
| 231 | pairTxt: { fontSize: 11, color: '#fff', opacity: 0.9 } | |
| 232 | 234 | }) |
| … | … | @@ -172,13 +172,15 @@ async function boot () { |
| 172 | 172 | // ota that THIS shell can service the settings page's update buttons. The shell ships in the |
| 173 | 173 | // APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell; |
| 174 | 174 | // the page renders those controls only when this says they will do something. |
| 175 | // pair the same claim for the pairing screen, which moved out of a floating overlay and into | |
| 176 | // the settings page — separate flag because the two arrived in different releases. | |
| 175 | 177 | // |
| 176 | 178 | // Written before every start and never appended to, so it always describes the CURRENT boot. Failure |
| 177 | 179 | // is non-fatal: the node boots with less knowledge, not not at all. |
| 178 | 180 | const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null) |
| 179 | 181 | try { |
| 180 | 182 | const dns = await dnsServers() |
| 181 | await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, bundle: bundleHash, dns }), 'utf8') | |
| 183 | await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, pair: true, bundle: bundleHash, dns }), 'utf8') | |
| 182 | 184 | console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length) |
| 183 | 185 | } catch (e) { |
| 184 | 186 | console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message) |
| … | … | @@ -9,8 +9,8 @@ |
| 9 | 9 | // |
| 10 | 10 | // Two files, two directions, both tiny: |
| 11 | 11 | // |
| 12 | // <storage>/shell.json shell → node. { ota, bundle, dns } | |
| 13 | // <storage>/app-update.json node → page. { ota, running, staged, checkedAt } | |
| 12 | // <storage>/shell.json shell → node. { ota, pair, bundle, dns } | |
| 13 | // <storage>/app-update.json node → page. { ota, pair, running, staged, checkedAt } | |
| 14 | 14 | // |
| 15 | 15 | // Nothing here does I/O: the callers own that (RNFS on one side, bare-fs on the other), which is what |
| 16 | 16 | // makes both shapes testable with no handset. |
| … | … | @@ -56,18 +56,23 @@ const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 && |
| 56 | 56 | export function parseShell (text) { |
| 57 | 57 | let raw = null |
| 58 | 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: [] } | |
| 59 | if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [] } | |
| 60 | 60 | return { |
| 61 | 61 | ota: raw.ota === true, |
| 62 | // A SEPARATE capability from `ota`, deliberately. The page arrives by OTA and this shell by APK, so | |
| 63 | // "can service update buttons" and "can open the pairing screen" are answered by whatever shell is | |
| 64 | // actually running — a page must never render a control the shell beneath it cannot honour. | |
| 65 | pair: raw.pair === true, | |
| 62 | 66 | bundle: isHash(raw.bundle) ? String(raw.bundle) : null, |
| 63 | 67 | dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : [] |
| 64 | 68 | } |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | 71 | /** 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 = [] } = {}) { | |
| 72 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [] } = {}) { | |
| 69 | 73 | return JSON.stringify({ |
| 70 | 74 | ota: ota === true, |
| 75 | pair: pair === true, | |
| 71 | 76 | bundle: isHash(bundle) ? String(bundle) : null, |
| 72 | 77 | dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [] |
| 73 | 78 | }) |
| … | … | @@ -83,9 +88,10 @@ export function encodeShell ({ ota = false, bundle = null, dns = [] } = {}) { |
| 83 | 88 | * `checkedAt` is an ISO string because favela-web's relTime() parses one. |
| 84 | 89 | */ |
| 85 | 90 | export function shellState ({ shell = null, staged = null, checkedAt = null } = {}) { |
| 86 | const s = shell || { ota: false, bundle: null } | |
| 91 | const s = shell || { ota: false, pair: false, bundle: null } | |
| 87 | 92 | return { |
| 88 | 93 | ota: s.ota === true, |
| 94 | pair: s.pair === true, | |
| 89 | 95 | running: isHash(s.bundle) ? String(s.bundle) : null, |
| 90 | 96 | staged: isHash(staged) ? String(staged) : null, |
| 91 | 97 | checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null |
| … | … | @@ -97,13 +103,14 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } = |
| 97 | 103 | * |
| 98 | 104 | * The page is served by our own loopback server and the WebView refuses off-origin navigation, but this |
| 99 | 105 | * 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. | |
| 106 | * node, and one that opens a ceremony granting a signing key. So it is an allow-list of three named | |
| 107 | * verbs and nothing else — no pass-through of an op name to a handler map, which is how a bridge like | |
| 108 | * this turns into a remote-call surface. | |
| 102 | 109 | */ |
| 103 | 110 | export function parseShellMessage (data) { |
| 104 | 111 | let raw = null |
| 105 | 112 | try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null } |
| 106 | 113 | if (!raw || typeof raw !== 'object' || raw.t !== 'app-update') return null |
| 107 | if (raw.op === 'check' || raw.op === 'restart') return { op: raw.op } | |
| 114 | if (raw.op === 'check' || raw.op === 'restart' || raw.op === 'pair') return { op: raw.op } | |
| 108 | 115 | return null |
| 109 | 116 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.0.9", | |
| 3 | "version": "0.1.0", | |
| 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", |
| … | … | @@ -38,6 +38,14 @@ ok(parseShell(JSON.stringify({ dns: ['::ffff:8.8.8.8'] })).dns.length === 1, 'an |
| 38 | 38 | ok(parseShell(JSON.stringify({ dns: ['2606:4700:4700::1111'] })).dns.length === 1, 'IPv6 is an address') |
| 39 | 39 | ok(encodeShell({ ota: 'yes' }).includes('"ota":false'), 'ota is a boolean claim, not a truthy value') |
| 40 | 40 | |
| 41 | // ⛔ `pair` IS ITS OWN CAPABILITY. It shipped a release after `ota`, and the page arrives by OTA while | |
| 42 | // the shell arrives by APK — so a bundle can meet a shell that services update buttons and knows | |
| 43 | // nothing about pairing. One flag for both would render a dead button on exactly that pairing. | |
| 44 | ok(parseShell(JSON.stringify({ ota: true })).pair === false, 'an ota-capable shell is not assumed to be pair-capable') | |
| 45 | ok(parseShell(encodeShell({ ota: true, pair: true })).pair === true, 'a shell that claims pair round-trips') | |
| 46 | ok(shellState({ shell: parseShell(JSON.stringify({ ota: true })) }).pair === false, 'and the page is told so') | |
| 47 | ok(shellState({ shell: parseShell(encodeShell({ pair: true })) }).pair === true, 'pair survives into the page state independently of ota') | |
| 48 | ||
| 41 | 49 | // ── what the settings page renders ─────────────────────────────────────────────────────────────── |
| 42 | 50 | // ⛔ `running` comes from the SHELL. The OTA store only knows what is staged; deriving "running" from it |
| 43 | 51 | // is exactly how the OTA log came to announce it had staged the bundle it was already executing. |
| … | … | @@ -54,6 +62,7 @@ ok(shellState({ shell: full, checkedAt: 12345 }).checkedAt === null, 'checkedAt |
| 54 | 62 | // allow-listed: no op name is ever passed through to a handler map. |
| 55 | 63 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'check' })).op === 'check', 'check is accepted') |
| 56 | 64 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'restart' })).op === 'restart', 'restart is accepted') |
| 65 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'pair' })).op === 'pair', 'pair is accepted') | |
| 57 | 66 | ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'compose' })) === null, 'an unknown op is refused, not forwarded') |
| 58 | 67 | ok(parseShellMessage(JSON.stringify({ t: 'other', op: 'restart' })) === null, 'another message type is not an update command') |
| 59 | 68 | ok(parseShellMessage('restart') === null, 'a bare string is not a command') |