Status: Production —
SimulatorKitHIDInputBackendships as Tier 1 ofgetInputBackend()(activated in #490). A dailysim-hid-sentinelCI job guards against Apple BC breaks. This document is the stability contract: every private symbol the project touches is listed here, together with the fallback plan if Apple changes or removes it.
OpenSafari keeps the set of private-framework dependencies small and auditable. Every private symbol we touch is listed here together with:
- Where the framework physically lives on disk.
- How we load it (always
dlopen, never link-time). - The behavioural contract with the TypeScript side.
- The monitoring / fallback strategy for Apple BC breaks.
If you add a new private-framework call, you must update this file in the same PR. Reviewers will block merges that skip this step.
TL;DR — host-side simulator automation only. Do not bundle into a shipping iOS app.
OpenSafari loads SimulatorKit.framework and CoreSimulator.framework via
dlopen and uses Apple-private IOKit / Accessibility entry points. These
frameworks are part of the macOS developer toolchain (they ship inside
/Library/Developer/PrivateFrameworks/ and Xcode.app), not the iOS SDK,
and they are intended for tooling that drives the iOS Simulator from a
developer Mac.
- Running OpenSafari on a developer Mac as an MCP server / CLI.
- Running OpenSafari on macOS CI runners (GitHub Actions
macos-*, Buildkite Mac agents, internal Mac mini farms) for headless QA against the iOS Simulator. - Bundling OpenSafari with internal developer tooling distributed to engineers (it never leaves macOS).
- Bundling
sim-hid-bridge,ax-bridge, the SimulatorKitdlopenhelper, or any other private-API helper inside an iOS.ipasubmitted to the App Store, TestFlight, Ad Hoc, or Enterprise (in-house) distribution. Private frameworks and undocumented APIs trigger App Store Review Guideline 2.5.1 / 2.5.2 rejections, and Enterprise Distribution programs prohibit shipping software that uses non-public APIs. - Shipping derivative works that load
SimulatorKit.framework,CoreSimulator.framework, or anyIndigo*/IOHIDEvent*symbol inside an iOS device build (the symbols don't exist on-device anyway, but stripping them out of a fork before redistribution is the consumer's responsibility). - Re-targeting the helpers to drive a physical iOS device. The
SimulatorKit HID path operates on
SimDeviceinstances managed by CoreSimulator; it has no on-device counterpart and any attempt to port it would require additional, separately-prohibited private APIs.
- Apple App Review — Guideline 2.5.1 requires apps to use only
public APIs; 2.5.2 requires self-contained executable code. Bundling
dlopen("…/SimulatorKit.framework/SimulatorKit")inside an iOS app fails both even before the framework's absence on iOS becomes the immediate runtime crash. - Framework provenance —
SimulatorKit.frameworkandCoreSimulator.frameworklive under/Library/Developer/PrivateFrameworks/(Mac developer tools), not in any iOS SDK. They have no code-signing entitlement that would let them ship inside an.ipa. - Same posture as
idb— Facebook'sidb(which uses the same SimulatorKit HID surface) is a host-side daemon for the same reason; it is not shipped inside iOS apps either.
The MIT license on this repository covers OpenSafari's own source. It
does not sublicense Apple's frameworks. Any use of
SimulatorKit.framework, CoreSimulator.framework, or other Apple
private APIs remains subject to the Xcode and macOS license agreements
you accepted when installing them. See ../LICENSE for
the OpenSafari grant; consult Apple's developer agreements for the
permissible scope of the loaded frameworks themselves.
| Framework | Path | Why |
|---|---|---|
SimulatorKit.framework |
/Library/Developer/PrivateFrameworks/SimulatorKit.framework/SimulatorKit |
HID event injection into a booted simulator |
CoreSimulator.framework |
/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator |
Resolve a booted SimDevice from a UDID |
Both frameworks also ship inside the active Xcode bundle (for example
/Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks/…).
The Swift bridge (src/native/sim-hid-bridge.swift) tries each known path
in order and exits with code 78 (SIMULATORKIT_UNAVAILABLE) if none of
them can be loaded. This keeps the failure mode deterministic — the Node
wrapper classifies that exit code as InputBackendError with
code === 'SIMULATORKIT_UNAVAILABLE' and the routing layer can fall
through to an already-supported tier.
- Private frameworks are not in any public SDK, so
-framework SimulatorKitwould require a host-specific search path and would make the project impossible to compile on machines without a matching Xcode. dlopenkeeps load failures recoverable at runtime — a missing symbol surfaces as a structured JSON error on stdout, not a dyld crash.dlopenalso isolates the blast radius of an Apple BC break: a broken framework cannot take the whole Node process down during module init.
All private-symbol resolution happens in sim-hid-bridge.swift. The
TypeScript side (src/tools/sim-hid-input-backend.ts) treats the bridge as
an opaque child process; it never dlopens anything itself.
The bridge is spawned per call via execFile. The contract is deliberately
narrow so we can swap the Swift implementation later without touching TS.
sim-hid-bridge <udid> tap <x> <y> [duration]
sim-hid-bridge <udid> swipe <x1> <y1> <x2> <y2> [duration]
sim-hid-bridge <udid> key <hidUsage> [duration]
sim-hid-bridge <udid> button <home|lock|sound-up|sound-down> [duration]
- Success:
{ "ok": true, "kind": "<tap|swipe|key|button>", "udid": "...", "elapsed_ms": N } - Failure:
{ "ok": false, "error": "<message>", "code": "<MACHINE_CODE>" }
| Code | Meaning | Node-side mapping |
|---|---|---|
0 |
Success | resolve |
64 |
Bad / missing arguments | InputBackendError("BAD_ARGS") |
69 |
Sim device not found or not booted | InputBackendError("DEVICE_NOT_BOOTED") |
78 |
Private framework failed to dlopen |
InputBackendError("SIMULATORKIT_UNAVAILABLE") |
99 |
Reserved — legacy PoC stub code (no longer emitted by the shipped bridge) | InputBackendError("NOT_IMPLEMENTED") |
| other | Unexpected | InputBackendError("UNKNOWN") with stderr surfaced |
Timeouts are enforced on the Node side (SPAWN_TIMEOUT_MS = 10_000). A
killed child classifies as InputBackendError("SPAWN_TIMEOUT").
Private API behaviour can drift silently between Xcode releases. We mitigate that risk with three independent layers:
- Sentinel CI job (daily) —
.github/workflows/sim-hid-sentinel.ymlrunstests/ci/sim-hid-sentinel.test.tsacross a matrix of macOS runners every day at 06:00 UTC (and on any push touchingsrc/native/sim-hid-bridge.swift). The sentinel probesdlopenforSimulatorKit.framework/CoreSimulator.frameworkand checks for theIndigoHIDMessage*symbols. A failure on a scheduled run opens (or updates) asentinel-labelled GitHub issue so on-call sees it on github.com without Slack access. - Fallback tiers stay wired — Tier 1 activation does not remove
SimctlInputBackend,WebKitInputBackend, orAppleScriptInputBackend.getInputBackend()only prefersSimulatorKitHIDInputBackendwhen the helper is present and resolvable; on exit78, on a missing binary, or onOPENSAFARI_HEADLESS_ONLY=1without a simhid backend, it drops to the next tier. This mirrors the default-deny pattern used for the AppleScript fallback introduced in #405. - Structured error codes — Every private-symbol entry point returns a
stable exit code defined above. Consumers of
InputBackendErrorcan decide routing ("fall through onSIMULATORKIT_UNAVAILABLEorNOT_IMPLEMENTED, surface onDEVICE_NOT_BOOTED") without string parsing. Error messages forSIMULATORKIT_UNAVAILABLE/NOT_IMPLEMENTEDembed a pointer to this document so CI operators can jump straight to the response playbook. - One-time stderr notice — The first time a process actually spawns
sim-hid-bridge,SimulatorKitHIDInputBackendemits a single[opensafari] SimulatorKitHIDInputBackend uses Apple private frameworks …line viaconsole.error. This makes the private-API dependency visible in MCP server logs and CI output without drowning them with per-call repetition.
Facebook's idb (MIT) has used the same
private SimulatorKit HID path in production since 2018. OpenSafari's
bridge is independently written but follows the same Apple-maintained
symbol surface, so it's useful to know where the two implementations agree
and where they diverge. Divergences exist only when OpenSafari's
single-binary CLI contract (argv in, JSON on stdout) demands a simpler
dispatch shape than idb's long-running Objective-C runtime.
| Aspect | idb_companion |
opensafari (sim-hid-bridge) |
Rationale |
|---|---|---|---|
| Framework loading | Link-time + weak symbols across FBSimulatorControl |
Runtime dlopen of /Library/Developer/PrivateFrameworks/SimulatorKit.framework and /…/CoreSimulator.framework |
We want to ship a single Mach-O that refuses to boot (exit 78) on a machine without Xcode, rather than failing at dyld load. |
SimDevice resolution |
FBSimulatorSet + Objective-C runtime walk |
SimServiceContext.sharedServiceContextForDeveloperDir:error: → defaultDeviceSetWithError: → availableDevices/devices via NSSelectorFromString |
Same symbols, different call shape — we avoid linking FB's wrapper classes so the bridge has no non-Apple dependencies. |
| HID client | FBSimulatorHIDClient (wraps SimDeviceLegacyHIDClient) |
_TtC12SimulatorKit24SimDeviceLegacyHIDClient class name resolved via NSClassFromString, initWithDevice:error: called through method_getImplementation + unsafeBitCast |
Drop FB's wrapper layer because we need exactly one code path per command; the raw legacy client is enough. |
| Tap event | IndigoHIDMessageForMouseNSEvent(sp, wp, 0, kMouseDown/Up, screenSize, 0) |
Same C function, same arg layout. screenSize resolved from SimDeviceType.mainScreenSize. |
Identical wire format — this is the arg shape Apple's daemon actually consumes. |
| Swipe event | kMouseDragged between Down / Up, interpolated over N steps |
Same pattern, default 10 steps over 0.3 s | Matching step count keeps behaviour consistent with idb-trained users. |
| Key event | IndigoHIDMessageForKeyboardArbitrary(hidUsage, down) + …(hidUsage, up) |
Same. | HID usage values come from the Apple HID Usage Tables (public spec). |
| Button event | IndigoHIDMessageForButton(code, down) / (code, up) |
Same. home=1, lock=2, soundUp=3, soundDown=4. |
Codes match the internal FBSimulatorHIDButton enum. |
| Send channel | SimDeviceLegacyHIDClient sendMessage:freeWhenDone:completionQueue:completion: |
Same selector via method_getImplementation + unsafeBitCast. freeWhenDone is false — we let ARC own the message buffer so a crash in the daemon doesn't leak. |
Same selector. |
| Transport | Persistent gRPC + long-running Obj-C server process | Short-lived child process per call; stdout = JSON, argv = command | MCP expects stateless tool invocations; a daemon would need a supervisor we don't want to own. |
| Arg encoding | Protobuf (RawHIDEventRequest) |
CLI argv + JSON stdout envelope | Matches the existing ax-bridge contract in the same repo — reviewers only learn one pattern. |
| Timing | Tap dwell 0.08 s default | Tap dwell 0.05 s default, swipe dwell total/(steps+2) per segment | Our dwell is tuned against the simulator frame cadence, not gesture recognisers. |
| License | MIT | MIT | OpenSafari does not copy idb source. Cross-references in reviews must cite commit + file, never paste code. |
When in doubt — if a simulator rejects a message shape — the idb source
is the authoritative reference for the Apple contract. Re-read the matching
FB file, compare symbol layout, and update this table if we diverge. See
the idb directories FBSimulatorControl/Session, CompanionLib, and
PrivateHeaders for the canonical call paths.
Disclaimer: This table is a behavioural comparison drawn from public idb source references and opensafari's own code. No source was copied from idb. When refreshing this table, reviewers must re-cite the exact idb commit they used for reference in the PR description.
| Aspect | idb | opensafari sim-hid-bridge |
|---|---|---|
| Framework loading | Static link at build time | dlopen at runtime (loadSimulatorKit / loadCoreSimulator — src/native/sim-hid-bridge.swift:49,:57) |
| SimDevice resolution | FBSimulatorSet |
CoreSimulator dlsym → SimServiceContext → defaultDeviceSetWithError: (src/native/sim-hid-bridge.swift:127) |
| Tap event | IndigoHIDData + IOHIDEvent via FBSimulatorHIDEvent |
IndigoHIDMessageForMouseNSEvent resolved via dlsym (src/native/sim-hid-bridge.swift:170) |
| Key event | FBKeyboardCommand / FBSimulatorHIDEvent |
IndigoHIDMessageForKeyboardArbitrary resolved via dlsym (src/native/sim-hid-bridge.swift:171) |
| Button event | FBSimulatorButton enum + FBSimulatorHIDEvent |
IndigoHIDMessageForButton resolved via dlsym (src/native/sim-hid-bridge.swift:172) |
| Arg encoding | Protobuf over a gRPC/socket transport | CLI argv + JSON stdout (newline-terminated envelope) |
| Process model | Long-lived FBSimulator session |
Short-lived execFile per call (src/tools/sim-hid-input-backend.ts) |
| Spawn timeout | idb default 10 s | SPAWN_TIMEOUT_MS = 10_000 (src/tools/sim-hid-input-backend.ts:35) |
opensafari uses execFile per call for process isolation and crash
containment: a misbehaving or crashing Swift helper cannot corrupt the
long-lived Node MCP server process. idb's Protobuf transport is not adopted
because opensafari is a single-language (TypeScript) wrapper — the overhead of
a schema registry and generated types is unjustified for a thin argv/JSON
contract. SPAWN_TIMEOUT_MS is deliberately kept at 10 000 ms to match idb's
default so operators who already know idb have a familiar mental model for
latency budgets. Finally, frameworks are dlopened rather than linked so that
a binary-incompatible Apple framework update cannot take the Node process down
at dyld time — failure surfaces as a structured SIMULATORKIT_UNAVAILABLE
error and the routing layer falls through to the next input tier.
The SimulatorKit HID pattern is well-known in the community thanks to
Facebook's idb (MIT). OpenSafari's
bridge is independently written from public framework headers, symbol
inspection, and Apple's dyld tooling. We do not copy or adapt idb source
files into the repository. If you do need to cross-reference idb, cite
the exact file and commit in the PR description — do not paste code.
- Update this document in the same PR that adds or removes a private-symbol
dependency. The
sim-hid-sentinelworkflow already fires onsrc/native/sim-hid-bridge.swiftedits to catch drift; reviewers should treat a missing update to this file as a blocking comment. - Bump the exit-code table above whenever a new failure mode is introduced.
The Node side's
InputBackendErrorCodeunion must stay in sync. - Keep the Swift bridge defensive: every private symbol call must be wrapped in a nil check, and any failure must emit a structured JSON envelope before the process exits.
All work in this area is tracked under the SimulatorKitHIDInputBackend umbrella issue (#483). Relevant shipped PRs:
- #487 — PoC: Swift bridge, Node wrapper, unit tests.
- #510 — Swift bridge
implementation (
IOHIDEventinjection viaSimDeviceLegacyHIDClient). - #511 — Tier 1
activation in
getInputBackend(). - #513 — Sentinel test
suite (
tests/ci/sim-hid-sentinel.test.ts). - #493 — Daily cron workflow + idb-pattern comparison (this document) + one-time stderr notice.
The Xcode 26 tap regression + the candidates we have already falsified
(mouse NSEvent coord units, CreatePointerService bracket, digitizer
IOHIDEvent → pointer wrapper) are catalogued in
docs/simhid-ios26-investigation.md.
That document is the canonical synthesis: it carries the
falsification log, remaining candidates ranked by effort × yield, and
the stability commitments
for coordinate tap vs element-targeted input on Xcode 26+. Read it
before adding a new candidate path so we don't reproduce work already
ruled out.