@appium/coresim 1.4.0 → 1.6.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 (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -0
  3. package/binding.gyp +14 -1
  4. package/lib/src/commands/video-recording.d.ts +57 -0
  5. package/lib/src/commands/video-recording.d.ts.map +1 -0
  6. package/lib/src/commands/video-recording.js +123 -0
  7. package/lib/src/commands/video-recording.js.map +1 -0
  8. package/lib/src/commands/video-stream.d.ts +92 -0
  9. package/lib/src/commands/video-stream.d.ts.map +1 -0
  10. package/lib/src/commands/video-stream.js +242 -0
  11. package/lib/src/commands/video-stream.js.map +1 -0
  12. package/lib/src/index.d.ts +2 -1
  13. package/lib/src/index.d.ts.map +1 -1
  14. package/lib/src/index.js +1 -0
  15. package/lib/src/index.js.map +1 -1
  16. package/lib/src/native-simctl.d.ts +4 -0
  17. package/lib/src/native-simctl.d.ts.map +1 -1
  18. package/lib/src/native-simctl.js +21 -1
  19. package/lib/src/native-simctl.js.map +1 -1
  20. package/lib/src/types.d.ts +134 -0
  21. package/lib/src/types.d.ts.map +1 -1
  22. package/lib/src/utils/index.d.ts +1 -1
  23. package/lib/src/utils/index.d.ts.map +1 -1
  24. package/lib/src/utils/index.js +1 -1
  25. package/lib/src/utils/index.js.map +1 -1
  26. package/lib/src/utils/run-catching.d.ts +4 -0
  27. package/lib/src/utils/run-catching.d.ts.map +1 -1
  28. package/lib/src/utils/run-catching.js +11 -0
  29. package/lib/src/utils/run-catching.js.map +1 -1
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  32. package/src/commands/video-recording.ts +158 -0
  33. package/src/commands/video-stream.ts +283 -0
  34. package/src/coresim.mm +708 -1
  35. package/src/index.ts +4 -0
  36. package/src/native/audio_encoder.h +65 -0
  37. package/src/native/audio_encoder.mm +270 -0
  38. package/src/native/av_recording.h +52 -0
  39. package/src/native/av_recording.mm +424 -0
  40. package/src/native/av_stream.h +70 -0
  41. package/src/native/av_stream.mm +217 -0
  42. package/src/native/monotonic_clock.h +20 -0
  43. package/src/native/sim_audio_tap.h +67 -0
  44. package/src/native/sim_audio_tap.mm +350 -0
  45. package/src/native/sim_process.h +10 -0
  46. package/src/native/sim_process.mm +81 -18
  47. package/src/native/sim_screenshot.h +10 -0
  48. package/src/native/sim_screenshot.mm +11 -6
  49. package/src/native/sim_video_recording.h +27 -0
  50. package/src/native/sim_video_recording.mm +87 -0
  51. package/src/native/sim_video_stream.h +64 -0
  52. package/src/native/sim_video_stream.mm +71 -0
  53. package/src/native/video_encoder.h +76 -0
  54. package/src/native/video_encoder.mm +417 -0
  55. package/src/native-simctl.ts +22 -1
  56. package/src/types.ts +149 -0
  57. package/src/utils/index.ts +1 -1
  58. package/src/utils/run-catching.ts +11 -0
package/src/coresim.mm CHANGED
@@ -10,18 +10,23 @@
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>
23
26
 
24
27
  #include "native/async_bridge.h"
28
+ #include "native/av_recording.h"
29
+ #include "native/av_stream.h"
25
30
  #include "native/nserror_bridge.h"
26
31
  #include "native/objc_runtime.h"
27
32
  #include "native/sim_device.h"
@@ -30,6 +35,8 @@
30
35
  #include "native/sim_process.h"
31
36
  #include "native/sim_screenshot.h"
32
37
  #include "native/sim_service_context.h"
38
+ #include "native/sim_video_recording.h"
39
+ #include "native/sim_video_stream.h"
33
40
  #include "native/tcc_privacy.h"
34
41
  #include "native/value_bridge.h"
35
42
 
@@ -177,6 +184,29 @@ struct RuntimeEntry {
177
184
  std::string versionString;
178
185
  };
179
186
 
187
+ // Napi::ThreadSafeFunction::Release() and Abort() are two mutually exclusive modes of one
188
+ // underlying napi_release_threadsafe_function call — Node's own docs say calling either a second
189
+ // time (including calling the other one after the first) is undefined behavior, since the handle
190
+ // may already be destroyed. A stream's normal stop-triggered release (onEnd) and
191
+ // CleanupActiveSessions's exit-time AbortDelivery can race — a session stays registered until its
192
+ // JS wrapper is GC'd, not until Stop() completes, so an already-stopped-but-still-referenced
193
+ // stream can still be hit by AbortDelivery() later. This guard makes whichever of Release()/
194
+ // Abort() runs first win, and turns the other into a no-op instead of a second, unsafe call.
195
+ struct TsfnReleaseGuard {
196
+ std::mutex mutex;
197
+ bool done = false;
198
+ };
199
+
200
+ template <typename Fn>
201
+ void ReleaseTsfnOnce(const std::shared_ptr<TsfnReleaseGuard>& guard, Fn&& releaseOrAbort) {
202
+ std::lock_guard<std::mutex> lock(guard->mutex);
203
+ if (guard->done) {
204
+ return;
205
+ }
206
+ guard->done = true;
207
+ releaseOrAbort();
208
+ }
209
+
180
210
  // Node-API explicitly prohibits sharing an Environment's data across Environments (e.g. two
181
211
  // worker_threads instances each `require()`-ing this addon) — each gets its own separate call
182
212
  // into Init() below. A process-global `static Napi::FunctionReference` per class would let one
@@ -185,14 +215,300 @@ struct RuntimeEntry {
185
215
  // Napi::Env::SetInstanceData/GetInstanceData instead keeps each Environment's constructors
186
216
  // scoped to it, and cleans them up automatically (the default finalizer just `delete`s this) when
187
217
  // that Environment tears down.
218
+ // A registry of live sessions (VideoStreamSession/AVStreamSession/AVRecordingSession) of one
219
+ // type, so the env cleanup hook can stop every still-live one — and thus release its
220
+ // ThreadSafeFunctions — before Node force-tears-down this Environment's own TSFNs (see Init's
221
+ // AddCleanupHook calls for why this ordering matters). `T` must expose a blocking `Stop()`.
222
+ template <typename T>
223
+ struct ActiveSessionRegistry {
224
+ std::mutex mutex;
225
+ std::vector<std::shared_ptr<T>> sessions;
226
+
227
+ void Register(const std::shared_ptr<T>& session) {
228
+ std::lock_guard<std::mutex> lock(mutex);
229
+ sessions.push_back(session);
230
+ }
231
+
232
+ void Deregister(const std::shared_ptr<T>& session) {
233
+ std::lock_guard<std::mutex> lock(mutex);
234
+ sessions.erase(std::remove(sessions.begin(), sessions.end(), session), sessions.end());
235
+ }
236
+
237
+ // Stops every still-registered session, blocking until each has fully torn down. `stop` invokes
238
+ // each session's own Stop() — a parameter (rather than always calling `Stop()` with no
239
+ // arguments) since AVRecordingSession's Stop() takes a completion callback the others don't.
240
+ template <typename StopFn>
241
+ void StopAll(StopFn stop) {
242
+ std::vector<std::shared_ptr<T>> toStop;
243
+ {
244
+ std::lock_guard<std::mutex> lock(mutex);
245
+ toStop.swap(sessions);
246
+ }
247
+ for (auto& session : toStop) {
248
+ stop(session);
249
+ }
250
+ }
251
+ };
252
+
188
253
  struct AddonInstanceData {
189
254
  Napi::FunctionReference deviceConstructor;
190
255
  Napi::FunctionReference deviceSetConstructor;
191
256
  Napi::FunctionReference serviceContextConstructor;
257
+ Napi::FunctionReference videoStreamConstructor;
258
+ Napi::FunctionReference avStreamConstructor;
259
+ Napi::FunctionReference avRecordingConstructor;
260
+ Napi::FunctionReference privateRecordingConstructor;
261
+ ActiveSessionRegistry<coresim::VideoStreamSession> activeVideoStreams;
262
+ ActiveSessionRegistry<coresim::AVStreamSession> activeAVStreams;
263
+ ActiveSessionRegistry<coresim::AVRecordingSession> activeAVRecordings;
192
264
  };
193
265
 
194
266
  } // namespace
195
267
 
268
+ // Wraps a live coresim::VideoStreamSession. Access units/errors are delivered live via the
269
+ // callbacks passed directly to startVideoStream, not through this object — it only exposes `stop()`.
270
+ class NativeVideoStream : public Napi::ObjectWrap<NativeVideoStream> {
271
+ public:
272
+ static void Init(Napi::Env env);
273
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session);
274
+ explicit NativeVideoStream(const Napi::CallbackInfo& info);
275
+
276
+ private:
277
+ std::shared_ptr<coresim::VideoStreamSession> session_;
278
+
279
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
280
+ Napi::Env env = info.Env();
281
+ auto session = session_;
282
+ return RunAsyncVoid(env, [session]() { session->Stop(); });
283
+ }
284
+
285
+ // Trivial in-memory flag set (see sim_video_stream.h) — no CoreSimulator dispatch, so kept
286
+ // synchronous like the other pure accessors in this file.
287
+ Napi::Value RequestKeyFrame(const Napi::CallbackInfo& info) {
288
+ if (session_) {
289
+ session_->RequestKeyFrame();
290
+ }
291
+ return info.Env().Undefined();
292
+ }
293
+
294
+ // Hands teardown off to a background queue instead of letting the default finalizer run
295
+ // ~VideoStreamSession()'s blocking Stop() synchronously on whatever thread GC runs on.
296
+ void Finalize(Napi::Env env) override {
297
+ auto session = std::move(session_);
298
+ if (session) {
299
+ env.GetInstanceData<AddonInstanceData>()->activeVideoStreams.Deregister(session);
300
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
301
+ session->Stop();
302
+ });
303
+ }
304
+ }
305
+ };
306
+
307
+ NativeVideoStream::NativeVideoStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeVideoStream>(info) {
308
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::VideoStreamSession>>>().Data();
309
+ session_ = *boxed;
310
+ info.Env().GetInstanceData<AddonInstanceData>()->activeVideoStreams.Register(session_);
311
+ }
312
+
313
+ void NativeVideoStream::Init(Napi::Env env) {
314
+ Napi::Function ctor = DefineClass(env, "NativeVideoStream",
315
+ {
316
+ InstanceMethod<&NativeVideoStream::Stop>("stop"),
317
+ InstanceMethod<&NativeVideoStream::RequestKeyFrame>("requestKeyFrame"),
318
+ });
319
+ env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor = Napi::Persistent(ctor);
320
+ }
321
+
322
+ Napi::Object NativeVideoStream::NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) {
323
+ auto* boxed = new std::shared_ptr<coresim::VideoStreamSession>(std::move(session));
324
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor.Value();
325
+ return ctor.New({Napi::External<std::shared_ptr<coresim::VideoStreamSession>>::New(
326
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::VideoStreamSession>* data) { delete data; })});
327
+ }
328
+
329
+ // Wraps a live coresim::AVStreamSession — the combined-AV counterpart to NativeVideoStream above,
330
+ // same shape (only `stop()`/`requestKeyFrame()`; access units/errors are delivered live via the
331
+ // callbacks passed directly to startAVStream).
332
+ class NativeAVStream : public Napi::ObjectWrap<NativeAVStream> {
333
+ public:
334
+ static void Init(Napi::Env env);
335
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::AVStreamSession> session);
336
+ explicit NativeAVStream(const Napi::CallbackInfo& info);
337
+
338
+ private:
339
+ std::shared_ptr<coresim::AVStreamSession> session_;
340
+
341
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
342
+ Napi::Env env = info.Env();
343
+ auto session = session_;
344
+ return RunAsyncVoid(env, [session]() { session->Stop(); });
345
+ }
346
+
347
+ Napi::Value RequestKeyFrame(const Napi::CallbackInfo& info) {
348
+ if (session_) {
349
+ session_->RequestKeyFrame();
350
+ }
351
+ return info.Env().Undefined();
352
+ }
353
+
354
+ // See NativeVideoStream::Finalize for why this hands off to a background queue.
355
+ void Finalize(Napi::Env env) override {
356
+ auto session = std::move(session_);
357
+ if (session) {
358
+ env.GetInstanceData<AddonInstanceData>()->activeAVStreams.Deregister(session);
359
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
360
+ session->Stop();
361
+ });
362
+ }
363
+ }
364
+ };
365
+
366
+ NativeAVStream::NativeAVStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeAVStream>(info) {
367
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::AVStreamSession>>>().Data();
368
+ session_ = *boxed;
369
+ info.Env().GetInstanceData<AddonInstanceData>()->activeAVStreams.Register(session_);
370
+ }
371
+
372
+ void NativeAVStream::Init(Napi::Env env) {
373
+ Napi::Function ctor = DefineClass(env, "NativeAVStream",
374
+ {
375
+ InstanceMethod<&NativeAVStream::Stop>("stop"),
376
+ InstanceMethod<&NativeAVStream::RequestKeyFrame>("requestKeyFrame"),
377
+ });
378
+ env.GetInstanceData<AddonInstanceData>()->avStreamConstructor = Napi::Persistent(ctor);
379
+ }
380
+
381
+ Napi::Object NativeAVStream::NewInstance(Napi::Env env, std::shared_ptr<coresim::AVStreamSession> session) {
382
+ auto* boxed = new std::shared_ptr<coresim::AVStreamSession>(std::move(session));
383
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->avStreamConstructor.Value();
384
+ return ctor.New({Napi::External<std::shared_ptr<coresim::AVStreamSession>>::New(
385
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::AVStreamSession>* data) { delete data; })});
386
+ }
387
+
388
+ // Wraps a live coresim::AVRecordingSession, kept alive between startAVRecording and its returned
389
+ // handle's stop() — unlike startVideoRecording/stopVideoRecording, which address CoreSimulator's
390
+ // own internally-tracked private recorder purely by udid, this session is a real local resource
391
+ // (VTCompressionSession + Core Audio tap + AVAssetWriter) with no equivalent server-side handle.
392
+ class NativeAVRecording : public Napi::ObjectWrap<NativeAVRecording> {
393
+ public:
394
+ static void Init(Napi::Env env);
395
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::AVRecordingSession> session);
396
+ explicit NativeAVRecording(const Napi::CallbackInfo& info);
397
+
398
+ private:
399
+ std::shared_ptr<coresim::AVRecordingSession> session_;
400
+
401
+ // Resolves once the output file has been finalized on disk and is safe to read — mirrors
402
+ // stopVideoRecording's own contract.
403
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
404
+ Napi::Env env = info.Env();
405
+ auto session = session_;
406
+ return RunAsyncVoid(env, [session]() {
407
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
408
+ NSError* capturedError = nil;
409
+ session->Stop([sema, &capturedError](NSError* error) {
410
+ capturedError = error;
411
+ dispatch_semaphore_signal(sema);
412
+ });
413
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
414
+ if (capturedError != nil) {
415
+ throw NSErrorException(capturedError);
416
+ }
417
+ });
418
+ }
419
+
420
+ // See NativeVideoStream::Finalize for why this hands off to a background queue — Stop()'s own
421
+ // completion handler here is simply dropped rather than awaited, matching Finalize's existing
422
+ // "GC thread must never block" contract for every session type.
423
+ void Finalize(Napi::Env env) override {
424
+ auto session = std::move(session_);
425
+ if (session) {
426
+ env.GetInstanceData<AddonInstanceData>()->activeAVRecordings.Deregister(session);
427
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
428
+ session->Stop([](NSError*) {});
429
+ });
430
+ }
431
+ }
432
+ };
433
+
434
+ NativeAVRecording::NativeAVRecording(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeAVRecording>(info) {
435
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::AVRecordingSession>>>().Data();
436
+ session_ = *boxed;
437
+ info.Env().GetInstanceData<AddonInstanceData>()->activeAVRecordings.Register(session_);
438
+ }
439
+
440
+ void NativeAVRecording::Init(Napi::Env env) {
441
+ Napi::Function ctor = DefineClass(env, "NativeAVRecording",
442
+ {
443
+ InstanceMethod<&NativeAVRecording::Stop>("stop"),
444
+ });
445
+ env.GetInstanceData<AddonInstanceData>()->avRecordingConstructor = Napi::Persistent(ctor);
446
+ }
447
+
448
+ Napi::Object NativeAVRecording::NewInstance(Napi::Env env, std::shared_ptr<coresim::AVRecordingSession> session) {
449
+ auto* boxed = new std::shared_ptr<coresim::AVRecordingSession>(std::move(session));
450
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->avRecordingConstructor.Value();
451
+ return ctor.New({Napi::External<std::shared_ptr<coresim::AVRecordingSession>>::New(
452
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::AVRecordingSession>* data) { delete data; })});
453
+ }
454
+
455
+ // Wraps a recording started via CoreSimulator's own private recorder (sim_video_recording.h,
456
+ // addressed purely by `device` — no client-side live resource, unlike AVRecordingSession above)
457
+ // in the same `stop()` shape NativeAVRecording exposes for the `audio: true` path, so
458
+ // NativeDevice::StartVideoRecording can return one handle type either way regardless of which
459
+ // path it took. No registry/cleanup-hook entry needed: CoreSimulator owns the actual recorder
460
+ // state internally, so there's nothing here to force-stop if the JS wrapper is just GC'd.
461
+ class NativePrivateRecordingHandle : public Napi::ObjectWrap<NativePrivateRecordingHandle> {
462
+ public:
463
+ static void Init(Napi::Env env);
464
+ static Napi::Object NewInstance(Napi::Env env, id device);
465
+ explicit NativePrivateRecordingHandle(const Napi::CallbackInfo& info);
466
+
467
+ private:
468
+ id device_;
469
+
470
+ // Mirrors the previous standalone StopVideoRecording native method.
471
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
472
+ Napi::Env env = info.Env();
473
+ id device = device_;
474
+ return RunAsyncVoid(env, [device]() {
475
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
476
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.stopRecordVideo", DISPATCH_QUEUE_SERIAL);
477
+ __block NSError* capturedError = nil;
478
+ NSError* resolveError = nil;
479
+ BOOL ok = coresim::StopVideoRecording(
480
+ device, queue,
481
+ ^(NSError* asyncError) {
482
+ capturedError = asyncError;
483
+ dispatch_semaphore_signal(sema);
484
+ },
485
+ &resolveError);
486
+ ThrowIfFailed(ok, resolveError);
487
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
488
+ if (capturedError != nil) {
489
+ throw NSErrorException(capturedError);
490
+ }
491
+ });
492
+ }
493
+ };
494
+
495
+ NativePrivateRecordingHandle::NativePrivateRecordingHandle(const Napi::CallbackInfo& info)
496
+ : Napi::ObjectWrap<NativePrivateRecordingHandle>(info) {
497
+ device_ = UnwrapExternalId(info);
498
+ }
499
+
500
+ void NativePrivateRecordingHandle::Init(Napi::Env env) {
501
+ Napi::Function ctor = DefineClass(env, "NativePrivateRecordingHandle",
502
+ {
503
+ InstanceMethod<&NativePrivateRecordingHandle::Stop>("stop"),
504
+ });
505
+ env.GetInstanceData<AddonInstanceData>()->privateRecordingConstructor = Napi::Persistent(ctor);
506
+ }
507
+
508
+ Napi::Object NativePrivateRecordingHandle::NewInstance(Napi::Env env, id device) {
509
+ return WrapExternalId(env, env.GetInstanceData<AddonInstanceData>()->privateRecordingConstructor, device);
510
+ }
511
+
196
512
  class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
197
513
  public:
198
514
  static void Init(Napi::Env env);
@@ -727,6 +1043,343 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
727
1043
  [](Napi::Env env, NSArray* result) -> Napi::Value { return NSObjectToJsValue(env, result); });
728
1044
  }
729
1045
 
1046
+ // Shared by StartVideoRecording (audio path)/StartVideoStream — `argIndex` is where the options
1047
+ // object (if any) sits in `info`.
1048
+ static coresim::VideoEncoderOptions ParseVideoEncoderOptions(const Napi::CallbackInfo& info, size_t argIndex) {
1049
+ NSString* displayId = nil;
1050
+ coresim::VideoStreamCodec codec = coresim::VideoStreamCodec::kH264;
1051
+ double fps = 15.0;
1052
+ int bitrate = 2000000;
1053
+ if (info.Length() > argIndex && info[argIndex].IsObject()) {
1054
+ Napi::Object options = info[argIndex].As<Napi::Object>();
1055
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
1056
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
1057
+ }
1058
+ if (options.Has("codec") && options.Get("codec").IsString() &&
1059
+ options.Get("codec").As<Napi::String>().Utf8Value() == "hevc") {
1060
+ codec = coresim::VideoStreamCodec::kHEVC;
1061
+ }
1062
+ if (options.Has("fps") && options.Get("fps").IsNumber()) {
1063
+ fps = options.Get("fps").As<Napi::Number>().DoubleValue();
1064
+ }
1065
+ if (options.Has("bitrate") && options.Get("bitrate").IsNumber()) {
1066
+ bitrate = options.Get("bitrate").As<Napi::Number>().Int32Value();
1067
+ }
1068
+ }
1069
+ return coresim::VideoEncoderOptions{codec, displayId, fps, bitrate};
1070
+ }
1071
+
1072
+ static bool OptionsWantAudio(const Napi::CallbackInfo& info, size_t argIndex) {
1073
+ return info.Length() > argIndex && info[argIndex].IsObject() && info[argIndex].As<Napi::Object>().Has("audio") &&
1074
+ info[argIndex].As<Napi::Object>().Get("audio").IsBoolean() &&
1075
+ info[argIndex].As<Napi::Object>().Get("audio").As<Napi::Boolean>().Value();
1076
+ }
1077
+
1078
+ static bool OptionsHasFps(const Napi::CallbackInfo& info, size_t argIndex) {
1079
+ return info.Length() > argIndex && info[argIndex].IsObject() && info[argIndex].As<Napi::Object>().Has("fps") &&
1080
+ info[argIndex].As<Napi::Object>().Get("fps").IsNumber();
1081
+ }
1082
+
1083
+ // Mirrors `simctl io <udid> recordVideo` by default — CoreSimulator's own private, video-only
1084
+ // recorder (see sim_video_recording.mm), which supports `mask`/`bitrate` but has no polling loop
1085
+ // for `fps` to cap. With `options.audio` OR an explicit `options.fps` (which only means something
1086
+ // against a real polling loop), drives this addon's own encoders instead (av_recording.h) — video
1087
+ // only when `fps` was the sole reason, video+audio when `audio` was set too — since the private
1088
+ // recorder has no per-frame hook to mux audio into and no `fps` knob either way. `mask` is not
1089
+ // supported on this path. Either way returns a handle (NativePrivateRecordingHandle or
1090
+ // NativeAVRecording — see their own doc comments) exposing the identical `stop()` shape, so the
1091
+ // caller never needs to know which path it took.
1092
+ Napi::Value StartVideoRecording(const Napi::CallbackInfo& info) {
1093
+ NSString* outputFile = @(info[0].As<Napi::String>().Utf8Value().c_str());
1094
+ bool wantsAudio = OptionsWantAudio(info, 1);
1095
+ if (wantsAudio || OptionsHasFps(info, 1)) {
1096
+ return StartAVRecording(info, outputFile, wantsAudio);
1097
+ }
1098
+ return StartPrivateVideoRecording(info, outputFile);
1099
+ }
1100
+
1101
+ // Resolves once the first frame is recorded — see CLAUDE.md for the race hit by calling this
1102
+ // handle's own stop() any earlier.
1103
+ Napi::Value StartPrivateVideoRecording(const Napi::CallbackInfo& info, NSString* outputFile) {
1104
+ Napi::Env env = info.Env();
1105
+ id device = device_;
1106
+ NSString* displayId = nil;
1107
+ coresim::VideoMaskPolicy mask = coresim::VideoMaskPolicy::kIgnored;
1108
+ NSMutableDictionary* assetWriterOutputSettings = [NSMutableDictionary dictionary];
1109
+ if (info.Length() > 1 && info[1].IsObject()) {
1110
+ Napi::Object options = info[1].As<Napi::Object>();
1111
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
1112
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
1113
+ }
1114
+ if (options.Has("mask") && options.Get("mask").IsString()) {
1115
+ std::string maskValue = options.Get("mask").As<Napi::String>().Utf8Value();
1116
+ if (maskValue == "alpha") {
1117
+ mask = coresim::VideoMaskPolicy::kAlpha;
1118
+ } else if (maskValue == "black") {
1119
+ mask = coresim::VideoMaskPolicy::kBlack;
1120
+ }
1121
+ }
1122
+ if (options.Has("codec") && options.Get("codec").IsString()) {
1123
+ std::string codecValue = options.Get("codec").As<Napi::String>().Utf8Value();
1124
+ assetWriterOutputSettings[AVVideoCodecKey] = codecValue == "hevc" ? AVVideoCodecTypeHEVC : AVVideoCodecTypeH264;
1125
+ }
1126
+ if (options.Has("bitrate") && options.Get("bitrate").IsNumber()) {
1127
+ int bitrate = options.Get("bitrate").As<Napi::Number>().Int32Value();
1128
+ assetWriterOutputSettings[AVVideoCompressionPropertiesKey] = @{AVVideoAverageBitRateKey : @(bitrate)};
1129
+ }
1130
+ }
1131
+ return RunAsync<id>(
1132
+ env,
1133
+ [device, displayId, mask, assetWriterOutputSettings, outputFile]() -> id {
1134
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
1135
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.recordVideo", DISPATCH_QUEUE_SERIAL);
1136
+ __block NSError* capturedError = nil;
1137
+ NSError* resolveError = nil;
1138
+ BOOL ok = coresim::StartVideoRecording(
1139
+ device, displayId, mask, assetWriterOutputSettings, outputFile, queue,
1140
+ ^(NSError* asyncError) {
1141
+ capturedError = asyncError;
1142
+ dispatch_semaphore_signal(sema);
1143
+ },
1144
+ &resolveError);
1145
+ ThrowIfFailed(ok, resolveError);
1146
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
1147
+ if (capturedError != nil) {
1148
+ throw NSErrorException(capturedError);
1149
+ }
1150
+ return device;
1151
+ },
1152
+ [](Napi::Env env, id device) -> Napi::Value { return NativePrivateRecordingHandle::NewInstance(env, device); });
1153
+ }
1154
+
1155
+ // Recording via this addon's own encoders — see av_recording.h. Combined audio+video when
1156
+ // `captureAudio` is set, video-only (via the same VideoFrameEncoder, just no audio tap/track)
1157
+ // when it's not — reached with `captureAudio == false` when `fps` was requested without `audio`
1158
+ // (see StartVideoRecording). Resolves once the first sample (video, or whichever of video/audio
1159
+ // comes first when both are captured) is written.
1160
+ Napi::Value StartAVRecording(const Napi::CallbackInfo& info, NSString* outputFile, bool captureAudio) {
1161
+ Napi::Env env = info.Env();
1162
+ id device = device_;
1163
+ NSString* udid = DeviceUDID(device).UUIDString;
1164
+ coresim::VideoEncoderOptions videoOptions = ParseVideoEncoderOptions(info, 1);
1165
+ Napi::Function onError = info[2].As<Napi::Function>();
1166
+
1167
+ // Released either from within onEnd_ (a live failure, which fires at most once per session —
1168
+ // see av_recording.h) or, if the recording fails before ever starting, right below instead
1169
+ // (onEnd_ never fires for a Start() that never got past its own setup).
1170
+ Napi::ThreadSafeFunction errorTsfn =
1171
+ Napi::ThreadSafeFunction::New(env, onError, "coresim AV recording error", 0, 1);
1172
+
1173
+ return RunAsync<std::shared_ptr<coresim::AVRecordingSession>>(
1174
+ env,
1175
+ [device, udid, videoOptions, outputFile, captureAudio,
1176
+ errorTsfn]() mutable -> std::shared_ptr<coresim::AVRecordingSession> {
1177
+ auto session =
1178
+ std::make_shared<coresim::AVRecordingSession>(device, udid, videoOptions, outputFile, captureAudio);
1179
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
1180
+ auto resolved = std::make_shared<std::atomic<bool>>(false);
1181
+ // Only ever written before `sema` is signaled on the "failed before starting" path
1182
+ // below, so `work`'s frame (and this variable) is always still alive when it happens.
1183
+ NSError* startupError = nil;
1184
+ try {
1185
+ session->Start(
1186
+ [sema, resolved]() mutable {
1187
+ resolved->store(true);
1188
+ dispatch_semaphore_signal(sema);
1189
+ },
1190
+ [errorTsfn, resolved, sema, &startupError](NSError* error) mutable {
1191
+ if (!resolved->exchange(true)) {
1192
+ // Failed before ever starting — reported via this call's own rejection below,
1193
+ // not the live error channel (nothing has "started" yet to report a live
1194
+ // failure for).
1195
+ startupError = error;
1196
+ dispatch_semaphore_signal(sema);
1197
+ return;
1198
+ }
1199
+ NSErrorException exception(error);
1200
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
1201
+ // See StartVideoStream's identical comment on why this is wrapped in try/catch.
1202
+ try {
1203
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
1204
+ } catch (...) {
1205
+ }
1206
+ });
1207
+ },
1208
+ [errorTsfn]() mutable { errorTsfn.Release(); });
1209
+ } catch (...) {
1210
+ errorTsfn.Release();
1211
+ throw;
1212
+ }
1213
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
1214
+ if (startupError != nil) {
1215
+ throw NSErrorException(startupError);
1216
+ }
1217
+ return session;
1218
+ },
1219
+ [](Napi::Env env, std::shared_ptr<coresim::AVRecordingSession> session) -> Napi::Value {
1220
+ return NativeAVRecording::NewInstance(env, session);
1221
+ });
1222
+ }
1223
+
1224
+ // Real-time encoding via public VideoToolbox APIs (see sim_video_stream.mm) — no private API,
1225
+ // no file. With `options.audio`, also drives this addon's Core Audio + AudioToolbox encoder
1226
+ // (av_stream.h), interleaving audio units into the same delivered sequence (each tagged
1227
+ // `track`). `onAccessUnit`/`onError` are invoked live for as long as the stream runs; the
1228
+ // returned handle (NativeVideoStream or NativeAVStream — identical `stop()`/`requestKeyFrame()`
1229
+ // shape either way) is all the caller needs, regardless of which path it took.
1230
+ Napi::Value StartVideoStream(const Napi::CallbackInfo& info) {
1231
+ Napi::Env env = info.Env();
1232
+ id device = device_;
1233
+ coresim::VideoEncoderOptions options = ParseVideoEncoderOptions(info, 0);
1234
+ Napi::Function onAccessUnit = info[1].As<Napi::Function>();
1235
+ Napi::Function onError = info[2].As<Napi::Function>();
1236
+
1237
+ // Must be constructed on the main thread, like Spawn's own exitTsfn above; released exactly
1238
+ // once each, via onEnd (see sim_video_stream.h/av_stream.h).
1239
+ //
1240
+ // accessUnitTsfn's queue is bounded, unlike every other one-shot-callback ThreadSafeFunction
1241
+ // in this addon — a slow-draining consumer would otherwise let queued frame buffers grow
1242
+ // unbounded; BlockingCall below naturally throttles the encoder(s) once this fills instead.
1243
+ static constexpr size_t kAccessUnitQueueSize = 60;
1244
+ Napi::ThreadSafeFunction accessUnitTsfn =
1245
+ Napi::ThreadSafeFunction::New(env, onAccessUnit, "coresim video stream access unit", kAccessUnitQueueSize, 1);
1246
+ Napi::ThreadSafeFunction errorTsfn =
1247
+ Napi::ThreadSafeFunction::New(env, onError, "coresim video stream error", 0, 1);
1248
+ // See TsfnReleaseGuard's own comment — shared between onEnd's release and AbortDelivery's
1249
+ // abort below so exactly one of them ever actually runs.
1250
+ auto tsfnGuard = std::make_shared<TsfnReleaseGuard>();
1251
+
1252
+ if (OptionsWantAudio(info, 0)) {
1253
+ NSString* udid = DeviceUDID(device).UUIDString;
1254
+ return RunAsync<std::shared_ptr<coresim::AVStreamSession>>(
1255
+ env,
1256
+ [device, udid, options, accessUnitTsfn, errorTsfn,
1257
+ tsfnGuard]() mutable -> std::shared_ptr<coresim::AVStreamSession> {
1258
+ auto session = std::make_shared<coresim::AVStreamSession>(
1259
+ device, udid, options,
1260
+ [accessUnitTsfn](coresim::AVAccessUnit unit) mutable {
1261
+ accessUnitTsfn.BlockingCall(
1262
+ [unit = std::move(unit)](Napi::Env env, Napi::Function jsCallback) mutable {
1263
+ // See the audio-less branch's identical comment on why this is wrapped in
1264
+ // try/catch.
1265
+ try {
1266
+ Napi::Object obj = Napi::Object::New(env);
1267
+ obj.Set("track",
1268
+ Napi::String::New(env, unit.track == coresim::AVTrack::kVideo ? "video" : "audio"));
1269
+ obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, unit.data.data(), unit.data.size()));
1270
+ obj.Set("isKeyFrame", Napi::Boolean::New(env, unit.isKeyFrame));
1271
+ obj.Set("sequence", Napi::Number::New(env, static_cast<double>(unit.sequence)));
1272
+ obj.Set("timestampMicros", Napi::Number::New(env, static_cast<double>(unit.timestampMicros)));
1273
+ jsCallback.Call({obj});
1274
+ } catch (...) {
1275
+ }
1276
+ });
1277
+ },
1278
+ [errorTsfn](NSError* error) mutable {
1279
+ NSErrorException exception(error);
1280
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
1281
+ try {
1282
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
1283
+ } catch (...) {
1284
+ }
1285
+ });
1286
+ },
1287
+ [accessUnitTsfn, errorTsfn, tsfnGuard]() mutable {
1288
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1289
+ accessUnitTsfn.Release();
1290
+ errorTsfn.Release();
1291
+ });
1292
+ },
1293
+ [accessUnitTsfn, errorTsfn, tsfnGuard]() mutable {
1294
+ // See ActiveSessionRegistry::StopAll's use of AbortDelivery — unblocks a
1295
+ // producer thread stuck pushing into a full queue so Stop() doesn't deadlock
1296
+ // waiting on it.
1297
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1298
+ accessUnitTsfn.Abort();
1299
+ errorTsfn.Abort();
1300
+ });
1301
+ });
1302
+ try {
1303
+ session->Start();
1304
+ } catch (...) {
1305
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1306
+ accessUnitTsfn.Release();
1307
+ errorTsfn.Release();
1308
+ });
1309
+ throw;
1310
+ }
1311
+ return session;
1312
+ },
1313
+ [](Napi::Env env, std::shared_ptr<coresim::AVStreamSession> session) -> Napi::Value {
1314
+ return NativeAVStream::NewInstance(env, session);
1315
+ });
1316
+ }
1317
+
1318
+ return RunAsync<std::shared_ptr<coresim::VideoStreamSession>>(
1319
+ env,
1320
+ [device, options, accessUnitTsfn, errorTsfn,
1321
+ tsfnGuard]() mutable -> std::shared_ptr<coresim::VideoStreamSession> {
1322
+ auto session = std::make_shared<coresim::VideoStreamSession>(
1323
+ device, options,
1324
+ [accessUnitTsfn](coresim::VideoAccessUnit unit) mutable {
1325
+ accessUnitTsfn.BlockingCall([unit = std::move(unit)](Napi::Env env, Napi::Function jsCallback) mutable {
1326
+ // The Environment can already be mid-teardown by the time a queued callback
1327
+ // like this one actually runs (e.g. worker.terminate() while frames were still
1328
+ // piling up) — node-addon-api's own WrapVoidCallback would otherwise re-throw
1329
+ // whatever escapes here as a JS exception, which itself aborts the process on a
1330
+ // torn-down env instead of just failing to deliver a frame nothing can receive
1331
+ // anymore. See CLAUDE.md.
1332
+ try {
1333
+ Napi::Object obj = Napi::Object::New(env);
1334
+ obj.Set("track", Napi::String::New(env, "video"));
1335
+ obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, unit.data.data(), unit.data.size()));
1336
+ obj.Set("isKeyFrame", Napi::Boolean::New(env, unit.isKeyFrame));
1337
+ obj.Set("sequence", Napi::Number::New(env, static_cast<double>(unit.sequence)));
1338
+ obj.Set("timestampMicros", Napi::Number::New(env, static_cast<double>(unit.timestampMicros)));
1339
+ jsCallback.Call({obj});
1340
+ } catch (...) {
1341
+ }
1342
+ });
1343
+ },
1344
+ [errorTsfn](NSError* error) mutable {
1345
+ NSErrorException exception(error);
1346
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
1347
+ // See accessUnitTsfn's callback above.
1348
+ try {
1349
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
1350
+ } catch (...) {
1351
+ }
1352
+ });
1353
+ },
1354
+ [accessUnitTsfn, errorTsfn, tsfnGuard]() mutable {
1355
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1356
+ accessUnitTsfn.Release();
1357
+ errorTsfn.Release();
1358
+ });
1359
+ },
1360
+ [accessUnitTsfn, errorTsfn, tsfnGuard]() mutable {
1361
+ // See the audio branch's identical comment above.
1362
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1363
+ accessUnitTsfn.Abort();
1364
+ errorTsfn.Abort();
1365
+ });
1366
+ });
1367
+ try {
1368
+ session->Start();
1369
+ } catch (...) {
1370
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1371
+ accessUnitTsfn.Release();
1372
+ errorTsfn.Release();
1373
+ });
1374
+ throw;
1375
+ }
1376
+ return session;
1377
+ },
1378
+ [](Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) -> Napi::Value {
1379
+ return NativeVideoStream::NewInstance(env, session);
1380
+ });
1381
+ }
1382
+
730
1383
  // Option dictionary keys for `spawnWithPath:options:...` aren't part of the ObjC runtime
