OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / ota.js

698 lines · javascript ref:
// 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 { nextRetryDelay } from './retry-policy.js'
import crypto from 'hypercore-crypto'
import { computeTreeHash, KIND_FILE, KIND_DIR } from 'hyper-overlay/tree'

const DEFAULT_BASE = 'https://7rnx.net/git'
// ⛔ THIS REPO'S OWN NAME, and the reason is not tidiness. This file was copied verbatim from
// favela-android, where the default is `favela-android` — and an OTA client that keeps the source
// repo's name fetches ANOTHER APP'S worklet bundle and stages it as its own. That bundle boots
// hyper-proto-peer and favela-web against this app's storage, which is a different node entirely.
// The registry below is a publisher identity shared by every hyper-fleeky app, so the repo name is
// the ONLY thing distinguishing one app's releases from another's here. Wrong default, wrong app.
const DEFAULT_REPO = 'favela-surface-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
// How long to let a COLD registry replica settle before reading it. See swarmFetcher.
const COLD_SETTLE_MS = 30000
const BUNDLE_NAME = 'worklet.bundle'
// The directory a release puts its native addons in. CI publishes it beside the bundle.
const ADDONS_NAME = 'addons'

// ⛔ THE STACK, NOT JUST THE MESSAGE. Three separate homedir defaults have now produced the SAME
// ENOENT on a handset, each masked by the one before it, and each cost a build-and-sideload cycle to
// guess at. `err.message` names the path and not the caller, so it cannot distinguish them. A device
// with no log anyone can read has to carry the frame itself.
const frame = (err) => {
  const st = (err && err.stack) || ''
  // ⛔ SKIP THE RUNTIME'S OWN FRAMES. `slice(1, 4)` reported three frames of bare-fs's RECURSIVE
  // mkdir and never reached the caller — the instrument named the failing primitive, which was
  // already in the message, and hid the only thing that was not. A recursive helper can produce
  // arbitrarily many internal frames, so a fixed depth is always wrong; filter by origin instead.
  const ours = String(st).split('\n').slice(1)
    .map((l) => l.trim().replace(/^at\s+/, ''))
    .filter((l) => l && !/bare-fs|bare-module|bare-path|^bare:|node_modules\/bare-/.test(l))
  return ours.length ? ' @ ' + ours.slice(0, 4).join(' < ') : ' @ ' + String(st).split('\n').slice(1, 3).join(' < ')
}

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 }
}

/**
 * The sonames a packed bundle demands, or null when they cannot be read.
 *
 * bare-bundle's container is an ASCII decimal header length, a newline, then that many bytes of JSON —
 * the length measured FROM the newline. Parsed by hand, exactly as scripts/stage-addons.mjs does at
 * build time, so the phone needs no bundle library to answer the one question chooseBundle asks.
 *
 * ⛔ NULL, NEVER []. chooseBundle refuses a staged bundle whose demands nothing can satisfy, and an
 * empty list would read as "demands nothing" — a bundle that always passes the guard. Unknown has to
 * DISABLE the guard, not sail through it. And an unreadable header is not a reason to reject a bundle
 * here: it is one fact missing, not a bad download, and the bytes were content-verified upstream.
 */
export function demandedAddons (bytes) {
  try {
    const buf = Buffer.from(bytes)
    const nl = buf.indexOf(10)
    if (nl === -1) return null
    const len = parseInt(buf.subarray(0, nl).toString(), 10)
    if (!Number.isInteger(len) || len <= 0) return null
    const header = JSON.parse(buf.subarray(nl, nl + len).toString().trim())
    if (!Array.isArray(header.addons)) return null
    return header.addons.map((a) => String(a).replace(/^linked:/, ''))
  } catch {
    return null
  }
}

