OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / app / App.jsx

198 lines · jsx ref:
// 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' }
})