@appium/coresim 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -0
  3. package/binding.gyp +14 -1
  4. package/lib/src/commands/video-recording.d.ts +57 -0
  5. package/lib/src/commands/video-recording.d.ts.map +1 -0
  6. package/lib/src/commands/video-recording.js +123 -0
  7. package/lib/src/commands/video-recording.js.map +1 -0
  8. package/lib/src/commands/video-stream.d.ts +92 -0
  9. package/lib/src/commands/video-stream.d.ts.map +1 -0
  10. package/lib/src/commands/video-stream.js +242 -0
  11. package/lib/src/commands/video-stream.js.map +1 -0
  12. package/lib/src/index.d.ts +2 -1
  13. package/lib/src/index.d.ts.map +1 -1
  14. package/lib/src/index.js +1 -0
  15. package/lib/src/index.js.map +1 -1
  16. package/lib/src/native-simctl.d.ts +4 -0
  17. package/lib/src/native-simctl.d.ts.map +1 -1
  18. package/lib/src/native-simctl.js +21 -1
  19. package/lib/src/native-simctl.js.map +1 -1
  20. package/lib/src/types.d.ts +134 -0
  21. package/lib/src/types.d.ts.map +1 -1
  22. package/lib/src/utils/index.d.ts +1 -1
  23. package/lib/src/utils/index.d.ts.map +1 -1
  24. package/lib/src/utils/index.js +1 -1
  25. package/lib/src/utils/index.js.map +1 -1
  26. package/lib/src/utils/run-catching.d.ts +4 -0
  27. package/lib/src/utils/run-catching.d.ts.map +1 -1
  28. package/lib/src/utils/run-catching.js +11 -0
  29. package/lib/src/utils/run-catching.js.map +1 -1
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  32. package/src/commands/video-recording.ts +158 -0
  33. package/src/commands/video-stream.ts +283 -0
  34. package/src/coresim.mm +708 -1
  35. package/src/index.ts +4 -0
  36. package/src/native/audio_encoder.h +65 -0
  37. package/src/native/audio_encoder.mm +270 -0
  38. package/src/native/av_recording.h +52 -0
  39. package/src/native/av_recording.mm +424 -0
  40. package/src/native/av_stream.h +70 -0
  41. package/src/native/av_stream.mm +217 -0
  42. package/src/native/monotonic_clock.h +20 -0
  43. package/src/native/sim_audio_tap.h +67 -0
  44. package/src/native/sim_audio_tap.mm +350 -0
  45. package/src/native/sim_process.h +10 -0
  46. package/src/native/sim_process.mm +81 -18
  47. package/src/native/sim_screenshot.h +10 -0
  48. package/src/native/sim_screenshot.mm +11 -6
  49. package/src/native/sim_video_recording.h +27 -0
  50. package/src/native/sim_video_recording.mm +87 -0
  51. package/src/native/sim_video_stream.h +64 -0
  52. package/src/native/sim_video_stream.mm +71 -0
  53. package/src/native/video_encoder.h +76 -0
  54. package/src/native/video_encoder.mm +417 -0
  55. package/src/native-simctl.ts +22 -1
  56. package/src/types.ts +149 -0
  57. package/src/utils/index.ts +1 -1
  58. package/src/utils/run-catching.ts +11 -0
