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 // MARK: - Internal
17
18 init() {
19 port = UInt16(ProcessInfo.processInfo.environment["CONTROLKIT_LISTEN_PORT"] ?? "12004") ?? 12004
20 host = ProcessInfo.processInfo.environment["CONTROLKIT_LISTEN_HOST"] ?? "127.0.0.1"
21 }
22
23 func start() async throws {
24 let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
25 defer {
26 try? group.syncShutdownGracefully()
27 }
28 let requestHandler: (HTTPMethod, String, Data) async -> Data = { [self] method, path, body in
29 if method == .GET && path == "/health" {
30 return Data("OK".utf8)
31 }
32 return await handle(body)
33 }
34 let channel = try await ServerBootstrap(group: group)
35 .serverChannelOption(ChannelOptions.backlog, value: 256)
36 .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
37 .childChannelInitializer { channel in
38 channel.pipeline.configureHTTPServerPipeline().flatMap {
39 channel.pipeline.addHandler(RPCHandler(requestHandler: requestHandler))
40 }
41 }
42 .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
43 .bind(host: host, port: Int(port))
44 .get()
45 try await channel.closeFuture.get()
46 }
47
48 // MARK: - Private
49
50 private static let protocolVersion = 1
51 private static let supportedMethods = [
52 "device.apps.launch",
53 "device.apps.terminate",
54 "device.capabilities",
55 "device.dump.ui",
56 "device.ui.tap",
57 "device.info",
58 "device.io.button",
59 "device.io.click",
60 "device.io.spatial.tap",
61 "device.io.tap",
62 "device.io.text"
63 ]
64
65 private static var platform: XCRSControlKitPlatform {
66 #if os(tvOS)
67 return .tvOS
68 #elseif os(visionOS)
69 return .visionOS
70 #elseif os(macOS)
71 return .macOS
72 #elseif os(watchOS)
73 return .watchOS
74 #else
75 return .iOS
76 #endif
77 }
78
79 private let port: UInt16
80 private let host: String
81
82 private func handle(_ body: Data) async -> Data {
83 do {
84 if body == Data("health".utf8) {
85 return try JSONSerialization.data(withJSONObject: ["status": "ok"])
86 }
87
88 guard
89 let object = try JSONSerialization.jsonObject(with: body) as? [String: Any],
90 let method = object["method"] as? String
91 else {
92 return try response(error: -32600, message: "Invalid Request", id: nil)
93 }
94
95 let request = JSONRPCRequest(
96 method: method,
97 params: object["params"] as? [String: Any] ?? [:],
98 id: object["id"]
99 )
100 return try await dispatch(request)
101 } catch {
102 return (try? response(error: -32603, message: error.localizedDescription, id: nil)) ?? Data()
103 }
104 }
105
106 private func dispatch(_ request: JSONRPCRequest) async throws -> Data {
107 switch request.method {
108 case "device.capabilities":
109 return try response(
110 result: [
111 "platform": Self.platform.rawValue,
112 "capabilities": XCRSControlKitCapabilities.standard(for: Self.platform).values.map(\.rawValue).sorted(),
113 "methods": Self.supportedMethods
114 ],
115 id: request.id
116 )
117 case "device.info":
118 return try response(
119 result: [
120 "port": port,
121 "runner": "XCRSControlKit",
122 "protocolVersion": Self.protocolVersion,
123 "methods": Self.supportedMethods
124 ],
125 id: request.id
126 )
127 case "device.dump.ui":
128 let bundleIdentifier = try stringParameter("bundleId", request.params)
129 let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
130 let hierarchy = try accessibilityHierarchy(
131 application,
132 bundleIdentifier: bundleIdentifier
133 )
134 return try response(result: dictionary(hierarchy), id: request.id)
135 case "device.ui.tap":
136 let bundleIdentifier = try stringParameter("bundleId", request.params)
137 let element = try stringParameter("element", request.params)
138 .trimmingCharacters(in: .whitespacesAndNewlines)
139 guard !element.isEmpty else {
140 throw RunnerError.invalidParameter("element")
141 }
142 try tapElement(named: element, in: bundleIdentifier)
143 return try response(result: ["success": true], id: request.id)
144 case "device.apps.launch":
145 let bundleIdentifier = try stringParameter("bundleId", request.params)
146 let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
147 application.launch()
148 return try response(result: ["success": true], id: request.id)
149 case "device.apps.terminate":
150 let bundleIdentifier = try stringParameter("bundleId", request.params)
151 XCUIApplication(bundleIdentifier: bundleIdentifier).terminate()
152 return try response(result: ["success": true], id: request.id)
153 case "device.io.tap":
154 #if os(tvOS) || os(visionOS) || os(macOS)
155 throw RunnerError.unsupportedInteraction("touch")
156 #else
157 try tap(request.params)
158 return try response(result: ["success": true], id: request.id)
159 #endif
160 case "device.io.click":
161 #if os(macOS)
162 try tap(request.params)
163 return try response(result: ["success": true], id: request.id)
164 #else
165 throw RunnerError.unsupportedInteraction("pointer click")
166 #endif
167 case "device.io.spatial.tap":
168 #if os(visionOS)
169 try tap(request.params)
170 return try response(result: ["success": true], id: request.id)
171 #else
172 throw RunnerError.unsupportedInteraction("spatial tap")
173 #endif
174 case "device.io.text":
175 let text = try stringParameter("text", request.params)
176 foregroundApplication(request.params).typeText(text)
177 return try response(result: ["success": true], id: request.id)
178 case "device.io.button":
179 let button = try stringParameter("button", request.params)
180 #if os(tvOS)
181 let press: XCUIRemote.Button
182 switch button {
183 case "menu":
184 press = .menu
185 case "select":
186 press = .select
187 case "playPause":
188 press = .playPause
189 case "up":
190 press = .up
191 case "down":
192 press = .down
193 case "left":
194 press = .left
195 case "right":
196 press = .right
197 default:
198 throw RunnerError.unsupportedButton(button)
199 }
200 XCUIRemote.shared.press(press)
201 return try response(result: ["success": true], id: request.id)
202 #elseif os(iOS)
203 guard button == "home" else {
204 throw RunnerError.unsupportedButton(button)
205 }
206 XCUIDevice.shared.press(.home)
207 return try response(result: ["success": true], id: request.id)
208 #else
209 throw RunnerError.unsupportedButton(button)
210 #endif
211 default:
212 return try response(error: -32601, message: "Method not found", id: request.id)
213 }
214 }
215
216 private func foregroundApplication(_ params: [String: Any]) -> XCUIApplication {
217 if let bundleIdentifier = params["bundleId"] as? String {
218 return XCUIApplication(bundleIdentifier: bundleIdentifier)
219 }
220 return XCUIApplication()
221 }
222
223 private func accessibilityHierarchy(
224 _ application: XCUIApplication,
225 bundleIdentifier: String
226 ) throws -> XCRSControlKitAccessibilityNode {
227 guard application.state == .runningForeground else {
228 throw RunnerError.applicationNotInForeground(bundleIdentifier)
229 }
230 let debugDescription = application.debugDescription
231 guard application.state == .runningForeground else {
232 throw RunnerError.applicationNotInForeground(bundleIdentifier)
233 }
234 return try XCRSControlKitAccessibilityParser.parse(
235 debugDescription: debugDescription
236 )
237 }
238
239 private func tapElement(named element: String, in bundleIdentifier: String) throws {
240 let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
241 guard application.state == .runningForeground else {
242 throw RunnerError.applicationNotInForeground(bundleIdentifier)
243 }
244 let predicate = NSPredicate(
245 format: "label == %@ OR identifier == %@ OR value == %@",
246 element,
247 element,
248 element
249 )
250 let matches = application.descendants(matching: .any).matching(predicate)
251 let matchCount = matches.count
252 guard matchCount > 0 else {
253 throw RunnerError.elementNotFound(element)
254 }
255 guard matchCount == 1 else {
256 throw RunnerError.ambiguousElement(element, matchCount)
257 }
258 try activate(matches.element(boundBy: 0), named: element)
259 }
260
261 private func activate(_ element: XCUIElement, named name: String) throws {
262 #if os(tvOS)
263 guard element.hasFocus else {
264 throw RunnerError.elementNotFocused(name)
265 }
266 XCUIRemote.shared.press(.select)
267 #else
268 guard element.isHittable else {
269 throw RunnerError.elementNotHittable(name)
270 }
271 #if os(macOS)
272 element.click()
273 #else
274 element.tap()
275 #endif
276 #endif
277 }
278
279 private func dictionary(_ node: XCRSControlKitAccessibilityNode) throws -> [String: Any] {
280 let data = try JSONEncoder().encode(node)
281 guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
282 throw RunnerError.invalidAccessibilityHierarchy
283 }
284 return object
285 }
286
287 #if !os(tvOS)
288 private func tap(_ params: [String: Any]) throws {
289 let x = try doubleParameter("x", params)
290 let y = try doubleParameter("y", params)
291 let application = foregroundApplication(params)
292 let frame = application.frame
293 let coordinate = application.coordinate(
294 withNormalizedOffset: CGVector(
295 dx: max(0, min(1, x / Double(frame.width))),
296 dy: max(0, min(1, y / Double(frame.height)))
297 )
298 )
299 coordinate.tap()
300 }
301 #endif
302
303 private func stringParameter(_ name: String, _ params: [String: Any]) throws -> String {
304 guard let value = params[name] as? String, !value.isEmpty else {
305 throw RunnerError.invalidParameter(name)
306 }
307 return value
308 }
309
310 private func doubleParameter(_ name: String, _ params: [String: Any]) throws -> Double {
311 if let value = params[name] as? Double {
312 return value
313 }
314 if let value = params[name] as? NSNumber {
315 return value.doubleValue
316 }
317 throw RunnerError.invalidParameter(name)
318 }
319
320 private func response(result: [String: Any], id: Any?) throws -> Data {
321 try JSONSerialization.data(withJSONObject: [
322 "jsonrpc": "2.0",
323 "result": result,
324 "id": id ?? NSNull()
325 ])
326 }
327
328 private func response(error code: Int, message: String, id: Any?) throws -> Data {
329 try JSONSerialization.data(withJSONObject: [
330 "jsonrpc": "2.0",
331 "error": ["code": code, "message": message],
332 "id": id ?? NSNull()
333 ])
334 }
335 }
336
337 private final class RPCHandler: ChannelInboundHandler {
338 // MARK: - Internal
339
340 typealias InboundIn = HTTPServerRequestPart
341 typealias OutboundOut = HTTPServerResponsePart
342
343 init(requestHandler: @escaping (HTTPMethod, String, Data) async -> Data) {
344 self.requestHandler = requestHandler
345 }
346
347 func channelRead(context: ChannelHandlerContext, data: NIOAny) {
348 switch unwrapInboundIn(data) {
349 case .head(let head):
350 requestHead = head
351 requestBody.removeAll(keepingCapacity: true)
352 case .body(var buffer):
353 if let bytes = buffer.readBytes(length: buffer.readableBytes) {
354 requestBody.append(contentsOf: bytes)
355 }
356 case .end:
357 guard let requestHead else {
358 context.close(promise: nil)
359 return
360 }
361
362 let method = requestHead.method
363 let path = requestHead.uri
364 let body = requestBody
365 Task {
366 let responseBody = await requestHandler(method, path, body)
367 context.eventLoop.execute {
368 var headers = HTTPHeaders()
369 headers.add(name: "Content-Type", value: "application/json")
370 headers.add(name: "Content-Length", value: "\(responseBody.count)")
371 headers.add(name: "Connection", value: "close")
372 let responseHead = HTTPResponseHead(
373 version: requestHead.version,
374 status: .ok,
375 headers: headers
376 )
377 var buffer = context.channel.allocator.buffer(capacity: responseBody.count)
378 buffer.writeBytes(responseBody)
379 context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
380 context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
381 context.writeAndFlush(self.wrapOutboundOut(.end(nil))).whenComplete { _ in
382 context.close(promise: nil)
383 }
384 }
385 }
386 }
387 }
388
389 // MARK: - Private
390
391 private let requestHandler: (HTTPMethod, String, Data) async -> Data
392 private var requestHead: HTTPRequestHead?
393 private var requestBody = Data()
394 }
395
396 private enum RunnerError: LocalizedError {
397 case ambiguousElement(String, Int)
398 case applicationNotInForeground(String)
399 case invalidAccessibilityHierarchy
400 case invalidParameter(String)
401 case elementNotFound(String)
402 case elementNotHittable(String)
403 case elementNotFocused(String)
404 case unsupportedButton(String)
405 case unsupportedInteraction(String)
406
407 var errorDescription: String? {
408 switch self {
409 case .ambiguousElement(let element, let count):
410 return "Multiple accessibility elements matched \(element): \(count)"
411 case .applicationNotInForeground(let bundleIdentifier):
412 return "Application is not running in the foreground: \(bundleIdentifier)"
413 case .invalidAccessibilityHierarchy:
414 return "Could not serialize the accessibility hierarchy"
415 case .invalidParameter(let name):
416 return "Missing or invalid parameter: \(name)"
417 case .elementNotFound(let element):
418 return "No accessibility element matched: \(element)"
419 case .elementNotHittable(let element):
420 return "Accessibility element is not hittable: \(element)"
421 case .elementNotFocused(let element):
422 return "Accessibility element is not focused on tvOS: \(element)"
423 case .unsupportedButton(let button):
424 return "Unsupported button: \(button)"
425 case .unsupportedInteraction(let interaction):
426 return "Unsupported interaction: \(interaction)"
427 }
428 }
429 }