@appium/coresim 1.5.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 (47) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +14 -0
  3. package/binding.gyp +7 -0
  4. package/lib/src/commands/video-recording.d.ts +14 -7
  5. package/lib/src/commands/video-recording.d.ts.map +1 -1
  6. package/lib/src/commands/video-recording.js +36 -11
  7. package/lib/src/commands/video-recording.js.map +1 -1
  8. package/lib/src/commands/video-stream.d.ts +55 -7
  9. package/lib/src/commands/video-stream.d.ts.map +1 -1
  10. package/lib/src/commands/video-stream.js +37 -35
  11. package/lib/src/commands/video-stream.js.map +1 -1
  12. package/lib/src/native-simctl.js +12 -1
  13. package/lib/src/native-simctl.js.map +1 -1
  14. package/lib/src/types.d.ts +72 -10
  15. package/lib/src/types.d.ts.map +1 -1
  16. package/lib/src/utils/index.d.ts +1 -1
  17. package/lib/src/utils/index.d.ts.map +1 -1
  18. package/lib/src/utils/index.js +1 -1
  19. package/lib/src/utils/index.js.map +1 -1
  20. package/lib/src/utils/run-catching.d.ts +4 -0
  21. package/lib/src/utils/run-catching.d.ts.map +1 -1
  22. package/lib/src/utils/run-catching.js +11 -0
  23. package/lib/src/utils/run-catching.js.map +1 -1
  24. package/package.json +1 -1
  25. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  26. package/src/commands/video-recording.ts +49 -19
  27. package/src/commands/video-stream.ts +48 -39
  28. package/src/coresim.mm +547 -118
  29. package/src/native/audio_encoder.h +65 -0
  30. package/src/native/audio_encoder.mm +270 -0
  31. package/src/native/av_recording.h +52 -0
  32. package/src/native/av_recording.mm +424 -0
  33. package/src/native/av_stream.h +70 -0
  34. package/src/native/av_stream.mm +217 -0
  35. package/src/native/monotonic_clock.h +20 -0
  36. package/src/native/sim_audio_tap.h +67 -0
  37. package/src/native/sim_audio_tap.mm +350 -0
  38. package/src/native/sim_process.h +10 -0
  39. package/src/native/sim_process.mm +81 -18
  40. package/src/native/sim_video_stream.h +14 -11
  41. package/src/native/sim_video_stream.mm +27 -383
  42. package/src/native/video_encoder.h +76 -0
  43. package/src/native/video_encoder.mm +417 -0
  44. package/src/native-simctl.ts +12 -1
  45. package/src/types.ts +84 -11
  46. package/src/utils/index.ts +1 -1
  47. package/src/utils/run-catching.ts +11 -0
package/src/coresim.mm CHANGED
@@ -25,6 +25,8 @@
25
25
  #include <vector>
26
26
 
27
27
  #include "native/async_bridge.h"
28
+ #include "native/av_recording.h"
29
+ #include "native/av_stream.h"
28
30
  #include "native/nserror_bridge.h"
29
31
  #include "native/objc_runtime.h"
30
32
  #include "native/sim_device.h"
@@ -182,6 +184,29 @@ struct RuntimeEntry {
182
184
  std::string versionString;
183
185
  };
184
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
+
185
210
  // Node-API explicitly prohibits sharing an Environment's data across Environments (e.g. two
186
211
  // worker_threads instances each `require()`-ing this addon) — each gets its own separate call
187
212
  // into Init() below. A process-global `static Napi::FunctionReference` per class would let one
@@ -190,45 +215,54 @@ struct RuntimeEntry {
190
215
  // Napi::Env::SetInstanceData/GetInstanceData instead keeps each Environment's constructors
191
216
  // scoped to it, and cleans them up automatically (the default finalizer just `delete`s this) when
192
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
+
193
253
  struct AddonInstanceData {
194
254
  Napi::FunctionReference deviceConstructor;
195
255
  Napi::FunctionReference deviceSetConstructor;
196
256
  Napi::FunctionReference serviceContextConstructor;
197
257
  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;
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;
203
264
  };
204
265
 
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
-
232
266
  } // namespace
233
267
 
234
268
  // Wraps a live coresim::VideoStreamSession. Access units/errors are delivered live via the
@@ -262,7 +296,7 @@ class NativeVideoStream : public Napi::ObjectWrap<NativeVideoStream> {
262
296
  void Finalize(Napi::Env env) override {
263
297
  auto session = std::move(session_);
264
298
  if (session) {
265
- DeregisterActiveStream(env, session);
299
+ env.GetInstanceData<AddonInstanceData>()->activeVideoStreams.Deregister(session);
266
300
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
267
301
  session->Stop();
268
302
  });
@@ -273,7 +307,7 @@ class NativeVideoStream : public Napi::ObjectWrap<NativeVideoStream> {
273
307
  NativeVideoStream::NativeVideoStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeVideoStream>(info) {
274
308
  auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::VideoStreamSession>>>().Data();
275
309
  session_ = *boxed;
276
- RegisterActiveStream(info.Env(), session_);
310
+ info.Env().GetInstanceData<AddonInstanceData>()->activeVideoStreams.Register(session_);
277
311
  }
278
312
 
279
313
  void NativeVideoStream::Init(Napi::Env env) {
@@ -292,6 +326,189 @@ Napi::Object NativeVideoStream::NewInstance(Napi::Env env, std::shared_ptr<cores
292
326
  env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::VideoStreamSession>* data) { delete data; })});
293
327
  }
294
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
+
295
512
  class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
296
513
  public:
297
514
  static void Init(Napi::Env env);
@@ -826,15 +1043,69 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
826
1043
  [](Napi::Env env, NSArray* result) -> Napi::Value { return NSObjectToJsValue(env, result); });
827
1044
  }
828
1045
 
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.
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.
831
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) {
832
1104
  Napi::Env env = info.Env();
833
1105
  id device = device_;
834
- NSString* outputFile = @(info[0].As<Napi::String>().Utf8Value().c_str());
835
1106
  NSString* displayId = nil;
836
1107
  coresim::VideoMaskPolicy mask = coresim::VideoMaskPolicy::kIgnored;
837
- NSDictionary* assetWriterOutputSettings = @{};
1108
+ NSMutableDictionary* assetWriterOutputSettings = [NSMutableDictionary dictionary];
838
1109
  if (info.Length() > 1 && info[1].IsObject()) {
839
1110
  Napi::Object options = info[1].As<Napi::Object>();
840
1111
  if (options.Has("displayId") && options.Get("displayId").IsString()) {
@@ -850,100 +1121,204 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
850
1121
  }
851
1122
  if (options.Has("codec") && options.Get("codec").IsString()) {
852
1123
  std::string codecValue = options.Get("codec").As<Napi::String>().Utf8Value();
853
- AVVideoCodecType codecType = codecValue == "hevc" ? AVVideoCodecTypeHEVC : AVVideoCodecTypeH264;
854
- assetWriterOutputSettings = @{AVVideoCodecKey : codecType};
1124
+ assetWriterOutputSettings[AVVideoCodecKey] = codecValue == "hevc" ? AVVideoCodecTypeHEVC : AVVideoCodecTypeH264;
855
1125
  }
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);
1126
+ if (options.Has("bitrate") && options.Get("bitrate").IsNumber()) {
1127
+ int bitrate = options.Get("bitrate").As<Napi::Number>().Int32Value();
1128
+ assetWriterOutputSettings[AVVideoCompressionPropertiesKey] = @{AVVideoAverageBitRateKey : @(bitrate)};
873
1129
  }
874
- });
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); });
875
1153
  }
876
1154
 
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) {
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) {
880
1161
  Napi::Env env = info.Env();
881
1162
  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
- });
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
+ });
900
1222
  }
901
1223
 
902
1224
  // 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()`.
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.
905
1230
  Napi::Value StartVideoStream(const Napi::CallbackInfo& info) {
906
1231
  Napi::Env env = info.Env();
907
1232
  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
- }
1233
+ coresim::VideoEncoderOptions options = ParseVideoEncoderOptions(info, 0);
928
1234
  Napi::Function onAccessUnit = info[1].As<Napi::Function>();
929
1235
  Napi::Function onError = info[2].As<Napi::Function>();
930
1236
 
931
1237
  // 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).
1238
+ // once each, via onEnd (see sim_video_stream.h/av_stream.h).
933
1239
  //
934
1240
  // accessUnitTsfn's queue is bounded, unlike every other one-shot-callback ThreadSafeFunction
935
1241
  // 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.
1242
+ // unbounded; BlockingCall below naturally throttles the encoder(s) once this fills instead.
937
1243
  static constexpr size_t kAccessUnitQueueSize = 60;