/**
 * 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, apkAddons = null }) {
  // ⛔ WHAT THE APK ALREADY PROVIDES IS NOT WORTH DOWNLOADING. Measured on the real bundle: it demands
  // 58 addons and the installed APK carries 28 of them, so fetching the lot costs 39.7 MB where 27.8 MB
  // is needed. A soname the APK provides resolves by dlopen anyway, so the bytes would buy a
  // byte-identical library.
  //
  // ⚠ NULL MEANS SKIP NOTHING. An older shell sends no list, and a soname wrongly assumed present is a
  // dlopen failure on a device with no way back in. Absence of knowledge must cost bandwidth, never
  // correctness.
  const apkHas = Array.isArray(apkAddons) && apkAddons.length > 0 ? new Set(apkAddons) : null
  const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
  // ⛔ NAMED OFF THE BUNDLE FILE, NOT THE TREE HASH. The staged bundle derives this itself from
  // import.meta.url (host/loader-core.js addonsDirFor), which is what lets the preloader ship as an
  // ordinary JS release to the APK already on the device. A tree-hash name would have to be handed in
  // by the shell, and a shell old enough to predate this cannot hand in anything.
  const addonsDir = (file) => path.join(dir, file + '.addons')
  // 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 { if (fs.statSync(path.join(dir, m.file)).size === 0) return false } catch { return false }
      // ⛔ AND EVERY ADDON. installTree SHORT-CIRCUITS on hasTree, so a run that wrote the bundle and
      // died before the addons would report complete for ever — and the bundle that then boots demands
      // a soname nothing on the device provides. Same "marker before the bytes" bug as above, one level
      // up, and the level where it cannot be fixed remotely.
      const addons = Array.isArray(m.addons) ? m.addons : []
      for (const name of addons) {
        try { if (fs.statSync(path.join(addonsDir(m.file), name)).size === 0) return false } catch { return false }
      }
      return true
    },

    /** 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
    },

    /**
     * Where this tree's staged addons live, or null when the release carries none.
     *
     * The loader registers each of these under the `linked:` URL the bundle asks for — Bare's
     * `Addon.cache` is keyed by url.href and the `linked:` branch of `Addon.resolve` goes through
     * `Addon.load`, so a staged addon wins before any dlopen-by-soname and an unstaged one still falls
     * through to the APK. Null, never an empty dir: a loader must be able to tell "no addons in this
     * release" from "addons that did not arrive".
     */
    addonsFor (treeHash) {
      const m = readMarker(treeHash)
      if (!m || !Array.isArray(m.addons) || m.addons.length === 0) return null
      return addonsDir(m.file)
    },

    async materialize (rootHash, entries, { fetchContent, fetchTree } = {}) {
      // ⛔ 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'
      // Read while the bytes are in hand. The alternative is the React Native side re-opening a 32 MB
      // file at every boot to re-read a header that never changes.
      const demands = demandedAddons(bytes)

      // ⛔ THE ADDONS, BEFORE THE MARKER. A `linked:` bundle names the sonames the APK must supply, and
      // until now a bundle demanding one the APK lacks could only die at dlopen — on a device with no
      // adb and no UI left to say why. Bare's Addon class is the way out: `new Addon(fileURL)` loads
      // from an absolute path, so an addon delivered here can be registered under the `linked:` name
      // the bundle asks for and win before any dlopen-by-soname.
      //
      // BY NAME AND BY KIND, for the same reason the bundle is: a release tree's shape is not ours to
      // assume. Absent `addons/`, this is exactly the single-file store it has always been — every
      // release published so far is { package.json, worklet.bundle } and must keep working untouched.
      const addonDirEntry = (entries || []).find((e) => e && e.kind === KIND_DIR && e.name === ADDONS_NAME)
      const addons = []
      if (addonDirEntry) {
        if (typeof fetchTree !== 'function') {
          throw new Error('release carries ' + ADDONS_NAME + '/ but no fetchTree to read it with')
        }
        const adir = addonsDir(want.childHash + '.bundle')
        fs.mkdirSync(adir, { recursive: true })
        const sub = await fetchTree(addonDirEntry.childHash)
        for (const e of (sub || [])) {
          if (!e || e.kind !== KIND_FILE) continue
          // Skipped BEFORE fetchContent, which is where the saving is — the old loop downloaded every
          // entry and only then decided what to keep.
          if (apkHas && apkHas.has(e.name)) continue
          const b = await fetchContent(e.childHash)
          const d = path.join(adir, e.name)
          const pt = d + '.part'
          fs.writeFileSync(pt, Buffer.from(b))
          // ⛔ THE EXEC BIT IS CARRIED IN THE TREE and it is part of the entry's identity hash, so it
          // cannot drift from what was published. Android needs execute on the FILE OBJECT to dlopen
          // it out of app storage; a mode that silently defaulted to 0644 would fail at load with an
          // error naming the path and not the permission.
          if (e.exec === 1) fs.chmodSync(pt, 0o755)
          fs.renameSync(pt, d)
          addons.push(e.name)
        }
      }
      // `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, addons, demands }, 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, addons, demands }, 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, storeDir = null, latest: opts_latest = null } = {}) {
  const { makeGetLatestRelease } = await import('pgh/resolve-release')
  const { NetworkFetcher } = await import('pgh/network-fetcher')

  // ⛔ storeDir, OR THE REGISTRY REPLICA GOES TO A HOME DIRECTORY THAT DOES NOT EXIST HERE.
  // Measured on a handset: ENOENT stat'ing the default path, caught into "no swarm fetcher —
  // updates disabled this boot". The phone could never update and said so only once the app was
  // taught to surface its own last OTA line.
  // ⛔ settleMs, AND IT IS THE REASON A PHONE NEVER UPDATED. The default is 500ms — correct for a
  // WARM replica, which is what every machine that has ever run pgh has at the default path. A
  // handset has no replica: it must pull the registry autobase from the swarm on every boot, and
  // 500ms is not enough to see a single block. `resolveRelease` then returns null and this throws
  // "no published release with a serverKey", which reads like a publishing problem and is not.
  //
  // ⛔ MEASURED, and the measurement is the whole point: with the SAME key and a cold store,
  // settleMs 500 -> null, settleMs 20000 -> the real entry with its serverKey. Every earlier
  // "OTA works" check I ran was resolving from tabertron's warm ~/.config/pgh replica and never
  // touched the swarm at all — a green light from an instrument pointed at the wrong thing.
  //
  // ⚠ The cost is a slow check, not a slow boot: startOta is awaited but non-fatal, the interval is
  // hours, and a check that answers in 30s having actually replicated beats one that answers in
  // 500ms having seen nothing.
  // ⛔ ITS OWN storeDir, AND IT MUST HAVE ONE. This closure is not only used here: swarmFetcher
  // REPLACES `fetcher.getLatestRelease` with it, and pgh-runtime calls that on every `_check()` —
  // so it resolves long after this function returns. Passing `latest` skips the call HERE and does
  // nothing for the calls pgh-runtime makes later.
  //
  // ⛔ AND IT MUST NOT BE THE WATCH'S DIRECTORY. RocksDB's lock is exclusive; sharing the path the
  // watch holds fails with "File descriptor could not be locked". Dropping storeDir entirely to
  // avoid that — which is what I did — sent it to `homedir()/.config/pgh/registries`, and on Android
  // that cannot be created, so every `_check()` threw ENOENT. A separate directory satisfies both.
  //
  // MEASURED with HOME pointed at an uncreatable path, which reproduces the handset exactly:
  //   resolveRelease < NetworkFetcher.getLatestRelease < PghRuntime._check < PghRuntime.start
  const getLatestRelease = makeGetLatestRelease({
    registryKeyHex,
    preferSidecar: false,
    storeDir: storeDir || (destDir ? path.join(destDir, '..', 'registry-check') : undefined),
    settleMs: COLD_SETTLE_MS
  })
  const latest = opts_latest || await getLatestRelease(repo)
  if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)

  // ⛔ localRegistryDir, THE SECOND HOMEDIR IN THIS PATH. NetworkFetcher defaults it to
  // `homedir()/.config/pgh/release-registry` and stats it — on Android that is `/data/config/…`,
  // which an unprivileged app cannot stat at all, so it throws rather than returning false.
  //
  // ⚠ IT WAS MASKED. In 0.0.10 `resolveRelease`'s own homedir default failed FIRST, so fixing that
  // one simply moved the ENOENT here and it looked like the same bug returning. Two independent
  // defaults, one symptom — which is why the fix has to be "no homedir anywhere in this path"
  // rather than "fix the one that threw".
  const fetcher = new NetworkFetcher({
    topic: latest.serverKey, timeout, destDir, swarm, localRegistryDir: path.join(destDir, '..', 'registry-local')
  })
  // 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, apkAddons = null, 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, apkAddons })
  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.
  // ⛔ EVENT-DRIVEN, NOT POLLED — and the poll was not merely slow, it was WRONG. A one-shot resolve
  // has to guess how long replication takes: `resolveRelease` waits `settleMs` because "flush
  // returns when peers are connected, not when the log has caught up". That guess is 500ms, correct
  // for a warm replica and far too short for a cold one, so a handset resolved null every boot and
  // reported "no published release with a serverKey" — a publishing failure that never happened.
  //
  // MEASURED against the same registry with a cold store: one-shot settleMs 500 -> null, settleMs
  // 20000 -> the entry, WATCH -> the entry in 2777ms with no settle at all. A held replica is TOLD
  // when blocks land; it does not estimate. That removes the class, rather than retuning the guess.
  //
  // ⚠ Nothing here blocks boot. The watch is opened, `startOta` returns, and the runtime is built by
  // the first release event whenever it arrives — which is also the reconciliation, since the watch
  // reads once itself for a replica that was already caught up.
  let watch = null
  let runtime = null
  let started = null
  // Hoisted: `wire()` builds it, `stop()` has to close it, and only what WE created (an injected
  // fetcher belongs to its caller — closing someone else's handle is a use-after-close in the suite
  // that was meant to catch exactly that).
  let activeFetcher = injectedFetcher
  const ownsFetcher = !injectedFetcher

  // ⛔ THE ENTRY THE WATCH ALREADY DELIVERED, held so a FAILED wire can be retried. Without this a
  // transient DHT miss disabled OTA for the whole session: `wire` returned, activeFetcher stayed
  // null, and nothing tried again until somebody published. Measured on a handset 2026-09-15 —
  // "no swarm fetcher (no peer within 120000ms)" at 15:58, ten minutes of silence, then a download
  // at 16:08 that worked first try, and only because a release was published to fire the watch.
  //
  // ⚠ This is what keeps the retry from being the sweep ota.js refuses: no pending entry, no timer.
  // The watch is still the only thing that produces news; this only re-attempts news already given.
  let pendingEntry = null
  let retryTimer = null
  let retryAttempt = 0

  const clearRetry = () => {
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
    retryAttempt = 0
  }

  const scheduleRetry = () => {
    if (retryTimer || !pendingEntry) return
    const ms = nextRetryDelay(retryAttempt++)
    say('ota: no fetcher yet — retrying in ' + Math.round(ms / 1000) + 's')
    retryTimer = setTimeout(() => {
      retryTimer = null
      wire(null).catch((e) => say('ota: ' + ((e && e.message) || e)))
    }, ms)
    // ⚠ A retry must not be the reason a process stays alive.
    if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
  }

  // Build the runtime once we have a release to point it at. Idempotent: later events only check.
  //
  // ⚠ `entry` is null on a RETRY — the pending one is used. A retry is not a new event and must not
  // look like one.
  const wire = async (entry) => {
    if (entry) pendingEntry = entry
    if (started) { clearRetry(); try { await runtime.checkNow() } catch { /* the next event retries */ } ; return }
    if (!activeFetcher) {
      try {
        // ⛔ `storeDir` UNDER THIS APP'S STORAGE. The default derives from the home directory, which
        // Android does not have — measured on a handset as ENOENT, surfacing as "no swarm fetcher".
        // ⛔ NO storeDir WHEN WE ALREADY HOLD THE ENTRY. The watch has that Corestore open and
        // RocksDB's lock is EXCLUSIVE — resolve-release's own header says the registry store "takes
        // an EXCLUSIVE" lock. Opening it a second time fails with "File descriptor could not be
        // locked", which surfaces as an OTA error rather than as the double-open it is. With
        // `latest` supplied there is nothing left to resolve, so there is nothing to open.
        ;({ fetcher: activeFetcher } = await swarmFetcher({
          registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm, latest: entry
        }))
      } catch (err) {
        say('ota: no swarm fetcher (' + ((err && err.message) || err) + ')' + frame(err))
        // ⛔ AND TRY AGAIN. Returning here was the whole defect: a 120-second DHT timeout became a
        // dead OTA client until the next publish.
        scheduleRetry()
        return
      }
    }
    // We have a fetcher; nothing is pending any more.
    clearRetry()
    pendingEntry = null

    runtime = new PghRuntime({
      name: repo,
      currentTreeHash: currentTreeHash || envBundleHash() || null,
      fetcher: activeFetcher,
      store,
      // ⛔ NO INTERVAL. The watch is the signal; a timer beside it would be an unconditional sweep,
      // and hyper-social #68 §2 removed exactly that from delivery because a sweep CONCEALS a dead
      // hook — the update appears to work, so nobody notices nothing fires on the event.
      interval: 0
    })
    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) + frame(e)))
    started = runtime.start()
    await started
  }

  if (injectedFetcher) {
    // A test double: no registry to watch, so wire it straight away and keep the old shape.
    await wire(null)
  } else {
    const { watchLatestRelease } = await import('pgh/resolve-release')
    try {
      watch = await watchLatestRelease({
        registryKeyHex,
        name: repo,
        storeDir: path.join(dir, 'registry'),
        onRelease: (entry) => wire(entry).catch((e) => say('ota: ' + ((e && e.message) || e)))
      })
      say('ota: watching ' + repo + ' — releases arrive as events, not on a timer')
    } catch (err) {
      say('ota: cannot watch the registry (' + ((err && err.message) || err) + ')' + frame(err))
      return null
    }
  }
  // ⛔ `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.
  return {
    stop: async () => {
      // ⛔ THE WATCH TOO. It holds a Hyperswarm and a Corestore, and a swarm left running is a DHT
      // node kept awake by something the user switched off — the leak shape that reached ~205k
      // sockets on stortron because the thing that created a handle was not the thing that owned it.
      // ⛔ THE RETRY TIMER FIRST. It calls back into wire(), which builds a swarm fetcher — a stop
      // that left it armed would create the very handle the rest of this teardown is closing.
      clearRetry()
      try { if (watch) await watch.close() } catch { /* stopping must not throw on the way out */ }
      try { if (runtime && runtime.stop) await runtime.stop() } catch { /* same */ }
      if (ownsFetcher && activeFetcher && typeof activeFetcher.close === 'function') {
        try { await activeFetcher.close() } catch { /* same */ }
      }
    },
    store,
    // ⚠ Before the first release event there is no runtime, and that is not an error — it means the
    // registry has not answered yet. Saying so beats throwing at whoever pressed the button.
    checkNow: async () => {
      if (!runtime) { say('ota: no release seen yet — still waiting on the registry'); return null }
      return runtime.checkNow()
    }
  }
}