OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / cbee9d1

a stale session is not a sign-out; plus boot-stage timing
Two things, and the version bump the CI needs — its release version is derived from <branch>-<shortsha>, so a rebuild with an unchanged HEAD is refused and the phone cannot receive any of the favela-web work vendored at pack time. 1. RE-LOGIN INSTEAD OF DEAD-ENDING favela-web keeps sessions in an in-memory Map, so every restart of that process makes this side's token unknown. The WebView then hits /auth/handoff, gets "unknown or expired token", and RENDERED THE REFUSAL PAGE — which reads as being signed out and could only be escaped by force-stopping the app from Android settings. Reported live today. Nothing about the credentials changed. The worklet holds the DID's signing key and can mint a new session whenever asked; it simply only ever did so at boot. The sign-in is now a callable function behind a `resession` verb, and a 403 from /auth/handoff triggers it and reloads. Guarded so it cannot loop: one attempt per mount, and a 403 that is not about staleness falls through to the existing handling. Deliberately does NOT setFailed on that path either — a first load with a stale token would otherwise show "could not load the local UI" while the re-login it needs is already in flight. Same shape as the conflations audit #45 flagged: a recoverable condition presented as a verdict. 2. BOOT-STAGE TIMING "The app takes a very long time to start" has been a standing complaint with no number attached, and every explanation for it — bundle size, swarm join, replication — is unfalsifiable without one. Each stage now logs its own duration and the running total. Measured around the stages that ALREADY log, so the timings line up with the existing narrative rather than introducing checkpoints that only exist to be measured. No optimisation in this commit on purpose: the next boot on a handset decides what, if anything, is worth deferring — which is also the input to whether overlay construction should be lazy. Suite green (9 files). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cbee9d190ffaf0286e819a3c506908f009617648
by plankatron <fleeky@gmail.com> · 2026-08-24T23:45:57.000Z

Changed files (3) +113 -27

