// favela-surface-android — the app shell.
//
// THE UI IS favela-surface. Not a port of it and not a native re-render of it: the actual pages,
// served by the actual favela-surface running inside this app's worklet, shown in a WebView.
//
// ⛔ That is inherited, not re-decided. favela-android built 715 lines of hand-written React Native
// screens on 2026-08-07 and deleted them four days later — the native surface looked nothing like
// favela, and every screen the web surface gained had to be built here a second time, by hand, to a
// lower standard. favela-surface is server-rendered HTML, so a WebView gets each one for free.
//
// ⛔ NOTHING ABOUT RECORDS, POSTS OR FEEDS LIVES HERE. This file boots the node, waits for the
// surface to answer, hands a session across, and shows a page. Anything else belongs in
// favela-surface, where a desktop gets it too.
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
import { WebView } from 'react-native-webview'
import { startNode, applyUpdate } from '../lib/host/worklet'
import { parseShellMessage } from '../lib/host/shell-handoff'
/** The splash and every failure share one layout — a phone should not have two ways to say "wait". */
function Center ({ children }) {
return <View style={s.center}>{children}</View>
}
export default function App () {
const [client, setClient] = useState(null)
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
// ⛔ A real progress line, not a spinner alone. A cold boot is tens of seconds — corestore, then
// the swarm, then the surface binding — and the worklet reports each stage. A WebView pointed at
// the server before it listens shows a browser connection error, which reads as "this app is
// broken" rather than "it is still starting".
const [booting, setBooting] = useState('starting the node…')
const boot = useCallback(async () => {
setClient(null); setError(null); setBooting('starting the node…')
const { client } = await startNode()
client.on('log', (m) => setBooting(m.stage || m.msg || String(m)))
client.on('fatal', (m) => setError(m.error || 'the node failed to start'))
await client.whenReady()
setStatus(await client.request('status'))
setClient(client)
}, [])
useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot])
// ⛔ AN ERROR HERE IS NOT A DEAD END, AND IT USED TO BE. This screen had no way out: `error` was set
// once and nothing ever cleared it, so a single failure anywhere in boot() showed this for ever —
// while the node behind it was healthy and still reporting. Observed on a handset 2026-09-15: the
// worklet reached "atproto up" and went on logging OTA lines for minutes, and the only way back was
// a force-stop, which a person holding the phone has no reason to try.
//
// ⚠ The live line below the error is the tell, and it is why this is safe to offer: `booting`
// updates from the log stream, so if the node is alive the reader can SEE it moving under the
// error. `startNode()` memoises, so retrying re-uses the running node rather than starting a second.
if (error) {
return (
<Center>
<Text style={s.err}>{error}</Text>
<Text style={s.dim}>{booting}</Text>
<Pressable onPress={() => boot().catch((e) => setError(e.message))}>
<Text style={s.retry}>try again</Text>
</Pressable>
</Center>
)
}
if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
if (!status.url) {
return (
<Center>
<Text style={s.err}>the surface did not start — there is nothing to show</Text>
<Text style={s.dim}>{booting}</Text>
</Center>
)
}
// ⛔ A PROCESS restart, not a thread one. `stopNode(); boot()` is what this used to do, and a staged
// bundle pushed through it never became the running bundle — only killing the process applied it.
// chooseBundle runs at worklet start and nowhere else, so this is the entire update mechanism.
return <Surface client={client} onRestart={async () => { await applyUpdate() }} />
}
/**
* The WebView, and the session handoff into it.
*
* ⛔ A session cannot be handed to a WebView from out here. favela-surface's cookie is `HttpOnly`,
* so `document.cookie` cannot write it and no native API portably can — it has to arrive on an HTTP
* response. The worklet mints a session and a ONE-TIME token, and `/auth/handoff` on loopback swaps
* the token for the cookie and redirects. That is why this component asks for a URL rather than
* being given one at boot: the token is spent on first load.
*/
function Surface ({ client, onRestart }) {
const ref = useRef(null)
const [uri, setUri] = useState(null)
const [canBack, setCanBack] = useState(false)
const [failed, setFailed] = useState(null)
// ⚠ One recovery attempt per mount. A refusal that is NOT about a spent token must stay visible
// rather than becoming a sign-in loop.
const recovering = useRef(false)
const handoff = useCallback(async (next) => {
const r = await client.request('session', next ? { next } : {})
setUri(r.url)
}, [client])
useEffect(() => { handoff().catch((e) => setFailed(e.message)) }, [handoff])
// ⛔ A SPENT TOKEN IS NOT A SIGN-OUT. The worklet can always mint another — it owns the session
// store outright — so landing on the login page after a reload means asking for a fresh handoff,
// once, rather than showing a person a sign-in screen for an app that is already signed in.
const onNavigation = useCallback((nav) => {
setCanBack(nav.canGoBack)
if (!nav.url || recovering.current) return
if (nav.url.includes('/login')) {
recovering.current = true
handoff().catch(() => { /* leave the login page visible rather than spinning */ })
}
}, [handoff])
// HARDWARE BACK follows page history, then falls through to the default. Without this, BACK from a
// thread finishes the activity and drops the person to the launcher.
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (canBack && ref.current) { ref.current.goBack(); return true }
return false
})
return () => sub.remove()
}, [canBack])
if (failed) {
return (
<Center>
<Text style={s.err}>could not load the local UI</Text>
<Text style={s.dim}>{failed}</Text>
<Pressable onPress={() => { setFailed(null); recovering.current = false; handoff().catch((e) => setFailed(e.message)) }}>
<Text style={s.retry}>retry</Text>
</Pressable>
<Pressable onPress={onRestart}><Text style={s.retry}>restart the node</Text></Pressable>
</Center>
)
}
if (!uri) return <Center><ActivityIndicator /><Text style={s.dim}>signing in…</Text></Center>
return (
<SafeAreaView style={s.fill}>
<WebView
ref={ref}
source={{ uri }}
onNavigationStateChange={onNavigation}
// ⛔ THE PAGE CAN ASK FOR EXACTLY THREE THINGS, and `parseShellMessage` is an allow-list of
// their NAMES — not an op passed through to a handler map, which is how a WebView bridge
// becomes a remote-call surface for whatever reaches the page. Anything else is dropped
// silently; there is nothing useful to tell a page that just tried something it may not do.
onMessage={async (e) => {
const m = parseShellMessage(e.nativeEvent && e.nativeEvent.data)
if (!m) return
if (m.op === 'check') {
// Drives the RUNNING ota client through the worklet, then reloads so the settings page
// re-renders from the state it just refreshed.
try { await client.request('check-update') } catch { /* the page keeps its old reading */ }
if (ref.current) ref.current.reload()
return
}
if (m.op === 'restart') {
// ⛔ A REAL RESTART, because that is the only thing that applies a staged bundle —
// chooseBundle runs at worklet start and nowhere else. Reloading the WebView would
// change the page and leave the same JS running underneath, which is the sort of
// "update" that reports success and does nothing.
if (onRestart) await onRestart()
}
}}
// ⛔ The pages come from this device and nowhere else. There is no remote origin to reach,
// so navigation away from loopback is a bug or an attack, never a feature.
originWhitelist={['http://127.0.0.1:*']}
// ⚠ A failure BEFORE anything has loaded is fatal; one after is a page that did not load.
onError={(e) => setFailed(String((e.nativeEvent && e.nativeEvent.description) || 'load failed'))}
// The surface serves its own vendored scripts; nothing is fetched from a CDN.
javaScriptEnabled
domStorageEnabled
// ⛔ Cookies must persist across reloads or every navigation would need a fresh handoff.
sharedCookiesEnabled
thirdPartyCookiesEnabled={false}
style={s.fill}
/>
</SafeAreaView>
)
}
const s = StyleSheet.create({
fill: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
dim: { opacity: 0.6, marginTop: 12, textAlign: 'center' },
err: { color: '#c33', textAlign: 'center' },
retry: { marginTop: 16, textDecorationLine: 'underline' }
})