| … | … | @@ -45,6 +45,13 @@ export async function planBoot ({ storage, io }) { |
| 45 | 45 | let bundleHash = null |
| 46 | 46 | let addonsDir = null |
| 47 | 47 | let reason = 'apk bundle' |
| 48 | // ⛔ WHAT WAS STAGED AND NOT RUN, AND WHY. Observed on a handset: "an update is ready, restart to | |
| 49 | // apply", for ever, while restarting changed nothing. The page had re-implemented the decision — | |
| 50 | // `if (running) return staged !== running` — with no build-time comparison and no sight of | |
| 51 | // failed.json, so it said YES on precisely the bundles chooseBundle refuses. Two rules for one | |
| 52 | // question, and the visible one was wrong. The shell already RAN chooseBundle; it reports the answer | |
| 53 | // rather than leaving the page to guess it. | |
| 54 | let refused = null | |
| 48 | 55 | |
| 49 | 56 | try { |
| 50 | 57 | const failed = (await io.readJson(bdir + '/failed.json')) || [] |
| … | … | @@ -73,6 +80,13 @@ export async function planBoot ({ storage, io }) { |
| 73 | 80 | io.log('[worklet] REFUSED the staged bundle: ' + choice.reason) |
| 74 | 81 | } |
| 75 | 82 | |
| 83 | if (choice.use === 'asset' && choice.hash === null) { | |
| 84 | // Something IS staged but was not used: name it and say why, so the page can stop offering a | |
| 85 | // restart that cannot help and show the reason instead. | |
| 86 | const cur = await io.readJson(bdir + '/current.json') | |
| 87 | if (cur && cur.treeHash) refused = { hash: cur.treeHash, reason: choice.reason } | |
| 88 | } | |
| 89 | ||
| 76 | 90 | if (choice.use === 'staged' && await io.exists(bdir + '/' + choice.file)) { |
| 77 | 91 | // The watchdog arms itself here, before the risky boot and never after it. |
| 78 | 92 | await io.writeText(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash })) |
| … | … | @@ -115,11 +129,11 @@ export async function planBoot ({ storage, io }) { |
| 115 | 129 | const dns = await io.dnsServers() |
| 116 | 130 | try { |
| 117 | 131 | await io.writeText(storage + '/' + SHELL_FILE, encodeShell({ |
| 118 | ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [] | |
| 132 | ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused | |
| 119 | 133 | })) |
| 120 | 134 | } catch (e) { |
| 121 | 135 | io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message) |
| 122 | 136 | } |
| 123 | 137 | |
| 124 | return { entryPath, startArgs, bundleHash, addonsDir, reason } | |
| 138 | return { entryPath, startArgs, bundleHash, addonsDir, reason, refused } | |
| 125 | 139 | } |
| … | … | @@ -68,18 +68,27 @@ export function parseShell (text) { |
| 68 | 68 | // The sonames THIS APK ships, so the OTA store can skip downloading addons that are already on the |
| 69 | 69 | // device. Only the shell can answer it — jniLibs are mapped out of the APK, never unpacked — and an |
| 70 | 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')) : [] | |
| 71 | apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [], | |
| 72 | // ⛔ THE SHELL'S OWN VERDICT on a staged bundle it declined to run, and the reason. The page must | |
| 73 | // never re-derive this: it did, disagreed with chooseBundle, and produced a restart button that | |
| 74 | // could not work and would not go away. | |
| 75 | refused: raw.refused && isHash(raw.refused.hash) | |
| 76 | ? { hash: String(raw.refused.hash), reason: String(raw.refused.reason || 'refused') } | |
| 77 | : null | |
| 72 | 78 | } |
| 73 | 79 | } |
| 74 | 80 | |
| 75 | 81 | /** The shell's side of the same shape. Written before every worklet start, never appended to. */ |
| 76 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [] } = {}) { | |
| 82 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) { | |
| 77 | 83 | return JSON.stringify({ |
| 78 | 84 | ota: ota === true, |
| 79 | 85 | pair: pair === true, |
| 80 | 86 | bundle: isHash(bundle) ? String(bundle) : null, |
| 81 | 87 | dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [], |
| 82 | apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [] | |
| 88 | apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [], | |
| 89 | refused: refused && isHash(refused.hash) | |
| 90 | ? { hash: String(refused.hash), reason: String(refused.reason || 'refused') } | |
| 91 | : null | |
| 83 | 92 | }) |
| 84 | 93 | } |
| 85 | 94 | |
| … | … | @@ -99,6 +108,8 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } = |
| 99 | 108 | pair: s.pair === true, |
| 100 | 109 | running: isHash(s.bundle) ? String(s.bundle) : null, |
| 101 | 110 | staged: isHash(staged) ? String(staged) : null, |
| 111 | // Passed straight through from the shell. The page renders THIS instead of deciding for itself. | |
| 112 | refused: s.refused || null, | |
| 102 | 113 | checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null |
| 103 | 114 | } |
| 104 | 115 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | 1 | // GENERATED by scripts/gen-version.mjs — do not edit. |
| 2 | export const VERSION = "0.0.21" | |
| 3 | export const BUILT_AT = 1789089403887 | |
| 2 | export const VERSION = "0.0.22" | |
| 3 | export const BUILT_AT = 1789090294391 | |
| 4 | 4 | export default { VERSION, BUILT_AT } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.21", | |
| 3 | "version": "0.0.22", | |
| 4 | 4 | "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |
| … | … | @@ -139,3 +139,65 @@ test('an APK with no addon list still boots, and claims nothing', async (t) => { |
| 139 | 139 | await planBoot({ storage: STORAGE, io: d.io }) |
| 140 | 140 | t.alike(JSON.parse(d.written[STORAGE + '/shell.json']).apkAddons, [], 'empty, which the store reads as skip-nothing') |
| 141 | 141 | }) |
| 142 | ||
| 143 | // ── the shell reports its own verdict ───────────────────────────────────────────────────────────── | |
| 144 | // | |
| 145 | // ⛔ THE BUG THIS FIXES, OBSERVED ON A HANDSET: "an update is ready, restart to apply" shown for ever | |
| 146 | // while restarting changed nothing. favela-surface's updateWaiting() re-implemented the decision — | |
| 147 | // `if (u.running) return u.staged !== u.running` — with no build-time comparison and no knowledge of | |
| 148 | // failed.json, so it said YES on exactly the bundles chooseBundle refuses. Two rules for one question, | |
| 149 | // and the one the user could see was the wrong one. | |
| 150 | // | |
| 151 | // The shell already RUNS chooseBundle at boot. It knows the verdict and the reason, so it reports them | |
| 152 | // rather than leaving the page to guess. Report state, never a derived verdict. | |
| 153 | ||
| 154 | test('a staged bundle that WILL boot is reported as applicable', async (t) => { | |
| 155 | const staged = { treeHash: 'a'.repeat(64), file: 'x.bundle', builtAt: 9000 } | |
| 156 | const d = device({ | |
| 157 | assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 158 | files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/x.bundle']: 'S' } | |
| 159 | }) | |
| 160 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 161 | const shell = JSON.parse(d.written[STORAGE + '/shell.json']) | |
| 162 | t.is(shell.bundle, 'a'.repeat(64), 'it booted') | |
| 163 | t.is(shell.refused, null, 'nothing refused') | |
| 164 | }) | |
| 165 | ||
| 166 | test('a staged bundle the APK supersedes is reported REFUSED, with the reason', async (t) => { | |
| 167 | // The 0.0.17-staged / 0.0.18-APK case exactly: legitimately un-appliable, for ever. | |
| 168 | const staged = { treeHash: 'b'.repeat(64), file: 'y.bundle', builtAt: 1000 } | |
| 169 | const d = device({ | |
| 170 | assets: { ...BASE, 'worklet.bundle.built': '9999' }, | |
| 171 | sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 172 | files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/y.bundle']: 'S' } | |
| 173 | }) | |
| 174 | const plan = await planBoot({ storage: STORAGE, io: d.io }) | |
| 175 | const shell = JSON.parse(d.written[STORAGE + '/shell.json']) | |
| 176 | t.is(shell.bundle, null, 'the APK bundle booted') | |
| 177 | t.is(shell.refused.hash, 'b'.repeat(64), 'and the staged one is named as refused') | |
| 178 | t.ok(shell.refused.reason.includes('more recently'), 'with the reason: ' + shell.refused.reason) | |
| 179 | }) | |
| 180 | ||
| 181 | test('a CONDEMNED staged bundle is reported refused too', async (t) => { | |
| 182 | // Nothing the page could ever have known: failed.json lives on the shell side. | |
| 183 | const staged = { treeHash: 'c'.repeat(64), file: 'z.bundle', builtAt: 9000 } | |
| 184 | const d = device({ | |
| 185 | assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 }, | |
| 186 | files: { | |
| 187 | [BDIR + '/current.json']: JSON.stringify(staged), | |
| 188 | [BDIR + '/failed.json']: JSON.stringify(['c'.repeat(64)]), | |
| 189 | [BDIR + '/z.bundle']: 'S' | |
| 190 | } | |
| 191 | }) | |
| 192 | await planBoot({ storage: STORAGE, io: d.io }) | |
| 193 | const shell = JSON.parse(d.written[STORAGE + '/shell.json']) | |
| 194 | t.is(shell.bundle, null) | |
| 195 | t.is(shell.refused.hash, 'c'.repeat(64)) | |
| 196 | t.ok(shell.refused.reason.includes('failed to boot'), shell.refused.reason) | |
| 197 | }) | |
| 198 | ||
| 199 | test('nothing staged means nothing refused', async (t) => { | |
| 200 | const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } }) | |
| 201 | await planBoot({ storage: STORAGE, io: d.io }) | |
| 202 | t.is(JSON.parse(d.written[STORAGE + '/shell.json']).refused, null) | |
| 203 | }) |