| … | … | @@ -17,6 +17,7 @@ import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, P |
| 17 | 17 | import { WebView } from 'react-native-webview' |
| 18 | 18 | import { startNode, stopNode } from './worklet' |
| 19 | 19 | import { parseShellMessage } from '../lib/shell-handoff' |
| 20 | import { initialScreen } from '../lib/first-run' | |
| 20 | 21 | import Pair from './Pair' |
| 21 | 22 | import { Center } from './Screens' |
| 22 | 23 | |
| … | … | @@ -34,6 +35,12 @@ export default function App () { |
| 34 | 35 | // not among them". Re-pairing from a roster-holding node is the FIX for that, and the app had no way |
| 35 | 36 | // to run it. The old UI reached pairing from settings for exactly this reason. |
| 36 | 37 | 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) | |
| 37 | 44 | |
| 38 | 45 | // Boot is a named operation because it happens TWICE: once at launch, and again after pairing. The |
| 39 | 46 | // 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 () { |
| 102 | 109 | // there is no identity config at all (lib/node.js only boots hpp when one exists), which is the |
| 103 | 110 | // fresh-install case — and a fresh install has no settings page to reach a button on. |
| 104 | 111 | 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 | ) | |
| 107 | 127 | } |
| 108 | 128 | |
| 109 | 129 | return <Web status={status} client={client} onShellMessage={onShellMessage} /> |
| … | … | @@ -10,7 +10,9 @@ |
| 10 | 10 | import React, { useState } from 'react' |
| 11 | 11 | import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native' |
| 12 | 12 | |
| 13 | export 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. | |
| 15 | export default function Pair ({ client, onPaired, onSkip = null }) { | |
| 14 | 16 | const [invite, setInvite] = useState('') |
| 15 | 17 | const [busy, setBusy] = useState(false) |
| 16 | 18 | const [step, setStep] = useState(null) |
| … | … | @@ -91,8 +93,23 @@ export default function Pair ({ client, onPaired }) { |
| 91 | 93 | {step ? <Text style={s.dim}>{step}</Text> : null} |
| 92 | 94 | {err ? <Text style={s.err}>{err}</Text> : null} |
| 93 | 95 | |
| 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 | ||
| 94 | 110 | <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. | |
| 96 | 113 | </Text> |
| 97 | 114 | </ScrollView> |
| 98 | 115 | ) |
| … | … | @@ -114,5 +131,8 @@ const s = StyleSheet.create({ |
| 114 | 131 | btnT: { color: '#fff', fontWeight: '600' }, |
| 115 | 132 | dim: { color: '#888', fontSize: 13 }, |
| 116 | 133 | err: { color: '#b00', marginTop: 8 }, |
| 134 | skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 }, | |
| 135 | skipT: { color: '#2563eb', fontSize: 16 }, | |
| 136 | skipOff: { opacity: 0.4 }, | |
| 117 | 137 | foot: { color: '#888', fontSize: 13, marginTop: 20, lineHeight: 19 } |
| 118 | 138 | }) |
| … | … | @@ -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. | |
| 27 | export 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 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.4", | |
| 3 | "version": "0.1.5", | |
| 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/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", | |
| 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));fs.writeFileSync(p+'.built',String(Date.now()))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs", |
| 15 | 15 | "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk" |
| … | … | @@ -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. | |
| 6 | import { initialScreen } from '../lib/first-run.js' | |
| 7 | ||
| 8 | let 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. | |
| 13 | const 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. | |
| 18 | ok(initialScreen({ signing: false }) === 'pair', 'no identity => the pairing screen, unprompted') | |
| 19 | ok(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. | |
| 24 | ok(initialScreen({ signing: false, readOnly: true }) === 'web', 'declined pairing => the read-only app, not a wall') | |
| 25 | ||
| 26 | // ── a paired device ────────────────────────────────────────────────────────────────────────────── | |
| 27 | ok(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. | |
| 32 | ok(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. | |
| 37 | ok(initialScreen({ signing: false, pairing: true, readOnly: true }) === 'pair', 'explicit pair beats an earlier skip (unpaired)') | |
| 38 | ok(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. | |
| 41 | ok(initialScreen({ signing: true, readOnly: true }) === 'web', 'a stale skip does not hide the app from a paired device') | |
| 42 | ||
| 43 | console.log('\n' + pass + ' assertions passed') |