731
1384
  // metadata this addon resolves selectors from (they're string literals inside CoreSimulator's
732
1385
  // own implementation) — confirmed by resolving each `SimDeviceSpawnKey*` symbol at runtime via
@@ -912,6 +1565,8 @@ void NativeDevice::Init(Napi::Env env) {
912
1565
  InstanceMethod<&NativeDevice::GetWebInspectorSocket>("getWebInspectorSocket"),
913
1566
  InstanceMethod<&NativeDevice::Screenshot>("screenshot"),
914
1567
  InstanceMethod<&NativeDevice::GetDisplays>("getDisplays"),
1568
+ InstanceMethod<&NativeDevice::StartVideoRecording>("startVideoRecording"),
1569
+ InstanceMethod<&NativeDevice::StartVideoStream>("startVideoStream"),
915
1570
  InstanceMethod<&NativeDevice::Spawn>("spawn"),
916
1571
  });
917
1572
  env.GetInstanceData<AddonInstanceData>()->deviceConstructor = Napi::Persistent(ctor);
@@ -1137,15 +1792,67 @@ Napi::Value FrameworkVersionBinding(const Napi::CallbackInfo& info) {
1137
1792
  [](Napi::Env env, std::string version) -> Napi::Value { return Napi::String::New(env, version); });