app/App.jsx +39 -5
……@@ -72,10 +72,10 @@ export default function App () {
7272 return <Pair client={client} onPaired={async (r) => { setPairing(false); await paired(r) }} />
7373 }
7474
75 return <Web status={status} onPair={() => setPairing(true)} />
75 return <Web status={status} client={client} onPair={() => setPairing(true)} />
7676}
7777
78function Web ({ status, onPair }) {
78function Web ({ status, client, onPair }) {
7979 const ref = useRef(null)
8080 const [canBack, setCanBack] = useState(false)
8181 const [failed, setFailed] = useState(null)
……@@ -87,10 +87,36 @@ function Web ({ status, onPair }) {
8787 // cookie form of the same session and redirects to the page. Without it every page renders anonymously:
8888 // no liked/reposted state, and every write refused — the silent degradation that made the hand-written
8989 // UI look fine while doing less.
90 const uri = status.favelaSession
91 ? status.favelaUrl + '/auth/handoff?token=' + encodeURIComponent(status.favelaSession)
90 // `nonce` forces a re-render with a fresh handoff URL after a re-session, since the URL is otherwise
91 // identical and the WebView would not reload it.
92 const [session, setSession] = useState(status.favelaSession)
93 const [nonce, setNonce] = useState(0)
94 const recovering = useRef(false)
95
96 const uri = session
97 ? status.favelaUrl + '/auth/handoff?token=' + encodeURIComponent(session) + (nonce ? '&r=' + nonce : '')
9298 : status.favelaUrl
9399
100 // A STALE TOKEN IS NOT A SIGN-OUT. favela-web keeps sessions in memory, so any restart of it makes
101 // this side's token unknown and /auth/handoff answers 403 — and we used to render that refusal page
102 // and stop, which looks exactly like being signed out and could only be escaped by force-stopping the
103 // app. Nothing about the credentials changed: the worklet holds the DID key and can mint a new
104 // session on demand. So ask for one, once, and reload.
105 //
106 // Guarded by `recovering` so a 403 that is NOT about staleness cannot become a re-login loop: one
107 // attempt per mount, and if the fresh token is refused too, the refusal stands and is shown.
108 const recoverSession = useCallback(async () => {
109 if (recovering.current) return
110 recovering.current = true
111 try {
112 const r = await client.resession()
113 if (r && r.ok && r.favelaSession) {
114 setSession(r.favelaSession)
115 setNonce((n) => n + 1)
116 }
117 } catch { /* leave the refusal visible rather than spinning */ }
118 }, [client])
119
94120 // HARDWARE BACK follows the page history, then falls through to the default (exit) at the first page.
95121 // Without this, BACK from a thread finishes the activity and drops the user to the launcher.
96122 useEffect(() => {
……@@ -132,7 +158,15 @@ function Web ({ status, onPair }) {
132158 // Only a failure BEFORE anything has ever rendered is fatal — that is the case the screen is for
133159 // (server never came up). After that, the page is still there and owns its own error reporting.
134160 onError={(e) => { if (!loaded.current) setFailed((e.nativeEvent && e.nativeEvent.description) || 'load failed') }}
135 onHttpError={(e) => { if (!loaded.current) setFailed('HTTP ' + ((e.nativeEvent && e.nativeEvent.statusCode) || '?')) }}
161 // A 403 from /auth/handoff is the STALE-SESSION case, not a broken app: favela-web restarted and
162 // forgot the token this side still holds. Recover it rather than tearing the UI down — and
163 // deliberately do NOT setFailed here, or a first load with a stale token would show "could not
164 // load the local UI" while the re-login it needs is already in flight.
165 onHttpError={(e) => {
166 const { statusCode, url } = (e && e.nativeEvent) || {}
167 if (statusCode === 403 && String(url || '').includes('/auth/handoff')) { recoverSession(); return }
168 if (!loaded.current) setFailed('HTTP ' + (statusCode || '?'))
169 }}
136170 // Loopback only, so no mixed-content or remote-code concern; the pages need JS for likes/compose.
137171 javaScriptEnabled
138172 domStorageEnabled
lib/ipc-client.js +2 -0
……@@ -127,6 +127,8 @@ export class WorkletClient {
127127
128128 // Typed wrappers. Thin on purpose — they exist so a screen never types an op string, not to add logic.
129129 status () { return this.request('status') }
130 /** Mint a fresh favela-web session. Used when /auth/handoff refuses a token the server has forgotten. */
131 resession () { return this.request('resession') }
130132 collections () { return this.request('collections') }
131133 // The ported surface. Thin by design — they exist so a screen never types an op string, not to add
132134 // logic; every one of them maps to an appview query or a device write in the worklet.
lib/node.js +72 -22
……@@ -74,7 +74,24 @@ const FAVELA_PORT = Number(process.env.FAVELA_PORT) || 4041
7474// worklet's second one used to be built.
7575const state = { hpp: null, ready: false, favelaUrl: null, favelaSession: null }
7676
77// BOOT TIMING. "The app takes a very long time to start" has been a standing complaint with no number
78// attached, and every guess about the cause (bundle size? swarm join? replication?) is unfalsifiable
79// without one. Each stage logs its own duration and the cumulative total, so the next boot on a real
80// handset says where the time actually goes instead of inviting another guess.
81//
82// Deliberately measured with Date.now() deltas around the stages that ALREADY log — no new checkpoints
83// invented, so the timings line up with the existing narrative in the log.
84let _bootT0 = 0
85let _stageT = 0
86function stage (name, extra = {}) {
87 const now = Date.now()
88 const ms = _stageT ? now - _stageT : 0
89 _stageT = now
90 log('boot stage: ' + name, { ms, totalMs: now - _bootT0, ...extra })
91}
92
7793async function boot () {
94 _bootT0 = _stageT = Date.now()
7895 prepareEnv()
7996 log('booting', { storage })
8097
……@@ -131,6 +148,7 @@ async function boot () {
131148 const configured = (cfg.dids || []).length
132149 if (up === 0 && configured > 0) log('hpp booted NO DIDs — read-only despite ' + configured + ' configured', { dids: 0, configured })
133150 else log('hpp up — signing enabled', { dids: up, configured })
151 stage('hpp up')
134152 } catch (e) {
135153 // A node that cannot sign still reads; a node that cannot boot is useless. Degrade, loudly.
136154 try { console.error('[favela] hpp boot FAILED — read-only:', (e && e.stack) || String(e)) } catch {}
……@@ -213,6 +231,7 @@ async function boot () {
213231 const roster = rosterEnv({ hosted: hostedDids(), tracked: readTracked() })
214232 if (roster) process.env.FAVELA_ROSTER = roster
215233 log('favela-web roster', { dids: roster ? roster.split(',').length : 0 })
234 stage('favela-web starting')
216235 fs.mkdirSync(process.env.FAVELA_STORE_ROOT, { recursive: true })
217236 await import('favela-web/bin/favela-web.js')
218237 state.favelaUrl = 'http://127.0.0.1:' + FAVELA_PORT
……@@ -221,6 +240,7 @@ async function boot () {
221240 const fetchImpl = (await import('bare-fetch')).default
222241 const res = await fetchImpl(state.favelaUrl + '/status')
223242 log('favela-web in-process', { url: state.favelaUrl, status: res.status })
243 stage('favela-web serving')
224244
225245 // SIGN IN to our own favela-web. Without this every UI fetch is ANONYMOUS: viewerOf(req) returns
226246 // null, so liked/reposted state and per-viewer mutes silently vanish from every row and every
……@@ -228,28 +248,8 @@ async function boot () {
228248 // the kind of quiet degradation this port exists to remove, so it is logged either way.
229249 //
230250 // Non-fatal: a phone that cannot sign in still reads (anonymously). Reporting beats guessing.
231 const entry = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
232 if (entry) {
233 try {
234 const { loadOrCreateSubKey, establishSession, sessionNodeOf } = await import('./session.js')
235 // hpp hands back a WRAPPER, not the SwarmDevice — see sessionNodeOf. Passing `entry` straight
236 // through is what made every boot sign-in fail with "an hpp node with delegate() required".
237 const node = sessionNodeOf(entry)
238 if (!node) throw new Error('hpp entry exposes no delegate() — cannot sign in')
239 const key = loadOrCreateSubKey(path.join(dirs.root, 'ui-subkey.json'), fs, path.dirname)
240 if (key.created) log('minted a UI sub-key for this device (delegated by our own DID)')
241 const s = await establishSession({
242 favelaUrl: state.favelaUrl, node, privHex: key.privHex, pubHex: key.pubHex, fetchImpl
243 })
244 state.favelaSession = s.token
245 log('signed in to favela-web', { did: s.did.slice(0, 20) + '…', handle: s.handle })
246 } catch (e) {
247 state.favelaSession = null
248 log('favela-web sign-in FAILED — the UI will read anonymously (no liked/reposted state, no writes)', { error: e.message })
249 }
250 } else {
251 log('no DID yet — favela-web stays anonymous until this device is paired')
252 }
251 await signInToFavela()
252 stage('signed in')
253253 } catch (e) { log('favela-web started but did not answer /status', { error: e.message }) }
254254 } catch (e) {
255255 state.favelaUrl = null
……@@ -325,7 +325,57 @@ function readTracked () { try { return JSON.parse(fs.readFileSync(TRACKED(), 'ut
325325function hostedDids () { return ((state.hpp && state.hpp.nodes) || []).map((n) => n && n.did).filter(Boolean) }
326326function writeTracked (list) { try { fs.writeFileSync(TRACKED(), JSON.stringify(list, null, 2) + '\n') } catch {} }
327327
328// SIGN IN to our own favela-web. Without this every UI fetch is ANONYMOUS: viewerOf(req) returns null,
329// so liked/reposted state and per-viewer mutes silently vanish from every row and every write route 401s.
330// The failure mode is invisible — posts just render un-liked — so it is logged either way.
331//
332// ⛔ CALLABLE AGAIN, not just at boot. favela-web's session store is an in-memory Map, so it is emptied
333// by every restart of that process while this side still holds the old token. The WebView then hits
334// /auth/handoff, gets "unknown or expired token", and RENDERS THE REFUSAL — a recoverable condition
335// presented as a dead end, with force-stopping the app as the only way out. Nothing about the
336// credentials changed: this side can mint a fresh session whenever asked, and now does.
337//
338// Non-fatal by design: a phone that cannot sign in still reads, anonymously. Reporting beats guessing.
339async function signInToFavela () {
340 const entry = state.hpp && state.hpp.nodes && state.hpp.nodes[0]
341 if (!entry) {
342 log('no DID yet — favela-web stays anonymous until this device is paired')
343 return { ok: false, reason: 'no DID' }
344 }
345 try {
346 const fetchImpl = (await import('bare-fetch')).default
347 const { loadOrCreateSubKey, establishSession, sessionNodeOf } = await import('./session.js')
348 // hpp hands back a WRAPPER, not the SwarmDevice — see sessionNodeOf. Passing `entry` straight
349 // through is what made every boot sign-in fail with "an hpp node with delegate() required".
350 const node = sessionNodeOf(entry)
351 if (!node) throw new Error('hpp entry exposes no delegate() — cannot sign in')
352 const key = loadOrCreateSubKey(path.join(dirs.root, 'ui-subkey.json'), fs, path.dirname)
353 if (key.created) log('minted a UI sub-key for this device (delegated by our own DID)')
354 const s = await establishSession({
355 favelaUrl: state.favelaUrl, node, privHex: key.privHex, pubHex: key.pubHex, fetchImpl
356 })
357 state.favelaSession = s.token
358 log('signed in to favela-web', { did: s.did.slice(0, 20) + '…', handle: s.handle })
359 return { ok: true, token: s.token }
360 } catch (e) {
361 state.favelaSession = null
362 log('favela-web sign-in FAILED — the UI will read anonymously (no liked/reposted state, no writes)', { error: e.message })
363 return { ok: false, reason: e.message }
364 }
365}
366
328367const handlers = {
368 // Mint a FRESH favela-web session and hand back the token.
369 //
370 // Exists because favela-web's session store is in memory: when that process restarts, this side's
371 // token is instantly unknown and /auth/handoff answers 403. The WebView used to render that refusal
372 // and stop, which reads as "signed out" and is only escapable by force-stopping the app. It is a
373 // recoverable condition — nothing about the credentials changed — so the UI now asks for a new
374 // session instead of dead-ending. See App.jsx's onHttpError.
375 async resession () {
376 const r = await signInToFavela()
377 return { ok: !!r.ok, favelaSession: state.favelaSession, favelaUrl: state.favelaUrl, reason: r.reason || null }
378 },
329379 async status () {
330380 // favelaSession is the session COOKIE VALUE, handed to the UI so its fetches can carry
331381 // `Cookie: favela_session=…`. HttpOnly is a browser protection against document-script access; there