OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / worklet.js

258 lines · javascript ref:
// The RN-side bridge: start the worklet, wire react-native-bare-kit's IPC to WorkletClient, hand the app
// a client. This is the ONLY file that knows react-native-bare-kit exists.
//
// ⛔ SEPARATE FROM lib/ipc-client.js ON PURPOSE. The client is runtime-agnostic and dependency-free so it
// can be tested against the real worklet with no device, no emulator and no Metro (test/ipc.test.mjs
// does exactly that). Importing react-native-bare-kit into it would make the whole data layer
// device-only to verify. Everything RN-specific is confined here.
import { Worklet } from 'react-native-bare-kit'
import RNFS from 'react-native-fs'
import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
import { WorkletClient } from './ipc-client'
import { createLifecycle } from './lifecycle'
import { planBoot } from './boot-plan'
import { encodeUtf8, createUtf8Decoder } from './utf8'

let started = null
let starting = null
let lifecycle = null
// Removed on teardown, because boot() runs AGAIN on restart — leaving them attached would give a
// twice-restarted app three AppState listeners driving one suspend policy.
let subs = []

// NodeService, as lib/lifecycle.js wants to see it. The permission request lives here rather than in the
// policy because it is a UI event: from Android 13 a foreground service still RUNS without
// POST_NOTIFICATIONS, but its notification is not displayed — so the node would be replicating in the
// background with nothing on screen saying so, which is the one thing the notification exists to prevent.
// Denial is not fatal and must not block the service.
const service = {
  async start () {
    const native = NativeModules.FavelaNode
    if (!native) return false
    if (Platform.OS === 'android' && Platform.Version >= 33) {
      try { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) } catch {}
    }
    return native.start()
  },
  async stop () {
    const native = NativeModules.FavelaNode
    if (native) await native.stop()
  }
}

// The OS's DNS servers for the ACTIVE network, which is the only place they exist on Android: there is
// no /etc/resolv.conf, `getprop net.dns1` has been empty since Android 8, and netd does not expose the
// list to an app except through ConnectivityManager. Needs no new permission — ACCESS_NETWORK_STATE is
// already in the manifest for the foreground service.
//
// ⚠ A BOOT-TIME SNAPSHOT. Roaming to another network changes the answer and the node will not notice,
// because favela-web captures its resolver at import. That matches how every other favela node behaves
// (they read resolv.conf once, at start), so it is a known limit rather than a regression — the fix is a
// resolver that can be re-pointed, which is a change on the favela-web side.
async function dnsServers () {
  const native = NativeModules.FavelaNode
  if (!native || typeof native.dnsServers !== 'function') return []
  try {
    const list = await native.dnsServers()
    return Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s) : []
  } catch { return [] }
}

// Replace the notification's text once the node can answer for itself. Never speculatively: the shell
// does not know what is being served until it asks, and the shade is the one surface a user reads
// without opening anything.
async function describe (text) {
  const native = NativeModules.FavelaNode
  if (native && text) { try { await native.describe(text) } catch {} }
}

/**
 * Start the node once per app launch and resolve with its client.
 *
 * The storage path is passed as argv — a phone has no $HOME and no env, so this argv handoff replaces
 * favela's FAVELA_STORE_ROOT convention entirely (spec/architecture.md §2).
 *
 * ⚠ ASYNC because the worklet bundle has to be READ. It cannot be imported: see metro.config.cjs — a
 * Metro asset import hands back an asset id (a number), not bytes, and bare-kit rejects it.
 */
export function startNode () {
  if (starting) return starting
  starting = boot()
  return starting
}

