OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 08e9584

0.1.0: the floating pair button is gone; settings opens the ceremony
Reported from a handset: tapping Home opened a "connect this phone" page. Not a routing bug — a hit-target collision. The native `pair` pill sat at left:10/bottom:14, which is exactly where favela-web's bottom nav put its Home item when that shipped yesterday. The overlay was on top, so Home was never receiving the tap. The note on that button already said what to do: it "goes away when pairing moves into favela-web (design §4) and the web UI owns this screen like every other". Yesterday's bridge made that a small change, so it is done rather than nudged out of the way — moving the pill would have left an overlay painting over a page that now has its own chrome, and the collision could come back with any layout change. - `pair` joins check/restart in parseShellMessage's allow-list — still named verbs, never an op forwarded to a handler map: this one opens a ceremony that hands over a signing key. - App.jsx handles it by setting `pairing`, so the NATIVE screen still runs the ceremony. Its wording is part of the security model and it has to work before there is any identity to serve a page with. - Auto-show on `!status.signing` stays: a fresh install has no settings page to reach a button on. - The overlay and its styles are deleted. ⛔ `pair` IS ANNOUNCED AS ITS OWN CAPABILITY in shell.json, separate from `ota`. The page arrives by OTA and this shell by APK, so a bundle can meet a shell that services update buttons and knows nothing about pairing; one flag for both would render a dead button on exactly that pairing. Suite green (35 assertions in test/shell.test.mjs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
08e95845d9eb21516220abf2b0caa0ae23199bdd
by plankatron <fleeky@gmail.com> · 2026-08-25T16:43:56.000Z

Changed files (5) +50 -30

app/App.jsx +22 -20
……@@ -61,9 +61,10 @@ export default function App () {
6161 // here, because neither half can do this alone: the worklet owns the OTA runtime, and only this side
6262 // owns the RUNTIME ITSELF and can start a different bundle.
6363 //
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.
6768 //
6869 // `reload` is how the page learns the result: the worklet writes the state to disk and favela-web
6970 // 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 () {
7576 if (msg.op === 'check') {
7677 if (client) await client.checkForUpdate()
7778 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)
7884 } else if (msg.op === 'restart') {
7985 // Not `reload` — the whole UI comes down with the node, and boot() puts the splash back with the
8086 // 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 () {
9096 // a browser connection error, which reads as "the app is broken" rather than "it is still starting".
9197 if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
9298
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.
98104 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>
99105 if (!status.signing || pairing) {
100106 return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} />
101107 }
102108
103 return <Web status={status} client={client} onPair={() => setPairing(true)} onShellMessage={onShellMessage} />
109 return <Web status={status} client={client} onShellMessage={onShellMessage} />
104110}
105111
106function Web ({ status, client, onPair, onShellMessage }) {
112function Web ({ status, client, onShellMessage }) {
107113 const ref = useRef(null)
108114 const [canBack, setCanBack] = useState(false)
109115 const [failed, setFailed] = useState(null)
……@@ -210,12 +216,12 @@ function Web ({ status, client, onPair, onShellMessage }) {
210216 renderLoading={() => <Center><ActivityIndicator /></Center>}
211217 style={s.fill}
212218 />
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. */}
219225 </SafeAreaView>
220226 )
221227}
……@@ -225,8 +231,4 @@ const s = StyleSheet.create({
225231 err: { color: '#b00020', padding: 16, textAlign: 'center' },
226232 dim: { color: '#666', padding: 8, textAlign: 'center' },
227233 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 }
232234})
app/worklet.js +3 -1
……@@ -172,13 +172,15 @@ async function boot () {
172172 // ota that THIS shell can service the settings page's update buttons. The shell ships in the
173173 // APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell;
174174 // 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.
175177 //
176178 // Written before every start and never appended to, so it always describes the CURRENT boot. Failure
177179 // is non-fatal: the node boots with less knowledge, not not at all.
178180 const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null)
179181 try {
180182 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')
182184 console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length)
183185 } catch (e) {
184186 console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
lib/shell-handoff.js +15 -8
……@@ -9,8 +9,8 @@
99//
1010// Two files, two directions, both tiny:
1111//
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 }
1414//
1515// Nothing here does I/O: the callers own that (RNFS on one side, bare-fs on the other), which is what
1616// makes both shapes testable with no handset.
……@@ -56,18 +56,23 @@ const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 &&
5656export function parseShell (text) {
5757 let raw = null
5858 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: [] }
6060 return {
6161 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,
6266 bundle: isHash(raw.bundle) ? String(raw.bundle) : null,
6367 dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : []
6468 }
6569}
6670
6771/** The shell's side of the same shape. Written before every worklet start, never appended to. */
68export function encodeShell ({ ota = false, bundle = null, dns = [] } = {}) {
72export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [] } = {}) {
6973 return JSON.stringify({
7074 ota: ota === true,
75 pair: pair === true,
7176 bundle: isHash(bundle) ? String(bundle) : null,
7277 dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : []
7378 })
……@@ -83,9 +88,10 @@ export function encodeShell ({ ota = false, bundle = null, dns = [] } = {}) {
8388 * `checkedAt` is an ISO string because favela-web's relTime() parses one.
8489 */
8590export 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 }
8792 return {
8893 ota: s.ota === true,
94 pair: s.pair === true,
8995 running: isHash(s.bundle) ? String(s.bundle) : null,
9096 staged: isHash(staged) ? String(staged) : null,
9197 checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
……@@ -97,13 +103,14 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } =
97103 *
98104 * The page is served by our own loopback server and the WebView refuses off-origin navigation, but this
99105 * 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.
102109 */
103110export function parseShellMessage (data) {
104111 let raw = null
105112 try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null }
106113 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 }
108115 return null
109116}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.0.9",
3 "version": "0.1.0",
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",
test/shell.test.mjs +9 -0
……@@ -38,6 +38,14 @@ ok(parseShell(JSON.stringify({ dns: ['::ffff:8.8.8.8'] })).dns.length === 1, 'an
3838ok(parseShell(JSON.stringify({ dns: ['2606:4700:4700::1111'] })).dns.length === 1, 'IPv6 is an address')
3939ok(encodeShell({ ota: 'yes' }).includes('"ota":false'), 'ota is a boolean claim, not a truthy value')
4040
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.
44ok(parseShell(JSON.stringify({ ota: true })).pair === false, 'an ota-capable shell is not assumed to be pair-capable')
45ok(parseShell(encodeShell({ ota: true, pair: true })).pair === true, 'a shell that claims pair round-trips')
46ok(shellState({ shell: parseShell(JSON.stringify({ ota: true })) }).pair === false, 'and the page is told so')
47ok(shellState({ shell: parseShell(encodeShell({ pair: true })) }).pair === true, 'pair survives into the page state independently of ota')
48
4149// ── what the settings page renders ───────────────────────────────────────────────────────────────
4250// ⛔ `running` comes from the SHELL. The OTA store only knows what is staged; deriving "running" from it
4351// 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
5462// allow-listed: no op name is ever passed through to a handler map.
5563ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'check' })).op === 'check', 'check is accepted')
5664ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'restart' })).op === 'restart', 'restart is accepted')
65ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'pair' })).op === 'pair', 'pair is accepted')
5766ok(parseShellMessage(JSON.stringify({ t: 'app-update', op: 'compose' })) === null, 'an unknown op is refused, not forwarded')
5867ok(parseShellMessage(JSON.stringify({ t: 'other', op: 'restart' })) === null, 'another message type is not an update command')
5968ok(parseShellMessage('restart') === null, 'a bare string is not a command')