@stim-cli/server 1.11.0 → 1.13.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,20 @@ 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}, on a simulator
25
+ // with several displays {"display": n} when display n is the one lit and streamed, or
26
+ // on an emulator {"keyboard": "yes|no"} once it reports its hardware), 3 is an H.264 access unit (1-byte
18
27
  // flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
19
28
  // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
20
29
  // The helper exits when stdin closes.
@@ -80,7 +89,7 @@ enum Output {
80
89
  }
81
90
  }
82
91
 
83
- static func notice(_ object: [String: String]) {
92
+ static func notice(_ object: [String: Any]) {
84
93
  guard let json = try? JSONSerialization.data(withJSONObject: object) else { return }
85
94
  writer.sync { write(Data([2]) + json) }
86
95
  }
@@ -135,7 +144,7 @@ final class Pacer {
135
144
  }
136
145
 
137
146
  private func schedule() {
138
- guard dirty, !scheduled else { return }
147
+ guard dirty, !scheduled, config.fps > 0 else { return }
139
148
  let next = last + .nanoseconds(Int(1_000_000_000 / max(config.fps, 0.1)))
140
149
  scheduled = true
141
150
  queue.asyncAfter(deadline: max(next, .now())) {
@@ -179,9 +188,13 @@ final class JpegGate {
179
188
  func now() -> Double { Date().timeIntervalSince1970 * 1000 }
180
189
 
181
190
  final class SimulatorSource {
182
- private let udid: String
191
+ let udid: String
192
+ let inputQueue = DispatchQueue(label: "stim.frames.input")
193
+ var hid: SimulatorHID?
183
194
  private let pacer: Pacer
184
- private var display: SimDisplay?
195
+ private var displays: [SimDisplay] = []
196
+ private var displayIndex = 0
197
+ private var reportedDisplay: Int?
185
198
  private let callbackID = NSUUID()
186
199
  private let video = videoEncoder()
187
200
  private let jpegGate = JpegGate()
@@ -196,16 +209,61 @@ final class SimulatorSource {
196
209
  var queue: DispatchQueue { pacer.queue }
197
210
 
198
211
  func start(deadline: Date = Date().addingTimeInterval(30)) {
199
- guard let display = CoreSimulator.displays(udid: udid).first else {
212
+ let found = CoreSimulator.displays(udid: udid)
213
+ guard !found.isEmpty else {
200
214
  if Date() > deadline { fail("Simulator \(udid) has no display with a framebuffer. Is it booted?") }
201
215
  queue.asyncAfter(deadline: .now() + 1) { self.start(deadline: deadline) }
202
216
  return
203
217
  }
204
- self.display = display
205
- display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
206
- display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
207
- display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
218
+ displays = found
219
+ displayIndex = 0
220
+ for display in found {
221
+ display.registerDamageCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
222
+ display.registerSurfacesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
223
+ display.registerPropertiesCallback(callbackID) { [weak self] _ in self?.pacer.changed() }
224
+ }
208
225
  pacer.changed()
226
+ watch()
227
+ }
228
+
229
+ // A simulator that shuts down and boots again gets new display objects, and
230
+ // the old ones stop reporting damage; so does its HID client. CoreSimulator
231
+ // returns a new proxy for the same display on every lookup, so the watch
232
+ // follows the device state instead, where 3 is SimDeviceStateBooted.
233
+ private func watch() {
234
+ queue.asyncAfter(deadline: .now() + 2) {
235
+ guard CoreSimulator.device(udid: self.udid)?.value(forKey: "state") as? Int != 3 else {
236
+ if self.displays.count > 1 { self.pacer.changed() }
237
+ return self.watch()
238
+ }
239
+ for display in self.displays {
240
+ display.unregisterDamageCallback(self.callbackID)
241
+ display.unregisterSurfacesCallback(self.callbackID)
242
+ display.unregisterPropertiesCallback(self.callbackID)
243
+ }
244
+ self.displays = []
245
+ self.reportedDisplay = nil
246
+ self.inputQueue.async { self.hid = nil }
247
+ self.start()
248
+ }
249
+ }
250
+
251
+ // CoreSimulator keeps the built-in display an iPhone Duo's posture turned
252
+ // off, the cover or the inner one, all black, and sends no damage when it
253
+ // turns off, so watch() also re-renders a device with several displays.
254
+ private func litDisplay() -> SimDisplay? {
255
+ let panels = displays.indices.filter { displays[$0].screenProperties?.screenType == 0 }
256
+ guard panels.count > 1 else { return displays.first }
257
+ let lit = { (index: Int) in self.displays[index].framebufferSurface.map { !isBlack($0) } ?? false }
258
+ guard let index = lit(displayIndex) ? displayIndex : panels.first(where: lit) else {
259
+ return displays[displayIndex]
260
+ }
261
+ displayIndex = index
262
+ if reportedDisplay != index {
263
+ reportedDisplay = index
264
+ Output.notice(["display": index])
265
+ }
266
+ return displays[index]
209
267
  }
210
268
 
211
269
  func configure(_ config: Config) {
@@ -224,10 +282,10 @@ final class SimulatorSource {
224
282
  // uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
225
283
  // display's native portrait orientation, so the image is turned upright.
226
284
  private func render(_ config: Config) {
227
- guard let surface = display?.framebufferSurface else { return }
285
+ guard let display = litDisplay(), let surface = display.framebufferSurface else { return }
228
286
  let orientation: CGImagePropertyOrientation
229
287
  let quarterTurns: Int
230
- switch display?.screenProperties?.uiOrientation ?? 1 {
288
+ switch display.screenProperties?.uiOrientation ?? 1 {
231
289
  case 2: (orientation, quarterTurns) = (.down, 2)
232
290
  case 3: (orientation, quarterTurns) = (.right, 3)
233
291
  case 4: (orientation, quarterTurns) = (.left, 1)
@@ -249,12 +307,15 @@ final class SimulatorSource {
249
307
  }
250
308
 
251
309
  final class EmulatorSource {
252
- private let serial: String
253
- private let queue = DispatchQueue(label: "stim.frames.emulator")
310
+ let serial: String
311
+ let queue = DispatchQueue(label: "stim.frames.emulator")
312
+ let inputQueue = DispatchQueue(label: "stim.frames.emulator-input")
313
+ var input: EmulatorInput?
314
+ private var status: (size: CGSize?, keyboard: Bool)?
254
315
  private var stream: ScreenshotStream?
255
316
  private var generation = 0
256
317
  private var config = Config()
257
- private var latest: EmulatorFrame?
318
+ var latest: EmulatorFrame?
258
319
  private var pacer: Pacer!
259
320
  private let video = videoEncoder()
260
321
  private let jpegGate = JpegGate()
@@ -269,6 +330,11 @@ final class EmulatorSource {
269
330
 
270
331
  func start() {
271
332
  queue.async { self.connect() }
333
+ inputQueue.async {
334
+ if let status = self.inputClient().flatMap(self.readStatus) {
335
+ Output.notice(["keyboard": status.keyboard ? "yes" : "no"])
336
+ }
337
+ }
272
338
  }
273
339
 
274
340
  func configure(_ config: Config) {
@@ -329,20 +395,58 @@ final class EmulatorSource {
329
395
  }
330
396
  }
331
397
 
332
- func parseConfig(_ line: Substring, base: Config) -> Config? {
398
+ enum Command {
399
+ case config(Config)
400
+ case keyframe
401
+ case touch(TouchPhase, CGPoint, display: Int)
402
+ case text(String)
403
+ case button(String)
404
+ case rotate(clockwise: Bool)
405
+ case posture(EmulatorPosture)
406
+ }
407
+
408
+ func parseCommand(_ line: String, base: Config) -> Command? {
333
409
  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
410
+ if object["keyframe"] as? Bool == true { return .keyframe }
411
+ switch object["input"] as? String {
412
+ case "touch":
413
+ let phases: [String: TouchPhase] = ["down": .down, "move": .move, "up": .up]
414
+ guard let phase = (object["phase"] as? String).flatMap({ phases[$0] }),
415
+ let x = object["x"] as? Double, let y = object["y"] as? Double, (0...1).contains(x), (0...1).contains(y)
416
+ else { return nil }
417
+ return .touch(phase, CGPoint(x: x, y: y), display: object["display"] as? Int ?? 0)
418
+ case "text":
419
+ return (object["text"] as? String).map { .text($0) }
420
+ case "button":
421
+ return (object["button"] as? String).map { .button($0) }
422
+ case "rotate":
423
+ let directions = ["left": false, "right": true]
424
+ return (object["direction"] as? String).flatMap { directions[$0] }.map { .rotate(clockwise: $0) }
425
+ case "posture":
426
+ let postures: [String: EmulatorPosture] = ["folded": .closed, "half-open": .halfOpened, "unfolded": .opened]
427
+ return (object["posture"] as? String).flatMap { postures[$0] }.map { .posture($0) }
428
+ case nil:
429
+ var config = base
430
+ if let fps = object["fps"] as? Double, fps >= 0 { config.fps = min(fps, 60) }
431
+ if let edge = object["maxEdge"] as? Int, edge > 0 { config.maxEdge = min(edge, 4096) }
432
+ if let quality = object["quality"] as? Double, quality > 0, quality <= 1 { config.quality = quality }
433
+ if let jpeg = object["jpeg"] as? Bool { config.jpeg = jpeg }
434
+ if let jpegFps = object["jpegFps"] as? Double, jpegFps > 0 { config.jpegFps = min(jpegFps, 60) }
435
+ if let video = object["video"] as? Bool { config.video = video }
436
+ if let bitrate = object["bitrate"] as? Int, bitrate > 0 { config.bitrate = bitrate }
437
+ return .config(config)
438
+ default:
439
+ return nil
440
+ }
441
+ }
442
+
443
+ protocol Source: AnyObject {
444
+ func configure(_ config: Config)
445
+ func keyframe()
446
+ func input(_ command: Command)
343
447
  }
344
448
 
345
- func readCommands(_ apply: @escaping (Config) -> Void, keyframe: @escaping () -> Void) {
449
+ func readCommands(_ source: Source) {
346
450
  Thread.detachNewThread {
347
451
  var buffer = Data()
348
452
  var config = Config()
@@ -353,19 +457,178 @@ func readCommands(_ apply: @escaping (Config) -> Void, keyframe: @escaping () ->
353
457
  while let newline = buffer.firstIndex(of: 0x0a) {
354
458
  let line = String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self)
355
459
  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) {
460
+ switch parseCommand(line, base: config) {
461
+ case .config(let parsed)?:
361
462
  config = parsed
362
- apply(config)
463
+ source.configure(config)
464
+ case .keyframe?:
465
+ source.keyframe()
466
+ case let command?:
467
+ source.input(command)
468
+ case nil:
469
+ Output.notice(["inputError": "stim-frames could not read \(line.prefix(80))"])
363
470
  }
364
471
  }
365
472
  }
366
473
  }
367
474
  }
368
475
 
476
+ // macOS virtual key codes of a US keyboard, which SimulatorKit's
477
+ // hidUsageForCGKeyCode turns into HID usages and the emulator turns into evdev
478
+ // keys; shifted characters also hold Shift (0x38).
479
+ let keyCodes: [Character: (code: UInt16, shift: Bool)] = {
480
+ var map: [Character: (UInt16, Bool)] = [:]
481
+ let plain: [(String, UInt16)] = [
482
+ ("a", 0x00), ("s", 0x01), ("d", 0x02), ("f", 0x03), ("h", 0x04), ("g", 0x05), ("z", 0x06), ("x", 0x07),
483
+ ("c", 0x08), ("v", 0x09), ("b", 0x0B), ("q", 0x0C), ("w", 0x0D), ("e", 0x0E), ("r", 0x0F), ("y", 0x10),
484
+ ("t", 0x11), ("1", 0x12), ("2", 0x13), ("3", 0x14), ("4", 0x15), ("6", 0x16), ("5", 0x17), ("=", 0x18),
485
+ ("9", 0x19), ("7", 0x1A), ("-", 0x1B), ("8", 0x1C), ("0", 0x1D), ("]", 0x1E), ("o", 0x1F), ("u", 0x20),
486
+ ("[", 0x21), ("i", 0x22), ("p", 0x23), ("l", 0x25), ("j", 0x26), ("'", 0x27), ("k", 0x28), (";", 0x29),
487
+ ("\\", 0x2A), (",", 0x2B), ("/", 0x2C), ("n", 0x2D), ("m", 0x2E), (".", 0x2F), ("`", 0x32), (" ", 0x31),
488
+ ("\n", 0x24), ("\t", 0x30), ("\u{8}", 0x33),
489
+ ]
490
+ for (text, code) in plain { map[Character(text)] = (code, false) }
491
+ for letter in "abcdefghijklmnopqrstuvwxyz" { map[Character(letter.uppercased())] = (map[letter]!.0, true) }
492
+ let shifted: [(Character, Character)] = [
493
+ ("!", "1"), ("@", "2"), ("#", "3"), ("$", "4"), ("%", "5"), ("^", "6"), ("&", "7"), ("*", "8"), ("(", "9"),
494
+ (")", "0"), ("_", "-"), ("+", "="), ("{", "["), ("}", "]"), ("|", "\\"), (":", ";"), ("\"", "'"), ("<", ","),
495
+ (">", "."), ("?", "/"), ("~", "`"),
496
+ ]
497
+ for (character, base) in shifted { map[character] = (map[base]!.0, true) }
498
+ return map
499
+ }()
500
+
501
+ extension SimulatorSource: Source {
502
+ func input(_ command: Command) {
503
+ inputQueue.async { self.apply(command) }
504
+ }
505
+
506
+ private func apply(_ command: Command) {
507
+ if case .rotate(let clockwise) = command {
508
+ if !SimulatorRotation.rotate(udid: udid, clockwise: clockwise) {
509
+ Output.notice(["inputError": "\(udid) did not take the rotation."])
510
+ }
511
+ return
512
+ }
513
+ if hid?.isConnected != true { hid = SimulatorHID(udid: udid) }
514
+ guard let hid else { return Output.notice(["inputError": "\(udid) could not be opened for input."]) }
515
+ switch command {
516
+ case .touch(let phase, let point, let index):
517
+ let displays = CoreSimulator.displays(udid: udid)
518
+ guard displays.indices.contains(index), let properties = displays[index].screenProperties else {
519
+ return Output.notice(["inputError": "\(udid) has no display \(index)."])
520
+ }
521
+ hid.touch(phase, at: nativeScreenPoint(point, orientation: properties.uiOrientation), screenID: properties.screenID)
522
+ case .text(let text):
523
+ for character in text {
524
+ guard let key = keyCodes[character] else { continue }
525
+ if key.shift { hid.hardwareKey(code: 0x38, down: true) }
526
+ hid.hardwareKey(code: key.code, down: true)
527
+ usleep(10_000)
528
+ hid.hardwareKey(code: key.code, down: false)
529
+ if key.shift { hid.hardwareKey(code: 0x38, down: false) }
530
+ usleep(15_000)
531
+ }
532
+ case .button(let name):
533
+ let buttons: [String: SimulatorButton] = ["home": .home, "lock": .lock]
534
+ guard let button = buttons[name] else { return Output.notice(["inputError": "iOS has no \(name) button."]) }
535
+ hid.button(button, down: true)
536
+ usleep(100_000)
537
+ hid.button(button, down: false)
538
+ case .config, .keyframe, .rotate, .posture:
539
+ break
540
+ }
541
+ }
542
+ }
543
+
544
+ private final class Applied: @unchecked Sendable {
545
+ var value = false
546
+ }
547
+
548
+ extension EmulatorSource: Source {
549
+ func input(_ command: Command) {
550
+ inputQueue.async { self.apply(command) }
551
+ }
552
+
553
+ private func apply(_ command: Command) {
554
+ guard let input = inputClient() else { return Output.notice(["inputError": "\(serial) has no gRPC endpoint for input."]) }
555
+ switch command {
556
+ case .touch(let phase, let point, let index):
557
+ guard index == 0 else { return Output.notice(["inputError": "Input goes to the emulator's main display only."]) }
558
+ guard let size = readStatus(input)?.size else {
559
+ return Output.notice(["inputError": "\(serial) did not report its display size."])
560
+ }
561
+ let rotation = queue.sync { latest?.rotation ?? 0 }
562
+ let pixel = displayPixel(point, rotation: rotation, displaySize: size)
563
+ input.call("sendTouch", InputMessages.touch(x: pixel.x, y: pixel.y, pressed: phase != .up))
564
+ case .text(let text):
565
+ guard readStatus(input)?.keyboard == true else { return noKeyboard() }
566
+ for character in text {
567
+ guard let key = keyCodes[character] else { continue }
568
+ if key.shift { send(input, InputMessages.key(macKeyCode: 0x38, down: true)) }
569
+ send(input, InputMessages.key(macKeyCode: key.code, down: true))
570
+ send(input, InputMessages.key(macKeyCode: key.code, down: false))
571
+ if key.shift { send(input, InputMessages.key(macKeyCode: 0x38, down: false)) }
572
+ // The emulator reorders a shifted key and the next one when they arrive back to back.
573
+ usleep(30_000)
574
+ }
575
+ case .button(let name):
576
+ guard readStatus(input)?.keyboard == true else { return noKeyboard() }
577
+ let keys = ["home": "GoHome", "back": "GoBack", "app-switch": "AppSwitch", "lock": "Power"]
578
+ guard let key = keys[name] else { return Output.notice(["inputError": "Android has no \(name) button."]) }
579
+ input.call("sendKey", InputMessages.namedKey(key))
580
+ case .rotate(let clockwise):
581
+ wait("rotation") { await EmulatorRotation.rotate(serial: self.serial, clockwise: clockwise) }
582
+ case .posture(let posture):
583
+ wait("posture") { await posture.apply(serial: self.serial) }
584
+ case .config, .keyframe:
585
+ break
586
+ }
587
+ }
588
+
589
+ private func wait(_ change: String, _ operation: @escaping () async -> Bool) {
590
+ let done = DispatchSemaphore(value: 0)
591
+ let applied = Applied()
592
+ Task {
593
+ applied.value = await operation()
594
+ done.signal()
595
+ }
596
+ if done.wait(timeout: .now() + 10) == .timedOut || !applied.value {
597
+ Output.notice(["inputError": "\(serial) did not take the \(change) change."])
598
+ }
599
+ }
600
+
601
+ private func send(_ input: EmulatorInput, _ key: Data) {
602
+ let done = DispatchSemaphore(value: 0)
603
+ input.call("sendKey", key) { _ in done.signal() }
604
+ _ = done.wait(timeout: .now() + 5)
605
+ }
606
+
607
+ private func noKeyboard() {
608
+ Output.notice(["inputError": "\(serial) has no hardware keyboard (hw.keyboard=no), so it drops key events."])
609
+ }
610
+
611
+ private func inputClient() -> EmulatorInput? {
612
+ if input == nil, let endpoint = EmulatorDiscovery.endpoint(serial: serial) { input = EmulatorInput(endpoint: endpoint) }
613
+ return input
614
+ }
615
+
616
+ private func readStatus(_ input: EmulatorInput) -> (size: CGSize?, keyboard: Bool)? {
617
+ if let status { return status }
618
+ let done = DispatchSemaphore(value: 0)
619
+ var response: Data?
620
+ input.call("getStatus", Data()) { reply in
621
+ response = reply
622
+ done.signal()
623
+ }
624
+ _ = done.wait(timeout: .now() + 5)
625
+ guard let response else { return nil }
626
+ let size = InputMessages.displaySize(fromStatus: response).map { CGSize(width: $0.width, height: $0.height) }
627
+ status = (size, InputMessages.hasKeyboard(fromStatus: response))
628
+ return status
629
+ }
630
+ }
631
+
369
632
  setvbuf(stdout, nil, _IONBF, 0)
370
633
  signal(SIGPIPE, SIG_IGN)
371
634
  let arguments = CommandLine.arguments
@@ -376,12 +639,12 @@ case "ios":
376
639
  guard CoreSimulator.deviceSet != nil else { fail("CoreSimulator could not be loaded from \(CoreSimulator.developerDir).") }
377
640
  let source = SimulatorSource(udid: arguments[2])
378
641
  Output.requestKeyframe = source.keyframe
379
- readCommands({ source.configure($0) }, keyframe: source.keyframe)
642
+ readCommands(source)
380
643
  source.queue.async { source.start() }
381
644
  case "android":
382
645
  let source = EmulatorSource(serial: arguments[2])
383
646
  Output.requestKeyframe = source.keyframe
384
- readCommands({ source.configure($0) }, keyframe: source.keyframe)
647
+ readCommands(source)
385
648
  source.start()
386
649
  default:
387
650
  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
+ }