OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / boot-plan.js

148 lines · javascript ref:
// 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 }
}