mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(companion): serve the wire protocol from a parked runner
This commit is contained in:
1 parent
84e8274d09
commit
88aeeff1a5
2 files changed
+159
No files matched your search
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<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 }
|
||||||
|
let requestId = (try? JSONSerialization.jsonObject(with: lineData))
|
||||||
|
.flatMap { ($0 as? [String: Any])?["id"] as? Int } ?? 0
|
||||||
|
do {
|
||||||
|
let response = try dispatch(lineData)
|
||||||
|
send(response, on: connection)
|
||||||
|
} catch {
|
||||||
|
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 "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user