OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 8ce6f78

wire the loader in: it is the APK's entry now, and staging feeds it
worklet.js starts loader.bundle instead of the chosen bundle directly, passing --bundle= and --addons= as TAGGED arguments with storage still LAST. Last matters: lib/node.js reads Bare.argv[Bare.argv.length - 1] and so does every bundle staged before the loader existed, so a device already holding one keeps working untouched. The addons directory comes from chooseBundle, which owns that name because it is the one module both sides import and they share no filesystem API. ⚠ Every failure here is caught and falls back to the old shape, deliberately. Starting the bundle directly is exactly what this app did before the loader: a bundle whose addons the APK already satisfies — every release published so far — runs identically, and one that NEEDS staged addons fails at dlopen, never reaches ready, and is condemned by the watchdog on the next boot. That is the correct outcome rather than a silent downgrade. An APK built before pack:loader existed has no such asset and takes this path. ⛔ stage-addons.mjs now unions BOTH bundles' demands. The APK's entry is the loader, so its own addons must be in the APK or the loader cannot run — and a loader that cannot run takes the app with it on a device with no adb. Staging only the worklet's demands would have excluded any addon the loader needs and the worklet does not, at packaging time, silently, via jniLibs.excludes. Measured rather than assumed: loader.bundle is 230,244 bytes and demands libbare-fs.4.8.1, libbare-path.3.1.2, libbare-url.2.5.4 — all three already among the worklet bundle's 58, so the union is 58 and the wiring costs zero APK bytes today. It is insurance against drift: the day the worklet stops importing bare-url, the loader still needs it. stage:addons run for real reports "bundles demand 58 addon(s) (worklet 58, loader 3)", 58/58 staged for arm64. The loader bundle is gitignored like the worklet bundle — both are build outputs, and pack:loader now runs in prebuild:android BEFORE stage:addons, which reads it. Suite 68/68, 245/245; all three touched files pass node --check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8ce6f780fa2e47e05b7e2f5a238bd0af56bf266c
by plankatron <fleeky@gmail.com> · 2026-09-11T00:29:46.000Z

Changed files (4) +89 -9

