@appium/coresim 1.7.0 → 1.8.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.
@@ -0,0 +1,233 @@
1
+ import {EventEmitter} from 'node:events';
2
+
3
+ import {logger} from '@appium/support';
4
+
5
+ import type {NativeSimctl} from '../native-simctl.js';
6
+ import type {JpegFrame, JpegStreamOptions, NativeJpegStreamHandle} from '../types.js';
7
+ import {runCatchingAsync, toTypedError} from '../utils/index.js';
8
+
9
+ declare module '../native-simctl.js' {
10
+ interface NativeSimctl {
11
+ startJpegStream(udid: string, options?: JpegStreamOptions): Promise<JpegStream>;
12
+ }
13
+ }
14
+
15
+ const log = logger.getLogger('CoreSim');
16
+
17
+ // Matches the native side's own ThreadSafeFunction queue bound (see coresim.mm's kFrameQueueSize).
18
+ const MAX_BUFFERED_FRAMES = 60;
19
+
20
+ const SINGLE_CONSUMER_ERROR =
21
+ 'JpegStream.frames() supports only one active consumer at a time — a second concurrent call rejects.';
22
+
23
+ /**
24
+ * Single-consumer FIFO between native's per-frame callback and `frames()`. Simpler than
25
+ * `AccessUnitQueue` (video-stream.ts): every JPEG frame is independently decodable, so there's no
26
+ * reference chain to protect — overflow just drops the oldest buffered frame(s), keeping delivery
27
+ * as close to real time as possible instead of falling further behind.
28
+ */
29
+ class JpegFrameQueue {
30
+ private readonly buffer: JpegFrame[] = [];
31
+ private waiter: {resolve: (result: IteratorResult<JpegFrame>) => void; reject: (err: unknown) => void} | undefined;
32
+ private ended = false;
33
+ private error: unknown;
34
+
35
+ push(frame: JpegFrame): void {
36
+ if (this.ended) {
37
+ return;
38
+ }
39
+ if (this.waiter) {
40
+ const {resolve} = this.waiter;
41
+ this.waiter = undefined;
42
+ resolve({value: frame, done: false});
43
+ return;
44
+ }
45
+ this.buffer.push(frame);
46
+ if (this.buffer.length > MAX_BUFFERED_FRAMES) {
47
+ this.buffer.shift();
48
+ }
49
+ }
50
+
51
+ /** Ends the queue with an error — any pending or future `next()` rejects with it. */
52
+ fail(err: unknown): void {
53
+ if (this.ended) {
54
+ return;
55
+ }
56
+ this.ended = true;
57
+ this.error = err;
58
+ this.buffer.length = 0;
59
+ if (this.waiter) {
60
+ const {reject} = this.waiter;
61
+ this.waiter = undefined;
62
+ reject(err);
63
+ }
64
+ }
65
+
66
+ /** Ends the queue cleanly — any pending or future `next()` resolves `done`. */
67
+ end(): void {
68
+ if (this.ended) {
69
+ return;
70
+ }
71
+ this.ended = true;
72
+ this.buffer.length = 0;
73
+ if (this.waiter) {
74
+ const {resolve} = this.waiter;
75
+ this.waiter = undefined;
76
+ resolve({value: undefined, done: true});
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolves `done` (not rejects) if `signal` aborts while waiting, mirroring `events.on()`.
82
+ * Checks cancellation/end *before* dequeuing, so an already-aborted signal or an already-ended
83
+ * queue never hands out a stale buffered frame.
84
+ */
85
+ next(signal: AbortSignal): Promise<IteratorResult<JpegFrame>> {
86
+ if (signal.aborted) {
87
+ return Promise.resolve({value: undefined, done: true});
88
+ }
89
+ if (this.ended) {
90
+ return this.error ? Promise.reject(this.error) : Promise.resolve({value: undefined, done: true});
91
+ }
92
+ const buffered = this.buffer.shift();
93
+ if (buffered !== undefined) {
94
+ return Promise.resolve({value: buffered, done: false});
95
+ }
96
+ return new Promise((resolve, reject) => {
97
+ const onAbort = () => {
98
+ this.waiter = undefined;
99
+ resolve({value: undefined, done: true});
100
+ };
101
+ signal.addEventListener('abort', onAbort, {once: true});
102
+ this.waiter = {
103
+ resolve: (result) => {
104
+ signal.removeEventListener('abort', onAbort);
105
+ resolve(result);
106
+ },
107
+ reject: (err) => {
108
+ signal.removeEventListener('abort', onAbort);
109
+ reject(err);
110
+ },
111
+ };
112
+ });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * A live JPEG frame stream from `NativeSimctl.startJpegStream` — polls the device's display and
118
+ * delivers each changed frame as a standalone JPEG image, at a configurable fps/quality. Unlike
119
+ * `startVideoStream`, this produces no video codec bitstream — it's meant for callers that want to
120
+ * build their own MJPEG (`multipart/x-mixed-replace`) HTTP stream, or otherwise just want a plain
121
+ * sequence of images, out of `frames()` themselves.
122
+ */
123
+ export class JpegStream extends EventEmitter {
124
+ private handle: NativeJpegStreamHandle | undefined;
125
+ private readonly stopController = new AbortController();
126
+ private stopPromise: Promise<void> | undefined;
127
+ private readonly queue = new JpegFrameQueue();
128
+ private activeConsumers = 0;
129
+
130
+ /** @internal */
131
+ _handleFrame(frame: JpegFrame): void {
132
+ this.queue.push(frame);
133
+ }
134
+
135
+ /**
136
+ * @internal
137
+ * An active `frames()` consumer receives the error via the queue itself (thrown out of its
138
+ * `for await` loop, per that method's contract) rather than the `'error'` event, so `emit` is
139
+ * only used for an explicit external listener; with neither, it's logged instead of lost.
140
+ */
141
+ _handleError(err: unknown): void {
142
+ const error = toTypedError(err);
143
+ this.queue.fail(error);
144
+ if (this.listenerCount('error') > 0) {
145
+ this.emit('error', error);
146
+ } else if (this.activeConsumers === 0) {
147
+ log.error(`Unhandled JpegStream error: ${error.stack ?? error}`);
148
+ }
149
+ }
150
+
151
+ /** @internal */
152
+ _attachHandle(handle: NativeJpegStreamHandle): void {
153
+ this.handle = handle;
154
+ }
155
+
156
+ /**
157
+ * Yields each JPEG frame as it's produced, until {@link stop} is called or the stream errors (in
158
+ * which case the error is thrown out of the loop). Pass `signal` to stop iterating without
159
+ * treating that as an error.
160
+ *
161
+ * Only one active consumer is supported at a time — a second concurrent call rejects rather than
162
+ * silently sharing (and corrupting) the first one's single internal waiter slot.
163
+ */
164
+ async *frames(signal?: AbortSignal): AsyncGenerator<JpegFrame> {
165
+ if (this.activeConsumers > 0) {
166
+ throw new Error(SINGLE_CONSUMER_ERROR);
167
+ }
168
+ const combined = signal ? AbortSignal.any([signal, this.stopController.signal]) : this.stopController.signal;
169
+ this.activeConsumers++;
170
+ try {
171
+ for (;;) {
172
+ const result = await this.queue.next(combined);
173
+ if (result.done) {
174
+ return;
175
+ }
176
+ yield result.value;
177
+ }
178
+ } finally {
179
+ this.activeConsumers--;
180
+ }
181
+ }
182
+
183
+ /** Stops the stream and releases the underlying encoder. Idempotent, including concurrently. */
184
+ async stop(): Promise<void> {
185
+ this.stopPromise ??= (async () => {
186
+ this.stopController.abort();
187
+ this.queue.end();
188
+ await this.handle?.stop();
189
+ })();
190
+ return this.stopPromise;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Starts polling the device's display and JPEG-encoding each changed frame in real time. Resolves
196
+ * once the encoder has actually started; the returned {@link JpegStream}'s `frames()` then yields
197
+ * each frame as it arrives. Independent of `startVideoStream`/`startVideoRecording` — any number of
198
+ * concurrent streams/recordings can run on the same device at once.
199
+ *
200
+ * @param udid — UDID of the device to stream; must be booted
201
+ * @param options — `displayId`, `fps`, `quality`, `scale` — see {@link JpegStreamOptions}
202
+ */
203
+ export async function startJpegStream(
204
+ this: NativeSimctl,
205
+ udid: string,
206
+ options: JpegStreamOptions = {},
207
+ ): Promise<JpegStream> {
208
+ // The native poller clamps below 1 fps to 1 fps rather than actually polling that slowly, so a
209
+ // sub-1 value here would silently poll far more often than requested — rejected instead.
210
+ if (options.fps !== undefined && (!Number.isFinite(options.fps) || options.fps < 1)) {
211
+ throw new RangeError(`fps must be a finite number >= 1, got ${options.fps}`);
212
+ }
213
+ if (
214
+ options.quality !== undefined &&
215
+ (!Number.isFinite(options.quality) || options.quality < 0 || options.quality > 100)
216
+ ) {
217
+ throw new RangeError(`quality must be a number between 0 and 100, got ${options.quality}`);
218
+ }
219
+ if (options.scale !== undefined && (!Number.isFinite(options.scale) || options.scale <= 0 || options.scale > 100)) {
220
+ throw new RangeError(`scale must be a number greater than 0 and no greater than 100, got ${options.scale}`);
221
+ }
222
+ const device = await runCatchingAsync(() => this._findDevice(udid));
223
+ const stream = new JpegStream();
224
+ const handle = await runCatchingAsync(() =>
225
+ device.startJpegStream(
226
+ options,
227
+ (frame) => stream._handleFrame(frame),
228
+ (err) => stream._handleError(err),
229
+ ),
230
+ );
231
+ stream._attachHandle(handle);
232
+ return stream;
233
+ }
package/src/coresim.mm CHANGED
@@ -31,6 +31,7 @@
31
31
  #include "native/objc_runtime.h"
32
32
  #include "native/sim_device.h"
33
33
  #include "native/sim_device_set.h"
34
+ #include "native/sim_jpeg_stream.h"
34
35
  #include "native/sim_pasteboard.h"
35
36
  #include "native/sim_process.h"
36
37
  #include "native/sim_screenshot.h"
@@ -99,7 +100,7 @@ NSError* MakeDescriptorError(NSString* which, int savedErrno) {
99
100
  }
100
101
 
101
102
  NSError* MakeSpawnPathError(NSString* message) {
102
- return [NSError errorWithDomain:@"com.appium.coresim.spawn" code:1 userInfo:@{NSLocalizedDescriptionKey : message}];
103
+ return [NSError errorWithDomain:@"io.appium.coresim.spawn" code:1 userInfo:@{NSLocalizedDescriptionKey : message}];
103
104
  }
104
105
 
105
106
  // Standard bin dirs to search, in order, when `path` is a bare command name (no `/`) — mirrors
@@ -258,9 +259,11 @@ struct AddonInstanceData {
258
259
  Napi::FunctionReference avStreamConstructor;
259
260
  Napi::FunctionReference avRecordingConstructor;
260
261
  Napi::FunctionReference privateRecordingConstructor;
262
+ Napi::FunctionReference jpegStreamConstructor;
261
263
  ActiveSessionRegistry<coresim::VideoStreamSession> activeVideoStreams;
262
264
  ActiveSessionRegistry<coresim::AVStreamSession> activeAVStreams;
263
265
  ActiveSessionRegistry<coresim::AVRecordingSession> activeAVRecordings;
266
+ ActiveSessionRegistry<coresim::JpegStreamSession> activeJpegStreams;
264
267
  };
265
268
 
266
269
  } // namespace
@@ -326,6 +329,58 @@ Napi::Object NativeVideoStream::NewInstance(Napi::Env env, std::shared_ptr<cores
326
329
  env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::VideoStreamSession>* data) { delete data; })});
327
330
  }
328
331
 
332
+ // Wraps a live coresim::JpegStreamSession — same shape as NativeVideoStream minus
333
+ // `requestKeyFrame()` (meaningless here: every JPEG frame is already independently decodable, see
334
+ // sim_jpeg_stream.h). Frames/errors are delivered live via the callbacks passed directly to
335
+ // startJpegStream, not through this object — it only exposes `stop()`.
336
+ class NativeJpegStream : public Napi::ObjectWrap<NativeJpegStream> {
337
+ public:
338
+ static void Init(Napi::Env env);
339
+ static Napi::Object NewInstance(Napi::Env env, std::shared_ptr<coresim::JpegStreamSession> session);
340
+ explicit NativeJpegStream(const Napi::CallbackInfo& info);
341
+
342
+ private:
343
+ std::shared_ptr<coresim::JpegStreamSession> session_;
344
+
345
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
346
+ Napi::Env env = info.Env();
347
+ auto session = session_;
348
+ return RunAsyncVoid(env, [session]() { session->Stop(); });
349
+ }
350
+
351
+ // See NativeVideoStream::Finalize for why this hands off to a background queue.
352
+ void Finalize(Napi::Env env) override {
353
+ auto session = std::move(session_);
354
+ if (session) {
355
+ env.GetInstanceData<AddonInstanceData>()->activeJpegStreams.Deregister(session);
356
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
357
+ session->Stop();
358
+ });
359
+ }
360
+ }
361
+ };
362
+
363
+ NativeJpegStream::NativeJpegStream(const Napi::CallbackInfo& info) : Napi::ObjectWrap<NativeJpegStream>(info) {
364
+ auto* boxed = info[0].As<Napi::External<std::shared_ptr<coresim::JpegStreamSession>>>().Data();
365
+ session_ = *boxed;
366
+ info.Env().GetInstanceData<AddonInstanceData>()->activeJpegStreams.Register(session_);
367
+ }
368
+
369
+ void NativeJpegStream::Init(Napi::Env env) {
370
+ Napi::Function ctor = DefineClass(env, "NativeJpegStream",
371
+ {
372
+ InstanceMethod<&NativeJpegStream::Stop>("stop"),
373
+ });
374
+ env.GetInstanceData<AddonInstanceData>()->jpegStreamConstructor = Napi::Persistent(ctor);
375
+ }
376
+
377
+ Napi::Object NativeJpegStream::NewInstance(Napi::Env env, std::shared_ptr<coresim::JpegStreamSession> session) {
378
+ auto* boxed = new std::shared_ptr<coresim::JpegStreamSession>(std::move(session));
379
+ Napi::Function ctor = env.GetInstanceData<AddonInstanceData>()->jpegStreamConstructor.Value();
380
+ return ctor.New({Napi::External<std::shared_ptr<coresim::JpegStreamSession>>::New(
381
+ env, boxed, [](Napi::Env /*env*/, std::shared_ptr<coresim::JpegStreamSession>* data) { delete data; })});
382
+ }
383
+
329
384
  // Wraps a live coresim::AVStreamSession — the combined-AV counterpart to NativeVideoStream above,
