@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.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -3
- package/binding.gyp +2 -1
- package/lib/src/commands/jpeg-stream.d.ts +44 -0
- package/lib/src/commands/jpeg-stream.d.ts.map +1 -0
- package/lib/src/commands/jpeg-stream.js +198 -0
- package/lib/src/commands/jpeg-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 +2 -0
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +3 -0
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +55 -2
- package/lib/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/jpeg-stream.ts +233 -0
- package/src/coresim.mm +170 -6
- package/src/index.ts +3 -0
- package/src/native/audio_encoder.mm +1 -1
- package/src/native/av_recording.mm +1 -1
- package/src/native/sim_audio_tap.mm +3 -3
- package/src/native/sim_jpeg_stream.h +69 -0
- package/src/native/sim_jpeg_stream.mm +322 -0
- package/src/native/sim_pasteboard.mm +3 -3
- package/src/native/sim_process.mm +1 -1
- package/src/native/sim_screenshot.h +8 -0
- package/src/native/sim_screenshot.mm +29 -25
- package/src/native/sim_video_recording.mm +1 -1
- package/src/native/tcc_privacy.mm +1 -1
- package/src/native/video_encoder.h +2 -2
- package/src/native/video_encoder.mm +2 -2
- package/src/native-simctl.ts +3 -0
- package/src/types.ts +59 -2
|
@@ -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:@"
|
|
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("
|
|
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("
|
|
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 =
|
|
1052
|
-
int bitrate =
|
|
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("
|
|
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 = @"
|
|
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 = @"
|
|
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 = @"
|
|
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("
|
|
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:@"
|
|
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
|