serve-sim 0.1.49-beta.134.1 → 0.1.49-beta.136.1
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 +10 -5
- package/Sources/SimNative/CaptureEngine.swift +75 -9
- package/Sources/SimNative/FrameCapture.swift +129 -25
- package/Sources/SimNative/H264Encoder.swift +7 -4
- package/Sources/SimNative/sim-module.swift +27 -3
- package/Sources/SimNativeSupport/DevicePose.swift +1 -2
- package/Sources/SimNativeSupport/FramebufferSourceCache.swift +58 -0
- package/Sources/SimNativeSupport/SeedRecheckSchedule.swift +14 -0
- package/Sources/SimNativeSupport/StreamBitrate.swift +26 -0
- package/Sources/SimNativeSupport/SurfaceFrameStamp.swift +13 -0
- package/Tests/SimNativeSupportTests/DevicePoseTests.swift +6 -2
- package/Tests/SimNativeSupportTests/FramebufferSourceCacheTests.swift +89 -0
- package/Tests/SimNativeSupportTests/SeedRecheckScheduleTests.swift +12 -0
- package/Tests/SimNativeSupportTests/StreamBitrateTests.swift +39 -0
- package/Tests/SimNativeSupportTests/SurfaceFrameStampTests.swift +25 -0
- package/dist/middleware.js +46 -46
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +80 -79
- package/package.json +29 -2
- package/src/auth-bootstrap.ts +48 -0
- package/src/client/types.ts +39 -0
- package/src/client/utils/permissions.ts +18 -0
- package/src/debug.ts +12 -0
- package/src/device-displays.ts +118 -0
- package/src/device-hub-input.ts +41 -0
- package/src/device-pose.ts +80 -0
- package/src/device-session.ts +1092 -0
- package/src/device.ts +49 -0
- package/src/devicekit-chrome.ts +1139 -0
- package/src/diagnostics-redact.ts +25 -0
- package/src/doctor.ts +446 -0
- package/src/duo-frame-parser.ts +40 -0
- package/src/duo-preview.ts +399 -0
- package/src/duo-renderer.ts +193 -0
- package/src/duo-state.ts +160 -0
- package/src/event-log.ts +674 -0
- package/src/exec-ws.ts +333 -0
- package/src/helper-lifecycle.ts +378 -0
- package/src/host-rpc.ts +454 -0
- package/src/max-bitrate.ts +41 -0
- package/src/native.ts +29 -4
- package/src/ports.ts +40 -0
- package/src/preview-initial-state.ts +51 -0
- package/src/request-trust.ts +217 -0
- package/src/runtime.ts +280 -0
- package/src/shell-quote.ts +18 -0
- package/src/ui-settings.ts +319 -0
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ Requires macOS with Xcode command line tools (`xcrun simctl`) and a [maintained
|
|
|
40
40
|
|
|
41
41
|
## iPhone Duo
|
|
42
42
|
|
|
43
|
-
With Xcode 27.1 and the iOS 27.1 runtime, the preview exposes **
|
|
44
|
-
**
|
|
43
|
+
With Xcode 27.1 and the iOS 27.1 runtime, the preview exposes **Closed**,
|
|
44
|
+
**Book**, and **Open** controls for iPhone Duo. Selecting one drives
|
|
45
45
|
the simulator's hinge and follows the active panel's framebuffer. Touches use the panel's own
|
|
46
46
|
integrated digitizer; volume and power controls use a guest HID service.
|
|
47
47
|
|
|
@@ -71,9 +71,9 @@ press-and-hold. The main control bar has no volume menu or 2D/3D mode toggle.
|
|
|
71
71
|
See the [feature contract and regression checklist](../../.agents/skills/serve-sim-duo-verification/references/expected-behavior.md)
|
|
72
72
|
for appearance, folding, rotation, controls, screenshot, and performance checks.
|
|
73
73
|
|
|
74
|
-
The preview follows hinge changes made in Device Hub.
|
|
75
|
-
|
|
76
|
-
orientation. SpringBoard primary-display events choose the captured panel,
|
|
74
|
+
The preview follows hinge changes made in Device Hub. The pose toolbar offers
|
|
75
|
+
exactly three presets: Closed (0°), Book (130°), and Open (180°). Set any other
|
|
76
|
+
angle with `serve-sim fold <deg>`; use Rotate for orientation. SpringBoard primary-display events choose the captured panel,
|
|
77
77
|
including intermediate poses that depend on whether you were opening or closing.
|
|
78
78
|
Until guest readback arrives, a 90° threshold provides the initial fallback.
|
|
79
79
|
The runtime decides which intermediate poses apps adopt.
|
|
@@ -141,6 +141,10 @@ Options:
|
|
|
141
141
|
--codec <codec> Stream codec for the preview UI: 'auto' (H.264 when the
|
|
142
142
|
browser can decode it) or 'mjpeg' (force software JPEG —
|
|
143
143
|
e.g. on VMs without H.264 encode)
|
|
144
|
+
--max-bitrate <Mbps>
|
|
145
|
+
Cap the H.264 stream bitrate (default scales with
|
|
146
|
+
screen size, 6-30 Mbps). Also read from
|
|
147
|
+
SERVE_SIM_MAX_BITRATE
|
|
144
148
|
--list [device] List running streams
|
|
145
149
|
--kill [device] Kill running stream(s)
|
|
146
150
|
|
|
@@ -167,6 +171,7 @@ serve-sim --kill # stop all helpers
|
|
|
167
171
|
serve-sim --panes devices,tools --fit # start with selected panes open and fit the simulator
|
|
168
172
|
serve-sim --theme dark # start the simulator in Dark Mode
|
|
169
173
|
serve-sim --allow-exec # enable `serve-sim exec` and .ipa drag-to-install
|
|
174
|
+
serve-sim --max-bitrate 8 # cap H.264 at 8 Mbps (slow links / shared hosts)
|
|
170
175
|
|
|
171
176
|
# Type text into the focused field
|
|
172
177
|
serve-sim type "Hello, world!"
|
|
@@ -13,13 +13,23 @@ import os
|
|
|
13
13
|
struct Frame: Identifiable {
|
|
14
14
|
let id = UUID()
|
|
15
15
|
let pixelBuffer: CVPixelBuffer
|
|
16
|
+
/// False when `pixelBuffer` wraps SimulatorKit's live framebuffer surface
|
|
17
|
+
/// (no pixel consumer was attached, so the private copy was skipped). Such
|
|
18
|
+
/// frames are only good for their dimensions.
|
|
19
|
+
var isPrivateCopy = true
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
protocol FrameEncoder {
|
|
19
23
|
associatedtype Encoded
|
|
24
|
+
/// Whether this encoder reads pixels (and so needs a private frame copy).
|
|
25
|
+
static var readsPixels: Bool { get }
|
|
20
26
|
func encode(_ frame: Frame) async throws -> Encoded
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
extension FrameEncoder {
|
|
30
|
+
static var readsPixels: Bool { true }
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
protocol CaptureConsuming: Sendable {
|
|
24
34
|
// this is intentionally synchronous. CaptureEngine sends all frames to all consumers,
|
|
25
35
|
// and lets them handle internal backpressure as they see fit. if instead this were async
|
|
@@ -66,6 +76,7 @@ actor CaptureConsumer<E: FrameEncoder>: CaptureConsuming {
|
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
nonisolated func handleFrame(_ frame: Frame) {
|
|
79
|
+
if E.readsPixels, !frame.isPrivateCopy { return }
|
|
69
80
|
continuation.yield(frame)
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -81,18 +92,27 @@ actor CaptureEngine {
|
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
private let deviceUDID: String
|
|
95
|
+
/// Optional cap (bits/s) on the resolution-scaled H.264 bitrate.
|
|
96
|
+
private let maxBitrate: Int?
|
|
84
97
|
private let frameCapture = FrameCapture()
|
|
85
98
|
private var phase = Phase.unstarted
|
|
86
99
|
|
|
87
100
|
// mjpeg is stateless so we can share a single encoder instance
|
|
88
101
|
private let mjpegEncoder = MJPEGEncoder()
|
|
89
102
|
private let encoderDiagnostics = EncoderDiagnostics()
|
|
103
|
+
// Separate so viewer seeds don't queue behind (or evict the cache of)
|
|
104
|
+
// per-frame MJPEG viewer encodes.
|
|
105
|
+
private let snapshotEncoder = MJPEGEncoder()
|
|
90
106
|
|
|
91
107
|
private(set) var screenSize = Dimensions(width: 0, height: 0)
|
|
92
108
|
private var consumers = [UUID: CaptureConsuming]()
|
|
109
|
+
/// Number of attached consumers that read pixels. FrameCapture only pays
|
|
110
|
+
/// for the private per-frame copy while this is non-zero.
|
|
111
|
+
private let pixelConsumers = PixelConsumerCount()
|
|
93
112
|
|
|
94
|
-
init(deviceUDID: String) {
|
|
113
|
+
init(deviceUDID: String, maxBitrate: Int? = nil) {
|
|
95
114
|
self.deviceUDID = deviceUDID
|
|
115
|
+
self.maxBitrate = maxBitrate
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
func setPreferredScreenSize(width: Int, height: Int) async {
|
|
@@ -124,8 +144,8 @@ actor CaptureEngine {
|
|
|
124
144
|
bufferingPolicy: .bufferingNewest(1)
|
|
125
145
|
)
|
|
126
146
|
do {
|
|
127
|
-
try await frameCapture.start(deviceUDID: deviceUDID) { pixelBuffer, _ in
|
|
128
|
-
frameContinuation.yield(Frame(pixelBuffer: pixelBuffer))
|
|
147
|
+
try await frameCapture.start(deviceUDID: deviceUDID, pixelConsumers: pixelConsumers) { pixelBuffer, _, isCopy in
|
|
148
|
+
frameContinuation.yield(Frame(pixelBuffer: pixelBuffer, isPrivateCopy: isCopy))
|
|
129
149
|
}
|
|
130
150
|
} catch {
|
|
131
151
|
// Preserve retryability after an ordinary start failure, but never
|
|
@@ -161,13 +181,21 @@ actor CaptureEngine {
|
|
|
161
181
|
}
|
|
162
182
|
let id = UUID()
|
|
163
183
|
consumers[id] = consumer
|
|
164
|
-
|
|
184
|
+
if E.readsPixels {
|
|
185
|
+
pixelConsumers.increment()
|
|
186
|
+
// Emit a copied frame now rather than waiting for the next screen
|
|
187
|
+
// change or idle tick, so a new viewer's first IDR is immediate.
|
|
188
|
+
Task { [frameCapture] in await frameCapture.requestFrame() }
|
|
189
|
+
}
|
|
190
|
+
return { await self.removeConsumer(id, readsPixels: E.readsPixels) }
|
|
165
191
|
}
|
|
166
192
|
|
|
167
193
|
private func removeConsumer(
|
|
168
|
-
_ id: UUID
|
|
194
|
+
_ id: UUID,
|
|
195
|
+
readsPixels: Bool
|
|
169
196
|
) {
|
|
170
|
-
consumers.removeValue(forKey: id)
|
|
197
|
+
guard consumers.removeValue(forKey: id) != nil else { return }
|
|
198
|
+
if readsPixels { pixelConsumers.decrement() }
|
|
171
199
|
}
|
|
172
200
|
|
|
173
201
|
private func handleFrame(_ frame: Frame) {
|
|
@@ -178,6 +206,27 @@ actor CaptureEngine {
|
|
|
178
206
|
}
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
/// Reports the screen size with the first frame after subscribing (at most
|
|
210
|
+
/// one idle tick away) and whenever it changes, without encoding anything.
|
|
211
|
+
func addSizeConsumer(
|
|
212
|
+
onSize: sending @escaping (Dimensions) async -> Void
|
|
213
|
+
) -> (@Sendable () async -> Void) {
|
|
214
|
+
var last: Dimensions?
|
|
215
|
+
return addConsumer(encoder: SizeEncoder()) { size in
|
|
216
|
+
guard size != last else { return }
|
|
217
|
+
last = size
|
|
218
|
+
await onSize(size)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// JPEG of the current screen, encoded on demand. Used to seed new viewers
|
|
223
|
+
/// so the session doesn't JPEG-encode every frame just in case.
|
|
224
|
+
func snapshotJPEG() async throws -> (Dimensions, Data)? {
|
|
225
|
+
guard phase == .running, let pixelBuffer = await frameCapture.copyCurrentFrame() else { return nil }
|
|
226
|
+
let frame = Frame(pixelBuffer: pixelBuffer)
|
|
227
|
+
return (frame.pixelBuffer.dimensions, try await snapshotEncoder.encode(frame))
|
|
228
|
+
}
|
|
229
|
+
|
|
181
230
|
func addMJPEGConsumer(
|
|
182
231
|
onFrame: sending @escaping (Dimensions, Data) async -> Void
|
|
183
232
|
) -> (@Sendable () async -> Void) {
|
|
@@ -190,7 +239,7 @@ actor CaptureEngine {
|
|
|
190
239
|
func addAVCCConsumer(
|
|
191
240
|
onFrame: sending @escaping (Dimensions, Data, Int32) async -> Void
|
|
192
241
|
) -> (@Sendable () async -> Void) {
|
|
193
|
-
addConsumer(encoder: AVCCEncoder(diagnostics: encoderDiagnostics)) { [weak self] encoded in
|
|
242
|
+
addConsumer(encoder: AVCCEncoder(diagnostics: encoderDiagnostics, maxBitrate: maxBitrate)) { [weak self] encoded in
|
|
194
243
|
let flagDescription: Int32 = 1 << 0
|
|
195
244
|
let flagKeyframe: Int32 = 1 << 1
|
|
196
245
|
|
|
@@ -224,9 +273,26 @@ actor CaptureEngine {
|
|
|
224
273
|
phase = .stopped
|
|
225
274
|
Task { [frameCapture] in await frameCapture.stop() }
|
|
226
275
|
consumers.removeAll()
|
|
276
|
+
pixelConsumers.reset()
|
|
227
277
|
}
|
|
228
278
|
}
|
|
229
279
|
|
|
280
|
+
struct SizeEncoder: FrameEncoder {
|
|
281
|
+
static var readsPixels: Bool { false }
|
|
282
|
+
func encode(_ frame: Frame) async throws -> Dimensions { frame.pixelBuffer.dimensions }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Lock-protected count shared by CaptureEngine (writer) and FrameCapture
|
|
286
|
+
/// (reader on its capture queue).
|
|
287
|
+
final class PixelConsumerCount: @unchecked Sendable {
|
|
288
|
+
private let lock = NSLock()
|
|
289
|
+
private var count = 0
|
|
290
|
+
func increment() { lock.lock(); count += 1; lock.unlock() }
|
|
291
|
+
func decrement() { lock.lock(); count = max(0, count - 1); lock.unlock() }
|
|
292
|
+
func reset() { lock.lock(); count = 0; lock.unlock() }
|
|
293
|
+
var isEmpty: Bool { lock.lock(); defer { lock.unlock() }; return count == 0 }
|
|
294
|
+
}
|
|
295
|
+
|
|
230
296
|
actor MJPEGEncoder: FrameEncoder {
|
|
231
297
|
private let videoEncoder = VideoEncoder(quality: 0.7)
|
|
232
298
|
private var lastImage: (UUID, Data)?
|
|
@@ -247,8 +313,8 @@ actor AVCCEncoder: FrameEncoder {
|
|
|
247
313
|
let h264Encoder: H264Encoder
|
|
248
314
|
var forceKeyframe = true
|
|
249
315
|
|
|
250
|
-
init(diagnostics: EncoderDiagnostics? = nil) {
|
|
251
|
-
h264Encoder = H264Encoder(fps: 60, diagnostics: diagnostics)
|
|
316
|
+
init(diagnostics: EncoderDiagnostics? = nil, maxBitrate: Int? = nil) {
|
|
317
|
+
h264Encoder = H264Encoder(fps: 60, maxBitrate: maxBitrate, diagnostics: diagnostics)
|
|
252
318
|
}
|
|
253
319
|
|
|
254
320
|
func encode(_ frame: Frame) async throws -> H264Encoder.Encoded? {
|
|
@@ -18,13 +18,14 @@ actor FrameCapture {
|
|
|
18
18
|
nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() }
|
|
19
19
|
|
|
20
20
|
private var photocopier = Photocopier()
|
|
21
|
-
private var onFrame: ((CVPixelBuffer, CMTime) -> Void)?
|
|
21
|
+
private var onFrame: ((CVPixelBuffer, CMTime, Bool) -> Void)?
|
|
22
|
+
private var pixelConsumers: PixelConsumerCount?
|
|
22
23
|
private var frameCount: UInt64 = 0
|
|
23
24
|
private(set) var capturedWidth: Int = 0
|
|
24
25
|
private(set) var capturedHeight: Int = 0
|
|
25
26
|
private var idleTimer: Task<Void, Never>?
|
|
26
27
|
private var lastCaptureTime: ContinuousClock.Instant = .now
|
|
27
|
-
private var
|
|
28
|
+
private var lastStamps: [ObjectIdentifier: SurfaceFrameStamp] = [:]
|
|
28
29
|
private var rewireTickCount: Int = 0
|
|
29
30
|
/// Interval at which the idle timer re-emits the current frame even when
|
|
30
31
|
/// the simulator isn't rendering anything new. This is load-bearing for two
|
|
@@ -50,9 +51,19 @@ actor FrameCapture {
|
|
|
50
51
|
private var lastSelectedIndex: Int?
|
|
51
52
|
private var lastSelectionReason = "none"
|
|
52
53
|
private var sizeChanges: [[String: Any]] = []
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
private var sourceCache = FramebufferSourceCache<FramebufferSource>(maxAge: 1.0)
|
|
55
|
+
private var recheckGeneration: UInt64 = 0
|
|
56
|
+
|
|
57
|
+
/// `onFrame(buffer, timestamp, isPrivateCopy)`: frames are deep-copied only
|
|
58
|
+
/// while `pixelConsumers` is non-empty; otherwise the buffer wraps the live
|
|
59
|
+
/// framebuffer surface and is only good for its dimensions.
|
|
60
|
+
func start(
|
|
61
|
+
deviceUDID: String,
|
|
62
|
+
pixelConsumers: PixelConsumerCount,
|
|
63
|
+
onFrame: @escaping @Sendable (CVPixelBuffer, CMTime, Bool) -> Void
|
|
64
|
+
) throws {
|
|
55
65
|
self.onFrame = onFrame
|
|
66
|
+
self.pixelConsumers = pixelConsumers
|
|
56
67
|
|
|
57
68
|
SimFrameworks.load()
|
|
58
69
|
guard let device = Self.findSimDevice(udid: deviceUDID) else {
|
|
@@ -105,7 +116,8 @@ actor FrameCapture {
|
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
callbackUUIDs.removeAll()
|
|
108
|
-
|
|
119
|
+
lastStamps.removeAll()
|
|
120
|
+
sourceCache.invalidate()
|
|
109
121
|
descriptors = candidates
|
|
110
122
|
|
|
111
123
|
// Registering screen callbacks is what causes SimulatorKit to wire the
|
|
@@ -234,15 +246,38 @@ actor FrameCapture {
|
|
|
234
246
|
let uuid = UUID()
|
|
235
247
|
callbackUUIDs[ObjectIdentifier(desc)] = uuid
|
|
236
248
|
|
|
249
|
+
// Explicitly typed locals keep the type checker from choking on the
|
|
250
|
+
// dynamic AnyObject call (some Xcode swiftc builds crash otherwise).
|
|
251
|
+
let frameCallback: @convention(block) () -> Void = { [self, weak desc] in
|
|
252
|
+
assumeIsolated { capture in
|
|
253
|
+
capture.onFrameCallback(from: desc)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let surfacesChangedCallback: @convention(block) () -> Void = { [self] in
|
|
257
|
+
assumeIsolated { capture in
|
|
258
|
+
_ = capture.captureFrame(trigger: .surfacesChanged)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let propertiesChangedCallback: @convention(block) () -> Void = {}
|
|
262
|
+
|
|
237
263
|
desc.registerScreenCallbacks(
|
|
238
264
|
uuid: uuid,
|
|
239
265
|
callbackQueue: queue,
|
|
240
|
-
frameCallback:
|
|
241
|
-
surfacesChangedCallback:
|
|
242
|
-
propertiesChangedCallback:
|
|
266
|
+
frameCallback: frameCallback,
|
|
267
|
+
surfacesChangedCallback: surfacesChangedCallback,
|
|
268
|
+
propertiesChangedCallback: propertiesChangedCallback
|
|
243
269
|
)
|
|
244
270
|
}
|
|
245
271
|
|
|
272
|
+
/// `desc` is nil once the registering descriptor has been released.
|
|
273
|
+
private func onFrameCallback(from desc: AnyObject?) {
|
|
274
|
+
var fromCached = false
|
|
275
|
+
if let desc, let current = sourceCache.current?.descriptor {
|
|
276
|
+
fromCached = current === desc
|
|
277
|
+
}
|
|
278
|
+
onFrameCallback(trigger: fromCached ? .frame : .otherDescriptorFrame)
|
|
279
|
+
}
|
|
280
|
+
|
|
246
281
|
private func startIdleTimer() {
|
|
247
282
|
self.idleTimer = Task { [weak self] in
|
|
248
283
|
while !Task.isCancelled {
|
|
@@ -256,7 +291,7 @@ actor FrameCapture {
|
|
|
256
291
|
private func onIdleTimerTick() {
|
|
257
292
|
let now = ContinuousClock.now
|
|
258
293
|
guard (now - self.lastCaptureTime) >= Self.idleInterval else { return }
|
|
259
|
-
self.captureFrame(force: true)
|
|
294
|
+
self.captureFrame(force: true, trigger: .idleTick)
|
|
260
295
|
// Self-heal: if we've never captured a frame, the cached descriptor
|
|
261
296
|
// is likely stale. Re-wire the pipeline periodically (every ~1s)
|
|
262
297
|
// until frames start flowing.
|
|
@@ -274,27 +309,41 @@ actor FrameCapture {
|
|
|
274
309
|
|
|
275
310
|
// MARK: - Frame capture
|
|
276
311
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
312
|
+
/// Resolve the selected descriptor and its current IOSurface. Every
|
|
313
|
+
/// `framebufferSurface` read is an XPC round-trip, so frame callbacks go
|
|
314
|
+
/// through `sourceCache` instead of calling this directly.
|
|
315
|
+
private func resolveSource() -> FramebufferSource? {
|
|
316
|
+
guard let desc = pickBestDescriptor() else { return nil }
|
|
280
317
|
let surfSel = NSSelectorFromString("framebufferSurface")
|
|
281
|
-
guard let surfObj = desc.perform(surfSel)?.takeUnretainedValue() else { return }
|
|
282
|
-
|
|
318
|
+
guard let surfObj = desc.perform(surfSel)?.takeUnretainedValue() else { return nil }
|
|
319
|
+
return FramebufferSource(descriptor: desc, surface: unsafeBitCast(surfObj, to: IOSurface.self))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Returns false only when the frame was skipped because the surface and
|
|
323
|
+
/// its seed hadn't changed (see `onFrameCallback`).
|
|
324
|
+
@discardableResult
|
|
325
|
+
private func captureFrame(force: Bool = false, trigger: FramebufferSourceCache<FramebufferSource>.Trigger = .frame) -> Bool {
|
|
326
|
+
let now = Double(DispatchTime.now().uptimeNanoseconds) / 1e9
|
|
327
|
+
guard let source = sourceCache.source(for: trigger, now: now, resolve: resolveSource) else { return true }
|
|
328
|
+
let desc = source.descriptor
|
|
329
|
+
let surface = source.surface
|
|
283
330
|
|
|
284
331
|
// Seed-skip: when the simulator's framebuffer content hasn't changed,
|
|
285
332
|
// don't spend cycles re-encoding the same pixels back-to-back from the
|
|
286
333
|
// frame-callback path. BUT: we must still re-emit at the idle floor
|
|
287
334
|
// (~5 fps) so that downstream consumers keep seeing a live stream —
|
|
288
|
-
// see the `idleInterval` doc-comment for why that matters.
|
|
335
|
+
// see the `idleInterval` doc-comment for why that matters. The surface
|
|
336
|
+
// ID is part of the stamp so a swapped-in surface whose seed happens to
|
|
337
|
+
// match the old one's still counts as new content.
|
|
289
338
|
let key = ObjectIdentifier(desc)
|
|
290
|
-
let
|
|
291
|
-
let
|
|
292
|
-
if frameCount > 0, !
|
|
293
|
-
|
|
339
|
+
let stamp = SurfaceFrameStamp(surfaceID: IOSurfaceGetID(surface), seed: IOSurfaceGetSeed(surface))
|
|
340
|
+
let contentChanged = lastStamps[key] != stamp
|
|
341
|
+
if frameCount > 0, !contentChanged, !force { return false }
|
|
342
|
+
lastStamps[key] = stamp
|
|
294
343
|
|
|
295
344
|
let w = IOSurfaceGetWidth(surface)
|
|
296
345
|
let h = IOSurfaceGetHeight(surface)
|
|
297
|
-
guard w > 0, h > 0 else { return }
|
|
346
|
+
guard w > 0, h > 0 else { return true }
|
|
298
347
|
|
|
299
348
|
if capturedWidth != w || capturedHeight != h {
|
|
300
349
|
let from = "\(capturedWidth)x\(capturedHeight)"
|
|
@@ -317,13 +366,39 @@ actor FrameCapture {
|
|
|
317
366
|
[kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA] as CFDictionary,
|
|
318
367
|
&pixelBuffer
|
|
319
368
|
)
|
|
320
|
-
guard status == kCVReturnSuccess, let pb = pixelBuffer?.takeRetainedValue() else { return }
|
|
369
|
+
guard status == kCVReturnSuccess, let pb = pixelBuffer?.takeRetainedValue() else { return true }
|
|
321
370
|
|
|
322
371
|
lastCaptureTime = .now
|
|
323
372
|
frameCount += 1
|
|
324
373
|
let timestamp = CMTime(value: CMTimeValue(frameCount), timescale: 60)
|
|
325
|
-
|
|
326
|
-
|
|
374
|
+
if pixelConsumers?.isEmpty ?? false {
|
|
375
|
+
onFrame?(pb, timestamp, false)
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
guard let copy = photocopier.copy(pb) else { return true }
|
|
379
|
+
onFrame?(copy, timestamp, true)
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/// Frame callbacks can arrive before the surface seed reflects the new
|
|
384
|
+
/// frame. If this one finds the seed unchanged, re-check briefly instead of
|
|
385
|
+
/// leaving the frame for the idle tick. A newer callback supersedes it.
|
|
386
|
+
private func onFrameCallback(trigger: FramebufferSourceCache<FramebufferSource>.Trigger) {
|
|
387
|
+
recheckGeneration &+= 1
|
|
388
|
+
if captureFrame(trigger: trigger) { return }
|
|
389
|
+
scheduleSeedRecheck(generation: recheckGeneration, attempt: 0)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private func scheduleSeedRecheck(generation: UInt64, attempt: Int) {
|
|
393
|
+
guard let delay = SeedRecheckSchedule.delayMs(afterAttempt: attempt) else { return }
|
|
394
|
+
queue.asyncAfter(deadline: .now() + .milliseconds(delay)) { [weak self] in
|
|
395
|
+
self?.assumeIsolated { capture in
|
|
396
|
+
guard capture.recheckGeneration == generation, capture.onFrame != nil else { return }
|
|
397
|
+
if !capture.captureFrame() {
|
|
398
|
+
capture.scheduleSeedRecheck(generation: generation, attempt: attempt + 1)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
327
402
|
}
|
|
328
403
|
|
|
329
404
|
func getScreenSize() -> (width: Int, height: Int)? {
|
|
@@ -344,8 +419,10 @@ actor FrameCapture {
|
|
|
344
419
|
}
|
|
345
420
|
callbackUUIDs.removeAll()
|
|
346
421
|
descriptors.removeAll()
|
|
347
|
-
|
|
422
|
+
lastStamps.removeAll()
|
|
423
|
+
sourceCache.invalidate()
|
|
348
424
|
onFrame = nil
|
|
425
|
+
pixelConsumers = nil
|
|
349
426
|
frameCount = 0
|
|
350
427
|
capturedWidth = 0
|
|
351
428
|
capturedHeight = 0
|
|
@@ -362,6 +439,28 @@ actor FrameCapture {
|
|
|
362
439
|
sizeChanges = []
|
|
363
440
|
}
|
|
364
441
|
|
|
442
|
+
/// Emit the current screen now (e.g. a viewer just subscribed).
|
|
443
|
+
func requestFrame() {
|
|
444
|
+
captureFrame(force: true)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/// A private copy of the current screen, for one-off snapshots. Taken at
|
|
448
|
+
/// call time from the cached surface rather than retaining a wrapper of
|
|
449
|
+
/// SimulatorKit's live (recycled) framebuffer between frames.
|
|
450
|
+
func copyCurrentFrame() -> CVPixelBuffer? {
|
|
451
|
+
let now = Double(DispatchTime.now().uptimeNanoseconds) / 1e9
|
|
452
|
+
guard frameCount > 0,
|
|
453
|
+
let source = sourceCache.source(for: .frame, now: now, resolve: resolveSource) else { return nil }
|
|
454
|
+
var pixelBuffer: Unmanaged<CVPixelBuffer>?
|
|
455
|
+
let status = CVPixelBufferCreateWithIOSurface(
|
|
456
|
+
kCFAllocatorDefault, source.surface,
|
|
457
|
+
[kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA] as CFDictionary,
|
|
458
|
+
&pixelBuffer
|
|
459
|
+
)
|
|
460
|
+
guard status == kCVReturnSuccess, let pb = pixelBuffer?.takeRetainedValue() else { return nil }
|
|
461
|
+
return photocopier.copy(pb)
|
|
462
|
+
}
|
|
463
|
+
|
|
365
464
|
func setPreferredScreenSize(width: Int, height: Int) {
|
|
366
465
|
if width > 0, height > 0 {
|
|
367
466
|
preferredScreenSize = FramebufferSurfaceSize(width: width, height: height)
|
|
@@ -370,7 +469,7 @@ actor FrameCapture {
|
|
|
370
469
|
}
|
|
371
470
|
didLogRejectedPresentationSurface = false
|
|
372
471
|
didLogMissingExpectedSurface = false
|
|
373
|
-
captureFrame(force: true)
|
|
472
|
+
captureFrame(force: true, trigger: .preferredSizeChanged)
|
|
374
473
|
}
|
|
375
474
|
|
|
376
475
|
/// JSON snapshot of display selection for `serve-sim doctor`.
|
|
@@ -502,6 +601,11 @@ actor FrameCapture {
|
|
|
502
601
|
}
|
|
503
602
|
}
|
|
504
603
|
|
|
604
|
+
struct FramebufferSource {
|
|
605
|
+
let descriptor: NSObject
|
|
606
|
+
let surface: IOSurface
|
|
607
|
+
}
|
|
608
|
+
|
|
505
609
|
@objc protocol FramebufferDescriptor {
|
|
506
610
|
@objc(registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:)
|
|
507
611
|
func registerScreenCallbacks(
|
|
@@ -2,6 +2,7 @@ import Foundation
|
|
|
2
2
|
import CoreVideo
|
|
3
3
|
import CoreMedia
|
|
4
4
|
import VideoToolbox
|
|
5
|
+
import SimNativeSupport
|
|
5
6
|
|
|
6
7
|
/// Real-time H.264 encoder backed by `VTCompressionSession`, producing AVCC
|
|
7
8
|
/// (length-prefixed NAL) output for the `/stream.avcc` endpoint.
|
|
@@ -28,15 +29,15 @@ actor H264Encoder {
|
|
|
28
29
|
private var width: Int32 = 0
|
|
29
30
|
private var height: Int32 = 0
|
|
30
31
|
private let fps: Int32
|
|
31
|
-
private
|
|
32
|
+
private let maxBitrate: Int?
|
|
32
33
|
private var emittedDescription = false
|
|
33
34
|
private var frameCount: Int64 = 0
|
|
34
35
|
|
|
35
36
|
private let diagnostics: EncoderDiagnostics?
|
|
36
37
|
|
|
37
|
-
init(fps: Int = 60,
|
|
38
|
+
init(fps: Int = 60, maxBitrate: Int? = nil, diagnostics: EncoderDiagnostics? = nil) {
|
|
38
39
|
self.fps = Int32(fps)
|
|
39
|
-
self.
|
|
40
|
+
self.maxBitrate = maxBitrate
|
|
40
41
|
self.diagnostics = diagnostics
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -144,7 +145,9 @@ actor H264Encoder {
|
|
|
144
145
|
(kVTCompressionPropertyKey_RealTime, kCFBooleanTrue!),
|
|
145
146
|
(kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel),
|
|
146
147
|
(kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse!),
|
|
147
|
-
(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value:
|
|
148
|
+
(kVTCompressionPropertyKey_AverageBitRate, NSNumber(value: StreamBitrate.average(
|
|
149
|
+
width: Int(width), height: Int(height), fps: Int(fps), cap: maxBitrate
|
|
150
|
+
))),
|
|
148
151
|
(kVTCompressionPropertyKey_ExpectedFrameRate, NSNumber(value: fps)),
|
|
149
152
|
// 5s keyframe interval: IDRs are far larger than P-frames, so
|
|
150
153
|
// spacing them out keeps scroll/animation smooth. Late joiners
|
|
@@ -117,13 +117,15 @@ private func u32(_ v: Int) -> UInt32 {
|
|
|
117
117
|
private let engine: CaptureEngine
|
|
118
118
|
private let queue: NodeAsyncQueue
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
/// `maxBitrate`: optional cap (bits/s) on the resolution-scaled H.264
|
|
121
|
+
/// bitrate; null/undefined keeps the default.
|
|
122
|
+
@NodeConstructor init(_ udid: String, _ maxBitrate: Int?) throws {
|
|
121
123
|
// unref'd by NodeAsyncQueue's init, so the frame pipeline alone won't
|
|
122
124
|
// keep the event loop alive. Bounded queue + blocking AVCC preserves
|
|
123
125
|
// inter-frame ordering; MJPEG is nonblocking and drops under backpressure.
|
|
124
126
|
let queue = try NodeAsyncQueue(label: "simCapture", maxQueueSize: 16)
|
|
125
127
|
self.queue = queue
|
|
126
|
-
self.engine = CaptureEngine(deviceUDID: udid)
|
|
128
|
+
self.engine = CaptureEngine(deviceUDID: udid, maxBitrate: maxBitrate)
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
// returns a function that can be called to unsubscribe
|
|
@@ -133,8 +135,10 @@ private func u32(_ v: Int) -> UInt32 {
|
|
|
133
135
|
) async throws -> NodeFunction {
|
|
134
136
|
let codecMJPEG: Int = 0
|
|
135
137
|
let codecAVCC: Int = 1
|
|
138
|
+
let codecSize: Int = 2
|
|
136
139
|
|
|
137
|
-
|
|
140
|
+
// Size-only subscriptions deliver an empty array; don't reserve 1 MB.
|
|
141
|
+
var buffer = try CaptureBuffer(initialCapacity: codec == codecSize ? 1 : 1024 * 1024)
|
|
138
142
|
let unsubscribe: @Sendable () async -> Void
|
|
139
143
|
switch codec {
|
|
140
144
|
case codecMJPEG:
|
|
@@ -159,12 +163,32 @@ private func u32(_ v: Int) -> UInt32 {
|
|
|
159
163
|
]).as(NodePromise.self)?.value
|
|
160
164
|
}
|
|
161
165
|
}
|
|
166
|
+
case codecSize:
|
|
167
|
+
unsubscribe = await engine.addSizeConsumer { [self] dimensions in
|
|
168
|
+
try? await queue.run {
|
|
169
|
+
let array = try buffer.setData(Data())
|
|
170
|
+
_ = try? await onFrame.call([
|
|
171
|
+
array,
|
|
172
|
+
Int(dimensions.width), Int(dimensions.height),
|
|
173
|
+
0,
|
|
174
|
+
]).as(NodePromise.self)?.value
|
|
175
|
+
}
|
|
176
|
+
}
|
|
162
177
|
default:
|
|
163
178
|
throw Errors.invalidCodec
|
|
164
179
|
}
|
|
165
180
|
return try NodeFunction { await unsubscribe() }
|
|
166
181
|
}
|
|
167
182
|
|
|
183
|
+
/// JPEG of the latest frame (or null before the first frame), encoded on
|
|
184
|
+
/// demand for viewer seeds.
|
|
185
|
+
@NodeMethod func snapshotJpeg() async throws -> NodeValueConvertible {
|
|
186
|
+
guard let (_, jpeg) = try await engine.snapshotJPEG() else { return try NodeNull() }
|
|
187
|
+
let buffer = try NodeArrayBuffer(capacity: jpeg.count)
|
|
188
|
+
_ = try buffer.withUnsafeMutableBytes { jpeg.copyBytes(to: $0) }
|
|
189
|
+
return try NodeTypedArray<UInt8>(for: buffer, count: jpeg.count)
|
|
190
|
+
}
|
|
191
|
+
|
|
168
192
|
@NodeMethod func setPreferredScreenSize(_ width: Int, _ height: Int) async {
|
|
169
193
|
await engine.setPreferredScreenSize(width: width, height: height)
|
|
170
194
|
}
|
|
@@ -30,10 +30,9 @@ public enum DevicePose {
|
|
|
30
30
|
return nil
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/// Mirrors `DUO_POSES` in src/device-pose.ts: exactly closed, book, open.
|
|
33
34
|
public static let presets: [DevicePoseSpec] = [
|
|
34
35
|
DevicePoseSpec(name: "closed", hingeDegrees: 0),
|
|
35
|
-
DevicePoseSpec(name: "tent", hingeDegrees: 80),
|
|
36
|
-
DevicePoseSpec(name: "tabletop", hingeDegrees: 100),
|
|
37
36
|
DevicePoseSpec(name: "book", hingeDegrees: 130),
|
|
38
37
|
DevicePoseSpec(name: "open", hingeDegrees: 180),
|
|
39
38
|
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/// Caches the framebuffer source (descriptor + IOSurface) that capture reads.
|
|
2
|
+
///
|
|
3
|
+
/// Resolving it means calling `framebufferSurface` on every display descriptor,
|
|
4
|
+
/// and each call is a synchronous XPC round-trip into CoreSimulatorService
|
|
5
|
+
/// (~2 ms each, 4 descriptors on an iPhone). Doing that on every frame callback
|
|
6
|
+
/// put ~5 ms on the capture path of every frame. SimulatorKit keeps the same
|
|
7
|
+
/// surface across frames and fires `surfacesChanged` when it swaps it, so frame
|
|
8
|
+
/// callbacks reuse the cached source; everything else (surface changes, idle
|
|
9
|
+
/// ticks, Duo panel/size changes) re-resolves. Frames reported by a descriptor
|
|
10
|
+
/// other than the cached one re-resolve at most every 100 ms (another display
|
|
11
|
+
/// became live), and a busy stream re-resolves every `maxAge` seconds as a
|
|
12
|
+
/// safety net.
|
|
13
|
+
public struct FramebufferSourceCache<Source> {
|
|
14
|
+
public enum Trigger: Sendable {
|
|
15
|
+
/// Frame callback from the cached source's descriptor.
|
|
16
|
+
case frame
|
|
17
|
+
/// Frame callback from any other descriptor.
|
|
18
|
+
case otherDescriptorFrame
|
|
19
|
+
case surfacesChanged
|
|
20
|
+
case idleTick
|
|
21
|
+
case preferredSizeChanged
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private let maxAge: Double
|
|
25
|
+
static var otherDescriptorMaxAge: Double { 0.1 }
|
|
26
|
+
private var cached: Source?
|
|
27
|
+
private var resolvedAt = -Double.infinity
|
|
28
|
+
|
|
29
|
+
public init(maxAge: Double) {
|
|
30
|
+
self.maxAge = maxAge
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public var current: Source? { cached }
|
|
34
|
+
|
|
35
|
+
public mutating func source(for trigger: Trigger, now: Double, resolve: () -> Source?) -> Source? {
|
|
36
|
+
let age = now - resolvedAt
|
|
37
|
+
if let cached {
|
|
38
|
+
switch trigger {
|
|
39
|
+
case .frame where age < maxAge,
|
|
40
|
+
.otherDescriptorFrame where age < Self.otherDescriptorMaxAge:
|
|
41
|
+
return cached
|
|
42
|
+
default:
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
} else if trigger == .frame || trigger == .otherDescriptorFrame, age < Self.otherDescriptorMaxAge {
|
|
46
|
+
// The last resolve failed; don't repeat its XPC on every frame.
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
49
|
+
cached = resolve()
|
|
50
|
+
resolvedAt = now
|
|
51
|
+
return cached
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public mutating func invalidate() {
|
|
55
|
+
cached = nil
|
|
56
|
+
resolvedAt = -Double.infinity
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// SimulatorKit can fire a frame callback before the framebuffer IOSurface's
|
|
2
|
+
/// seed reflects the new frame (~13% of callbacks in measurement; the seed
|
|
3
|
+
/// moved 1–16 ms later). Capture used to read the seed only after ~5 ms of
|
|
4
|
+
/// per-frame XPC, which hid this. Without a re-check such a frame isn't
|
|
5
|
+
/// emitted until the next idle tick (up to 200 ms later), so a callback that
|
|
6
|
+
/// finds the seed unchanged re-checks on this schedule.
|
|
7
|
+
public enum SeedRecheckSchedule {
|
|
8
|
+
static let delaysMs = [1, 1, 2, 4, 8, 16]
|
|
9
|
+
|
|
10
|
+
/// Delay before re-check number `attempt` (0-based), or nil to give up.
|
|
11
|
+
public static func delayMs(afterAttempt attempt: Int) -> Int? {
|
|
12
|
+
delaysMs.indices.contains(attempt) ? delaysMs[attempt] : nil
|
|
13
|
+
}
|
|
14
|
+
}
|