330
385
  // same shape (only `stop()`/`requestKeyFrame()`; access units/errors are delivered live via the
331
386
  // callbacks passed directly to startAVStream).
@@ -473,7 +528,7 @@ class NativePrivateRecordingHandle : public Napi::ObjectWrap<NativePrivateRecord
473
528
  id device = device_;
474
529
  return RunAsyncVoid(env, [device]() {
475
530
  dispatch_semaphore_t sema = dispatch_semaphore_create(0);
476
- dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.stopRecordVideo", DISPATCH_QUEUE_SERIAL);
531
+ dispatch_queue_t queue = dispatch_queue_create("io.appium.coresim.stopRecordVideo", DISPATCH_QUEUE_SERIAL);
477
532
  __block NSError* capturedError = nil;
478
533
  NSError* resolveError = nil;
479
534
  BOOL ok = coresim::StopVideoRecording(
@@ -573,7 +628,7 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
573
628
  NSDictionary* options = OptionsArg(info, 0);
574
629
  return RunAsyncVoid(env, [device, options]() {
575
630
  dispatch_semaphore_t sema = dispatch_semaphore_create(0);
576
- dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.boot", DISPATCH_QUEUE_SERIAL);
631
+ dispatch_queue_t queue = dispatch_queue_create("io.appium.coresim.boot", DISPATCH_QUEUE_SERIAL);
577
632
  __block NSError* capturedError = nil;
578
633
  BootAsync(device, options, queue, ^(NSError* error) {
579
634
  capturedError = error;
@@ -1048,8 +1103,8 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
1048
1103
  static coresim::VideoEncoderOptions ParseVideoEncoderOptions(const Napi::CallbackInfo& info, size_t argIndex) {
1049
1104
  NSString* displayId = nil;
1050
1105
  coresim::VideoStreamCodec codec = coresim::VideoStreamCodec::kH264;
1051
- double fps = 15.0;
1052
- int bitrate = 2000000;
1106
+ double fps = 60.0;
1107
+ int bitrate = 4000000;
1053
1108
  if (info.Length() > argIndex && info[argIndex].IsObject()) {
1054
1109
  Napi::Object options = info[argIndex].As<Napi::Object>();
1055
1110
  if (options.Has("displayId") && options.Get("displayId").IsString()) {
@@ -1069,6 +1124,37 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
1069
1124
  return coresim::VideoEncoderOptions{codec, displayId, fps, bitrate};
1070
1125
  }
1071
1126
 
1127
+ // `quality`/`displayId`/`fps` are this addon's own options, not a CoreSimulator options
1128
+ // dictionary — the TS layer (commands/jpeg-stream.ts) already constrains them, so anything else
1129
+ // (including absent) is just defaulted here rather than validated again (mirrors Screenshot's
1130
+ // identical comment above).
1131
+ static coresim::JpegStreamOptions ParseJpegStreamOptions(const Napi::CallbackInfo& info, size_t argIndex) {
1132
+ NSString* displayId = nil;
1133
+ double fps = 60.0;
1134
+ // Defaults to 80, not ImageIO's own (near-lossless, much larger) default — see
1135
+ // JpegStreamOptions's own comment (sim_jpeg_stream.h) for why.
1136
+ NSNumber* jpegQualityPercent = @80;
1137
+ double scale = 1.0;
1138
+ if (info.Length() > argIndex && info[argIndex].IsObject()) {
1139
+ Napi::Object options = info[argIndex].As<Napi::Object>();
1140
+ if (options.Has("displayId") && options.Get("displayId").IsString()) {
1141
+ displayId = @(options.Get("displayId").As<Napi::String>().Utf8Value().c_str());
1142
+ }
1143
+ if (options.Has("fps") && options.Get("fps").IsNumber()) {
1144
+ fps = options.Get("fps").As<Napi::Number>().DoubleValue();
1145
+ }
1146
+ if (options.Has("quality") && options.Get("quality").IsNumber()) {
1147
+ jpegQualityPercent = @(options.Get("quality").As<Napi::Number>().DoubleValue());
1148
+ }
1149
+ // JS-facing `scale` is a 1-100 percent (commands/jpeg-stream.ts already validates the
1150
+ // range), converted here to the 0.0-1.0 fraction JpegStreamSession actually applies.
1151
+ if (options.Has("scale") && options.Get("scale").IsNumber()) {
1152
+ scale = options.Get("scale").As<Napi::Number>().DoubleValue() / 100.0;
1153
+ }
1154
+ }
1155
+ return coresim::JpegStreamOptions{displayId, fps, jpegQualityPercent, scale};
1156
+ }
1157
+
1072
1158
  static bool OptionsWantAudio(const Napi::CallbackInfo& info, size_t argIndex) {
1073
1159
  return info.Length() > argIndex && info[argIndex].IsObject() && info[argIndex].As<Napi::Object>().Has("audio") &&
1074
1160
  info[argIndex].As<Napi::Object>().Get("audio").IsBoolean() &&
@@ -1132,7 +1218,7 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
1132
1218
  env,
1133
1219
  [device, displayId, mask, assetWriterOutputSettings, outputFile]() -> id {
1134
1220
  dispatch_semaphore_t sema = dispatch_semaphore_create(0);
1135
- dispatch_queue_t queue = dispatch_queue_create("com.appium.coresim.recordVideo", DISPATCH_QUEUE_SERIAL);
1221
+ dispatch_queue_t queue = dispatch_queue_create("io.appium.coresim.recordVideo", DISPATCH_QUEUE_SERIAL);
1136
1222
  __block NSError* capturedError = nil;
1137
1223
  NSError* resolveError = nil;
1138
1224
  BOOL ok = coresim::StartVideoRecording(
@@ -1380,6 +1466,81 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
1380
1466
  });
1381
1467
  }
1382
1468
 
1469
+ // Real-time JPEG frame stream via ImageIO (see sim_jpeg_stream.mm) — a client-side MJPEG stream
1470
+ // is just this frame sequence multipart-boundary-framed over HTTP, which coresim itself has no
1471
+ // opinion about. Same TSFN/registry/teardown shape as StartVideoStream's audio-less branch,
1472
+ // minus keyframes/resync (every JPEG frame is independently decodable).
1473
+ Napi::Value StartJpegStream(const Napi::CallbackInfo& info) {
1474
+ Napi::Env env = info.Env();
1475
+ id device = device_;
1476
+ coresim::JpegStreamOptions options = ParseJpegStreamOptions(info, 0);
1477
+ Napi::Function onFrame = info[1].As<Napi::Function>();
1478
+ Napi::Function onError = info[2].As<Napi::Function>();
1479
+
1480
+ // See StartVideoStream's identical comment on accessUnitTsfn — bounded so a slow-draining
1481
+ // consumer throttles the encoder via BlockingCall instead of letting queued frames grow
1482
+ // unbounded.
1483
+ static constexpr size_t kFrameQueueSize = 60;
1484
+ Napi::ThreadSafeFunction frameTsfn =
1485
+ Napi::ThreadSafeFunction::New(env, onFrame, "coresim jpeg stream frame", kFrameQueueSize, 1);
1486
+ Napi::ThreadSafeFunction errorTsfn = Napi::ThreadSafeFunction::New(env, onError, "coresim jpeg stream error", 0, 1);
1487
+ auto tsfnGuard = std::make_shared<TsfnReleaseGuard>();
1488
+
1489
+ return RunAsync<std::shared_ptr<coresim::JpegStreamSession>>(
1490
+ env,
1491
+ [device, options, frameTsfn, errorTsfn, tsfnGuard]() mutable -> std::shared_ptr<coresim::JpegStreamSession> {
1492
+ auto session = std::make_shared<coresim::JpegStreamSession>(
1493
+ device, options,
1494
+ [frameTsfn](coresim::JpegFrame frame) mutable {
1495
+ frameTsfn.BlockingCall([frame = std::move(frame)](Napi::Env env, Napi::Function jsCallback) mutable {
1496
+ // See StartVideoStream's identical comment on why this is wrapped in try/catch.
1497
+ try {
1498
+ Napi::Object obj = Napi::Object::New(env);
1499
+ obj.Set("data", Napi::Buffer<uint8_t>::Copy(env, frame.data.data(), frame.data.size()));
1500
+ obj.Set("sequence", Napi::Number::New(env, static_cast<double>(frame.sequence)));
1501
+ obj.Set("timestampMicros", Napi::Number::New(env, static_cast<double>(frame.timestampMicros)));
1502
+ jsCallback.Call({obj});
1503
+ } catch (...) {
1504
+ }
1505
+ });
1506
+ },
1507
+ [errorTsfn](NSError* error) mutable {
1508
+ NSErrorException exception(error);
1509
+ errorTsfn.BlockingCall([exception](Napi::Env env, Napi::Function jsCallback) {
1510
+ try {
1511
+ jsCallback.Call({NSErrorExceptionToJsError(env, exception).Value()});
1512
+ } catch (...) {
1513
+ }
1514
+ });
1515
+ },
1516
+ [frameTsfn, errorTsfn, tsfnGuard]() mutable {
1517
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1518
+ frameTsfn.Release();
1519
+ errorTsfn.Release();
1520
+ });
1521
+ },
1522
+ [frameTsfn, errorTsfn, tsfnGuard]() mutable {
1523
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1524
+ frameTsfn.Abort();
1525
+ errorTsfn.Abort();
1526
+ });
1527
+ });
1528
+ try {
1529
+ session->Start();
1530
+ } catch (...) {
1531
+ ReleaseTsfnOnce(tsfnGuard, [&]() mutable {
1532
+ frameTsfn.Release();
1533
+ errorTsfn.Release();
1534
+ });
1535
+ throw;
1536
+ }
1537
+ return session;
1538
+ },
1539
+ [](Napi::Env env, std::shared_ptr<coresim::JpegStreamSession> session) -> Napi::Value {
1540
+ return NativeJpegStream::NewInstance(env, session);
1541
+ });
1542
+ }
1543
+
1383
1544
  // Option dictionary keys for `spawnWithPath:options:...` aren't part of the ObjC runtime
1384
1545
  // metadata this addon resolves selectors from (they're string literals inside CoreSimulator's
1385
1546
  // own implementation) — confirmed by resolving each `SimDeviceSpawnKey*` symbol at runtime via
@@ -1567,6 +1728,7 @@ void NativeDevice::Init(Napi::Env env) {
1567
1728
  InstanceMethod<&NativeDevice::GetDisplays>("getDisplays"),
1568
1729
  InstanceMethod<&NativeDevice::StartVideoRecording>("startVideoRecording"),
1569
1730
  InstanceMethod<&NativeDevice::StartVideoStream>("startVideoStream"),
1731
+ InstanceMethod<&NativeDevice::StartJpegStream>("startJpegStream"),
1570
1732
  InstanceMethod<&NativeDevice::Spawn>("spawn"),
1571
1733
  });
1572
1734
  env.GetInstanceData<AddonInstanceData>()->deviceConstructor = Napi::Persistent(ctor);
@@ -1818,6 +1980,7 @@ void CleanupActiveSessions(AddonInstanceData* instanceData) {
1818
1980
  };
1819
1981
  instanceData->activeVideoStreams.StopAll(stopStream);
1820
1982
  instanceData->activeAVStreams.StopAll(stopStream);
1983
+ instanceData->activeJpegStreams.StopAll(stopStream);
1821
1984
  instanceData->activeAVRecordings.StopAll([](auto& session) {
1822
1985
  dispatch_semaphore_t sema = dispatch_semaphore_create(0);
1823
1986
  session->Stop([sema](NSError*) { dispatch_semaphore_signal(sema); });
@@ -1850,6 +2013,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
1850
2013
  NativeAVStream::Init(env);
1851
2014
  NativeAVRecording::Init(env);
1852
2015
  NativePrivateRecordingHandle::Init(env);
2016
+ NativeJpegStream::Init(env);
1853
2017
  exports.Set("sharedServiceContext", Napi::Function::New(env, SharedServiceContextBinding));
1854
2018
  exports.Set("frameworkVersion", Napi::Function::New(env, FrameworkVersionBinding));
1855
2019
  exports.Set("flushActiveSessions", Napi::Function::New(env, FlushActiveSessionsBinding));
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export {NativeSimError, NativeSimUnavailableError, NativeSimDispatchError, Nativ
2
2
  export {NativeSimctl} from './native-simctl.js';
3
3
  export {SpawnedProcess} from './commands/spawn.js';
4
4
  export {VideoStream} from './commands/video-stream.js';
5
+ export {JpegStream} from './commands/jpeg-stream.js';
5
6
  export type {AppContainerType} from './commands/app.js';
6
7
  export type {BiometricName} from './commands/biometric.js';
7
8
  export {
@@ -10,6 +11,8 @@ export {
10
11
  type ApnsAlert,
11
12
  type ApnsPayload,
12
13
  type ApnsSound,
14
+ type JpegFrame,
15
+ type JpegStreamOptions,
13
16
  type PushNotificationPayload,
14
17
  type ScreenshotOptions,
15
18
  type SimBootInfo,
@@ -12,7 +12,7 @@ namespace coresim {
12
12
 
13
13
  namespace {
14
14
 
15
- NSString* const kAudioEncoderErrorDomain = @"com.appium.coresim.AudioEncoder";
15
+ NSString* const kAudioEncoderErrorDomain = @"io.appium.coresim.AudioEncoder";
16
16
 
17
17
  NSError* MakeError(NSInteger code, NSString* message) {
18
18
  return [NSError errorWithDomain:kAudioEncoderErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
@@ -13,7 +13,7 @@ namespace coresim {
13
13
 
14
14
  namespace {
15
15
 
16
- NSString* const kAVRecordingErrorDomain = @"com.appium.coresim.AVRecording";
16
+ NSString* const kAVRecordingErrorDomain = @"io.appium.coresim.AVRecording";
17
17
 
18
18
  NSError* MakeError(NSInteger code, NSString* message) {
19
19
  return [NSError errorWithDomain:kAVRecordingErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
@@ -11,7 +11,7 @@
11
11
 
12
12
  namespace coresim {
13
13
 
14
- NSString* const kAudioTapErrorDomain = @"com.appium.coresim.AudioTap";
14
+ NSString* const kAudioTapErrorDomain = @"io.appium.coresim.AudioTap";
15
15
 
16
16
  namespace {
17
17
 
@@ -66,7 +66,7 @@ class AudioTapSession::Impl {
66
66
  Impl(NSString* udid, std::function<void(const AudioBufferList*, const AudioTimeStamp*)> onBuffer,
67
67
  std::function<void(NSError*)> onError, std::function<void()> onEnd)
68
68
  : udid_(udid), onBuffer_(std::move(onBuffer)), onError_(std::move(onError)), onEnd_(std::move(onEnd)) {
69
- queue_ = dispatch_queue_create("com.appium.coresim.audioTap", DISPATCH_QUEUE_SERIAL);
69
+ queue_ = dispatch_queue_create("io.appium.coresim.audioTap", DISPATCH_QUEUE_SERIAL);
70
70
  }
71
71
 
72
72
  ~Impl() { Stop(); }
@@ -154,7 +154,7 @@ class AudioTapSession::Impl {
154
154
  NSDictionary* aggregateDescription = @{
155
155
  @(kAudioAggregateDeviceNameKey) : [NSString stringWithFormat:@"coresim-audio-%@", udid_],
156
156
  @(kAudioAggregateDeviceUIDKey) :
157
- [NSString stringWithFormat:@"com.appium.coresim.audio.%@.%@", udid_, uuid.UUIDString],
157
+ [NSString stringWithFormat:@"io.appium.coresim.audio.%@.%@", udid_, uuid.UUIDString],
158
158
  @(kAudioAggregateDeviceIsPrivateKey) : @YES,
159
159
  @(kAudioAggregateDeviceTapAutoStartKey) : @NO,
160
160
  @(kAudioAggregateDeviceTapListKey) : @[ @{
@@ -0,0 +1,69 @@
1
+ #pragma once
2
+
3
+ #import <Foundation/Foundation.h>
4
+
5
+ #include <cstdint>
6
+ #include <functional>
7
+ #include <memory>
8
+ #include <vector>
9
+
10
+ namespace coresim {
11
+
12
+ struct JpegStreamOptions {
13
+ NSString* displayId = nil;
14
+ double fps = 60.0;
15
+ // 0-100 percent; same semantics as CaptureScreenshot's jpegQualityPercent (sim_screenshot.h),
16
+ // nil-able for ImageIO's own default (near-lossless, so a much larger frame) — but defaulted to
17
+ // 80 by coresim.mm rather than left nil, since a continuous live stream should default to
18
+ // noticeably smaller frames, unlike a one-off CaptureScreenshot call.
19
+ NSNumber* jpegQualityPercent = @80;
20
+ // 0.0-1.0 fraction of the original frame's width/height; 1.0 (default) performs no scaling.
21
+ // Already normalized from the JS-facing 1-100 percent option by coresim.mm.
22
+ double scale = 1.0;
23
+ };
24
+
25
+ // One JPEG-encoded frame. Unlike VideoAccessUnit, every frame is independently decodable — there's
26
+ // no keyframe/interframe distinction, so no resync semantics are needed on the consuming side.
27
+ struct JpegFrame {
28
+ std::vector<uint8_t> data;
29
+ uint64_t sequence = 0;
30
+ // Microseconds since the stream started.
31
+ int64_t timestampMicros = 0;
32
+ };
33
+
34
+ // Polls the live display IOSurface (sim_screenshot.h) on a serial queue and JPEG-encodes changed
35
+ // frames via ImageIO, delivering each live — see CLAUDE.md for how this relates to
36
+ // VideoStreamSession and CaptureScreenshot.
37
+ class JpegStreamSession {
38
+ public:
39
+ // `onAbortDelivery`, if set, is invoked by AbortDelivery() below — not called by this class on
40
+ // its own.
41
+ JpegStreamSession(id device, JpegStreamOptions options, std::function<void(JpegFrame)> onFrame,
42
+ std::function<void(NSError*)> onError, std::function<void()> onEnd,
43
+ std::function<void()> onAbortDelivery = nullptr);
44
+ ~JpegStreamSession();
45
+
46
+ JpegStreamSession(const JpegStreamSession&) = delete;
47
+ JpegStreamSession& operator=(const JpegStreamSession&) = delete;
48
+
49
+ // Resolves the display and starts the polling loop; throws synchronously on resolution/setup
50
+ // failure (`onEnd` never called then). Later failures go to `onError`, then `onEnd`.
51
+ void Start();
52
+
53
+ // Idempotent; blocks until the loop has fully stopped. Never call from inside onFrame/onError/
54
+ // onEnd — same queue this blocks on, so it would deadlock.
55
+ void Stop();
56
+
57
+ // Runs the constructor's `onAbortDelivery` (if any) — e.g. aborting a bounded delivery queue so
58
+ // a producer thread blocked pushing into it unblocks. Call before Stop() when the caller can't
59
+ // rely on anything else draining that queue concurrently (e.g. process-exit cleanup running
60
+ // synchronously on the same thread `Stop()` would otherwise wait on) — a normal Stop() doesn't
61
+ // need this. Safe from any thread; a no-op if unset.
62
+ void AbortDelivery();
63
+
64
+ private:
65
+ class Impl;
66
+ std::unique_ptr<Impl> impl_;
67
+ };
68
+
69
+ } // namespace coresim