async function boot () {
  const worklet = new Worklet()
  const storage = RNFS.DocumentDirectoryPath

  // `readFileAssets` reads out of the APK's assets/ — the only encoding that survives a binary file is
  // base64, so it is decoded here. bare-kit takes a string or a TypedArray (index.js:189); the bundle is
  // a length-prefixed binary container, so it must be the TypedArray.
  // ⛔ THE BUNDLE MUST NOT PASS THROUGH THE JS HEAP. It used to: readFileAssets(…,'base64') materialised
  // the whole thing as a base64 string, which is 4/3 of the bundle, and RN's heap has a hard growth
  // limit. At a 21 MB bundle it just fit (b64 chars=28201128). At 34.9 MB it died on the device:
  //
  //   Exception in HostFunction: Failed to allocate a 46477264 byte allocation with 8388608 free bytes
  //   and 22MB until OOM, target footprint 185804240, growth limit 201326592
  //
  // 46,477,264 is exactly the base64 of 34,857,935 bytes — the handoff, not the payload. bare-kit can
  // load the bundle ITSELF (NativeBareKit.startFile, index.js:210) when `source` is null, so copy the
  // asset out with a NATIVE copy and hand over a path. Nothing large is ever allocated in JS, and the
  // bundle is free to grow — which is what porting favela-web in requires.
  //
  // Copied once and re-copied only when the size stamp changes (written by prebuild): hashing 35 MB on
  // a phone at every launch to detect a change we already know about at build time would be silly.
  // ⛔ THE DECISION LIVES IN host/boot-plan.js, WHERE IT CAN BE TESTED. This is the path that decides
  // what EXECUTES on a device with no adb and no UI left if the node cannot boot, and it was the one
  // uncovered file in the repo — uncovered because this module imports react-native-bare-kit and
  // react-native-fs at module scope and so cannot load under bare. Everything below is now RN glue:
  // the filesystem surface planBoot needs, injected.
  //
  // ⚠ Keep this a FAITHFUL adapter. A behaviour implemented here rather than there is a behaviour with
  // no test, which is exactly the state this replaced.
  const t0 = Date.now()
  const plan = await planBoot({
    storage,
    io: {
      readAsset: (name) => RNFS.readFileAssets(name, 'utf8'),
      size: async (path) => (await RNFS.stat(path)).size,
      exists: (path) => RNFS.exists(path),
      readJson: async (path) => { try { return JSON.parse(await RNFS.readFile(path, 'utf8')) } catch { return null } },
      writeText: (path, text) => RNFS.writeFile(path, text, 'utf8'),
      copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest),
      unlink: (path) => RNFS.unlink(path),
      dnsServers,
      log: (m) => console.log(m)
    }
  })
  const { entryPath, startArgs } = plan
  const bundleHash = plan.bundleHash
  // The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure
  // inside it is silent from here. This line is the only evidence of what was actually handed over.
  console.log('[worklet] entry=' + entryPath + ' bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') +
              ' addons=' + (plan.addonsDir || 'none') + ' in ' + (Date.now() - t0) + 'ms')

  // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
  worklet.start(entryPath, null, startArgs)
  console.log('[worklet] start() returned')

  // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
  // is under react-native-bare-kit's own policy — an AppState listener it registers at import time that
  // suspends EVERY worklet on 'background', i.e. on every screen lock. lib/lifecycle.js takes that over
  // and ties suspend to whether the foreground service is running instead. Awaited rather than fired off:
  // the policy must be settled before the user can background the app, and it costs ~100ms against a
  // ~40s boot.
  lifecycle = createLifecycle({ worklet, service, log: (m) => console.log('[worklet] ' + m) })
  const foreground = await lifecycle.start()
  console.log('[worklet] background replication ' + (foreground ? 'ON (foreground service)' : 'OFF — the node will suspend when the app is not on screen'))
  subs.push(AppState.addEventListener('change', (state) => { lifecycle.onAppState(state) }))
  // The Stop action on the notification. The service drops the exemption and goes away; the node is NOT
  // torn down — it keeps running while the app is open and suspends once it is backgrounded, which is
  // what "stop replicating in the background" means. Tearing it down here would make one button mean two
  // things.
  subs.push(DeviceEventEmitter.addListener('favela-node-service-stopped', () => {
    console.log('[worklet] foreground service stopped — the node will suspend when the app is not on screen')
    lifecycle.onServiceStopped()
  }))

  const { IPC } = worklet

  // ⛔ BYTES ON THE WIRE, TEXT IN THE CLIENT. bare-kit's IPC converts nothing in either direction: it
  // pushes a plain Uint8Array up (`.toString()` on which is Array.prototype.toString — the comma-joined
  // byte VALUES, not text) and it writes only TypedArrays down. The client speaks strings by design, so
  // this file owns the conversion — see lib/utf8.js and test/utf8.test.mjs.
  const client = new WorkletClient((line) => IPC.write(encodeUtf8(line)))

  // bare-kit's IPC is a stream: chunk boundaries are NOT message boundaries, which is why the client
  // buffers partial lines rather than parsing per-chunk (proven in test/ipc.test.mjs). They are not
  // character boundaries either, which is why the decoder is stateful.
  const decode = createUtf8Decoder()
  IPC.on('data', (data) => client.receive(decode(data)))
  IPC.on('close', () => client.close('worklet stream closed'))
  IPC.on('error', (err) => client.close('worklet stream error: ' + (err && err.message)))

  // Once the node is up, let the notification say what it is actually serving rather than the generic
  // line the service starts with. Fire-and-forget and non-blocking: boot takes ~40s, a node that cannot
  // describe itself still runs, and nothing here may delay the UI appearing.
  // ⛔ READY IS THE ONLY PROOF A BUNDLE WORKS. Clearing the pending marker here, and nowhere earlier, is
  // what makes the watchdog meaningful: a bundle that starts, throws, and dies still leaves its marker.
  client.whenReady()
    .then(async () => { try { await RNFS.unlink(RNFS.DocumentDirectoryPath + '/bundles/pending.json') } catch {} })
    .catch(() => {})

  // ⛔ REWRITTEN, not ported. favela-android asked `hostedDids()` and rendered the DID list via
  // `lib/describe.js`. Neither exists here: that op was removed from `ipc-client.js` with the rest of
  // favela-android's typed API, and this node has no DIDs — it serves a favela feed. Ported verbatim,
  // this line called a missing op through a missing module, which is what broke the Metro bundle.
  //
  // ⚠ Keeps describe.js's rule: never say more than is known, and never read the same in the healthy
  // and the wrong case. `serving` is claimed only when the node reports a URL it actually bound.
  client.whenReady()
    .then(() => client.request('status'))
    .then((s) => {
      if (!s || !s.ready || !s.url) return describe('starting')
      const feed = String(s.feed || '')
      return describe('serving · ' + (feed.length > 12 ? feed.slice(0, 8) + '…' + feed.slice(-4) : feed))
    })
    .catch(() => {})

  started = { worklet, client, storage }
  return started
}

