OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 326a561

0.1.5: a fresh install can reach the app it is already running
The pairing screen was a wall. `!status.signing || pairing` had no third state and Pair's only exit was a successful adopt, so a phone with no identity could leave that screen exactly one way — complete the ceremony — while the screen's own footer promised "you can keep using the app without pairing". No control did that and no rule would have honoured one. Reported on a handset: it will not go past the initial pairing modal. The promise was true about the system and false about the UI. favela-web is up behind that screen the whole time, read-only and loopback-only (hyper-social #54 §7); this only lets the user reach it. The rule moves to lib/first-run.js as a pure function and is tested, because it decides whether a handset can reach the product at all and it has now been wrong on a real device. `pairing` is checked before `readOnly` so an explicit request from favela-web's settings can never be swallowed by an earlier skip — that is the re-pair case, where a device holds the identity but is absent from the room's __members__ and every write is refused. Pairing stays the primary action, and stays reachable: settingsPage renders pairSection for an anonymous viewer, and worklet.js announces pair:true unconditionally, so skipping does not strand anyone. The skip is this run only — the phone still has no identity, and a launch is the honest moment to say so again. Identity CREATION is still unbuilt (#54 §8) and is the other half of this modal. The test helper throws rather than calling process.exit: there is no `process` under bare, so the sibling suites report a failure and then bury it under a ReferenceError about the reporter (#54 §9.1). Ships in the APK, not the bundle — app/ is Metro's, worklet.bundle is bare-pack over lib/node.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326a561f46b955f7637261196d55a560b3eed922
by plankatron <fleeky@gmail.com> · 2026-08-25T20:36:19.000Z

Changed files (5) +123 -6

app/App.jsx +22 -2
……@@ -17,6 +17,7 @@ import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, P
1717import { WebView } from 'react-native-webview'
1818import { startNode, stopNode } from './worklet'
1919import { parseShellMessage } from '../lib/shell-handoff'
20import { initialScreen } from '../lib/first-run'
2021import Pair from './Pair'
2122import { Center } from './Screens'
2223
……@@ -34,6 +35,12 @@ export default function App () {
3435 // not among them". Re-pairing from a roster-holding node is the FIX for that, and the app had no way
3536 // to run it. The old UI reached pairing from settings for exactly this reason.
3637 const [pairing, setPairing] = useState(false)
38 // The user chose to carry on unpaired. THIS RUN ONLY, deliberately: the phone genuinely has no
39 // identity, that is still the main thing to resolve, and a launch is the honest moment to say so
40 // again. Persisting the choice is a separate decision, not a detail to settle by leaving state lying
41 // around. Reaching pairing again does not depend on it either way — favela-web's settings offer the
42 // ceremony to an anonymous viewer (render.js settingsPage renders pairSection with no viewer).
43 const [readOnly, setReadOnly] = useState(false)
3744
3845 // Boot is a named operation because it happens TWICE: once at launch, and again after pairing. The
3946 // worklet reads `cfg.dids` only in bootMultiDevice (lib/node.js), so a node that has just been given
……@@ -102,8 +109,21 @@ export default function App () {
102109 // there is no identity config at all (lib/node.js only boots hpp when one exists), which is the
103110 // fresh-install case — and a fresh install has no settings page to reach a button on.
104111 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>
105 if (!status.signing || pairing) {
106 return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} />
112 // ⛔ THE RULE IS IN lib/first-run.js AND IS TESTED. It used to be `!status.signing || pairing` inline
113 // here, which had no third state — so an unpaired phone could leave this screen ONLY by completing the
114 // ceremony, while the screen's own footer promised it could carry on without one. Reported on a handset
115 // 2026-08-25: "it also will not go past the initial pairing modal". Skipping shows the app that is
116 // ALREADY RUNNING behind it — read-only and loopback-only (hyper-social #54 §7).
117 if (initialScreen({ signing: status.signing, pairing, readOnly }) === 'pair') {
118 return (
119 <Pair
120 client={client}
121 onPaired={async (r) => { setPairing(false); await paired(r) }}
122 // Both flags are cleared: `pairing` because the ceremony is no longer open, `readOnly` because
123 // the user just declined it. Leaving `pairing` set would re-open this screen immediately.
124 onSkip={() => { setPairing(false); setReadOnly(true) }}
125 />
126 )
107127 }
108128
109129 return <Web status={status} client={client} onShellMessage={onShellMessage} />
app/Pair.jsx +22 -2
……@@ -10,7 +10,9 @@
1010import React, { useState } from 'react'
1111import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native'
1212
13export default function Pair ({ client, onPaired }) {
13// `onSkip` is OPTIONAL and the control renders only when it is given, so this screen can never again
14// offer a way out that its caller does not actually implement — the exact shape of the bug it fixes.
15export default function Pair ({ client, onPaired, onSkip = null }) {
1416 const [invite, setInvite] = useState('')
1517 const [busy, setBusy] = useState(false)
1618 const [step, setStep] = useState(null)
……@@ -91,8 +93,23 @@ export default function Pair ({ client, onPaired }) {
9193 {step ? <Text style={s.dim}>{step}</Text> : null}
9294 {err ? <Text style={s.err}>{err}</Text> : null}
9395
96 {/* ⛔ THIS FOOTER USED TO BE A LIE. It said "you can keep using the app without pairing" and there
97 was no control that did it — the only exit from this screen was a successful ceremony, so a
98 fresh install was walled in (reported on a handset 2026-08-25). The node behind this screen is
99 up and serving pages the whole time; the promise was true about the SYSTEM and false about the
100 UI. Now the text and the button say the same thing.
101
102 Pairing stays the primary action above — this is deliberately the quieter of the two, because on
103 a phone with no identity pairing IS what you most likely came to do. */}
104 {onSkip ? (
105 <Pressable style={s.skip} disabled={busy} onPress={onSkip}>
106 <Text style={[s.skipT, busy && s.skipOff]}>Continue without pairing</Text>
107 </Pressable>
108 ) : null}
109
94110 <Text style={s.foot}>
95 You can keep using the app without pairing — you will see posts, but you will not be able to write.
111 You can keep using the app without pairing — you will see posts, and writing stays refused until
112 this phone has an identity. Pairing is always available again from Settings.
96113 </Text>
97114 </ScrollView>
98115 )
……@@ -114,5 +131,8 @@ const s = StyleSheet.create({
114131 btnT: { color: '#fff', fontWeight: '600' },
115132 dim: { color: '#888', fontSize: 13 },
116133 err: { color: '#b00', marginTop: 8 },
134 skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 },
135 skipT: { color: '#2563eb', fontSize: 16 },
136 skipOff: { opacity: 0.4 },
117137 foot: { color: '#888', fontSize: 13, marginTop: 20, lineHeight: 19 }
118138})
lib/first-run.js +34 -0
……@@ -0,0 +1,34 @@
1// WHICH SCREEN THE SHELL SHOWS — the one rule, as a pure function.
2//
3// It lives in lib/ rather than inside App.jsx because it is a RULE, not a rendering: it decides whether a
4// handset can reach the product at all, and it has been wrong on a real device. app/ holds views, lib/
5// holds things that can be tested under bare without a renderer — and this is the half worth testing.
6//
7// ⛔ THE PREVIOUS RULE STRANDED A FRESH INSTALL. It was `!signing || pairing -> pair`, with no third
8// state, and Pair's only exit was a SUCCESSFUL adopt. So a phone with no identity had exactly one way out
9// of the pairing screen — complete the ceremony — while the screen's own footer promised "you can keep
10// using the app without pairing". There was no control that did that, and no rule that would have
11// honoured one. Reported on a handset 2026-08-25: "it also will not go past the initial pairing modal".
12//
13// The node behind that screen is RUNNING: favela-web is up, read-only and loopback-only, serving real
14// pages (hyper-social #54 §7 — "a fresh install DOES start favela-web; what hides it is App.jsx routing
15// to the native Pair screen"). So this is not a new capability, it is letting the user reach what the
16// phone is already doing.
17//
18// The three inputs are deliberately independent:
19// signing the node holds a signing key (lib/node.js only boots hpp when an identity config exists)
20// pairing the user ASKED for the ceremony — from favela-web's settings (render.js pairSection)
21// readOnly the user chose to continue unpaired, this run
22//
23// `pairing` beats `readOnly`, always: an explicit request for the ceremony must never be swallowed by a
24// skip the user made earlier. That case is real — a device that HOLDS the identity but is missing from
25// the room's __members__ has to re-pair, and App.jsx's own note records that removing this route once
26// already broke exactly that.
27export function initialScreen ({ signing = false, pairing = false, readOnly = false } = {}) {
28 // Asked for, explicitly — from favela-web's settings. Checked FIRST so a skip can never swallow it.
29 if (pairing) return 'pair'
30 // No identity, and the user has not declined: the ceremony, unprompted. A fresh install has no settings
31 // page to reach a button on, so this stays the first thing the phone shows.
32 if (!signing && !readOnly) return 'pair'
33 return 'web'
34}
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.4",
3 "version": "0.1.5",
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/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
12 "test": "bare test/first-run.test.mjs && 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));fs.writeFileSync(p+'.built',String(Date.now()))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1515 "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk"
test/first-run.test.mjs +43 -0
……@@ -0,0 +1,43 @@
1// Which screen a fresh install shows, and whether it can ever leave it.
2// Run: bare test/first-run.test.mjs
3//
4// Every case below is a state a real handset reaches. The one that mattered on 2026-08-25 is the third:
5// a phone with no identity could not get past the pairing modal, because there was no rule that let it.
6import { initialScreen } from '../lib/first-run.js'
7
8let pass = 0
9// ⛔ NOT `process.exit(1)`. There is no `process` global under bare, so the sibling suites' helper dies
10// with "ReferenceError: process is not defined" the moment an assertion fails — the failure is reported,
11// then buried under a stack about the reporter. That trap cost three wrong measurements on 2026-08-25
12// (hyper-social #54 §9.1). Throwing exits non-zero and says only the thing that broke.
13const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ }
14
15// ── a fresh install ──────────────────────────────────────────────────────────────────────────────
16// Auto-showing the ceremony STAYS. A fresh install has no settings page to reach a button on, and the
17// thing the phone most needs is an identity — so the modal is still the first thing, unprompted.
18ok(initialScreen({ signing: false }) === 'pair', 'no identity => the pairing screen, unprompted')
19ok(initialScreen({}) === 'pair', 'nothing known at all => the pairing screen (never a blank app)')
20
21// ⛔ THE BUG. The user declines to pair. Pair.jsx's footer says "you can keep using the app without
22// pairing — you will see posts, but you will not be able to write", and the node behind it is serving
23// those posts right now. Before this rule existed the answer here was 'pair', for ever.
24ok(initialScreen({ signing: false, readOnly: true }) === 'web', 'declined pairing => the read-only app, not a wall')
25
26// ── a paired device ──────────────────────────────────────────────────────────────────────────────
27ok(initialScreen({ signing: true }) === 'web', 'has an identity => straight to the app')
28
29// Re-pairing is reached from favela-web's settings. HAVING AN IDENTITY IS NOT BEING A MEMBER: a handset
30// holding the DID and its key, but absent from the room's __members__, has every write refused and
31// re-pairing is the fix. App.jsx's own note records that the first cut of this shell removed that route.
32ok(initialScreen({ signing: true, pairing: true }) === 'pair', 'asked to pair while paired => the ceremony (the re-pair case)')
33
34// ── the interaction that is easy to get wrong ────────────────────────────────────────────────────
35// An explicit request must never be swallowed by a skip made earlier in the same run. Both of these are
36// reachable: skip on launch, then open pairing from settings.
37ok(initialScreen({ signing: false, pairing: true, readOnly: true }) === 'pair', 'explicit pair beats an earlier skip (unpaired)')
38ok(initialScreen({ signing: true, pairing: true, readOnly: true }) === 'pair', 'explicit pair beats an earlier skip (paired)')
39
40// A skip does not survive gaining an identity — nothing to suppress once signing is true.
41ok(initialScreen({ signing: true, readOnly: true }) === 'web', 'a stale skip does not hide the app from a paired device')
42
43console.log('\n' + pass + ' assertions passed')