@@ -0,0 +1,283 @@
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 {NativeVideoStreamHandle, VideoAccessUnit, VideoStreamOptions} from '../types.js';
7
+ import {runCatchingAsync, toTypedError} from '../utils/index.js';
8
+
9
+ declare module '../native-simctl.js' {
10
+ interface NativeSimctl {
11
+ startVideoStream(udid: string, options?: VideoStreamOptions): Promise<VideoStream>;
12
+ }
13
+ }
14
+
15
+ const log = logger.getLogger('CoreSim');
16
+
17
+ // Matches the native side's own ThreadSafeFunction queue bound (see coresim.mm's
18
+ // kAccessUnitQueueSize) — kept here too since the native bound alone provides no real
19
+ // backpressure: _handleAccessUnit's emit-equivalent push always returns immediately, regardless of
20
+ // how slow the actual accessUnits() consumer is, so without a bound of its own this queue could
21
+ // otherwise grow without limit while a slow consumer falls behind.
22
+ export const MAX_BUFFERED_UNITS = 60;
23
+
24
+ const SINGLE_CONSUMER_ERROR =
25
+ 'VideoStream.accessUnits() supports only one active consumer at a time — a second concurrent call rejects.';
26
+
27
+ /**
28
+ * Single-consumer FIFO between native's per-frame callback and `accessUnits()`. Unlike routing
29
+ * through `EventEmitter`, a unit pushed before any consumer has started iterating is retained
30
+ * (fixing the encoder's own first-frame/keyframe otherwise being lost to a startup race) rather
31
+ * than silently dropped.
32
+ *
33
+ * Bounded at `MAX_BUFFERED_UNITS`, but resync-aware about *how* it sheds load once a slow consumer
34
+ * falls behind: since interframes only reference earlier frames they were encoded against (no
35
+ * `AllowFrameReordering`), dropping an arbitrary one would orphan every later one from its
36
+ * reference chain, corrupting decode from that point on even though delivery looks unbroken.
37
+ * Overflow instead clears the backlog entirely and enters a resync state — `dropsDuringResync`
38
+ * decides which further units to discard (not buffer) until `endsResync` sees a self-decodable
39
+ * point to resume clean delivery from. Generic (not hardcoded to video's own reference-chain
40
+ * concern) so `VideoStream` can reuse this same machinery when it's carrying interleaved audio
41
+ * too (`VideoStreamOptions.audio`) — an audio unit is always independently decodable, so it's
42
+ * configured to never be dropped and never itself end a resync; only video interframes are.
43
+ */
44
+ export class AccessUnitQueue<T> {
45
+ private readonly buffer: T[] = [];
46
+ private waiter: {resolve: (result: IteratorResult<T>) => void; reject: (err: unknown) => void} | undefined;
47
+ private ended = false;
48
+ private error: unknown;
49
+ private resyncing = false;
50
+
51
+ constructor(
52
+ private readonly opts: {
53
+ /** Whether `unit` should be discarded (not buffered) while resyncing. */
54
+ dropsDuringResync: (unit: T) => boolean;
55
+ /** Whether `unit` is a self-decodable point resync can resume clean delivery from. */
56
+ endsResync: (unit: T) => boolean;
57
+ /** Called once when overflow first forces a resync, e.g. to request a fresh keyframe. */
58
+ onOverflow?: () => void;
59
+ },
60
+ ) {}
61
+
62
+ push(unit: T): void {
63
+ if (this.ended) {
64
+ return;
65
+ }
66
+ if (this.resyncing) {
67
+ if (this.opts.dropsDuringResync(unit)) {
68
+ return; // still waiting for a self-decodable point to resume delivery from
69
+ }
70
+ if (this.opts.endsResync(unit)) {
71
+ this.resyncing = false;
72
+ }
73
+ }
74
+ if (this.waiter) {
75
+ const {resolve} = this.waiter;
76
+ this.waiter = undefined;
77
+ resolve({value: unit, done: false});
78
+ return;
79
+ }
80
+ this.buffer.push(unit);
81
+ if (this.buffer.length > MAX_BUFFERED_UNITS) {
82
+ this.buffer.length = 0;
83
+ this.resyncing = true;
84
+ this.opts.onOverflow?.();
85
+ }
86
+ }
87
+
88
+ /** Ends the queue with an error — any pending or future `next()` rejects with it. */
89
+ fail(err: unknown): void {
90
+ if (this.ended) {
91
+ return;
92
+ }
93
+ this.ended = true;
94
+ this.error = err;
95
+ this.buffer.length = 0;
96
+ if (this.waiter) {
97
+ const {reject} = this.waiter;
98
+ this.waiter = undefined;
99
+ reject(err);
100
+ }
101
+ }
102
+
103
+ /** Ends the queue cleanly — any pending or future `next()` resolves `done`. */
104
+ end(): void {
105
+ if (this.ended) {
106
+ return;
107
+ }
108
+ this.ended = true;
109
+ this.buffer.length = 0;
110
+ if (this.waiter) {
111
+ const {resolve} = this.waiter;
112
+ this.waiter = undefined;
113
+ resolve({value: undefined, done: true});
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolves `done` (not rejects) if `signal` aborts while waiting, mirroring `events.on()`.
119
+ * Checks cancellation/end *before* dequeuing, so an already-aborted signal or an already-ended
120
+ * queue never hands out a stale buffered unit — an aborted consumer, or a fresh iterator started
121
+ * after `stop()`, must see the boundary immediately rather than draining leftovers first.
122
+ */
123
+ next(signal: AbortSignal): Promise<IteratorResult<T>> {
124
+ if (signal.aborted) {
125
+ return Promise.resolve({value: undefined, done: true});
126
+ }
127
+ if (this.ended) {
128
+ return this.error ? Promise.reject(this.error) : Promise.resolve({value: undefined, done: true});
129
+ }
130
+ const buffered = this.buffer.shift();
131
+ if (buffered !== undefined) {
132
+ return Promise.resolve({value: buffered, done: false});
133
+ }
134
+ return new Promise((resolve, reject) => {
135
+ const onAbort = () => {
136
+ this.waiter = undefined;
137
+ resolve({value: undefined, done: true});
138
+ };
139
+ signal.addEventListener('abort', onAbort, {once: true});
140
+ this.waiter = {
141
+ resolve: (result) => {
142
+ signal.removeEventListener('abort', onAbort);
143
+ resolve(result);
144
+ },
145
+ reject: (err) => {
146
+ signal.removeEventListener('abort', onAbort);
147
+ reject(err);
148
+ },
149
+ };
150
+ });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A live video stream from `NativeSimctl.startVideoStream` — encodes the device's display (and,
156
+ * with `options.audio`, its audio too — see {@link VideoStreamOptions}) in real time via
157
+ * VideoToolbox/Core Audio, unlike `startVideoRecording`, which drives CoreSimulator's own private,
158
+ * file-only recorder. Mirrors `appium-ios-remotexpc`'s `ScreenStreamCapture` shape
159
+ * (`accessUnits()`/`stop()`) for API consistency; the transport is otherwise unrelated.
160
+ */
161
+ export class VideoStream extends EventEmitter {
162
+ private handle: NativeVideoStreamHandle | undefined;
163
+ private readonly stopController = new AbortController();
164
+ private stopPromise: Promise<void> | undefined;
165
+ // Requests a fresh keyframe on resync so delivery can resume immediately rather than waiting
166
+ // for the next periodic one — `handle` may not be attached yet on a startup-time overflow
167
+ // (vanishingly unlikely given MAX_BUFFERED_UNITS), in which case this is just a no-op. Only a
168
+ // video keyframe ends/is exempt from resync — an audio unit (when present) is always
169
+ // independently decodable, so it's never dropped and never itself ends a resync.
170
+ private readonly queue = new AccessUnitQueue<VideoAccessUnit>({
171
+ dropsDuringResync: (unit) => unit.track === 'video' && !unit.isKeyFrame,
172
+ endsResync: (unit) => unit.track === 'video' && unit.isKeyFrame,
173
+ onOverflow: () => this.handle?.requestKeyFrame(),
174
+ });
175
+ private activeConsumers = 0;
176
+
177
+ /** @internal */
178
+ constructor(public readonly codec: 'h264' | 'hevc') {
179
+ super();
180
+ }
181
+
182
+ /** @internal */
183
+ _handleAccessUnit(unit: VideoAccessUnit): void {
184
+ this.queue.push(unit);
185
+ }
186
+
187
+ /**
188
+ * @internal
189
+ * An active `accessUnits()` consumer receives the error via the queue itself (thrown out of its
190
+ * `for await` loop, per that method's contract) rather than the `'error'` event, so `emit` is
191
+ * only used for an explicit external listener; with neither, it's logged instead of lost.
192
+ */
193
+ _handleError(err: unknown): void {
194
+ const error = toTypedError(err);
195
+ this.queue.fail(error);
196
+ if (this.listenerCount('error') > 0) {
197
+ this.emit('error', error);
198
+ } else if (this.activeConsumers === 0) {
199
+ log.error(`Unhandled VideoStream error: ${error.stack ?? error}`);
200
+ }
201
+ }
202
+
203
+ /** @internal */
204
+ _attachHandle(handle: NativeVideoStreamHandle): void {
205
+ this.handle = handle;
206
+ }
207
+
208
+ /**
209
+ * Yields each encoded access unit as it's produced, until {@link stop} is called or the stream
210
+ * errors (in which case the error is thrown out of the loop). Pass `signal` to stop iterating
211
+ * without treating that as an error. Mirrors `ScreenStreamCapture.accessUnits()`'s shape.
212
+ *
213
+ * Only one active consumer is supported at a time — a second concurrent call rejects rather
214
+ * than silently sharing (and corrupting) the first one's single internal waiter slot.
215
+ */
216
+ async *accessUnits(signal?: AbortSignal): AsyncGenerator<VideoAccessUnit> {
217
+ if (this.activeConsumers > 0) {
218
+ throw new Error(SINGLE_CONSUMER_ERROR);
219
+ }
220
+ const combined = signal ? AbortSignal.any([signal, this.stopController.signal]) : this.stopController.signal;
221
+ this.activeConsumers++;
222
+ try {
223
+ for (;;) {
224
+ const result = await this.queue.next(combined);
225
+ if (result.done) {
226
+ return;
227
+ }
228
+ yield result.value;
229
+ }
230
+ } finally {
231
+ this.activeConsumers--;
232
+ }
233
+ }
234
+
235
+ /** Stops the stream and releases the underlying encoder. Idempotent, including concurrently. */
236
+ async stop(): Promise<void> {
237
+ this.stopPromise ??= (async () => {
238
+ this.stopController.abort();
239
+ this.queue.end();
240
+ await this.handle?.stop();
241
+ })();
242
+ return this.stopPromise;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Starts encoding the device's display (and, with `options.audio`, its audio) in real time.
248
+ * Resolves once the encoder(s) have actually started; the returned {@link VideoStream}'s
249
+ * `accessUnits()` then yields each unit as it arrives. Independent of
250
+ * `startVideoRecording`/`stopVideoRecording` — both, and any number of concurrent streams, can
251
+ * run on the same device at once.
252
+ *
253
+ * @param udid — UDID of the device to stream; must be booted
254
+ * @param options — `displayId`, `codec`, `fps`, `bitrate`, `audio` — see {@link VideoStreamOptions}
255
+ */
256
+ export async function startVideoStream(
257
+ this: NativeSimctl,
258
+ udid: string,
259
+ options: VideoStreamOptions = {},
260
+ ): Promise<VideoStream> {
261
+ // The native poller clamps below 1 fps to 1 fps rather than actually polling that slowly, so a
262
+ // sub-1 value here would silently poll far more often than requested — rejected instead.
263
+ if (options.fps !== undefined && (!Number.isFinite(options.fps) || options.fps < 1)) {
264
+ throw new RangeError(`fps must be a finite number >= 1, got ${options.fps}`);
265
+ }
266
+ if (
267
+ options.bitrate !== undefined &&
268
+ (!Number.isFinite(options.bitrate) || options.bitrate <= 0 || options.bitrate > 2 ** 31 - 1)
269
+ ) {
270
+ throw new RangeError(`bitrate must be a positive number no greater than ${2 ** 31 - 1}, got ${options.bitrate}`);
271
+ }
272
+ const device = await runCatchingAsync(() => this._findDevice(udid));
273
+ const stream = new VideoStream(options.codec === 'hevc' ? 'hevc' : 'h264');
274
+ const handle = await runCatchingAsync(() =>
275
+ device.startVideoStream(
276
+ options,
277
+ (unit) => stream._handleAccessUnit(unit),
278
+ (err) => stream._handleError(err),
279
+ ),
280
+ );
281
+ stream._attachHandle(handle);
282
+ return stream;
283
+ }