Skip to content

feat(physical-ios): add WebDriverAgent parity support#489

Draft
pr-zkp2p wants to merge 26 commits into
software-mansion:mainfrom
pr-zkp2p:feat/physical-ios-e2e
Draft

feat(physical-ios): add WebDriverAgent parity support#489
pr-zkp2p wants to merge 26 commits into
software-mansion:mainfrom
pr-zkp2p:feat/physical-ios-e2e

Conversation

@pr-zkp2p

Copy link
Copy Markdown

Summary

  • replace the limited physical-iOS CoreDevice input path with a persistent WebDriverAgent/XCTest backend that automatically discovers signing, builds and caches WDA, and reuses one session per phone
  • route tap, swipe, long press, arbitrary touch/pan, pinch, rotate, keyboard, paste, hardware buttons, orientation, screenshots, accessibility, deep links, and app lifecycle through the same public tools used by simulators
  • add continuous physical/simulator unified-log sessions and physical-device Time Profiler capture, including protected system apps through an all-processes capture filtered to the app PID
  • ship Appium/XCUITest and WebDriverAgent runtime assets correctly in @swmansion/argent, and update packaged rules/skills so agents discover connected physical iPhones

Why

Argent's existing physical-iOS experiment depended on a CoreDevice control surface that was unavailable for the tested iOS version and only covered a small subset of the simulator interface. Agents could discover the phone but could not reliably type, use multi-touch, inspect the foreground hierarchy, profile protected apps, or run realistic end-to-end workflows.

The new backend keeps the existing tool contracts and normalized coordinates, so callers select the kind: "device" target returned by list-devices and use the normal Argent tools.

Validation

  • npm run build
  • physical-iOS regression suites: 28/28 tests passing
  • package manifest/driver asset regression: 3/3 tests passing
  • isolated await-ui-element suite: 37/37 tests passing
  • isolated package dispatcher suite: 6/6 tests passing
  • complete tool-server suite: 235 files / 2,159 tests passing
  • git diff --check

Live end-to-end validation used an iPhone 15 Pro on iOS 26.2 and an iPhone 17 simulator. Apple Maps was driven through search, keyboard input, tap, swipe, hold, interpolated pan, pinch, and rotation while unified logs and native profiling ran concurrently. Three-pass physical control registration remained within 5% of the simulator for every measured gesture (largest difference: tap at 4.59%).

Platform notes

  • XCTest does not expose App Switcher on a physical phone.
  • Protected system apps support CPU and potential-hang analysis; Leaks/Allocations still require a process-scoped task port.
  • The feature remains opt-in with argent enable physical-ios-devices.

latekvo and others added 26 commits June 19, 2026 13:44
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.
…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
…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.
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.
@pr-zkp2p pr-zkp2p force-pushed the feat/physical-ios-e2e branch from d843f14 to 311c61e Compare July 10, 2026 19:22
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.

3 participants