@stim-cli/server 1.14.0 → 1.15.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.
Files changed (52) hide show
  1. package/README.md +1584 -89
  2. package/dist/protocol-MBMvJpvV.mjs +2715 -0
  3. package/dist/protocol.d.mts +2 -496
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5074 -549
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-OiJI9xLU.mjs +13336 -0
  50. package/dist/stim-server.mjs +6 -4421
  51. package/package.json +3 -3
  52. package/dist/protocol-IroIg8Td.mjs +0 -865
@@ -1,17 +1,30 @@
1
1
  import CoreImage
2
2
  import CoreVideo
3
3
  import Foundation
4
- import ImageIO
5
4
  import IOSurface
5
+ import ImageIO
6
6
 
7
7
  // stim-frames streams one device's screen as JPEG frames for stim-server.
8
8
  //
9
9
  // stim-frames ios <udid>
10
- // stim-frames android <serial>
10
+ // stim-frames android <serial> [owned-avd-name]
11
+ // stim-frames android-device <serial> <adb> <scrcpy-server>
12
+ // stim-frames web <cdpEndpoint> <chromePid> <targetId>
13
+ // stim-frames macos <owned-app-json>
14
+ // stim-frames iphone <udid> [name]
11
15
  //
16
+ // android-device streams any adb device through the scrcpy server jar at <scrcpy-server>,
17
+ // decoding its H.264 to re-encode it here; stim-server uses it for physical devices.
18
+ // It takes touch, text and the home, back, app-switch and lock buttons, and does not
19
+ // rotate or fold. It cleans up the device before any exit.
20
+ // A web page streams Chrome's screencast of the owned page: its JPEGs pass through as
21
+ // frames and are decoded for video. Input on a page takes touch, text and the "back"
22
+ // button, as DevTools input events. A USB-cabled iPhone streams its screen and takes no input.
12
23
  // 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:
24
+ // "jpeg": bool, "jpegFps": n, "video": bool, "bitrate": bits per second, "record":
25
+ // {"maxEdge": px, "fps": n, "bitrate": bits per second}}, where fps 0 pauses frames and
26
+ // "record", present only while stim-server records the device, runs a second encoder for it
27
+ // at no more than its own fps; {"recordKeyframe": true} makes that encoder's next frame a keyframe; {"keyframe": true} to make the next video frame a keyframe; or an input:
15
28
  // {"input": "touch", "phase": "down|move|up", "x": 0-1, "y": 0-1, "display": n} with x
16
29
  // and y on the upright screen; {"input": "text", "text": s}, printable ASCII where "\n"
17
30
  // is Return, "\t" is Tab and "\u{8}" is Delete; and {"input": "button", "button":
@@ -23,9 +36,13 @@ import IOSurface
23
36
  // 1 is a frame (2-byte width, 2-byte height, JPEG bytes), 2 is a JSON notice
24
37
  // ({"error": message} before a failed exit, {"inputError": message}, on a simulator
25
38
  // 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
39
+ // on an emulator {"keyboard": "yes|no"} once it reports its hardware, or on an iPhone
40
+ // {"stalled": message} while frames cannot arrive and {"stalled": null} once they can), 3 is an H.264 access unit (1-byte
27
41
  // flags with bit 0 set on a keyframe, 8-byte big-endian float capture time in
28
- // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes).
42
+ // milliseconds since the epoch, 2-byte width, 2-byte height, Annex-B bytes), and 4 is an
43
+ // access unit of the recording encoder, laid out like 3. Kind 5 adds a clockwise artwork
44
+ // quarter-turn byte after the frame dimensions. Live video bit 5 marks that rotation
45
+ // in bits 3-4; recordings omit it. The deviceFrame config requests PNG artwork notices.
29
46
  // The helper exits when stdin closes.
30
47
 
31
48
  struct Config: Equatable {
@@ -35,25 +52,43 @@ struct Config: Equatable {
35
52
  var jpeg = true
36
53
  var jpegFps: Double?
37
54
  var video = false
55
+ var deviceFrame = false
56
+ var duoFrame: DuoCapture?
38
57
  var bitrate = 3_000_000
58
+ var record: Recording?
59
+ }
60
+
61
+ struct Recording: Equatable {
62
+ var maxEdge: Int
63
+ var fps: Double
64
+ var bitrate: Int
65
+ }
66
+
67
+ struct DuoCapture: Equatable {
68
+ var maxEdge: Int
69
+ var fps: Double
39
70
  }
40
71
 
41
72
  enum Output {
42
73
  private static let writer = DispatchQueue(label: "stim.frames.output")
43
74
  private static let lock = NSLock()
44
75
  private static var writing = false
76
+ private static var writingDuo = false
45
77
  private static var pendingVideo = 0
46
78
  private static var videoNeedsKeyframe = false
47
79
  private static var keyframeRequested = false
48
80
  private static let maxPendingVideo = 30
49
81
  static var requestKeyframe: () -> Void = {}
50
82
 
51
- static func frame(jpeg: Data, width: Int, height: Int) {
83
+ static func frame(jpeg: Data, width: Int, height: Int, artworkTurns: Int? = nil) {
52
84
  lock.lock()
53
85
  defer { lock.unlock() }
54
86
  guard !writing else { return }
55
87
  writing = true
56
- var body = Data([1, UInt8(width >> 8), UInt8(width & 0xff), UInt8(height >> 8), UInt8(height & 0xff)])
88
+ var body = Data([
89
+ artworkTurns == nil ? 1 : 5, UInt8(width >> 8), UInt8(width & 0xff), UInt8(height >> 8), UInt8(height & 0xff),
90
+ ])
91
+ if let artworkTurns { body.append(UInt8(artworkTurns)) }
57
92
  body += jpeg
58
93
  writer.async {
59
94
  write(body)
@@ -63,6 +98,28 @@ enum Output {
63
98
  }
64
99
  }
65
100
 
101
+ static func duoFrame(_ frame: DuoFrameRenderer.Frame) {
102
+ guard
103
+ let metadata = try? JSONSerialization.data(withJSONObject: [
104
+ "width": frame.width, "height": frame.height, "revision": frame.revision,
105
+ "screenID": frame.screenID, "angle": frame.angle, "orientation": frame.orientation,
106
+ ]), metadata.count <= 65535
107
+ else { return }
108
+ lock.lock()
109
+ defer { lock.unlock() }
110
+ guard !writingDuo else { return }
111
+ writingDuo = true
112
+ var body = Data([6, UInt8(metadata.count >> 8), UInt8(metadata.count & 0xff)])
113
+ body += metadata
114
+ body += frame.jpeg
115
+ writer.async {
116
+ write(body)
117
+ lock.lock()
118
+ writingDuo = false
119
+ lock.unlock()
120
+ }
121
+ }
122
+
66
123
  static func video(_ unit: AccessUnit) {
67
124
  lock.lock()
68
125
  defer { lock.unlock() }
@@ -74,7 +131,8 @@ enum Output {
74
131
  videoNeedsKeyframe = false
75
132
  keyframeRequested = false
76
133
  pendingVideo += 1
77
- var body = Data([3, unit.keyframe ? 1 : 0])
134
+ let artwork = unit.artworkTurns.map { UInt8(32 | ($0 << 3)) } ?? 0
135
+ var body = Data([3, (unit.keyframe ? 1 : 0) | artwork])
78
136
  withUnsafeBytes(of: unit.capturedAt.bitPattern.bigEndian) { body.append(contentsOf: $0) }
79
137
  body += Data([UInt8(unit.width >> 8), UInt8(unit.width & 0xff), UInt8(unit.height >> 8), UInt8(unit.height & 0xff)])
80
138
  body += unit.data
@@ -89,6 +147,15 @@ enum Output {
89
147
  }
90
148
  }
91
149
 
150
+ /// Recording units are never dropped: a gap would leave the rest of the segment undecodable.
151
+ static func record(_ unit: AccessUnit) {
152
+ var body = Data([4, unit.keyframe ? 1 : 0])
153
+ withUnsafeBytes(of: unit.capturedAt.bitPattern.bigEndian) { body.append(contentsOf: $0) }
154
+ body += Data([UInt8(unit.width >> 8), UInt8(unit.width & 0xff), UInt8(unit.height >> 8), UInt8(unit.height & 0xff)])
155
+ body += unit.data
156
+ writer.async { write(body) }
157
+ }
158
+
92
159
  static func notice(_ object: [String: Any]) {
93
160
  guard let json = try? JSONSerialization.data(withJSONObject: object) else { return }
94
161
  writer.sync { write(Data([2]) + json) }
@@ -102,8 +169,12 @@ enum Output {
102
169
  }
103
170
  }
104
171
 
172
+ /// Runs before every exit, so a source can undo what it did on a device.
173
+ var beforeExit: () -> Void = {}
174
+
105
175
  func fail(_ message: String) -> Never {
106
176
  Output.notice(["error": message])
177
+ beforeExit()
107
178
  exit(1)
108
179
  }
109
180
 
@@ -161,6 +232,30 @@ func videoEncoder() -> VideoEncoder {
161
232
  VideoEncoder(maxEdge: Config().maxEdge, fps: Int(Config().fps), bitrate: Config().bitrate, output: Output.video)
162
233
  }
163
234
 
235
+ func recordEncoder() -> VideoEncoder {
236
+ VideoEncoder(maxEdge: Config().maxEdge, fps: Int(Config().fps), bitrate: Config().bitrate, output: Output.record)
237
+ }
238
+
239
+ extension VideoEncoder {
240
+ func configure(record config: Config) {
241
+ configure(
242
+ enabled: config.record != nil, maxEdge: config.record?.maxEdge ?? config.maxEdge,
243
+ fps: Int(config.record?.fps ?? config.fps), bitrate: config.record?.bitrate ?? config.bitrate)
244
+ }
245
+ }
246
+
247
+ /// Keeps the recording encoder at its own fps while live video renders faster, the way JpegGate paces JPEG.
248
+ final class RecordGate {
249
+ private let gate = JpegGate()
250
+
251
+ func admit(_ config: Config, pacer: Pacer) -> Bool {
252
+ guard let record = config.record else { return false }
253
+ var paced = config
254
+ paced.jpegFps = record.fps
255
+ return gate.admit(paced, pacer: pacer)
256
+ }
257
+ }
258
+
164
259
  /// Keeps JPEG at `jpegFps` while video renders faster. A frame it skips is rendered again once the
165
260
  /// interval passes, so the last frame of a burst still reaches JPEG subscribers. Used on the pacer queue.
166
261
  final class JpegGate {
@@ -197,7 +292,16 @@ final class SimulatorSource {
197
292
  private var reportedDisplay: Int?
198
293
  private let callbackID = NSUUID()
199
294
  private let video = videoEncoder()
295
+ private let recorder = recordEncoder()
296
+ private let recordGate = RecordGate()
200
297
  private let jpegGate = JpegGate()
298
+ private let duoGate = JpegGate()
299
+ private var duo: DuoCaptureSource?
300
+ private var duoRendering = false
301
+ private var duoMissed = false
302
+ private let stopLock = NSLock()
303
+ private var frameTurns: Int?
304
+ private lazy var artwork = FrameArtworkPublisher { SimulatorFrameArtwork.load(udid: self.udid) }
201
305
 
202
306
  init(udid: String) {
203
307
  self.udid = udid
@@ -208,6 +312,14 @@ final class SimulatorSource {
208
312
 
209
313
  var queue: DispatchQueue { pacer.queue }
210
314
 
315
+ func stop() {
316
+ stopLock.lock()
317
+ defer { stopLock.unlock() }
318
+ let touch = DispatchQueue.main.sync { MainActor.assumeIsolated { duo?.configure(nil) } }
319
+ guard let touch else { return }
320
+ inputQueue.sync { hid?.touch(.up, at: touch.point, screenID: touch.screenID) }
321
+ }
322
+
211
323
  func start(deadline: Date = Date().addingTimeInterval(30)) {
212
324
  let found = CoreSimulator.displays(udid: udid)
213
325
  guard !found.isEmpty else {
@@ -243,6 +355,12 @@ final class SimulatorSource {
243
355
  }
244
356
  self.displays = []
245
357
  self.reportedDisplay = nil
358
+ DispatchQueue.main.async {
359
+ MainActor.assumeIsolated {
360
+ self.releaseDuo(self.duo?.configure(nil))
361
+ self.duo = nil
362
+ }
363
+ }
246
364
  self.inputQueue.async { self.hid = nil }
247
365
  self.start()
248
366
  }
@@ -268,8 +386,17 @@ final class SimulatorSource {
268
386
 
269
387
  func configure(_ config: Config) {
270
388
  video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
389
+ recorder.configure(record: config)
271
390
  queue.async {
272
391
  self.pacer.config = config
392
+ DispatchQueue.main.async {
393
+ MainActor.assumeIsolated {
394
+ if self.duo == nil, config.duoFrame != nil {
395
+ self.duo = DuoCaptureSource(udid: self.udid, changed: self.pacer.changed)
396
+ }
397
+ self.releaseDuo(self.duo?.configure(config.duoFrame))
398
+ }
399
+ }
273
400
  self.pacer.changed()
274
401
  }
275
402
  }
@@ -279,6 +406,11 @@ final class SimulatorSource {
279
406
  pacer.changed()
280
407
  }
281
408
 
409
+ func recordKeyframe() {
410
+ recorder.requestKeyframe()
411
+ pacer.changed()
412
+ }
413
+
282
414
  // uiOrientation is a UIInterfaceOrientation; the framebuffer stays in the
283
415
  // display's native portrait orientation, so the image is turned upright.
284
416
  private func render(_ config: Config) {
@@ -292,22 +424,64 @@ final class SimulatorSource {
292
424
  default: (orientation, quarterTurns) = (.up, 0)
293
425
  }
294
426
  let ioSurface = unsafeBitCast(surface, to: IOSurfaceRef.self)
295
- if config.video {
427
+ let artworkTurns = (4 - quarterTurns) % 4
428
+ if config.duoFrame != nil, duoRendering { duoMissed = true }
429
+ if let requested = config.duoFrame, !duoRendering,
430
+ let properties = display.screenProperties
431
+ {
432
+ var paced = config
433
+ paced.jpegFps = requested.fps
434
+ if duoGate.admit(paced, pacer: pacer) {
435
+ duoRendering = true
436
+ let surfaces = displays.compactMap { display -> (screenID: UInt32, surface: IOSurface?)? in
437
+ guard let properties = display.screenProperties, properties.screenType == 0 else { return nil }
438
+ return (properties.screenID, display.framebufferSurface)
439
+ }
440
+ let viewport = CIImage(ioSurface: ioSurface).oriented(orientation).extent.size
441
+ DispatchQueue.main.async {
442
+ MainActor.assumeIsolated {
443
+ if self.duo == nil {
444
+ self.duo = DuoCaptureSource(udid: self.udid, changed: self.pacer.changed)
445
+ self.releaseDuo(self.duo?.configure(config.duoFrame))
446
+ }
447
+ self.duo?.render(
448
+ surfaces: surfaces, activeID: properties.screenID,
449
+ orientation: properties.uiOrientation, viewport: viewport, config: config)
450
+ self.queue.async {
451
+ self.duoRendering = false
452
+ if self.duoMissed {
453
+ self.duoMissed = false
454
+ self.pacer.changed()
455
+ }
456
+ }
457
+ }
458
+ }
459
+ }
460
+ }
461
+ if config.deviceFrame, frameTurns != artworkTurns {
462
+ frameTurns = artworkTurns
463
+ artwork.send(quarterTurns: artworkTurns)
464
+ }
465
+ let record = recordGate.admit(config, pacer: pacer)
466
+ if config.video || record {
296
467
  var buffer: Unmanaged<CVPixelBuffer>?
297
468
  CVPixelBufferCreateWithIOSurface(nil, ioSurface, nil, &buffer)
298
469
  if let pixels = buffer?.takeRetainedValue() {
299
- video.encode(pixels, quarterTurns: quarterTurns, capturedAt: now())
470
+ let capturedAt = now()
471
+ if config.video { video.encode(pixels, quarterTurns: quarterTurns, capturedAt: capturedAt, artworkTurns: artworkTurns) }
472
+ if record { recorder.encode(pixels, quarterTurns: quarterTurns, capturedAt: capturedAt) }
300
473
  }
301
474
  }
302
475
  guard config.jpeg, jpegGate.admit(config, pacer: pacer) else { return }
303
476
  let image = CIImage(ioSurface: ioSurface).oriented(orientation)
304
477
  guard let (data, width, height) = jpeg(image, config: config) else { return }
305
- Output.frame(jpeg: data, width: width, height: height)
478
+ Output.frame(jpeg: data, width: width, height: height, artworkTurns: artworkTurns)
306
479
  }
307
480
  }
308
481
 
309
482
  final class EmulatorSource {
310
483
  let serial: String
484
+ let avdName: String?
311
485
  let queue = DispatchQueue(label: "stim.frames.emulator")
312
486
  let inputQueue = DispatchQueue(label: "stim.frames.emulator-input")
313
487
  var input: EmulatorInput?
@@ -318,10 +492,15 @@ final class EmulatorSource {
318
492
  var latest: EmulatorFrame?
319
493
  private var pacer: Pacer!
320
494
  private let video = videoEncoder()
495
+ private let recorder = recordEncoder()
496
+ private let recordGate = RecordGate()
321
497
  private let jpegGate = JpegGate()
498
+ private var frameTurns: Int?
499
+ private lazy var artwork = FrameArtworkPublisher { self.avdName.flatMap { EmulatorFrameArtwork.load(avdName: $0) } }
322
500
 
323
- init(serial: String) {
501
+ init(serial: String, avdName: String? = nil) {
324
502
  self.serial = serial
503
+ self.avdName = avdName
325
504
  pacer = Pacer { [unowned self] config in
326
505
  guard let frame = self.queue.sync(execute: { self.latest }) else { return }
327
506
  self.render(frame, config: config)
@@ -339,6 +518,7 @@ final class EmulatorSource {
339
518
 
340
519
  func configure(_ config: Config) {
341
520
  video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
521
+ recorder.configure(record: config)
342
522
  queue.async {
343
523
  let resized = config.maxEdge != self.config.maxEdge
344
524
  self.config = config
@@ -359,6 +539,11 @@ final class EmulatorSource {
359
539
  pacer.changed()
360
540
  }
361
541
 
542
+ func recordKeyframe() {
543
+ recorder.requestKeyframe()
544
+ pacer.changed()
545
+ }
546
+
362
547
  private func connect() {
363
548
  guard let endpoint = EmulatorDiscovery.endpoint(serial: serial) else {
364
549
  fail("\(serial) has no gRPC endpoint. Frames appear after Stim next boots this emulator.")
@@ -383,7 +568,18 @@ final class EmulatorSource {
383
568
  }
384
569
 
385
570
  private func render(_ frame: EmulatorFrame, config: Config) {
386
- if config.video { video.encode(rgba: frame.rgba, width: frame.width, height: frame.height, capturedAt: now()) }
571
+ let capturedAt = now()
572
+ let artworkTurns = (-frame.rotation % 4 + 4) % 4
573
+ if config.deviceFrame, frameTurns != artworkTurns {
574
+ frameTurns = artworkTurns
575
+ artwork.send(quarterTurns: artworkTurns)
576
+ }
577
+ if config.video {
578
+ video.encode(rgba: frame.rgba, width: frame.width, height: frame.height, capturedAt: capturedAt, artworkTurns: artworkTurns)
579
+ }
580
+ if recordGate.admit(config, pacer: pacer) {
581
+ recorder.encode(rgba: frame.rgba, width: frame.width, height: frame.height, capturedAt: capturedAt)
582
+ }
387
583
  guard config.jpeg, jpegGate.admit(config, pacer: pacer), let provider = CGDataProvider(data: frame.rgba as CFData),
388
584
  let image = CGImage(
389
585
  width: frame.width, height: frame.height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: frame.width * 4,
@@ -391,14 +587,283 @@ final class EmulatorSource {
391
587
  provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
392
588
  let (data, width, height) = jpeg(CIImage(cgImage: image), config: config)
393
589
  else { return }
590
+ Output.frame(jpeg: data, width: width, height: height, artworkTurns: artworkTurns)
591
+ }
592
+ }
593
+
594
+ final class AndroidDeviceSource {
595
+ let queue = DispatchQueue(label: "stim.frames.android-device")
596
+ let stream: AndroidDeviceStream
597
+ private var latest: CVPixelBuffer?
598
+ private var pacer: Pacer!
599
+ private let video = videoEncoder()
600
+ private let jpegGate = JpegGate()
601
+
602
+ init(serial: String, adb: String, serverJar: URL) {
603
+ stream = AndroidDeviceStream(serial: serial, adb: adb, serverJar: serverJar)
604
+ pacer = Pacer { [unowned self] config in
605
+ guard let frame = self.queue.sync(execute: { self.latest }) else { return }
606
+ self.render(frame, config: config)
607
+ }
608
+ }
609
+
610
+ func start() {
611
+ stream.onFrame = { [weak self] frame in
612
+ guard let self else { return }
613
+ self.queue.async { self.latest = frame }
614
+ self.pacer.changed()
615
+ }
616
+ stream.onEnd = { fail($0) }
617
+ stream.start()
618
+ }
619
+
620
+ func configure(_ config: Config) {
621
+ video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
622
+ pacer.queue.async {
623
+ self.pacer.config = config
624
+ self.pacer.changed()
625
+ }
626
+ }
627
+
628
+ /// A device whose screen does not change sends no frame, so a keyframe re-encodes the last one.
629
+ func keyframe() {
630
+ video.requestKeyframe()
631
+ pacer.changed()
632
+ }
633
+
634
+ /// stim-server does not record a physical device.
635
+ func recordKeyframe() {}
636
+
637
+ private func render(_ frame: CVPixelBuffer, config: Config) {
638
+ if config.video { video.encode(frame, quarterTurns: 0, capturedAt: now()) }
639
+ guard config.jpeg, jpegGate.admit(config, pacer: pacer),
640
+ let (data, width, height) = jpeg(CIImage(cvPixelBuffer: frame), config: config)
641
+ else { return }
394
642
  Output.frame(jpeg: data, width: width, height: height)
395
643
  }
396
644
  }
397
645
 
398
- enum Command {
646
+ extension AndroidDeviceSource: Source {
647
+ func input(_ command: Command) {
648
+ switch command {
649
+ case .touch(let phase, let point, let display):
650
+ guard display == 0 else { return Output.notice(["inputError": "Input goes to the device's main display only."]) }
651
+ guard let size = stream.size else { return Output.notice(["inputError": "The device has sent no frame yet."]) }
652
+ let action: Scrcpy.TouchAction = phase == .down ? .down : phase == .up ? .up : .move
653
+ stream.send(
654
+ Scrcpy.touch(
655
+ action, x: Int32((point.x * Double(size.width - 1)).rounded()),
656
+ y: Int32((point.y * Double(size.height - 1)).rounded()), width: UInt16(size.width),
657
+ height: UInt16(size.height)))
658
+ case .text(let text):
659
+ var run = ""
660
+ let flush = {
661
+ if !run.isEmpty { self.stream.send(Scrcpy.text(run)) }
662
+ run = ""
663
+ }
664
+ for character in text {
665
+ if let key = Scrcpy.keycodes[String(character)] {
666
+ flush()
667
+ stream.send(Scrcpy.keycode(.down, key))
668
+ stream.send(Scrcpy.keycode(.up, key))
669
+ } else {
670
+ run.append(character)
671
+ }
672
+ }
673
+ flush()
674
+ case .button(let name):
675
+ guard ["home", "back", "app-switch", "lock"].contains(name), let key = Scrcpy.keycodes[name] else {
676
+ return Output.notice(["inputError": "Android has no \(name) button."])
677
+ }
678
+ stream.send(Scrcpy.keycode(.down, key))
679
+ stream.send(Scrcpy.keycode(.up, key))
680
+ case .rotate, .posture:
681
+ Output.notice(["inputError": "A physical device rotates and folds only in hand."])
682
+ case .config, .keyframe, .recordKeyframe, .control, .scoped, .scroll, .key, .window, .duoTouch, .duoRelease:
683
+ break
684
+ }
685
+ }
686
+ }
687
+
688
+ final class WebSource {
689
+ let queue = DispatchQueue(label: "stim.frames.web")
690
+ private let endpoint: URL
691
+ private let chromePid: Int32
692
+ private let targetId: String
693
+ private var page: WebPage?
694
+ private var latest: ScreencastFrame?
695
+ private var screencast: (maxEdge: Int, quality: Int)?
696
+ private var config = Config()
697
+ private var pacer: Pacer!
698
+ private let video = videoEncoder()
699
+ private let recorder = recordEncoder()
700
+ private let recordGate = RecordGate()
701
+ private let jpegGate = JpegGate()
702
+ private var pixels: CVPixelBufferPool?
703
+ private var pixelSize = (width: 0, height: 0)
704
+ private var encodedAt: Double?
705
+ private var waiting: [Command] = []
706
+
707
+ init(endpoint: URL, chromePid: Int32, targetId: String) {
708
+ self.endpoint = endpoint
709
+ self.chromePid = chromePid
710
+ self.targetId = targetId
711
+ pacer = Pacer { [unowned self] config in
712
+ guard let frame = self.queue.sync(execute: { self.latest }) else { return }
713
+ self.render(frame, config: config)
714
+ }
715
+ }
716
+
717
+ func start() {
718
+ WebPage.open(endpoint: endpoint, chromePid: chromePid, targetId: targetId) { result in
719
+ switch result {
720
+ case .failure(let failure):
721
+ fail(failure.description)
722
+ case .success(let page):
723
+ page.onEnd { fail($0) }
724
+ page.onFrame { frame in
725
+ self.queue.async { self.latest = frame }
726
+ self.pacer.changed()
727
+ }
728
+ self.queue.async {
729
+ self.page = page
730
+ self.updateScreencast()
731
+ let waiting = self.waiting
732
+ self.waiting = []
733
+ for command in waiting { self.apply(command, on: page) }
734
+ }
735
+ }
736
+ }
737
+ }
738
+
739
+ func configure(_ config: Config) {
740
+ video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
741
+ recorder.configure(record: config)
742
+ queue.async {
743
+ self.config = config
744
+ self.updateScreencast()
745
+ self.pacer.queue.async {
746
+ self.pacer.config = config
747
+ self.pacer.changed()
748
+ }
749
+ }
750
+ }
751
+
752
+ /// A page that does not change sends no frame, so a keyframe re-encodes the last one.
753
+ func keyframe() {
754
+ video.requestKeyframe()
755
+ pacer.changed()
756
+ }
757
+
758
+ func recordKeyframe() {
759
+ recorder.requestKeyframe()
760
+ pacer.changed()
761
+ }
762
+
763
+ private func updateScreencast() {
764
+ guard let page else { return }
765
+ guard config.fps > 0 else {
766
+ if screencast != nil { page.stopScreencast() }
767
+ screencast = nil
768
+ return
769
+ }
770
+ let wanted = (maxEdge: config.maxEdge, quality: config.video ? 85 : Int(config.quality * 100))
771
+ guard screencast.map({ $0 != wanted }) ?? true else { return }
772
+ screencast = wanted
773
+ page.startScreencast(maxEdge: wanted.maxEdge, quality: wanted.quality)
774
+ }
775
+
776
+ private func render(_ frame: ScreencastFrame, config: Config) {
777
+ guard let source = CGImageSourceCreateWithData(frame.jpeg as CFData, nil) else { return }
778
+ let record = recordGate.admit(config, pacer: pacer)
779
+ if config.video || record, let image = CGImageSourceCreateImageAtIndex(source, 0, nil),
780
+ let buffer = pixelBuffer(image)
781
+ {
782
+ let repeated = frame.capturedAt == encodedAt
783
+ encodedAt = frame.capturedAt
784
+ let capturedAt = repeated ? now() : frame.capturedAt
785
+ if config.video { video.encode(buffer, quarterTurns: 0, capturedAt: capturedAt) }
786
+ if record { recorder.encode(buffer, quarterTurns: 0, capturedAt: capturedAt) }
787
+ }
788
+ guard config.jpeg, jpegGate.admit(config, pacer: pacer),
789
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
790
+ let width = properties[kCGImagePropertyPixelWidth] as? Int,
791
+ let height = properties[kCGImagePropertyPixelHeight] as? Int
792
+ else { return }
793
+ Output.frame(jpeg: frame.jpeg, width: width, height: height)
794
+ }
795
+
796
+ private func pixelBuffer(_ image: CGImage) -> CVPixelBuffer? {
797
+ let size = (width: image.width, height: image.height)
798
+ if pixels == nil || pixelSize != size {
799
+ let attributes: [CFString: Any] = [
800
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
801
+ kCVPixelBufferWidthKey: size.width,
802
+ kCVPixelBufferHeightKey: size.height,
803
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
804
+ ]
805
+ pixels = nil
806
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pixels)
807
+ pixelSize = size
808
+ }
809
+ var buffer: CVPixelBuffer?
810
+ guard let pixels, CVPixelBufferPoolCreatePixelBuffer(nil, pixels, &buffer) == kCVReturnSuccess, let buffer else {
811
+ return nil
812
+ }
813
+ CVPixelBufferLockBaseAddress(buffer, [])
814
+ defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
815
+ guard
816
+ let context = CGContext(
817
+ data: CVPixelBufferGetBaseAddress(buffer), width: size.width, height: size.height, bitsPerComponent: 8,
818
+ bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), space: colorSpace,
819
+ bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue)
820
+ else { return nil }
821
+ context.draw(image, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
822
+ return buffer
823
+ }
824
+ }
825
+
826
+ extension WebSource: Source {
827
+ func input(_ command: Command) {
828
+ queue.async {
829
+ guard let page = self.page else { return self.waiting.append(command) }
830
+ self.apply(command, on: page)
831
+ }
832
+ }
833
+
834
+ private func apply(_ command: Command, on page: WebPage) {
835
+ switch command {
836
+ case .touch(let phase, let point, _):
837
+ switch phase {
838
+ case .down: page.touch(.down, x: point.x, y: point.y)
839
+ case .move: page.touch(.move, x: point.x, y: point.y)
840
+ case .up: page.touch(.up, x: point.x, y: point.y)
841
+ }
842
+ case .text(let text):
843
+ page.type(text)
844
+ case .button(let name):
845
+ guard name == "back" else { return Output.notice(["inputError": "A web page has no \(name) button."]) }
846
+ page.back()
847
+ case .rotate, .posture:
848
+ Output.notice(["inputError": "A web page does not rotate or fold."])
849
+ case .config, .keyframe, .recordKeyframe, .control, .scoped, .scroll, .key, .window, .duoTouch, .duoRelease:
850
+ break
851
+ }
852
+ }
853
+ }
854
+
855
+ indirect enum Command {
856
+ case control(String, enabled: Bool)
857
+ case scoped(String, Command)
858
+ case scroll(CGPoint, delta: CGPoint)
859
+ case key(String, modifiers: [String])
860
+ case window(UInt32?)
399
861
  case config(Config)
400
862
  case keyframe
863
+ case recordKeyframe
401
864
  case touch(TouchPhase, CGPoint, display: Int)
865
+ case duoTouch(TouchPhase, CGPoint, revision: String)
866
+ case duoRelease
402
867
  case text(String)
403
868
  case button(String)
404
869
  case rotate(clockwise: Bool)
@@ -407,14 +872,43 @@ enum Command {
407
872
 
408
873
  func parseCommand(_ line: String, base: Config) -> Command? {
409
874
  guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] else { return nil }
875
+ if let control = object["control"] as? [String: Any], let session = control["session"] as? String,
876
+ let enabled = control["enabled"] as? Bool { return .control(session, enabled: enabled) }
877
+ if let session = object["controlSession"] as? String {
878
+ var unscoped = object
879
+ unscoped.removeValue(forKey: "controlSession")
880
+ guard let data = try? JSONSerialization.data(withJSONObject: unscoped),
881
+ let command = parseCommand(String(decoding: data, as: UTF8.self), base: base)
882
+ else { return nil }
883
+ return .scoped(session, command)
884
+ }
410
885
  if object["keyframe"] as? Bool == true { return .keyframe }
886
+ if object["recordKeyframe"] as? Bool == true { return .recordKeyframe }
411
887
  switch object["input"] as? String {
888
+ case "duo-release":
889
+ return .duoRelease
412
890
  case "touch":
413
891
  let phases: [String: TouchPhase] = ["down": .down, "move": .move, "up": .up]
414
892
  guard let phase = (object["phase"] as? String).flatMap({ phases[$0] }),
415
893
  let x = object["x"] as? Double, let y = object["y"] as? Double, (0...1).contains(x), (0...1).contains(y)
416
894
  else { return nil }
895
+ if let revision = object["duoRevision"] as? String, UUID(uuidString: revision) != nil {
896
+ return .duoTouch(phase, CGPoint(x: x, y: y), revision: revision)
897
+ }
417
898
  return .touch(phase, CGPoint(x: x, y: y), display: object["display"] as? Int ?? 0)
899
+ case "scroll":
900
+ guard let x = object["x"] as? Double, let y = object["y"] as? Double,
901
+ let dx = object["deltaX"] as? Double, let dy = object["deltaY"] as? Double,
902
+ (0...1).contains(x), (0...1).contains(y), dx.isFinite, dy.isFinite, abs(dx) <= 1000, abs(dy) <= 1000
903
+ else { return nil }
904
+ return .scroll(CGPoint(x: x, y: y), delta: CGPoint(x: dx, y: dy))
905
+ case "key":
906
+ guard let key = object["key"] as? String, let modifiers = object["modifiers"] as? [String] else { return nil }
907
+ return .key(key, modifiers: modifiers)
908
+ case "window":
909
+ if object["window"] is NSNull { return .window(nil) }
910
+ guard let id = object["window"] as? Int, let window = UInt32(exactly: id) else { return nil }
911
+ return .window(window)
418
912
  case "text":
419
913
  return (object["text"] as? String).map { .text($0) }
420
914
  case "button":
@@ -433,7 +927,20 @@ func parseCommand(_ line: String, base: Config) -> Command? {
433
927
  if let jpeg = object["jpeg"] as? Bool { config.jpeg = jpeg }
434
928
  if let jpegFps = object["jpegFps"] as? Double, jpegFps > 0 { config.jpegFps = min(jpegFps, 60) }
435
929
  if let video = object["video"] as? Bool { config.video = video }
930
+ config.deviceFrame = object["deviceFrame"] as? Bool ?? false
931
+ config.duoFrame = (object["duoFrame"] as? [String: Any]).flatMap { duo in
932
+ guard let edge = duo["maxEdge"] as? Int, edge > 0,
933
+ let fps = duo["fps"] as? Double, fps > 0
934
+ else { return nil }
935
+ return DuoCapture(maxEdge: min(edge, 4096), fps: min(fps, 60))
936
+ }
436
937
  if let bitrate = object["bitrate"] as? Int, bitrate > 0 { config.bitrate = bitrate }
938
+ config.record = (object["record"] as? [String: Any]).flatMap { record in
939
+ guard let edge = record["maxEdge"] as? Int, edge > 0, let bitrate = record["bitrate"] as? Int, bitrate > 0,
940
+ let fps = record["fps"] as? Double, fps > 0
941
+ else { return nil }
942
+ return Recording(maxEdge: min(edge, 4096), fps: min(fps, 60), bitrate: bitrate)
943
+ }
437
944
  return .config(config)
438
945
  default:
439
946
  return nil
@@ -443,6 +950,7 @@ func parseCommand(_ line: String, base: Config) -> Command? {
443
950
  protocol Source: AnyObject {
444
951
  func configure(_ config: Config)
445
952
  func keyframe()
953
+ func recordKeyframe()
446
954
  func input(_ command: Command)
447
955
  }
448
956
 
@@ -452,7 +960,10 @@ func readCommands(_ source: Source) {
452
960
  var config = Config()
453
961
  while true {
454
962
  let chunk = FileHandle.standardInput.availableData
455
- if chunk.isEmpty { exit(0) }
963
+ if chunk.isEmpty {
964
+ beforeExit()
965
+ exit(0)
966
+ }
456
967
  buffer += chunk
457
968
  while let newline = buffer.firstIndex(of: 0x0a) {
458
969
  let line = String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self)
@@ -463,6 +974,8 @@ func readCommands(_ source: Source) {
463
974
  source.configure(config)
464
975
  case .keyframe?:
465
976
  source.keyframe()
977
+ case .recordKeyframe?:
978
+ source.recordKeyframe()
466
979
  case let command?:
467
980
  source.input(command)
468
981
  case nil:
@@ -500,9 +1013,30 @@ let keyCodes: [Character: (code: UInt16, shift: Bool)] = {
500
1013
 
501
1014
  extension SimulatorSource: Source {
502
1015
  func input(_ command: Command) {
1016
+ if case .duoTouch(let phase, let point, let revision) = command {
1017
+ DispatchQueue.main.async {
1018
+ MainActor.assumeIsolated {
1019
+ guard let touch = self.duo?.touch(phase, point: point, revision: revision) else { return }
1020
+ self.inputQueue.async {
1021
+ if self.hid?.isConnected != true { self.hid = SimulatorHID(udid: self.udid) }
1022
+ self.hid?.touch(phase, at: touch.point, screenID: touch.screenID)
1023
+ }
1024
+ }
1025
+ }
1026
+ return
1027
+ }
1028
+ if case .duoRelease = command {
1029
+ DispatchQueue.main.async { MainActor.assumeIsolated { self.releaseDuo(self.duo?.release()) } }
1030
+ return
1031
+ }
503
1032
  inputQueue.async { self.apply(command) }
504
1033
  }
505
1034
 
1035
+ private func releaseDuo(_ touch: DuoFrameRenderer.Touch?) {
1036
+ guard let touch else { return }
1037
+ inputQueue.async { self.hid?.touch(.up, at: touch.point, screenID: touch.screenID) }
1038
+ }
1039
+
506
1040
  private func apply(_ command: Command) {
507
1041
  if case .rotate(let clockwise) = command {
508
1042
  if !SimulatorRotation.rotate(udid: udid, clockwise: clockwise) {
@@ -535,7 +1069,7 @@ extension SimulatorSource: Source {
535
1069
  hid.button(button, down: true)
536
1070
  usleep(100_000)
537
1071
  hid.button(button, down: false)
538
- case .config, .keyframe, .rotate, .posture:
1072
+ case .config, .keyframe, .recordKeyframe, .rotate, .posture, .control, .scoped, .scroll, .key, .window, .duoTouch, .duoRelease:
539
1073
  break
540
1074
  }
541
1075
  }
@@ -581,7 +1115,7 @@ extension EmulatorSource: Source {
581
1115
  wait("rotation") { await EmulatorRotation.rotate(serial: self.serial, clockwise: clockwise) }
582
1116
  case .posture(let posture):
583
1117
  wait("posture") { await posture.apply(serial: self.serial) }
584
- case .config, .keyframe:
1118
+ case .config, .keyframe, .recordKeyframe, .control, .scoped, .scroll, .key, .window, .duoTouch, .duoRelease:
585
1119
  break
586
1120
  }
587
1121
  }
@@ -632,21 +1166,116 @@ extension EmulatorSource: Source {
632
1166
  setvbuf(stdout, nil, _IONBF, 0)
633
1167
  signal(SIGPIPE, SIG_IGN)
634
1168
  let arguments = CommandLine.arguments
635
- guard arguments.count == 3 else { fail("usage: stim-frames ios <udid> | android <serial>") }
1169
+ let usage =
1170
+ "usage: stim-frames ios <udid> | android <serial> | android-device <serial> <adb> <scrcpy-server> | web <cdpEndpoint> <chromePid> <targetId> | iphone <udid> [name]"
1171
+ let terminated = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global())
1172
+ let counts = [
1173
+ "simulator-options": [4, 5], "hinge-monitor": [3], "web": [5], "iphone": [3, 4], "android-device": [5], "android": [3, 4],
1174
+ ]
1175
+ guard arguments.count > 1, (counts[arguments[1]] ?? [3]).contains(arguments.count) else { fail(usage) }
636
1176
  switch arguments[1] {
1177
+ case "hinge-monitor":
1178
+ guard let directory = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] else { exit(1) }
1179
+ CoreSimulator.developerDir = directory
1180
+ DuoHingeMonitor.run(udid: arguments[2])
1181
+ case "simulator-options":
1182
+ DispatchQueue.global().async {
1183
+ _ = FileHandle.standardInput.readDataToEndOfFile()
1184
+ exit(0)
1185
+ }
1186
+ CoreSimulator.developerDir = CoreSimulator.selectedDeveloperDir()
1187
+ do {
1188
+ let udid = arguments[2]
1189
+ let settings: SimulatorDevelopmentOptions.Settings
1190
+ switch arguments[3] {
1191
+ case "read" where arguments.count == 4:
1192
+ settings = try SimulatorDevelopmentOptions.read(udid: udid)
1193
+ case "shake" where arguments.count == 4:
1194
+ try SimulatorDevelopmentOptions.shake(udid: udid)
1195
+ settings = try SimulatorDevelopmentOptions.read(udid: udid)
1196
+ case "slow-animations" where arguments.count == 5 && ["on", "off"].contains(arguments[4]):
1197
+ settings = try SimulatorDevelopmentOptions.setSlowAnimations(arguments[4] == "on", udid: udid)
1198
+ default:
1199
+ throw NSError(domain: "StimFrames", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid simulator option."])
1200
+ }
1201
+ let result: [String: Any] = [
1202
+ "canShake": settings.canShake,
1203
+ "slowAnimations": settings.slowAnimations.map { $0 as Any } ?? NSNull(),
1204
+ ]
1205
+ let data = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
1206
+ FileHandle.standardOutput.write(data)
1207
+ exit(0)
1208
+ } catch {
1209
+ FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
1210
+ exit(1)
1211
+ }
1212
+ case "macos":
1213
+ guard let data = arguments[2].data(using: .utf8),
1214
+ let app = try? JSONDecoder().decode(MacosSource.OwnedApp.self, from: data)
1215
+ else { fail("Invalid owned macOS app target.") }
1216
+ let source = MacosSource(app: app)
1217
+ let stop = source.stop
1218
+ beforeExit = stop
1219
+ signal(SIGTERM, SIG_IGN)
1220
+ terminated.setEventHandler {
1221
+ stop()
1222
+ exit(0)
1223
+ }
1224
+ terminated.resume()
1225
+ Output.requestKeyframe = source.keyframe
1226
+ readCommands(source)
1227
+ source.start()
637
1228
  case "ios":
638
1229
  CoreSimulator.developerDir = CoreSimulator.selectedDeveloperDir()
639
1230
  guard CoreSimulator.deviceSet != nil else { fail("CoreSimulator could not be loaded from \(CoreSimulator.developerDir).") }
640
1231
  let source = SimulatorSource(udid: arguments[2])
1232
+ beforeExit = source.stop
1233
+ signal(SIGTERM, SIG_IGN)
1234
+ terminated.setEventHandler {
1235
+ source.stop()
1236
+ exit(0)
1237
+ }
1238
+ terminated.resume()
641
1239
  Output.requestKeyframe = source.keyframe
642
1240
  readCommands(source)
643
1241
  source.queue.async { source.start() }
644
1242
  case "android":
645
- let source = EmulatorSource(serial: arguments[2])
1243
+ let source = EmulatorSource(serial: arguments[2], avdName: arguments.count == 4 ? arguments[3] : nil)
1244
+ Output.requestKeyframe = source.keyframe
1245
+ readCommands(source)
1246
+ source.start()
1247
+ case "android-device":
1248
+ let source = AndroidDeviceSource(
1249
+ serial: arguments[2], adb: arguments[3], serverJar: URL(fileURLWithPath: arguments[4]))
1250
+ let stop = source.stream.stop
1251
+ beforeExit = stop
1252
+ signal(SIGTERM, SIG_IGN)
1253
+ terminated.setEventHandler {
1254
+ stop()
1255
+ exit(0)
1256
+ }
1257
+ terminated.resume()
1258
+ Output.requestKeyframe = source.keyframe
1259
+ readCommands(source)
1260
+ source.start()
1261
+ case "web":
1262
+ guard let endpoint = URL(string: arguments[2]), let pid = Int32(arguments[3]) else { fail(usage) }
1263
+ let source = WebSource(endpoint: endpoint, chromePid: pid, targetId: arguments[4])
646
1264
  Output.requestKeyframe = source.keyframe
647
1265
  readCommands(source)
648
1266
  source.start()
1267
+ case "iphone":
1268
+ let source = PhoneSource(udid: arguments[2], name: arguments.count > 3 ? arguments[3] : nil)
1269
+ Output.requestKeyframe = source.keyframe
1270
+ readCommands(source)
1271
+ source.queue.async { source.start() }
649
1272
  default:
650
- fail("usage: stim-frames ios <udid> | android <serial>")
1273
+ fail(usage)
1274
+ }
1275
+ if arguments[1] == "macos" {
1276
+ // AppKit refreshes NSRunningApplication activation during main run-loop turns.
1277
+ RunLoop.main.add(Port(), forMode: .default)
1278
+ CFRunLoopRun()
1279
+ } else {
1280
+ dispatchMain()
651
1281
  }
652
- dispatchMain()