// Register OTA-staged native addons so a bundle can demand a soname the APK does not carry.
//
// THE PROBLEM THIS SOLVES. `bare-pack --preset android` sets `linked: true`, so the worklet bundle does
// not contain native code — it NAMES the libraries the APK must supply (`linked:librocksdb-native.3.17.2.so`).
// That is why a JS-only OTA works and a native change has needed a 154 MB sideload: a staged bundle whose
// addon set the APK cannot satisfy dies at dlopen, on a device with no adb and no UI left to say why.
//
// THE WAY OUT IS BARE'S OWN ADDON CLASS. From bare/src/addon.js:
//
// - `new Addon(url)` accepts `file:` and loads from an ABSOLUTE PATH — no linker search involved.
// - `Addon.cache` is a live object keyed by `url.href`.
// - `Addon.resolve()`'s `linked:` branch returns `Addon.load(resolution).url`, and `Addon.load` checks
// that cache BEFORE constructing anything.
//
// So an addon whose bytes arrived over the swarm can be loaded from app storage and registered under the
// `linked:` URL the bundle will ask for. It then wins, and anything NOT staged still falls through to
// dlopen-by-soname and the APK. Staging is additive, which is what lets a release ship the one addon that
// changed instead of all 58.
//
// ⛔ THIS MUST NOT BE PART OF THE STAGED BUNDLE. worklet.js hands bare-kit a path with a null source, so
// Bare loads the staged bundle as its own entry — anything inside it runs too late, and its own import of
// bare-fs would resolve to `linked:libbare-fs.<staged version>.so`, the very soname that might be missing.
// The loader is its own bundle, packed against the APK's addon set, and loads the real one afterwards.
//
// ⚠ Addon.cache / Addon.load / Addon.resolve are all marked @deprecated upstream while bare-module still
// calls them on every `require.addon`. If they are ever removed, the replacement is the same idea one
// layer down — construct the Addon and hand it to whatever resolves addons then.
// ⛔ ONE ADDON DEPENDENCY, DELIBERATELY. This runs BEFORE the staged addons are registered, so every
// addon it imports must already be satisfiable by the APK on the device. bare-fs is foundational and
// its soname changes only when we bump it; bare-path and bare-url were dropped for string joins and a
// hand-built file: URL precisely to keep this surface at one. The fewer sonames the preloader needs,
// the more releases can carry new native code without a new APK.
import fs from 'bare-fs'
// ⛔ THE FILENAME IS THE WHOLE CONTRACT. Each staged file is named after the soname it satisfies, so the
// directory listing IS the mapping — no manifest to fall out of step with the bytes, and no parsing of
// the bundle header on a phone. CI names them from that header, which is the same list stage-addons.mjs
// already reads to build the APK's jniLibs.
export function addonHref (filename) {
return 'linked:' + filename
}
/**
* Load every staged addon and register it under its linked: URL. Returns how many were registered.
*
* Never throws: this runs on every boot, and the overwhelmingly common case is a release with no addons
* at all. A loader that died on an empty or missing directory would break the path it exists to protect.
*
* @param {object} opts
* @param {?string} opts.dir the staged addon directory (bundleStore.addonsFor), or null for none
* @param {object} [opts.cache] addon cache to populate — defaults to Bare's
* @param {function}[opts.Addon] addon constructor — defaults to Bare's
* @param {function}[opts.say] diagnostic sink
*/
export function preloadAddons ({ dir, cache = Bare.Addon.cache, Addon = Bare.Addon, say = () => {} } = {}) {
if (!dir) return 0
let names
try {
names = fs.readdirSync(dir)
} catch {
// Not an error: a release with no addons has no directory, and bundleStore.addonsFor returns null
// for exactly that case. Reaching here means the dir was removed under us, which the next boot's
// hasTree check will notice properly.
return 0
}
let n = 0
for (const name of names) {
// `.so` and nothing else. A `.part` is an interrupted write — the store renames into place, so a
// leftover one means a run that died, and dlopening a truncated ELF is a crash rather than an error.
if (!name.endsWith('.so')) continue
// ⛔ FIRST REGISTRATION WINS. Two preloaders can run in one process — the APK's loader before the
// bundle starts, and the bundle's own bootstrap once it does — and both read the same directory.
// A second `new Addon(sameUrl)` would dlopen the same file and re-run its init. The cache is keyed
// by href, so this check is exact.
if (cache[addonHref(name)]) continue
const file = dir + '/' + name
try {
// ⛔ ONE addon failing must not take the boot with it. The bundle still cannot run without it, but
// this is not the place that discovers that — the failure surfaces at the require that needs it,
// where the message names the module rather than the loader.
cache[addonHref(name)] = new Addon(new URL('file://' + file))
n++
} catch (err) {
say('preload: ' + name + ' failed to load — ' + ((err && err.message) || err))
}
}
return n
}