appium-ios-remotexpc 0.3.3 → 0.4.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.
@@ -0,0 +1,517 @@
1
+ import { logger } from '@appium/support';
2
+ import { createHash } from 'crypto';
3
+ import { Stats, promises as fs } from 'fs';
4
+ import { performance } from 'perf_hooks';
5
+ import { Readable } from 'stream';
6
+
7
+ import { parseXmlPlist } from '../../../lib/plist/index.js';
8
+ import { getManifestFromTSS } from '../../../lib/tss/index.js';
9
+ import type {
10
+ MobileImageMounterService as MobileImageMounterServiceInterface,
11
+ PlistDictionary,
12
+ } from '../../../lib/types.js';
13
+ import { ServiceConnection } from '../../../service-connection.js';
14
+ import { BaseService } from '../base-service.js';
15
+
16
+ const log = logger.getLogger('MobileImageMounterService');
17
+
18
+ /**
19
+ * Base interface for service responses
20
+ */
21
+ interface BaseResponse {
22
+ Status?: string;
23
+ Error?: string;
24
+ DetailedError?: string;
25
+ }
26
+
27
+ /**
28
+ * Interface for image-related responses
29
+ */
30
+ export interface ImageResponse extends BaseResponse {
31
+ ImagePresent?: boolean;
32
+ ImageSignature?: Buffer[] | Buffer;
33
+ }
34
+
35
+ /**
36
+ * MobileImageMounterService provides an API to:
37
+ * - Mount Developer Disk Images on iOS devices
38
+ * - Lookup mounted images and their signatures
39
+ * - Check if personalized images are mounted
40
+ * - Unmount images when needed
41
+ */
42
+ class MobileImageMounterService
43
+ extends BaseService
44
+ implements MobileImageMounterServiceInterface
45
+ {
46
+ static readonly RSD_SERVICE_NAME =
47
+ 'com.apple.mobile.mobile_image_mounter.shim.remote';
48
+
49
+ // Constants
50
+ private static readonly FILE_TYPE_IMAGE = 'image';
51
+ private static readonly FILE_TYPE_BUILD_MANIFEST = 'build_manifest';
52
+ private static readonly FILE_TYPE_TRUST_CACHE = 'trust_cache';
53
+ private static readonly IMAGE_TYPE = 'Personalized';
54
+ private static readonly MOUNT_PATH = '/System/Developer';
55
+ private static readonly UPLOAD_IMAGE_TIMEOUT = 20000;
56
+
57
+ // Connection cache
58
+ private connection: ServiceConnection | null = null;
59
+
60
+ constructor(address: [string, number]) {
61
+ super(address);
62
+ }
63
+
64
+ /**
65
+ * Clean up resources when service is no longer needed
66
+ */
67
+ async cleanup(): Promise<void> {
68
+ this.closeConnection();
69
+ }
70
+
71
+ /**
72
+ * Lookup mounted images by type
73
+ * @param imageType Type of image to lookup (defaults to 'Personalized')
74
+ * @returns Array of signatures of mounted images
75
+ */
76
+ async lookup(
77
+ imageType = MobileImageMounterService.IMAGE_TYPE,
78
+ ): Promise<Buffer[]> {
79
+ const response = (await this.sendRequest({
80
+ Command: 'LookupImage',
81
+ ImageType: imageType,
82
+ })) as ImageResponse;
83
+
84
+ const signatures = response.ImageSignature || [];
85
+ return signatures.filter(Buffer.isBuffer) as Buffer[];
86
+ }
87
+
88
+ /**
89
+ * Check if personalized image is mounted
90
+ * @returns True if personalized image is mounted
91
+ */
92
+ async isPersonalizedImageMounted(): Promise<boolean> {
93
+ try {
94
+ return (await this.lookup()).length > 0;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Mount personalized image for device (iOS >= 17)
102
+ * @param imageFilePath Path to the image file (.dmg)
103
+ * @param buildManifestFilePath Path to the build manifest file (.plist)
104
+ * @param trustCacheFilePath Path to the trust cache file (.trustcache)
105
+ * @param infoPlist Optional info plist dictionary
106
+ */
107
+ async mount(
108
+ imageFilePath: string,
109
+ buildManifestFilePath: string,
110
+ trustCacheFilePath: string,
111
+ infoPlist?: PlistDictionary,
112
+ ): Promise<void> {
113
+ if (await this.isPersonalizedImageMounted()) {
114
+ log.info('Personalized image is already mounted');
115
+ return;
116
+ }
117
+
118
+ const start = performance.now();
119
+
120
+ // Validate files and read content
121
+ await Promise.all([
122
+ this.assertIsFile(
123
+ imageFilePath,
124
+ MobileImageMounterService.FILE_TYPE_IMAGE,
125
+ ),
126
+ this.assertIsFile(
127
+ buildManifestFilePath,
128
+ MobileImageMounterService.FILE_TYPE_BUILD_MANIFEST,
129
+ ),
130
+ this.assertIsFile(
131
+ trustCacheFilePath,
132
+ MobileImageMounterService.FILE_TYPE_TRUST_CACHE,
133
+ ),
134
+ ]);
135
+
136
+ const [image, trustCache, buildManifestContent] = await Promise.all([
137
+ fs.readFile(imageFilePath),
138
+ fs.readFile(trustCacheFilePath),
139
+ fs.readFile(buildManifestFilePath, 'utf8'),
140
+ ]);
141
+
142
+ const buildManifest = parseXmlPlist(
143
+ buildManifestContent,
144
+ ) as PlistDictionary;
145
+ const manifest = await this.getOrRetrieveManifestFromTSS(
146
+ image,
147
+ buildManifest,
148
+ );
149
+
150
+ await this.uploadImage(
151
+ MobileImageMounterService.IMAGE_TYPE,
152
+ image,
153
+ manifest,
154
+ );
155
+
156
+ const extras: Record<string, any> = {
157
+ ImageTrustCache: trustCache,
158
+ };
159
+
160
+ if (infoPlist) {
161
+ extras.ImageInfoPlist = infoPlist;
162
+ }
163
+
164
+ await this.mountImage(
165
+ MobileImageMounterService.IMAGE_TYPE,
166
+ manifest,
167
+ extras,
168
+ );
169
+
170
+ const end = performance.now();
171
+ log.info(
172
+ `Successfully mounted personalized image in ${(end - start).toFixed(2)} ms`,
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Unmount image from device
178
+ * @param mountPath Mount path to unmount (defaults to '/System/Developer')
179
+ */
180
+ async unmountImage(
181
+ mountPath = MobileImageMounterService.MOUNT_PATH,
182
+ ): Promise<void> {
183
+ const response = (await this.sendRequest({
184
+ Command: 'UnmountImage',
185
+ MountPath: mountPath,
186
+ })) as BaseResponse;
187
+
188
+ if (response.Error === 'UnknownCommand') {
189
+ throw new Error('Unmount command is not supported on this iOS version');
190
+ }
191
+ if (response.DetailedError?.includes('There is no matching entry')) {
192
+ throw new Error(`No mounted image found at path: ${mountPath}`);
193
+ }
194
+ if (response.Error === 'InternalError') {
195
+ throw new Error(
196
+ `Internal error occurred while unmounting: ${JSON.stringify(response)}`,
197
+ );
198
+ }
199
+
200
+ this.checkIfError(response);
201
+ log.info(`Successfully unmounted image from ${mountPath}`);
202
+ }
203
+
204
+ /**
205
+ * Query developer mode status (iOS 16+)
206
+ * @returns True if developer mode is enabled (defaults to true for older iOS)
207
+ */
208
+ async queryDeveloperModeStatus(): Promise<boolean> {
209
+ try {
210
+ const response = await this.sendRequest({
211
+ Command: 'QueryDeveloperModeStatus',
212
+ });
213
+ this.checkIfError(response);
214
+ return Boolean(response.DeveloperModeStatus);
215
+ } catch {
216
+ return true; // Default for older iOS versions
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Query personalization nonce for personalized images
222
+ * @param personalizedImageType Optional personalized image type
223
+ * @returns Personalization nonce as Buffer
224
+ */
225
+ async queryNonce(personalizedImageType?: string): Promise<Buffer> {
226
+ const request: PlistDictionary = { Command: 'QueryNonce' };
227
+ if (personalizedImageType) {
228
+ request.PersonalizedImageType = personalizedImageType;
229
+ }
230
+
231
+ const response = await this.sendRequest(request);
232
+ this.checkIfError(response);
233
+
234
+ const nonce = response.PersonalizationNonce;
235
+ if (!Buffer.isBuffer(nonce)) {
236
+ throw new Error('Invalid nonce received from device');
237
+ }
238
+ return nonce;
239
+ }
240
+
241
+ /**
242
+ * Query personalization identifiers from the device
243
+ * @returns Personalization identifiers dictionary
244
+ */
245
+ async queryPersonalizationIdentifiers(): Promise<PlistDictionary> {
246
+ const response = await this.sendRequest({
247
+ Command: 'QueryPersonalizationIdentifiers',
248
+ });
249
+ this.checkIfError(response);
250
+ return response.PersonalizationIdentifiers as PlistDictionary;
251
+ }
252
+
253
+ /**
254
+ * Copy devices info (only for mounted images)
255
+ * @returns List of mounted devices
256
+ */
257
+ async copyDevices(): Promise<any[]> {
258
+ const response = await this.sendRequest({ Command: 'CopyDevices' });
259
+ return (response.EntryList as any[]) || [];
260
+ }
261
+
262
+ /**
263
+ * Query personalization manifest from device
264
+ * @param imageType The image type
265
+ * @param signature The image signature/hash
266
+ * @returns Personalization manifest as Buffer
267
+ */
268
+ async queryPersonalizationManifest(
269
+ imageType: string,
270
+ signature: Buffer,
271
+ ): Promise<Buffer> {
272
+ try {
273
+ const response = await this.sendRequest({
274
+ Command: 'QueryPersonalizationManifest',
275
+ PersonalizedImageType: imageType,
276
+ ImageType: imageType,
277
+ ImageSignature: signature,
278
+ });
279
+
280
+ this.checkIfError(response);
281
+ const manifest = response.ImageSignature;
282
+
283
+ if (!manifest || !Buffer.isBuffer(manifest)) {
284
+ throw new Error(
285
+ 'MissingManifestError: Personalization manifest not found on device',
286
+ );
287
+ }
288
+
289
+ return manifest;
290
+ } catch (error) {
291
+ if (
292
+ error instanceof Error &&
293
+ error.message.includes('MissingManifestError')
294
+ ) {
295
+ throw error;
296
+ }
297
+ throw new Error(
298
+ 'MissingManifestError: Personalization manifest not found on device',
299
+ );
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Upload image to device
305
+ * @param imageType The image type
306
+ * @param image The image data
307
+ * @param signature The image signature/manifest
308
+ * @param timeout Optional timeout for upload operation (defaults to 20000ms)
309
+ */
310
+ async uploadImage(
311
+ imageType: string,
312
+ image: Buffer,
313
+ signature: Buffer,
314
+ timeout = MobileImageMounterService.UPLOAD_IMAGE_TIMEOUT,
315
+ ): Promise<void> {
316
+ const receiveBytesResult = (await this.sendRequest({
317
+ Command: 'ReceiveBytes',
318
+ ImageType: imageType,
319
+ ImageSize: image.length,
320
+ ImageSignature: signature,
321
+ })) as BaseResponse;
322
+
323
+ this.checkIfError(receiveBytesResult);
324
+
325
+ if (receiveBytesResult.Status !== 'ReceiveBytesAck') {
326
+ throw new Error(
327
+ `Unexpected return from mobile_image_mounter: ${JSON.stringify(receiveBytesResult)}`,
328
+ );
329
+ }
330
+
331
+ const conn = await this.connectToMobileImageMounterService();
332
+ const socket = conn.getSocket();
333
+
334
+ await new Promise<void>((resolve, reject) => {
335
+ socket.write(image, (error?: Error | null) =>
336
+ error ? reject(error) : resolve(),
337
+ );
338
+ });
339
+
340
+ const uploadResult = await conn.receive(timeout);
341
+ if (uploadResult.Status !== 'Complete') {
342
+ throw new Error(`Image upload failed: ${JSON.stringify(uploadResult)}`);
343
+ }
344
+
345
+ log.debug('Image uploaded successfully');
346
+ }
347
+
348
+ /**
349
+ * Mount image on device
350
+ * @param imageType The image type
351
+ * @param signature The image signature/manifest
352
+ * @param extras Additional parameters for mounting
353
+ */
354
+ async mountImage(
355
+ imageType: string,
356
+ signature: Buffer,
357
+ extras?: Record<string, any>,
358
+ ): Promise<void> {
359
+ const request = {
360
+ Command: 'MountImage',
361
+ ImageType: imageType,
362
+ ImageSignature: signature,
363
+ ...extras,
364
+ };
365
+
366
+ const response = (await this.sendRequest(request)) as BaseResponse;
367
+
368
+ if (response.DetailedError?.includes('is already mounted')) {
369
+ log.info('Image was already mounted');
370
+ return;
371
+ }
372
+
373
+ if (response.DetailedError?.includes('Developer mode is not enabled')) {
374
+ throw new Error('Developer mode is not enabled on this device');
375
+ }
376
+
377
+ this.checkIfError(response);
378
+
379
+ if (response.Status !== 'Complete') {
380
+ throw new Error(`Mount image failed: ${JSON.stringify(response)}`);
381
+ }
382
+
383
+ log.debug('Image mounted successfully');
384
+ }
385
+
386
+ private async sendRequest(
387
+ request: PlistDictionary,
388
+ timeout?: number,
389
+ ): Promise<PlistDictionary> {
390
+ const isNewConnection = !this.connection || this.isConnectionDestroyed();
391
+ const conn = await this.connectToMobileImageMounterService();
392
+ const res = await conn.sendPlistRequest(request, timeout);
393
+
394
+ if (isNewConnection && res?.Request === 'StartService') {
395
+ return await conn.receive();
396
+ }
397
+ return res;
398
+ }
399
+
400
+ /**
401
+ * Calculate hash of a buffer asynchronously
402
+ * @param buffer The buffer to hash
403
+ * @returns Promise resolving to the hash digest
404
+ */
405
+ private async hashLargeBufferAsync(buffer: Buffer): Promise<Buffer> {
406
+ return new Promise((resolve, reject) => {
407
+ const hash = createHash('sha384');
408
+ const stream = Readable.from(buffer);
409
+
410
+ stream.on('data', (chunk) => {
411
+ hash.update(chunk);
412
+ });
413
+
414
+ stream.on('end', () => {
415
+ resolve(hash.digest());
416
+ });
417
+
418
+ stream.on('error', (err) => {
419
+ reject(err);
420
+ });
421
+ });
422
+ }
423
+
424
+ private async getOrRetrieveManifestFromTSS(
425
+ image: Buffer,
426
+ buildManifest: PlistDictionary,
427
+ ): Promise<Buffer> {
428
+ try {
429
+ const imageHash = await this.hashLargeBufferAsync(image);
430
+ const manifest = await this.queryPersonalizationManifest(
431
+ 'DeveloperDiskImage',
432
+ imageHash,
433
+ );
434
+ log.debug(
435
+ 'Successfully retrieved existing personalization manifest from device',
436
+ );
437
+ return manifest;
438
+ } catch (error) {
439
+ if ((error as Error).message?.includes('MissingManifestError')) {
440
+ log.debug('Personalization manifest not found on device, using TSS...');
441
+
442
+ const identifiers = await this.queryPersonalizationIdentifiers();
443
+
444
+ const manifest = await getManifestFromTSS(
445
+ identifiers,
446
+ buildManifest,
447
+ (type: string) => this.queryNonce(type),
448
+ );
449
+
450
+ log.debug('Successfully generated manifest from TSS');
451
+ return manifest;
452
+ }
453
+ throw error;
454
+ }
455
+ }
456
+
457
+ private isConnectionDestroyed(): boolean {
458
+ try {
459
+ const socket = this.connection!.getSocket();
460
+ return !socket || socket.destroyed;
461
+ } catch {
462
+ return true;
463
+ }
464
+ }
465
+
466
+ private async connectToMobileImageMounterService(): Promise<ServiceConnection> {
467
+ if (this.connection && !this.isConnectionDestroyed()) {
468
+ return this.connection;
469
+ }
470
+
471
+ const newConnection = await this.startLockdownService({
472
+ serviceName: MobileImageMounterService.RSD_SERVICE_NAME,
473
+ port: this.address[1].toString(),
474
+ });
475
+
476
+ this.connection = newConnection;
477
+ return newConnection;
478
+ }
479
+
480
+ private closeConnection(): void {
481
+ if (this.connection) {
482
+ try {
483
+ this.connection.close();
484
+ } catch {
485
+ // Ignore close errors
486
+ }
487
+ this.connection = null;
488
+ }
489
+ }
490
+
491
+ private checkIfError(response: BaseResponse): void {
492
+ if (response.Error) {
493
+ throw new Error(response.Error);
494
+ }
495
+ }
496
+
497
+ private async assertIsFile(
498
+ filePath: string,
499
+ fileType: string,
500
+ ): Promise<Stats> {
501
+ try {
502
+ const fileStat = await fs.stat(filePath);
503
+ if (!fileStat.isFile()) {
504
+ throw new Error(`Expected ${fileType} file, got non-file: ${filePath}`);
505
+ }
506
+ return fileStat;
507
+ } catch (error: any) {
508
+ if (error.code === 'ENOENT') {
509
+ throw new Error(`${fileType} file not found: ${filePath}`);
510
+ }
511
+ throw error;
512
+ }
513
+ }
514
+ }
515
+
516
+ export default MobileImageMounterService;
517
+ export { MobileImageMounterService };
package/src/services.ts CHANGED
@@ -5,10 +5,12 @@ import { TunnelManager } from './lib/tunnel/index.js';
5
5
  import { TunnelApiClient } from './lib/tunnel/tunnel-api-client.js';
6
6
  import type {
7
7
  DiagnosticsServiceWithConnection,
8
+ MobileImageMounterServiceWithConnection,
8
9
  NotificationProxyServiceWithConnection,
9
10
  SyslogService as SyslogServiceType,
10
11
  } from './lib/types.js';
11
12
  import DiagnosticsService from './services/ios/diagnostic-service/index.js';
13
+ import MobileImageMounterService from './services/ios/mobile-image-mounter/index.js';
12
14
  import { NotificationProxyService } from './services/ios/notification-proxy/index.js';
13
15
  import SyslogService from './services/ios/syslog-service/index.js';
14
16
 
@@ -47,6 +49,22 @@ export async function startNotificationProxyService(
47
49
  };
48
50
  }
49
51
 
52
+ export async function startMobileImageMounterService(
53
+ udid: string,
54
+ ): Promise<MobileImageMounterServiceWithConnection> {
55
+ const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
56
+ const mobileImageMounterService = remoteXPC.findService(
57
+ MobileImageMounterService.RSD_SERVICE_NAME,
58
+ );
59
+ return {
60
+ remoteXPC: remoteXPC as RemoteXpcConnection,
61
+ mobileImageMounterService: new MobileImageMounterService([
62
+ tunnelConnection.host,
63
+ parseInt(mobileImageMounterService.port, 10),
64
+ ]),
65
+ };
66
+ }
67
+
50
68
  export async function startSyslogService(
51
69
  udid: string,
52
70
  ): Promise<SyslogServiceType> {