Files
pj 90224dfd06 Physical-device iOS support (#64) (#66)
* feat(companion): add appState, eraseText, pressKey runner handlers

The Go runner transport already calls these methods; the in-device runner
implemented them only latently. They become load-bearing on the device
path, where the hybrid's legacy-companion fallback is absent. Backward
compatible: the simulator hybrid never calls them.

* feat(ios): resolve physical devices from devicectl

ResolveDevice parses xcrun devicectl list devices into Device{Name,
HardwareUDID, CoreDeviceID}: the hardware UDID feeds xcodebuild/iproxy
and the CoreDevice id feeds devicectl install. Matches by name or either
id; errors list candidates on none/ambiguous. Fixes the stale sidecar
comment on ResolveTarget.

* feat(ioscompanion): runner-only device driver mode

NewDevice reuses Driver with d.companion set to the runner dialed over an
iproxy usbmux tunnel, hybrid=false, runnerClient=nil. The existing accessor
seams then route launch/snapshot/text/gesture to the runner with no new
DeviceDriver methods. Device seams swap clear-state to a devicectl
reinstall, container reset to a warn-once no-op, and paste grant to a no-op.
realSpawnDeviceRunner builds and signs the runner at run time via the App
Store Connect API key (no Xcode UI), caching on a source hash.

* test(ioscompanion): cover device wiring, routing, and shell-out argv

Seam-driven NewDevice wiring + gesture/text routing (asserting no keyboard
HID), devicectl/build/test/iproxy argv builders, xctestrun test-target dict
name parsing, signing-credential env checks, and source-hash cache keying.

* feat(testrun): route physical-device iOS runs to the device driver

Execute resolves a non-simulator iOS target through ios.ResolveDevice into
its hardware UDID and CoreDevice id; buildDriver constructs NewDevice via a
seam instead of rejecting the device. Generalizes the --ios-device and
--ios-app-path help to cover the device path; signing stays env-read, never
a flag.

* feat(doctor): device prereqs replace java/sidecar for ios-device

iosDeviceChecks now verifies devicectl, iproxy on PATH, a connected+paired
device (via ios.ConnectedDevices), and App Store Connect signing creds (via
ioscompanion.VerifyDeviceSigning). The retired JVM sidecar checks stay only
under android.

* feat(conformance): device backend uses iphoneos app and tunnel orphan checks

The device backend now builds via just ios-device, points --ios-app-path at
the Debug-iphoneos bundle, and reinstalls each run for clear-state. The G5
orphan scan replaces the retired sidecar.jar check with lingering iproxy and
device test-without-building sessions (destination platform=iOS,id=).

* feat(folio): device build linking the iosArm64 framework

project.yml selects the Kotlin framework slice by SDK (iosArm64 for
iphoneos, iosSimulatorArm64 for simulator) and links via -framework Shared
on the SDK-conditional search path. New ios-device/test-ios-device recipes
mirror ios/test-ios, signing the Debug-iphoneos build with the .env API key.

* docs(cli): document ios-device doctor checks and the device flags

The --ios-device flag now also selects a connected device; --ios-app-path
covers the device install; the doctor gains an ios-device platform whose
checks are devicectl, iproxy, a paired device, and signing credentials.
Corrects the --clear-data default to true.

* fix(ioscompanion): resolve signing key path to absolute

xcodebuild's -authenticationKeyPath requires an absolute path, but .env
files commonly carry a repo-relative one. Resolve it against the working
directory before the stat so a relative ASC_API_KEY_PATH still signs.

* fix(ioscompanion): re-enable signing for the device runner build

companion/project.yml disables code signing for the simulator build, so
the device build inherited it and produced an unsigned runner that the
device rejected at install (0xe8008018). build-for-testing now forces
CODE_SIGNING_ALLOWED/REQUIRED=YES so automatic provisioning signs it.

* fix(ioscompanion): key the device build cache on signing identity

The cache marker hashed only sources, so switching signing team or key
reused a runner signed with the stale identity, which the device rejects at
install (0xe8008018). Fold team + key id into the cache key so a signing
change forces a rebuild.

* docs(getting-started): document physical iOS device setup

Lists the iproxy requirement and the App Store Connect signing env vars
(SANDERLING_IOS_TEAM, ASC_API_*) a device run needs, plus the
test-ios-device recipe and the doctor check.

* feat(ios): native usbmux client and in-process tunnel forwarder

Talk to macOS usbmuxd directly instead of shelling out to iproxy, so the
device path depends on nothing beyond macOS + Xcode.

* refactor(ios): drive device tunnel via io.Closer seam

Replace the tunnelChild *exec.Cmd and spawnTunnel seam with a tunnel
io.Closer and startTunnel seam backed by the in-process usbmux forwarder.

* refactor(ios): remove iproxy spawn from device runner

* test(ios): cover tunnel close via io.Closer not child process

* feat(doctor): check usbmuxd socket instead of iproxy on PATH

* chore(conformance): drop iproxy orphan check; tunnel is in-process

* docs(ios): device tunnel uses native usbmux, nothing to install

* chore: gitignore the signing keys directory

* feat(folio): add Android launcher icon (black bg, white dot)

* feat(folio): add iOS app icon (black bg, white dot)

* feat(folio): add web favicon (black bg, white dot)

* docs(ioscompanion): fix stale const comments

* refactor(ioscompanion): inline single-use devicectl argv builders

* refactor(ioscompanion): inline xcodegenArgs, drop tautological argv tests

* refactor(ioscompanion): inline firstNonEmpty

* refactor(doctor): dedup usbmuxd socket path via ioscompanion seam

* test(doctor): trim redundant signing-check test

* refactor(ioscompanion): deliver COMPANION_PORT via TEST_RUNNER_ env

* fix(testrun): seam preflight so iOS routing tests pass on CI without xcrun
2026-06-09 18:38:52 +05:30

188 lines
7.9 KiB
Swift

import Foundation
import Network
import UIKit
import XCTest
// Newline-delimited JSON-over-TCP server. Listens on 127.0.0.1 on the port from
// the COMPANION_PORT environment variable (default 27753). Each line is one
// request; each reply is one line.
final class Server {
private let port: NWEndpoint.Port
private let listener: NWListener
private let queue = DispatchQueue(label: "dev.sanderling.companion.server")
// Requests are handled off the network queue so blocking automation work
// (snapshot, gesture synthesis) never stalls accept and receive.
private let work = DispatchQueue(label: "dev.sanderling.companion.work")
// The bundle identifier of the most recently snapshotted app, used as the
// default target for typeText when no explicit bundleId is supplied.
private var currentBundleIdentifier = "com.apple.springboard"
init() throws {
let resolvedPort = ProcessInfo.processInfo.environment["COMPANION_PORT"]
.flatMap { UInt16($0) } ?? 27753
self.port = NWEndpoint.Port(rawValue: resolvedPort)!
let parameters = NWParameters.tcp
parameters.allowLocalEndpointReuse = true
// The simulator shares the host network stack, so an unbound listener
// would be reachable from the host's LAN interfaces. Pin it to
// loopback so only local processes can drive the automation surface.
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: self.port)
self.listener = try NWListener(using: parameters)
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.accept(connection)
}
listener.start(queue: queue)
}
private func accept(_ connection: NWConnection) {
connection.start(queue: queue)
receive(on: connection, buffer: Data())
}
private func receive(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self = self else { return }
var working = buffer
if let data = data {
working.append(data)
}
while let newlineIndex = working.firstIndex(of: 0x0A) {
let lineData = working.subdata(in: working.startIndex..<newlineIndex)
working.removeSubrange(working.startIndex...newlineIndex)
self.handleLine(lineData, on: connection)
}
if isComplete || error != nil {
connection.cancel()
return
}
self.receive(on: connection, buffer: working)
}
}
private func handleLine(_ lineData: Data, on connection: NWConnection) {
guard !lineData.isEmpty else { return }
work.async { [weak self] in
guard let self = self else { return }
let requestId = (try? JSONSerialization.jsonObject(with: lineData))
.flatMap { ($0 as? [String: Any])?["id"] as? Int } ?? 0
do {
let response = try self.dispatch(lineData)
self.send(response, on: connection)
} catch {
self.send(["id": requestId, "error": "\(error)"], on: connection)
}
}
}
private func dispatch(_ lineData: Data) throws -> [String: Any] {
guard let request = try JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
throw ServerError.malformedRequest
}
let requestId = request["id"] as? Int ?? 0
guard let method = request["method"] as? String else {
throw ServerError.malformedRequest
}
let params = request["params"] as? [String: Any] ?? [:]
let result = try handle(method: method, params: params)
return ["id": requestId, "result": result]
}
private func handle(method: String, params: [String: Any]) throws -> [String: Any] {
switch method {
case "health":
return ["ok": true]
case "describe":
return describeScreen()
case "snapshot":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
currentBundleIdentifier = bundleIdentifier
return ["elements": Snapshot.elements(bundleIdentifier: bundleIdentifier)]
case "gesture":
let events = params["events"] as? [[String: Any]] ?? []
try Gesture.perform(events: events)
return ["ok": true]
case "gestureProbe":
return Gesture.probe()
case "launch":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
let foregroundIfRunning = params["foregroundIfRunning"] as? Bool ?? false
try AppLifecycle.launch(bundleIdentifier: bundleIdentifier, foregroundIfRunning: foregroundIfRunning)
return ["ok": true]
case "terminate":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try AppLifecycle.terminate(bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "typeText":
let text = params["text"] as? String ?? ""
let replace = params["replace"] as? Bool ?? false
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "eraseText":
let count = params["count"] as? Int ?? 0
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "pressKey":
let key = params["key"] as? String ?? ""
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "appState":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)]
case "screenshot":
return try screenshot()
default:
throw ServerError.unknownMethod(method)
}
}
private func describeScreen() -> [String: Any] {
// The runner process has no foreground scene, so UIScreen.main.bounds
// returns a fallback size. The springboard application snapshot frame is
// the device size in points; the screen scale comes from UIScreen.
var scale: CGFloat = 1
let collect = { scale = UIScreen.main.scale }
if Thread.isMainThread {
collect()
} else {
DispatchQueue.main.sync(execute: collect)
}
var width = 0
var height = 0
if let root = Snapshot.elements(bundleIdentifier: "com.apple.springboard").first,
let frame = root["frame"] as? [String: Any] {
width = Int(frame["width"] as? Double ?? 0)
height = Int(frame["height"] as? Double ?? 0)
}
return [
"widthPoints": width,
"heightPoints": height,
"scale": Double(scale),
]
}
private func screenshot() throws -> [String: Any] {
let image = XCUIScreen.main.screenshot()
return ["pngBase64": image.pngRepresentation.base64EncodedString()]
}
private func send(_ object: [String: Any], on connection: NWConnection) {
guard var data = try? JSONSerialization.data(withJSONObject: object) else {
return
}
data.append(0x0A)
connection.send(content: data, completion: .contentProcessed { _ in })
}
enum ServerError: Error {
case malformedRequest
case unknownMethod(String)
}
}