// What the native shell tells the node at boot, and what the node tells the settings page back.
//
// ⛔ WHY A FILE AND NOT argv. The obvious way to hand the running bundle's hash to the worklet is a
// second argv element. It is a trap: the SHELL (the APK) and the BUNDLE (arrives by OTA) version
// independently, and lib/node.js reads storage as `Bare.argv[argv.length - 1]`. A new shell passing
// `[storage, hash]` to an OLD bundle would hand it the hash as its storage path — a node booting into
// an empty directory, which is the worst failure this app has. A file is additive in both directions:
// an old bundle never reads it, a new bundle finds it missing and says so.
//
// Two files, two directions, both tiny:
//
// <storage>/shell.json shell → node. { ota, pair, bundle, dns }
// <storage>/app-update.json node → page. { ota, pair, running, staged, checkedAt }
//
// Nothing here does I/O: the callers own that (RNFS on one side, bare-fs on the other), which is what
// makes both shapes testable with no handset.
export const SHELL_FILE = 'shell.json'
export const STATE_FILE = 'app-update.json'
const isHash = (v) => typeof v === 'string' && /^[0-9a-f]{16,128}$/i.test(v)
// Addresses only — this list becomes FAVELA_NAMESERVERS and is handed to connect(). A hostname would
// need a resolver to resolve, which is the thing we are obtaining, and `8.8.8.8:53` is a connect() to a
// host that does not exist. A character-class test admits both, so the families are parsed apart.
//
// ⚠ Deliberately duplicated from favela-web's lib/dns-txt.js rather than imported: this module is also
// loaded by the React Native side, which does not bundle favela-web, and a cross-package import here
// would make the app shell depend on the worklet's dependency graph to open a settings screen.
function isIpv4 (s) {
const parts = s.split('.')
return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
}
function isIpv6 (s) {
if (!s.includes(':')) return false
if ((s.match(/::/g) || []).length > 1) return false
const parts = s.split(':')
if (parts.length > 8) return false
return parts.every((p, i) => {
if (p === '') return true
if (i === parts.length - 1 && p.includes('.')) return isIpv4(p)
return /^[0-9a-fA-F]{1,4}$/.test(p)
})
}
const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 && (isIpv4(v) || isIpv6(v))
/**
* Read what the shell said. Tolerant by construction: every field is optional and a malformed file is
* the same as no file, because a node that refuses to boot over its own handoff blob is unrecoverable
* from a user's phone.
*
* `ota` is a CAPABILITY CLAIM, not a preference: it means "this shell has a message handler that can
* service check/restart". The settings page renders its buttons only when it is true, so an old shell
* running a new bundle shows no controls rather than dead ones.
*/
export function parseShell (text) {
let raw = null
try { raw = typeof text === 'string' ? JSON.parse(text) : text } catch { raw = null }
if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [], apkAddons: [] }
return {
ota: raw.ota === true,
// A SEPARATE capability from `ota`, deliberately. The page arrives by OTA and this shell by APK, so
// "can service update buttons" and "can open the pairing screen" are answered by whatever shell is
// actually running — a page must never render a control the shell beneath it cannot honour.
pair: raw.pair === true,
bundle: isHash(raw.bundle) ? String(raw.bundle) : null,
dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : [],
// The sonames THIS APK ships, so the OTA store can skip downloading addons that are already on the
// device. Only the shell can answer it — jniLibs are mapped out of the APK, never unpacked — and an
// older shell sends nothing, which the store reads as "skip nothing".
apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [],
// ⛔ THE SHELL'S OWN VERDICT on a staged bundle it declined to run, and the reason. The page must
// never re-derive this: it did, disagreed with chooseBundle, and produced a restart button that
// could not work and would not go away.
refused: raw.refused && isHash(raw.refused.hash)
? { hash: String(raw.refused.hash), reason: String(raw.refused.reason || 'refused') }
: null
}
}
/** The shell's side of the same shape. Written before every worklet start, never appended to. */
export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) {
return JSON.stringify({
ota: ota === true,
pair: pair === true,
bundle: isHash(bundle) ? String(bundle) : null,
dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [],
apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [],
refused: refused && isHash(refused.hash)
? { hash: String(refused.hash), reason: String(refused.reason || 'refused') }
: null
})
}
/**
* What the settings page renders.
*
* ⛔ `running` COMES FROM THE SHELL, not from the OTA store. The store knows what is STAGED; only the
* side that chose a file and started the runtime knows what is EXECUTING. Deriving "running" from
* current.json is how the OTA log came to announce it had staged the bundle it was already running.
*
* `checkedAt` is an ISO string because favela-web's relTime() parses one.
*/
export function shellState ({ shell = null, staged = null, checkedAt = null } = {}) {
const s = shell || { ota: false, pair: false, bundle: null }
return {
ota: s.ota === true,
pair: s.pair === true,
running: isHash(s.bundle) ? String(s.bundle) : null,
staged: isHash(staged) ? String(staged) : null,
// Passed straight through from the shell. The page renders THIS instead of deciding for itself.
refused: s.refused || null,
checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
}
}
/**
* A message from the settings page, arriving through the WebView bridge.
*
* The page is served by our own loopback server and the WebView refuses off-origin navigation, but this
* still parses UNTRUSTED input: it is a string from a web page reaching a function that can restart the
* node, and one that opens a ceremony granting a signing key. So it is an allow-list of three named
* verbs and nothing else — no pass-through of an op name to a handler map, which is how a bridge like
* this turns into a remote-call surface.
*/
export function parseShellMessage (data) {
let raw = null
try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null }
if (!raw || typeof raw !== 'object' || raw.t !== 'app-update') return null
if (raw.op === 'check' || raw.op === 'restart' || raw.op === 'pair') return { op: raw.op }
return null
}