Skip to content

feat(ios): control physical iOS devices over CoreDevice (experimental)#369

Draft
latekvo wants to merge 25 commits into
mainfrom
feat/physical-ios-device-support
Draft

feat(ios): control physical iOS devices over CoreDevice (experimental)#369
latekvo wants to merge 25 commits into
mainfrom
feat/physical-ios-device-support

Conversation

@latekvo

@latekvo latekvo commented Jun 18, 2026

Copy link
Copy Markdown
Member

Physical iOS device support (experimental)

Adds app-free control of a physical iPhone (iOS 27+) over Apple's CoreDevice
"remote control" services (the path Xcode's device window uses) via
pymobiledevice3no app installed on the
device, no WebDriverAgent, no code signing
. Gated behind a new physical-ios-devices flag.

Mirrors the existing physical-Android split: a real iPhone is a separate backend
(blueprints/core-device.ts), selected by device.kind === "device", the same way
android_device differs from the emulator controller.

Supported on physical iOS

list-devices (shows the iPhone as kind: "device"), screenshot, gesture-tap,
gesture-swipe, button, launch-app (via devicectl), and describe (the SpringBoard
home-screen grid — see below). Every other tool returns a clear "not supported on physical iOS"
error rather than mis-routing.

Requirements (⚠️ needs sudo, once)

  • iOS 27+ — Apple gates host-driven input ("remote control") to 27.0+; below that the
    device returns CoreDeviceError 9021 and only screenshots work.
  • pymobiledevice3 installed; iPhone connected, unlocked, trusted, Developer Mode on.
  • A running CoreDevice tunnel: sudo pymobiledevice3 remote tunneld — this needs root
    once to create the tunnel interface; every Argent command then runs unprivileged.
  • Enable the flag: argent enable physical-ios-devices.

The sudo/tunnel requirement is surfaced in the runtime error, the README, the flag
description, and the list-devices description.

Performance: persistent CoreDevice sidecar

Interactions no longer spawn the pymobiledevice3 CLI per call (~0.8s each, ~0.5s of which is
just import pymobiledevice3). A single long-lived helper per device connects the RSD tunnel and
opens the HID / screenshot services once, then serves newline-delimited JSON on stdio. Warm
tap/button/swipe drop from ~800ms to ~150-320ms (5x), measured live through the tool-server
on an iPhone 15 (iOS 27). The helper is a small Python program base64-embedded in
coredevice-agent.ts (survives esbuild, no build-pipeline wiring), materialized to a temp file and
run with pmd3's own venv interpreter; core-device.ts maps its responses (incl. the 9021 gate) to
FailureErrors and disposes the process with the service.

describe — the real on-screen accessibility tree (app-free, any app)

describe on a physical iPhone returns the device's live accessibility tree — the frontmost
app's elements (or the home screen) — read app-free via the iOS-26+ axAudit service over CoreDevice.
Labels, values, traits (mapped to roles) and reading order are exact.

The unlock: iOS 26 re-plumbed the accessibility daemon onto RSD and it now needs the RSDCheckin
handshake before it accepts DTX framing. pmd3's DtxServiceProvider opens RSD services with a raw
connect and skips RSDCheckin, so iOS 26/27 dropped it on the first byte — which had read as an
"Apple wall". The fix is to open the service through start_lockdown_service (which performs
RSDCheckin); the sidecar does that. (installation_proxy worked over the same tunnel because it
already used that path — the entitlement was never the problem.)

Verified live (iPhone 15 / iOS 27): describe Settings → read the "Other…" button's frame →
gesture-tap its centre → the Join-Wi-Fi form opened. On the home screen it lists every app + widget.

Frames: Apple exposes no per-element geometry on hardware, so exact rects come only from the
accessibility audit (a subset of elements, correlated to the tree by element id); the rest are
interpolated from reading-order neighbours — enough to tap a list row, with a hint to confirm precise
taps via screenshot. Pixel-exact in-app frames/taps would need an on-device XCUITest runner
(code-signing), which is a possible follow-up.

Not supported yet (clean rejections)

keyboard/typing, pinch & rotate (multi-touch), open-url, reinstall-app, restart-app,
native-* (simulator-only mechanisms).

Verification

  • tsc --build, typecheck:tests, typecheck:scripts, prettier --check . all clean;
    1207 tool-server + 38 configuration-core unit tests pass (new suite covers
    classification, devicectl parsing, coord mapping).
  • E2E live on a physical iPhone 15 (iOS 27) through the tool-server HTTP API:
    list-devices (kind:device), screenshot, button (woke device), gesture-swipe
    (Notification Center), gesture-tap (opened Messages).
  • Sidecar + describe re-verified live (iPhone 15, iOS 27) through the tool-server:
    list-devices → single kind:"device" (no phantom sims); warm gesture-tap 155ms,
    button 187ms, gesture-swipe 323ms (vs ~810ms per CLI call); describe → home-screen
    tree, and tapping "Settings" via its describe frame opened Settings. The in-app AX wall was
    reproduced against the real axAuditDaemon (see the describe section).
  • Clean-setup audit: flag OFF hides the device; flag ON shows it; missing
    pymobiledevice3 → install hint; no tunnel → the sudo … tunneld error above.

Notes

Interactions run through a persistent pymobiledevice3 sidecar (one process per device); a
native re-implementation in the Rust simulator-server is still a possible follow-up. Env overrides:
ARGENT_PYMOBILEDEVICE3, ARGENT_PMD3_TUNNELD_PORT.

@latekvo latekvo force-pushed the feat/physical-ios-device-support branch 4 times, most recently from a1d97e7 to 5a91c67 Compare June 18, 2026 16:27
Drive a real iPhone (iOS 27+) app-free via Apple's CoreDevice remote-control
services through pymobiledevice3 — no on-device app, no WebDriverAgent. Gated
behind the `physical-ios-devices` flag.

- Discovery: classify physical UDIDs (8hex-16hex) as ios/device; list connected
  iPhones via `xcrun devicectl`, flag-gated.
- CoreDevice backend (blueprints/core-device.ts): resolves the tunneld RSD
  endpoint, ensures the DDI is mounted, shells to pymobiledevice3 for screenshot,
  tap (dwell-drag — a zero-dwell tap is ignored by iOS), swipe, and buttons.
- Branded privilege escalation: the tunnel needs root once to create its network
  interface. Instead of manual sudo, Argent auto-starts it via a signed macOS
  host helper (argent-device-auth) that shows the standard auth modal branded as
  "Argent" with the Argent icon (Authorization Services + dlsym of
  AuthorizationExecuteWithPrivileges). Falls back to the osascript admin prompt,
  then manual-sudo instructions (headless). HOME is pinned so tunneld finds its
  pairing records under the privileged-exec environment.
- Wire screenshot/gesture-tap/gesture-swipe/button to the backend; launch-app via
  devicectl; boot-device starts the tunnel (branded prompt). Unsupported tools on
  physical iOS are cleanly rejected (simulator-server / ax-service /
  native-devtools guards; open-url/reinstall/restart handlers).
- Helper resolvers + Argent icon in @argent/native-devtools-ios;
  download-native-binaries.sh fetches the signed helper (optional until
  published); bundle-tools copies it for distribution. Helper source + signing
  live in argent-private.
- Tests for classification, devicectl parsing, coord mapping, and privileged
  command building. Overrides: ARGENT_PYMOBILEDEVICE3, ARGENT_PMD3_TUNNELD_PORT,
  ARGENT_DEVICE_AUTH_HELPER, ARGENT_DEVICE_ICON.
@latekvo latekvo force-pushed the feat/physical-ios-device-support branch from 5a91c67 to 6710eef Compare June 19, 2026 11:44
latekvo added 3 commits June 22, 2026 15:35
…y target list

The preview / Argent Lens window streams frames over simulator-server, which
refuses physical iOS devices (kind === "device", driven over CoreDevice). With
the physical-ios-devices flag on, a connected iPhone leaked into
GET /preview/simulators because the iOS branch matched any platform === "ios"
without checking kind — surfacing a target the UI can't drive (and emitting an
entry with no runtime under a type that asserted runtime: string).