.gitignore +2 -0
……@@ -4,6 +4,8 @@ android/build/
44android/.gradle/
55android/app/.cxx/
66android/app/src/main/assets/worklet.bundle
7# The APK's entry bundle, built by `npm run pack:loader` — an artifact like the worklet bundle above.
8android/app/src/main/assets/loader.bundle
79*.apk
810*.keystore
911android/app/src/main/assets/worklet.bundle.size
lib/host/worklet.js +42 -1
……@@ -128,6 +128,7 @@ async function boot () {
128128 // APK's own bundle, which is the one known to work because it shipped with this binary.
129129 let bundlePath = dest
130130 let bundleHashChosen = null
131 let stagedAddonsDir = null
131132 try {
132133 const bdir = RNFS.DocumentDirectoryPath + '/bundles'
133134 const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } }
……@@ -152,6 +153,9 @@ async function boot () {
152153 await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8')
153154 bundlePath = bdir + '/' + choice.file
154155 bundleHashChosen = choice.hash
156 // chooseBundle owns this name — it is the one module both sides import, and the store that writes
157 // the directory lives on the other side of a bridge with no shared filesystem API.
158 if (choice.addons) stagedAddonsDir = bdir + '/' + choice.addons
155159 console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
156160 }
157161 } catch (e) {
……@@ -191,8 +195,45 @@ async function boot () {
191195 console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
192196 }
193197
198 // ── THE LOADER ────────────────────────────────────────────────────────────────────────────────────
199 // A `linked:` bundle NAMES the sonames the APK must supply, so a staged bundle demanding one this APK
200 // lacks could only ever die at dlopen. lib/loader.js registers OTA-staged addons in Bare's Addon.cache
201 // under exactly those names — a cache hit short-circuits dlopen-by-soname — and then loads the chosen
202 // bundle. Anything not staged still falls through to the APK, so staging is additive.
203 //
204 // ⛔ THE LOADER CANNOT BE PART OF THE STAGED BUNDLE. Bare loads whatever file is passed here as its own
205 // entry, so there is no "before" inside it; and preload code inside the staged bundle would import
206 // bare-fs as `linked:libbare-fs.<staged version>.so`, the very soname that might be missing.
207 //
208 // ⚠ FALLING BACK TO THE OLD SHAPE IS SAFE, and is why every failure below is caught. Starting the
209 // bundle directly is exactly what this app did before the loader existed: a bundle whose addons the
210 // APK already satisfies — every release published so far — runs identically. And one that NEEDS staged
211 // addons fails at dlopen, never reaches ready, and is condemned by the watchdog on the next boot,
212 // which is the correct outcome rather than a silent downgrade.
213 let entryPath = bundlePath
214 let startArgs = [storage]
215 try {
216 const loaderDest = RNFS.DocumentDirectoryPath + '/loader.bundle'
217 // Copied natively and unconditionally: it is a few hundred KB, not the 32 MB worklet bundle, so the
218 // size-stamp cache that exists for that one would be more machinery than it saves. Copying every
219 // boot also means an APK upgrade can never leave a stale loader behind.
220 await RNFS.copyFileAssets('loader.bundle', loaderDest)
221 // ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle
222 // staged before the loader existed — which is what makes this safe to ship to a device already
223 // holding one. The loader reads its own inputs by TAG, so it does not care where they sit.
224 const args = ['--bundle=' + bundlePath]
225 if (bundleHashChosen && stagedAddonsDir) args.push('--addons=' + stagedAddonsDir)
226 args.push(storage)
227 entryPath = loaderDest
228 startArgs = args
229 console.log('[worklet] entry is the loader' + (stagedAddonsDir ? ' with staged addons ' + stagedAddonsDir : ' (no staged addons)'))
230 } catch (e) {
231 // An APK built before pack:loader existed has no such asset. Not fatal — see above.
232 console.log('[worklet] no loader asset, starting the bundle directly: ' + e.message)
233 }
234
194235 // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
195 worklet.start(bundlePath, source, [storage])
236 worklet.start(entryPath, source, startArgs)
196237 console.log('[worklet] start() returned')
197238
198239 // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
package.json +23 -7
……@@ -10,16 +10,32 @@
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1212 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size));fs.writeFileSync(p+'.built',String(Date.now()))\"",
13 "prebuild:android": "npm run gen:version && npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
13 "prebuild:android": "npm run gen:version && npm run pack:worklet && npm run pack:loader && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
1414 "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-surface-android.apk --out apk.tree.json --name favela-surface-android.apk",
15 "gen:version": "node scripts/gen-version.mjs"
15 "gen:version": "node scripts/gen-version.mjs",
16 "pack:loader": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/loader.bundle lib/loader.js"
1617 },
1718 "imports": {
18 "#node:fs": { "bare": "bare-fs", "default": "fs" },
19 "#node:path": { "bare": "bare-path", "default": "path" },
20 "#node:os": { "bare": "bare-os", "default": "os" },
21 "#node:net": { "bare": "bare-net", "default": "net" },
22 "#node:process": { "bare": "bare-process", "default": "process" }
19 "#node:fs": {
20 "bare": "bare-fs",
21 "default": "fs"
22 },
23 "#node:path": {
24 "bare": "bare-path",
25 "default": "path"
26 },
27 "#node:os": {
28 "bare": "bare-os",
29 "default": "os"
30 },
31 "#node:net": {
32 "bare": "bare-net",
33 "default": "net"
34 },
35 "#node:process": {
36 "bare": "bare-process",
37 "default": "process"
38 }
2339 },
2440 "dependencies": {
2541 "anproto": "file:../anproto",
scripts/stage-addons.mjs +22 -1
……@@ -73,8 +73,29 @@ if (!fs.existsSync(bundlePath)) {
7373 process.exit(1)
7474}
7575
76// ⛔ BOTH BUNDLES. The APK's entry is loader.bundle, not the worklet bundle — it preloads OTA-staged
77// addons and then loads whichever bundle chooseBundle picked. Its OWN addons (bare-fs, bare-path,
78// bare-url) must therefore be in the APK or the loader itself cannot run, and a loader that cannot run
79// takes the app with it on a device with no adb. Staging only the worklet's demands would exclude any
80// addon the loader needs and the worklet does not, at packaging time, silently.
81//
82// A union rather than two passes: the keep-list below is a single per-ABI set, and an addon demanded by
83// either bundle is an addon this APK must ship.
84const loaderBundlePath = path.join(root, 'android/app/src/main/assets/loader.bundle')
7685const want = demandedAddons(bundlePath)
77console.log(`bundle demands ${want.size} addon(s)`)
86const workletCount = want.size
87let loaderCount = 0
88if (fs.existsSync(loaderBundlePath)) {
89 const loaderWant = demandedAddons(loaderBundlePath)
90 loaderCount = loaderWant.size
91 for (const a of loaderWant) want.add(a)
92} else {
93 // Not fatal: an APK built before the loader existed still boots the worklet bundle directly, which is
94 // exactly the fallback worklet.js keeps. Loud, because silently shipping without it would mean OTA
95 // native updates quietly not working.
96 console.log('no loader.bundle — building without the addon preloader (run `npm run pack:loader`)')
97}
98console.log(`bundles demand ${want.size} addon(s) (worklet ${workletCount}, loader ${loaderCount})`)
7899
79100for await (const _ of link(root, { hosts, out })) { /* bare-link stages the superset */ }
80101