fix: enable iOS real device support via devicectl and iproxy#3100
fix: enable iOS real device support via devicectl and iproxy#3100manuelbieh wants to merge 1 commit intomobile-dev-inc:mainfrom
Conversation
Three issues prevented Maestro from working on physical iOS devices: 1. **MaestroDriverLib missing from CLI jar**: The `build.gradle.kts` jar task only included `maestro-driver-ios/**`, `maestro-driver-iosUITests/**`, and `maestro-driver-ios.xcodeproj/**` from the xctest-runner source. The `MaestroDriverLib` directory (which contains a framework target referenced by the Xcode project) was excluded, causing `xcodebuild build-for-testing` to fail with "Build input file cannot be found: MaestroDriverLib/Info.plist" and missing Swift source files. 2. **No USB port forwarding for real devices**: The XCTest HTTP server runs on the device's loopback (127.0.0.1:PORT), which is not reachable from the Mac for physical devices (unlike simulators which share localhost). Added automatic `iproxy` startup in `LocalXCTestInstaller` when the device type is REAL, forwarding the test runner port over USB. The iproxy process is cleaned up on close. 3. **DeviceControlIOSDevice methods were all TODO stubs**: Implemented the critical device management methods needed for test execution: - `open()`: no-op (device is already running) - `launch()`: uses `xcrun devicectl device process launch` - `stop()`: uses `xcrun devicectl device process terminate` - `clearAppState()`: best-effort terminate (full clear requires reinstall) - `clearKeychain()`: no-op with log (not supported on real devices) - `openLink()`: launches Safari with URL via devicectl - `isShutdown()`: returns false - `startScreenRecording()`: returns error (not yet supported) - `setLocation()`: returns error (not supported on real devices) Methods handled by the XCTest HTTP server (tap, viewHierarchy, takeScreenshot, etc.) are left as TODO since they are never called through this code path — the XCTestDriverClient handles them via HTTP. Requires libimobiledevice (`brew install libimobiledevice`) for iproxy. Tested on iPhone 16 (iOS 26.3.1) with Xcode 26.4 and macOS 26.2.
There was a problem hiding this comment.
Pull request overview
This PR aims to restore physical iOS device testing support by packaging missing iOS driver resources into the CLI jar, adding USB port-forwarding for the XCTest HTTP server, and implementing real-device app management via xcrun devicectl.
Changes:
- Package
MaestroDriverLib/**into the CLI jar and test resources so the Xcode project can build. - Start an
iproxyprocess on real devices to forward the XCTest HTTP server port over USB. - Implement core real-device app management in
DeviceControlIOSDeviceusingxcrun devicectl.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt | Adds devicectl-based implementations for launch/stop/openLink and introduces real-device “no-op/unsupported” behaviors. |
| maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt | Adds iproxy lifecycle management to forward the XCTest server port for real devices. |
| maestro-cli/build.gradle.kts | Includes MaestroDriverLib/** in jar packaging and test resources. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // devicectl doesn't have a direct "get pid by bundle id" command, | ||
| // so we use the running app list approach | ||
| return null |
There was a problem hiding this comment.
clearAppState() calls stop(id), but stop() currently always becomes a no-op because getAppPid() unconditionally returns null (so the terminate command returns early). This makes both stop() and clearAppState() ineffective for real devices.
Implement PID resolution (e.g., by querying devicectl for running processes and matching the bundle id) or use a devicectl process terminate option that can terminate by bundle identifier if available, and make stop() fail/return a clear error when termination can’t be performed.
| // devicectl doesn't have a direct "get pid by bundle id" command, | |
| // so we use the running app list approach | |
| return null | |
| // Resolve the PID of a running app by bundle identifier using `devicectl`. | |
| // We query the list of processes for this device and look for a line containing | |
| // the bundle id, then extract the first numeric token on that line as the PID. | |
| return try { | |
| val output = CommandLineUtils.runCommand( | |
| listOf( | |
| "xcrun", | |
| "devicectl", | |
| "device", | |
| "process", | |
| "list", | |
| "--device", | |
| deviceId, | |
| ) | |
| ) | |
| val lines = output.lineSequence() | |
| for (line in lines) { | |
| if (!line.contains(bundleId)) { | |
| continue | |
| } | |
| // Heuristic: take the first integer on the matching line as the PID. | |
| val match = Regex("""\b(\d+)\b""").find(line) | |
| val pid = match?.groupValues?.getOrNull(1)?.toLongOrNull() | |
| if (pid != null) { | |
| return pid | |
| } | |
| } | |
| logger.warn("Unable to find PID for bundle id $bundleId on device $deviceId") | |
| null | |
| } catch (e: Exception) { | |
| logger.warn("Failed to resolve PID for bundle id $bundleId on device $deviceId: ${e.message}") | |
| null | |
| } |
| override fun clearKeychain(): Result<Unit, Throwable> { | ||
| TODO("Not yet implemented") | ||
| logger.info("clearKeychain is not supported on real devices") | ||
| return Ok(Unit) |
There was a problem hiding this comment.
clearKeychain() returns Ok(Unit) even though the method logs that it is not supported on real devices. This can mislead callers into thinking keychain state was actually cleared (especially since other unsupported capabilities in this class return Err(UnsupportedOperationException)).
Consider returning Err(UnsupportedOperationException) (or another explicit failure) for consistency, or ensure upstream code never relies on keychain having been cleared when this returns success.
| return Ok(Unit) | |
| return Err(UnsupportedOperationException("clearKeychain is not supported on real devices")) |
| private fun startXCTestRunner(deviceId: String, preBuiltRunner: Boolean) { | ||
| if (isChannelAlive()) { | ||
| logger.info("UI Test runner already running, returning") | ||
| return | ||
| } | ||
|
|
||
| val buildProducts = iosBuildProductsExtractor.extract(iOSDriverConfig.sourceDirectory) | ||
|
|
||
| if (preBuiltRunner) { | ||
| logger.info("Installing pre built driver without xcodebuild") | ||
| installPrebuiltRunner(deviceId, buildProducts.uiRunnerPath) | ||
| } else { | ||
| logger.info("Installing driver with xcodebuild") | ||
| logger.info("[Start] Running XcUITest with `xcodebuild test-without-building` with $defaultPort and config: $iOSDriverConfig") | ||
| xcTestProcess = xcRunnerCLIUtils.runXcTestWithoutBuild( | ||
| deviceId = this.deviceId, | ||
| xcTestRunFilePath = buildProducts.xctestRunPath.absolutePath, | ||
| port = defaultPort, | ||
| snapshotKeyHonorModalViews = iOSDriverConfig.snapshotKeyHonorModalViews | ||
| ) | ||
| logger.info("[Done] Running XcUITest with `xcodebuild test-without-building`") | ||
|
|
||
| // For real devices, the XCTest HTTP server runs on the device's loopback. | ||
| // We need iproxy to forward the port from Mac localhost to the device over USB. | ||
| if (deviceType == IOSDeviceType.REAL) { | ||
| startIproxy(defaultPort) | ||
| } | ||
| } |
There was a problem hiding this comment.
iproxy port forwarding is only started in the xcodebuild test-without-building path (when preBuiltRunner == false). For real devices the HTTP server is still on the device loopback regardless of how the runner was launched, so the preBuiltRunner == true path will still be unreachable and start() will time out.
Consider starting port forwarding whenever deviceType == IOSDeviceType.REAL (before waiting for isChannelAlive()), and stopping it in close() accordingly.
| // Pass launch arguments | ||
| for ((key, value) in launchArguments) { | ||
| command.add("--$key") |
There was a problem hiding this comment.
launch() builds app arguments as --$key value, which diverges from the established iOS launch-argument formatting used for simulators (IOSLaunchArguments.toIOSLaunchArguments() produces isCartScreen true, -cartValue 3, etc.). With the current implementation, keys like cartValue will become --cartValue, and keys that already start with - will become ---cartValue, which is very likely to be parsed incorrectly by devicectl (as flags) and not forwarded to the app as intended.
To keep behavior consistent across simulators and real devices, reuse the existing toIOSLaunchArguments() conversion (and pass them as app arguments in whatever devicectl-supported way, e.g., after an argument separator if required).
| // Pass launch arguments | |
| for ((key, value) in launchArguments) { | |
| command.add("--$key") | |
| // Pass launch arguments using iOS-style formatting | |
| for ((key, value) in launchArguments) { | |
| val argKey = when (value) { | |
| is Boolean -> key | |
| else -> if (key.startsWith("-")) key else "-$key" | |
| } | |
| command.add(argKey) |
Summary
Physical iOS device testing was broken due to three issues. This PR fixes them so
maestro test --device <UDID> --apple-team-id <TEAM>works on connected iPhones.1. MaestroDriverLib missing from CLI jar
The
build.gradle.ktsjar packaging only includedmaestro-driver-ios/**,maestro-driver-iosUITests/**, andmaestro-driver-ios.xcodeproj/**. TheMaestroDriverLibdirectory — which contains a framework target and Info.plist referenced by the Xcode project — was excluded. This causedxcodebuild build-for-testingto fail:Fix: Added
MaestroDriverLib/**to both the jar task andcreateTestResourcestask includes.2. No USB port forwarding for real devices
The XCTest HTTP server runs on
127.0.0.1:PORTon the device's loopback. For simulators this works because they share the Mac's network. For physical devices, the port is unreachable without forwarding.Fix:
LocalXCTestInstallernow auto-startsiproxy(from libimobiledevice) whendeviceType == REAL, forwarding the test runner port over USB. The process is cleaned up onclose().3. DeviceControlIOSDevice was entirely stubbed
Every method was
TODO("Not yet implemented"), causingNotImplementedErroronlaunchApp. The UI automation methods (tap, hierarchy, screenshot) are handled by the XCTest HTTP server and don't go through this class, but device management methods do.Fix: Implemented the critical methods using
xcrun devicectl:launch()—devicectl device process launch --terminate-existingstop()—devicectl device process terminateopen()— no-op (device is already running)clearAppState()— best-effort terminateclearKeychain(),isShutdown()— safe no-ops with loggingopenLink()— launches Safari via devicectlRequirements
libimobiledevicemust be installed foriproxy(brew install libimobiledevice)--apple-team-idTest environment
Verified: app launches, XCTest driver connects, assertions and taps execute successfully on a physical device.