// Whether the node keeps running when the app is not on screen.
//
// ⛔ THE DEFAULT IS TO STOP, AND IT IS NOT ANDROID DOING IT. react-native-bare-kit registers, at import
// time, `AppState.addEventListener('change', BareKitWorklet.update)` — and `update` is
// `case 'background': return this.suspend()`. So every worklet in the process is suspended the moment the
// app is backgrounded, which on a phone is every screen lock. That policy is right for a worklet that
// serves a UI and does nothing else; it is exactly wrong for one that IS a peer, holds swarm connections
// and owes other people replication. It is also why a foreground service ALONE fixes nothing: it would
// faithfully keep a suspended node alive.
//
// So this module takes the policy over. The rule it replaces "is the screen on" with:
//
// suspend when we are NOT ALLOWED to keep running — i.e. when there is no live foreground service.
//
// Both halves are load-bearing. Without the override, the service keeps a suspended thread alive. Without
// the service, an unsuspended thread runs in a CACHED process: Android kills it under memory pressure and
// on task-swipe, and the CPU it burns until then is a battery bug with nothing to show for it. Hence the
// fallback: no service, library behaviour.
//
// ⛔ RUNTIME-AGNOSTIC ON PURPOSE, like lib/ipc-client.js. No react-native import, so the policy is
// testable off-device (test/lifecycle.test.mjs) instead of only being observable on a handset overnight.
// app/worklet.js is the only file that knows AppState and the native module exist.
/**
* @param {object} opts
* @param {{ suspend(): void, resume(): void, suspended: boolean, terminated: boolean, update?: Function }} opts.worklet
* the bare-kit worklet. `update` is overwritten — see takeOver below.
* @param {{ start(): Promise<boolean>, stop(): Promise<any> }} opts.service
* the foreground service. `start` resolves truthy only if the service is actually running.
* @param {(msg: string) => void} [opts.log]
*/
export function createLifecycle ({ worklet, service, log = () => {} }) {
let foreground = false
let appState = 'active'
// Take the library's hands off the wheel.
//
// The AppState listener is registered on the CLASS at module import, before any of our code runs, and
// there is no handle to remove it — but all it does is call `worklet.update(state)` per worklet, so an
// own property on the instance shadows the prototype method and the listener becomes inert for us.
// Deliberately NOT `Worklet._worklets.delete(worklet)`: that reaches into a private static and would
// also disable the static suspend/resume/wakeup helpers, which is a bigger change than the one needed.
function takeOver () {
worklet.update = () => {}
}
function suspend () {
if (worklet.terminated || worklet.suspended) return
try { worklet.suspend() } catch (e) { log('could not suspend the node: ' + ((e && e.message) || e)) }
}
function resume () {
if (worklet.terminated || !worklet.suspended) return
try { worklet.resume() } catch (e) { log('could not resume the node: ' + ((e && e.message) || e)) }
}
function apply () {
if (foreground) return resume() // running in the background is the point; only ever undo a suspend
if (appState === 'background') suspend()
else resume()
}
return {
/** Is the node currently exempt from suspend-on-background? */
get foreground () { return foreground },
/**
* Take over the lifecycle and ask for the foreground service.
*
* Resolves with whether the service is running. FALSE IS NOT A BOOT FAILURE: the node still works
* while the app is open, it just goes back to being suspended in the background. A phone where the
* user denied notifications, or an OEM build that refuses the FGS start, lands here — and it must
* say so rather than silently behave like a different product.
*/
async start () {
takeOver()
try {
foreground = !!(await service.start())
if (!foreground) log('the foreground service did not start — the node will suspend in the background')
} catch (e) {
foreground = false
log('the foreground service did not start — the node will suspend in the background: ' + ((e && e.message) || e))
}
// The ~40s boot is long enough to lock the screen in, so the library's listener may already have
// suspended us before we got here. apply() undoes a suspend we would not have made.
apply()
return foreground
},
/** AppState changes, ours rather than the library's. */
async onAppState (state) {
if (state !== 'active' && state !== 'background') return // 'inactive' is iOS-only; ignore, do not guess
appState = state
apply()
},
/**
* Called when the service tells us it is going away — the notification's stop action, or Android
* revoking it. The node is NOT terminated here: stopping it is the app's decision, and a policy
* module that also tore down the node would make one failure look like the other.
*/
onServiceStopped () {
foreground = false
apply()
},
/** Drop the notification and go back to the library policy. */
async stop () {
foreground = false
try { await service.stop() } catch (e) { log('could not stop the foreground service: ' + ((e && e.message) || e)) }
apply()
}
}
}