On-device iOS touch automation and screen streaming. A client on the local network connects over TCP to see the screen and drive the device. No Mac, no jailbreak, no WebDriverAgent.
The app is a self-runner: it plays both the IDE (DTX to testmanagerd) and the test runner (XCTest via dlopen) in one process. That's what removes the need for WDA — on iOS 26, AMFI strips all environment variables from processes launched via AppService, so XCTestSessionIdentifier never survives to a separately-spawned runner. Setting it on ourselves with setenv does survive.
- VPN loopback — LocalDevVPN maps
10.7.0.0 ↔ 10.7.0.1, giving a network path to our ownlockdownd - Lockdown — TCP to
10.7.0.1:62078, TLS session from the pairing record - Heartbeat — marco/polo keepalive on a background thread; keeps the DDI mounted
- CDTunnel —
StartService(CoreDeviceProxy)→ RemoteServiceDiscovery → developer services - DTX — two connections to
com.apple.dt.testmanagerd.remotethrough an RSD proxy: control session + test session. The lockdown-basedtestmanagerdservices don't exist on iOS 26, so the RSD path is required. - XCTest —
dlopenXCTest.framework, then theXCTRunnerDaemonSessionXPC handshake (~50s on first call) enableAutomationModeWithError:— triggers the iOS passcode prompt; on entry, the "Automation Running" overlay appears
Call only enableAutomationModeWithError: here. finishInitializationForUIAutomation, requestAutomationSessionForTestTargetWithPID:, and _XCT_enableAutomationModeWithReply: all tear the overlay back down within a second.
Once automation is enabled, the tunnel is out of the loop entirely:
- Touch — build an
XCPointerEventPath(geometry + timing), wrap it in anXCSynthesizedEventRecord, callsynthesizeWithError:. Synchronous and in-process. AdaemonProxy._XCT_synthesizeEvent:fallback exists but carries a ~5s quiescence wait per event, so it is not the path normally taken. - Screenshot —
daemonProxy._XCT_requestScreenshot:over the already-open testmanagerd XPC session, requesting JPEG directly. ~15-25 FPS. Falls back to RemoteServer/ScreenshotClient over a fresh CDTunnel per frame after five consecutive failures.
Every gesture is those same two objects, differing only in how the path is drawn: a tap is a touch-down plus liftUpAtOffset:0.125; a swipe adds interpolated moveToPoint:atOffset: steps; a pinch is two paths in one record; keyboard input swaps initForTouchAtPoint: for initForTextInput. Hardware buttons (home, volume) go through XCUIDevice.pressButton: instead. An IOKit HID path is wired up as a last resort but does not deliver system-wide touches without additional entitlements.
TCP on port 8347, length-prefixed JSON.
- Screenshot streaming —
{"action":"startStream","params":{"quality":0.3}}begins a continuous stream of length-prefixed JPEG frames (4-byte big-endian length + data).TCP_NODELAYis on. - Touch relay — clients stream
touchBegan/touchMoved/touchEndedas the finger moves. The server accumulates every point with its timing and, ontouchEnded, replays the whole trajectory as one continuousXCPointerEventPathrather than a burst of taps. - Fire-and-forget — touch and gesture commands return success immediately and synthesize in the background, keeping round-trip latency off the critical path.
- iOS 26+ (tested on iPhone 13 Pro, iOS 26.3)
- DDI mounted + VPN loopback, via LocalDevVPN or similar
- A pairing record from a trusted Mac (
/var/db/lockdown/or Xcode) - Rust toolchain, to build the idevice FFI library
Requires XcodeGen, Xcode 16+, and Rust.
git clone --recursive <repo-url> && cd TouchSynthesis
rustup target add aarch64-apple-ios
./scripts/build-idevice.sh # cross-compile idevice FFI
xcodegen generate
xcodebuild -project TouchSynthesis.xcodeproj -scheme TouchSynthesis \
-sdk iphoneos -allowProvisioningUpdates build- Open LocalDevVPN (DDI mount + VPN loopback)
- Launch TouchSynthesis and import the pairing record (first run only)
- Tap Start UI Automation, wait ~60s, and enter the passcode when prompted
- Tap Start Server to open the TCP listener on 8347, then connect a client
- Stop UI Automation tears everything down — hold the volume buttons if the overlay lingers
| Path | Contents |
|---|---|
TestManager/ |
TestManagerClient (DTX RPC to testmanagerd), SelfRunner (orchestration) |
TouchSynthesizer/ |
XCTest dlopen, event construction, synthesis, screenshots, IOKit HID |
DTX/ |
Connection, message codec, channel multiplexing, auxiliary encoding |
Lockdown/ |
TCP + TLS lockdownd client |
RemoteControl/ |
TCP server, command dispatch, streaming, touch accumulation |
idevice/ |
ObjC wrapper over the Rust FFI: CDTunnel, heartbeat, RSD proxies |
App/, Model/, Util/ |
UI, pairing record parsing, logging, background keepalive |
Build artifacts (idevice.h, libidevice_ffi.a) land in idevice/; the upstream submodule is in vendor/idevice.