@appium/coresim 1.11.1 → 1.13.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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -1
  3. package/lib/src/commands/jpeg-stream.d.ts +2 -0
  4. package/lib/src/commands/jpeg-stream.d.ts.map +1 -1
  5. package/lib/src/commands/jpeg-stream.js +6 -2
  6. package/lib/src/commands/jpeg-stream.js.map +1 -1
  7. package/lib/src/commands/permissions.d.ts +15 -7
  8. package/lib/src/commands/permissions.d.ts.map +1 -1
  9. package/lib/src/commands/permissions.js +299 -5
  10. package/lib/src/commands/permissions.js.map +1 -1
  11. package/lib/src/commands/screenshot.d.ts +3 -3
  12. package/lib/src/commands/screenshot.d.ts.map +1 -1
  13. package/lib/src/commands/screenshot.js +10 -5
  14. package/lib/src/commands/screenshot.js.map +1 -1
  15. package/lib/src/commands/video-recording.d.ts +2 -0
  16. package/lib/src/commands/video-recording.d.ts.map +1 -1
  17. package/lib/src/commands/video-recording.js +13 -6
  18. package/lib/src/commands/video-recording.js.map +1 -1
  19. package/lib/src/commands/video-stream.d.ts +2 -0
  20. package/lib/src/commands/video-stream.d.ts.map +1 -1
  21. package/lib/src/commands/video-stream.js +6 -2
  22. package/lib/src/commands/video-stream.js.map +1 -1
  23. package/lib/src/index.d.ts +1 -1
  24. package/lib/src/index.d.ts.map +1 -1
  25. package/lib/src/index.js.map +1 -1
  26. package/lib/src/types.d.ts +25 -31
  27. package/lib/src/types.d.ts.map +1 -1
  28. package/lib/src/utils/display.d.ts +24 -0
  29. package/lib/src/utils/display.d.ts.map +1 -0
  30. package/lib/src/utils/display.js +55 -0
  31. package/lib/src/utils/display.js.map +1 -0
  32. package/lib/src/utils/index.d.ts +1 -0
  33. package/lib/src/utils/index.d.ts.map +1 -1
  34. package/lib/src/utils/index.js +1 -0
  35. package/lib/src/utils/index.js.map +1 -1
  36. package/package.json +3 -4
  37. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  38. package/src/commands/jpeg-stream.ts +6 -2
  39. package/src/commands/permissions.ts +324 -10
  40. package/src/commands/screenshot.ts +10 -5
  41. package/src/commands/video-recording.ts +10 -5
  42. package/src/commands/video-stream.ts +6 -2
  43. package/src/coresim.mm +7 -0
  44. package/src/index.ts +2 -0
  45. package/src/native/sim_screenshot.h +12 -7
  46. package/src/native/sim_screenshot.mm +18 -2
  47. package/src/types.ts +27 -30
  48. package/src/utils/display.ts +67 -0
  49. package/src/utils/index.ts +1 -0
@@ -1,14 +1,34 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {once} from 'node:events';
4
+ import path from 'node:path';
5
+ import {promisify} from 'node:util';
6
+
7
+ import {fs, plist} from '@appium/support';
8
+ import {waitForCondition} from 'asyncbox';
9
+
1
10
  import type {NativeSimctl} from '../native-simctl.js';
2
- import type {SimPermissionService, SimPermissionStatus} from '../types.js';
11
+ import {SimDeviceState, type SimPermissionService, type SimPermissionStatus} from '../types.js';
3
12
  import {runCatchingAsync} from '../utils/index.js';
