@appium/coresim 1.13.0 → 1.13.1
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 +7 -0
- package/README.md +1 -0
- package/lib/src/commands/permissions.d.ts.map +1 -1
- package/lib/src/commands/permissions.js +12 -246
- package/lib/src/commands/permissions.js.map +1 -1
- package/lib/src/index.d.ts +1 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts +27 -9
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +9 -16
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/utils/bulletin-board-model-v2.d.ts +10 -0
- package/lib/src/utils/bulletin-board-model-v2.d.ts.map +1 -0
- package/lib/src/utils/bulletin-board-model-v2.js +73 -0
- package/lib/src/utils/bulletin-board-model-v2.js.map +1 -0
- package/lib/src/utils/bulletin-board-model-v3.d.ts +4 -0
- package/lib/src/utils/bulletin-board-model-v3.d.ts.map +1 -0
- package/lib/src/utils/bulletin-board-model-v3.js +16 -0
- package/lib/src/utils/bulletin-board-model-v3.js.map +1 -0
- package/lib/src/utils/bulletin-board-model.d.ts +19 -0
- package/lib/src/utils/bulletin-board-model.d.ts.map +1 -0
- package/lib/src/utils/bulletin-board-model.js +2 -0
- package/lib/src/utils/bulletin-board-model.js.map +1 -0
- package/lib/src/utils/bulletin-board-store.d.ts +41 -0
- package/lib/src/utils/bulletin-board-store.d.ts.map +1 -0
- package/lib/src/utils/bulletin-board-store.js +114 -0
- package/lib/src/utils/bulletin-board-store.js.map +1 -0
- package/lib/src/utils/index.d.ts +3 -0
- package/lib/src/utils/index.d.ts.map +1 -1
- package/lib/src/utils/index.js +2 -0
- package/lib/src/utils/index.js.map +1 -1
- package/lib/src/utils/springboard.d.ts +26 -0
- package/lib/src/utils/springboard.d.ts.map +1 -0
- package/lib/src/utils/springboard.js +106 -0
- package/lib/src/utils/springboard.js.map +1 -0
- package/package.json +3 -2
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/permissions.ts +13 -263
- package/src/index.ts +1 -1
- package/src/native-simctl.ts +45 -19
- package/src/utils/bulletin-board-model-v2.ts +79 -0
- package/src/utils/bulletin-board-model-v3.ts +16 -0
- package/src/utils/bulletin-board-model.ts +20 -0
- package/src/utils/bulletin-board-store.ts +126 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/springboard.ts +119 -0
|
@@ -1,26 +1,8 @@
|
|
|
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
|
-
|
|
10
1
|
import type {NativeSimctl} from '../native-simctl.js';
|
|
11
2
|
import {SimDeviceState, type SimPermissionService, type SimPermissionStatus} from '../types.js';
|
|
12
|
-
import {runCatchingAsync} from '../utils/index.js';
|
|
13
|
-
|
|
14
|
-
const execFileAsync = promisify(execFile);
|
|
3
|
+
import {BulletinBoardStore, runCatchingAsync, SpringBoard, type NotificationsStatus} from '../utils/index.js';
|
|
15
4
|
|
|
16
5
|
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
6
|
|
|
25
7
|
declare module '../native-simctl.js' {
|
|
26
8
|
interface NativeSimctl {
|
|
@@ -178,27 +160,27 @@ function toTCCIdentifier(service: Exclude<SimPermissionService, 'notifications'>
|
|
|
178
160
|
}
|
|
179
161
|
|
|
180
162
|
/**
|
|
181
|
-
* `notifications` isn't a TCC row: SpringBoard keeps it per app in its BulletinBoard store
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
163
|
+
* `notifications` isn't a TCC row: SpringBoard keeps it per app in its BulletinBoard store, reads
|
|
164
|
+
* that file only on its own start and rewrites it from memory from time to time (e.g. a few seconds
|
|
165
|
+
* after it starts). So SpringBoard of a booted device is paused while the app's section is written,
|
|
166
|
+
* then restarted with the store kept immutable until the old process is gone. The new SpringBoard
|
|
167
|
+
* loads the change and terminates every running app. A shut down device only gets the file,
|
|
168
|
+
* SpringBoard loads it on boot.
|
|
187
169
|
*/
|
|
188
170
|
async function setNotificationsAccess(
|
|
189
171
|
this: NativeSimctl,
|
|
190
172
|
udid: string,
|
|
191
173
|
bundleId: string,
|
|
192
|
-
status:
|
|
174
|
+
status: NotificationsStatus,
|
|
193
175
|
): Promise<void> {
|
|
194
176
|
const device = await this._findDevice(udid);
|
|
195
|
-
const
|
|
196
|
-
await
|
|
177
|
+
const store = new BulletinBoardStore(device.dataPath(), this._options.bulletinBoardVersions);
|
|
178
|
+
await store.withLock(async () => {
|
|
197
179
|
const springBoard = device.state() === SimDeviceState.Booted ? new SpringBoard(this, udid) : undefined;
|
|
198
180
|
await springBoard?.pause();
|
|
199
181
|
let written = false;
|
|
200
182
|
try {
|
|
201
|
-
written = await
|
|
183
|
+
written = await store.setNotificationsStatus(bundleId, status);
|
|
202
184
|
} finally {
|
|
203
185
|
if (!written) {
|
|
204
186
|
springBoard?.resume();
|
|
@@ -207,243 +189,11 @@ async function setNotificationsAccess(
|
|
|
207
189
|
if (!springBoard || !written) {
|
|
208
190
|
return;
|
|
209
191
|
}
|
|
210
|
-
await setImmutable(
|
|
192
|
+
await store.setImmutable(true);
|
|
211
193
|
try {
|
|
212
194
|
await springBoard.restart();
|
|
213
195
|
} finally {
|
|
214
|
-
await setImmutable(
|
|
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);
|
|
196
|
+
await store.setImmutable(false);
|
|
233
197
|
}
|
|
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
198
|
});
|
|
332
199
|
}
|
|
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
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export {NativeSimError, NativeSimUnavailableError, NativeSimDispatchError, NativeSimOperationError} from './errors.js';
|
|
2
|
-
export {NativeSimctl} from './native-simctl.js';
|
|
2
|
+
export {NativeSimctl, type NativeSimctlOptions} from './native-simctl.js';
|
|
3
3
|
export {SpawnedProcess} from './commands/spawn.js';
|
|
4
4
|
export {VideoStream} from './commands/video-stream.js';
|
|
5
5
|
export {JpegStream} from './commands/jpeg-stream.js';
|
package/src/native-simctl.ts
CHANGED
|
@@ -56,6 +56,13 @@ import {
|
|
|
56
56
|
import {isVideoRecording, startVideoRecording, stopVideoRecording} from './commands/video-recording.js';
|
|
57
57
|
import {startVideoStream} from './commands/video-stream.js';
|
|
58
58
|
import {getWebInspectorSocket} from './commands/webinspector.js';
|
|
59
|
+
import {NativeSimUnavailableError} from './errors.js';
|
|
60
|
+
import type {
|
|
61
|
+
NativeCoreSimModule,
|
|
62
|
+
NativeDeviceHandle,
|
|
63
|
+
NativeDeviceSetHandle,
|
|
64
|
+
NativeServiceContextHandle,
|
|
65
|
+
} from './types.js';
|
|
59
66
|
// Bare re-imports so `declare module './native-simctl.js'` augmentations in the command modules
|
|
60
67
|
// (which add their methods to NativeSimctl's type) reach downstream consumers' emitted .d.ts
|
|
61
68
|
// import graph — the named imports above alone aren't part of NativeSimctl's emitted type
|
|
@@ -78,17 +85,26 @@ import './commands/ui.js';
|
|
|
78
85
|
import './commands/video-recording.js';
|
|
79
86
|
import './commands/video-stream.js';
|
|
80
87
|
import './commands/webinspector.js';
|
|
81
|
-
import {NativeSimUnavailableError} from './errors.js';
|
|
82
|
-
import type {
|
|
83
|
-
NativeCoreSimModule,
|
|
84
|
-
NativeDeviceHandle,
|
|
85
|
-
NativeDeviceSetHandle,
|
|
86
|
-
NativeServiceContextHandle,
|
|
87
|
-
} from './types.js';
|
|
88
88
|
import {getPkgRoot, PACKAGE_NAME, runCatchingAsync} from './utils/index.js';
|
|
89
89
|
|
|
90
90
|
const require = createRequire(import.meta.url);
|
|
91
91
|
|
|
92
|
+
export interface NativeSimctlOptions {
|
|
93
|
+
/** Defaults to `xcode-select -p` (the active Xcode's Developer dir). */
|
|
94
|
+
developerDir?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Defaults to the default device set (`~/Library/Developer/CoreSimulator/Devices`), same as
|
|
97
|
+
* `simctl --set <path>`.
|
|
98
|
+
*/
|
|
99
|
+
deviceSetPath?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The versions of SpringBoard's notifications store (`sectionInfoVersionNumber`) the runtimes in
|
|
102
|
+
* use may have, the oldest first. Defaults to all the known ones; callers that know their Xcode
|
|
103
|
+
* narrow it. A store of any other version is refused.
|
|
104
|
+
*/
|
|
105
|
+
bulletinBoardVersions?: readonly number[];
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
/**
|
|
93
109
|
* Drives `CoreSimulator.framework` directly — no `simctl`/`xcrun` subprocess — for the operations
|
|
94
110
|
* `node-simctl`'s `Simctl` class exposes over the CLI. Devices are addressed by UDID; each call
|
|
@@ -110,18 +126,28 @@ const require = createRequire(import.meta.url);
|
|
|
110
126
|
export class NativeSimctl {
|
|
111
127
|
private cachedServiceContext: Promise<NativeServiceContextHandle> | undefined;
|
|
112
128
|
|
|
129
|
+
/** @internal Read by the command mixins. */
|
|
130
|
+
readonly _options: Readonly<NativeSimctlOptions>;
|
|
131
|
+
|
|
113
132
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* (
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* the simulator (getDevices(), createDevice(), ...) can raise.
|
|
133
|
+
* Constructing a NativeSimctl must never throw — the platform check and the native
|
|
134
|
+
* sharedServiceContext call both happen lazily in the `_serviceContext` getter below, so only a
|
|
135
|
+
* method that actually needs the simulator (getDevices(), createDevice(), ...) can raise.
|
|
136
|
+
*
|
|
137
|
+
* @param options — see {@link NativeSimctlOptions}.
|
|
120
138
|
*/
|
|
121
|
-
constructor(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
constructor(options?: NativeSimctlOptions);
|
|
140
|
+
/**
|
|
141
|
+
* @deprecated Pass {@link NativeSimctlOptions} instead.
|
|
142
|
+
* @param developerDir — see {@link NativeSimctlOptions.developerDir}.
|
|
143
|
+
* @param deviceSetPath — see {@link NativeSimctlOptions.deviceSetPath}.
|
|
144
|
+
*/
|
|
145
|
+
constructor(developerDir?: string, deviceSetPath?: string);
|
|
146
|
+
constructor(optionsOrDeveloperDir?: NativeSimctlOptions | string, deviceSetPath?: string) {
|
|
147
|
+
this._options = util.isPlainObject(optionsOrDeveloperDir)
|
|
148
|
+
? {...(optionsOrDeveloperDir as NativeSimctlOptions)}
|
|
149
|
+
: {developerDir: optionsOrDeveloperDir as string | undefined, deviceSetPath};
|
|
150
|
+
}
|
|
125
151
|
|
|
126
152
|
/** @returns `CFBundleVersion` of the loaded `CoreSimulator.framework` (e.g. `"1171.6"`). */
|
|
127
153
|
static async frameworkVersion(): Promise<string> {
|
|
@@ -143,7 +169,7 @@ export class NativeSimctl {
|
|
|
143
169
|
async _serviceContext(): Promise<NativeServiceContextHandle> {
|
|
144
170
|
if (!this.cachedServiceContext) {
|
|
145
171
|
this.cachedServiceContext = runCatchingAsync(() =>
|
|
146
|
-
loadNative().sharedServiceContext(this.developerDir ?? defaultDeveloperDir()),
|
|
172
|
+
loadNative().sharedServiceContext(this._options.developerDir ?? defaultDeveloperDir()),
|
|
147
173
|
).catch((err: unknown) => {
|
|
148
174
|
this.cachedServiceContext = undefined;
|
|
149
175
|
throw err;
|
|
@@ -155,7 +181,7 @@ export class NativeSimctl {
|
|
|
155
181
|
/** @internal Not part of the public API — exposed only so `src/commands/*.ts` mixins can call it. */
|
|
156
182
|
async _deviceSet(): Promise<NativeDeviceSetHandle> {
|
|
157
183
|
const context = await this._serviceContext();
|
|
158
|
-
const deviceSetPath = this.deviceSetPath;
|
|
184
|
+
const deviceSetPath = this._options.deviceSetPath;
|
|
159
185
|
return runCatchingAsync(() =>
|
|
160
186
|
deviceSetPath ? context.deviceSetWithPath(deviceSetPath) : context.defaultDeviceSet(),
|
|
161
187
|
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {plist} from '@appium/support';
|
|
2
|
+
|
|
3
|
+
import type {BulletinBoardModel, NotificationsStatus} from './bulletin-board-model.js';
|
|
4
|
+
|
|
5
|
+
/** The format used by SpringBoard since iOS 18. */
|
|
6
|
+
export const bulletinBoardModelV2: BulletinBoardModel = {
|
|
7
|
+
version: 2,
|
|
8
|
+
createStore: () => ({sectionInfoVersionNumber: 2}),
|
|
9
|
+
setNotificationsStatus(store, bundleId, status) {
|
|
10
|
+
const sectionInfo = (store.sectionInfo ??= {});
|
|
11
|
+
if (status === 'unset') {
|
|
12
|
+
delete sectionInfo[bundleId];
|
|
13
|
+
} else {
|
|
14
|
+
sectionInfo[bundleId] = buildSectionInfo(bundleId, status);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the NSKeyedArchiver-encoded `BBSectionInfo` of `bundleId`, the same archive
|
|
21
|
+
* AppleSimulatorUtils writes (SetNotificationsPermission.m). SpringBoard migrates its
|
|
22
|
+
* `allowsNotifications` flag to the current `authorizationStatus` when it loads the section.
|
|
23
|
+
*/
|
|
24
|
+
export function buildSectionInfo(
|
|
25
|
+
bundleId: string,
|
|
26
|
+
status: Exclude<NotificationsStatus, 'unset'>,
|
|
27
|
+
extraSectionKeys: Record<string, unknown> = {},
|
|
28
|
+
): Buffer {
|
|
29
|
+
const nil = {UID: 0};
|
|
30
|
+
return plist.createBinaryPlist({
|
|
31
|
+
$version: 100000,
|
|
32
|
+
$objects: [
|
|
33
|
+
'$null',
|
|
34
|
+
{
|
|
35
|
+
suppressFromSettings: false,
|
|
36
|
+
suppressedSettings: 0,
|
|
37
|
+
hideWeeApp: false,
|
|
38
|
+
sectionID: {UID: 2},
|
|
39
|
+
displayName: {UID: 5},
|
|
40
|
+
icon: nil,
|
|
41
|
+
displaysCriticalBulletins: false,
|
|
42
|
+
subsections: nil,
|
|
43
|
+
sectionInfoSettings: {UID: 3},
|
|
44
|
+
$class: {UID: 7},
|
|
45
|
+
sectionCategory: 0,
|
|
46
|
+
subsectionPriority: 0,
|
|
47
|
+
version: {UID: 6},
|
|
48
|
+
managedSectionInfoSettings: nil,
|
|
49
|
+
appName: {UID: 5},
|
|
50
|
+
sectionType: 0,
|
|
51
|
+
factorySectionID: nil,
|
|
52
|
+
dataProviderIDs: nil,
|
|
53
|
+
subsectionID: nil,
|
|
54
|
+
filters: nil,
|
|
55
|
+
pathToWeeAppPluginBundle: nil,
|
|
56
|
+
...extraSectionKeys,
|
|
57
|
+
},
|
|
58
|
+
bundleId,
|
|
59
|
+
{
|
|
60
|
+
pushSettings: 63,
|
|
61
|
+
showsInNotificationCenter: true,
|
|
62
|
+
allowsNotifications: status !== 'denied',
|
|
63
|
+
showsOnExternalDevices: true,
|
|
64
|
+
contentPreviewSetting: 0,
|
|
65
|
+
carPlaySetting: 0,
|
|
66
|
+
$class: {UID: 4},
|
|
67
|
+
showsInLockScreen: true,
|
|
68
|
+
alertType: 1,
|
|
69
|
+
criticalAlertSetting: status === 'critical' ? 2 : 0,
|
|
70
|
+
},
|
|
71
|
+
{$classname: 'BBSectionInfoSettings', $classes: ['BBSectionInfoSettings', 'NSObject']},
|
|
72
|
+
bundleId,
|
|
73
|
+
0,
|
|
74
|
+
{$classname: 'BBSectionInfo', $classes: ['BBSectionInfo', 'NSObject']},
|
|
75
|
+
],
|
|
76
|
+
$archiver: 'NSKeyedArchiver',
|
|
77
|
+
$top: {root: {UID: 1}},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {buildSectionInfo} from './bulletin-board-model-v2.js';
|
|
2
|
+
import type {BulletinBoardModel} from './bulletin-board-model.js';
|
|
3
|
+
|
|
4
|
+
/** The format used by SpringBoard since iOS 27: v2 with `isLocked` in the `BBSectionInfo`. */
|
|
5
|
+
export const bulletinBoardModelV3: BulletinBoardModel = {
|
|
6
|
+
version: 3,
|
|
7
|
+
createStore: () => ({sectionInfoVersionNumber: 3}),
|
|
8
|
+
setNotificationsStatus(store, bundleId, status) {
|
|
9
|
+
const sectionInfo = (store.sectionInfo ??= {});
|
|
10
|
+
if (status === 'unset') {
|
|
11
|
+
delete sectionInfo[bundleId];
|
|
12
|
+
} else {
|
|
13
|
+
sectionInfo[bundleId] = buildSectionInfo(bundleId, status, {isLocked: false});
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type NotificationsStatus = 'granted' | 'denied' | 'critical' | 'unset';
|
|
2
|
+
|
|
3
|
+
/** Content of `VersionedSectionInfo.plist`. */
|
|
4
|
+
export interface BulletinBoardStoreContent {
|
|
5
|
+
sectionInfoVersionNumber?: number;
|
|
6
|
+
sectionInfo?: Record<string, Uint8Array>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The data model of one `sectionInfoVersionNumber` of the BulletinBoard store. The store operations
|
|
11
|
+
* (locking, atomic replace, ...) don't depend on it, so a new format version only needs a new model.
|
|
12
|
+
*/
|
|
13
|
+
export interface BulletinBoardModel {
|
|
14
|
+
/** The `sectionInfoVersionNumber` this model reads and writes. */
|
|
15
|
+
readonly version: number;
|
|
16
|
+
/** An empty store, as SpringBoard of this version would start it. */
|
|
17
|
+
createStore(): BulletinBoardStoreContent;
|
|
18
|
+
/** Adds, replaces or (`unset`) removes the app's notifications section in place. */
|
|
19
|
+
setNotificationsStatus(store: BulletinBoardStoreContent, bundleId: string, status: NotificationsStatus): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {promisify} from 'node:util';
|
|
5
|
+
|
|
6
|
+
import {fs, plist} from '@appium/support';
|
|
7
|
+
|
|
8
|
+
import {bulletinBoardModelV2} from './bulletin-board-model-v2.js';
|
|
9
|
+
import {bulletinBoardModelV3} from './bulletin-board-model-v3.js';
|
|
10
|
+
import type {BulletinBoardModel, BulletinBoardStoreContent, NotificationsStatus} from './bulletin-board-model.js';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
// All the known data models, the oldest first.
|
|
15
|
+
const KNOWN_MODELS: readonly BulletinBoardModel[] = [bulletinBoardModelV2, bulletinBoardModelV3];
|
|
16
|
+
|
|
17
|
+
// The last pending update of each store, see BulletinBoardStore.withLock.
|
|
18
|
+
const pendingUpdates = new Map<string, Promise<void>>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SpringBoard's BulletinBoard store of a device (`Library/BulletinBoard/VersionedSectionInfo.plist`),
|
|
22
|
+
* which keeps the notifications settings of every app as one archived `BBSectionInfo` per bundle id.
|
|
23
|
+
*/
|
|
24
|
+
export class BulletinBoardStore {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
private readonly models: readonly BulletinBoardModel[];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param dataPath — data directory of the device
|
|
30
|
+
* @param versions — the `sectionInfoVersionNumber`s supported by the runtime, the oldest first
|
|
31
|
+
* (all the known ones by default). A store of any other version is refused, as the format may
|
|
32
|
+
* change in a future runtime. A missing store (a never-booted device) is created in the first
|
|
33
|
+
* one, as SpringBoard migrates older versions but not newer ones.
|
|
34
|
+
* @throws {Error} if a version is unknown
|
|
35
|
+
*/
|
|
36
|
+
constructor(dataPath: string, versions: readonly number[] = KNOWN_MODELS.map(({version}) => version)) {
|
|
37
|
+
this.models = versions.map((version) => {
|
|
38
|
+
const model = KNOWN_MODELS.find((m) => m.version === version);
|
|
39
|
+
if (!model) {
|
|
40
|
+
throw new Error(`Unknown BulletinBoard store version ${version}`);
|
|
41
|
+
}
|
|
42
|
+
return model;
|
|
43
|
+
});
|
|
44
|
+
this.path = path.join(dataPath, 'Library', 'BulletinBoard', 'VersionedSectionInfo.plist');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs `update` once every earlier update of the same store has settled: concurrent updates for
|
|
49
|
+
* different apps would otherwise read the same store, and the last write would drop the other app's
|
|
50
|
+
* section. Only the calls made by this process are serialized.
|
|
51
|
+
*/
|
|
52
|
+
async withLock<T>(update: () => Promise<T>): Promise<T> {
|
|
53
|
+
const result = (pendingUpdates.get(this.path) ?? Promise.resolve()).then(update);
|
|
54
|
+
const settled = result.then(
|
|
55
|
+
() => {},
|
|
56
|
+
() => {},
|
|
57
|
+
);
|
|
58
|
+
pendingUpdates.set(this.path, settled);
|
|
59
|
+
try {
|
|
60
|
+
return await result;
|
|
61
|
+
} finally {
|
|
62
|
+
if (pendingUpdates.get(this.path) === settled) {
|
|
63
|
+
pendingUpdates.delete(this.path);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Adds, replaces or (`unset`) removes the app's section. The store is replaced atomically, so a
|
|
70
|
+
* failed write leaves the previous one intact. A device that has never been booted has no store
|
|
71
|
+
* yet: it is created with just this section, and SpringBoard adds the rest on boot.
|
|
72
|
+
*
|
|
73
|
+
* @returns `false` if there was nothing to write (`unset` without a store)
|
|
74
|
+
* @throws {Error} if the existing store is of an unsupported format version
|
|
75
|
+
*/
|
|
76
|
+
async setNotificationsStatus(bundleId: string, status: NotificationsStatus): Promise<boolean> {
|
|
77
|
+
let store: BulletinBoardStoreContent;
|
|
78
|
+
let model: BulletinBoardModel;
|
|
79
|
+
if (await fs.exists(this.path)) {
|
|
80
|
+
store = (await plist.parsePlistFile(this.path)) as BulletinBoardStoreContent;
|
|
81
|
+
model = this.modelOf(store);
|
|
82
|
+
} else if (status === 'unset') {
|
|
83
|
+
return false;
|
|
84
|
+
} else {
|
|
85
|
+
await fs.mkdir(path.dirname(this.path), {recursive: true});
|
|
86
|
+
model = this.models[0];
|
|
87
|
+
store = model.createStore();
|
|
88
|
+
}
|
|
89
|
+
model.setNotificationsStatus(store, bundleId, status);
|
|
90
|
+
await this.replace(store);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Makes the store immutable (`uchg`) so that nothing, including a dying SpringBoard, can rewrite it.
|
|
96
|
+
* Best effort.
|
|
97
|
+
*/
|
|
98
|
+
async setImmutable(immutable: boolean): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await execFileAsync('chflags', [immutable ? 'uchg' : 'nouchg', this.path]);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private modelOf(store: BulletinBoardStoreContent): BulletinBoardModel {
|
|
105
|
+
const model = this.models.find(({version}) => version === store.sectionInfoVersionNumber);
|
|
106
|
+
if (!model) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Unsupported version of ${this.path}: sectionInfoVersionNumber is ${store.sectionInfoVersionNumber}, ` +
|
|
109
|
+
`supported: ${this.models.map(({version}) => version).join(', ')}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return model;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async replace(store: BulletinBoardStoreContent): Promise<void> {
|
|
116
|
+
// In the same directory, so that the rename is atomic.
|
|
117
|
+
const tmpPath = `${this.path}.${randomUUID()}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
await fs.writeFile(tmpPath, plist.createPlist(store, false), {flag: 'wx'});
|
|
120
|
+
await fs.rename(tmpPath, this.path);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export {getPkgRoot, PACKAGE_NAME} from './pkg-root.js';
|
|
2
2
|
export {runCatchingAsync, toTypedError} from './run-catching.js';
|
|
3
3
|
export {resolveDisplayPortId, validateDisplayId} from './display.js';
|
|
4
|
+
export {BulletinBoardStore} from './bulletin-board-store.js';
|
|
5
|
+
export type {NotificationsStatus} from './bulletin-board-model.js';
|
|
6
|
+
export {SpringBoard} from './springboard.js';
|