From 88aeeff1a5cfb5b349e45fa08ac91060f2c34def Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 7 Jun 2026 15:41:16 +0530 Subject: [PATCH] feat(companion): serve the wire protocol from a parked runner --- companion/Sources/RunnerTestCase.swift | 11 ++ companion/Sources/Server.swift | 148 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 companion/Sources/RunnerTestCase.swift create mode 100644 companion/Sources/Server.swift diff --git a/companion/Sources/RunnerTestCase.swift b/companion/Sources/RunnerTestCase.swift new file mode 100644 index 0000000..9cc17bb --- /dev/null +++ b/companion/Sources/RunnerTestCase.swift @@ -0,0 +1,11 @@ +import XCTest + +// The single long-lived test method: it starts the server and parks the runner +// in a run loop so the simulator process stays alive serving the protocol. +final class RunnerTestCase: XCTestCase { + func testServeForever() throws { + let server = try Server() + server.start() + RunLoop.current.run() + } +} diff --git a/companion/Sources/Server.swift b/companion/Sources/Server.swift new file mode 100644 index 0000000..b770663 --- /dev/null +++ b/companion/Sources/Server.swift @@ -0,0 +1,148 @@ +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") + + // 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.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: self.port) + self.listener = try NWListener(using: parameters, on: self.port) + } + + 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.. [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 "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 "screenshot": + return try screenshot() + default: + throw ServerError.unknownMethod(method) + } + } + + private func describeScreen() -> [String: Any] { + var bounds = CGRect.zero + var scale: CGFloat = 1 + let collect = { + let screen = UIScreen.main + bounds = screen.bounds + scale = screen.scale + } + if Thread.isMainThread { + collect() + } else { + DispatchQueue.main.sync(execute: collect) + } + return [ + "widthPoints": Int(bounds.size.width), + "heightPoints": Int(bounds.size.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) + } +}