1138
1793
  }
1139
1794
 
1795
+ // Node force-releases any ThreadSafeFunctions still outstanding when an Environment (e.g. a
1796
+ // worker_threads Worker) tears down — racing our own release of the same TSFNs (fired
1797
+ // asynchronously from NativeVideoStream/NativeAVStream/NativeAVRecording::Finalize, or never, if
1798
+ // a running session's JS wrapper was never explicitly stopped) crashes the process. Cleanup hooks
1799
+ // are guaranteed to run before that automatic TSFN teardown, so stopping every active session
1800
+ // here — synchronously, blocking until each has released its own TSFNs — establishes the
1801
+ // ordering Node itself doesn't.
1802
+ //
1803
+ // Also called directly (not just as a cleanup hook) by FlushActiveSessionsBinding below — cleanup
1804
+ // hooks are confirmed (empirically, not just per docs) to NOT run at all for a `process.exit()`
1805
+ // on the main process/thread (unlike a Worker's `worker.terminate()`, or the main thread's own
1806
+ // natural empty-event-loop exit, both of which do invoke them). Without a second, JS-`'exit'`-
1807
+ // event-triggered path to this same function, a forgotten (never explicitly stopped) AV recording
1808
+ // silently loses its AVAssetWriter-buffered data — the file is left with no moov atom — the
1809
+ // instant a caller force-exits via `process.exit()`, since nothing ever calls finishWriting.
1810
+ void CleanupActiveSessions(AddonInstanceData* instanceData) {
1811
+ // AbortDelivery() first: this runs on the main JS thread with the event loop not being pumped
1812
+ // (a cleanup hook, or the synchronous process.on('exit') path below), so nothing else can drain
1813
+ // accessUnitTsfn's bounded queue — a producer thread already blocked pushing into a full queue
1814
+ // would otherwise deadlock against Stop()'s own wait on that same producer thread.
1815
+ auto stopStream = [](auto& session) {
1816
+ session->AbortDelivery();
1817
+ session->Stop();
1818
+ };
1819
+ instanceData->activeVideoStreams.StopAll(stopStream);
1820
+ instanceData->activeAVStreams.StopAll(stopStream);
1821
+ instanceData->activeAVRecordings.StopAll([](auto& session) {
1822
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
1823
+ session->Stop([sema](NSError*) { dispatch_semaphore_signal(sema); });
1824
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
1825
+ });
1826
+ }
1827
+
1828
+ // Deliberately synchronous (not RunAsync/Promise-returning) — meant to be called from a JS-level
1829
+ // `process.on('exit', ...)` listener (native-simctl.ts), which per Node's own contract may only
1830
+ // run synchronous code, but that includes a plain blocking native call like this one (it isn't
1831
+ // scheduling new async JS work, just blocking the calling thread on GCD semaphores that are
1832
+ // serviced by GCD's own independent thread pool — unaffected by anything happening to Node's own
1833
+ // event loop or threadpool during exit). See CleanupActiveSessions's own comment for why this
1834
+ // second call path exists at all.
1835
+ Napi::Value FlushActiveSessionsBinding(const Napi::CallbackInfo& info) {
1836
+ CleanupActiveSessions(info.Env().GetInstanceData<AddonInstanceData>());
1837
+ return info.Env().Undefined();
1838
+ }
1839
+
1140
1840
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
1141
1841
  // Runs once per Environment (see AddonInstanceData above) — never shared across a
1142
1842
  // worker_threads instance also `require()`-ing this addon.
1143
- env.SetInstanceData(new AddonInstanceData());
1843
+ auto* instanceData = new AddonInstanceData();
1844
+ env.SetInstanceData(instanceData);
1845
+ env.AddCleanupHook(CleanupActiveSessions, instanceData);
1144
1846
  NativeDevice::Init(env);
1145
1847
  NativeDeviceSet::Init(env);
1146
1848
  NativeServiceContext::Init(env);
1849
+ NativeVideoStream::Init(env);
1850
+ NativeAVStream::Init(env);
1851
+ NativeAVRecording::Init(env);
1852
+ NativePrivateRecordingHandle::Init(env);
1147
1853
  exports.Set("sharedServiceContext", Napi::Function::New(env, SharedServiceContextBinding));
1148
1854
  exports.Set("frameworkVersion", Napi::Function::New(env, FrameworkVersionBinding));
1855
+ exports.Set("flushActiveSessions", Napi::Function::New(env, FlushActiveSessionsBinding));
1149
1856
  return exports;
1150
1857
  }
1151
1858