4
13
 
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ const NOTIFICATIONS_SERVICE = 'notifications';
17
+ const SPRINGBOARD_SERVICE = 'com.apple.SpringBoard';
18
+ // The `sectionInfoVersionNumber` of the BulletinBoard stores SpringBoard writes (iOS 18 and 26).
19
+ const BULLETIN_BOARD_STORE_VERSION = 2;
20
+ const SPRINGBOARD_RESTART_TIMEOUT_MS = 30000;
21
+
22
+ // The last pending notifications update of each BulletinBoard store, see withStoreLock.
23
+ const storeUpdates = new Map<string, Promise<void>>();
24
+
5
25
  declare module '../native-simctl.js' {
6
26
  interface NativeSimctl {
7
27
  grantPermission(
8
28
  udid: string,
9
29
  service: SimPermissionService,
10
30
  bundleId: string,
11
- status?: 'granted' | 'limited',
31
+ status?: 'granted' | 'limited' | 'critical',
12
32
  ): Promise<void>;
13
33
  revokePermission(udid: string, service: SimPermissionService, bundleId: string): Promise<void>;
14
34
  resetPermission(udid: string, service: SimPermissionService, bundleId: string): Promise<void>;
@@ -21,30 +41,41 @@ declare module '../native-simctl.js' {
21
41
  * simulator's own TCC (privacy) database — see CLAUDE.md for why this bypasses CoreSimulator's own
22
42
  * privacy API.
23
43
  *
44
+ * `notifications` isn't a TCC row: it is written to SpringBoard's BulletinBoard store instead. On a
45
+ * booted device SpringBoard is restarted to load it, which terminates every running app; a shut down
46
+ * device picks it up on its next boot.
47
+ *
24
48
  * @param udid — UDID of the target device
25
- * @param service — permission to grant, e.g. `"camera"`, `"contacts"`, `"photos"`
49
+ * @param service — permission to grant, e.g. `"camera"`, `"contacts"`, `"photos"`, `"notifications"`
26
50
  * @param bundleId — bundle identifier of the app the permission applies to
27
51
  * @param status — defaults to `'granted'`. `'limited'` ("selected photos" access) is only valid
28
- * for the `photos` service.
52
+ * for the `photos` service, `'critical'` (critical alerts allowed too) — for `notifications`.
29
53
  */
30
54
  export async function grantPermission(
31
55
  this: NativeSimctl,
32
56
  udid: string,
33
57
  service: SimPermissionService,
34
58
  bundleId: string,
35
- status: 'granted' | 'limited' = 'granted',
59
+ status: 'granted' | 'limited' | 'critical' = 'granted',
36
60
  ): Promise<void> {
37
61
  return runCatchingAsync(async () => {
38
62
  if (status === 'limited' && service !== 'photos') {
39
63
  throw new Error(`'limited' is only a valid status for the 'photos' service, not '${service}'`);
40
64
  }
65
+ if (service === NOTIFICATIONS_SERVICE) {
66
+ return await setNotificationsAccess.call(this, udid, bundleId, status === 'critical' ? 'critical' : 'granted');
67
+ }
68
+ if (status === 'critical') {
69
+ throw new Error(`'critical' is only a valid status for the 'notifications' service, not '${service}'`);
70
+ }
41
71
  const tccIdentifier = toTCCIdentifier(service);
42
72
  return (await this._findDevice(udid)).grantPermission(tccIdentifier, bundleId, status);
43
73
  });
44
74
  }
45
75
 
46
76
  /**
47
- * Revokes a previously granted privacy permission from the given app.
77
+ * Revokes a previously granted privacy permission from the given app. For `notifications`, a booted
78
+ * device's SpringBoard is restarted, see {@link grantPermission}.
48
79
  *
49
80
  * @param udid — UDID of the target device
50
81
  * @param service — permission to revoke (see {@link grantPermission})
@@ -57,13 +88,18 @@ export async function revokePermission(
57
88
  bundleId: string,
58
89
  ): Promise<void> {
59
90
  return runCatchingAsync(async () => {
91
+ if (service === NOTIFICATIONS_SERVICE) {
92
+ return await setNotificationsAccess.call(this, udid, bundleId, 'denied');
93
+ }
60
94
  const tccIdentifier = toTCCIdentifier(service);
61
95
  return (await this._findDevice(udid)).revokePermission(tccIdentifier, bundleId);
62
96
  });
63
97
  }
64
98
 
65
99
  /**
66
- * Resets a privacy permission for the given app to its default (unprompted) state.
100
+ * Resets a privacy permission for the given app to its default (unprompted) state. For
101
+ * `notifications`, the app's section is removed from SpringBoard's store and a booted device's
102
+ * SpringBoard is restarted, see {@link grantPermission}.
67
103
  *
68
104
  * @param udid — UDID of the target device
69
105
  * @param service — permission to reset (see {@link grantPermission})
@@ -76,6 +112,9 @@ export async function resetPermission(
76
112
  bundleId: string,
77
113
  ): Promise<void> {
78
114
  return runCatchingAsync(async () => {
115
+ if (service === NOTIFICATIONS_SERVICE) {
116
+ return await setNotificationsAccess.call(this, udid, bundleId, 'unset');
117
+ }
79
118
  const tccIdentifier = toTCCIdentifier(service);
80
119
  return (await this._findDevice(udid)).resetPermission(tccIdentifier, bundleId);
81
120
  });
@@ -90,6 +129,7 @@ export async function resetPermission(
90
129
  * @param service — permission to check (see {@link grantPermission})
91
130
  * @param bundleId — bundle identifier of the app the permission applies to
92
131
  * @returns `'unset'` if the app has never been prompted/decided for this permission
132
+ * @throws {Error} for `notifications`: reading it isn't supported
93
133
  */
94
134
  export async function getPermission(
95
135
  this: NativeSimctl,
@@ -98,6 +138,9 @@ export async function getPermission(
98
138
  bundleId: string,
99
139
  ): Promise<SimPermissionStatus> {
100
140
  return runCatchingAsync(async () => {
141
+ if (service === NOTIFICATIONS_SERVICE) {
142
+ throw new Error(`The status of the '${NOTIFICATIONS_SERVICE}' permission cannot be read`);
143
+ }
101
144
  const tccIdentifier = toTCCIdentifier(service);
102
145
  return (await this._findDevice(udid)).getPermission(tccIdentifier, bundleId);
103
146
  });
@@ -106,8 +149,8 @@ export async function getPermission(
106
149
  // Maps a friendly service name to the internal TCC service identifier its row in the simulator's
107
150
  // own TCC.db is keyed on (see native/tcc_privacy.h). `location` is deliberately absent: it isn't a
108
151
  // plain TCC row (CoreLocation simulation has its own subsystem), so it isn't supported by this
109
- // TCC.db-based implementation.
110
- const SERVICE_TO_TCC_IDENTIFIER: Record<SimPermissionService, string> = {
152
+ // TCC.db-based implementation. `notifications` isn't a TCC row either, see setNotificationsAccess.
153
+ const SERVICE_TO_TCC_IDENTIFIER: Record<Exclude<SimPermissionService, 'notifications'>, string> = {
111
154
  calendar: 'kTCCServiceCalendar',
112
155
  camera: 'kTCCServiceCamera',
113
156
  contacts: 'kTCCServiceAddressBook',
@@ -124,7 +167,7 @@ const SERVICE_TO_TCC_IDENTIFIER: Record<SimPermissionService, string> = {
124
167
  usertracking: 'kTCCServiceUserTracking',
125
168
  };
126
169
 
127
- function toTCCIdentifier(service: SimPermissionService): string {
170
+ function toTCCIdentifier(service: Exclude<SimPermissionService, 'notifications'>): string {
128
171
  const identifier = SERVICE_TO_TCC_IDENTIFIER[service];
129
172
  if (!identifier) {
130
173
  throw new Error(
@@ -133,3 +176,274 @@ function toTCCIdentifier(service: SimPermissionService): string {
133
176
  }
134
177
  return identifier;
135
178
  }
179
+
180
+ /**
181
+ * `notifications` isn't a TCC row: SpringBoard keeps it per app in its BulletinBoard store
182
+ * (`Library/BulletinBoard/VersionedSectionInfo.plist`), reads that file only on its own start and
183
+ * rewrites it from memory from time to time (e.g. a few seconds after it starts). So SpringBoard of
184
+ * a booted device is paused while the app's section is written, then restarted with the store kept
185
+ * immutable until the old process is gone. The new SpringBoard loads the change and terminates
186
+ * every running app. A shut down device only gets the file, SpringBoard loads it on boot.
187
+ */
188
+ async function setNotificationsAccess(
189
+ this: NativeSimctl,
190
+ udid: string,
191
+ bundleId: string,
192
+ status: 'granted' | 'denied' | 'critical' | 'unset',
193
+ ): Promise<void> {
194
+ const device = await this._findDevice(udid);
195
+ const storePath = path.join(device.dataPath(), 'Library', 'BulletinBoard', 'VersionedSectionInfo.plist');
196
+ await withStoreLock(storePath, async () => {
197
+ const springBoard = device.state() === SimDeviceState.Booted ? new SpringBoard(this, udid) : undefined;
198
+ await springBoard?.pause();
199
+ let written = false;
200
+ try {
201
+ written = await writeNotificationsSection(storePath, bundleId, status);
202
+ } finally {
203
+ if (!written) {
204
+ springBoard?.resume();
205
+ }
206
+ }
207
+ if (!springBoard || !written) {
208
+ return;
209
+ }
210
+ await setImmutable(storePath, true);
211
+ try {
212
+ await springBoard.restart();
213
+ } finally {
214
+ await setImmutable(storePath, false);
215
+ }
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Runs `update` once every earlier update of the same store has settled: concurrent updates for
221
+ * different apps would otherwise read the same store, and the last write would drop the other app's
222
+ * section. Only the calls made by this process are serialized.
223
+ */
224
+ async function withStoreLock(storePath: string, update: () => Promise<void>): Promise<void> {
225
+ const result = (storeUpdates.get(storePath) ?? Promise.resolve()).then(update);
226
+ const settled = result.catch(() => {});
227
+ storeUpdates.set(storePath, settled);
228
+ try {
229
+ await result;
230
+ } finally {
231
+ if (storeUpdates.get(storePath) === settled) {
232
+ storeUpdates.delete(storePath);
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Adds, replaces or (`unset`) removes the app's section. The store is replaced atomically, so a
239
+ * failed write leaves the previous one intact. A device that has never been booted has no store yet:
240
+ * it is created with just this section, and SpringBoard adds the rest on boot.
241
+ *
242
+ * @returns `false` if there was nothing to write (`unset` without a store)
243
+ */
244
+ async function writeNotificationsSection(
245
+ storePath: string,
246
+ bundleId: string,
247
+ status: 'granted' | 'denied' | 'critical' | 'unset',
248
+ ): Promise<boolean> {
249
+ let store: {sectionInfoVersionNumber?: number; sectionInfo?: Record<string, Uint8Array>};
250
+ if (await fs.exists(storePath)) {
251
+ store = (await plist.parsePlistFile(storePath)) as typeof store;
252
+ } else if (status === 'unset') {
253
+ return false;
254
+ } else {
255
+ await fs.mkdir(path.dirname(storePath), {recursive: true});
256
+ store = {sectionInfoVersionNumber: BULLETIN_BOARD_STORE_VERSION};
257
+ }
258
+ const sectionInfo = store.sectionInfo ?? {};
259
+ if (status === 'unset') {
260
+ delete sectionInfo[bundleId];
261
+ } else {
262
+ sectionInfo[bundleId] = buildNotificationsSectionInfo(bundleId, status);
263
+ }
264
+ store.sectionInfo = sectionInfo;
265
+ // In the same directory, so that the rename is atomic.
266
+ const tmpPath = `${storePath}.${randomUUID()}.tmp`;
267
+ try {
268
+ await fs.writeFile(tmpPath, plist.createPlist(store, false), {flag: 'wx'});
269
+ await fs.rename(tmpPath, storePath);
270
+ } catch (err) {
271
+ await fs.unlink(tmpPath).catch(() => {});
272
+ throw err;
273
+ }
274
+ return true;
275
+ }
276
+
277
+ /**
278
+ * Builds the NSKeyedArchiver-encoded `BBSectionInfo` of `bundleId`, the same archive
279
+ * AppleSimulatorUtils writes (SetNotificationsPermission.m). SpringBoard migrates its
280
+ * `allowsNotifications` flag to the current `authorizationStatus` when it loads the section.
281
+ */
282
+ function buildNotificationsSectionInfo(bundleId: string, status: 'granted' | 'denied' | 'critical'): Buffer {
283
+ const nil = {UID: 0};
284
+ return plist.createBinaryPlist({
285
+ $version: 100000,
286
+ $objects: [
287
+ '$null',
288
+ {
289
+ suppressFromSettings: false,
290
+ suppressedSettings: 0,
291
+ hideWeeApp: false,
292
+ sectionID: {UID: 2},
293
+ displayName: {UID: 5},
294
+ icon: nil,
295
+ displaysCriticalBulletins: false,
296
+ subsections: nil,
297
+ sectionInfoSettings: {UID: 3},
298
+ $class: {UID: 7},
299
+ sectionCategory: 0,
300
+ subsectionPriority: 0,
301
+ version: {UID: 6},
302
+ managedSectionInfoSettings: nil,
303
+ appName: {UID: 5},
304
+ sectionType: 0,
305
+ factorySectionID: nil,
306
+ dataProviderIDs: nil,
307
+ subsectionID: nil,
308
+ filters: nil,
309
+ pathToWeeAppPluginBundle: nil,
310
+ },
311
+ bundleId,
312
+ {
313
+ pushSettings: 63,
314
+ showsInNotificationCenter: true,
315
+ allowsNotifications: status !== 'denied',
316
+ showsOnExternalDevices: true,
317
+ contentPreviewSetting: 0,
318
+ carPlaySetting: 0,
319
+ $class: {UID: 4},
320
+ showsInLockScreen: true,
321
+ alertType: 1,
322
+ criticalAlertSetting: status === 'critical' ? 2 : 0,
323
+ },
324
+ {$classname: 'BBSectionInfoSettings', $classes: ['BBSectionInfoSettings', 'NSObject']},
325
+ bundleId,
326
+ 0,
327
+ {$classname: 'BBSectionInfo', $classes: ['BBSectionInfo', 'NSObject']},
328
+ ],
329
+ $archiver: 'NSKeyedArchiver',
330
+ $top: {root: {UID: 1}},
331
+ });
332
+ }
333
+
334
+ async function setImmutable(filePath: string, immutable: boolean): Promise<void> {
335
+ try {
336
+ await execFileAsync('chflags', [immutable ? 'uchg' : 'nouchg', filePath]);
337
+ } catch {}
338
+ }
339
+
340
+ /**
341
+ * SpringBoard of a booted device: the process that owns the BulletinBoard store, see
342
+ * setNotificationsAccess. The simulator shares the host kernel, so it is signalled directly.
343
+ */
344
+ class SpringBoard {
345
+ private pausedPid: number | undefined;
346
+
347
+ constructor(
348
+ private readonly simctl: NativeSimctl,
349
+ private readonly udid: string,
350
+ ) {}
351
+
352
+ /**
353
+ * Pauses the running SpringBoard so that it cannot rewrite the BulletinBoard store. It can be in
354
+ * the middle of a restart, hence the retries.
355
+ */
356
+ async pause(): Promise<void> {
357
+ try {
358
+ await waitForCondition(
359
+ async () => {
360
+ const pid = await this.pid().catch(() => undefined);
361
+ if (pid && sendSignal(pid, 'SIGSTOP')) {
362
+ this.pausedPid = pid;
363
+ }
364
+ return Boolean(this.pausedPid);
365
+ },
366
+ {waitMs: SPRINGBOARD_RESTART_TIMEOUT_MS, intervalMs: 300},
367
+ );
368
+ } catch {}
369
+ if (!this.pausedPid) {
370
+ throw new Error(`${SPRINGBOARD_SERVICE} is not running`);
371
+ }
372
+ }
373
+
374
+ /** Lets the paused SpringBoard run again. */
375
+ resume(): void {
376
+ if (this.pausedPid) {
377
+ sendSignal(this.pausedPid, 'SIGCONT');
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Replaces the paused SpringBoard with a new one: asks launchd to stop it (launchd starts a new
383
+ * one right away), resumes it so that it can exit, then waits for the new process.
384
+ */
385
+ async restart(): Promise<void> {
386
+ const previousPid = this.pausedPid;
387
+ try {
388
+ await this.stop();
389
+ } finally {
390
+ this.resume();
391
+ this.pausedPid = undefined;
392
+ }
393
+ try {
394
+ await waitForCondition(
395
+ async () => {
396
+ const pid = await this.pid().catch(() => undefined);
397
+ return Number.isInteger(pid) && pid !== previousPid;
398
+ },
399
+ {waitMs: SPRINGBOARD_RESTART_TIMEOUT_MS, intervalMs: 300},
400
+ );
401
+ } catch {
402
+ throw new Error(`${SPRINGBOARD_SERVICE} did not restart within ${SPRINGBOARD_RESTART_TIMEOUT_MS}ms`);
403
+ }
404
+ }
405
+
406
+ private async pid(): Promise<number | undefined> {
407
+ return (await this.simctl.listProcesses(this.udid)).find(({name}) => name === SPRINGBOARD_SERVICE)?.pid;
408
+ }
409
+
410
+ private async stop(): Promise<void> {
411
+ // Bare name: resolved against the guest runtime's own bin dirs, like in listProcesses.
412
+ const proc = await this.simctl.spawnProcess(this.udid, 'launchctl', {
413
+ arguments: ['launchctl', 'stop', SPRINGBOARD_SERVICE],
414
+ });
415
+ let stderr = '';
416
+ proc.stdout.resume();
417
+ proc.stderr.on('data', (chunk: Buffer) => {
418
+ stderr += chunk;
419
+ });
420
+ const streamError = Promise.race([once(proc.stdout, 'error'), once(proc.stderr, 'error')]).then(([err]) => {
421
+ throw err;
422
+ });
423
+ const [[code, signal]] = await Promise.race([
424
+ Promise.all([once(proc, 'exit'), once(proc.stderr, 'end')]),
425
+ streamError,
426
+ ]);
427
+ if (code !== 0) {
428
+ const reason = signal ? `signal ${signal}` : `exit code ${code}`;
429
+ throw new Error(
430
+ `'launchctl stop ${SPRINGBOARD_SERVICE}' failed with ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ''}`,
431
+ );
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * @returns `false` if the process no longer exists
438
+ */
439
+ function sendSignal(pid: number, name: NodeJS.Signals): boolean {
440
+ try {
441
+ process.kill(pid, name);
442
+ return true;
443
+ } catch (err: any) {
444
+ if (err?.code === 'ESRCH') {
445
+ return false;
446
+ }
447
+ throw err;
448
+ }
449
+ }
@@ -1,6 +1,6 @@
1
1
  import type {NativeSimctl} from '../native-simctl.js';
2
2
  import type {ScreenshotOptions, SimDisplayInfo} from '../types.js';
3
- import {runCatchingAsync} from '../utils/index.js';
3
+ import {resolveDisplayPortId, runCatchingAsync, validateDisplayId} from '../utils/index.js';
4
4
 
5
5
  declare module '../native-simctl.js' {
6
6
  interface NativeSimctl {
@@ -12,11 +12,11 @@ declare module '../native-simctl.js' {
12
12
  /**
13
13
  * Captures a device display as an image — the native equivalent of `simctl io <udid> screenshot`
14
14
  * (see native/sim_screenshot.mm for how this reads the framebuffer directly, with no temp file or
15
- * subprocess). Rejects if the resolved display has no renderable surface yet (e.g. not booted), or
16
- * if `options.displayId` doesn't match any display from {@link getDisplays}.
15
+ * subprocess). Rejects if the resolved display has no renderable surface yet (e.g. not booted).
16
+ * Display selection follows {@link validateDisplayId} and {@link resolveDisplayPortId}.
17
17
  *
18
18
  * @param udid — UDID of the device to capture; must be booted
19
- * @param options — `format` (defaults to `'png'`), `displayId` (defaults to the primary display),
19
+ * @param options — `format` (defaults to `'png'`), `displayId`,
20
20
  * and `quality` (JPEG only, 0-100 percent)
21
21
  * @returns image data encoded as `options.format`
22
22
  */
@@ -31,7 +31,12 @@ export async function getScreenshot(
31
31
  ) {
32
32
  throw new RangeError(`quality must be a number between 0 and 100, got ${options.quality}`);
33
33
  }
34
- return runCatchingAsync(async () => (await this._findDevice(udid)).screenshot(options));
34
+ validateDisplayId(options.displayId);
35
+ return runCatchingAsync(async () => {
36
+ const device = await this._findDevice(udid);
37
+ const displayId = await resolveDisplayPortId(device, options.displayId);
38
+ return await device.screenshot({...options, displayId});
39
+ });
35
40
  }
36
41
 
37
42
  /**
@@ -4,7 +4,7 @@ import {logger} from '@appium/support';
4
4
 
5
5
  import type {NativeSimctl} from '../native-simctl.js';
6
6
  import type {NativeVideoRecordingHandle, StopVideoRecordingOptions, VideoRecordingOptions} from '../types.js';
7
- import {runCatchingAsync, toTypedError} from '../utils/index.js';
7
+ import {resolveDisplayPortId, runCatchingAsync, toTypedError, validateDisplayId} from '../utils/index.js';
8
8
 
9
9
  declare module '../native-simctl.js' {
10
10
  interface NativeSimctl {
@@ -48,6 +48,8 @@ const activeRecordings = new Map<string, RecordingState>();
48
48
  * mid-recording) doesn't reject it — it's logged here instead — call {@link stopVideoRecording} to
49
49
  * observe it as a rejection and release the recording's resources.
50
50
  *
51
+ * Display selection follows {@link validateDisplayId} and {@link resolveDisplayPortId}.
52
+ *
51
53
  * @param udid — UDID of the device to record; must be booted
52
54
  * @param outputFile — filesystem path to write the video to; resolved against `process.cwd()` if
53
55
  * relative, since the native layer requires an absolute path
@@ -71,18 +73,21 @@ export async function startVideoRecording(
71
73
  ) {
72
74
  throw new RangeError(`bitrate must be a positive number no greater than ${2 ** 31 - 1}, got ${options.bitrate}`);
73
75
  }
76
+ validateDisplayId(options.displayId);
74
77
  const key = udid.toLowerCase();
75
78
  if (activeRecordings.has(key)) {
76
79
  throw new Error(`A video recording is already in progress for device '${udid}'`);
77
80
  }
78
81
  const absoluteOutputFile = path.resolve(outputFile);
79
- const startPromise = runCatchingAsync(async () =>
80
- (await this._findDevice(udid)).startVideoRecording(absoluteOutputFile, options, (err) => {
82
+ const startPromise = runCatchingAsync(async () => {
83
+ const device = await this._findDevice(udid);
84
+ const displayId = await resolveDisplayPortId(device, options.displayId);
85
+ return device.startVideoRecording(absoluteOutputFile, {...options, displayId}, (err) => {
81
86
  // Only ever invoked on the `audio`/`fps` (own-encoder) path — see its own doc comment above.
82
87
  // No live consumer to report to (this call already resolved), so logged instead of lost.
83
88
  log.error(`Unhandled video recording error for device '${udid}': ${toTypedError(err).stack ?? err}`);
84
- }),
85
- );
89
+ });
90
+ });
86
91
  // Marked before the native call resolves, not after, so a concurrent startVideoRecording for the
87
92
  // same device is rejected immediately instead of racing this one — rolled back below on failure.
88
93
  const state: RecordingState = {start: startPromise};
@@ -4,7 +4,7 @@ import {logger} from '@appium/support';
4
4
 
5
5
  import type {NativeSimctl} from '../native-simctl.js';
6
6
  import type {NativeVideoStreamHandle, VideoAccessUnit, VideoStreamOptions} from '../types.js';
7
- import {runCatchingAsync, toTypedError} from '../utils/index.js';
7
+ import {resolveDisplayPortId, runCatchingAsync, toTypedError, validateDisplayId} from '../utils/index.js';
8
8
 
9
9
  declare module '../native-simctl.js' {
10
10
  interface NativeSimctl {
@@ -250,6 +250,8 @@ export class VideoStream extends EventEmitter {
250
250
  * `startVideoRecording`/`stopVideoRecording` — both, and any number of concurrent streams, can
251
251
  * run on the same device at once.
252
252
  *
253
+ * Display selection follows {@link validateDisplayId} and {@link resolveDisplayPortId}.
254
+ *
253
255
  * @param udid — UDID of the device to stream; must be booted
254
256
  * @param options — `displayId`, `codec`, `fps`, `bitrate`, `audio` — see {@link VideoStreamOptions}
255
257
  */
@@ -269,11 +271,13 @@ export async function startVideoStream(
269
271
  ) {
270
272
  throw new RangeError(`bitrate must be a positive number no greater than ${2 ** 31 - 1}, got ${options.bitrate}`);
271
273
  }
274
+ validateDisplayId(options.displayId);
272
275
  const device = await runCatchingAsync(() => this._findDevice(udid));
276
+ const displayId = await runCatchingAsync(() => resolveDisplayPortId(device, options.displayId));
273
277
  const stream = new VideoStream(options.codec === 'hevc' ? 'hevc' : 'h264');
274
278
  const handle = await runCatchingAsync(() =>
275
279
  device.startVideoStream(
276
- options,
280
+ {...options, displayId},
277
281
  (unit) => stream._handleAccessUnit(unit),
278
282
  (err) => stream._handleError(err),
279
283
  ),
package/src/coresim.mm CHANGED
@@ -619,6 +619,12 @@ class NativeDevice : public Napi::ObjectWrap<NativeDevice> {
619
619
  return Napi::String::New(info.Env(), coresim::RuntimeRootPath(runtime).UTF8String);
620
620
  });
621
621
  }
622
+ Napi::Value DataPath(const Napi::CallbackInfo& info) {
623
+ return CatchToJs(info.Env(), [&]() -> Napi::Value {
624
+ NSString* dataPath = coresim::DeviceDataPath(device_);
625
+ return Napi::String::New(info.Env(), dataPath == nil ? "" : dataPath.UTF8String);
626
+ });
627
+ }
622
628
 
623
629
  // Bridges SimDevice's GCD-completion-block-based bootAsyncWithOptions:completionQueue:
624
630
  // completionHandler: — this is what replaces the CLI's bootstatus poll-until-timeout race with
@@ -1724,6 +1730,7 @@ void NativeDevice::Init(Napi::Env env) {
1724
1730
  InstanceMethod<&NativeDevice::DeviceTypeIdentifier>("deviceTypeIdentifier"),
1725
1731
  InstanceMethod<&NativeDevice::RuntimeIdentifier>("runtimeIdentifier"),
1726
1732
  InstanceMethod<&NativeDevice::RuntimeRootPath>("runtimeRootPath"),
1733
+ InstanceMethod<&NativeDevice::DataPath>("dataPath"),
1727
1734
  InstanceMethod<&NativeDevice::Boot>("boot"),
1728
1735
  InstanceMethod<&NativeDevice::GetBootStatus>("getBootStatus"),
1729
1736
  InstanceMethod<&NativeDevice::Shutdown>("shutdown"),
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ export {
15
15
  type JpegFrame,
16
16
  type JpegStreamOptions,
17
17
  type PushNotificationPayload,
18
+ type DisplayId,
19
+ type DisplayOptions,
18
20
  type ScreenshotOptions,
19
21
  type SimBootInfo,
20
22
  type SimDeviceInfo,
@@ -6,11 +6,15 @@
6
6
  namespace coresim {
7
7
 
8
8
  // Enumerates the device's renderable display IO ports — the same information `simctl io <udid>
9
- // enumerate` reports for its own `--display` selection. Each element has "id" (NSString, a port
10
- // UUID — pass to CaptureScreenshot's displayId to target it), "displayClass" (NSNumber,
11
- // unsigned short — 0 is always the primary display), and "isMain" (NSNumber/BOOL). Returns nil
12
- // (and sets *error) if the device has no IO client at all (e.g. not booted); an empty (non-nil)
13
- // array means the device has an IO client but no renderable display.
9
+ // enumerate` reports for its own `--display` selection. Each element contains:
10
+ // - "id": NSString port UUID — pass to CaptureScreenshot's displayId to target it.
11
+ // - "displayClass": NSNumber/unsigned short, the raw display class.
12
+ // - "isMain": NSNumber/BOOL, true when displayClass == 0. Multiple integrated displays can
13
+ // have class 0 (e.g. both Duo displays); this does not identify a unique primary display.
14
+ // - "screenId": optional NSNumber/unsigned int guest screen ID from SimScreenProperties.screenID,
15
+ // matching XCTest's displayID. Omitted when the required selectors are unavailable.
16
+ // Returns nil (and sets *error) if the device has no IO client at all (e.g. not booted); an empty
17
+ // (non-nil) array means the device has an IO client but no renderable display.
14
18
  NSArray<NSDictionary*>* ListDisplays(id device, NSError** error);
15
19
 
16
20
  // Resolves the display descriptor CaptureScreenshot itself reads from, without capturing a
@@ -40,8 +44,9 @@ NSData* EncodeImage(CGImageRef cgImage, ScreenshotFormat format, NSNumber* jpegQ
40
44
  // Captures a display as an image, reading the same in-process framebuffer surface `simctl io
41
45
  // <udid> screenshot` itself reads (see sim_screenshot.mm) — no temp file, no subprocess.
42
46
  // `displayId`, if non-nil, selects a specific port by its UUID (see ListDisplays); nil selects the
43
- // primary display (displayClass 0), falling back to the first renderable display found (e.g. for
44
- // tvOS, which has no class-0 display). `jpegQualityPercent` (0-100, nil for ImageIO's own default)
47
+ // first class-0 display, falling back to the first renderable display if none has class 0 (e.g.
48
+ // tvOS). Native selection does not reject multiple class-0 ports; callers that require a unique
49
+ // target must resolve a UUID first. `jpegQualityPercent` (0-100, nil for ImageIO's own default)
45
50
  // only applies when `format` is kJPEG — ignored for kPNG, which is always lossless. Returns nil
46
51
  // (and sets *error) if `displayId` doesn't match any port, the resolved display has no renderable
47
52
  // surface yet (e.g. not booted), or rendering/encoding failed.
@@ -183,11 +183,27 @@ NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
183
183
  NSMutableArray<NSDictionary*>* result = [NSMutableArray arrayWithCapacity:candidates.count];
184
184
  for (NSDictionary* candidate in candidates) {
185
185
  unsigned short displayClass = [candidate[kCandidateDisplayClassKey] unsignedShortValue];
186
- [result addObject:@{
186
+ NSMutableDictionary* display = [@{
187
187
  @"id" : candidate[kCandidateUUIDKey],
188
188
  @"displayClass" : candidate[kCandidateDisplayClassKey],
189
189
  @"isMain" : @(displayClass == 0),
190
- }];
190
+ } mutableCopy];
191
+ // A Duo has multiple class-0 displays. Use the guest screen ID to associate
192
+ // a port UUID with XCTest's displayID; class or enumeration order is ambiguous.
193
+ id descriptor = candidate[kCandidateDescriptorKey];
194
+ SEL propertiesSelector = SelectorNamed("screenProperties");
195
+ if ([descriptor respondsToSelector:propertiesSelector]) {
196
+ id properties = IdGetter(descriptor, "screenProperties");
197
+ SEL screenIDSelector = SelectorNamed("screenID");
198
+ if ([properties respondsToSelector:screenIDSelector]) {
199
+ unsigned int screenID = SafeInvoke([&] {
200
+ using Fn = unsigned int (*)(id, SEL);
201
+ return ((Fn)objc_msgSend)(properties, screenIDSelector);
202
+ });
203
+ display[@"screenId"] = @(screenID);
204
+ }
205
+ }
206
+ [result addObject:display];
191
207
  }
192
208
  return result;
193
209
  }