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