@appium/coresim 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/binding.gyp +7 -1
  3. package/lib/src/commands/process.d.ts.map +1 -1
  4. package/lib/src/commands/process.js +3 -4
  5. package/lib/src/commands/process.js.map +1 -1
  6. package/lib/src/commands/spawn.d.ts +9 -5
  7. package/lib/src/commands/spawn.d.ts.map +1 -1
  8. package/lib/src/commands/spawn.js +9 -5
  9. package/lib/src/commands/spawn.js.map +1 -1
  10. package/lib/src/commands/video-recording.d.ts +50 -0
  11. package/lib/src/commands/video-recording.d.ts.map +1 -0
  12. package/lib/src/commands/video-recording.js +98 -0
  13. package/lib/src/commands/video-recording.js.map +1 -0
  14. package/lib/src/commands/video-stream.d.ts +44 -0
  15. package/lib/src/commands/video-stream.d.ts.map +1 -0
  16. package/lib/src/commands/video-stream.js +240 -0
  17. package/lib/src/commands/video-stream.js.map +1 -0
  18. package/lib/src/index.d.ts +2 -1
  19. package/lib/src/index.d.ts.map +1 -1
  20. package/lib/src/index.js +1 -0
  21. package/lib/src/index.js.map +1 -1
  22. package/lib/src/native-simctl.d.ts +4 -0
  23. package/lib/src/native-simctl.d.ts.map +1 -1
  24. package/lib/src/native-simctl.js +9 -0
  25. package/lib/src/native-simctl.js.map +1 -1
  26. package/lib/src/types.d.ts +72 -0
  27. package/lib/src/types.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  30. package/src/commands/process.ts +3 -4
  31. package/src/commands/spawn.ts +9 -5
  32. package/src/commands/video-recording.ts +128 -0
  33. package/src/commands/video-stream.ts +274 -0
  34. package/src/coresim.mm +310 -4
  35. package/src/index.ts +4 -0
  36. package/src/native/sim_screenshot.h +10 -0
  37. package/src/native/sim_screenshot.mm +11 -6
  38. package/src/native/sim_video_recording.h +27 -0
  39. package/src/native/sim_video_recording.mm +87 -0
  40. package/src/native/sim_video_stream.h +61 -0
  41. package/src/native/sim_video_stream.mm +427 -0
  42. package/src/native-simctl.ts +10 -0
  43. package/src/types.ts +76 -0
@@ -12,6 +12,16 @@ namespace coresim {
12
12
  // array means the device has an IO client but no renderable display.
13
13
  NSArray<NSDictionary*>* ListDisplays(id device, NSError** error);
14
14
 
15
+ // Resolves the display descriptor CaptureScreenshot itself reads from, without capturing a
16
+ // screenshot — same displayId/fallback semantics, exposed for callers that need the descriptor
17
+ // object itself (e.g. StartVideoRecording's `screen` argument).
18
+ id ResolveCaptureDisplay(id device, NSString* displayId, NSError** error);
19
+
20
+ // The descriptor's current framebuffer as an `IOSurfaceRef` (bridge-cast the returned `id`).
21
+ // Returns nil if not available yet (e.g. connection just dropped) — not an error, since that can
22
+ // legitimately happen from one call to the next on a live device.
23
+ id CurrentDisplaySurface(id descriptor);
24
+
15
25
  enum class ScreenshotFormat { kPNG, kJPEG };
16
26
 
17
27
  // Captures a display as an image, reading the same in-process framebuffer surface `simctl io
@@ -144,6 +144,16 @@ NSString* const kJPEGUTI = @"public.jpeg";
144
144
 
145
145
  } // namespace
146
146
 
147
+ id ResolveCaptureDisplay(id device, NSString* displayId, NSError** error) {
148
+ NSArray<NSDictionary*>* candidates = RenderableDisplayCandidates(device, error);
149
+ if (candidates == nil) {
150
+ return nil;
151
+ }
152
+ return ResolveDisplayDescriptor(candidates, displayId, error);
153
+ }
154
+
155
+ id CurrentDisplaySurface(id descriptor) { return RenderableSurface(descriptor); }
156
+
147
157
  NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
148
158
  NSArray<NSDictionary*>* candidates = RenderableDisplayCandidates(device, error);
