@appium/coresim 1.4.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.
package/src/coresim.mm CHANGED
@@ -10,13 +10,16 @@
10
10
 
11
11
  #include <napi.h>
12
12
 
13
+ #import <AVFoundation/AVFoundation.h>
13
14
  #import <Foundation/Foundation.h>
14
15
 
15
16
  #include <sys/wait.h>
16
17
  #include <unistd.h>
17
18
 
19
+ #include <algorithm>
18
20
  #include <cerrno>
19
21
  #include <cstring>
22
+ #include <mutex>
20
23
  #include <stdexcept>
21
24
  #include <string>
22
25
  #include <vector>
@@ -30,6 +33,8 @@
30
33
  #include "native/sim_process.h"
31
34
  #include "native/sim_screenshot.h"
32
35
  #include "native/sim_service_context.h"
36
+ #include "native/sim_video_recording.h"
37
+ #include "native/sim_video_stream.h"
33
38
  #include "native/tcc_privacy.h"
34
39
  #include "native/value_bridge.h"
35
40
 
@@ -189,10 +194,104 @@ struct AddonInstanceData {
189
194
  Napi::FunctionReference deviceConstructor;
190
195
  Napi::FunctionReference deviceSetConstructor;
191
196
  Napi::FunctionReference serviceContextConstructor;
197
+ Napi::FunctionReference videoStreamConstructor;
198
+ // Every VideoStreamSession currently backing a live NativeVideoStream, so the env cleanup hook
199
+ // below can stop them (and thus release their ThreadSafeFunctions) before Node force-tears-down
200
+ // this Environment's own TSFNs — see that hook for why this ordering matters.
201
+ std::mutex activeVideoStreamsMutex;
202
+ std::vector<std::shared_ptr<coresim::VideoStreamSession>> activeVideoStreams;
192
203
  };
193
204
 
205
+ void RegisterActiveStream(Napi::Env env, const std::shared_ptr<coresim::VideoStreamSession>& session) {
206
+ auto* instanceData = env.GetInstanceData<AddonInstanceData>();
207
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
208
+ instanceData->activeVideoStreams.push_back(session);
209
+ }
210
+
211
+ void DeregisterActiveStream(Napi::Env env, const std::shared_ptr<coresim::VideoStreamSession>& session) {
212
+ auto* instanceData = env.GetInstanceData<AddonInstanceData>();
213
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
214
+ auto& streams = instanceData->activeVideoStreams;
215
+ streams.erase(std::remove(streams.begin(), streams.end(), session), streams.end());
216
+ }
217
+
218
+ // Stops every still-registered stream, blocking until each has fully torn down (including its own
219
+ // onEnd_ releasing its ThreadSafeFunctions) — see the env cleanup hook this backs, in Init below,
220
+ // for why this must run to completion before returning.
221
+ void StopAllActiveStreams(AddonInstanceData* instanceData) {
222
+ std::vector<std::shared_ptr<coresim::VideoStreamSession>> streams;
223
+ {
224
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
225
+ streams.swap(instanceData->activeVideoStreams);
226
+ }
227
+ for (auto& session : streams) {
228
+ session->Stop();
229
+ }
230
+ }
231
+
194
232
  } // namespace
195
233
 
234
+ // Wraps a live coresim::VideoStreamSession. Access units/errors are delivered live via the
235
+ // callbacks passed directly to startVideoStream, not through this object — it only exposes `stop()`.
236
+ class NativeVideoStream : public Napi::ObjectWrap<NativeVideoStream> {
237
+ public:
238
+ static void Init(Napi::Env env);
239
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session);
240
+ explicit NativeVideoStream(const Napi::CallbackInfo& info);
241
+
242
+ private:
243
+ std::shared_ptr<coresim::VideoStreamSession> session_;
244
+
245
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
246
+ Napi::Env env = info.Env();
247
+ auto session = session_;
248
+ return RunAsyncVoid(env, [session]() { session->Stop(); });
249
+ }
250
+
251
+ // Trivial in-memory flag set (see sim_video_stream.h) — no CoreSimulator dispatch, so kept
252
+ // synchronous like the other pure accessors in this file.
253
+ Napi::Value RequestKeyFrame(const Napi::CallbackInfo& info) {
254
+ if (session_) {
255
+ session_->RequestKeyFrame();
256
+ }
257
+ return info.Env().Undefined();
258
+ }
259
+
260
+ // Hands teardown off to a background queue instead of letting the default finalizer run
261
+ // ~VideoStreamSession()'s blocking Stop() synchronously on whatever thread GC runs on.
262
+ void Finalize(Napi::Env env) override {
263
+ auto session = std::move(session_);
264
+ if (session) {
265
+ DeregisterActiveStream(env, session);
266
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
267
+ session->Stop();
268
+ });
269
+ }
270
+ }
271
+ };
272
+
273
+ NativeVideoStream::NativeVideoStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeVideoStream>(info) {
274
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::VideoStreamSession>>>().Data();
275
+ session_ = *boxed;
276
+ RegisterActiveStream(info.Env(), session_);
277
+ }
278
+
279
+ void NativeVideoStream::Init(Napi::Env env) {
280
+ Napi::Function ctor = DefineClass(env, "NativeVideoStream",
281
+ {
282
+ InstanceMethod<&NativeVideoStream::Stop>("stop"),
283
+ InstanceMethod<&NativeVideoStream::RequestKeyFrame>("requestKeyFrame"),
284
+ });
285
+ env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor = Napi::Persistent(ctor);
286
+ }
287
+
288
+ Napi::Object NativeVideoStream::NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) {
289
+ auto* boxed = new std::shared_ptr<coresim::VideoStreamSession>(std::move(session));
290
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor.Value();
291
+ return ctor.New({Napi::External<std::shared_ptr<coresim::VideoStreamSession>>::New(
292
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::VideoStreamSession>* data) { delete data; })});
293
+ }
294
+
196
295
  class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
