@appium/coresim 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/binding.gyp +7 -1
  3. package/lib/src/commands/process.d.ts.map +1 -1
  4. package/lib/src/commands/process.js +3 -4
  5. package/lib/src/commands/process.js.map +1 -1
  6. package/lib/src/commands/spawn.d.ts +9 -5
  7. package/lib/src/commands/spawn.d.ts.map +1 -1
  8. package/lib/src/commands/spawn.js +9 -5
  9. package/lib/src/commands/spawn.js.map +1 -1
  10. package/lib/src/commands/video-recording.d.ts +50 -0
  11. package/lib/src/commands/video-recording.d.ts.map +1 -0
  12. package/lib/src/commands/video-recording.js +98 -0
  13. package/lib/src/commands/video-recording.js.map +1 -0
  14. package/lib/src/commands/video-stream.d.ts +44 -0
  15. package/lib/src/commands/video-stream.d.ts.map +1 -0
  16. package/lib/src/commands/video-stream.js +240 -0
  17. package/lib/src/commands/video-stream.js.map +1 -0
  18. package/lib/src/index.d.ts +2 -1
  19. package/lib/src/index.d.ts.map +1 -1
  20. package/lib/src/index.js +1 -0
  21. package/lib/src/index.js.map +1 -1
  22. package/lib/src/native-simctl.d.ts +4 -0
  23. package/lib/src/native-simctl.d.ts.map +1 -1
  24. package/lib/src/native-simctl.js +9 -0
  25. package/lib/src/native-simctl.js.map +1 -1
  26. package/lib/src/types.d.ts +72 -0
  27. package/lib/src/types.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  30. package/src/commands/process.ts +3 -4
  31. package/src/commands/spawn.ts +9 -5
  32. package/src/commands/video-recording.ts +128 -0
  33. package/src/commands/video-stream.ts +274 -0
  34. package/src/coresim.mm +310 -4
  35. package/src/index.ts +4 -0
  36. package/src/native/sim_screenshot.h +10 -0
  37. package/src/native/sim_screenshot.mm +11 -6
  38. package/src/native/sim_video_recording.h +27 -0
  39. package/src/native/sim_video_recording.mm +87 -0
  40. package/src/native/sim_video_stream.h +61 -0
  41. package/src/native/sim_video_stream.mm +427 -0
  42. package/src/native-simctl.ts +10 -0
  43. package/src/types.ts +76 -0
