@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.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -0
- package/binding.gyp +14 -1
- package/lib/src/commands/video-recording.d.ts +57 -0
- package/lib/src/commands/video-recording.d.ts.map +1 -0
- package/lib/src/commands/video-recording.js +123 -0
- package/lib/src/commands/video-recording.js.map +1 -0
- package/lib/src/commands/video-stream.d.ts +92 -0
- package/lib/src/commands/video-stream.d.ts.map +1 -0
- package/lib/src/commands/video-stream.js +242 -0
- package/lib/src/commands/video-stream.js.map +1 -0
- package/lib/src/index.d.ts +2 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js +1 -0
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts +4 -0
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +21 -1
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +134 -0
- package/lib/src/types.d.ts.map +1 -1
- package/lib/src/utils/index.d.ts +1 -1
- package/lib/src/utils/index.d.ts.map +1 -1
- package/lib/src/utils/index.js +1 -1
- package/lib/src/utils/index.js.map +1 -1
- package/lib/src/utils/run-catching.d.ts +4 -0
- package/lib/src/utils/run-catching.d.ts.map +1 -1
- package/lib/src/utils/run-catching.js +11 -0
- package/lib/src/utils/run-catching.js.map +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/video-recording.ts +158 -0
- package/src/commands/video-stream.ts +283 -0
- package/src/coresim.mm +708 -1
- package/src/index.ts +4 -0
- package/src/native/audio_encoder.h +65 -0
- package/src/native/audio_encoder.mm +270 -0
- package/src/native/av_recording.h +52 -0
- package/src/native/av_recording.mm +424 -0
- package/src/native/av_stream.h +70 -0
- package/src/native/av_stream.mm +217 -0
- package/src/native/monotonic_clock.h +20 -0
- package/src/native/sim_audio_tap.h +67 -0
- package/src/native/sim_audio_tap.mm +350 -0
- package/src/native/sim_process.h +10 -0
- package/src/native/sim_process.mm +81 -18
- package/src/native/sim_screenshot.h +10 -0
- package/src/native/sim_screenshot.mm +11 -6
- package/src/native/sim_video_recording.h +27 -0
- package/src/native/sim_video_recording.mm +87 -0
- package/src/native/sim_video_stream.h +64 -0
- package/src/native/sim_video_stream.mm +71 -0
- package/src/native/video_encoder.h +76 -0
- package/src/native/video_encoder.mm +417 -0
- package/src/native-simctl.ts +22 -1
- package/src/types.ts +149 -0
- package/src/utils/index.ts +1 -1
- 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
|
-
|
|
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
|
|