149
159
  if (candidates == nil) {
@@ -163,12 +173,7 @@ NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
163
173
 
164
174
  NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat format, NSNumber* jpegQualityPercent,
165
175
  NSError** error) {
166
- NSArray<NSDictionary*>* candidates = RenderableDisplayCandidates(device, error);
167
- if (candidates == nil) {
168
- return nil;
169
- }
170
-
171
- id descriptor = ResolveDisplayDescriptor(candidates, displayId, error);
176
+ id descriptor = ResolveCaptureDisplay(device, displayId, error);
172
177
  if (descriptor == nil) {
173
178
  return nil;
174
179
  }
@@ -0,0 +1,27 @@
1
+ #pragma once
2
+
3
+ #import <Foundation/Foundation.h>
4
+ #import <dispatch/dispatch.h>
5
+
6
+ namespace coresim {
7
+
8
+ // maskPolicy for a non-rectangular display. 0/1/2 = ignored/alpha/black, confirmed empirically —
9
+ // alpha renders identically to black (see CLAUDE.md).
10
+ enum class VideoMaskPolicy : long long {
11
+ kIgnored = 0,
12
+ kAlpha = 1,
13
+ kBlack = 2,
14
+ };
15
+
16
+ // Starts recording `displayId` (nil = primary) to `outputFile`, an absolute path, not a URL (see
17
+ // CLAUDE.md). Returns NO+*error on synchronous resolution failure (`handler` never called then);
18
+ // otherwise `handler` fires once the first frame is recorded, non-nil NSError on failure. Throws
19
+ // NativeSimUnavailableError instead if this CoreSimulator has no video capture service at all.
20
+ BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, NSDictionary* assetWriterOutputSettings,
21
+ NSString* outputFile, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error);
22
+
23
+ // Stops the recording started by StartVideoRecording. Must not be called before its `handler` has
24
+ // already fired — see CLAUDE.md for the race that otherwise causes a silent empty-file failure.
25
+ BOOL StopVideoRecording(id device, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error);
26
+
27
+ } // namespace coresim
@@ -0,0 +1,87 @@
1
+ #include "sim_video_recording.h"
2
+
3
+ #import <objc/message.h>
4
+
5
+ #include "objc_runtime.h"
6
+ #include "safe_dispatch.h"
7
+ #include "sim_screenshot.h"
8
+
9
+ namespace coresim {
10
+
11
+ namespace {
12
+
13
+ NSString* const kVideoRecordingErrorDomain = @"com.appium.coresim.VideoRecording";
14
+
15
+ NSError* MakeError(NSInteger code, NSString* message) {
16
+ return [NSError errorWithDomain:kVideoRecordingErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
17
+ }
18
+
19
+ id IdGetter(id target, const std::string& selectorName) {
20
+ RequireSelector(target, selectorName);
21
+ SEL selector = SelectorNamed(selectorName);
22
+ return SafeInvoke([&] {
23
+ using Fn = id (*)(id, SEL);
24
+ return ((Fn)objc_msgSend)(target, selector);
25
+ });
26
+ }
27
+
28
+ // The device-wide "capture service" port (real protocol: SimScreenCaptureService — see CLAUDE.md),
29
+ // distinct from the display descriptor passed as `screen` below. Found by scanning ioPorts since
30
+ // no header exists. Throws NativeSimUnavailableError (not NSError**) if absent.
31
+ id ResolveVideoCaptureService(id device, NSError** error) {
32
+ static const std::string kStartRecordingSelector =
33
+ "startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
34
+ id ioClient = IdGetter(device, "io");
35
+ if (ioClient == nil) {
36
+ *error = MakeError(1, @"Device has no IO client available — is it booted?");
37
+ return nil;
38
+ }
39
+ NSArray* ports = IdGetter(ioClient, "ioPorts");
40
+ SEL selector = NSSelectorFromString(@(kStartRecordingSelector.c_str()));
41
+ for (id port in ports) {
42
+ id descriptor = IdGetter(port, "descriptor");
43
+ if (descriptor != nil && [descriptor respondsToSelector:selector]) {
44
+ return descriptor;
45
+ }
46
+ }
47
+ throw NativeSimUnavailableError("selector", kStartRecordingSelector, CoreSimulatorFrameworkVersion());
48
+ }
49
+
50
+ } // namespace
51
+
52
+ BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, NSDictionary* assetWriterOutputSettings,
53
+ NSString* outputFile, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
54
+ id captureService = ResolveVideoCaptureService(device, error);
55
+ if (captureService == nil) {
56
+ return NO;
57
+ }
58
+ id screen = ResolveCaptureDisplay(device, displayId, error);
59
+ if (screen == nil) {
60
+ return NO;
61
+ }
62
+ static const std::string kSelectorName =
63
+ "startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
64
+ SEL selector = NSSelectorFromString(@(kSelectorName.c_str()));
65
+ return SafeInvoke([&] {
66
+ using Fn = void (*)(id, SEL, id, long long, NSDictionary*, NSString*, dispatch_queue_t, void (^)(NSError*));
67
+ ((Fn)objc_msgSend)(captureService, selector, screen, static_cast<long long>(mask), assetWriterOutputSettings,
68
+ outputFile, queue, handler);
69
+ return YES;
70
+ });
71
+ }
72
+
73
+ BOOL StopVideoRecording(id device, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
74
+ id captureService = ResolveVideoCaptureService(device, error);
75
+ if (captureService == nil) {
76
+ return NO;
77
+ }
78
+ static const std::string kSelectorName = "stopRecordingWithCompletionQueue:completionHandler:";
79
+ SEL selector = NSSelectorFromString(@(kSelectorName.c_str()));
80
+ return SafeInvoke([&] {
81
+ using Fn = void (*)(id, SEL, dispatch_queue_t, void (^)(NSError*));
82
+ ((Fn)objc_msgSend)(captureService, selector, queue, handler);
83
+ return YES;
84
+ });
85
+ }
86
+
87
+ } // namespace coresim
@@ -0,0 +1,61 @@
1
+ #pragma once
2
+
3
+ #import <Foundation/Foundation.h>
4
+
5
+ #include <cstdint>
6
+ #include <functional>
7
+ #include <memory>
8
+ #include <vector>
9
+
10
+ namespace coresim {
11
+
12
+ enum class VideoStreamCodec { kH264, kHEVC };
13
+
14
+ // One encoded frame — Annex-B NAL units, concatenated. A keyframe's `data` has parameter sets
15
+ // (SPS/PPS, or VPS/SPS/PPS for HEVC) prepended, so every keyframe is self-decodable alone.
16
+ struct VideoAccessUnit {
17
+ std::vector<uint8_t> data;
18
+ bool isKeyFrame = false;
19
+ uint64_t sequence = 0;
20
+ // Microseconds since the stream started.
21
+ int64_t timestampMicros = 0;
22
+ };
23
+
24
+ struct VideoStreamOptions {
25
+ VideoStreamCodec codec = VideoStreamCodec::kH264;
26
+ NSString* displayId = nil;
27
+ double fps = 15.0;
28
+ int bitrate = 2000000;
29
+ };
30
+
31
+ // Polls the live display IOSurface (sim_screenshot.h) on a serial queue and encodes changed
32
+ // frames via the public VideoToolbox API — unlike StartVideoRecording's private, file-only
33
+ // recorder, this delivers access units live. Full thread-safety contract: see CLAUDE.md.
34
+ class VideoStreamSession {
35
+ public:
36
+ VideoStreamSession(id device, VideoStreamOptions options, std::function<void(VideoAccessUnit)> onAccessUnit,
37
+ std::function<void(NSError*)> onError, std::function<void()> onEnd);
38
+ ~VideoStreamSession();
39
+
40
+ VideoStreamSession(const VideoStreamSession&) = delete;
41
+ VideoStreamSession& operator=(const VideoStreamSession&) = delete;
42
+
43
+ // Resolves the display and starts the polling loop; throws synchronously on resolution/setup
44
+ // failure (`onEnd` never called then). Later failures go to `onError`, then `onEnd`.
45
+ void Start();
46
+
47
+ // Idempotent; blocks until the loop has fully stopped. Never call from inside onAccessUnit/
48
+ // onError/onEnd — same queue this blocks on, so it would deadlock.
49
+ void Stop();
50
+
51
+ // Forces the next encoded frame to be a keyframe (self-decodable, parameter sets included) —
52
+ // e.g. so a consumer that just resynced after dropping frames can resume cleanly instead of
53
+ // waiting for the next periodic one. Safe from any thread; just sets a flag.
54
+ void RequestKeyFrame();
55
+
56
+ private:
57
+ class Impl;
58
+ std::unique_ptr<Impl> impl_;
59
+ };
60
+
61
+ } // namespace coresim
@@ -0,0 +1,427 @@
1
+ #include "sim_video_stream.h"
2
+
3
+ #import <CoreMedia/CoreMedia.h>
4
+ #import <CoreVideo/CoreVideo.h>
5
+ #import <IOSurface/IOSurface.h>
6
+ #import <VideoToolbox/VideoToolbox.h>
7
+
8
+ #include <algorithm>
9
+ #include <atomic>
10
+ #include <ctime>
11
+
12
+ #include "nserror_bridge.h"
13
+ #include "safe_dispatch.h"
14
+ #include "sim_screenshot.h"
15
+
16
+ namespace coresim {
17
+
18
+ namespace {
19
+
20
+ NSString* const kVideoStreamErrorDomain = @"com.appium.coresim.VideoStream";
21
+
22
+ NSError* MakeError(NSInteger code, NSString* message) {
23
+ return [NSError errorWithDomain:kVideoStreamErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
24
+ }
25
+
26
+ NSError* MakeStatusError(NSInteger code, NSString* what, OSStatus status) {
27
+ return MakeError(code, [NSString stringWithFormat:@"%@ (OSStatus %d)", what, static_cast<int>(status)]);
28
+ }
29
+
30
+ // CFAbsoluteTimeGetCurrent() is wall time — subject to backward NTP/manual clock corrections,
31
+ // which would violate VTCompressionSessionEncodeFrame's requirement of strictly increasing PTS.
32
+ // CLOCK_MONOTONIC_RAW never goes backward and isn't adjusted by NTP.
33
+ double MonotonicSeconds() {
34
+ struct timespec ts;
35
+ clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
36
+ return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
37
+ }
38
+
39
+ void AppendAnnexB(std::vector<uint8_t>& out, const uint8_t* nal, size_t length) {
40
+ static const uint8_t kStartCode[4] = {0, 0, 0, 1};
41
+ out.insert(out.end(), kStartCode, kStartCode + 4);
42
+ out.insert(out.end(), nal, nal + length);
43
+ }
44
+
45
+ // VideoToolbox's compressed output is AVCC-framed (a 4-byte big-endian length prefix per NAL,
46
+ // no start codes) — rewrites it into Annex-B, matching VideoAccessUnit's documented wire format.
47
+ void AppendSampleBufferNALs(std::vector<uint8_t>& out, CMSampleBufferRef sampleBuffer) {
48
+ CMBlockBufferRef block = CMSampleBufferGetDataBuffer(sampleBuffer);
49
+ if (block == nullptr) {
50
+ return;
51
+ }
52
+ size_t totalLength = CMBlockBufferGetDataLength(block);
53
+ if (totalLength == 0) {
54
+ return;
55
+ }
56
+ // CMBlockBufferGetDataPointer's pointer only covers the contiguous region starting at the given
57
+ // offset, which for a segmented buffer (multiple backing memory blocks — CoreMedia's documented
58
+ // contract, not just a VideoToolbox implementation detail) can be far shorter than totalLength;
59
+ // reading up to totalLength through it would run past that region. CopyDataBytes stitches
60
+ // segments together into a caller-owned, guaranteed-contiguous copy instead.
61
+ std::vector<uint8_t> data(totalLength);
62
+ if (CMBlockBufferCopyDataBytes(block, 0, totalLength, data.data()) != kCMBlockBufferNoErr) {
63
+ return;
64
+ }
65
+ const uint8_t* dataPointer = data.data();
66
+ size_t offset = 0;
67
+ while (offset + 4 <= totalLength) {
68
+ uint32_t nalLength = (static_cast<uint32_t>(dataPointer[offset]) << 24) |
69
+ (static_cast<uint32_t>(dataPointer[offset + 1]) << 16) |
70
+ (static_cast<uint32_t>(dataPointer[offset + 2]) << 8) | dataPointer[offset + 3];
71
+ offset += 4;
72
+ if (nalLength == 0 || offset + nalLength > totalLength) {
73
+ break;
74
+ }
75
+ AppendAnnexB(out, dataPointer + offset, nalLength);
76
+ offset += nalLength;
77
+ }
78
+ }
79
+
80
+ using ParameterSetAtIndexFn = OSStatus (*)(CMFormatDescriptionRef, size_t, const uint8_t**, size_t*, size_t*, int*);
81
+
82
+ void AppendParameterSets(std::vector<uint8_t>& out, CMFormatDescriptionRef format, ParameterSetAtIndexFn getAtIndex) {
83
+ size_t count = 0;
84
+ if (getAtIndex(format, 0, nullptr, nullptr, &count, nullptr) != noErr) {
85
+ return;
86
+ }
87
+ for (size_t i = 0; i < count; i++) {
88
+ const uint8_t* bytes = nullptr;
89
+ size_t size = 0;
90
+ if (getAtIndex(format, i, &bytes, &size, nullptr, nullptr) == noErr) {
91
+ AppendAnnexB(out, bytes, size);
92
+ }
93
+ }
94
+ }
95
+
96
+ } // namespace
97
+
98
+ class VideoStreamSession::Impl {
99
+ public:
100
+ Impl(id device, VideoStreamOptions options, std::function<void(VideoAccessUnit)> onAccessUnit,
101
+ std::function<void(NSError*)> onError, std::function<void()> onEnd)
102
+ : device_(device),
103
+ options_(options),
104
+ onAccessUnit_(std::move(onAccessUnit)),
105
+ onError_(std::move(onError)),
106
+ onEnd_(std::move(onEnd)) {
107
+ queue_ = dispatch_queue_create("com.appium.coresim.videoStream", DISPATCH_QUEUE_SERIAL);
108
+ }
109
+
110
+ ~Impl() { Stop(); }
111
+
112
+ void Start() {
113
+ NSError* error = nil;
114
+ id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &error);
115
+ if (descriptor == nil) {
116
+ throw NSErrorException(error);
117
+ }
118
+ id surfaceObj = CurrentDisplaySurface(descriptor);
119
+ if (surfaceObj == nil) {
120
+ throw NSErrorException(MakeError(4, @"The device's display surface is not available yet"));
121
+ }
122
+ IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
123
+ // Set up synchronously (not lazily on the first Tick()) so a setup failure rejects Start()
124
+ // directly rather than only reaching onError, which the caller may not be listening for yet.
125
+ NSError* setupError = nil;
126
+ if (!SetUpSession(surface, &setupError)) {
127
+ throw NSErrorException(setupError);
128
+ }
129
+ // Must be set before EncodeSurface below — both it and HandleEncodedSample measure elapsed
130
+ // time from this.
131
+ startTime_ = MonotonicSeconds();
132
+ // Encode immediately rather than waiting for a *changed* seed on the first tick, or the
133
+ // stream would stay silent until the display changes again. running_ is set true before this
134
+ // call (not after), since VTCompressionSessionEncodeFrame's output callback can in principle
135
+ // fire on another thread before this one returns — HandleEncodedSample discards samples while
136
+ // running_ is false, which would otherwise silently drop the stream's very first (keyframe)
137
+ // access unit. A failure resets it and tears session_ down itself here, rather than going
138
+ // through Stop()/onEnd_ (see coresim.mm — onEnd_ firing this early would double-release its
139
+ // ThreadSafeFunctions).
140
+ running_ = true;
141
+ bool encoded = false;
142
+ try {
143
+ encoded = EncodeSurface(surface);
144
+ } catch (...) {
145
+ running_ = false;
146
+ VTCompressionSessionInvalidate(session_);
147
+ CFRelease(session_);
148
+ session_ = nullptr;
149
+ throw;
150
+ }
151
+ // Only commit the seed once a frame was actually submitted — a transient pixel-buffer
152
+ // creation failure (EncodeSurface returning false) otherwise leaves lastSeed_ at its default
153
+ // 0, so the first Tick() sees the real seed as "changed" and retries automatically instead of
154
+ // the stream going silent forever on a display that never changes again.
155
+ if (encoded) {
156
+ lastSeed_ = IOSurfaceGetSeed(surface);
157
+ }
158
+
159
+ double interval = 1.0 / std::max(options_.fps, 1.0);
160
+ dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue_);
161
+ dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0),
162
+ static_cast<uint64_t>(interval * NSEC_PER_SEC),
163
+ static_cast<uint64_t>(interval * NSEC_PER_SEC / 10));
164
+ // `this` outlives the timer: Stop()/StopFromQueue() always drain or outrun it before `this`
165
+ // can be destroyed (see their comments below).
166
+ dispatch_source_set_event_handler(timer, ^{
167
+ Tick();
168
+ });
169
+ timer_ = timer;
170
+ dispatch_resume(timer_);
171
+ }
172
+
173
+ // Callable from any thread except `queue_` itself (would deadlock on the dispatch_sync below).
174
+ void Stop() {
175
+ if (!running_.exchange(false)) {
176
+ return; // idempotent
177
+ }
178
+ if (timer_ != nullptr) {
179
+ dispatch_source_cancel(timer_);
180
+ // Blocks until any in-flight Tick() finishes — by then running_ is already false, so it
181
+ // won't touch session_ again.
182
+ dispatch_sync(queue_, ^{
183
+ });
184
+ timer_ = nullptr;
185
+ }
186
+ TearDownSessionAndFireEnd();
187
+ }
188
+
189
+ void RequestKeyFrame() { forceKeyFrame_ = true; }
190
+
191
+ private:
192
+ // Same as Stop() minus the dispatch_sync barrier — only safe from within Tick() itself, already
193
+ // serialized on `queue_`; would race a concurrent Tick() from any other thread.
194
+ void StopFromQueue() {
195
+ if (!running_.exchange(false)) {
196
+ return; // idempotent — e.g. an external Stop() already won this race
197
+ }
198
+ if (timer_ != nullptr) {
199
+ dispatch_source_cancel(timer_);
200
+ timer_ = nullptr;
201
+ }
202
+ TearDownSessionAndFireEnd();
203
+ }
204
+
205
+ void TearDownSessionAndFireEnd() {
206
+ if (session_ != nullptr) {
207
+ // Flushes and blocks until every already-submitted frame's callback has returned — without
208
+ // this, a frame submitted just before Stop() could fire after onEnd_ releases the
209
+ // ThreadSafeFunctions below (a use-after-release; see CLAUDE.md).
210
+ VTCompressionSessionCompleteFrames(session_, kCMTimeInvalid);
211
+ VTCompressionSessionInvalidate(session_);
212
+ CFRelease(session_);
213
+ session_ = nullptr;
214
+ }
215
+ if (onEnd_) {
216
+ onEnd_();
217
+ }
218
+ }
219
+
220
+ void Tick() {
221
+ if (!running_) {
222
+ return;
223
+ }
224
+ if (pendingErrorTeardown_) {
225
+ // HandleEncodedSample (below) can run on VideoToolbox's own callback thread, where it's
226
+ // unsafe to tear down directly — StopFromQueue()/CompleteFrames() are only safe already
227
+ // serialized on `queue_` (here), and CompleteFrames specifically would deadlock waiting on
228
+ // its own still-executing callback. It sets this flag instead; picked up on the very next
229
+ // tick (queue_-serialized, safe) rather than via a raw cross-thread dispatch, since nothing
230
+ // here can outlive `this` the way a block captured on another thread otherwise could.
231
+ StopFromQueue();
232
+ return;
233
+ }
234
+ @autoreleasepool {
235
+ try {
236
+ // Re-resolved every tick (like CaptureScreenshot does), not cached once in Start(), so a
237
+ // deleted device or disconnected display surfaces a real error instead of Tick() quietly
238
+ // doing nothing forever.
239
+ NSError* resolveError = nil;
240
+ id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &resolveError);
241
+ if (descriptor == nil) {
242
+ if (onError_) {
243
+ onError_(resolveError);
244
+ }
245
+ StopFromQueue();
246
+ return;
247
+ }
248
+ id surfaceObj = CurrentDisplaySurface(descriptor);
249
+ if (surfaceObj == nil) {
250
+ return; // transient — the connection may not have a frame ready yet, try again next tick
251
+ }
252
+ IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
253
+ uint32_t seed = IOSurfaceGetSeed(surface);
254
+ if (seed == lastSeed_) {
255
+ return; // unchanged since the last tick — mirrors CoreSimulator's own recorder, which
256
+ // only encodes a frame when the display actually changes (see CLAUDE.md)
257
+ }
258
+ // Only commit the new seed once EncodeSurface actually submits it — a transient failure
259
+ // (pixel-buffer creation) must leave lastSeed_ stale so the next tick retries this same
260
+ // frame instead of silently going quiet until the display changes again.
261
+ if (EncodeSurface(surface)) {
262
+ lastSeed_ = seed;
263
+ }
264
+ } catch (const std::exception& e) {
265
+ // Without this, an exception here (e.g. a dropped display-proxy connection) would escape
266
+ // this bare GCD timer handler uncaught and crash the whole process (see CLAUDE.md).
267
+ if (onError_) {
268
+ onError_(MakeError(3, [NSString stringWithFormat:@"Video stream encoding failed: %s", e.what()]));
269
+ }
270
+ StopFromQueue();
271
+ }
272
+ }
273
+ }
274
+
275
+ bool SetUpSession(IOSurfaceRef surface, NSError** error) {
276
+ int32_t width = static_cast<int32_t>(IOSurfaceGetWidth(surface));
277
+ int32_t height = static_cast<int32_t>(IOSurfaceGetHeight(surface));
278
+ CMVideoCodecType codecType =
279
+ options_.codec == VideoStreamCodec::kHEVC ? kCMVideoCodecType_HEVC : kCMVideoCodecType_H264;
280
+ OSStatus status = VTCompressionSessionCreate(kCFAllocatorDefault, width, height, codecType, nullptr, nullptr,
281
+ kCFAllocatorDefault, OutputCallback, this, &session_);
282
+ if (status != noErr) {
283
+ *error = MakeStatusError(1, @"Failed to create a VTCompressionSession", status);
284
+ return false;
285
+ }
286
+ // Clamped like Start()'s timer interval — an unvalidated 0 here would set MaxKeyFrameInterval
287
+ // to an out-of-spec value.
288
+ double fps = std::max(options_.fps, 1.0);
289
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
290
+ if (status == noErr) {
291
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
292
+ }
293
+ if (status == noErr) {
294
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_AverageBitRate,
295
+ (__bridge CFNumberRef) @(options_.bitrate));
296
+ }
297
+ if (status == noErr) {
298
+ status =
299
+ VTSessionSetProperty(session_, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFNumberRef) @(fps));
300
+ }
301
+ if (status == noErr) {
302
+ status = VTSessionSetProperty(session_, kVTCompressionPropertyKey_MaxKeyFrameInterval,
303
+ (__bridge CFNumberRef) @(static_cast<int>(fps * 2)));
304
+ }
305
+ if (status != noErr) {
306
+ *error = MakeStatusError(2, @"Failed to configure the VTCompressionSession", status);
307
+ VTCompressionSessionInvalidate(session_);
308
+ CFRelease(session_);
309
+ session_ = nullptr;
310
+ return false;
311
+ }
312
+ VTCompressionSessionPrepareToEncodeFrames(session_);
313
+ return true;
314
+ }
315
+
316
+ // Returns whether a frame was actually submitted to the encoder — false for a transient
317
+ // pixel-buffer creation failure the caller should retry, as opposed to a real encode failure
318
+ // (thrown, not returned, since that tears down the whole stream).
319
+ bool EncodeSurface(IOSurfaceRef surface) {
320
+ CVPixelBufferRef pixelBuffer = nullptr;
321
+ CVReturn cvStatus = CVPixelBufferCreateWithIOSurface(kCFAllocatorDefault, surface, nullptr, &pixelBuffer);
322
+ if (cvStatus != kCVReturnSuccess || pixelBuffer == nullptr) {
323
+ return false; // transient — try again next tick rather than tearing down the whole stream
324
+ }
325
+ CMTime pts = CMTimeMake(static_cast<int64_t>((MonotonicSeconds() - startTime_) * 1000000), 1000000);
326
+ NSDictionary* frameProperties = nil;
327
+ if (forceKeyFrame_.exchange(false)) {
328
+ frameProperties = @{(__bridge NSString*)kVTEncodeFrameOptionKey_ForceKeyFrame : @YES};
329
+ }
330
+ OSStatus status = VTCompressionSessionEncodeFrame(session_, pixelBuffer, pts, kCMTimeInvalid,
331
+ (__bridge CFDictionaryRef)frameProperties, nullptr, nullptr);
332
+ CVPixelBufferRelease(pixelBuffer);
333
+ if (status != noErr) {
334
+ throw std::runtime_error([[NSString stringWithFormat:@"VTCompressionSessionEncodeFrame failed (OSStatus %d)",
335
+ static_cast<int>(status)] UTF8String]);
336
+ }
337
+ return true;
338
+ }
339
+
340
+ static void OutputCallback(void* outputCallbackRefCon, void* /*sourceFrameRefCon*/, OSStatus status,
341
+ VTEncodeInfoFlags /*infoFlags*/, CMSampleBufferRef sampleBuffer) {
342
+ static_cast<Impl*>(outputCallbackRefCon)->HandleEncodedSample(status, sampleBuffer);
343
+ }
344
+
345
+ void HandleEncodedSample(OSStatus status, CMSampleBufferRef sampleBuffer) {
346
+ if (!running_) {
347
+ return;
348
+ }
349
+ if (status != noErr) {
350
+ // Only the first failure is reported — pendingErrorTeardown_ doubles as the report-once
351
+ // gate, since Tick() (the only place that consumes it) only ever needs to see it once too.
352
+ if (!pendingErrorTeardown_.exchange(true)) {
353
+ if (onError_) {
354
+ onError_(MakeStatusError(3, @"VideoToolbox reported an encoding failure", status));
355
+ }
356
+ }
357
+ return;
358
+ }
359
+ if (sampleBuffer == nullptr) {
360
+ return;
361
+ }
362
+ bool isKeyFrame = true;
363
+ CFArrayRef attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false);
364
+ if (attachments != nullptr && CFArrayGetCount(attachments) > 0) {
365
+ CFDictionaryRef attachment = static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachments, 0));
366
+ isKeyFrame = !CFDictionaryContainsKey(attachment, kCMSampleAttachmentKey_NotSync);
367
+ }
368
+
369
+ // From the sample's own presentation timestamp (as submitted in EncodeSurface), not a fresh
370
+ // wall-clock read here — this callback can fire well after encoding actually happened, and
371
+ // resampling "now" would report a later, jittery timestamp than when the frame was captured.
372
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
373
+ VideoAccessUnit unit;
374
+ unit.isKeyFrame = isKeyFrame;
375
+ unit.sequence = sequence_++;
376
+ unit.timestampMicros = pts.timescale != 0 ? (pts.value * 1000000 / pts.timescale) : 0;
377
+ if (isKeyFrame) {
378
+ CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
379
+ if (format != nullptr) {
380
+ if (options_.codec == VideoStreamCodec::kHEVC) {
381
+ AppendParameterSets(unit.data, format, CMVideoFormatDescriptionGetHEVCParameterSetAtIndex);
382
+ } else {
383
+ AppendParameterSets(unit.data, format, CMVideoFormatDescriptionGetH264ParameterSetAtIndex);
384
+ }
385
+ }
386
+ }
387
+ AppendSampleBufferNALs(unit.data, sampleBuffer);
388
+ if (onAccessUnit_) {
389
+ onAccessUnit_(std::move(unit));
390
+ }
391
+ }
392
+
393
+ id device_;
394
+ VideoStreamOptions options_;
395
+ std::function<void(VideoAccessUnit)> onAccessUnit_;
396
+ std::function<void(NSError*)> onError_;
397
+ std::function<void()> onEnd_;
398
+
399
+ dispatch_queue_t queue_ = nullptr;
400
+ dispatch_source_t timer_ = nullptr;
401
+ VTCompressionSessionRef session_ = nullptr;
402
+ uint32_t lastSeed_ = 0;
403
+ // VideoToolbox's output callback isn't documented as single-threaded, so this is read-modify-
404
+ // written atomically rather than assuming HandleEncodedSample never runs concurrently.
405
+ std::atomic<uint64_t> sequence_{0};
406
+ double startTime_ = 0;
407
+ std::atomic<bool> running_{false};
408
+ // Set by HandleEncodedSample (possibly off queue_) on an encoder failure, consumed by the next
409
+ // Tick() (on queue_) — see both for why teardown can't just happen inline there.
410
+ std::atomic<bool> pendingErrorTeardown_{false};
411
+ std::atomic<bool> forceKeyFrame_{false};
412
+ };
413
+
414
+ VideoStreamSession::VideoStreamSession(id device, VideoStreamOptions options,
415
+ std::function<void(VideoAccessUnit)> onAccessUnit,
416
+ std::function<void(NSError*)> onError, std::function<void()> onEnd)
417
+ : impl_(std::make_unique<Impl>(device, options, std::move(onAccessUnit), std::move(onError), std::move(onEnd))) {}
418
+
419
+ VideoStreamSession::~VideoStreamSession() = default;
420
+
421
+ void VideoStreamSession::Start() { impl_->Start(); }
422
+
423
+ void VideoStreamSession::Stop() { impl_->Stop(); }
424
+
425
+ void VideoStreamSession::RequestKeyFrame() { impl_->RequestKeyFrame(); }
426
+
427
+ } // namespace coresim
@@ -49,6 +49,8 @@ import {
49
49
  setContentSize,
50
50
  setIncreaseContrast,
51
51
  } from './commands/ui.js';
52
+ import {isVideoRecording, startVideoRecording, stopVideoRecording} from './commands/video-recording.js';
53
+ import {startVideoStream} from './commands/video-stream.js';
52
54
  import {getWebInspectorSocket} from './commands/webinspector.js';
53
55
  // Bare re-imports so `declare module './native-simctl.js'` augmentations in the command modules
54
56
  // (which add their methods to NativeSimctl's type) reach downstream consumers' emitted .d.ts
@@ -68,6 +70,8 @@ import './commands/process.js';
68
70
  import './commands/screenshot.js';
69
71
  import './commands/spawn.js';
70
72
  import './commands/ui.js';
73
+ import './commands/video-recording.js';
74
+ import './commands/video-stream.js';
71
75
  import './commands/webinspector.js';
72
76
  import {NativeSimUnavailableError} from './errors.js';
73
77
  import type {
@@ -247,6 +251,12 @@ Object.assign(NativeSimctl.prototype, {
247
251
  getScreenshot,
248
252
  getDisplays,
249
253
 
254
+ // video recording
255
+ startVideoRecording,
256
+ stopVideoRecording,
257
+ isVideoRecording,
258
+ startVideoStream,
259
+
250
260
  // webinspector
251
261
  getWebInspectorSocket,
252
262