|
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
|
} |