| … | … | @@ -27,7 +27,7 @@ |
| 27 | 27 | import fs from 'bare-fs' |
| 28 | 28 | import path from 'bare-path' |
| 29 | 29 | import crypto from 'hypercore-crypto' |
| 30 | | import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree' |
| 30 | import { computeTreeHash, KIND_FILE, KIND_DIR } from 'hyper-overlay/tree' |
| 31 | 31 | |
| 32 | 32 | const DEFAULT_BASE = 'https://7rnx.net/git' |
| 33 | 33 | // ⛔ THIS REPO'S OWN NAME, and the reason is not tidiness. This file was copied verbatim from |
| … | … | @@ -49,6 +49,8 @@ const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; d |
| 49 | 49 | // How long to let a COLD registry replica settle before reading it. See swarmFetcher. |
| 50 | 50 | const COLD_SETTLE_MS = 30000 |
| 51 | 51 | const BUNDLE_NAME = 'worklet.bundle' |
| 52 | // The directory a release puts its native addons in. CI publishes it beside the bundle. |
| 53 | const ADDONS_NAME = 'addons' |
| 52 | 54 | |
| 53 | 55 | // ⛔ THE STACK, NOT JUST THE MESSAGE. Three separate homedir defaults have now produced the SAME |
| 54 | 56 | // ENOENT on a handset, each masked by the one before it, and each cost a build-and-sideload cycle to |
| … | … | @@ -224,6 +226,7 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet |
| 224 | 226 | */ |
| 225 | 227 | export function bundleStore ({ dir }) { |
| 226 | 228 | const markerPath = (treeHash) => path.join(dir, treeHash + '.json') |
| 229 | const addonsDir = (treeHash) => path.join(dir, treeHash + '.addons') |
| 227 | 230 | // The release currently being installed. pgh-runtime's installTree does not pass the release down to |
| 228 | 231 | // the store, but it emits `updating` with it immediately beforehand — so the caller notes it here and |
| 229 | 232 | // materialize can stamp the staged marker with a comparable build time. Without that the shell has no |
| … | … | @@ -247,7 +250,16 @@ export function bundleStore ({ dir }) { |
| 247 | 250 | hasTree (treeHash) { |
| 248 | 251 | const m = readMarker(treeHash) |
| 249 | 252 | if (!m || !m.file) return false |
| 250 | | try { return fs.statSync(path.join(dir, m.file)).size > 0 } catch { return false } |
| 253 | try { if (fs.statSync(path.join(dir, m.file)).size === 0) return false } catch { return false } |
| 254 | // ⛔ AND EVERY ADDON. installTree SHORT-CIRCUITS on hasTree, so a run that wrote the bundle and |
| 255 | // died before the addons would report complete for ever — and the bundle that then boots demands |
| 256 | // a soname nothing on the device provides. Same "marker before the bytes" bug as above, one level |
| 257 | // up, and the level where it cannot be fixed remotely. |
| 258 | const addons = Array.isArray(m.addons) ? m.addons : [] |
| 259 | for (const name of addons) { |
| 260 | try { if (fs.statSync(path.join(addonsDir(treeHash), name)).size === 0) return false } catch { return false } |
| 261 | } |
| 262 | return true |
| 251 | 263 | }, |
| 252 | 264 | |
| 253 | 265 | /** Where the staged bundle for this tree lives, or null. */ |
| … | … | @@ -256,7 +268,22 @@ export function bundleStore ({ dir }) { |
| 256 | 268 | return m && m.file ? path.join(dir, m.file) : null |
| 257 | 269 | }, |
| 258 | 270 | |
| 259 | | async materialize (rootHash, entries, { fetchContent } = {}) { |
| 271 | /** |
| 272 | * Where this tree's staged addons live, or null when the release carries none. |
| 273 | * |
| 274 | * The loader registers each of these under the `linked:` URL the bundle asks for — Bare's |
| 275 | * `Addon.cache` is keyed by url.href and the `linked:` branch of `Addon.resolve` goes through |
| 276 | * `Addon.load`, so a staged addon wins before any dlopen-by-soname and an unstaged one still falls |
| 277 | * through to the APK. Null, never an empty dir: a loader must be able to tell "no addons in this |
| 278 | * release" from "addons that did not arrive". |
| 279 | */ |
| 280 | addonsFor (treeHash) { |
| 281 | const m = readMarker(treeHash) |
| 282 | if (!m || !Array.isArray(m.addons) || m.addons.length === 0) return null |
| 283 | return addonsDir(treeHash) |
| 284 | }, |
| 285 | |
| 286 | async materialize (rootHash, entries, { fetchContent, fetchTree } = {}) { |
| 260 | 287 | // ⛔ RECORD A BUILD TIME, WHATEVER THE RELEASE CALLS IT. chooseBundle compares this against the |
| 261 | 288 | // APK's stamp, and a staged bundle with no time is one an APK install cannot supersede — the |
| 262 | 289 | // failure that left a handset running 0.1.6's JS through four installs on 2026-08-25. |
| … | … | @@ -291,14 +318,48 @@ export function bundleStore ({ dir }) { |
| 291 | 318 | fs.writeFileSync(part, Buffer.from(bytes)) |
| 292 | 319 | fs.renameSync(part, dest) |
| 293 | 320 | const file = want.childHash + '.bundle' |
| 321 | |
| 322 | // ⛔ THE ADDONS, BEFORE THE MARKER. A `linked:` bundle names the sonames the APK must supply, and |
| 323 | // until now a bundle demanding one the APK lacks could only die at dlopen — on a device with no |
| 324 | // adb and no UI left to say why. Bare's Addon class is the way out: `new Addon(fileURL)` loads |
| 325 | // from an absolute path, so an addon delivered here can be registered under the `linked:` name |
| 326 | // the bundle asks for and win before any dlopen-by-soname. |
| 327 | // |
| 328 | // BY NAME AND BY KIND, for the same reason the bundle is: a release tree's shape is not ours to |
| 329 | // assume. Absent `addons/`, this is exactly the single-file store it has always been — every |
| 330 | // release published so far is { package.json, worklet.bundle } and must keep working untouched. |
| 331 | const addonDirEntry = (entries || []).find((e) => e && e.kind === KIND_DIR && e.name === ADDONS_NAME) |
| 332 | const addons = [] |
| 333 | if (addonDirEntry) { |
| 334 | if (typeof fetchTree !== 'function') { |
| 335 | throw new Error('release carries ' + ADDONS_NAME + '/ but no fetchTree to read it with') |
| 336 | } |
| 337 | const adir = addonsDir(rootHash) |
| 338 | fs.mkdirSync(adir, { recursive: true }) |
| 339 | const sub = await fetchTree(addonDirEntry.childHash) |
| 340 | for (const e of (sub || [])) { |
| 341 | if (!e || e.kind !== KIND_FILE) continue |
| 342 | const b = await fetchContent(e.childHash) |
| 343 | const d = path.join(adir, e.name) |
| 344 | const pt = d + '.part' |
| 345 | fs.writeFileSync(pt, Buffer.from(b)) |
| 346 | // ⛔ THE EXEC BIT IS CARRIED IN THE TREE and it is part of the entry's identity hash, so it |
| 347 | // cannot drift from what was published. Android needs execute on the FILE OBJECT to dlopen |
| 348 | // it out of app storage; a mode that silently defaulted to 0644 would fail at load with an |
| 349 | // error naming the path and not the permission. |
| 350 | if (e.exec === 1) fs.chmodSync(pt, 0o755) |
| 351 | fs.renameSync(pt, d) |
| 352 | addons.push(e.name) |
| 353 | } |
| 354 | } |
| 294 | 355 | // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it |
| 295 | 356 | // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree |
| 296 | 357 | // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up. |
| 297 | | fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n') |
| 358 | fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt, addons }, null, 2) + '\n') |
| 298 | 359 | // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and |
| 299 | 360 | // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the |
| 300 | 361 | // marker, so it never advertises something that is not fully on disk. |
| 301 | | fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n') |
| 362 | fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, addons }, null, 2) + '\n') |
| 302 | 363 | } |
| 303 | 364 | } |
| 304 | 365 | } |