iOS Simulator
August 25, 2026 · View on GitHub
ADE drives the system iOS Simulator from the Work tools pane and from
ade ios-sim. It discovers launchable iOS targets, builds and launches the
selected app, mirrors the running Simulator.app window into the drawer when the
user wants to watch, and turns drawer gestures into simulator input or context
items for the active chat. Agent launches stay in the background.
The feature is macOS-only. xcrun, xcodebuild, and Simulator.app must be
available on the runtime host. idb and idb_companion are optional but
recommended for direct tap, drag, text, accessibility, and hit-test actions.
Runtime ownership
The simulator service runs where the ADE runtime runs. A local Mac runtime can
build, launch, mirror, and control the local Simulator.app. Non-macOS runtimes
report supported: false; the renderer hides simulator controls and every CLI
command rejects with the macOS-only error. status is the capability gate.
A remote Mac runtime supports control, screenshots, and Preview Lab, but not
the drawer live view, which is a local desktop-window capture.
ChatIosSimulatorPanel takes a runtimePin naming the machine it drives, so
the panel follows the chat rather than the project tab: mounted in the Work
tools pane it receives that pane's pin (the machine the active Work session
runs on) and its projectRoot is the pinned machine's worktree for the active
lane, not the tab-local checkout. Status reads, device and launch-target lists,
launch / shutdown / screenshot / snapshot / stream / input / Preview Lab calls,
and the iosSimulator.onEvent subscription all carry the pin. The event half
matters as much as the reads: without a pinned subscription a pinned panel got
status reads and no live updates, and the bound machine's stream described a
different simulator entirely. The one part that does not follow the pin is the
live view — it captures the Simulator window through this window's own screen
capture — so a chat on another machine gets an explicit message saying the live
view shows the Simulator on this computer while the chat runs elsewhere.
Window parking, Screen Recording / Automation probes, and System Settings
openers stay on local Electron IPC: the brain daemon has no BrowserWindow.
Each launched simulator session has one owner chat/lane. A second chat trying
to launch against an active session receives
IOS_SIMULATOR_OWNED_BY_OTHER_SESSION, whose message carries the owning chat
id and lane and how long ago it claimed. Service messages state the fact and
the code and stop there — the drawer and the iOS app read the same string and
cannot run a shell command, so the "now run this" half lives in the CLI's own
hint (iosSimulatorErrorHint), keyed off the code.
Ownership releases automatically when the owning chat is deleted or archived —
those are the only two paths that call notifyChatSessionEnded, so a chat that
is merely closed or navigated away from still holds the session. shutdown
carries the caller's chat session id (the CLI forwards $ADE_CHAT_SESSION_ID),
and a shutdown from any other chat — or from a caller with no session id at all
— is refused with the same IOS_SIMULATOR_OWNED_BY_OTHER_SESSION code. The
owning chat releases its own session with a plain shutdown.
The guard is cooperative, and the docs should not suggest otherwise: it sorts honest callers so no chat ends another's session by accident, and every way of stating the intent works.
shutdown --force— the documented escape hatch. It also hard-resets the session: trackedidbcompanions are stopped andactiveLaunchIdis cleared, which is what makes it the answer to a wedgedIOS_SIMULATOR_LAUNCH_IN_PROGRESS.shutdown({ ignoreOwnership: true })— the bypass without the hard reset. ADE's lane-scoped drawer uses it: that surface deliberately drives whatever session its lane is running and hides the ownership card, so it names itself and asks the rule to stand down rather than impersonating the owner. It is reachable from the CLI asshutdown --ignore-ownership(and, like any other action argument, through the generic--arg ignoreOwnership=true).- Naming the owner's own chat session id.
getStatusis in the agent-allowedios_simulatoraction list and reportsactiveSession.chatSessionId, so any caller can read the owner's id and pass it as its own — via--chat-sessionor--arg chatSessionId=…. A caller that names the owner is the owner as far as the check is concerned. launch --force, which validates the new target before evicting, andattachToChatSession({ takeOver: true }), which transfers ownership without a teardown at all.attachToChatSession(<any chat id>, null)— the cheapest path on the list, and the only one that needs no flag at all. The guard only runs when the caller supplies a non-emptycallerChatSessionId, so a caller that passesnullfor it skips the check entirely: it can transfer the session to any chat, andattachToChatSession(null, null)detaches it outright — after which any chat's plainshutdownis accepted. It is reachable from an agent, not just from trusted code:attachToChatSessionis in the agent-allowedios_simulatoraction list, the RPC tool accepts a positionalargsList,ios_simulatorhas no object-args requirement to force the named form, andargsListis applied straight to the method. The IPC path is unaffected — the renderer always passes both ids. The hole is documented inattachToChatSessionitself and left open on purpose: closing it is a change to the ownership rules, not a doc fix.claim --ignore-ownership/claim --force(orclaim --arg ignoreOwnership=true, which is the same thing).claimrewritesactiveSession.chatSessionIdoutright, and the CLI defaults that id to the caller's own$ADE_CHAT_SESSION_ID, soade ios-sim claim --lane …from a foreign chat used to take ownership with no bypass flag at all — after which a plainshutdownwas accepted. It now carries the same cooperative guard asshutdown, and the bypass is spelled the same way. Naming no chat session id (or the owner's own) still only re-attributes the lane and is not a takeover.
Treat it as a guard rail against accidents, not as a lock: nothing here stops a determined caller, and the agent skill tells agents to ask before evicting rather than relying on the service to refuse.
Build root
Simulator commands default their build root to the caller's lane worktree, not
the primary checkout, so a lane builds and launches its own code. An explicit
--project-root still wins. Target ids are validated against the resolved
root: an id minted under a different root fails with
IOS_SIMULATOR_TARGET_ROOT_MISMATCH, and the caller re-runs apps for a fresh
id. A --lane that resolves to no worktree is a hard failure
(IOS_SIMULATOR_LANE_NOT_RESOLVED), never a quiet fall back to the primary
checkout — that fallback is how a lane agent "verifies" code it never wrote.
The resolved root comes back on the launch result and on the session as
buildRoot, and on each build-app launch-progress step.
Source file map
| Path | Role |
|---|---|
apps/desktop/src/main/services/ios/iosSimulatorService.ts | Tool readiness, device/target discovery, build/install/launch, screenshots, ADEInspector/accessibility snapshots, Simulator.app live-view status, idb-backed input, Preview Lab, and single-owner session locking. |
apps/desktop/src/main/services/ios/simulatorWindowCapture.ts | Simulator.app window state, parking, Screen Recording / Automation probes, and System Settings openers. Process-wide follow state lives here so IPC stays handler wiring. |
apps/desktop/src/shared/types/iosSimulator.ts | Cross-process iOS simulator types. IosSimulatorStreamBackend is simulator-window-capture. |
apps/desktop/src/main/services/ipc/registerIpc.ts | IPC handlers for the simulator domain. Window capture and parking are delegated to simulatorWindowCapture.ts. |
apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx | Work drawer UI: setup checklist, device/target pickers, launch progress, live Simulator.app window video, interact/inspect modes, Preview Lab, and context attachment. Takes runtimePin and drives the simulator on that machine — every status read, list, launch/shutdown/capture call, and the onEvent subscription carry it. |
apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx | Mounts the panel under the Work tools pane's ios tab, keyed work-ios:<pinKey> so a machine switch remounts it. Runs its own pinned iosSimulator.getStatus / onEvent pair to detect lane mismatches, resolving lane names against the pinned machine's lanes; skips the probe entirely when that machine is known offline. |
apps/ade-cli/src/cli.ts | ade ios-sim typed commands. window-start and live-start both start the same Simulator.app window stream. |
apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift | DEBUG-only Swift helpers that publish element frames into the app container for accurate inspect/select context. |
Lifecycle
-
Status.
getStatus()checks macOS support plusxcrun,xcodebuild,idb, andidb_companionreadiness. The returned tool list drives the drawer checklist. -
Device and target discovery.
listDevices()parsesxcrun simctl list -j devices.listLaunchTargets()combines Xcode projects, DerivedData app bundles, and already-installed simulator apps. Stale saved target ids are recovered when there is one clear replacement. -
Launch.
launch(args)emits progress forresolve-device -> boot-simulator -> open-simulator -> resolve-target -> build-app -> install-app -> launch-app -> ready. The drawer renders those steps live.ade ios-sim launch --followwaits on a budget sized for a real cold build and prints the launch summary when it completes; the CLI does not stream per-step progress.CLI launches run in the background: Simulator.app is not foregrounded and the drawer is not forced open (
openDrawerdefaults to false; the drawer passes true for its own launches), so the user gets a "Simulator running" pill with an Open action instead. OnlyselectPoint/inspectPointstill reveal the drawer on their own.--foregroundopts into the visible open, after which IPC parks the real Simulator.app window under the owning ADE window.The result carries
capabilities(canTap,canType,canDrag,canInspect), the resolvedbuildRoot, andusedInstalledBinary. A launch that would silently reuse a previously-installed binary instead of the one just built fails, unless that installed target was chosen explicitly. -
Screenshot and proof.
screenshotwrites a PNG and always returns an absolutefilePathan agent can read (dataUrlremains for the renderer).--out <path>chooses where, resolving relative paths against the build root. The resolved path must stay inside that root — a../tail or an absolute path elsewhere is rejected, so a capture can never overwrite a file outside the tree it is proving. With no--outthe file lands in<buildRoot>/.ade/cache/ios-simulator/screenshots/and the newest 20 are kept.proof [--caption <text>]captures a screenshot and attaches it to the ADE proof drawer, mirroringade browser proof. -
Live view.
startStream()starts the Simulator.app window stream; the compatibilitybackend: "auto"input normalizes tosimulator-window-captureand there is no separate ADE-managed backend. The renderer asks IPC for capturable Simulator window sources — the preload derives the binding itself — then attaches a desktop-capture stream to a<video>.prepareSimulatorWindowForCapture()unhides, sizes, and parks the simulator window under the owning ADE window;followSimulatorWindowUnderAde()re-parks on ADE move/resize. -
Inspect and select.
getScreenSnapshot()captures a PNG, reads the app's ADEInspector snapshot when present, optionally augments it with idb accessibility data, and returns selectable elements.inspectPoint()returns a single context item;selectPoint()also emits a drawer selection event. -
Input.
tap,drag,swipe, andtypeTextuse idb when bothidbandidb_companionare installed. Missing idb does not block the live view; it only blocks direct control and accessibility-backed inspection, and shows up as falsecapabilitieson the launch result. -
Preview Lab.
listPreviewTargets()discovers nearby#PreviewandPreviewProviderdefinitions.resolvePreviewMatch()ranks the best target for the selected source file or the drawer's last selected item, using inspector label/component metadata only as naming hints when a preview must be created.renderCurrentPreview()resolves the match, opens/waits for Xcode, then callsrenderPreview()through Xcode MCP. -
Shutdown.
shutdown({ chatSessionId?, force?, ignoreOwnership? })stops live-view status, releases the active session, stops idb companion work, clears window parking follow state, and emitssession-released. The ownership check runs before any of that, so a refused shutdown leaves the owner's session untouched — including its window-parking follow, which the IPC handler drops only after the call resolves.forceadditionally stops tracked idb companions and clearsactiveLaunchId;ignoreOwnershipdoes neither and only stands the ownership check down. The session also releases on its own when the owning chat closes.
CLI
The agent path is status (gate on supported) → apps → launch →
screenshot --out → proof.
ade --socket ios-sim status --text
ade --socket ios-sim apps --text
ade --socket ios-sim launch --target <id> --follow --text
ade --socket ios-sim screenshot --out .ade/tmp/sim.png --text
ade --socket ios-sim proof --caption "Settings row renders" --text
ade --socket ios-sim shutdown --text
Beyond that: devices, launch --foreground, live-start --fps 60,
stream-status, snapshot, select --x --y, tap --x --y, preview-match,
preview-ensure, preview-current, preview-render, shutdown --force.
Use --socket whenever the CLI and desktop drawer must share live session,
selection, and proof state. launch defaults to the caller's lane worktree and
to a background Simulator.app; --foreground opts into the visible window.
claim --lane <id> only attaches an already-running session to a lane; it is
not a step in a normal launch.
For current-screen Preview Lab work, run select on a source-backed element or
pass an explicit --source and --line, then preview-current. A no-context
result means nothing source-backed is selected; it is not a signal to guess the
SwiftUI screen from stale code.
Troubleshooting
The drawer polls getSimulatorWindowState() and renders the specific
IosSimulatorWindowIssue as an overlay on the video area with one action:
| Issue | Overlay | Action |
|---|---|---|
screen-recording-permission | ADE can't see the simulator window. | Open Privacy & Security > Screen Recording |
automation-denied | ADE can't control Simulator. | Open Privacy & Security > Automation |
not-running / no-window | Simulator not running / no window. | Relaunch |
hidden / minimized | Simulator hidden or minimized. | Reveal |
A stream that reports active but delivers no new frame for ~3s shows a
"No frames" overlay with a restart action — a UI-only watchdog, not an issue.
- Taps or text fail but the live view works: install
idbandidb_companion. The launch result'scapabilitiesflags say up front which inputs are usable. - Live view never renders on a remote Mac runtime: expected. Window capture is
local-only. Use
screenshotandproof. IOS_SIMULATOR_OWNED_BY_OTHER_SESSION: see "Runtime ownership". Closing the owning chat releases it. The drawer offers Attach (adopt the running session without a rebuild) and Take over (force shutdown + relaunch); the CLI equivalent isade --socket ios-sim shutdown --force --text.IOS_SIMULATOR_TARGET_ROOT_MISMATCH: a stored target id points outside the resolved root — abuiltid carrying an absolute.apppath from another checkout, or aprojectid naming a.xcodeprojthat does not exist here. Re-runade --socket ios-sim apps --textand relaunch with an id from that list.IOS_SIMULATOR_NO_BUILDABLE_TARGET: nothing buildable under the root and the caller named no target, so the only candidates would run stale code. The message lists buildable targets when any exist;--target-id/--bundle-idselects an installed app deliberately.IOS_SIMULATOR_LAUNCH_IN_PROGRESS: a launch is already running; the message carries itslaunchId. Wait rather than retrying, orade --socket ios-sim shutdown --force --textif it is wedged — a force shutdown releases the launch lock as well as the session.IOS_SIMULATOR_LANE_NOT_RESOLVED: the named lane has no worktree on this machine. Pass--project-rootwith the checkout you actually want built.- Target discovery wrong: run
ade --socket ios-sim apps --textbefore creating schemes or project shims.