// What the shell decides before it starts the runtime: which bundle runs, with which addons, and what
// the node is told.
//
// ⛔ EXTRACTED SO IT CAN BE TESTED. This is the code path that decides what EXECUTES on a device with no
// adb and no UI left if the node cannot boot — and it was the one file with no coverage, because
// worklet.js imports react-native-bare-kit and react-native-fs at module scope and cannot load under
// bare. Every filesystem touch is injected, so the whole decision tree runs in the suite: the watchdog
// arming before a risky boot, a condemnation recorded before anything starts, the loader falling back,
// and a refusal being said out loud.
//
// worklet.js keeps only the RN glue — RNFS calls and `worklet.start` — and hands the plan straight to
// bare-kit.
import { chooseBundle } from './choose-bundle.js'
import { encodeShell, SHELL_FILE } from './shell-handoff.js'
/**
* @param {object} opts
* @param {string} opts.storage the app-private directory
* @param {object} opts.io injected filesystem + logging
* @returns {Promise<{entryPath, startArgs, bundleHash, addonsDir, reason}>}
*/
export async function planBoot ({ storage, io }) {
const bdir = storage + '/bundles'
const dest = storage + '/worklet.bundle'
// ⛔ THE BUNDLE MUST NOT PASS THROUGH THE JS HEAP — see worklet.js. The size stamp is written by
// prebuild, so a change is known at build time and nothing hashes 32 MB on a phone at every launch.
const wantSize = Number((await io.readAsset('worklet.bundle.size')).trim())
let assetBuiltAt = null
try { assetBuiltAt = Number((await io.readAsset('worklet.bundle.built')).trim()) || null } catch {}
let haveSize = -1
try { haveSize = await io.size(dest) } catch { /* first run */ }
if (haveSize !== wantSize) {
try { await io.unlink(dest) } catch {}
await io.copyAsset('worklet.bundle', dest)
}
// The sonames THIS APK ships. Nothing at runtime can enumerate jniLibs — they are mapped out of the
// APK, never unpacked — so the list is written at build time by scripts/stage-addons.mjs. Absent on an
// older APK, which chooseBundle reads as "not known" and proceeds on.
let apkAddons = null
try { apkAddons = JSON.parse(await io.readAsset('apk-addons.json')) } catch {}
let bundlePath = dest
let bundleHash = null
let addonsDir = null
let reason = 'apk bundle'
// ⛔ WHAT WAS STAGED AND NOT RUN, AND WHY. Observed on a handset: "an update is ready, restart to
// apply", for ever, while restarting changed nothing. The page had re-implemented the decision —
// `if (running) return staged !== running` — with no build-time comparison and no sight of
// failed.json, so it said YES on precisely the bundles chooseBundle refuses. Two rules for one
// question, and the visible one was wrong. The shell already RAN chooseBundle; it reports the answer
// rather than leaving the page to guess it.
let refused = null
try {
const failed = (await io.readJson(bdir + '/failed.json')) || []
const choice = chooseBundle({
current: await io.readJson(bdir + '/current.json'),
pending: await io.readJson(bdir + '/pending.json'),
failed,
assetBuiltAt,
apkAddons
})
reason = choice.reason
if (choice.condemn) {
// ⛔ BEFORE ANYTHING STARTS. Last boot began this bundle and never reached ready; if it were not
// recorded now, every boot would pick it, die the same way, and the phone would be unrecoverable
// from outside.
io.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned')
if (!failed.includes(choice.condemn)) failed.push(choice.condemn)
await io.writeText(bdir + '/failed.json', JSON.stringify(failed))
try { await io.unlink(bdir + '/pending.json') } catch {}
}
// ⛔ SAY A REFUSAL OUT LOUD. Otherwise it is invisible: the app keeps running the old bundle, which
// is exactly what a working install looks like.
if (choice.use === 'asset' && choice.reason.includes('needs ')) {
io.log('[worklet] REFUSED the staged bundle: ' + choice.reason)
}
if (choice.use === 'asset' && choice.hash === null) {
// Something IS staged but was not used: name it and say why, so the page can stop offering a
// restart that cannot help and show the reason instead.
const cur = await io.readJson(bdir + '/current.json')
// ⛔ ONLY IF IT WOULD HAVE BEEN AN UPDATE. Seen on the handset: after installing 0.0.22 the page
// said "this update cannot be applied — staged bundle previously failed at boot" while running
// 0.0.22. True, and useless — the condemned bundle was 0.0.21, older than what booted and
// permanently dead. Nobody can act on that, and noise in the update line is how a real notice
// gets ignored. A staged bundle older than the running one is history, not a pending update.
const stagedAt = Number(cur && cur.builtAt) || 0
const bootedAt = Number(assetBuiltAt) || 0
const wouldHaveBeenAnUpdate = !stagedAt || !bootedAt || stagedAt > bootedAt
if (cur && cur.treeHash && wouldHaveBeenAnUpdate) refused = { hash: cur.treeHash, reason: choice.reason }
}
if (choice.use === 'staged' && await io.exists(bdir + '/' + choice.file)) {
// The watchdog arms itself here, before the risky boot and never after it.
await io.writeText(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }))
bundlePath = bdir + '/' + choice.file
bundleHash = choice.hash
if (choice.addons) addonsDir = bdir + '/' + choice.addons
io.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
}
} catch (e) {
// A broken OTA directory must never stop the app starting: the APK's bundle is always there.
io.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message)
bundlePath = dest
bundleHash = null
addonsDir = null
}
// ── THE ENTRY ─────────────────────────────────────────────────────────────────────────────────────
// lib/bootstrap.js inside the bundle already registers staged addons and can find them unaided, so
// this loader is belt and braces: it is the only thing that can help when bare-fs, bare-path or
// bare-url THEMSELVES changed, which the in-bundle bootstrap cannot bootstrap around.
//
// ⚠ FALLING BACK IS SAFE. Starting the bundle directly is what this app did before the loader existed.
let entryPath = bundlePath
let startArgs = [storage]
try {
const loaderDest = storage + '/loader.bundle'
await io.copyAsset('loader.bundle', loaderDest)
const args = ['--bundle=' + bundlePath]
if (addonsDir) args.push('--addons=' + addonsDir)
// ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle
// staged before the loader existed.
args.push(storage)
entryPath = loaderDest
startArgs = args
} catch (e) {
io.log('[worklet] no loader asset, starting the bundle directly: ' + e.message)
}
// What this side knows and the node cannot. Written before every start, never appended to.
const dns = await io.dnsServers()
try {
await io.writeText(storage + '/' + SHELL_FILE, encodeShell({
ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused
}))
} catch (e) {
io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
}
return { entryPath, startArgs, bundleHash, addonsDir, reason, refused }
}