@stim-cli/server 1.10.1 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -27
- package/dist/protocol-BElQc2vm.mjs +808 -0
- package/dist/protocol.d.mts +286 -11
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +1004 -148
- package/dist/stim-frames/EmulatorDiscovery.swift +49 -0
- package/dist/stim-frames/EmulatorInput.swift +141 -0
- package/dist/stim-frames/EmulatorPosture.swift +73 -0
- package/dist/stim-frames/EmulatorRotation.swift +59 -0
- package/dist/stim-frames/GrpcFraming.swift +85 -0
- package/dist/stim-frames/InputMessages.swift +123 -0
- package/dist/stim-frames/ScreenshotMessages.swift +152 -0
- package/dist/stim-frames/ScreenshotStream.swift +130 -0
- package/dist/stim-frames/SimulatorDisplay.swift +116 -0
- package/dist/stim-frames/SimulatorHID.swift +336 -0
- package/dist/stim-frames/SimulatorRotation.swift +64 -0
- package/dist/stim-frames/VideoEncoder.swift +282 -0
- package/dist/stim-frames/main.swift +623 -0
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +2135 -208
- package/package.json +4 -4
- package/dist/protocol-B3k9Ykuu.mjs +0 -407
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import CoreImage
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import ImageIO
|
|
5
|
+
import IOSurface
|
|
6
|
+
|
|
7
|
+
// stim-frames streams one device's screen as JPEG frames for stim-server.
|
|
8
|
+
//
|
|
9
|
+
// stim-frames ios <udid>
|
|
10
|
+
// stim-frames android <serial>
|
|
11
|
+
//
|
|
12
|
+
// stdin takes one JSON object per line: {"fps": n, "maxEdge": px, "quality": 0-1,
|
|
13
|
+
// "jpeg": bool, "jpegFps": n, "video": bool, "bitrate": bits per second}, where fps 0
|
|
14
|
+
// pauses frames; {"keyframe": true} to make the next video frame a keyframe; or an input:
|
|
15
|
+
// {"input": "touch", "phase": "down|move|up", "x": 0-1, "y": 0-1, "display": n} with x
|
|
16
|
+
// and y on the upright screen; {"input": "text", "text": s}, printable ASCII where "\n"
|
|
17
|
+
// is Return, "\t" is Tab and "\u{8}" is Delete; and {"input": "button", "button":
|
|
18
|
+
// "home|lock"}, or on an emulator also "back|app-switch"; {"input": "rotate", "direction":
|
|
19
|
+
// "left|right"}; and on an emulator {"input": "posture", "posture":
|
|
20
|
+
// "folded|half-open|unfolded"}. An emulator types and presses buttons only with a hardware
|
|
21
|
+
// keyboard.
|
|
22
|
+
// stdout carries messages framed as a 4-byte big-endian length, then a kind byte:
|
|
23
|
+
// 1 is a frame (2-byte width, 2-byte height, JPEG bytes), 2 is a JSON notice
|
|
24
|
+
// ({"error": message} before a failed exit, {"inputError": message}, or on an emulator
|
|
25
|
+
// {"keyboard": "yes|no"} once it reports its hardware), 3 is an H.264 access unit (1-byte
|
|
26
|
+
// flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
|
|
27
|
+
// milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
|
|
28
|
+
// The helper exits when stdin closes.
|
|
29
|
+
|
|
30
|
+
struct Config: Equatable {
|
|
31
|
+
var fps = 5.0
|
|
32
|
+
var maxEdge = 1280
|
|
33
|
+
var quality = 0.7
|
|
34
|
+
var jpeg = true
|
|
35
|
+
var jpegFps: Double?
|
|
36
|
+
var video = false
|
|
37
|
+
var bitrate = 3_000_000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enum Output {
|
|
41
|
+
private static let writer = DispatchQueue(label: "stim.frames.output")
|
|
42
|
+
private static let lock = NSLock()
|
|
43
|
+
private static var writing = false
|
|
44
|
+
private static var pendingVideo = 0
|
|
45
|
+
private static var videoNeedsKeyframe = false
|
|
46
|
+
private static var keyframeRequested = false
|
|
47
|
+
private static let maxPendingVideo = 30
|
|
48
|
+
static var requestKeyframe: () -> Void = {}
|
|
49
|
+
|
|
50
|
+
static func frame(jpeg: Data, width: Int, height: Int) {
|
|
51
|
+
lock.lock()
|
|
52
|
+
defer { lock.unlock() }
|
|
53
|
+
guard !writing else { return }
|
|
54
|
+
writing = true
|
|
55
|
+
var body = Data([1, UInt8(width >> 8), UInt8(width & 0xff), UInt8(height >> 8), UInt8(height & 0xff)])
|
|
56
|
+
body += jpeg
|
|
57
|
+
writer.async {
|
|
58
|
+
write(body)
|
|
59
|
+
lock.lock()
|
|
60
|
+
writing = false
|
|
61
|
+
lock.unlock()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static func video(_ unit: AccessUnit) {
|
|
66
|
+
lock.lock()
|
|
67
|
+
defer { lock.unlock() }
|
|
68
|
+
if videoNeedsKeyframe && !unit.keyframe { return }
|
|
69
|
+
guard pendingVideo < maxPendingVideo else {
|
|
70
|
+
videoNeedsKeyframe = true
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
videoNeedsKeyframe = false
|
|
74
|
+
keyframeRequested = false
|
|
75
|
+
pendingVideo += 1
|
|
76
|
+
var body = Data([3, unit.keyframe ? 1 : 0])
|
|
77
|
+
withUnsafeBytes(of: unit.capturedAt.bitPattern.bigEndian) { body.append(contentsOf: $0) }
|
|
78
|
+
body += Data([UInt8(unit.width >> 8), UInt8(unit.width & 0xff), UInt8(unit.height >> 8), UInt8(unit.height & 0xff)])
|
|
79
|
+
body += unit.data
|
|
80
|
+
writer.async {
|
|
81
|
+
write(body)
|
|
82
|
+
lock.lock()
|
|
83
|
+
pendingVideo -= 1
|
|
84
|
+
let ask = videoNeedsKeyframe && !keyframeRequested
|
|
85
|
+
if ask { keyframeRequested = true }
|
|
86
|
+
lock.unlock()
|
|
87
|
+
if ask { requestKeyframe() }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static func notice(_ object: [String: String]) {
|
|
92
|
+
guard let json = try? JSONSerialization.data(withJSONObject: object) else { return }
|
|
93
|
+
writer.sync { write(Data([2]) + json) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private static func write(_ body: Data) {
|
|
97
|
+
let length = UInt32(body.count)
|
|
98
|
+
var message = Data([UInt8(length >> 24), UInt8((length >> 16) & 0xff), UInt8((length >> 8) & 0xff), UInt8(length & 0xff)])
|
|
99
|
+
message += body
|
|
100
|
+
FileHandle.standardOutput.write(message)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func fail(_ message: String) -> Never {
|
|
105
|
+
Output.notice(["error": message])
|
|
106
|
+
exit(1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
|
110
|
+
let context = CIContext(options: [.cacheIntermediates: false])
|
|
111
|
+
|
|
112
|
+
func jpeg(_ image: CIImage, config: Config) -> (Data, Int, Int)? {
|
|
113
|
+
let extent = image.extent
|
|
114
|
+
let scale = min(1, Double(config.maxEdge) / max(extent.width, extent.height))
|
|
115
|
+
let scaled = scale < 1 ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) : image
|
|
116
|
+
let size = scaled.extent.integral
|
|
117
|
+
let cropped = scaled.transformed(by: CGAffineTransform(translationX: -size.minX, y: -size.minY))
|
|
118
|
+
guard size.width > 0, size.height > 0,
|
|
119
|
+
let data = context.jpegRepresentation(
|
|
120
|
+
of: cropped.cropped(to: CGRect(origin: .zero, size: size.size)), colorSpace: colorSpace,
|
|
121
|
+
options: [CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): config.quality])
|
|
122
|
+
else { return nil }
|
|
123
|
+
return (data, Int(size.width), Int(size.height))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
final class Pacer {
|
|
127
|
+
let queue = DispatchQueue(label: "stim.frames.pacer")
|
|
128
|
+
var config = Config()
|
|
129
|
+
private var dirty = false
|
|
130
|
+
private var scheduled = false
|
|
131
|
+
private var last = DispatchTime(uptimeNanoseconds: 0)
|
|
132
|
+
private let render: (Config) -> Void
|
|
133
|
+
|
|
134
|
+
init(render: @escaping (Config) -> Void) {
|
|
135
|
+
self.render = render
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func changed() {
|
|
139
|
+
queue.async {
|
|
140
|
+
self.dirty = true
|
|
141
|
+
self.schedule()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func schedule() {
|
|
146
|
+
guard dirty, !scheduled, config.fps > 0 else { return }
|
|
147
|
+
let next = last + .nanoseconds(Int(1_000_000_000 / max(config.fps, 0.1)))
|
|
148
|
+
scheduled = true
|
|
149
|
+
queue.asyncAfter(deadline: max(next, .now())) {
|
|
150
|
+
self.scheduled = false
|
|
151
|
+
guard self.dirty else { return }
|
|
152
|
+
self.dirty = false
|
|
153
|
+
self.last = .now()
|
|
154
|
+
self.render(self.config)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func videoEncoder() -> VideoEncoder {
|
|
160
|
+
VideoEncoder(maxEdge: Config().maxEdge, fps: Int(Config().fps), bitrate: Config().bitrate, output: Output.video)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Keeps JPEG at `jpegFps` while video renders faster. A frame it skips is rendered again once the
|
|
164
|
+
/// interval passes, so the last frame of a burst still reaches JPEG subscribers. Used on the pacer queue.
|
|
165
|
+
final class JpegGate {
|
|
166
|
+
private var last = 0.0
|
|
167
|
+
private var retrying = false
|
|
168
|
+
|
|
169
|
+
func admit(_ config: Config, pacer: Pacer) -> Bool {
|
|
170
|
+
let now = CFAbsoluteTimeGetCurrent()
|
|
171
|
+
let wait = last + 1 / max(config.jpegFps ?? config.fps, 0.1) - now
|
|
172
|
+
if wait <= 0 {
|
|
173
|
+
last = now
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
if !retrying {
|
|
177
|
+
retrying = true
|
|
178
|
+
pacer.queue.asyncAfter(deadline: .now() + wait) {
|
|
179
|
+
self.retrying = false
|
|
180
|
+
pacer.changed()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func now() -> Double { Date().timeIntervalSince1970 * 1000 }
|
|
188
|
+
|
|
189
|
+
final class SimulatorSource {
|
|
190
|
+
let udid: String
|
|
191
|
+
let inputQueue = DispatchQueue(label: "stim.frames.input")
|
|
192
|
+
var hid: SimulatorHID?
|
|
193
|
+
private let pacer: Pacer
|
|
194
|
+
private var display: SimDisplay?
|
|
195
|
+
private let callbackID = NSUUID()
|
|
196
|
+
private let video = videoEncoder()
|
|
197
|
+
private let jpegGate = JpegGate()
|
|
198
|
+
|
|
199
|
+
init(udid: String) {
|
|
200
|
+
self.udid = udid
|
|
201
|
+
var render: (Config) -> Void = { _ in }
|
|
202
|
+
pacer = Pacer { render($0) }
|
|
203
|
+
render = { [unowned self] config in self.render(config) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var queue: DispatchQueue { pacer.queue }
|
|
207
|
+
|
|
208
|
+
func start(deadline: Date = Date().addingTimeInterval(30)) {
|
|
209
|
+
guard let display = CoreSimulator.displays(udid: udid).first else {
|
|
210
|
+
if Date() > deadline { fail("Simulator \(udid) has no display with a framebuffer. Is it booted?") }
|
|
211
|
+
queue.asyncAfter(deadline: .now() + 1) { self.start(deadline: deadline) }
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
self.display = display
|
|
215
|
+
display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
|
|
216
|
+
display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
|
|
217
|
+
display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
|
|
218
|
+
pacer.changed()
|
|
219
|
+
watch(display)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A simulator that shuts down and boots again gets new display objects, and
|
|
223
|
+
// the old ones stop reporting damage; so does its HID client. CoreSimulator
|
|
224
|
+
// returns a new proxy for the same display on every lookup, so the watch
|
|
225
|
+
// follows the device state instead, where 3 is SimDeviceStateBooted.
|
|
226
|
+
private func watch(_ display: SimDisplay) {
|
|
227
|
+
queue.asyncAfter(deadline: .now() + 2) {
|
|
228
|
+
guard CoreSimulator.device(udid: self.udid)?.value(forKey: "state") as? Int != 3 else {
|
|
229
|
+
return self.watch(display)
|
|
230
|
+
}
|
|
231
|
+
display.unregisterDamageCallback(self.callbackID)
|
|
232
|
+
display.unregisterSurfacesCallback(self.callbackID)
|
|
233
|
+
display.unregisterPropertiesCallback(self.callbackID)
|
|
234
|
+
self.display = nil
|
|
235
|
+
self.inputQueue.async { self.hid = nil }
|
|
236
|
+
self.start()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func configure(_ config: Config) {
|
|
241
|
+
video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
|
|
242
|
+
queue.async {
|
|
243
|
+
self.pacer.config = config
|
|
244
|
+
self.pacer.changed()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func keyframe() {
|
|
249
|
+
video.requestKeyframe()
|
|
250
|
+
pacer.changed()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
|
|
254
|
+
// display's native portrait orientation, so the image is turned upright.
|
|
255
|
+
private func render(_ config: Config) {
|
|
256
|
+
guard let surface = display?.framebufferSurface else { return }
|
|
257
|
+
let orientation: CGImagePropertyOrientation
|
|
258
|
+
let quarterTurns: Int
|
|
259
|
+
switch display?.screenProperties?.uiOrientation ?? 1 {
|
|
260
|
+
case 2: (orientation, quarterTurns) = (.down, 2)
|
|
261
|
+
case 3: (orientation, quarterTurns) = (.right, 3)
|
|
262
|
+
case 4: (orientation, quarterTurns) = (.left, 1)
|
|
263
|
+
default: (orientation, quarterTurns) = (.up, 0)
|
|
264
|
+
}
|
|
265
|
+
let ioSurface = unsafeBitCast(surface, to: IOSurfaceRef.self)
|
|
266
|
+
if config.video {
|
|
267
|
+
var buffer: Unmanaged<CVPixelBuffer>?
|
|
268
|
+
CVPixelBufferCreateWithIOSurface(nil, ioSurface, nil, &buffer)
|
|
269
|
+
if let pixels = buffer?.takeRetainedValue() {
|
|
270
|
+
video.encode(pixels, quarterTurns: quarterTurns, capturedAt: now())
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
guard config.jpeg, jpegGate.admit(config, pacer: pacer) else { return }
|
|
274
|
+
let image = CIImage(ioSurface: ioSurface).oriented(orientation)
|
|
275
|
+
guard let (data, width, height) = jpeg(image, config: config) else { return }
|
|
276
|
+
Output.frame(jpeg: data, width: width, height: height)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
final class EmulatorSource {
|
|
281
|
+
let serial: String
|
|
282
|
+
let queue = DispatchQueue(label: "stim.frames.emulator")
|
|
283
|
+
let inputQueue = DispatchQueue(label: "stim.frames.emulator-input")
|
|
284
|
+
var input: EmulatorInput?
|
|
285
|
+
private var status: (size: CGSize?, keyboard: Bool)?
|
|
286
|
+
private var stream: ScreenshotStream?
|
|
287
|
+
private var generation = 0
|
|
288
|
+
private var config = Config()
|
|
289
|
+
var latest: EmulatorFrame?
|
|
290
|
+
private var pacer: Pacer!
|
|
291
|
+
private let video = videoEncoder()
|
|
292
|
+
private let jpegGate = JpegGate()
|
|
293
|
+
|
|
294
|
+
init(serial: String) {
|
|
295
|
+
self.serial = serial
|
|
296
|
+
pacer = Pacer { [unowned self] config in
|
|
297
|
+
guard let frame = self.queue.sync(execute: { self.latest }) else { return }
|
|
298
|
+
self.render(frame, config: config)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func start() {
|
|
303
|
+
queue.async { self.connect() }
|
|
304
|
+
inputQueue.async {
|
|
305
|
+
if let status = self.inputClient().flatMap(self.readStatus) {
|
|
306
|
+
Output.notice(["keyboard": status.keyboard ? "yes" : "no"])
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
func configure(_ config: Config) {
|
|
312
|
+
video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
|
|
313
|
+
queue.async {
|
|
314
|
+
let resized = config.maxEdge != self.config.maxEdge
|
|
315
|
+
self.config = config
|
|
316
|
+
self.pacer.queue.async {
|
|
317
|
+
self.pacer.config = config
|
|
318
|
+
self.pacer.changed()
|
|
319
|
+
}
|
|
320
|
+
if resized, let stream = self.stream {
|
|
321
|
+
self.stream = nil
|
|
322
|
+
stream.cancel()
|
|
323
|
+
self.connect()
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func keyframe() {
|
|
329
|
+
video.requestKeyframe()
|
|
330
|
+
pacer.changed()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private func connect() {
|
|
334
|
+
guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else {
|
|
335
|
+
fail("\(serial) has no gRPC endpoint. Frames appear after Stim next boots this emulator.")
|
|
336
|
+
}
|
|
337
|
+
generation += 1
|
|
338
|
+
let current = generation
|
|
339
|
+
let stream = ScreenshotStream(
|
|
340
|
+
endpoint: endpoint, width: config.maxEdge, height: config.maxEdge,
|
|
341
|
+
onFrame: { [weak self] frame in
|
|
342
|
+
guard let self else { return }
|
|
343
|
+
self.queue.async { self.latest = frame }
|
|
344
|
+
self.pacer.changed()
|
|
345
|
+
},
|
|
346
|
+
onEnd: { [weak self] in
|
|
347
|
+
guard let self else { return }
|
|
348
|
+
self.queue.async {
|
|
349
|
+
if self.generation == current { fail("The emulator \(self.serial) ended its screenshot stream.") }
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
self.stream = stream
|
|
353
|
+
stream.start()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private func render(_ frame: EmulatorFrame, config: Config) {
|
|
357
|
+
if config.video { video.encode(rgba: frame.rgba, width: frame.width, height: frame.height, capturedAt: now()) }
|
|
358
|
+
guard config.jpeg, jpegGate.admit(config, pacer: pacer), let provider = CGDataProvider(data: frame.rgba as CFData),
|
|
359
|
+
let image = CGImage(
|
|
360
|
+
width: frame.width, height: frame.height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: frame.width * 4,
|
|
361
|
+
space: colorSpace, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
|
|
362
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
|
|
363
|
+
let (data, width, height) = jpeg(CIImage(cgImage: image), config: config)
|
|
364
|
+
else { return }
|
|
365
|
+
Output.frame(jpeg: data, width: width, height: height)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
enum Command {
|
|
370
|
+
case config(Config)
|
|
371
|
+
case keyframe
|
|
372
|
+
case touch(TouchPhase, CGPoint, display: Int)
|
|
373
|
+
case text(String)
|
|
374
|
+
case button(String)
|
|
375
|
+
case rotate(clockwise: Bool)
|
|
376
|
+
case posture(EmulatorPosture)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func parseCommand(_ line: String, base: Config) -> Command? {
|
|
380
|
+
guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { return nil }
|
|
381
|
+
if object["keyframe"] as? Bool == true { return .keyframe }
|
|
382
|
+
switch object["input"] as? String {
|
|
383
|
+
case "touch":
|
|
384
|
+
let phases: [String: TouchPhase] = ["down": .down, "move": .move, "up": .up]
|
|
385
|
+
guard let phase = (object["phase"] as? String).flatMap({ phases[$0] }),
|
|
386
|
+
let x = object["x"] as? Double, let y = object["y"] as? Double, (0...1).contains(x), (0...1).contains(y)
|
|
387
|
+
else { return nil }
|
|
388
|
+
return .touch(phase, CGPoint(x: x, y: y), display: object["display"] as? Int ?? 0)
|
|
389
|
+
case "text":
|
|
390
|
+
return (object["text"] as? String).map { .text($0) }
|
|
391
|
+
case "button":
|
|
392
|
+
return (object["button"] as? String).map { .button($0) }
|
|
393
|
+
case "rotate":
|
|
394
|
+
let directions = ["left": false, "right": true]
|
|
395
|
+
return (object["direction"] as? String).flatMap { directions[$0] }.map { .rotate(clockwise: $0) }
|
|
396
|
+
case "posture":
|
|
397
|
+
let postures: [String: EmulatorPosture] = ["folded": .closed, "half-open": .halfOpened, "unfolded": .opened]
|
|
398
|
+
return (object["posture"] as? String).flatMap { postures[$0] }.map { .posture($0) }
|
|
399
|
+
case nil:
|
|
400
|
+
var config = base
|
|
401
|
+
if let fps = object["fps"] as? Double, fps >= 0 { config.fps = min(fps, 60) }
|
|
402
|
+
if let edge = object["maxEdge"] as? Int, edge > 0 { config.maxEdge = min(edge, 4096) }
|
|
403
|
+
if let quality = object["quality"] as? Double, quality > 0, quality <= 1 { config.quality = quality }
|
|
404
|
+
if let jpeg = object["jpeg"] as? Bool { config.jpeg = jpeg }
|
|
405
|
+
if let jpegFps = object["jpegFps"] as? Double, jpegFps > 0 { config.jpegFps = min(jpegFps, 60) }
|
|
406
|
+
if let video = object["video"] as? Bool { config.video = video }
|
|
407
|
+
if let bitrate = object["bitrate"] as? Int, bitrate > 0 { config.bitrate = bitrate }
|
|
408
|
+
return .config(config)
|
|
409
|
+
default:
|
|
410
|
+
return nil
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
protocol Source: AnyObject {
|
|
415
|
+
func configure(_ config: Config)
|
|
416
|
+
func keyframe()
|
|
417
|
+
func input(_ command: Command)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func readCommands(_ source: Source) {
|
|
421
|
+
Thread.detachNewThread {
|
|
422
|
+
var buffer = Data()
|
|
423
|
+
var config = Config()
|
|
424
|
+
while true {
|
|
425
|
+
let chunk = FileHandle.standardInput.availableData
|
|
426
|
+
if chunk.isEmpty { exit(0) }
|
|
427
|
+
buffer += chunk
|
|
428
|
+
while let newline = buffer.firstIndex(of: 0x0a) {
|
|
429
|
+
let line = String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self)
|
|
430
|
+
buffer.removeSubrange(buffer.startIndex...newline)
|
|
431
|
+
switch parseCommand(line, base: config) {
|
|
432
|
+
case .config(let parsed)?:
|
|
433
|
+
config = parsed
|
|
434
|
+
source.configure(config)
|
|
435
|
+
case .keyframe?:
|
|
436
|
+
source.keyframe()
|
|
437
|
+
case let command?:
|
|
438
|
+
source.input(command)
|
|
439
|
+
case nil:
|
|
440
|
+
Output.notice(["inputError": "stim-frames could not read \(line.prefix(80))"])
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// macOS virtual key codes of a US keyboard, which SimulatorKit's
|
|
448
|
+
// hidUsageForCGKeyCode turns into HID usages and the emulator turns into evdev
|
|
449
|
+
// keys; shifted characters also hold Shift (0x38).
|
|
450
|
+
let keyCodes: [Character: (code: UInt16, shift: Bool)] = {
|
|
451
|
+
var map: [Character: (UInt16, Bool)] = [:]
|
|
452
|
+
let plain: [(String, UInt16)] = [
|
|
453
|
+
("a", 0x00), ("s", 0x01), ("d", 0x02), ("f", 0x03), ("h", 0x04), ("g", 0x05), ("z", 0x06), ("x", 0x07),
|
|
454
|
+
("c", 0x08), ("v", 0x09), ("b", 0x0B), ("q", 0x0C), ("w", 0x0D), ("e", 0x0E), ("r", 0x0F), ("y", 0x10),
|
|
455
|
+
("t", 0x11), ("1", 0x12), ("2", 0x13), ("3", 0x14), ("4", 0x15), ("6", 0x16), ("5", 0x17), ("=", 0x18),
|
|
456
|
+
("9", 0x19), ("7", 0x1A), ("-", 0x1B), ("8", 0x1C), ("0", 0x1D), ("]", 0x1E), ("o", 0x1F), ("u", 0x20),
|
|
457
|
+
("[", 0x21), ("i", 0x22), ("p", 0x23), ("l", 0x25), ("j", 0x26), ("'", 0x27), ("k", 0x28), (";", 0x29),
|
|
458
|
+
("\\", 0x2A), (",", 0x2B), ("/", 0x2C), ("n", 0x2D), ("m", 0x2E), (".", 0x2F), ("`", 0x32), (" ", 0x31),
|
|
459
|
+
("\n", 0x24), ("\t", 0x30), ("\u{8}", 0x33),
|
|
460
|
+
]
|
|
461
|
+
for (text, code) in plain { map[Character(text)] = (code, false) }
|
|
462
|
+
for letter in "abcdefghijklmnopqrstuvwxyz" { map[Character(letter.uppercased())] = (map[letter]!.0, true) }
|
|
463
|
+
let shifted: [(Character, Character)] = [
|
|
464
|
+
("!", "1"), ("@", "2"), ("#", "3"), ("$", "4"), ("%", "5"), ("^", "6"), ("&", "7"), ("*", "8"), ("(", "9"),
|
|
465
|
+
(")", "0"), ("_", "-"), ("+", "="), ("{", "["), ("}", "]"), ("|", "\\"), (":", ";"), ("\"", "'"), ("<", ","),
|
|
466
|
+
(">", "."), ("?", "/"), ("~", "`"),
|
|
467
|
+
]
|
|
468
|
+
for (character, base) in shifted { map[character] = (map[base]!.0, true) }
|
|
469
|
+
return map
|
|
470
|
+
}()
|
|
471
|
+
|
|
472
|
+
extension SimulatorSource: Source {
|
|
473
|
+
func input(_ command: Command) {
|
|
474
|
+
inputQueue.async { self.apply(command) }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private func apply(_ command: Command) {
|
|
478
|
+
if case .rotate(let clockwise) = command {
|
|
479
|
+
if !SimulatorRotation.rotate(udid: udid, clockwise: clockwise) {
|
|
480
|
+
Output.notice(["inputError": "\(udid) did not take the rotation."])
|
|
481
|
+
}
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
if hid?.isConnected != true { hid = SimulatorHID(udid: udid) }
|
|
485
|
+
guard let hid else { return Output.notice(["inputError": "\(udid) could not be opened for input."]) }
|
|
486
|
+
switch command {
|
|
487
|
+
case .touch(let phase, let point, let index):
|
|
488
|
+
let displays = CoreSimulator.displays(udid: udid)
|
|
489
|
+
guard displays.indices.contains(index), let properties = displays[index].screenProperties else {
|
|
490
|
+
return Output.notice(["inputError": "\(udid) has no display \(index)."])
|
|
491
|
+
}
|
|
492
|
+
hid.touch(phase, at: nativeScreenPoint(point, orientation: properties.uiOrientation), screenID: properties.screenID)
|
|
493
|
+
case .text(let text):
|
|
494
|
+
for character in text {
|
|
495
|
+
guard let key = keyCodes[character] else { continue }
|
|
496
|
+
if key.shift { hid.hardwareKey(code: 0x38, down: true) }
|
|
497
|
+
hid.hardwareKey(code: key.code, down: true)
|
|
498
|
+
usleep(10_000)
|
|
499
|
+
hid.hardwareKey(code: key.code, down: false)
|
|
500
|
+
if key.shift { hid.hardwareKey(code: 0x38, down: false) }
|
|
501
|
+
usleep(15_000)
|
|
502
|
+
}
|
|
503
|
+
case .button(let name):
|
|
504
|
+
let buttons: [String: SimulatorButton] = ["home": .home, "lock": .lock]
|
|
505
|
+
guard let button = buttons[name] else { return Output.notice(["inputError": "iOS has no \(name) button."]) }
|
|
506
|
+
hid.button(button, down: true)
|
|
507
|
+
usleep(100_000)
|
|
508
|
+
hid.button(button, down: false)
|
|
509
|
+
case .config, .keyframe, .rotate, .posture:
|
|
510
|
+
break
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private final class Applied: @unchecked Sendable {
|
|
516
|
+
var value = false
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
extension EmulatorSource: Source {
|
|
520
|
+
func input(_ command: Command) {
|
|
521
|
+
inputQueue.async { self.apply(command) }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private func apply(_ command: Command) {
|
|
525
|
+
guard let input = inputClient() else { return Output.notice(["inputError": "\(serial) has no gRPC endpoint for input."]) }
|
|
526
|
+
switch command {
|
|
527
|
+
case .touch(let phase, let point, let index):
|
|
528
|
+
guard index == 0 else { return Output.notice(["inputError": "Input goes to the emulator's main display only."]) }
|
|
529
|
+
guard let size = readStatus(input)?.size else {
|
|
530
|
+
return Output.notice(["inputError": "\(serial) did not report its display size."])
|
|
531
|
+
}
|
|
532
|
+
let rotation = queue.sync { latest?.rotation ?? 0 }
|
|
533
|
+
let pixel = displayPixel(point, rotation: rotation, displaySize: size)
|
|
534
|
+
input.call("sendTouch", InputMessages.touch(x: pixel.x, y: pixel.y, pressed: phase != .up))
|
|
535
|
+
case .text(let text):
|
|
536
|
+
guard readStatus(input)?.keyboard == true else { return noKeyboard() }
|
|
537
|
+
for character in text {
|
|
538
|
+
guard let key = keyCodes[character] else { continue }
|
|
539
|
+
if key.shift { send(input, InputMessages.key(macKeyCode: 0x38, down: true)) }
|
|
540
|
+
send(input, InputMessages.key(macKeyCode: key.code, down: true))
|
|
541
|
+
send(input, InputMessages.key(macKeyCode: key.code, down: false))
|
|
542
|
+
if key.shift { send(input, InputMessages.key(macKeyCode: 0x38, down: false)) }
|
|
543
|
+
// The emulator reorders a shifted key and the next one when they arrive back to back.
|
|
544
|
+
usleep(30_000)
|
|
545
|
+
}
|
|
546
|
+
case .button(let name):
|
|
547
|
+
guard readStatus(input)?.keyboard == true else { return noKeyboard() }
|
|
548
|
+
let keys = ["home": "GoHome", "back": "GoBack", "app-switch": "AppSwitch", "lock": "Power"]
|
|
549
|
+
guard let key = keys[name] else { return Output.notice(["inputError": "Android has no \(name) button."]) }
|
|
550
|
+
input.call("sendKey", InputMessages.namedKey(key))
|
|
551
|
+
case .rotate(let clockwise):
|
|
552
|
+
wait("rotation") { await EmulatorRotation.rotate(serial: self.serial, clockwise: clockwise) }
|
|
553
|
+
case .posture(let posture):
|
|
554
|
+
wait("posture") { await posture.apply(serial: self.serial) }
|
|
555
|
+
case .config, .keyframe:
|
|
556
|
+
break
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private func wait(_ change: String, _ operation: @escaping () async -> Bool) {
|
|
561
|
+
let done = DispatchSemaphore(value: 0)
|
|
562
|
+
let applied = Applied()
|
|
563
|
+
Task {
|
|
564
|
+
applied.value = await operation()
|
|
565
|
+
done.signal()
|
|
566
|
+
}
|
|
567
|
+
if done.wait(timeout: .now() + 10) == .timedOut || !applied.value {
|
|
568
|
+
Output.notice(["inputError": "\(serial) did not take the \(change) change."])
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private func send(_ input: EmulatorInput, _ key: Data) {
|
|
573
|
+
let done = DispatchSemaphore(value: 0)
|
|
574
|
+
input.call("sendKey", key) { _ in done.signal() }
|
|
575
|
+
_ = done.wait(timeout: .now() + 5)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private func noKeyboard() {
|
|
579
|
+
Output.notice(["inputError": "\(serial) has no hardware keyboard (hw.keyboard=no), so it drops key events."])
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private func inputClient() -> EmulatorInput? {
|
|
583
|
+
if input == nil, let endpoint = EmulatorDiscovery.endpoint(serial: serial) { input = EmulatorInput(endpoint: endpoint) }
|
|
584
|
+
return input
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private func readStatus(_ input: EmulatorInput) -> (size: CGSize?, keyboard: Bool)? {
|
|
588
|
+
if let status { return status }
|
|
589
|
+
let done = DispatchSemaphore(value: 0)
|
|
590
|
+
var response: Data?
|
|
591
|
+
input.call("getStatus", Data()) { reply in
|
|
592
|
+
response = reply
|
|
593
|
+
done.signal()
|
|
594
|
+
}
|
|
595
|
+
_ = done.wait(timeout: .now() + 5)
|
|
596
|
+
guard let response else { return nil }
|
|
597
|
+
let size = InputMessages.displaySize(fromStatus: response).map { CGSize(width: $0.width, height: $0.height) }
|
|
598
|
+
status = (size, InputMessages.hasKeyboard(fromStatus: response))
|
|
599
|
+
return status
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
setvbuf(stdout, nil, _IONBF, 0)
|
|
604
|
+
signal(SIGPIPE, SIG_IGN)
|
|
605
|
+
let arguments = CommandLine.arguments
|
|
606
|
+
guard arguments.count == 3 else { fail("usage: stim-frames ios <udid> | android <serial>") }
|
|
607
|
+
switch arguments[1] {
|
|
608
|
+
case "ios":
|
|
609
|
+
CoreSimulator.developerDir = CoreSimulator.selectedDeveloperDir()
|
|
610
|
+
guard CoreSimulator.deviceSet != nil else { fail("CoreSimulator could not be loaded from \(CoreSimulator.developerDir).") }
|
|
611
|
+
let source = SimulatorSource(udid: arguments[2])
|
|
612
|
+
Output.requestKeyframe = source.keyframe
|
|
613
|
+
readCommands(source)
|
|
614
|
+
source.queue.async { source.start() }
|
|
615
|
+
case "android":
|
|
616
|
+
let source = EmulatorSource(serial: arguments[2])
|
|
617
|
+
Output.requestKeyframe = source.keyframe
|
|
618
|
+
readCommands(source)
|
|
619
|
+
source.start()
|
|
620
|
+
default:
|
|
621
|
+
fail("usage: stim-frames ios <udid> | android <serial>")
|
|
622
|
+
}
|
|
623
|
+
dispatchMain()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Folds or unfolds a booted iPhone Duo simulator. It runs inside the
|
|
2
|
+
// simulator through `xcrun simctl spawn <udid> <path>`.
|
|
3
|
+
//
|
|
4
|
+
// SBSDisplayToolService is SpringBoardServices SPI that iOS 27.1 ships. Its
|
|
5
|
+
// swapDisplayWithSettings:completion: sweeps the simulated hinge to the other
|
|
6
|
+
// posture, and SpringBoard accepts it only from a process with the
|
|
7
|
+
// com.apple.springboard.sbdisplay.service entitlement, which simulator
|
|
8
|
+
// processes carry in their __TEXT,__entitlements section. The settings layout
|
|
9
|
+
// matches the method's type encoding {?=qdqqBqdd}.
|
|
10
|
+
#import <Foundation/Foundation.h>
|
|
11
|
+
#import <objc/message.h>
|
|
12
|
+
#include <dlfcn.h>
|
|
13
|
+
|
|
14
|
+
typedef struct {
|
|
15
|
+
long long state;
|
|
16
|
+
double duration;
|
|
17
|
+
long long orientation;
|
|
18
|
+
long long profile;
|
|
19
|
+
BOOL wait;
|
|
20
|
+
long long targetMode;
|
|
21
|
+
double position;
|
|
22
|
+
double lockedUntil;
|
|
23
|
+
} SwapSettings;
|
|
24
|
+
|
|
25
|
+
int main(void) {
|
|
26
|
+
dlopen("/System/Library/PrivateFrameworks/SpringBoardServices.framework/SpringBoardServices", RTLD_NOW);
|
|
27
|
+
Class serviceClass = NSClassFromString(@"SBSDisplayToolService");
|
|
28
|
+
SEL swap = NSSelectorFromString(@"swapDisplayWithSettings:completion:");
|
|
29
|
+
if (![serviceClass instancesRespondToSelector:swap]) {
|
|
30
|
+
fprintf(stderr, "SBSDisplayToolService is not available in this runtime\n");
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
id service = [[serviceClass alloc] init];
|
|
34
|
+
SwapSettings settings = {.duration = 1, .wait = YES};
|
|
35
|
+
dispatch_semaphore_t done = dispatch_semaphore_create(0);
|
|
36
|
+
((void (*)(id, SEL, SwapSettings, id))objc_msgSend)(service, swap, settings, ^{
|
|
37
|
+
dispatch_semaphore_signal(done);
|
|
38
|
+
});
|
|
39
|
+
long timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_SEC));
|
|
40
|
+
SEL invalidate = NSSelectorFromString(@"invalidate");
|
|
41
|
+
if ([service respondsToSelector:invalidate]) ((void (*)(id, SEL))objc_msgSend)(service, invalidate);
|
|
42
|
+
if (timedOut) {
|
|
43
|
+
fprintf(stderr, "SpringBoard did not finish the fold within 20 seconds\n");
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|