@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.
- package/README.md +100 -14
- package/dist/{protocol-CmlG7X7_.mjs → protocol-rC-5KT1O.mjs} +175 -7
- package/dist/protocol.d.mts +120 -8
- package/dist/protocol.mjs +2 -2
- package/dist/protocol.schema.json +408 -3
- 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 +299 -36
- package/dist/stim-frames/sim-fold.entitlements +8 -0
- package/dist/stim-frames/sim-fold.m +47 -0
- package/dist/stim-server.mjs +1080 -181
- package/package.json +3 -3
|
@@ -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},
|
|
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}, 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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
display
|
|
207
|
-
|
|
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
|
|
285
|
+
guard let display = litDisplay(), let surface = display.framebufferSurface else { return }
|
|
228
286
|
let orientation: CGImagePropertyOrientation
|
|
229
287
|
let quarterTurns: Int
|
|
230
|
-
switch display
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(_
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
647
|
+
readCommands(source)
|
|
385
648
|
source.start()
|
|
386
649
|
default:
|
|
387
650
|
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
|
+
}
|