// Which bundle this boot runs — the staged one, or the APK's.
//
// ⛔ ITS OWN MODULE, WITH NO IMPORTS, because it is needed on BOTH sides of the bridge and they share no
// filesystem API. The decision is made in React Native (RNFS) before the worklet starts; the staging that
// feeds it happens inside the worklet (bare-fs). lib/ota.js imports bare-fs at module top level, so
// importing it from the RN side would break the app on load — a mistake worth naming, because the split
// is not tidiness, it is the only reason this can be called where it has to be called.
//
// Pure and dependency-free, like lib/ipc-client.js and lib/lifecycle.js, and testable without a handset —
// which is the only place the consequences of getting this wrong actually show up.
/**
* @param {{current: ?{treeHash: string, file: string, builtAt: ?number}, pending: ?{treeHash: string},
* failed: string[], assetBuiltAt: ?number}} state
* @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}}
*
* ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked.
* There is no adb on a user's handset and no UI left if the node cannot boot, so the only safe shape is a
* watchdog: record the intent BEFORE starting, clear it once the node reports ready, and treat a marker
* that survived a boot as proof that bundle does not work.
*
* The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying
* identically each time, with no way in from outside.
*/
export function chooseBundle ({ current = null, pending = null, failed = [], assetBuiltAt = null, apkAddons = null } = {}) {
// ⛔ `addons: null` ON EVERY ASSET PATH. Booting the APK's own bundle must not preload addons staged
// for a DIFFERENT one — their sonames carry versions, so a staged libbare-fs.4.9.0.so registered
// while the APK's 4.8.1 bundle runs would shadow a library this binary actually ships.
const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, addons: null, markPending: false, condemn, reason })
// A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it
// names — which is not necessarily what is staged now, so it is read from the marker, not from current.
const condemn = pending && pending.treeHash ? pending.treeHash : null
const dead = new Set([...(Array.isArray(failed) ? failed : []), ...(condemn ? [condemn] : [])])
if (!current || !current.treeHash || !current.file) return asset('nothing staged', condemn)
// A newer bundle still gets its chance: refusing every staged bundle after one bad build would make the
// phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one.
if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn)
// ⛔ AN APK INSTALL MUST SUPERSEDE AN OLDER STAGED BUNDLE. Staged used to win unconditionally, and tree
// hashes carry no ordering — so installing a newer APK left the phone running the JS staged before it,
// until the next OTA pass happened to catch up. Observed twice on a handset while shipping today: the
// new shell booted, the new bundle sat in assets, and the old staged one ran anyway.
//
// Build times are the only comparable thing the two sides share: the CI stamps the APK's bundle
// (`worklet.bundle.built`) and lib/ota.js records the release's build time in the staged marker. Both
// absent means an older shell or an older staged marker, and the historic behaviour (staged wins) is
// kept — never guess an ordering out of nothing.
const staged = Number(current.builtAt) || 0
const shipped = Number(assetBuiltAt) || 0
if (shipped && staged && shipped > staged) {
return asset('the app was updated more recently than the staged bundle', condemn)
}
// ⛔ AND AN UNDATED STAGED BUNDLE LOSES TO A STAMPED APK TOO.
//
// Requiring BOTH times made a whole class of staged bundles permanently unbeatable: the HTTPS fetcher's
// getLatestRelease returned { name, version, treeHash } and no builtAt, so every bundle it ever staged
// has a marker with no build time. `staged` is 0, the clause above cannot fire, and the phone keeps
// running that JS through every future APK install.
//
// It is not hypothetical and the symptom is the worst kind — the install succeeds, the version looks
// right, and the code does not run. Four APKs were installed on a handset on 2026-08-25, each carrying
// fixes, and none took effect: a bundle staged from 0.1.6 won every boot. Most of an evening went into
// diagnosing "my changes are not on the phone".
//
// An APK install is a PHYSICAL, DELIBERATE act by someone holding the device. A staged bundle that
// cannot say when it was built cannot be shown to be newer than that, so the tie goes to the APK.
// A staged bundle that CAN say, and is newer, still wins — OTA has to keep working, and that is the
// case immediately above.
if (shipped && !staged) {
return asset('the staged bundle carries no build time; an installed APK is the more recent evidence', condemn)
}
// ⛔ THE ADDON GUARD. A `linked:` bundle NAMES the sonames it needs, and two sources can supply one:
// the APK's jniLibs, or the addons staged beside the bundle. A demand in NEITHER means a dlopen
// failure, a node that never reaches ready, and this tree hash condemned for good — an update
// silently burned on a device with no adb and no way to say why. Refusing turns that into "this
// update needs a newer app", which is a sentence someone can act on.
//
// ⛔ REFUSED IS NOT CONDEMNED. Nothing was tried, so nothing failed: the bundle is left alone and a
// later APK carrying the missing library can still run it. Condemning here would destroy a perfectly
// good update because the app was briefly too old, which is the opposite of the intent.
//
// ⛔ AND THE GUARD ONLY FIRES ON KNOWLEDGE. `demands` is absent from any marker written before the
// store recorded it, and `apkAddons` is absent in any shell that does not ship its own list. Either
// way the question cannot be answered — and refusing on missing information would strand every device
// already holding a staged bundle. Unknown means proceed, exactly as before this existed. An empty
// apkAddons is treated as unknown for the same reason: no APK demands nothing, so an empty list is a
// failed read, not a fact.
const demands = Array.isArray(current.demands) ? current.demands : null
const apk = Array.isArray(apkAddons) && apkAddons.length > 0 ? apkAddons : null
if (demands && apk) {
const have = new Set(apk)
for (const a of (Array.isArray(current.addons) ? current.addons : [])) have.add(a)
const missing = demands.filter((d) => !have.has(d))
if (missing.length > 0) {
return asset('the staged bundle needs ' + missing.join(', ') + ' — in neither the APK nor the release', condemn)
}
}
return {
use: 'staged',
hash: current.treeHash,
file: current.file,
// Where bundleStore put this release's native addons, or null when it carries none.
//
// ⛔ THE NAME LIVES HERE because both sides need it and they share no filesystem API: the store that
// writes the directory runs inside the worklet (bare-fs), and the side that must hand the loader its
// path runs in React Native (RNFS). Spelling the convention out twice across that bridge is the
// failure this module exists to prevent.
//
// Null and never '' — a loader has to tell "this release has no addons" (every release published so
// far, and a marker written before addons existed) from "the addons did not arrive".
addons: Array.isArray(current.addons) && current.addons.length > 0 ? current.file + '.addons' : null,
markPending: true,
condemn,
reason: 'staged bundle available'
}
}