@stim-cli/server 1.11.0 → 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.
@@ -10,11 +10,19 @@ import IOSurface
10
10
  // stim-frames android <serial>
11
11
  //
12
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}, or
14
- // {"keyframe": true} to make the next video frame a keyframe.
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.
15
22
  // stdout carries messages framed as a 4-byte big-endian length, then a kind byte:
16
23
  // 1 is a frame (2-byte width, 2-byte height, JPEG bytes), 2 is a JSON notice
17
- // ({"error": message} before a failed exit), 3 is an H.264 access unit (1-byte
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
18
26
  // flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
19
27
  // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
20
28
  // The helper exits when stdin closes.
@@ -135,7 +143,7 @@ final class Pacer {
135
143
  }
136
144
 
137
145
  private func schedule() {
138
- guard dirty, !scheduled else { return }
146
+ guard dirty, !scheduled, config.fps > 0 else { return }
139
147
  let next = last + .nanoseconds(Int(1_000_000_000 / max(config.fps, 0.1)))
140
148
  scheduled = true
141
149
  queue.asyncAfter(deadline: max(next, .now())) {
@@ -179,7 +187,9 @@ final class JpegGate {
179
187
  func now() -> Double { Date().timeIntervalSince1970 * 1000 }
180
188
 
181
189
  final class SimulatorSource {
182
- private let udid: String
190
+ let udid: String
191
+ let inputQueue = DispatchQueue(label: "stim.frames.input")
192
+ var hid: SimulatorHID?
183
193
  private let pacer: Pacer
184
194
  private var display: SimDisplay?
185
195
  private let callbackID = NSUUID()
@@ -206,6 +216,25 @@ final class SimulatorSource {
206
216
  display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
207
217
  display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
208
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
+ }
209
238
  }
210
239
 
211
240
  func configure(_ config: Config) {
@@ -249,12 +278,15 @@ final class SimulatorSource {
249
278
  }
250
279
 
251
280
  final class EmulatorSource {
252
- private let serial: String
253
- private let queue = DispatchQueue(label: "stim.frames.emulator")
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)?
254
286
  private var stream: ScreenshotStream?
255
287
  private var generation = 0
256
288
  private var config = Config()
257
- private var latest: EmulatorFrame?
289
+ var latest: EmulatorFrame?
258
290
  private var pacer: Pacer!
259
291
  private let video = videoEncoder()
260
292
  private let jpegGate = JpegGate()
@@ -269,6 +301,11 @@ final class EmulatorSource {
269
301
 
270
302
  func start() {
271
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
+ }
272
309
  }
273
310
 
274
311
  func configure(_ config: Config) {
@@ -329,20 +366,58 @@ final class EmulatorSource {
329
366
  }
330
367
  }
331
368
 
332
- func parseConfig(_ line: Substring, base: Config) -> Config? {
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? {
333
380
  guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { return nil }
334
- var config = base
335
- if let fps = object["fps"] as? Double, fps > 0 { config.fps = min(fps, 60) }
336
- if let edge = object["maxEdge"] as? Int, edge > 0 { config.maxEdge = min(edge, 4096) }
337
- if let quality = object["quality"] as? Double, quality > 0, quality <= 1 { config.quality = quality }
338
- if let jpeg = object["jpeg"] as? Bool { config.jpeg = jpeg }
339
- if let jpegFps = object["jpegFps"] as? Double, jpegFps > 0 { config.jpegFps = min(jpegFps, 60) }
340
- if let video = object["video"] as? Bool { config.video = video }
341
- if let bitrate = object["bitrate"] as? Int, bitrate > 0 { config.bitrate = bitrate }
342
- return config
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
+ }
343
412
  }
344
413
 
345
- func readCommands(_ apply: @escaping (Config) -> Void, keyframe: @escaping () -> Void) {
414
+ protocol Source: AnyObject {
415
+ func configure(_ config: Config)
416
+ func keyframe()
417
+ func input(_ command: Command)
418
+ }
419
+
420
+ func readCommands(_ source: Source) {
346
421
  Thread.detachNewThread {
347
422
  var buffer = Data()
348
423
  var config = Config()
@@ -353,19 +428,178 @@ func readCommands(_ apply: @escaping (Config) -> Void, keyframe: @escaping () ->
353
428
  while let newline = buffer.firstIndex(of: 0x0a) {
354
429
  let line = String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self)
355
430
  buffer.removeSubrange(buffer.startIndex...newline)
356
- if line.contains("\"keyframe\"") {
357
- if (try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any])?["keyframe"] as? Bool == true {
358
- keyframe()
359
- }
360
- } else if let parsed = parseConfig(Substring(line), base: config) {
431
+ switch parseCommand(line, base: config) {
432
+ case .config(let parsed)?:
361
433
  config = parsed
362
- apply(config)
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))"])
363
441
  }
364
442
  }
365
443
  }
366
444
  }
367
445
  }
368
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
+
369
603
  setvbuf(stdout, nil, _IONBF, 0)
370
604
  signal(SIGPIPE, SIG_IGN)
371
605
  let arguments = CommandLine.arguments
@@ -376,12 +610,12 @@ case "ios":
376
610
  guard CoreSimulator.deviceSet != nil else { fail("CoreSimulator could not be loaded from \(CoreSimulator.developerDir).") }
377
611
  let source = SimulatorSource(udid: arguments[2])
378
612
  Output.requestKeyframe = source.keyframe
379
- readCommands({ source.configure($0) }, keyframe: source.keyframe)
613
+ readCommands(source)
380
614
  source.queue.async { source.start() }
381
615
  case "android":
382
616
  let source = EmulatorSource(serial: arguments[2])
383
617
  Output.requestKeyframe = source.keyframe
384
- readCommands({ source.configure($0) }, keyframe: source.keyframe)
618
+ readCommands(source)
385
619
  source.start()
386
620
  default:
387
621
  fail("usage: stim-frames ios <udid> | android <serial>")
@@ -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
+ }