197
296
  public:
198
297
  static void Init(Napi::Env env);
@@ -727,6 +826,173 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
727
826
  [](Napi::Env env, NSArray* result) -> Napi::Value { return NSObjectToJsValue(env, result); });
728
827
  }
729
828
 
829
+ // Mirrors `simctl io <udid> recordVideo` (see sim_video_recording.mm). Resolves once the first
830
+ // frame is recorded — see CLAUDE.md for the race hit by calling StopVideoRecording any earlier.
831
+ Napi::Value StartVideoRecording(const Napi::CallbackInfo& info) {
832
+ Napi::Env env = info.Env();
833
+ id device = device_;
834
+ NSString* outputFile = @(info[0].As<Napi::String>().Utf8Value().c_str());
835
+ NSString* displayId = nil;
836
+ coresim::VideoMaskPolicy mask = coresim::VideoMaskPolicy::kIgnored;
837
+ NSDictionary* assetWriterOutputSettings = @{};
838
+ if (info.Length() > 1 && info[1].IsObject()) {
839
+ Napi::Object options = info[1].As<Napi::Object>();
840
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
841
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
842
+ }
843
+ if (options.Has("mask") && options.Get("mask").IsString()) {
844
+ std::string maskValue = options.Get("mask").As<Napi::String>().Utf8Value();
845
+ if (maskValue == "alpha") {
846
+ mask = coresim::VideoMaskPolicy::kAlpha;
847
+ } else if (maskValue == "black") {
848
+ mask = coresim::VideoMaskPolicy::kBlack;
849
+ }
850
+ }
851
+ if (options.Has("codec") && options.Get("codec").IsString()) {
852
+ std::string codecValue = options.Get("codec").As<Napi::String>().Utf8Value();
853
+ AVVideoCodecType codecType = codecValue == "hevc" ? AVVideoCodecTypeHEVC : AVVideoCodecTypeH264;
854
+ assetWriterOutputSettings = @{AVVideoCodecKey : codecType};
855
+ }
856
+ }
857
+ return RunAsyncVoid(env, [device, displayId, mask, assetWriterOutputSettings, outputFile]() {
858
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
859
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.recordVideo", DISPATCH_QUEUE_SERIAL);
860
+ __block NSError* capturedError = nil;
861
+ NSError* resolveError = nil;
862
+ BOOL ok = coresim::StartVideoRecording(
863
+ device, displayId, mask, assetWriterOutputSettings, outputFile, queue,
864
+ ^(NSError* asyncError) {
865
+ capturedError = asyncError;
866
+ dispatch_semaphore_signal(sema);
867
+ },
868
+ &resolveError);
869
+ ThrowIfFailed(ok, resolveError);
870
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
871
+ if (capturedError != nil) {
872
+ throw NSErrorException(capturedError);
873
+ }
874
+ });
875
+ }
876
+
877
+ // Stops a recording started by StartVideoRecording above. Resolves once the video file has been
878
+ // finalized on disk and is safe to read.
879
+ Napi::Value StopVideoRecording(const Napi::CallbackInfo& info) {
880
+ Napi::Env env = info.Env();
881
+ id device = device_;
882
+ return RunAsyncVoid(env, [device]() {
883
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
884
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.stopRecordVideo", DISPATCH_QUEUE_SERIAL);
885
+ __block NSError* capturedError = nil;
886
+ NSError* resolveError = nil;
887
+ BOOL ok = coresim::StopVideoRecording(
888
+ device, queue,
889
+ ^(NSError* asyncError) {
890
+ capturedError = asyncError;
891
+ dispatch_semaphore_signal(sema);
892
+ },
893
+ &resolveError);
894
+ ThrowIfFailed(ok, resolveError);
895
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
896
+ if (capturedError != nil) {
897
+ throw NSErrorException(capturedError);
898
+ }
899
+ });
900
+ }
901
+
902
+ // Real-time encoding via public VideoToolbox APIs (see sim_video_stream.mm) — no private API,
903
+ // no file. `onAccessUnit`/`onError` are invoked live for as long as the stream runs; the
904
+ // returned NativeVideoStream only exposes `stop()`.
905
+ Napi::Value StartVideoStream(const Napi::CallbackInfo& info) {
906
+ Napi::Env env = info.Env();
907
+ id device = device_;
908
+ NSString* displayId = nil;
909
+ coresim::VideoStreamCodec codec = coresim::VideoStreamCodec::kH264;
910
+ double fps = 15.0;
911
+ int bitrate = 2000000;
912
+ if (info.Length() > 0 && info[0].IsObject()) {
913
+ Napi::Object options = info[0].As<Napi::Object>();
914
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
915
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
916
+ }
917
+ if (options.Has("codec") && options.Get("codec").IsString() &&
918
+ options.Get("codec").As<Napi::String>().Utf8Value() == "hevc") {
919
+ codec = coresim::VideoStreamCodec::kHEVC;
920
+ }
921
+ if (options.Has("fps") && options.Get("fps").IsNumber()) {
922
+ fps = options.Get("fps").As<Napi::Number>().DoubleValue();
923
+ }
924
+ if (options.Has("bitrate") && options.Get("bitrate").IsNumber()) {
925
+ bitrate = options.Get("bitrate").As<Napi::Number>().Int32Value();
926
+ }
927
+ }
928
+ Napi::Function onAccessUnit = info[1].As<Napi::Function>();
929
+ Napi::Function onError = info[2].As<Napi::Function>();
930
+
931
+ // Must be constructed on the main thread, like Spawn's own exitTsfn above; released exactly
932
+ // once each, via onEnd (see sim_video_stream.h).
933
+ //
934
+ // accessUnitTsfn's queue is bounded, unlike every other one-shot-callback ThreadSafeFunction
935
+ // in this addon — a slow-draining consumer would otherwise let queued frame buffers grow
936
+ // unbounded; BlockingCall below naturally throttles the encoder once this fills instead.
937
+ static constexpr size_t kAccessUnitQueueSize = 60;
938
+ Napi::ThreadSafeFunction accessUnitTsfn =
939
+ Napi::ThreadSafeFunction::New(env, onAccessUnit, "coresim video stream access unit", kAccessUnitQueueSize, 1);
940
+ Napi::ThreadSafeFunction errorTsfn =
941
+ Napi::ThreadSafeFunction::New(env, onError, "coresim video stream error", 0, 1);
942
+
943
+ coresim::VideoStreamOptions options{codec, displayId, fps, bitrate};
944
+ return RunAsync<std::shared_ptr<coresim::VideoStreamSession>>(
945
+ env,
946
+ [device, options, accessUnitTsfn, errorTsfn]() mutable -> std::shared_ptr<coresim::VideoStreamSession> {
947
+ auto session = std::make_shared<coresim::VideoStreamSession>(
948
+ device, options,
949
+ [accessUnitTsfn](coresim::VideoAccessUnit unit) mutable {
950
+ accessUnitTsfn.BlockingCall([unit = std::move(unit)](Napi::Env env, Napi::Function jsCallback) mutable {
951
+ // The Environment can already be mid-teardown by the time a queued callback
952
+ // like this one actually runs (e.g. worker.terminate() while frames were still
953
+ // piling up) — node-addon-api's own WrapVoidCallback would otherwise re-throw
954
+ // whatever escapes here as a JS exception, which itself aborts the process on a
955
+ // torn-down env instead of just failing to deliver a frame nothing can receive
956
+ // anymore. See CLAUDE.md.
957
+ try {
958
+ Napi::Object obj = Napi::Object::New(env);
959
+ obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, unit.data.data(), unit.data.size()));
960
+ obj.Set("isKeyFrame", Napi::Boolean::New(env, unit.isKeyFrame));
961
+ obj.Set("sequence", Napi::Number::New(env, static_cast<double>(unit.sequence)));
962
+ obj.Set("timestampMicros", Napi::Number::New(env, static_cast<double>(unit.timestampMicros)));
963
+ jsCallback.Call({obj});
964
+ } catch (...) {
965
+ }
966
+ });
967
+ },
968
+ [errorTsfn](NSError* error) mutable {
969
+ NSErrorException exception(error);
970
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
971
+ // See accessUnitTsfn's callback above.
972
+ try {
973
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
974
+ } catch (...) {
975
+ }
976
+ });
977
+ },
978
+ [accessUnitTsfn, errorTsfn]() mutable {
979
+ accessUnitTsfn.Release();
980
+ errorTsfn.Release();
981
+ });
982
+ try {
983
+ session->Start();
984
+ } catch (...) {
985
+ accessUnitTsfn.Release();
986
+ errorTsfn.Release();
987
+ throw;
988
+ }
989
+ return session;
990
+ },
991
+ [](Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) -> Napi::Value {
992
+ return NativeVideoStream::NewInstance(env, session);
993
+ });
994
+ }
995
+
730
996
  // Option dictionary keys for `spawnWithPath:options:...` aren't part of the ObjC runtime
731
997
  // metadata this addon resolves selectors from (they're string literals inside CoreSimulator's
732
998
  // own implementation) — confirmed by resolving each `SimDeviceSpawnKey*` symbol at runtime via
@@ -912,6 +1178,9 @@ void NativeDevice::Init(Napi::Env env) {
912
1178
  InstanceMethod<&NativeDevice::GetWebInspectorSocket>("getWebInspectorSocket"),
913
1179
  InstanceMethod<&NativeDevice::Screenshot>("screenshot"),
914
1180
  InstanceMethod<&NativeDevice::GetDisplays>("getDisplays"),
1181
+ InstanceMethod<&NativeDevice::StartVideoRecording>("startVideoRecording"),
1182
+ InstanceMethod<&NativeDevice::StopVideoRecording>("stopVideoRecording"),
1183
+ InstanceMethod<&NativeDevice::StartVideoStream>("startVideoStream"),
915
1184
  InstanceMethod<&NativeDevice::Spawn>("spawn"),
916
1185
  });
917
1186
  env.GetInstanceData<AddonInstanceData>()->deviceConstructor = Napi::Persistent(ctor);
@@ -1140,10 +1409,19 @@ Napi::Value FrameworkVersionBinding(const Napi::CallbackInfo& info) {
1140
1409
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
1141
1410
  // Runs once per Environment (see AddonInstanceData above) — never shared across a
1142
1411
  // worker_threads instance also `require()`-ing this addon.
1143
- env.SetInstanceData(new AddonInstanceData());
1412
+ auto* instanceData = new AddonInstanceData();
1413
+ env.SetInstanceData(instanceData);
1414
+ // Node force-releases any ThreadSafeFunctions still outstanding when an Environment (e.g. a
1415
+ // worker_threads Worker) tears down — racing our own release of the same TSFNs (fired
1416
+ // asynchronously from NativeVideoStream::Finalize, or never, if a running stream's JS wrapper
1417
+ // was never explicitly stopped) crashes the process. Cleanup hooks are guaranteed to run before
1418
+ // that automatic TSFN teardown, so stopping every active stream here — synchronously, blocking
1419
+ // until each has released its own TSFNs — establishes the ordering Node itself doesn't.
1420
+ env.AddCleanupHook(StopAllActiveStreams, instanceData);
1144
1421
  NativeDevice::Init(env);
1145
1422
  NativeDeviceSet::Init(env);
1146
1423
  NativeServiceContext::Init(env);
1424
+ NativeVideoStream::Init(env);
1147
1425
  exports.Set("sharedServiceContext", Napi::Function::New(env, SharedServiceContextBinding));
1148
1426
  exports.Set("frameworkVersion", Napi::Function::New(env, FrameworkVersionBinding));
1149
1427
  return exports;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {NativeSimError, NativeSimUnavailableError, NativeSimDispatchError, NativeSimOperationError} from './errors.js';
2
2
  export {NativeSimctl} from './native-simctl.js';
3
3
  export {SpawnedProcess} from './commands/spawn.js';
4
+ export {VideoStream} from './commands/video-stream.js';
4
5
  export type {AppContainerType} from './commands/app.js';
5
6
  export type {BiometricName} from './commands/biometric.js';
6
7
  export {
@@ -20,4 +21,7 @@ export {
20
21
  type SimProcessInfo,
21
22
  type SimRuntimeInfo,
22
23
  type SpawnOptions,
24
+ type VideoAccessUnit,
25
+ type VideoRecordingOptions,
26
+ type VideoStreamOptions,
23
27
  } from './types.js';
@@ -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