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 globalsglobalThis.__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 duplicationSurfaceMetricsRuntime 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.

shell workertenant worker
mounts viaHomeTreeRoot.tsxIOSShell in app-in-worker.tsx
scene authorityyes — posts to hostno — scene changes are local
handles invokeShellCommandyes — drives transitionsyes — updates aux-surface-map