- Exclude physical iOS (kind === "device") from the preview list, mirroring the
  existing Chromium exclusion.
- Consume list-devices' own exported ListDevicesResult type instead of a
  hand-rolled copy that had already drifted from it (the soundness hole tsc
  couldn't catch because invokeTool<T> takes a caller-supplied generic).
- Extract the mapping into a pure, exported devicesToPreviewEntries() and add
  regression tests asserting physical iOS + Chromium are excluded while the
  simulator and Android emulator are kept.
…app behavior

list-devices description and the simulator-server guard message listed
screenshot/gesture-tap/gesture-swipe/button but omitted launch-app, which
is implemented for physical iOS via devicectl. The README limitations list
omitted restart-app, which (like open-url/reinstall-app) returns a clear
"not supported" error on physical iOS.
…ice-support

# Conflicts:
#	packages/tool-server/src/preview.ts
#	packages/tool-server/src/tools/open-url/platforms/ios.ts
@latekvo latekvo marked this pull request as ready for review June 22, 2026 16:18
@latekvo latekvo marked this pull request as draft June 25, 2026 10:44
latekvo added 16 commits June 26, 2026 14:09
…lity matrix

Review follow-ups on the physical-iOS CoreDevice feature:

- discovery: parsePhysicalIosDevices surfaced every host iOS simulator as a
  phantom physical device (devicectl lists simulators with transportType
  "sameMachine"); gate on the physical ECID UDID shape, which also aligns
  discovery with classifyDevice's routing. Verified against real devicectl JSON
  and a connected iPhone (returns only the device, not the host's simulators).
- run-sequence: was eagerly resolving simulator-server for a physical iPhone,
  failing the whole call before step 1; hold no eager service so per-step
  CoreDevice routing works.
- describe: swallowed the simulator-only guards and returned an empty tree with
  a misleading "reboot the simulator" hint; reject with a clear error instead.
- swipe: command timeout was a fixed 15s, killing long swipes mid-drag; scale
  the timeout with the requested duration and clamp degenerate durations
  (extracted swipeDragParams, unit-tested).
- native-profiler: guard the session blueprint so a physical iPhone isn't driven
  via simulator-only simctl (which mislabeled it an unresponsive "simulator").
- capability matrix: tools whose iOS backend is simulator-only (keyboard, paste,
  rotate, gesture-pinch/rotate/custom, screenshot-diff, native-devtools-*,
  native-profiler-start) now declare apple.device:false, so physical iOS is
  rejected with a clean 400 at the capability gate rather than a 500 from a deep
  service guard. open-url/reinstall-app/restart-app throw UnsupportedOperationError.
- launch-app: wrap the devicectl launch so "app not installed/signed" surfaces a
  clean message instead of a raw subprocess blob.
- core-device: check the feature flag before the pymobiledevice3 setup probe;
  echo a custom tunneld port in the manual-fallback hint.
- docs: flags description and README now match the supported tool set and the
  auto-start prompt reality.

Verified end-to-end on a physical iPhone (iOS 27): discovery, tunnel
auto-escalation, screenshot (1179x2556), and swipe.
…e entry

The previous commit guarded the shared native-profiler-session blueprint
against physical iOS, but that blueprint also backs the device-agnostic
analysis tools (profiler-stack-query / profiler-combined-report / -analyze),
which profiler-query-android-capability.test.ts intentionally keeps supported
on Apple device. The guard made those analysis tools 500 on a physical-iOS
device_id.

Scope the fix to where the actual bug is: only native-profiler-start enumerates
running processes via simulator-only `simctl` (mislabeling a real iPhone as an
unresponsive "simulator"), and it already declares apple.device:false, so the
capability gate rejects it cleanly (400) before execute() runs. Drop the
redundant session-blueprint guard; the analysis tools keep their cross-device
capability. Also formats physical-ios-followups.test.ts.
launch-app drives a physical iPhone via `xcrun devicectl` directly rather than
the CoreDevice service, so its handler never hit the flag check that gates every
other physical-iOS operation — with `physical-ios-devices` off, launch-app on a
physical UDID still launched the app. Extract the gate into a shared
assertPhysicalIosEnabled() (reused by the CoreDevice factory and tunnel start)
and call it from the launch-app physical-iOS branch before shelling devicectl.

Add coverage: the launch-app flag gate, assertPhysicalIosEnabled, and the
gesture-swipe physical-iOS routing (coords + duration forwarding).
… as unsupported

The README limitations section listed only the interaction/lifecycle tools that
reject on physical iOS; align it with the actual behavior by also noting that
describe (accessibility), the native-* inspection tools, native-profiler-*, and
screenshot-diff are simulator-only.
…; cover pmd3 argv

On iOS 18-26 Apple gates host-driven input (the CoreDevice "remote control"
services behind tap/swipe/button) to iOS 27+, so the device is discovered and
advertised as tap-capable, then fails mid-session with a raw `CoreDeviceError
9021`. Detect that specific failure in `conciseError` (matched against
pymobiledevice3's stderr/stdout only, never the argv-echoing execFile message,
so a HID coordinate that contains "9021" can't false-positive) and surface an
actionable message that points at iOS 27 and notes screenshots still work.
`screen-capture` is not gated, so the screenshot path is untouched.

Also note the physical-iOS/CoreDevice routing in the gesture-tap and
gesture-swipe descriptions, and add a focused test that drives the CoreDevice
factory through a mocked execFile boundary to pin the exact pmd3 argv for tap,
button, screenshot, and swipe (previously untested — every other physical-iOS
test mocks the CoreDeviceApi wholesale) plus the 9021 translation.
…ice-support

# Conflicts:
#	packages/tool-server/src/preview.ts
…quivalent

button's services() resolved coreDeviceRef() unconditionally for any
physical-iOS button press, before execute() validates whether the
button is even supported. Since the registry resolves services()
before execute(), pressing appSwitch/actionButton on a physical
iPhone triggered CoreDevice's tunnel-setup side effects (possibly a
macOS admin prompt) just to reject the button afterward. Skip
resolving the service when the button has no CoreDevice HID mapping.
…essage accuracy

core-device.ts threw plain Error at all 12 sites, and ax-service.ts /
simulator-server.ts's own new physical-iOS rejection branches did too
- unlike every sibling blueprint, which uses FailureError with a
dedicated error_code. Every physical-iOS failure (auth declined, no
tunnel, missing pymobiledevice3, iOS<27 gate, flag disabled) was
landing in telemetry as generic REGISTRY_TOOL_FAILURE_UNCLASSIFIED,
indistinguishable from any other crash. Add CORE_DEVICE_* /
AX_PHYSICAL_DEVICE_UNSUPPORTED / SIMULATOR_SERVER_PHYSICAL_DEVICE_UNSUPPORTED
failure codes and convert every throw site.

Also, three smaller correctness fixes found in the same pass:
- resolveTunnel's `tunnel-port == null` check let a port of 0 through
  as "present", inconsistent with the sibling tunnel-address check
  which treats any falsy value as missing.
- The iOS<27 gate message claimed "tap/swipe/button" all require
  iOS 27+, but this is hardware-verified false: screenshot and
  hardware buttons work below iOS 27, only touch (tap/swipe) is
  actually gated.
- conciseError's line-matching heuristic could pick a misleading
  rich-boxed Python source-context line (e.g. "if rsd is not None:")
  instead of the real exception (e.g. "OSError: No route to host")
  from a stale-tunnel traceback, and searched execFile's synthesized
  "Command failed: <argv>" message with equal priority to the real
  stderr/stdout output.
… too

native-profiler-start already rejects physical iOS (apple.device:false)
since live capture uses simctl process enumeration that mislabels a
real iPhone as a simulator. stop/analyze were left at device:true, so
- since a session can never exist for physical iOS - they always fell
through to a generic "no active session" error instead of the same
clean, actionable rejection. Also fixes the README's "native-profiler-*"
claim, which the stop/analyze gap made inaccurate, and two other
accuracy issues found in the same pass: the iOS<27 limitation
overstated to include hardware buttons, and a note that launch-app
(devicectl) doesn't need the CoreDevice tunnel other tools require.
# Conflicts:
#	packages/argent/scripts/bundle-tools.cjs
#	packages/native-devtools-ios/src/index.ts
#	packages/tool-server/src/blueprints/native-devtools.ts
#	packages/tool-server/src/blueprints/simulator-server.ts
#	packages/tool-server/src/tools/describe/platforms/ios/index.ts
#	packages/tool-server/src/tools/devices/boot-device.ts
#	packages/tool-server/src/tools/devices/list-devices.ts
#	packages/tool-server/src/tools/gesture-custom/index.ts
#	packages/tool-server/src/tools/gesture-pinch/index.ts
#	packages/tool-server/src/tools/gesture-rotate/index.ts
#	packages/tool-server/src/tools/keyboard/index.ts
#	packages/tool-server/src/tools/launch-app/index.ts
#	packages/tool-server/src/tools/launch-app/platforms/ios.ts
#	packages/tool-server/src/tools/native-devtools/native-describe-screen.ts
#	packages/tool-server/src/tools/native-devtools/native-devtools-status.ts
#	packages/tool-server/src/tools/native-devtools/native-find-views.ts
#	packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts
#	packages/tool-server/src/tools/native-devtools/native-network-logs.ts
#	packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts
#	packages/tool-server/src/tools/native-devtools/native-view-at-point.ts
#	packages/tool-server/src/tools/paste/index.ts
#	packages/tool-server/src/tools/restart-app/index.ts
#	packages/tool-server/src/tools/restart-app/platforms/ios.ts
#	packages/tool-server/src/tools/rotate/index.ts
#	packages/tool-server/src/tools/run-sequence/index.ts
#	packages/tool-server/src/tools/screenshot/index.ts
#	packages/tool-server/src/utils/device-info.ts
#	packages/tool-server/src/utils/ios-devices.ts
#	scripts/download-native-binaries.sh
# Conflicts:
#	packages/tool-server/src/preview.ts
The app-free CoreDevice accessibility path was investigated on a physical
iPhone (iOS 27). The on-device accessibility tree is served by CoreDevice's
axAuditDaemon, but Apple gates it: the DTX service
`com.apple.accessibility.axAuditDaemon.remoteserver.shim.remote` requires the
`com.apple.mobile.lockdown.remote.trusted` entitlement — over the developer
(untrusted) CoreDevice tunnel pymobiledevice3 forms, the daemon accepts the
socket but terminates it on the first request (every audit selector, and even
the standard DTX capability handshake). The RemoteXPC replacement
`…axAuditDaemon.remoteAXService` requires `AppleInternal`, unreachable by any
third party. DTX transport itself works over the same tunnel
(`developer dvt proclist` succeeds), so this is Apple's auth wall, not a
transport gap. Screenshot (`canViewDeviceDisplay`) remains the only app-free
screen capture.

Replace the imprecise describe rejection ("no on-device accessibility/describe
path") and the ax-service backstop's "not supported yet" with accurate,
evidence-based messages, and split describe out of the README "not yet" list.
Drive a physical iPhone through one long-lived pymobiledevice3 process per
device instead of spawning the CLI per interaction. Each `pymobiledevice3`
invocation cost ~0.8s (~0.5s just `import pymobiledevice3`); the sidecar
connects the RSD tunnel and opens the HID/screenshot services once, so a
warm tap/button/swipe is now ~150-320ms (5x) — verified live on an iPhone 15
(iOS 27) through the tool-server. The agent is a small Python program,
base64-embedded in coredevice-agent.ts (survives esbuild, no build-pipeline
wiring), materialized to a temp file and run with pmd3's own venv interpreter;
it speaks newline-delimited JSON over stdio. core-device.ts now maps its
responses (incl. the iOS-27 9021 gate) to FailureErrors and tears the process
down on dispose.

Also make `describe` work on a physical iPhone: it returns the SpringBoard
home-screen layout (app icons + dock) via CoreDevice's springboardservices —
the only app-free structured screen data on a real device. Icon frames are
derived from the home-screen grid (row-major first-fit so multi-cell widgets
don't shift the icons after them) and are approximate; the hint says to
confirm with screenshot and that in-app content isn't reachable. Verified the
describe→tap loop live (tapped Settings via describe's frame; it opened).

In-app accessibility stays Apple-gated: the axAuditDaemon DTX service drops
the connection on the first message over the developer tunnel, and its RemoteXPC
replacement needs the AppleInternal entitlement.

Replaces the per-call argv test with sidecar/adapter/agent-protocol coverage.
@latekvo

latekvo commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Describe doesn't work well yet. This is the primary blocker as of now

Replaces the SpringBoard home-screen-only describe with the device's live
on-screen accessibility tree, read app-free over CoreDevice via the iOS-26+
axAudit service. Works in ANY app and on the home screen (the same VoiceOver
walk): labels, values, traits (mapped to roles) and reading order are exact.

The unlock is the RSDCheckin handshake iOS 26 added to RSD DTX services. pmd3's
DtxServiceProvider opens RSD services with a raw connect and skips RSDCheckin,
so iOS 26/27 dropped the accessibility daemon on the first byte (which had read
as an "Apple wall"); the sidecar monkeypatches the open to go through
start_lockdown_service, which performs the checkin. Verified live on iPhone/iOS
27: describe Settings -> read the "Other…" button's frame -> gesture-tap it ->
the Join-Wi-Fi form opened. Home screen lists every app + widget.

Frames: Apple exposes no per-element geometry on hardware, so exact rects come
only from the accessibility audit (a subset of elements, correlated to the tree
by element id); the rest are interpolated from reading-order neighbours -
enough to tap a list row, with a hint to confirm precise taps via screenshot.
Pixel-exact in-app frames would need an on-device XCUITest runner (code-signing).

- sidecar: RSDCheckin fix + an `axtree` op (iter_elements walk + run_audit rects
  + screen size), replacing `homescreen`.
- new ios-coredevice-ax-adapter (caption->role/label parse, rect normalize,
  frame interpolation); removes the springboard adapter. Source: coredevice-ax.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant