@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.
- package/README.md +1584 -89
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -496
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5074 -549
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -4421
- package/package.json +3 -3
- 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
|
|
14
|
-
//
|
|
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
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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()
|