/**
 * Stop the node. Called when the app is being torn down for real, and to RESTART it — NOT on
 * backgrounding.
 *
 * ⚠ Backgrounding must NOT stop the node: that is the whole point of running a peer on the phone, and
 * spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Backgrounding is
 * handled by lib/lifecycle.js + NodeService (spec/architecture.md §7), never here.
 *
 * ⛔ EVERY SUBSCRIPTION IS DROPPED. stopNode + startNode is now a supported pair (it is how a staged
 * bundle is applied and how a wedged node is recovered), so anything this file attached must come off:
 * a listener left behind would still be holding the TERMINATED worklet's lifecycle policy, and the
 * second boot's listener would be the second voice deciding when to suspend.
 */
export function stopNode () {
  if (!started) return
  // The notification goes first: it says the node is replicating in the background, and for the moment
  // between terminate() and the service being told, it would be claiming something untrue.
  if (lifecycle) { lifecycle.stop().catch(() => {}); lifecycle = null }
  for (const sub of subs) { try { sub.remove() } catch {} }
  subs = []
  try { started.client.close('app teardown') } catch {}
  try { started.worklet.terminate() } catch {}
  started = null
  starting = null
}

/**
 * Stop the node and start it again — the ONLY way a staged bundle becomes the running one.
 *
 * Applying an update to a running node would mean swapping code under itself, so lib/ota.js stages and
 * stops. This is the other half, and it lives here because only this side owns the runtime: the worklet
 * cannot start a different worklet. On the way back up, chooseBundle picks whatever is staged, which is
 * what makes "restart" and "apply the update" the same button.
 */
export async function restartNode () {
  stopNode()
  return startNode()
}

/**
 * Apply a staged bundle — by restarting the PROCESS, not the thread.
 *
 * The decision lives in `apply-plan.js` so it can be tested off-device; this is the glue that hands
 * it the real native module and the real worklet restart. See that file for why the process is what
 * has to go.
 */
export async function applyUpdate () {
  const { planApply } = await import('./apply-plan.js')
  return planApply({
    native: NativeModules.FavelaNode,
    restartNode,
    log: (m) => console.log(JSON.stringify(m))
  })
}