feature/xcodegen-modernize-runner
swift 300 lines 11 KB
Raw
1 import Foundation
2 import NIOCore
3 import NIOHTTP1
4 import NIOPosix
5 import XCTest
6 import XCRSControlKit
7
8 private struct JSONRPCRequest {
9 let method: String
10 let params: [String: Any]
11 let id: Any?
12 }
13
14 @MainActor
15 final class ControlKitRPCServer {
16 private let port: UInt16
17 private let host: String
18
19 init() {
20 port = UInt16(ProcessInfo.processInfo.environment["CONTROLKIT_LISTEN_PORT"] ?? "12004") ?? 12004
21 host = ProcessInfo.processInfo.environment["CONTROLKIT_LISTEN_HOST"] ?? "127.0.0.1"
22 }
23
24 func start() async throws {
25 let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
26 defer {
27 try? group.syncShutdownGracefully()
28 }
29 let requestHandler: (HTTPMethod, String, Data) async -> Data = { [self] method, path, body in
30 if method == .GET && path == "/health" {
31 return Data("OK".utf8)
32 }
33 return await handle(body)
34 }
35 let channel = try await ServerBootstrap(group: group)
36 .serverChannelOption(ChannelOptions.backlog, value: 256)
37 .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
38 .childChannelInitializer { channel in
39 channel.pipeline.configureHTTPServerPipeline().flatMap {
40 channel.pipeline.addHandler(RPCHandler(requestHandler: requestHandler))
41 }
42 }
43 .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
44 .bind(host: host, port: Int(port))
45 .get()
46 try await channel.closeFuture.get()
47 }
48
49 private func handle(_ body: Data) async -> Data {
50 do {
51 if body == Data("health".utf8) {
52 return try JSONSerialization.data(withJSONObject: ["status": "ok"])
53 }
54
55 guard
56 let object = try JSONSerialization.jsonObject(with: body) as? [String: Any],
57 let method = object["method"] as? String
58 else {
59 return try response(error: -32600, message: "Invalid Request", id: nil)
60 }
61
62 let request = JSONRPCRequest(
63 method: method,
64 params: object["params"] as? [String: Any] ?? [:],
65 id: object["id"]
66 )
67 return try await dispatch(request)
68 } catch {
69 return (try? response(error: -32603, message: error.localizedDescription, id: nil)) ?? Data()
70 }
71 }
72
73 private func dispatch(_ request: JSONRPCRequest) async throws -> Data {
74 switch request.method {
75 case "device.capabilities":
76 let platform: XCRSControlKitPlatform = {
77 #if os(tvOS)
78 return .tvOS
79 #elseif os(visionOS)
80 return .visionOS
81 #elseif os(macOS)
82 return .macOS
83 #elseif os(watchOS)
84 return .watchOS
85 #else
86 return .iOS
87 #endif
88 }()
89 return try response(
90 result: [
91 "platform": platform.rawValue,
92 "capabilities": XCRSControlKitCapabilities.standard(for: platform).values.map(\.rawValue).sorted()
93 ],
94 id: request.id
95 )
96 case "device.info":
97 return try response(
98 result: ["port": port, "runner": "XCRSControlKit"],
99 id: request.id
100 )
101 case "device.apps.launch":
102 let bundleIdentifier = try stringParameter("bundleId", request.params)
103 let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
104 application.launch()
105 return try response(result: ["success": true], id: request.id)
106 case "device.apps.terminate":
107 let bundleIdentifier = try stringParameter("bundleId", request.params)
108 XCUIApplication(bundleIdentifier: bundleIdentifier).terminate()
109 return try response(result: ["success": true], id: request.id)
110 case "device.io.tap":
111 #if os(tvOS) || os(visionOS) || os(macOS)
112 throw RunnerError.unsupportedInteraction("touch")
113 #else
114 try tap(request.params)
115 return try response(result: ["success": true], id: request.id)
116 #endif
117 case "device.io.click":
118 #if os(macOS)
119 try tap(request.params)
120 return try response(result: ["success": true], id: request.id)
121 #else
122 throw RunnerError.unsupportedInteraction("pointer click")
123 #endif
124 case "device.io.spatial.tap":
125 #if os(visionOS)
126 try tap(request.params)
127 return try response(result: ["success": true], id: request.id)
128 #else
129 throw RunnerError.unsupportedInteraction("spatial tap")
130 #endif
131 case "device.io.text":
132 let text = try stringParameter("text", request.params)
133 foregroundApplication(request.params).typeText(text)
134 return try response(result: ["success": true], id: request.id)
135 case "device.io.button":
136 let button = try stringParameter("button", request.params)
137 #if os(tvOS)
138 let press: XCUIRemote.Button
139 switch button {
140 case "menu":
141 press = .menu
142 case "select":
143 press = .select
144 case "playPause":
145 press = .playPause
146 case "up":
147 press = .up
148 case "down":
149 press = .down
150 case "left":
151 press = .left
152 case "right":
153 press = .right
154 default:
155 throw RunnerError.unsupportedButton(button)
156 }
157 XCUIRemote.shared.press(press)
158 return try response(result: ["success": true], id: request.id)
159 #elseif os(iOS)
160 guard button == "home" else {
161 throw RunnerError.unsupportedButton(button)
162 }
163 XCUIDevice.shared.press(.home)
164 return try response(result: ["success": true], id: request.id)
165 #else
166 throw RunnerError.unsupportedButton(button)
167 #endif
168 default:
169 return try response(error: -32601, message: "Method not found", id: request.id)
170 }
171 }
172
173 private func foregroundApplication(_ params: [String: Any]) -> XCUIApplication {
174 if let bundleIdentifier = params["bundleId"] as? String {
175 return XCUIApplication(bundleIdentifier: bundleIdentifier)
176 }
177 return XCUIApplication()
178 }
179
180 #if !os(tvOS)
181 private func tap(_ params: [String: Any]) throws {
182 let x = try doubleParameter("x", params)
183 let y = try doubleParameter("y", params)
184 let application = foregroundApplication(params)
185 let frame = application.frame
186 let coordinate = application.coordinate(
187 withNormalizedOffset: CGVector(
188 dx: max(0, min(1, x / Double(frame.width))),
189 dy: max(0, min(1, y / Double(frame.height)))
190 )
191 )
192 coordinate.tap()
193 }
194 #endif
195
196 private func stringParameter(_ name: String, _ params: [String: Any]) throws -> String {
197 guard let value = params[name] as? String, !value.isEmpty else {
198 throw RunnerError.invalidParameter(name)
199 }
200 return value
201 }
202
203 private func doubleParameter(_ name: String, _ params: [String: Any]) throws -> Double {
204 if let value = params[name] as? Double {
205 return value
206 }
207 if let value = params[name] as? NSNumber {
208 return value.doubleValue
209 }
210 throw RunnerError.invalidParameter(name)
211 }
212
213 private func response(result: [String: Any], id: Any?) throws -> Data {
214 try JSONSerialization.data(withJSONObject: [
215 "jsonrpc": "2.0",
216 "result": result,
217 "id": id ?? NSNull()
218 ])
219 }
220
221 private func response(error code: Int, message: String, id: Any?) throws -> Data {
222 try JSONSerialization.data(withJSONObject: [
223 "jsonrpc": "2.0",
224 "error": ["code": code, "message": message],
225 "id": id ?? NSNull()
226 ])
227 }
228 }
229
230 private final class RPCHandler: ChannelInboundHandler {
231 typealias InboundIn = HTTPServerRequestPart
232 typealias OutboundOut = HTTPServerResponsePart
233
234 private let requestHandler: (HTTPMethod, String, Data) async -> Data
235 private var requestHead: HTTPRequestHead?
236 private var requestBody = Data()
237
238 init(requestHandler: @escaping (HTTPMethod, String, Data) async -> Data) {
239 self.requestHandler = requestHandler
240 }
241
242 func channelRead(context: ChannelHandlerContext, data: NIOAny) {
243 switch unwrapInboundIn(data) {
244 case .head(let head):
245 requestHead = head
246 requestBody.removeAll(keepingCapacity: true)
247 case .body(var buffer):
248 if let bytes = buffer.readBytes(length: buffer.readableBytes) {
249 requestBody.append(contentsOf: bytes)
250 }
251 case .end:
252 guard let requestHead else {
253 context.close(promise: nil)
254 return
255 }
256
257 let method = requestHead.method
258 let path = requestHead.uri
259 let body = requestBody
260 Task {
261 let responseBody = await requestHandler(method, path, body)
262 context.eventLoop.execute {
263 var headers = HTTPHeaders()
264 headers.add(name: "Content-Type", value: "application/json")
265 headers.add(name: "Content-Length", value: "\(responseBody.count)")
266 headers.add(name: "Connection", value: "close")
267 let responseHead = HTTPResponseHead(
268 version: requestHead.version,
269 status: .ok,
270 headers: headers
271 )
272 var buffer = context.channel.allocator.buffer(capacity: responseBody.count)
273 buffer.writeBytes(responseBody)
274 context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
275 context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
276 context.writeAndFlush(self.wrapOutboundOut(.end(nil))).whenComplete { _ in
277 context.close(promise: nil)
278 }
279 }
280 }
281 }
282 }
283 }
284
285 private enum RunnerError: LocalizedError {
286 case invalidParameter(String)
287 case unsupportedButton(String)
288 case unsupportedInteraction(String)
289
290 var errorDescription: String? {
291 switch self {
292 case .invalidParameter(let name):
293 return "Missing or invalid parameter: \(name)"
294 case .unsupportedButton(let button):
295 return "Unsupported button: \(button)"
296 case .unsupportedInteraction(let interaction):
297 return "Unsupported interaction: \(interaction)"
298 }
299 }
300 }