@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.
- package/README.md +82 -5
- package/dist/{protocol-CmlG7X7_.mjs → protocol-BElQc2vm.mjs} +159 -5
- package/dist/protocol.d.mts +109 -4
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +399 -2
- package/dist/stim-frames/EmulatorPosture.swift +73 -0
- package/dist/stim-frames/EmulatorRotation.swift +59 -0
- package/dist/stim-frames/InputMessages.swift +56 -6
- package/dist/stim-frames/ScreenshotMessages.swift +27 -4
- package/dist/stim-frames/SimulatorHID.swift +256 -21
- package/dist/stim-frames/SimulatorRotation.swift +64 -0
- package/dist/stim-frames/main.swift +261 -27
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +1011 -155
- package/package.json +3 -3
|
@@ -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},
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
618
|
+
readCommands(source)
|
|
385
619
|
source.start()
|
|
386
620
|
default:
|
|
387
621
|
fail("usage: stim-frames ios <udid> | android <serial>")
|
|
@@ -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
|
+
}
|