// android-ota — keep the JS current on a phone, so the APK only has to change when native code does.
//
// THE SPLIT THIS EXISTS TO CREATE. An APK carries two very different things: ~77 MB of native libraries
// that change rarely (bare-kit + our addons), and a worklet bundle that changes constantly. Today proved
// the cost of not having this: a favela-web fix reached the handset only by rebuilding a 153 MB APK
// (150s of CI) and sideloading it, because favela-android vendors favela-web at build time.
//
// ⛔ WHAT WAS HERE BEFORE HAD NEVER RUN. pgh-runtime was `--defer`red out of the bundle and absent from
// dependencies, so `startOta`'s dynamic import always failed and this file returned null after logging
// "updates disabled". The code underneath was free to be wrong, and was: `getTree` returned
// `{entries: []}` for every hash, and pgh-runtime's installTree hashes the entries it is handed and
// rejects the tree unless they reproduce the root hash — a guard that exists so a peer cannot answer "I
// don't have it" with an empty directory. So it could not have downloaded a bundle even with the
// dependency present. It also took `versions[versions.length - 1]` as "newest", and this forge lists
// newest FIRST, so it would have pinned a phone to the oldest build on the server and called it current.
//
// ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE:
// - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA. Replicates P2P, never produces a file.
// - pear-git-v3's ReleaseManager: real files on disk, served over HTTPS at
// /git/releases/<repo>/<version>/<file>. This is the one that can hand a phone bytes.
// A phone needs BYTES, so this targets the second.
//
// ⚠ Artifacts are per-daemon. 7rnx.net/git is load-balanced and a release built on one backend is not
// automatically complete on the other (measured 2026-08-18: a 153 MB APK never reached the second
// backend while its build.log and manifest did). So `base` must point at a backend that actually holds
// the bytes, and every fetch is hash-verified rather than trusted because of where it came from.
import fs from 'bare-fs'
import path from 'bare-path'
import crypto from 'hypercore-crypto'
import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'
const DEFAULT_BASE = 'https://7rnx.net/git'
const DEFAULT_REPO = 'favela-android'
// The publisher's release registry — the hex in every hyper-fleeky app's `pgh.upgrade`
// (hyper-proto-peer, atproto-bridge, hypernote, favela-web, pgh-sidecar all carry this exact value).
//
// ⛔ A CONSTANT, AND DELIBERATELY NOT A LIST. This is a publisher IDENTITY, not a set of hosts: the
// serving topic comes from the resolved release's own serverKey, so adding, moving or rotating a backend
// changes nothing here. A hand-maintained list of hosts would be the frontedDids anti-pattern
// (hyper-social #54 §8) on the one component that cannot be fixed remotely.
const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
const BUNDLE_NAME = 'worklet.bundle'
const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')
// The running bundle's hash, if the environment happens to say. Guarded because `process` is a shim in
// the worklet and absent everywhere else; an OTA client is not the place to discover that.
function envBundleHash () {
try { return (typeof process !== 'undefined' && process.env) ? process.env.FAVELA_BUNDLE_HASH : null } catch { return null }
}
/**
* The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against.
*
* Deliberately built with hyper-overlay's own encoder rather than by hand — pgh-runtime verifies with
* `computeTreeHash`, so producing the hash any other way would be a second definition of the format that
* agrees until it doesn't.
*/
export function treeFor (bytes, name = BUNDLE_NAME) {
const contentHash = hashOf(bytes)
const entries = [{ kind: KIND_FILE, exec: 0, name, childHash: contentHash }]
return { entries, treeHash: computeTreeHash(entries), contentHash, size: bytes.length }
}
/**
* Newest release by BUILD TIME, not by position in the list.
*
* The old code took the last element and commented "newest last by publish order". `pgh deploy log`
* returns newest FIRST, so that read the oldest build on the server. Ordering the caller cannot see is
* not a contract; the timestamp is.
*/
export function pickNewest (versions) {
const list = (Array.isArray(versions) ? versions : []).filter(Boolean)
if (!list.length) return null
return list.reduce((a, b) => ((Number(b.builtAt) || 0) > (Number(a.builtAt) || 0) ? b : a))
}
/**
* A pgh-runtime fetcher over the forge's HTTPS release routes.
*
* Shape required by pgh-runtime: { getLatestRelease(name), getTree(hash), getContent(hash) }.
*/
export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFetch }) {
// treeHash -> { version, entries }, and contentHash -> { version, name }. Populated when a tree.json is
// read, because installTree asks for a tree and then for content BY HASH, with no version in hand.
const trees = new Map()
const contents = new Map()
const releaseUrl = (version, file) => `${base}/releases/${repo}/${version}/${file}`
async function readTree (version) {
const res = await doFetch(releaseUrl(version, 'tree.json'))
if (!res || res.ok === false) throw new Error(`no tree.json for ${version}`)
const body = await res.json()
const entries = (body && body.entries) || []
// Trust the ENTRIES, not the advertised hash: recompute it. installTree checks this too, but a
// fetcher that propagated a hash it never verified would hand the guard its own answer.
const treeHash = computeTreeHash(entries)
trees.set(treeHash, { version, entries })
for (const e of entries) contents.set(e.childHash, { version, name: e.name })
return { treeHash, entries }
}
// Named, because getTree's fallback calls back into getLatestRelease — one discovery path, not two.
const api = {
// ⛔ `release-index`, NOT `list-repo-releases`. The latter is behind the anonymous-RPC gate (pear-git
// org #12: the whole /rpc/ surface was world-callable until it became opt-in), so a phone — anonymous
// by construction — gets 403 forever. Measured on the handset: the check SUCCEEDS having found
// nothing, prints no error, and is indistinguishable from being up to date.
//
// release-index is already in ANONYMOUS_METHODS, and for the same use: "Release discovery for a
// ReleasePuller… The handler filters its response by repoAccess for anonymous callers, so a private
// repo's versions and hashes stay hidden." A phone updating itself is a puller. Opening a second
// method would have been inventing a path around one that already exists.
async getLatestRelease (name) {
const res = await doFetch(`${base}/rpc/release-index`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}'
})
if (!res || res.ok === false) return null
const body = await res.json()
// hyper-svc wraps HTTP /rpc/ responses in { result }. The old code read body.versions and would
// have found undefined even if the gate had let it through.
const index = (body && body.result) || body || {}
// Group this repo's files by version. The index carries every artifact, so it can answer the
// question that matters: which versions are actually COMPLETE?
const byVersion = new Map()
for (const f of index.files || []) {
if (!f || f.repo !== repo || !f.version) continue
if (!byVersion.has(f.version)) byVersion.set(f.version, { version: f.version, builtAt: 0, names: new Set() })
const v = byVersion.get(f.version)
v.names.add(String(f.path || '').split('/').pop())
if (Number(f.mtimeMs) > v.builtAt) v.builtAt = Number(f.mtimeMs)
}
// ⛔ SKIP INCOMPLETE RELEASES. Artifacts replicate per-file between forge backends and partial
// arrivals are real — measured 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the
// APK while tree.json and worklet.bundle never arrived. Taking "newest" blindly would pin the phone
// to a version it can never install, and retry it forever. Newest COMPLETE is the honest rule.
const complete = [...byVersion.values()].filter((v) => v.names.has('tree.json') && v.names.has(BUNDLE_NAME))
const newest = pickNewest(complete)
if (!newest) return null
// A release with no readable tree is not an error worth crashing a boot over.
let tree
try { tree = await readTree(newest.version) } catch { return null }
return { name, version: newest.version, treeHash: tree.treeHash }
},
// ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's
// computeTreeHash guard accept or reject it. An earlier cut gated here too and had to be pulled: a
// fetcher that verifies is a SECOND definition of the check, and the two agree right up until they
// do not — and it made the fetcher order-dependent, answering only for a tree getLatestRelease had
// pre-warmed. One guard, in the layer that owns it.
async getTree (rootHash) {
const hit = trees.get(rootHash)
if (hit) return { entries: hit.entries, size: hit.entries.length }
// Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand
// it over; if it is not the one asked for, installTree says so in the words an operator needs.
// Same discovery path as getLatestRelease — the gated route would 403 here too.
const latest = await api.getLatestRelease(repo)
if (!latest) throw new Error('no complete release for ' + repo)
const again = trees.get(latest.treeHash)
if (!again) throw new Error('no tree for ' + latest.version)
return { entries: again.entries, size: again.entries.length }
},
async getContent (contentHash) {
const where = contents.get(contentHash)
if (!where) throw new Error('unknown content ' + String(contentHash).slice(0, 12) + '…')
const res = await doFetch(releaseUrl(where.version, where.name))
if (!res || res.ok === false) throw new Error('fetch failed: ' + where.name)
const bytes = new Uint8Array(await res.arrayBuffer())
// ⛔ THE ONLY THING PROTECTING THIS PHONE. Releases are unsigned (`signed: false` in every publish)
// and the forge is load-balanced HTTPS, so "it downloaded" says nothing about WHAT downloaded. The
// bundle becomes the code that holds the DID signing key. Verify, or do not install.
const got = hashOf(bytes)
if (got !== contentHash) {
throw new Error(`content hash mismatch for ${where.name}: expected ${contentHash.slice(0, 12)}…, got ${got.slice(0, 12)}…`)
}
return bytes
}
}
return api
}
/**
* Store-only: write the bundle, and let the NEXT start pick it up.
*
* Applying a new bundle to a running node would mean swapping the code under itself. A phone restarts
* constantly anyway, so "active on next start" costs nothing and avoids a class of live-reload bugs.
*
* ⛔ NO MATERIALIZER. pgh's node materializer HARDLINKS, and SELinux denies `link` device-wide on
* Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written
* directly, is the whole of what a bundle needs.
*/
export function bundleStore ({ dir }) {
const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
// The release currently being installed. pgh-runtime's installTree does not pass the release down to
// the store, but it emits `updating` with it immediately beforehand — so the caller notes it here and
// materialize can stamp the staged marker with a comparable build time. Without that the shell has no
// way to tell a staged bundle from the one inside a freshly installed APK.
let pendingRelease = null
const readMarker = (treeHash) => {
try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null }
}
return {
init () { fs.mkdirSync(dir, { recursive: true }) },
/** Told by startOta from the `updating` event, just before installTree runs. */
noteRelease (r) { pendingRelease = r || null },
/**
* ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the
* forge has been living with all week — an index advertising a release whose artifact never arrived.
* So the marker is written LAST and this checks the file too.
*/
hasTree (treeHash) {
const m = readMarker(treeHash)
if (!m || !m.file) return false
try { return fs.statSync(path.join(dir, m.file)).size > 0 } catch { return false }
},
/** Where the staged bundle for this tree lives, or null. */
pathFor (treeHash) {
const m = readMarker(treeHash)
return m && m.file ? path.join(dir, m.file) : null
},
async materialize (rootHash, entries, { fetchContent } = {}) {
// ⛔ RECORD A BUILD TIME, WHATEVER THE RELEASE CALLS IT. chooseBundle compares this against the
// APK's stamp, and a staged bundle with no time is one an APK install cannot supersede — the
// failure that left a handset running 0.1.6's JS through four installs on 2026-08-25.
//
// The two release shapes name it differently and neither is wrong: a forge release carries
// `builtAt`, the pgh ReleaseRegistry carries an ISO `publishedAt`. Reading only one is how the
// swarm fetcher would have inherited the very bug it was written to replace.
const builtAt = (pendingRelease && (
(Number(pendingRelease.builtAt) || 0) ||
(Date.parse(pendingRelease.publishedAt || '') || 0)
)) || null
this.init()
// ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed
// last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what
// happens otherwise: "a two-entry tree stages a 154 MB APK as the worklet bundle. The OTA client
// on a handset is exactly what cannot be fixed remotely."
//
// It stopped being hypothetical when CI started publishing to the release registry: `pgh publish`
// requires a package.json, so the published tree is { package.json, worklet.bundle } and the winner
// depended on an ordering nothing here controls or asserts. Selecting by name also stops the phone
// downloading content it will discard — the old loop fetched every entry before dropping all but one.
const want = (entries || []).find((e) => e && e.kind === KIND_FILE && e.name === BUNDLE_NAME)
if (!want) {
const names = (entries || []).filter((e) => e && e.kind === KIND_FILE).map((e) => e.name)
throw new Error('tree contains no ' + BUNDLE_NAME + ' to stage (files: ' + (names.join(', ') || 'none') + ')')
}
const bytes = await fetchContent(want.childHash)
// Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
// then trusted would be a node that cannot boot with no way to say why.
const dest = path.join(dir, want.childHash + '.bundle')
const part = dest + '.part'
fs.writeFileSync(part, Buffer.from(bytes))
fs.renameSync(part, dest)
const file = want.childHash + '.bundle'
// `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it
// an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree
// hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up.
fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n')
// current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
// cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
// marker, so it never advertises something that is not fully on disk.
fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n')
}
}
}
// The boot-time choice lives in its own dependency-free module so the React Native side can import
// it too (this file imports bare-fs, which does not exist there). Re-exported for callers inside the
// worklet, so there is one name for it.
export { chooseBundle } from './choose-bundle.js'
/**
* THE FETCHER EVERY OTHER hyper-fleeky APP USES — Hyperswarm + hyper-overlay. No HTTPS, no backend.
*
* ⛔ WHAT THIS REPLACES, AND WHY IT WAS WRONG. The HTTPS fetcher spoke to https://7rnx.net/git — a
* LOAD-BALANCED pair of forge daemons — for discovery, tree AND content. pgh-runtime never asked for
* that: it is transport-agnostic and REQUIRES an injected fetcher, and pgh has shipped the Hyperswarm
* implementation the whole time. favela-android was the only app not using it, so hyper-overlay was
* reduced to a hashing library here while every other app used it as the transport it is.
*
* The cost was not theoretical. A release lands on the backend that BUILT it and replicates to the other
* asynchronously; the phone asks the load balancer and gets whichever. Measured 2026-08-25: 0.1.6
* replicated in ~15s, 0.1.7 had NOT replicated after 600s — the phone correctly reported no update and
* was stuck. Measured 2026-08-18: a 153 MB APK never reached the second backend at all.
*
* TWO PIECES, both already exported by pgh:
* makeGetLatestRelease({ registryKeyHex }) replicates the PUBLISHER's registry over Hyperswarm —
* resolve-release.js path 2, written for exactly this case
* ("every standalone install"), which a handset is.
* NetworkFetcher({ topic: serverKey }) tree + content over hyper-overlay's protocol.
*
* MEASURED end-to-end against the real published release before this shipped: resolve 13.2s cold,
* getTree 26ms, and the 38,135,946-byte worklet bundle in 8.4s.
*
* ⛔ RESOLVE ONCE. The 13s is registry replication from cold and it dominates everything else; fetches
* after it are milliseconds. A phone that re-resolved per wake would pay it every time, so the topic is
* learned once per boot — the same thing pgh's own runtime-factory does. A publisher that moves to a new
* serverKey is a restart, not a case worth carrying state for.
*
* ANDROID: `preferSidecar: false`. resolve-release tries a pgh-sidecar UDS first because on a normal
* machine both paths open the same registry corestore and a direct open loses an fd-lock race. A handset
* has no sidecar and never will, so the probe is pure latency — and SELinux makes pathname sockets their
* own hazard here.
*/
export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null } = {}) {
const { makeGetLatestRelease } = await import('pgh/resolve-release')
const { NetworkFetcher } = await import('pgh/network-fetcher')
const getLatestRelease = makeGetLatestRelease({ registryKeyHex, preferSidecar: false })
const latest = await getLatestRelease(repo)
if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)
const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout, destDir, swarm })
// NetworkFetcher's own getLatestRelease reads a LOCAL registry, which a phone does not have. Its header
// calls the replacement a follow-on; the follow-on is the resolver above, and this is the same
// substitution runtime-factory makes for the same reason.
fetcher.getLatestRelease = getLatestRelease
await fetcher.ready()
return { fetcher, latest }
}
/**
* Start the bundle updater. Returns { stop() } or null when OTA is unavailable.
*
* Never throws: the caller boots a node either way.
*/
export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
let PghRuntime = null
try {
;({ PghRuntime } = await import('pgh-runtime'))
} catch {
say('ota: pgh-runtime not bundled — updates disabled (add the dep to enable)')
return null
}
const dir = path.join(storage, 'bundles')
const store = bundleStore({ dir })
store.init()
// THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in
// the layer that owns it. Production passes nothing and gets the swarm; tests pass a double and can
// exercise the store/runtime wiring without a DHT, which is the coverage that caught the two-week
// "OTA never ran" bug and must not be lost with the transport.
//
// ⛔ NO HTTPS FALLBACK, DELIBERATELY. A fallback means the phone silently keeps using the load-balanced
// path whenever the swarm is slow — which is precisely when the swarm is the thing to debug, and
// precisely the failure HTTPS already has. Two transports is also two sets of failure modes on the one
// component that cannot be fixed remotely. If this cannot reach a peer, OTA is off for that boot and
// says so; a sideloaded APK stays the recovery route, as it already is for every native change.
let fetcher = injectedFetcher
if (!fetcher) {
try {
;({ fetcher } = await swarmFetcher({ registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm }))
} catch (err) {
say('ota: no swarm fetcher — updates disabled this boot (' + ((err && err.message) || err) + ')')
return null
}
}
const runtime = new PghRuntime({
name: repo,
// ⛔ NOT `process.env.X` BARE. `process` is not a Bare global — it exists in the worklet only because
// lib/node.js imports favela-web's bare-globals shim before anything else. So this line worked on the
// phone and threw ReferenceError everywhere else, including the first test that ever called startOta.
// A library used from three environments must not read a global it did not import.
currentTreeHash: currentTreeHash || envBundleHash() || null,
fetcher,
store,
interval
})
runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) })
runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))
await runtime.start()
say('ota: watching ' + repo + ' over the swarm every ' + Math.round(interval / 3600000) + 'h')
// ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
// so the interval is almost never when the user is actually looking — the one moment a check is worth
// making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
// just stops throwing the handle away, which is the whole reason the app had no update control.
// ⛔ CLOSE THE SWARM WE OPENED. stop() used to stop only the poll loop, leaving NetworkFetcher's
// Hyperswarm node and corestore open for the life of the process. On a server that is untidy; on a
// phone it is a DHT node and a set of sockets held awake by something the user has switched off. It is
// the same shape as the hyper-svc UDS leak that reached ~205k sockets and ~14 GB on stortron — a
// handle nobody closed because the thing that created it was not the thing that owned it.
//
// Caught empirically: the real end-to-end probe finished staging in 23.5s and then never exited, and
// `timeout` killed it at 600s (EXIT=124). A stop() that leaves the process alive is not a stop.
//
// OWNERSHIP: only close what we created. An injected fetcher belongs to the caller — a test owns its
// double's lifecycle, and closing someone else's handle is how you get a use-after-close in the suite
// that was meant to be catching this.
const ownsFetcher = !injectedFetcher
return {
stop: async () => {
try { if (runtime.stop) await runtime.stop() } catch { /* stopping must not throw on the way out */ }
if (ownsFetcher && fetcher && typeof fetcher.close === 'function') {
try { await fetcher.close() } catch { /* same */ }
}
},
store,
checkNow: () => runtime.checkNow()
}
}