@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.
@@ -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,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.springboard.sbdisplay.service</key>
6
+ <true/>
7
+ </dict>
8
+ </plist>
@@ -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
+ }