938
1244
  Napi::ThreadSafeFunction accessUnitTsfn =
939
1245
  Napi::ThreadSafeFunction::New(env, onAccessUnit, "coresim video stream access unit", kAccessUnitQueueSize, 1);
940
1246
  Napi::ThreadSafeFunction errorTsfn =
941
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
+ }
942
1317
 
943
- coresim::VideoStreamOptions options{codec, displayId, fps, bitrate};
944
1318
  return RunAsync<std::shared_ptr<coresim::VideoStreamSession>>(
945
1319
  env,
946
- [device, options, accessUnitTsfn, errorTsfn]() mutable -> std::shared_ptr<coresim::VideoStreamSession> {
1320
+ [device, options, accessUnitTsfn, errorTsfn,
1321
+ tsfnGuard]() mutable -> std::shared_ptr<coresim::VideoStreamSession> {
947
1322
  auto session = std::make_shared<coresim::VideoStreamSession>(
948
1323
  device, options,
949
1324
  [accessUnitTsfn](coresim::VideoAccessUnit unit) mutable {
@@ -956,6 +1331,7 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
956
1331
  // anymore. See CLAUDE.md.
957
1332
  try {
958
1333
  Napi::Object obj = Napi::Object::New(env);
1334
+ obj.Set("track", Napi::String::New(env, "video"));
959
1335
  obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, unit.data.data(), unit.data.size()));
960
1336
  obj.Set("isKeyFrame", Napi::Boolean::New(env, unit.isKeyFrame));
961
1337
  obj.Set("sequence", Napi::Number::New(env, static_cast<double>(unit.sequence)));
@@ -975,15 +1351,26 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
975
1351
  }
976
1352
  });
977
1353
  },
978
- [accessUnitTsfn, errorTsfn]() mutable {
979
- accessUnitTsfn.Release();
980
- errorTsfn.Release();
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
+ });
981
1366
  });
982
1367
  try {
983
1368
  session->Start();
984
1369
  } catch (...) {
985
- accessUnitTsfn.Release();
986
- errorTsfn.Release();
1370
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1371
+ accessUnitTsfn.Release();
1372
+ errorTsfn.Release();
1373
+ });
987
1374
  throw;
988
1375
  }
989
1376
  return session;
@@ -1179,7 +1566,6 @@ void NativeDevice::Init(Napi::Env env) {
1179
1566
  InstanceMethod<&NativeDevice::Screenshot>("screenshot"),
1180
1567
  InstanceMethod<&NativeDevice::GetDisplays>("getDisplays"),
1181
1568
  InstanceMethod<&NativeDevice::StartVideoRecording>("startVideoRecording"),
1182
- InstanceMethod<&NativeDevice::StopVideoRecording>("stopVideoRecording"),
1183
1569
  InstanceMethod<&NativeDevice::StartVideoStream>("startVideoStream"),
1184
1570
  InstanceMethod<&NativeDevice::Spawn>("spawn"),
1185
1571
  });
@@ -1406,24 +1792,67 @@ Napi::Value FrameworkVersionBinding(const Napi::CallbackInfo& info) {
1406
1792
  [](Napi::Env env, std::string version) -> Napi::Value { return Napi::String::New(env, version); });
1407
1793
  }
1408
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
+
1409
1840
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
1410
1841
  // Runs once per Environment (see AddonInstanceData above) — never shared across a
1411
1842
  // worker_threads instance also `require()`-ing this addon.
1412
1843
  auto* instanceData = new AddonInstanceData();
1413
1844
  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);
1845
+ env.AddCleanupHook(CleanupActiveSessions, instanceData);
1421
1846
  NativeDevice::Init(env);
1422
1847
  NativeDeviceSet::Init(env);
1423
1848
  NativeServiceContext::Init(env);
1424
1849
  NativeVideoStream::Init(env);
1850
+ NativeAVStream::Init(env);
1851
+ NativeAVRecording::Init(env);
1852
+ NativePrivateRecordingHandle::Init(env);
1425
1853
  exports.Set("sharedServiceContext", Napi::Function::New(env, SharedServiceContextBinding));
1426
1854
  exports.Set("frameworkVersion", Napi::Function::New(env, FrameworkVersionBinding));
1855
+ exports.Set("flushActiveSessions", Napi::Function::New(env, FlushActiveSessionsBinding));
1427
1856
  return exports;
1428
1857
  }
1429
1858