@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.
- package/CHANGELOG.md +12 -0
- package/binding.gyp +7 -1
- package/lib/src/commands/process.d.ts.map +1 -1
- package/lib/src/commands/process.js +3 -4
- package/lib/src/commands/process.js.map +1 -1
- package/lib/src/commands/spawn.d.ts +9 -5
- package/lib/src/commands/spawn.d.ts.map +1 -1
- package/lib/src/commands/spawn.js +9 -5
- package/lib/src/commands/spawn.js.map +1 -1
- package/lib/src/commands/video-recording.d.ts +50 -0
- package/lib/src/commands/video-recording.d.ts.map +1 -0
- package/lib/src/commands/video-recording.js +98 -0
- package/lib/src/commands/video-recording.js.map +1 -0
- package/lib/src/commands/video-stream.d.ts +44 -0
- package/lib/src/commands/video-stream.d.ts.map +1 -0
- package/lib/src/commands/video-stream.js +240 -0
- package/lib/src/commands/video-stream.js.map +1 -0
- package/lib/src/index.d.ts +2 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js +1 -0
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts +4 -0
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +9 -0
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +72 -0
- 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/process.ts +3 -4
- package/src/commands/spawn.ts +9 -5
- package/src/commands/video-recording.ts +128 -0
- package/src/commands/video-stream.ts +274 -0
- package/src/coresim.mm +310 -4
- package/src/index.ts +4 -0
- package/src/native/sim_screenshot.h +10 -0
- package/src/native/sim_screenshot.mm +11 -6
- package/src/native/sim_video_recording.h +27 -0
- package/src/native/sim_video_recording.mm +87 -0
- package/src/native/sim_video_stream.h +61 -0
- package/src/native/sim_video_stream.mm +427 -0
- package/src/native-simctl.ts +10 -0
- 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).
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
-
|
|
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';
|