Workers
SootSim runs across three execution contexts: the host (main thread / DOM),
the shell worker (iOS chrome + home + native UI), and the tenant worker
(the guest app bundle). Each one owns a different slice of the system, and
keeping that split clean is what lets a frozen guest app coexist with a
responsive home screen, keyboard, and notification center.
The architecture in one picture
┌─────────────────────────────────────────────────────────────────────────┐
│ MAIN THREAD │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ SootSimCanvas.tsx │ │
│ │ │ │
│ │ DOM canvas stack: │ │
│ │ z=30 overlay ───────────────┐ │ │
│ │ z=10 app:one ──────────┐ │ OffscreenCanvas │ │
│ │ z=10 app:two ──────┐ │ │ transferred at boot │ │
│ │ z=5 home ──┐ │ │ │ │ │
│ │ z=0 underlay ┐ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ pointer routing: │ │ │ │ │ │ │
│ │ home pointers ─┼─┼───┼───┼──→ shell worker │ │
│ │ app pointers ──┼─┼───┼──→ tenant worker │ │
│ │ shell regions ─┼─┼───┼───┼──→ shell worker (overlay/chrome) │ │
│ └───────────────────┼─┼───┼───┼────────────────────────────────────┘ │
│ │ │ │ │ │
│ ┌───────────────────┼─┼───┼───┼────────────────────────────────────┐ │
│ │ host.ts │ │ │ │ shell-host.ts │ │
│ │ ┌────────────────┼─┼───┼───┼──┐ ┌────────────────────────────┐ │ │
│ │ │ tenant worker │ │ │ │ │ │ shell worker comms: │ │ │
│ │ │ comms: │ │ │ │ │ │ syncInputs (coalesced) │ │ │
│ │ │ init/resize │ │ │ │ │ │ shellEvent (12 types) │ │ │
│ │ │ shellEvent │ │ │ │ │ │ invokeShellCommand │ │ │
│ │ │ invokeShell │ │ │ │ │ │ registerSurface │ │ │
│ │ │ Command │ │ │ │ │ │ pointerOnSurface │ │ │
│ │ │ syncAux │ │ │ │ │ │ shellTestBridge │ │ │
│ │ │ Bindings │ │ │ │ │ │ vsync (rAF pump) │ │ │
│ │ └────────────────┼─┼───┼───┼──┘ └────────────────────────────┘ │ │
│ │ │ │
│ │ shell-scene-runtime.ts │ │
│ │ receives shellSceneFromShell → updates aux-surface-map │ │
│ │ → posts syncAuxBindings to tenant │ │
│ │ → posts setSurfacePaused to tenant │ │
│ │ → notifies SootSimCanvas subscribers │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ │
│ postMessage │ postMessage
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────────────────┐
│ TENANT WORKER │ │ SHELL WORKER │
│ │ │ │
│ ┌────────────────────┐ │ │ ┌──────────────────────────────────┐ │
│ │ React trees: │ │ │ │ React trees: │ │
│ │ │ │ │ │ │ │
│ │ ShellApp │ │ │ │ ShellRoot (chrome overlay) │ │
│ │ └─ IOSShell │ │ │ │ ├─ CanvasTopChrome │ │
│ │ (controller │ │ │ │ ├─ CanvasInspectOverlay │ │
│ │ for tenant │ │ │ │ ├─ SootSimSettings (NC) │ │
│ │ side state) │ │ │ │ ├─ DocsPanel (CC) │ │
│ │ │ │ │ │ └─ LockScreen │ │
│ │ AuxAppSurface │ │ │ │ │ │
│ │ (app:one canvas) │ │ │ │ HomeTreeRoot (home canvas) │ │
│ │ (app:two canvas) │ │ │ │ ├─ useIOSShellController │ │
│ │ reads scene via │ │ │ │ │ (AUTHORITATIVE) │ │
│ │ syncAuxBindings │ │ │ │ │ posts shellSceneFromShell │ │
│ │ │ │ │ │ └─ AuxHomeSurfaceRoot │ │
│ │ ConnectRN bundle │ │ │ │ ├─ HomeScreen │ │
│ │ (user app code) │ │ │ │ ├─ SwitcherOverlay │ │
│ │ │ │ │ │ └─ gesture dispatchers │ │
│ └────────────────────┘ │ │ └──────────────────────────────────┘ │
│ │ │ │
│ pointer dispatch: │ │ pointer dispatch: │
│ aux-pointer-dispatch │ │ aux-pointer-dispatch │
│ (shared factory) │ │ (shared factory, same code) │
│ handles app:one/two │ │ handles home surface │
│ │ │ │
│ __sootsimPostShellScene │ │ __sootsimPostShellScene │
│ = undefined │ │ = posts shellSceneFromShell │
│ (tenant CANNOT post │ │ (shell IS scene authority) │
│ scenes to main) │ │ │
└──────────────────────────┘ └──────────────────────────────────────────┘
The host owns the DOM — rail, menu bar, the canvas element stack, and the
pointer routing that decides which worker sees a given event. The shell worker
owns everything that looks like iOS chrome: status bar, home indicator, lock
screen, notification center, control center, alerts, action sheets, keyboard,
image picker. The tenant worker owns the guest React tree and its app surface
canvases.
Both workers render to OffscreenCanvas instances transferred at boot — there
is no main-thread rendering path. Pointers are routed at the DOM level: home
and shell-region pointers go to the shell worker; app-region pointers go to
the tenant worker.
Scene publication
The shell worker is the single source of truth for what’s on screen. Scene
changes flow out of useIOSShellController in the shell worker, get posted to
the host, and are then forwarded to the tenant worker so its app surfaces know
which app to render.
shell worker main thread tenant worker
──────────── ─────────── ─────────────
useIOSShellController
signals change
│
▼
useAuxShellScenePublisher
rAF-coalesced push()
├─► publishAuxShellScene() ← shell-worker-local, feeds AuxHomeSurface
└─► __sootsimPostShellScene()
│
│ postMessage: { type: 'shellSceneFromShell', ...scene }
▼
shell-host.ts
onMessage → computeSceneKey() dedupe
│
└─► sceneListeners
│
├─► SootSimCanvas callback
│ └─► host.applyShellSceneFromShell()
│ └─► shellSceneRuntime.applySceneFromShell()
│ ├─► store scene
│ ├─► setAuxAppSurfaceBindingsForScene()
│ ├─► syncAuxSurfacePause()
│ │ └──► postMessage: { type: 'setSurfacePaused' } ──────► tenant
│ ├─► postMessage: { type: 'syncAuxBindings', ──────► tenant
│ │ surfaceBindings, activeAppId, shellState } case 'syncAuxBindings'
│ │ ├─► setAuxAppSurfaceBindingsForScene()
│ │ └─► markAllAuxSurfacesDirty()
│ └─► notify subscribers (SootSimCanvas re-renders)
The dedupe step (computeSceneKey) is important: without it, every rAF tick
would re-forward the same scene to the tenant and trigger a round of aux
surface invalidation.
Shell command routing
Shell commands (launchApp, goHome, openSwitcher, etc.) fan out to both
workers. The shell worker runs the real controller — it drives scene
transitions and publishes the new scene. The tenant worker runs a parallel
copy that updates its local aux-surface-map so AuxAppSurface knows which
app to mount.
SootSimCanvas.tsx or SootSim.bridges.shellCommands
│
▼
host.invokeShellCommand(command, { appId })
│
├──► shellHost.invokeShellCommand(command, appId)
│ │
│ │ postMessage: { type: 'invokeShellCommand', command, appId }
│ ▼
│ shell-worker.ts case 'invokeShellCommand'
│ └─► ensureSootSim().bridges.shellCommands.launchApp(appId)
│ (real controller handlers — drives scene transitions)
│
└──► send({ type: 'invokeShellCommand', command, appId })
│
│ postMessage to tenant
▼
worker.ts case 'invokeShellCommand'
└─► ensureSootSim().bridges.shellCommands.launchApp(appId)
(tenant-local handlers — updates aux-surface-map so
AuxAppSurface knows which app to render)
Miss either side of this fan-out and the system desynchronizes: forget the
shell and no transition runs; forget the tenant and AuxAppSurface renders
black because appId is null.
The tenant runtime layer
On top of the worker split, the tenant carries a small stack of
cross-cutting runtime modules. Each one collapses state that used to drift
across multiple files — focus, keyboard, scroll, navigation, surface metrics —
into a single module-level singleton with a read-only global snapshot for CLI
and test consumers.
tenant worker
──────────────
┌─ SurfaceOwnershipRuntime — owner resolver + pointer session map + ack tracker
│ (route table for performTap / surfaceTouch / cli / dispatchPointerOnSurface)
│
input events ─────────────┤
│
└─ FocusKeyboardRuntime — focus identity + rect + keyboard phase + frame
├─ ScrollRuntime — scroll container registry + ensureRectVisible
├─ NavigationRuntime — presented screens + transition phase + header
└─ SurfaceMetricsRuntime — window / device / safe-area / visible rect
(composes FocusKeyboardRuntime + device-spec-bus)
Every runtime follows the same rules:
- module-level state on the tenant worker — same pattern as
Keyboard.ts._visible and pointer-delivery.ts counters
- read-only globals —
globalThis.__sootsim<Name>Runtime exposes
{ getSnapshot, ... }; writes only happen through the typed exports
- no per-tick React work — subscriptions fire on state transitions
(register/unregister, phase change, focus change), not per frame or per
scroll offset
- composition, not duplication —
SurfaceMetricsRuntime composes
device-spec-bus and FocusKeyboardRuntime; NavigationRuntime
composes engine/screen-transition-state
Dual controllers
Both workers run a full useIOSShellController. The shell worker’s copy is
scene-authoritative — it’s the only one whose scene changes reach the host.
The tenant worker’s copy is local: it updates its own aux-surface-map so
AuxAppSurface knows which app to mount, but it never posts a scene.