| … | … | @@ -0,0 +1,125 @@ |
| 1 | // What the shell decides before it starts the runtime: which bundle runs, with which addons, and what | |
| 2 | // the node is told. | |
| 3 | // | |
| 4 | // ⛔ EXTRACTED SO IT CAN BE TESTED. This is the code path that decides what EXECUTES on a device with no | |
| 5 | // adb and no UI left if the node cannot boot — and it was the one file with no coverage, because | |
| 6 | // worklet.js imports react-native-bare-kit and react-native-fs at module scope and cannot load under | |
| 7 | // bare. Every filesystem touch is injected, so the whole decision tree runs in the suite: the watchdog | |
| 8 | // arming before a risky boot, a condemnation recorded before anything starts, the loader falling back, | |
| 9 | // and a refusal being said out loud. | |
| 10 | // | |
| 11 | // worklet.js keeps only the RN glue — RNFS calls and `worklet.start` — and hands the plan straight to | |
| 12 | // bare-kit. | |
| 13 | import { chooseBundle } from './choose-bundle.js' | |
| 14 | import { encodeShell, SHELL_FILE } from './shell-handoff.js' | |
| 15 | ||
| 16 | /** | |
| 17 | * @param {object} opts | |
| 18 | * @param {string} opts.storage the app-private directory | |
| 19 | * @param {object} opts.io injected filesystem + logging | |
| 20 | * @returns {Promise<{entryPath, startArgs, bundleHash, addonsDir, reason}>} | |
| 21 | */ | |
| 22 | export async function planBoot ({ storage, io }) { | |
| 23 | const bdir = storage + '/bundles' | |
| 24 | const dest = storage + '/worklet.bundle' | |
| 25 | ||
| 26 | // ⛔ THE BUNDLE MUST NOT PASS THROUGH THE JS HEAP — see worklet.js. The size stamp is written by | |
| 27 | // prebuild, so a change is known at build time and nothing hashes 32 MB on a phone at every launch. | |
| 28 | const wantSize = Number((await io.readAsset('worklet.bundle.size')).trim()) | |
| 29 | let assetBuiltAt = null | |
| 30 | try { assetBuiltAt = Number((await io.readAsset('worklet.bundle.built')).trim()) || null } catch {} | |
| 31 | let haveSize = -1 | |
| 32 | try { haveSize = await io.size(dest) } catch { /* first run */ } | |
| 33 | if (haveSize !== wantSize) { | |
| 34 | try { await io.unlink(dest) } catch {} | |
| 35 | await io.copyAsset('worklet.bundle', dest) | |
| 36 | } | |
| 37 | ||
| 38 | // The sonames THIS APK ships. Nothing at runtime can enumerate jniLibs — they are mapped out of the | |
| 39 | // APK, never unpacked — so the list is written at build time by scripts/stage-addons.mjs. Absent on an | |
| 40 | // older APK, which chooseBundle reads as "not known" and proceeds on. | |
| 41 | let apkAddons = null | |
| 42 | try { apkAddons = JSON.parse(await io.readAsset('apk-addons.json')) } catch {} | |
| 43 | ||
| 44 | let bundlePath = dest | |
| 45 | let bundleHash = null | |
| 46 | let addonsDir = null | |
| 47 | let reason = 'apk bundle' | |
| 48 | ||
| 49 | try { | |
| 50 | const failed = (await io.readJson(bdir + '/failed.json')) || [] | |
| 51 | const choice = chooseBundle({ | |
| 52 | current: await io.readJson(bdir + '/current.json'), | |
| 53 | pending: await io.readJson(bdir + '/pending.json'), | |
| 54 | failed, | |
| 55 | assetBuiltAt, | |
| 56 | apkAddons | |
| 57 | }) | |
| 58 | reason = choice.reason | |
| 59 | ||
| 60 | if (choice.condemn) { | |
| 61 | // ⛔ BEFORE ANYTHING STARTS. Last boot began this bundle and never reached ready; if it were not | |
| 62 | // recorded now, every boot would pick it, die the same way, and the phone would be unrecoverable | |
| 63 | // from outside. | |
| 64 | io.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned') | |
| 65 | if (!failed.includes(choice.condemn)) failed.push(choice.condemn) | |
| 66 | await io.writeText(bdir + '/failed.json', JSON.stringify(failed)) | |
| 67 | try { await io.unlink(bdir + '/pending.json') } catch {} | |
| 68 | } | |
| 69 | ||
| 70 | // ⛔ SAY A REFUSAL OUT LOUD. Otherwise it is invisible: the app keeps running the old bundle, which | |
| 71 | // is exactly what a working install looks like. | |
| 72 | if (choice.use === 'asset' && choice.reason.includes('needs ')) { | |
| 73 | io.log('[worklet] REFUSED the staged bundle: ' + choice.reason) | |
| 74 | } | |
| 75 | ||
| 76 | if (choice.use === 'staged' && await io.exists(bdir + '/' + choice.file)) { | |
| 77 | // The watchdog arms itself here, before the risky boot and never after it. | |
| 78 | await io.writeText(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash })) | |
| 79 | bundlePath = bdir + '/' + choice.file | |
| 80 | bundleHash = choice.hash | |
| 81 | if (choice.addons) addonsDir = bdir + '/' + choice.addons | |
| 82 | io.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')') | |
| 83 | } | |
| 84 | } catch (e) { | |
| 85 | // A broken OTA directory must never stop the app starting: the APK's bundle is always there. | |
| 86 | io.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message) | |
| 87 | bundlePath = dest | |
| 88 | bundleHash = null | |
| 89 | addonsDir = null | |
| 90 | } | |
| 91 | ||
| 92 | // ── THE ENTRY ───────────────────────────────────────────────────────────────────────────────────── | |
| 93 | // lib/bootstrap.js inside the bundle already registers staged addons and can find them unaided, so | |
| 94 | // this loader is belt and braces: it is the only thing that can help when bare-fs, bare-path or | |
| 95 | // bare-url THEMSELVES changed, which the in-bundle bootstrap cannot bootstrap around. | |
| 96 | // | |
| 97 | // ⚠ FALLING BACK IS SAFE. Starting the bundle directly is what this app did before the loader existed. | |
| 98 | let entryPath = bundlePath | |
| 99 | let startArgs = [storage] | |
| 100 | try { | |
| 101 | const loaderDest = storage + '/loader.bundle' | |
| 102 | await io.copyAsset('loader.bundle', loaderDest) | |
| 103 | const args = ['--bundle=' + bundlePath] | |
| 104 | if (addonsDir) args.push('--addons=' + addonsDir) | |
| 105 | // ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle | |
| 106 | // staged before the loader existed. | |
| 107 | args.push(storage) | |
| 108 | entryPath = loaderDest | |
| 109 | startArgs = args | |
| 110 | } catch (e) { | |
| 111 | io.log('[worklet] no loader asset, starting the bundle directly: ' + e.message) | |
| 112 | } | |
| 113 | ||
| 114 | // What this side knows and the node cannot. Written before every start, never appended to. | |
| 115 | const dns = await io.dnsServers() | |
| 116 | try { | |
| 117 | await io.writeText(storage + '/' + SHELL_FILE, encodeShell({ | |
| 118 | ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [] | |
| 119 | })) | |
| 120 | } catch (e) { | |
| 121 | io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message) | |
| 122 | } | |
| 123 | ||
| 124 | return { entryPath, startArgs, bundleHash, addonsDir, reason } | |
| 125 | } |
| … | … | @@ -251,7 +251,16 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet |
| 251 | 251 | * Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written |
| 252 | 252 | * directly, is the whole of what a bundle needs. |
| 253 | 253 | */ |
| 254 | export function bundleStore ({ dir }) { | |
| 254 | export function bundleStore ({ dir, apkAddons = null }) { | |
| 255 | // ⛔ WHAT THE APK ALREADY PROVIDES IS NOT WORTH DOWNLOADING. Measured on the real bundle: it demands | |
| 256 | // 58 addons and the installed APK carries 28 of them, so fetching the lot costs 39.7 MB where 27.8 MB | |
| 257 | // is needed. A soname the APK provides resolves by dlopen anyway, so the bytes would buy a | |
| 258 | // byte-identical library. | |
| 259 | // | |
| 260 | // ⚠ NULL MEANS SKIP NOTHING. An older shell sends no list, and a soname wrongly assumed present is a | |
| 261 | // dlopen failure on a device with no way back in. Absence of knowledge must cost bandwidth, never | |
| 262 | // correctness. | |
| 263 | const apkHas = Array.isArray(apkAddons) && apkAddons.length > 0 ? new Set(apkAddons) : null | |
| 255 | 264 | const markerPath = (treeHash) => path.join(dir, treeHash + '.json') |
| 256 | 265 | // ⛔ NAMED OFF THE BUNDLE FILE, NOT THE TREE HASH. The staged bundle derives this itself from |
| 257 | 266 | // import.meta.url (host/loader-core.js addonsDirFor), which is what lets the preloader ship as an |
| … | … | @@ -373,6 +382,9 @@ export function bundleStore ({ dir }) { |
| 373 | 382 | const sub = await fetchTree(addonDirEntry.childHash) |
| 374 | 383 | for (const e of (sub || [])) { |
| 375 | 384 | if (!e || e.kind !== KIND_FILE) continue |
| 385 | // Skipped BEFORE fetchContent, which is where the saving is — the old loop downloaded every | |
| 386 | // entry and only then decided what to keep. | |
| 387 | if (apkHas && apkHas.has(e.name)) continue | |
| 376 | 388 | const b = await fetchContent(e.childHash) |
| 377 | 389 | const d = path.join(adir, e.name) |
| 378 | 390 | const pt = d + '.part' |
| … | … | @@ -503,7 +515,7 @@ export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = |
| 503 | 515 | * |
| 504 | 516 | * Never throws: the caller boots a node either way. |
| 505 | 517 | */ |
| 506 | export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) { | |
| 518 | export async function startOta ({ storage, apkAddons = null, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) { | |
| 507 | 519 | let PghRuntime = null |
| 508 | 520 | try { |
| 509 | 521 | ;({ PghRuntime } = await import('pgh-runtime')) |
| … | … | @@ -513,7 +525,7 @@ export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, |
| 513 | 525 | } |
| 514 | 526 | |
| 515 | 527 | const dir = path.join(storage, 'bundles') |
| 516 | const store = bundleStore({ dir }) | |
| 528 | const store = bundleStore({ dir, apkAddons }) | |
| 517 | 529 | store.init() |
| 518 | 530 | |
| 519 | 531 | // THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in |
| … | … | @@ -56,7 +56,7 @@ const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 && |
| 56 | 56 | export function parseShell (text) { |
| 57 | 57 | let raw = null |
| 58 | 58 | try { raw = typeof text === 'string' ? JSON.parse(text) : text } catch { raw = null } |
| 59 | if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [] } | |
| 59 | if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [], apkAddons: [] } | |
| 60 | 60 | return { |
| 61 | 61 | ota: raw.ota === true, |
| 62 | 62 | // A SEPARATE capability from `ota`, deliberately. The page arrives by OTA and this shell by APK, so |
| … | … | @@ -64,17 +64,22 @@ export function parseShell (text) { |
| 64 | 64 | // actually running — a page must never render a control the shell beneath it cannot honour. |
| 65 | 65 | pair: raw.pair === true, |
| 66 | 66 | bundle: isHash(raw.bundle) ? String(raw.bundle) : null, |
| 67 | dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : [] | |
| 67 | dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : [], | |
| 68 | // The sonames THIS APK ships, so the OTA store can skip downloading addons that are already on the | |
| 69 | // device. Only the shell can answer it — jniLibs are mapped out of the APK, never unpacked — and an | |
| 70 | // older shell sends nothing, which the store reads as "skip nothing". | |
| 71 | apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [] | |
| 68 | 72 | } |
| 69 | 73 | } |
| 70 | 74 | |
| 71 | 75 | /** The shell's side of the same shape. Written before every worklet start, never appended to. */ |
| 72 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [] } = {}) { | |
| 76 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [] } = {}) { | |
| 73 | 77 | return JSON.stringify({ |
| 74 | 78 | ota: ota === true, |
| 75 | 79 | pair: pair === true, |
| 76 | 80 | bundle: isHash(bundle) ? String(bundle) : null, |
| 77 | dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [] | |
| 81 | dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [], | |
| 82 | apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [] | |
| 78 | 83 | }) |
| 79 | 84 | } |
| 80 | 85 |
| … | … | @@ -10,9 +10,8 @@ import RNFS from 'react-native-fs' |
| 10 | 10 | import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native' |
| 11 | 11 | import { WorkletClient } from './ipc-client' |
| 12 | 12 | import { createLifecycle } from './lifecycle' |
| 13 | import { chooseBundle } from './choose-bundle' | |
| 13 | import { planBoot } from './boot-plan' | |
| 14 | 14 | import { encodeUtf8, createUtf8Decoder } from './utf8' |
| 15 | import { encodeShell, SHELL_FILE } from './shell-handoff' | |
| 16 | 15 | |
| 17 | 16 | let started = null |
| 18 | 17 | let starting = null |
| … | … | @@ -103,151 +102,38 @@ async function boot () { |
| 103 | 102 | // |
| 104 | 103 | // Copied once and re-copied only when the size stamp changes (written by prebuild): hashing 35 MB on |
| 105 | 104 | // a phone at every launch to detect a change we already know about at build time would be silly. |
| 106 | const t0 = Date.now() | |
| 107 | const dest = RNFS.DocumentDirectoryPath + '/worklet.bundle' | |
| 108 | const wantSize = Number((await RNFS.readFileAssets('worklet.bundle.size', 'utf8')).trim()) | |
| 109 | // When the bundle inside THIS APK was built. chooseBundle compares it with the staged bundle's release | |
| 110 | // time so that installing a newer APK is not silently overridden by JS staged before it. Absent on an | |
| 111 | // APK built before the stamp existed, which chooseBundle reads as "no ordering available". | |
| 112 | let assetBuiltAt = null | |
| 113 | try { assetBuiltAt = Number((await RNFS.readFileAssets('worklet.bundle.built', 'utf8')).trim()) || null } catch {} | |
| 114 | let haveSize = -1 | |
| 115 | try { haveSize = (await RNFS.stat(dest)).size } catch { /* first run */ } | |
| 116 | if (haveSize !== wantSize) { | |
| 117 | try { await RNFS.unlink(dest) } catch {} | |
| 118 | await RNFS.copyFileAssets('worklet.bundle', dest) | |
| 119 | } | |
| 120 | // ── WHICH BUNDLE BOOTS ──────────────────────────────────────────────────────────────────────────── | |
| 121 | // OTA stages new worklet bundles into <storage>/bundles (lib/ota.js, inside the worklet). This side | |
| 122 | // decides whether to RUN one — and it is the decision that can brick a handset, because there is no | |
| 123 | // adb on a user's phone and no UI left if the node cannot boot. | |
| 105 | // ⛔ THE DECISION LIVES IN host/boot-plan.js, WHERE IT CAN BE TESTED. This is the path that decides | |
| 106 | // what EXECUTES on a device with no adb and no UI left if the node cannot boot, and it was the one | |
| 107 | // uncovered file in the repo — uncovered because this module imports react-native-bare-kit and | |
| 108 | // react-native-fs at module scope and so cannot load under bare. Everything below is now RN glue: | |
| 109 | // the filesystem surface planBoot needs, injected. | |
| 124 | 110 | // |
| 125 | // The watchdog: record the attempt BEFORE starting, clear it once the node reports ready, and treat a | |
| 126 | // marker that survived a boot as proof that bundle does not work. chooseBundle is pure and tested | |
| 127 | // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the | |
| 128 | // APK's own bundle, which is the one known to work because it shipped with this binary. | |
| 129 | let bundlePath = dest | |
| 130 | let bundleHashChosen = null | |
| 131 | let stagedAddonsDir = null | |
| 132 | try { | |
| 133 | const bdir = RNFS.DocumentDirectoryPath + '/bundles' | |
| 134 | const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } } | |
| 135 | const failed = (await readJson(bdir + '/failed.json')) || [] | |
| 136 | // What THIS APK's jniLibs can satisfy, written at build time by scripts/stage-addons.mjs. Nothing | |
| 137 | // at runtime can enumerate them — they are mapped out of the APK, never unpacked — so the list has | |
| 138 | // to ship as an asset. Absent (an older APK, or a build where readelf was unavailable) reads as | |
| 139 | // "not known", and chooseBundle then proceeds exactly as it did before the guard existed. | |
| 140 | let apkAddons = null | |
| 141 | try { apkAddons = JSON.parse(await RNFS.readFileAssets('apk-addons.json', 'utf8')) } catch {} | |
| 142 | ||
| 143 | const choice = chooseBundle({ | |
| 144 | current: await readJson(bdir + '/current.json'), | |
| 145 | pending: await readJson(bdir + '/pending.json'), | |
| 146 | failed, | |
| 147 | assetBuiltAt, | |
| 148 | apkAddons | |
| 149 | }) | |
| 150 | ||
| 151 | if (choice.condemn) { | |
| 152 | // Last boot started this bundle and never reached ready. Remember it so it is never chosen again — | |
| 153 | // otherwise every boot picks it, dies the same way, and the phone is unrecoverable from outside. | |
| 154 | console.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned') | |
| 155 | if (!failed.includes(choice.condemn)) failed.push(choice.condemn) | |
| 156 | await RNFS.writeFile(bdir + '/failed.json', JSON.stringify(failed), 'utf8') | |
| 157 | try { await RNFS.unlink(bdir + '/pending.json') } catch {} | |
| 158 | } | |
| 159 | ||
| 160 | // ⛔ SAY IT OUT LOUD. A refused update is invisible otherwise — the app just keeps running the old | |
| 161 | // bundle, which is exactly what a working install looks like. The reason names the missing soname. | |
| 162 | if (choice.use === 'asset' && choice.reason.includes('needs ')) { | |
| 163 | console.log('[worklet] REFUSED the staged bundle: ' + choice.reason) | |
| 164 | } | |
| 165 | ||
| 166 | if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) { | |
| 167 | await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8') | |
| 168 | bundlePath = bdir + '/' + choice.file | |
| 169 | bundleHashChosen = choice.hash | |
| 170 | // chooseBundle owns this name — it is the one module both sides import, and the store that writes | |
| 171 | // the directory lives on the other side of a bridge with no shared filesystem API. | |
| 172 | if (choice.addons) stagedAddonsDir = bdir + '/' + choice.addons | |
| 173 | console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')') | |
| 111 | // ⚠ Keep this a FAITHFUL adapter. A behaviour implemented here rather than there is a behaviour with | |
| 112 | // no test, which is exactly the state this replaced. | |
| 113 | const t0 = Date.now() | |
| 114 | const plan = await planBoot({ | |
| 115 | storage, | |
| 116 | io: { | |
| 117 | readAsset: (name) => RNFS.readFileAssets(name, 'utf8'), | |
| 118 | size: async (path) => (await RNFS.stat(path)).size, | |
| 119 | exists: (path) => RNFS.exists(path), | |
| 120 | readJson: async (path) => { try { return JSON.parse(await RNFS.readFile(path, 'utf8')) } catch { return null } }, | |
| 121 | writeText: (path, text) => RNFS.writeFile(path, text, 'utf8'), | |
| 122 | copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest), | |
| 123 | unlink: (path) => RNFS.unlink(path), | |
| 124 | dnsServers, | |
| 125 | log: (m) => console.log(m) | |
| 174 | 126 | } |
| 175 | } catch (e) { | |
| 176 | // A broken OTA directory must never stop the app starting: the APK's bundle is always there. | |
| 177 | console.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message) | |
| 178 | bundlePath = dest | |
| 179 | } | |
| 180 | ||
| 181 | const source = null | |
| 127 | }) | |
| 128 | const { entryPath, startArgs } = plan | |
| 129 | const bundleHash = plan.bundleHash | |
| 182 | 130 | // The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure |
| 183 | // inside it is silent from here. These lines are the only evidence of what was actually handed over. | |
| 184 | console.log('[worklet] bundle ' + wantSize + ' bytes at ' + dest + | |
| 185 | (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms') | |
| 186 | ||
| 187 | // ── WHAT THIS SIDE KNOWS AND THE NODE CANNOT ───────────────────────────────────────────────────── | |
| 188 | // Three facts, written where the node will look for them (lib/shell-handoff.js explains why a file and | |
| 189 | // not argv — a positional argument would be read as the storage path by any older bundle): | |
| 190 | // | |
| 191 | // bundle WHICH bundle is executing. The OTA store knows what is STAGED; only this side knows what | |
| 192 | // was started, and without it every boot "staged" the bundle it was already running. | |
| 193 | // dns the OS's resolvers for the active network. Android has no /etc/resolv.conf, so without | |
| 194 | // this the node turns DNS TXT handle verification off and most handles render invalid. | |
| 195 | // ota that THIS shell can service the settings page's update buttons. The shell ships in the | |
| 196 | // APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell; | |
| 197 | // the page renders those controls only when this says they will do something. | |
| 198 | // pair the same claim for the pairing screen, which moved out of a floating overlay and into | |
| 199 | // the settings page — separate flag because the two arrived in different releases. | |
| 200 | // | |
| 201 | // Written before every start and never appended to, so it always describes the CURRENT boot. Failure | |
| 202 | // is non-fatal: the node boots with less knowledge, not not at all. | |
| 203 | const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null) | |
| 204 | try { | |
| 205 | const dns = await dnsServers() | |
| 206 | await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, pair: true, bundle: bundleHash, dns }), 'utf8') | |
| 207 | console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length) | |
| 208 | } catch (e) { | |
| 209 | console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message) | |
| 210 | } | |
| 211 | ||
| 212 | // ── THE LOADER ──────────────────────────────────────────────────────────────────────────────────── | |
| 213 | // A `linked:` bundle NAMES the sonames the APK must supply, so a staged bundle demanding one this APK | |
| 214 | // lacks could only ever die at dlopen. lib/loader.js registers OTA-staged addons in Bare's Addon.cache | |
| 215 | // under exactly those names — a cache hit short-circuits dlopen-by-soname — and then loads the chosen | |
| 216 | // bundle. Anything not staged still falls through to the APK, so staging is additive. | |
| 217 | // | |
| 218 | // ⛔ THE LOADER CANNOT BE PART OF THE STAGED BUNDLE. Bare loads whatever file is passed here as its own | |
| 219 | // entry, so there is no "before" inside it; and preload code inside the staged bundle would import | |
| 220 | // bare-fs as `linked:libbare-fs.<staged version>.so`, the very soname that might be missing. | |
| 221 | // | |
| 222 | // ⚠ FALLING BACK TO THE OLD SHAPE IS SAFE, and is why every failure below is caught. Starting the | |
| 223 | // bundle directly is exactly what this app did before the loader existed: a bundle whose addons the | |
| 224 | // APK already satisfies — every release published so far — runs identically. And one that NEEDS staged | |
| 225 | // addons fails at dlopen, never reaches ready, and is condemned by the watchdog on the next boot, | |
| 226 | // which is the correct outcome rather than a silent downgrade. | |
| 227 | let entryPath = bundlePath | |
| 228 | let startArgs = [storage] | |
| 229 | try { | |
| 230 | const loaderDest = RNFS.DocumentDirectoryPath + '/loader.bundle' | |
| 231 | // Copied natively and unconditionally: it is a few hundred KB, not the 32 MB worklet bundle, so the | |
| 232 | // size-stamp cache that exists for that one would be more machinery than it saves. Copying every | |
| 233 | // boot also means an APK upgrade can never leave a stale loader behind. | |
| 234 | await RNFS.copyFileAssets('loader.bundle', loaderDest) | |
| 235 | // ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle | |
| 236 | // staged before the loader existed — which is what makes this safe to ship to a device already | |
| 237 | // holding one. The loader reads its own inputs by TAG, so it does not care where they sit. | |
| 238 | const args = ['--bundle=' + bundlePath] | |
| 239 | if (bundleHashChosen && stagedAddonsDir) args.push('--addons=' + stagedAddonsDir) | |
| 240 | args.push(storage) | |
| 241 | entryPath = loaderDest | |
| 242 | startArgs = args | |
| 243 | console.log('[worklet] entry is the loader' + (stagedAddonsDir ? ' with staged addons ' + stagedAddonsDir : ' (no staged addons)')) | |
| 244 | } catch (e) { | |
| 245 | // An APK built before pack:loader existed has no such asset. Not fatal — see above. | |
| 246 | console.log('[worklet] no loader asset, starting the bundle directly: ' + e.message) | |
| 247 | } | |
| 131 | // inside it is silent from here. This line is the only evidence of what was actually handed over. | |
| 132 | console.log('[worklet] entry=' + entryPath + ' bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + | |
| 133 | ' addons=' + (plan.addonsDir || 'none') + ' in ' + (Date.now() - t0) + 'ms') | |
| 248 | 134 | |
| 249 | 135 | // Path + null source => NativeBareKit.startFile: the runtime reads it, not us. |
| 250 | worklet.start(entryPath, source, startArgs) | |
| 136 | worklet.start(entryPath, null, startArgs) | |
| 251 | 137 | console.log('[worklet] start() returned') |
| 252 | 138 | |
| 253 | 139 | // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node |
| … | … | @@ -288,6 +288,10 @@ export async function boot (storage) { |
| 288 | 288 | // only that nothing happened, which is what several hours of guessing were spent on. |
| 289 | 289 | const ota = await startOta({ |
| 290 | 290 | storage, |
| 291 | // What this APK already ships. Only the shell can answer it — jniLibs are mapped out of the APK, | |
| 292 | // never unpacked — so the store skips downloading addons that are already on the device. An older | |
| 293 | // shell sends nothing and the store then skips nothing, which costs bandwidth, never correctness. | |
| 294 | apkAddons: shell.apkAddons, | |
| 291 | 295 | say: (m) => { |
| 292 | 296 | lastOtaNote = String(m) |
| 293 | 297 | send({ t: 'log', msg: m }) |
| … | … | @@ -0,0 +1,141 @@ |
| 1 | // The shell's boot decision — the code path that decides what EXECUTES on the device. | |
| 2 | // | |
| 3 | // ⛔ THIS WAS THE ONLY UNCOVERED FILE, and it is the one that can brick a handset: there is no adb on a | |
| 4 | // user's phone and no UI left if the node cannot boot. It was uncovered because worklet.js imports | |
| 5 | // react-native-bare-kit and react-native-fs at module scope and cannot load under bare. The decision is | |
| 6 | // now in host/boot-plan.js with every filesystem touch injected, so the whole tree runs here. | |
| 7 | import test from 'brittle' | |
| 8 | import { planBoot } from '../lib/host/boot-plan.js' | |
| 9 | ||
| 10 | const STORAGE = '/data/user/0/app/files' | |
| 11 | const BDIR = STORAGE + '/bundles' | |
| 12 | ||
| 13 | // A fake device: assets from the APK, a filesystem, and a log. | |
| 14 | function device ({ assets = {}, files = {}, sizes = {} } = {}) { | |
| 15 | const written = {} | |
| 16 | const unlinked = [] | |
| 17 | const log = [] | |
| 18 | const io = { | |
| 19 | async readAsset (n) { if (!(n in assets)) throw new Error('no asset ' + n); return assets[n] }, | |
| 20 | async size (p) { if (!(p in sizes)) throw new Error('ENOENT'); return sizes[p] }, | |
| 21 | async exists (p) { return p in files || p in written }, | |
| 22 | async readJson (p) { const v = p in written ? written[p] : files[p]; try { return JSON.parse(v) } catch { return null } }, | |
| 23 | async writeText (p, t) { written[p] = t }, | |
| 24 | async copyAsset (n, d) { if (!(n in assets)) throw new Error('no asset ' + n); written[d] = assets[n] }, | |
| 25 | async unlink (p) { unlinked.push(p) }, | |
| 26 | async dnsServers () { return ['1.1.1.1'] }, | |
| 27 | log: (m) => log.push(m) | |
| 28 | } | |
| 29 | return { io, written, unlinked, log } | |
| 30 | } | |
| 31 | ||
| 32 | const BASE = { | |
| 33 | 'worklet.bundle.size': '100', | |
| 34 | 'worklet.bundle.built': '5000', | |
| 35 | 'worklet.bundle': 'APK-BUNDLE-BYTES', | |
| 36 | 'loader.bundle': 'LOADER-BYTES', | |
| 37 | 'apk-addons.json': JSON.stringify(['libbare-fs.4.8.1.so']) | |
| 38 | } | |
| 39 | ||
| 40 | test('with nothing staged the APK bundle runs, through the loader', async (t) => { | |
| 41 | const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 42 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 43 | t.is(plan.entryPath, STORAGE + '/loader.bundle', 'the loader is the entry') | |
| 44 | t.alike(plan.startArgs, ['--bundle=' + STORAGE + '/worklet.bundle', STORAGE], 'storage LAST, no --addons') | |
| 45 | t.is(plan.bundleHash, null, 'nothing staged') | |
| 46 | }) | |
| 47 | ||
| 48 | test('the watchdog arms BEFORE a staged bundle starts', async (t) => { | |
| 49 | // If pending.json were written after start, a bundle that hangs the runtime would never be condemned. | |
| 50 | const staged = { treeHash: 'a'.repeat(64), file: 'x.bundle', builtAt: 9000, addons: ['libnew.1.0.0.so'], demands: ['libnew.1.0.0.so'] } | |
| 51 | const d = device({ | |
| 52 | assets: BASE, | |
| 53 | sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 54 | files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/x.bundle']: 'STAGED' } | |
| 55 | }) | |
| 56 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 57 | t.is(plan.bundleHash, 'a'.repeat(64), 'the staged bundle was chosen') | |
| 58 | t.alike(JSON.parse(d.written[BDIR + '/pending.json']), { treeHash: 'a'.repeat(64) }, 'pending written') | |
| 59 | t.ok(plan.startArgs.includes('--addons=' + BDIR + '/x.bundle.addons'), 'and its addons passed') | |
| 60 | }) | |
| 61 | ||
| 62 | test('a pending marker that survived a boot is condemned before anything starts', async (t) => { | |
| 63 | const d = device({ | |
| 64 | assets: BASE, | |
| 65 | sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 66 | files: { | |
| 67 | [BDIR + '/pending.json']: JSON.stringify({ treeHash: 'b'.repeat(64) }), | |
| 68 | [BDIR + '/current.json']: JSON.stringify({ treeHash: 'b'.repeat(64), file: 'y.bundle', builtAt: 9000 }), | |
| 69 | [BDIR + '/y.bundle']: 'STAGED' | |
| 70 | } | |
| 71 | }) | |
| 72 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 73 | t.alike(JSON.parse(d.written[BDIR + '/failed.json']), ['b'.repeat(64)], 'recorded as failed') | |
| 74 | t.is(plan.bundleHash, null, 'and it is NOT run again') | |
| 75 | t.ok(d.log.some((m) => m.includes('condemned')), 'and said so') | |
| 76 | }) | |
| 77 | ||
| 78 | test('a bundle demanding a soname nobody has is refused, out loud', async (t) => { | |
| 79 | const staged = { | |
| 80 | treeHash: 'c'.repeat(64), file: 'z.bundle', builtAt: 9000, | |
| 81 | addons: [], demands: ['libbare-fs.4.8.1.so', 'libmissing.9.9.9.so'] | |
| 82 | } | |
| 83 | const d = device({ | |
| 84 | assets: BASE, | |
| 85 | sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 86 | files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/z.bundle']: 'STAGED' } | |
| 87 | }) | |
| 88 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 89 | t.is(plan.bundleHash, null, 'the APK bundle runs instead') | |
| 90 | t.absent(d.written[BDIR + '/pending.json'], 'and the watchdog is NOT armed for a boot we declined') | |
| 91 | t.ok(d.log.some((m) => m.includes('REFUSED') && m.includes('libmissing.9.9.9.so')), 'named it: ' + d.log.join(' | ')) | |
| 92 | }) | |
| 93 | ||
| 94 | test('no loader asset falls back to starting the bundle directly', async (t) => { | |
| 95 | // An APK built before pack:loader existed. The in-bundle bootstrap still registers staged addons, so | |
| 96 | // this is a fallback in shape only. | |
| 97 | const assets = { ...BASE } | |
| 98 | delete assets['loader.bundle'] | |
| 99 | const d = device({ assets, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 100 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 101 | t.is(plan.entryPath, STORAGE + '/worklet.bundle', 'the bundle itself is the entry') | |
| 102 | t.alike(plan.startArgs, [STORAGE], 'and the old argv shape is used') | |
| 103 | t.ok(d.log.some((m) => m.includes('no loader asset'))) | |
| 104 | }) | |
| 105 | ||
| 106 | test('a broken OTA directory never stops the app starting', async (t) => { | |
| 107 | const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 108 | d.io.readJson = async () => { throw new Error('storage is corrupt') } | |
| 109 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 110 | t.is(plan.bundleHash, null, 'falls back to the APK bundle') | |
| 111 | t.ok(d.log.some((m) => m.includes('bundle choice failed'))) | |
| 112 | }) | |
| 113 | ||
| 114 | test('the bundle asset is re-copied only when its size stamp changes', async (t) => { | |
| 115 | const cached = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 116 | await planBoot({ storage: STORAGE, io: cached.io }) | |
| 117 | t.absent(cached.written[STORAGE + '/worklet.bundle'], 'sizes match — not re-copied') | |
| 118 | ||
| 119 | const stale = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 99 } }) | |
| 120 | await planBoot({ storage: STORAGE, io: stale.io }) | |
| 121 | t.is(stale.written[STORAGE + '/worklet.bundle'], 'APK-BUNDLE-BYTES', 'size differs — re-copied') | |
| 122 | t.ok(stale.unlinked.includes(STORAGE + '/worklet.bundle'), 'after unlinking the old one') | |
| 123 | }) | |
| 124 | ||
| 125 | test('the shell handoff carries the APK addon list to the node', async (t) => { | |
| 126 | // How the OTA store learns what it can skip downloading. Only this side can answer it. | |
| 127 | const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 128 | await planBoot({ storage: STORAGE, io: d.io }) | |
| 129 | const shell = JSON.parse(d.written[STORAGE + '/shell.json']) | |
| 130 | t.alike(shell.apkAddons, ['libbare-fs.4.8.1.so']) | |
| 131 | t.is(shell.ota, true) | |
| 132 | t.alike(shell.dns, ['1.1.1.1']) | |
| 133 | }) | |
| 134 | ||
| 135 | test('an APK with no addon list still boots, and claims nothing', async (t) => { | |
| 136 | const assets = { ...BASE } | |
| 137 | delete assets['apk-addons.json'] | |
| 138 | const d = device({ assets, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 139 | await planBoot({ storage: STORAGE, io: d.io }) | |
| 140 | t.alike(JSON.parse(d.written[STORAGE + '/shell.json']).apkAddons, [], 'empty, which the store reads as skip-nothing') | |
| 141 | }) |
| … | … | @@ -503,3 +503,48 @@ test('the seam survives the bundle being renamed by content hash', async (t) => |
| 503 | 503 | t.ok(staged.endsWith(hashOf(other) + '.bundle'), 'named by content hash') |
| 504 | 504 | t.is(addonsDirFor('file://' + staged + '/x.js'), store.addonsFor(treeHash), 'and the pairing still holds') |
| 505 | 505 | }) |
| 506 | ||
| 507 | test('addons the APK already provides are NOT downloaded', async (t) => { | |
| 508 | // ⛔ MEASURED WASTE, NOT A HYPOTHETICAL. The bundle demands 58 addons and the installed APK already | |
| 509 | // carries 28 of them; fetching all 58 costs 39.7 MB where 27.8 MB is needed. The store learns the | |
| 510 | // APK's list through the shell handoff, and a soname the APK provides resolves by dlopen anyway — | |
| 511 | // so downloading it would buy a byte-identical library. | |
| 512 | const dir = tmpdir('skip-apk') | |
| 513 | const { entries, treeHash, sub, subHash } = treeWithAddons(BUNDLE, ADDONS) | |
| 514 | const contents = { [hashOf(BUNDLE)]: BUNDLE } | |
| 515 | for (const [, bytes] of Object.entries(ADDONS)) contents[hashOf(bytes)] = bytes | |
| 516 | ||
| 517 | const fetched = [] | |
| 518 | const fetcher = { | |
| 519 | async getLatestRelease () { return null }, | |
| 520 | async getTree (h) { return ({ entries: { [treeHash]: entries, [subHash]: sub }[h] }) }, | |
| 521 | async getContent (h) { fetched.push(h); return contents[h] } | |
| 522 | } | |
| 523 | ||
| 524 | // the APK already has bare-fs; sodium-native is new in this release | |
| 525 | const store = bundleStore({ dir, apkAddons: ['libbare-fs.4.8.1.so'] }) | |
| 526 | await installTree(store, treeHash, fetcher) | |
| 527 | ||
| 528 | t.absent(fetched.includes(hashOf(ADDONS['libbare-fs.4.8.1.so'])), 'the APK one was never fetched') | |
| 529 | t.ok(fetched.includes(hashOf(ADDONS['libsodium-native.5.1.0.so'])), 'the new one was') | |
| 530 | ||
| 531 | const adir = store.addonsFor(treeHash) | |
| 532 | t.absent(fs.existsSync(path.join(adir, 'libbare-fs.4.8.1.so')), 'and not written') | |
| 533 | t.ok(fs.existsSync(path.join(adir, 'libsodium-native.5.1.0.so')), 'the new one is') | |
| 534 | t.ok(store.hasTree(treeHash), 'and the tree counts as COMPLETE — the marker records what was written') | |
| 535 | }) | |
| 536 | ||
| 537 | test('with no APK list the store fetches everything, as before', async (t) => { | |
| 538 | // An older shell sends no list. Skipping nothing is the only safe default: a soname wrongly assumed | |
| 539 | // present is a dlopen failure with no way back in. | |
| 540 | const dir = tmpdir('skip-none') | |
| 541 | const { entries, treeHash, sub, subHash } = treeWithAddons(BUNDLE, ADDONS) | |
| 542 | const contents = { [hashOf(BUNDLE)]: BUNDLE } | |
| 543 | for (const [, bytes] of Object.entries(ADDONS)) contents[hashOf(bytes)] = bytes | |
| 544 | ||
| 545 | const store = bundleStore({ dir }) | |
| 546 | await installTree(store, treeHash, treeFetcher({ trees: { [treeHash]: entries, [subHash]: sub }, contents })) | |
| 547 | for (const name of Object.keys(ADDONS)) { | |
| 548 | t.ok(fs.existsSync(path.join(store.addonsFor(treeHash), name)), name + ' still staged') | |
| 549 | } | |
| 550 | }) |