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.
Files changed (47) hide show
  1. package/README.md +10 -5
  2. package/Sources/SimNative/CaptureEngine.swift +75 -9
  3. package/Sources/SimNative/FrameCapture.swift +129 -25
  4. package/Sources/SimNative/H264Encoder.swift +7 -4
  5. package/Sources/SimNative/sim-module.swift +27 -3
  6. package/Sources/SimNativeSupport/DevicePose.swift +1 -2
  7. package/Sources/SimNativeSupport/FramebufferSourceCache.swift +58 -0
  8. package/Sources/SimNativeSupport/SeedRecheckSchedule.swift +14 -0
  9. package/Sources/SimNativeSupport/StreamBitrate.swift +26 -0
  10. package/Sources/SimNativeSupport/SurfaceFrameStamp.swift +13 -0
  11. package/Tests/SimNativeSupportTests/DevicePoseTests.swift +6 -2
  12. package/Tests/SimNativeSupportTests/FramebufferSourceCacheTests.swift +89 -0
  13. package/Tests/SimNativeSupportTests/SeedRecheckScheduleTests.swift +12 -0
  14. package/Tests/SimNativeSupportTests/StreamBitrateTests.swift +39 -0
  15. package/Tests/SimNativeSupportTests/SurfaceFrameStampTests.swift +25 -0
  16. package/dist/middleware.js +46 -46
  17. package/dist/native/serve-sim-native.node +0 -0
  18. package/dist/serve-sim.js +80 -79
  19. package/package.json +29 -2
  20. package/src/auth-bootstrap.ts +48 -0
  21. package/src/client/types.ts +39 -0
  22. package/src/client/utils/permissions.ts +18 -0
  23. package/src/debug.ts +12 -0
  24. package/src/device-displays.ts +118 -0
  25. package/src/device-hub-input.ts +41 -0
  26. package/src/device-pose.ts +80 -0
  27. package/src/device-session.ts +1092 -0
  28. package/src/device.ts +49 -0
  29. package/src/devicekit-chrome.ts +1139 -0
  30. package/src/diagnostics-redact.ts +25 -0
  31. package/src/doctor.ts +446 -0
  32. package/src/duo-frame-parser.ts +40 -0
  33. package/src/duo-preview.ts +399 -0
  34. package/src/duo-renderer.ts +193 -0
  35. package/src/duo-state.ts +160 -0
  36. package/src/event-log.ts +674 -0
  37. package/src/exec-ws.ts +333 -0
  38. package/src/helper-lifecycle.ts +378 -0
  39. package/src/host-rpc.ts +454 -0
  40. package/src/max-bitrate.ts +41 -0
  41. package/src/native.ts +29 -4
  42. package/src/ports.ts +40 -0
  43. package/src/preview-initial-state.ts +51 -0
  44. package/src/request-trust.ts +217 -0
  45. package/src/runtime.ts +280 -0
  46. package/src/shell-quote.ts +18 -0
  47. 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 **Folded**,
44
- **Semi-folded**, and **Fully open** controls for iPhone Duo. Selecting one drives
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. Closed (0°), Tent (80°),
75
- Table (100°), Book (130°), and Open (180°) are angle presets; use Rotate for
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
- return { await self.removeConsumer(id) }
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 lastSeeds: [ObjectIdentifier: UInt32] = [:]
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
- func start(deviceUDID: String, onFrame: @escaping @Sendable (CVPixelBuffer, CMTime) -> Void) throws {
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
- lastSeeds.removeAll()
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: { [self] in assumeIsolated { $0.captureFrame() } },
241
- surfacesChangedCallback: { [self] in assumeIsolated { $0.captureFrame() } },
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
- private func captureFrame(force: Bool = false) {
278
- guard let desc = pickBestDescriptor() else { return }
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
- let surface = unsafeBitCast(surfObj, to: IOSurface.self)
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 seed = IOSurfaceGetSeed(surface)
291
- let seedChanged = lastSeeds[key] != seed
292
- if frameCount > 0, !seedChanged, !force { return }
293
- lastSeeds[key] = seed
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
- guard let copy = photocopier.copy(pb) else { return }
326
- onFrame?(copy, timestamp)
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
- lastSeeds.removeAll()
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 var bitrate: Int
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, bitrate: Int = 6_000_000, diagnostics: EncoderDiagnostics? = nil) {
38
+ init(fps: Int = 60, maxBitrate: Int? = nil, diagnostics: EncoderDiagnostics? = nil) {
38
39
  self.fps = Int32(fps)
39
- self.bitrate = bitrate
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: bitrate)),
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
- @NodeConstructor init(_ udid: String) throws {
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
- var buffer = try CaptureBuffer(initialCapacity: 1024 * 1024)
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
+ }