@@ -0,0 +1,274 @@
1
+ import {EventEmitter} from 'node:events';
2
+
3
+ import {logger} from '@appium/support';
4
+
5
+ import {wrapNativeError} from '../errors.js';
6
+ import type {NativeSimctl} from '../native-simctl.js';
7
+ import type {NativeVideoStreamHandle, VideoAccessUnit, VideoStreamOptions} from '../types.js';
8
+ import {runCatchingAsync} from '../utils/index.js';
9
+
10
+ declare module '../native-simctl.js' {
11
+ interface NativeSimctl {
12
+ startVideoStream(udid: string, options?: VideoStreamOptions): Promise<VideoStream>;
13
+ }
14
+ }
15
+
16
+ const log = logger.getLogger('CoreSim');
17
+
18
+ // Matches the native side's own ThreadSafeFunction queue bound (see coresim.mm's
19
+ // kAccessUnitQueueSize) — kept here too since the native bound alone provides no real
20
+ // backpressure: _handleAccessUnit's emit-equivalent push always returns immediately, regardless of
21
+ // how slow the actual accessUnits() consumer is, so without a bound of its own this queue could
22
+ // otherwise grow without limit while a slow consumer falls behind.
23
+ const MAX_BUFFERED_UNITS = 60;
24
+
25
+ const SINGLE_CONSUMER_ERROR =
26
+ 'VideoStream.accessUnits() supports only one active consumer at a time — a second concurrent call rejects.';
27
+
28
+ /** `wrapNativeError` always throws — this just gets its thrown value back as a plain return, to emit rather than raise it. */
29
+ function toTypedError(err: unknown): Error {
30
+ try {
31
+ wrapNativeError(err);
32
+ } catch (wrapped) {
33
+ return wrapped as Error;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Single-consumer FIFO between native's per-frame callback and `accessUnits()`. Unlike routing
39
+ * through `EventEmitter`, a unit pushed before any consumer has started iterating is retained
40
+ * (fixing the encoder's own first-frame/keyframe otherwise being lost to a startup race) rather
41
+ * than silently dropped.
42
+ *
43
+ * Bounded at `MAX_BUFFERED_UNITS`, but codec-aware about *how* it sheds load once a slow consumer
44
+ * falls behind: since frames only reference earlier frames they were encoded against (no
45
+ * `AllowFrameReordering`), dropping an arbitrary interframe would orphan every later one from its
46
+ * reference chain, corrupting decode from that point on even though delivery looks unbroken.
47
+ * Overflow instead clears the backlog entirely and enters a resync state, discarding every
48
+ * further interframe (not buffering them) until the next keyframe — self-decodable on its own —
49
+ * lets delivery resume cleanly.
50
+ */
51
+ class AccessUnitQueue {
52
+ private readonly buffer: VideoAccessUnit[] = [];
53
+ private waiter:
54
+ | {resolve: (result: IteratorResult<VideoAccessUnit>) => void; reject: (err: unknown) => void}
55
+ | undefined;
56
+ private ended = false;
57
+ private error: unknown;
58
+ private resyncing = false;
59
+
60
+ /** @param onOverflow — called once when overflow first forces a resync, e.g. to request a fresh keyframe. */
61
+ constructor(private readonly onOverflow?: () => void) {}
62
+
63
+ push(unit: VideoAccessUnit): void {
64
+ if (this.ended) {
65
+ return;
66
+ }
67
+ if (this.resyncing) {
68
+ if (!unit.isKeyFrame) {
69
+ return; // still waiting for a self-decodable point to resume delivery from
70
+ }
71
+ this.resyncing = false;
72
+ }
73
+ if (this.waiter) {
74
+ const {resolve} = this.waiter;
75
+ this.waiter = undefined;
76
+ resolve({value: unit, done: false});
77
+ return;
78
+ }
79
+ this.buffer.push(unit);
80
+ if (this.buffer.length > MAX_BUFFERED_UNITS) {
81
+ this.buffer.length = 0;
82
+ this.resyncing = true;
83
+ this.onOverflow?.();
84
+ }
85
+ }
86
+
87
+ /** Ends the queue with an error — any pending or future `next()` rejects with it. */
88
+ fail(err: unknown): void {
89
+ if (this.ended) {
90
+ return;
91
+ }
92
+ this.ended = true;
93
+ this.error = err;
94
+ this.buffer.length = 0;
95
+ if (this.waiter) {
96
+ const {reject} = this.waiter;
97
+ this.waiter = undefined;
98
+ reject(err);
99
+ }
100
+ }
101
+
102
+ /** Ends the queue cleanly — any pending or future `next()` resolves `done`. */
103
+ end(): void {
104
+ if (this.ended) {
105
+ return;
106
+ }
107
+ this.ended = true;
108
+ this.buffer.length = 0;
109
+ if (this.waiter) {
110
+ const {resolve} = this.waiter;
111
+ this.waiter = undefined;
112
+ resolve({value: undefined, done: true});
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolves `done` (not rejects) if `signal` aborts while waiting, mirroring `events.on()`.
118
+ * Checks cancellation/end *before* dequeuing, so an already-aborted signal or an already-ended
119
+ * queue never hands out a stale buffered unit — an aborted consumer, or a fresh iterator started
120
+ * after `stop()`, must see the boundary immediately rather than draining leftovers first.
121
+ */
122
+ next(signal: AbortSignal): Promise<IteratorResult<VideoAccessUnit>> {
123
+ if (signal.aborted) {
124
+ return Promise.resolve({value: undefined, done: true});
125
+ }
126
+ if (this.ended) {
127
+ return this.error ? Promise.reject(this.error) : Promise.resolve({value: undefined, done: true});
128
+ }
129
+ const buffered = this.buffer.shift();
130
+ if (buffered !== undefined) {
131
+ return Promise.resolve({value: buffered, done: false});
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const onAbort = () => {
135
+ this.waiter = undefined;
136
+ resolve({value: undefined, done: true});
137
+ };
138
+ signal.addEventListener('abort', onAbort, {once: true});
139
+ this.waiter = {
140
+ resolve: (result) => {
141
+ signal.removeEventListener('abort', onAbort);
142
+ resolve(result);
143
+ },
144
+ reject: (err) => {
145
+ signal.removeEventListener('abort', onAbort);
146
+ reject(err);
147
+ },
148
+ };
149
+ });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * A live video stream from `NativeSimctl.startVideoStream` — encodes the device's display in real
155
+ * time via VideoToolbox, unlike `startVideoRecording`, which drives CoreSimulator's own private,
156
+ * file-only recorder. Mirrors `appium-ios-remotexpc`'s `ScreenStreamCapture` shape
157
+ * (`accessUnits()`/`stop()`) for API consistency; the transport is otherwise unrelated.
158
+ */
159
+ export class VideoStream extends EventEmitter {
160
+ private handle: NativeVideoStreamHandle | undefined;
161
+ private readonly stopController = new AbortController();
162
+ private stopPromise: Promise<void> | undefined;
163
+ // Requests a fresh keyframe on resync so delivery can resume immediately rather than waiting
164
+ // for the next periodic one — `handle` may not be attached yet on a startup-time overflow
165
+ // (vanishingly unlikely given MAX_BUFFERED_UNITS), in which case this is just a no-op.
166
+ private readonly queue = new AccessUnitQueue(() => this.handle?.requestKeyFrame());
167
+ private activeConsumers = 0;
168
+
169
+ /** @internal */
170
+ constructor(public readonly codec: 'h264' | 'hevc') {
171
+ super();
172
+ }
173
+
174
+ /** @internal */
175
+ _handleAccessUnit(unit: VideoAccessUnit): void {
176
+ this.queue.push(unit);
177
+ }
178
+
179
+ /**
180
+ * @internal
181
+ * An active `accessUnits()` consumer receives the error via the queue itself (thrown out of its
182
+ * `for await` loop, per that method's contract) rather than the `'error'` event, so `emit` is
183
+ * only used for an explicit external listener; with neither, it's logged instead of lost.
184
+ */
185
+ _handleError(err: unknown): void {
186
+ const error = toTypedError(err);
187
+ this.queue.fail(error);
188
+ if (this.listenerCount('error') > 0) {
189
+ this.emit('error', error);
190
+ } else if (this.activeConsumers === 0) {
191
+ log.error(`Unhandled VideoStream error: ${error.stack ?? error}`);
192
+ }
193
+ }
194
+
195
+ /** @internal */
196
+ _attachHandle(handle: NativeVideoStreamHandle): void {
197
+ this.handle = handle;
198
+ }
199
+
200
+ /**
201
+ * Yields each encoded access unit as it's produced, until {@link stop} is called or the stream
202
+ * errors (in which case the error is thrown out of the loop). Pass `signal` to stop iterating
203
+ * without treating that as an error. Mirrors `ScreenStreamCapture.accessUnits()`'s shape.
204
+ *
205
+ * Only one active consumer is supported at a time — a second concurrent call rejects rather
206
+ * than silently sharing (and corrupting) the first one's single internal waiter slot.
207
+ */
208
+ async *accessUnits(signal?: AbortSignal): AsyncGenerator<VideoAccessUnit> {
209
+ if (this.activeConsumers > 0) {
210
+ throw new Error(SINGLE_CONSUMER_ERROR);
211
+ }
212
+ const combined = signal ? AbortSignal.any([signal, this.stopController.signal]) : this.stopController.signal;
213
+ this.activeConsumers++;
214
+ try {
215
+ for (;;) {
216
+ const result = await this.queue.next(combined);
217
+ if (result.done) {
218
+ return;
219
+ }
220
+ yield result.value;
221
+ }
222
+ } finally {
223
+ this.activeConsumers--;
224
+ }
225
+ }
226
+
227
+ /** Stops the stream and releases the underlying encoder. Idempotent, including concurrently. */
228
+ async stop(): Promise<void> {
229
+ this.stopPromise ??= (async () => {
230
+ this.stopController.abort();
231
+ this.queue.end();
232
+ await this.handle?.stop();
233
+ })();
234
+ return this.stopPromise;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Starts encoding the device's display in real time. Resolves once the encoder has actually
240
+ * started; the returned {@link VideoStream}'s `accessUnits()` then yields each frame as it
241
+ * arrives. Independent of `startVideoRecording`/`stopVideoRecording` — both, and any number of
242
+ * concurrent streams, can run on the same device at once.
243
+ *
244
+ * @param udid — UDID of the device to stream; must be booted
245
+ * @param options — `displayId`, `codec`, `fps`, `bitrate` — see {@link VideoStreamOptions}
246
+ */
247
+ export async function startVideoStream(
248
+ this: NativeSimctl,
249
+ udid: string,
250
+ options: VideoStreamOptions = {},
251
+ ): Promise<VideoStream> {
252
+ // The native poller clamps below 1 fps to 1 fps rather than actually polling that slowly, so a
253
+ // sub-1 value here would silently poll far more often than requested — rejected instead.
254
+ if (options.fps !== undefined && (!Number.isFinite(options.fps) || options.fps < 1)) {
255
+ throw new RangeError(`fps must be a finite number >= 1, got ${options.fps}`);
256
+ }
257
+ if (
258
+ options.bitrate !== undefined &&
259
+ (!Number.isFinite(options.bitrate) || options.bitrate <= 0 || options.bitrate > 2 ** 31 - 1)
260
+ ) {
261
+ throw new RangeError(`bitrate must be a positive number no greater than ${2 ** 31 - 1}, got ${options.bitrate}`);
262
+ }
263
+ const device = await runCatchingAsync(() => this._findDevice(udid));
264
+ const stream = new VideoStream(options.codec === 'hevc' ? 'hevc' : 'h264');
265
+ const handle = await runCatchingAsync(() =>
266
+ device.startVideoStream(
267
+ options,
268
+ (unit) => stream._handleAccessUnit(unit),
269
+ (err) => stream._handleError(err),
270
+ ),
271
+ );
272
+ stream._attachHandle(handle);
273
+ return stream;
274
+ }
package/src/coresim.mm CHANGED
@@ -10,13 +10,16 @@
10
10
 
11
11
  #include <napi.h>
12
12
 
13
+ #import <AVFoundation/AVFoundation.h>
13
14
  #import <Foundation/Foundation.h>
14
15
 
15
16
  #include <sys/wait.h>
16
17
  #include <unistd.h>
17
18
 
19
+ #include <algorithm>
18
20
  #include <cerrno>
19
21
  #include <cstring>
22
+ #include <mutex>
20
23
  #include <stdexcept>
21
24
  #include <string>
22
25
  #include <vector>
@@ -30,6 +33,8 @@
30
33
  #include "native/sim_process.h"
31
34
  #include "native/sim_screenshot.h"
32
35
  #include "native/sim_service_context.h"
36
+ #include "native/sim_video_recording.h"
37
+ #include "native/sim_video_stream.h"
33
38
  #include "native/tcc_privacy.h"
34
39
  #include "native/value_bridge.h"
35
40
 
@@ -95,11 +100,36 @@ NSError* MakeSpawnPathError(NSString* message) {
95
100
  return [NSError errorWithDomain:@"com.appium.coresim.spawn" code:1 userInfo:@{NSLocalizedDescriptionKey : message}];
96
101
  }
97
102
 
103
+ // Standard bin dirs to search, in order, when `path` is a bare command name (no `/`) — mirrors
104
+ // the guest's default $PATH. There's no way to query the guest's actual $PATH (no shell, no env
105
+ // to read before a process even exists), so this is a fixed best-effort list, not a real PATH
106
+ // search — a binary installed somewhere else won't be found this way.
107
+ NSArray<NSString*>* BareCommandSearchDirs() { return @[ @"usr/bin", @"bin", @"usr/sbin", @"sbin", @"usr/local/bin" ]; }
108
+
109
+ // Resolves a bare command name (e.g. "launchctl") against BareCommandSearchDirs() under
110
+ // `runtimeRoot`, mirroring how `simctl spawn` resolves a bare name against the guest's $PATH —
111
+ // CoreSimulator's own spawn API takes only a literal path, so it does no such resolution itself.
112
+ NSString* ResolveBareCommand(NSString* runtimeRoot, NSString* name, NSError** error) {
113
+ NSFileManager* fm = [NSFileManager defaultManager];
114
+ for (NSString* dir in BareCommandSearchDirs()) {
115
+ NSString* candidate = [runtimeRoot stringByAppendingPathComponent:[dir stringByAppendingPathComponent:name]];
116
+ BOOL isDirectory = NO;
117
+ if ([fm fileExistsAtPath:candidate isDirectory:&isDirectory] && !isDirectory &&
118
+ [fm isExecutableFileAtPath:candidate]) {
119
+ return candidate;
120
+ }
121
+ }
122
+ *error = MakeSpawnPathError(
123
+ [NSString stringWithFormat:@"'%@' not found in the Simulator runtime's standard bin directories", name]);
124
+ return nil;
125
+ }
126
+
98
127
  // `spawnWithPath:options:...` can run anything the host user can execute, so Spawn() confines it
99
128
  // to the Simulator's own runtime image rather than trusting `path` as a literal host path — a
100
- // deliberately breaking restriction (see CLAUDE.md). `path` is resolved as relative to the
101
- // runtime root, then re-verified (via -stringByStandardizingPath, which collapses ".."/".") to
102
- // still fall under it, since `path` may come from arbitrary caller input.
129
+ // deliberately breaking restriction (see CLAUDE.md). A bare name (no `/`) is resolved via
130
+ // ResolveBareCommand above instead; otherwise `path` is resolved as relative to the runtime root,
131
+ // then re-verified (via -stringByStandardizingPath, which collapses ".."/".") to still fall under
132
+ // it, since `path` may come from arbitrary caller input.
103
133
  NSString* ResolveRuntimeBinaryPath(id device, NSString* path, NSError** error) {
104
134
  id runtime = DeviceRuntime(device);
105
135
  if (runtime == nil) {
@@ -107,6 +137,9 @@ NSString* ResolveRuntimeBinaryPath(id device, NSString* path, NSError** error) {
107
137
  return nil;
108
138
  }
109
139
  NSString* runtimeRoot = coresim::RuntimeRootPath(runtime).stringByStandardizingPath;
140
+ if (![path containsString:@"/"]) {
141
+ return ResolveBareCommand(runtimeRoot, path, error);
142
+ }
110
143
  NSString* resolved = [runtimeRoot stringByAppendingPathComponent:path].stringByStandardizingPath;
111
144
  if (resolved != runtimeRoot && ![resolved hasPrefix:[runtimeRoot stringByAppendingString:@"/"]]) {
112
145
  *error = MakeSpawnPathError(
@@ -161,10 +194,104 @@ struct AddonInstanceData {
161
194
  Napi::FunctionReference deviceConstructor;
162
195
  Napi::FunctionReference deviceSetConstructor;
163
196
  Napi::FunctionReference serviceContextConstructor;
197
+ Napi::FunctionReference videoStreamConstructor;
198
+ // Every VideoStreamSession currently backing a live NativeVideoStream, so the env cleanup hook
199
+ // below can stop them (and thus release their ThreadSafeFunctions) before Node force-tears-down
200
+ // this Environment's own TSFNs — see that hook for why this ordering matters.
201
+ std::mutex activeVideoStreamsMutex;
202
+ std::vector<std::shared_ptr<coresim::VideoStreamSession>> activeVideoStreams;
164
203
  };
165
204
 
205
+ void RegisterActiveStream(Napi::Env env, const std::shared_ptr<coresim::VideoStreamSession>& session) {
206
+ auto* instanceData = env.GetInstanceData<AddonInstanceData>();
207
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
208
+ instanceData->activeVideoStreams.push_back(session);
209
+ }
210
+
211
+ void DeregisterActiveStream(Napi::Env env, const std::shared_ptr<coresim::VideoStreamSession>& session) {
212
+ auto* instanceData = env.GetInstanceData<AddonInstanceData>();
213
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
214
+ auto& streams = instanceData->activeVideoStreams;
215
+ streams.erase(std::remove(streams.begin(), streams.end(), session), streams.end());
216
+ }
217
+
218
+ // Stops every still-registered stream, blocking until each has fully torn down (including its own
219
+ // onEnd_ releasing its ThreadSafeFunctions) — see the env cleanup hook this backs, in Init below,
220
+ // for why this must run to completion before returning.
221
+ void StopAllActiveStreams(AddonInstanceData* instanceData) {
222
+ std::vector<std::shared_ptr<coresim::VideoStreamSession>> streams;
223
+ {
224
+ std::lock_guard<std::mutex> lock(instanceData->activeVideoStreamsMutex);
225
+ streams.swap(instanceData->activeVideoStreams);
226
+ }
227
+ for (auto& session : streams) {
228
+ session->Stop();
229
+ }
230
+ }
231
+
166
232
  } // namespace
167
233
 
234
+ // Wraps a live coresim::VideoStreamSession. Access units/errors are delivered live via the
235
+ // callbacks passed directly to startVideoStream, not through this object — it only exposes `stop()`.
236
+ class NativeVideoStream : public Napi::ObjectWrap<NativeVideoStream> {
237
+ public:
238
+ static void Init(Napi::Env env);
239
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session);
240
+ explicit NativeVideoStream(const Napi::CallbackInfo& info);
241
+
242
+ private:
243
+ std::shared_ptr<coresim::VideoStreamSession> session_;
244
+
245
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
246
+ Napi::Env env = info.Env();
247
+ auto session = session_;
248
+ return RunAsyncVoid(env, [session]() { session->Stop(); });
249
+ }
250
+
251
+ // Trivial in-memory flag set (see sim_video_stream.h) — no CoreSimulator dispatch, so kept
252
+ // synchronous like the other pure accessors in this file.
253
+ Napi::Value RequestKeyFrame(const Napi::CallbackInfo& info) {
254
+ if (session_) {
255
+ session_->RequestKeyFrame();
256
+ }
257
+ return info.Env().Undefined();
258
+ }
259
+
260
+ // Hands teardown off to a background queue instead of letting the default finalizer run
261
+ // ~VideoStreamSession()'s blocking Stop() synchronously on whatever thread GC runs on.
262
+ void Finalize(Napi::Env env) override {
263
+ auto session = std::move(session_);
264
+ if (session) {
265
+ DeregisterActiveStream(env, session);
266
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
267
+ session->Stop();
268
+ });
269
+ }
270
+ }
271
+ };
272
+
273
+ NativeVideoStream::NativeVideoStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeVideoStream>(info) {
274
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::VideoStreamSession>>>().Data();
275
+ session_ = *boxed;
276
+ RegisterActiveStream(info.Env(), session_);
277
+ }
278
+
279
+ void NativeVideoStream::Init(Napi::Env env) {
280
+ Napi::Function ctor = DefineClass(env, "NativeVideoStream",
281
+ {
282
+ InstanceMethod<&NativeVideoStream::Stop>("stop"),
283
+ InstanceMethod<&NativeVideoStream::RequestKeyFrame>("requestKeyFrame"),
284
+ });
285
+ env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor = Napi::Persistent(ctor);
286
+ }
287
+
288
+ Napi::Object NativeVideoStream::NewInstance(Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) {
289
+ auto* boxed = new std::shared_ptr<coresim::VideoStreamSession>(std::move(session));
290
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->videoStreamConstructor.Value();
291
+ return ctor.New({Napi::External<std::shared_ptr<coresim::VideoStreamSession>>::New(
292
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::VideoStreamSession>* data) { delete data; })});
293
+ }
294
+
168
295
  class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
169
296
  public:
170
297
  static void Init(Napi::Env env);
@@ -699,6 +826,173 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
699
826
  [](Napi::Env env, NSArray* result) -> Napi::Value { return NSObjectToJsValue(env, result); });
700
827
  }
701
828
 
829
+ // Mirrors `simctl io <udid> recordVideo` (see sim_video_recording.mm). Resolves once the first
830
+ // frame is recorded — see CLAUDE.md for the race hit by calling StopVideoRecording any earlier.
831
+ Napi::Value StartVideoRecording(const Napi::CallbackInfo& info) {
832
+ Napi::Env env = info.Env();
833
+ id device = device_;
834
+ NSString* outputFile = @(info[0].As<Napi::String>().Utf8Value().c_str());
835
+ NSString* displayId = nil;
836
+ coresim::VideoMaskPolicy mask = coresim::VideoMaskPolicy::kIgnored;
837
+ NSDictionary* assetWriterOutputSettings = @{};
838
+ if (info.Length() > 1 && info[1].IsObject()) {
839
+ Napi::Object options = info[1].As<Napi::Object>();
840
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
841
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
842
+ }
843
+ if (options.Has("mask") && options.Get("mask").IsString()) {
844
+ std::string maskValue = options.Get("mask").As<Napi::String>().Utf8Value();
845
+ if (maskValue == "alpha") {
846
+ mask = coresim::VideoMaskPolicy::kAlpha;
847
+ } else if (maskValue == "black") {
848
+ mask = coresim::VideoMaskPolicy::kBlack;
849
+ }
850
+ }
851
+ if (options.Has("codec") && options.Get("codec").IsString()) {
852
+ std::string codecValue = options.Get("codec").As<Napi::String>().Utf8Value();
853
+ AVVideoCodecType codecType = codecValue == "hevc" ? AVVideoCodecTypeHEVC : AVVideoCodecTypeH264;
854
+ assetWriterOutputSettings = @{AVVideoCodecKey : codecType};
855
+ }
856
+ }
857
+ return RunAsyncVoid(env, [device, displayId, mask, assetWriterOutputSettings, outputFile]() {
858
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
859
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.recordVideo", DISPATCH_QUEUE_SERIAL);
860
+ __block NSError* capturedError = nil;
861
+ NSError* resolveError = nil;
862
+ BOOL ok = coresim::StartVideoRecording(
863
+ device, displayId, mask, assetWriterOutputSettings, outputFile, queue,
864
+ ^(NSError* asyncError) {
865
+ capturedError = asyncError;
866
+ dispatch_semaphore_signal(sema);
867
+ },
868
+ &resolveError);
869
+ ThrowIfFailed(ok, resolveError);
870
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
871
+ if (capturedError != nil) {
872
+ throw NSErrorException(capturedError);
873
+ }
874
+ });
875
+ }
876
+
877
+ // Stops a recording started by StartVideoRecording above. Resolves once the video file has been
878
+ // finalized on disk and is safe to read.
879
+ Napi::Value StopVideoRecording(const Napi::CallbackInfo& info) {
880
+ Napi::Env env = info.Env();
881
+ id device = device_;
882
+ return RunAsyncVoid(env, [device]() {
883
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
884
+ dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.stopRecordVideo", DISPATCH_QUEUE_SERIAL);
885
+ __block NSError* capturedError = nil;
886
+ NSError* resolveError = nil;
887
+ BOOL ok = coresim::StopVideoRecording(
888
+ device, queue,
889
+ ^(NSError* asyncError) {
890
+ capturedError = asyncError;
891
+ dispatch_semaphore_signal(sema);
892
+ },
893
+ &resolveError);
894
+ ThrowIfFailed(ok, resolveError);
895
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
896
+ if (capturedError != nil) {
897
+ throw NSErrorException(capturedError);
898
+ }
899
+ });
900
+ }
901
+
902
+ // Real-time encoding via public VideoToolbox APIs (see sim_video_stream.mm) — no private API,
903
+ // no file. `onAccessUnit`/`onError` are invoked live for as long as the stream runs; the
904
+ // returned NativeVideoStream only exposes `stop()`.
905
+ Napi::Value StartVideoStream(const Napi::CallbackInfo& info) {
906
+ Napi::Env env = info.Env();
907
+ id device = device_;
908
+ NSString* displayId = nil;
909
+ coresim::VideoStreamCodec codec = coresim::VideoStreamCodec::kH264;
910
+ double fps = 15.0;
911
+ int bitrate = 2000000;
912
+ if (info.Length() > 0 && info[0].IsObject()) {
913
+ Napi::Object options = info[0].As<Napi::Object>();
914
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
915
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
916
+ }
917
+ if (options.Has("codec") && options.Get("codec").IsString() &&
918
+ options.Get("codec").As<Napi::String>().Utf8Value() == "hevc") {
919
+ codec = coresim::VideoStreamCodec::kHEVC;
920
+ }
921
+ if (options.Has("fps") && options.Get("fps").IsNumber()) {
922
+ fps = options.Get("fps").As<Napi::Number>().DoubleValue();
923
+ }
924
+ if (options.Has("bitrate") && options.Get("bitrate").IsNumber()) {
925
+ bitrate = options.Get("bitrate").As<Napi::Number>().Int32Value();
926
+ }
927
+ }
928
+ Napi::Function onAccessUnit = info[1].As<Napi::Function>();
929
+ Napi::Function onError = info[2].As<Napi::Function>();
930
+
931
+ // Must be constructed on the main thread, like Spawn's own exitTsfn above; released exactly
932
+ // once each, via onEnd (see sim_video_stream.h).
933
+ //
934
+ // accessUnitTsfn's queue is bounded, unlike every other one-shot-callback ThreadSafeFunction
935
+ // in this addon — a slow-draining consumer would otherwise let queued frame buffers grow
936
+ // unbounded; BlockingCall below naturally throttles the encoder once this fills instead.
937
+ static constexpr size_t kAccessUnitQueueSize = 60;
938
+ Napi::ThreadSafeFunction accessUnitTsfn =
939
+ Napi::ThreadSafeFunction::New(env, onAccessUnit, "coresim video stream access unit", kAccessUnitQueueSize, 1);
940
+ Napi::ThreadSafeFunction errorTsfn =
941
+ Napi::ThreadSafeFunction::New(env, onError, "coresim video stream error", 0, 1);
942
+
943
+ coresim::VideoStreamOptions options{codec, displayId, fps, bitrate};
944
+ return RunAsync<std::shared_ptr<coresim::VideoStreamSession>>(
945
+ env,
946
+ [device, options, accessUnitTsfn, errorTsfn]() mutable -> std::shared_ptr<coresim::VideoStreamSession> {
947
+ auto session = std::make_shared<coresim::VideoStreamSession>(
948
+ device, options,
949
+ [accessUnitTsfn](coresim::VideoAccessUnit unit) mutable {
950
+ accessUnitTsfn.BlockingCall([unit = std::move(unit)](Napi::Env env, Napi::Function jsCallback) mutable {
951
+ // The Environment can already be mid-teardown by the time a queued callback
952
+ // like this one actually runs (e.g. worker.terminate() while frames were still
953
+ // piling up) — node-addon-api's own WrapVoidCallback would otherwise re-throw
954
+ // whatever escapes here as a JS exception, which itself aborts the process on a
955
+ // torn-down env instead of just failing to deliver a frame nothing can receive
956
+ // anymore. See CLAUDE.md.
957
+ try {
958
+ Napi::Object obj = Napi::Object::New(env);
959
+ obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, unit.data.data(), unit.data.size()));
960
+ obj.Set("isKeyFrame", Napi::Boolean::New(env, unit.isKeyFrame));
961
+ obj.Set("sequence", Napi::Number::New(env, static_cast<double>(unit.sequence)));
962
+ obj.Set("timestampMicros", Napi::Number::New(env, static_cast<double>(unit.timestampMicros)));
963
+ jsCallback.Call({obj});
964
+ } catch (...) {
965
+ }
966
+ });
967
+ },
968
+ [errorTsfn](NSError* error) mutable {
969
+ NSErrorException exception(error);
970
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
971
+ // See accessUnitTsfn's callback above.
972
+ try {
973
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
974
+ } catch (...) {
975
+ }
976
+ });
977
+ },
978
+ [accessUnitTsfn, errorTsfn]() mutable {
979
+ accessUnitTsfn.Release();
980
+ errorTsfn.Release();
981
+ });
982
+ try {
983
+ session->Start();
984
+ } catch (...) {
985
+ accessUnitTsfn.Release();
986
+ errorTsfn.Release();
987
+ throw;
988
+ }
989
+ return session;
990
+ },
991
+ [](Napi::Env env, std::shared_ptr<coresim::VideoStreamSession> session) -> Napi::Value {
992
+ return NativeVideoStream::NewInstance(env, session);
993
+ });
994
+ }
995
+
702
996
  // Option dictionary keys for `spawnWithPath:options:...` aren't part of the ObjC runtime
703
997
  // metadata this addon resolves selectors from (they're string literals inside CoreSimulator's
704
998
  // own implementation) — confirmed by resolving each `SimDeviceSpawnKey*` symbol at runtime via
@@ -884,6 +1178,9 @@ void NativeDevice::Init(Napi::Env env) {
884
1178
  InstanceMethod<&NativeDevice::GetWebInspectorSocket>("getWebInspectorSocket"),
885
1179
  InstanceMethod<&NativeDevice::Screenshot>("screenshot"),
886
1180
  InstanceMethod<&NativeDevice::GetDisplays>("getDisplays"),
1181
+ InstanceMethod<&NativeDevice::StartVideoRecording>("startVideoRecording"),
1182
+ InstanceMethod<&NativeDevice::StopVideoRecording>("stopVideoRecording"),
1183
+ InstanceMethod<&NativeDevice::StartVideoStream>("startVideoStream"),
887
1184
  InstanceMethod<&NativeDevice::Spawn>("spawn"),
888
1185
  });
889
1186
  env.GetInstanceData<AddonInstanceData>()->deviceConstructor = Napi::Persistent(ctor);
@@ -1112,10 +1409,19 @@ Napi::Value FrameworkVersionBinding(const Napi::CallbackInfo& info) {
1112
1409
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
1113
1410
  // Runs once per Environment (see AddonInstanceData above) — never shared across a
1114
1411
  // worker_threads instance also `require()`-ing this addon.
1115
- env.SetInstanceData(new AddonInstanceData());
1412
+ auto* instanceData = new AddonInstanceData();
1413
+ env.SetInstanceData(instanceData);
1414
+ // Node force-releases any ThreadSafeFunctions still outstanding when an Environment (e.g. a
1415
+ // worker_threads Worker) tears down — racing our own release of the same TSFNs (fired
1416
+ // asynchronously from NativeVideoStream::Finalize, or never, if a running stream's JS wrapper
1417
+ // was never explicitly stopped) crashes the process. Cleanup hooks are guaranteed to run before
1418
+ // that automatic TSFN teardown, so stopping every active stream here — synchronously, blocking
1419
+ // until each has released its own TSFNs — establishes the ordering Node itself doesn't.
1420
+ env.AddCleanupHook(StopAllActiveStreams, instanceData);
1116
1421
  NativeDevice::Init(env);
1117
1422
  NativeDeviceSet::Init(env);
1118
1423
  NativeServiceContext::Init(env);
1424
+ NativeVideoStream::Init(env);
1119
1425
  exports.Set("sharedServiceContext", Napi::Function::New(env, SharedServiceContextBinding));
1120
1426
  exports.Set("frameworkVersion", Napi::Function::New(env, FrameworkVersionBinding));
1121
1427
  return exports;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {NativeSimError, NativeSimUnavailableError, NativeSimDispatchError, NativeSimOperationError} from './errors.js';
2
2
  export {NativeSimctl} from './native-simctl.js';
3
3
  export {SpawnedProcess} from './commands/spawn.js';
4
+ export {VideoStream} from './commands/video-stream.js';
4
5
  export type {AppContainerType} from './commands/app.js';
5
6
  export type {BiometricName} from './commands/biometric.js';
6
7
  export {
@@ -20,4 +21,7 @@ export {
20
21
  type SimProcessInfo,
21
22
  type SimRuntimeInfo,
22
23
  type SpawnOptions,
24
+ type VideoAccessUnit,
25
+ type VideoRecordingOptions,
26
+ type VideoStreamOptions,
23
27
  } from './types.js';