appium-ios-remotexpc 0.3.3 → 0.4.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.
@@ -0,0 +1,363 @@
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
+ import { parseXmlPlist } from '../../../lib/plist/index.js';
7
+ import { getManifestFromTSS } from '../../../lib/tss/index.js';
8
+ import { ServiceConnection } from '../../../service-connection.js';
9
+ import { BaseService } from '../base-service.js';
10
+ const log = logger.getLogger('MobileImageMounterService');
11
+ /**
12
+ * MobileImageMounterService provides an API to:
13
+ * - Mount Developer Disk Images on iOS devices
14
+ * - Lookup mounted images and their signatures
15
+ * - Check if personalized images are mounted
16
+ * - Unmount images when needed
17
+ */
18
+ class MobileImageMounterService extends BaseService {
19
+ static RSD_SERVICE_NAME = 'com.apple.mobile.mobile_image_mounter.shim.remote';
20
+ // Constants
21
+ static FILE_TYPE_IMAGE = 'image';
22
+ static FILE_TYPE_BUILD_MANIFEST = 'build_manifest';
23
+ static FILE_TYPE_TRUST_CACHE = 'trust_cache';
24
+ static IMAGE_TYPE = 'Personalized';
25
+ static MOUNT_PATH = '/System/Developer';
26
+ static UPLOAD_IMAGE_TIMEOUT = 20000;
27
+ // Connection cache
28
+ connection = null;
29
+ constructor(address) {
30
+ super(address);
31
+ }
32
+ /**
33
+ * Clean up resources when service is no longer needed
34
+ */
35
+ async cleanup() {
36
+ this.closeConnection();
37
+ }
38
+ /**
39
+ * Lookup mounted images by type
40
+ * @param imageType Type of image to lookup (defaults to 'Personalized')
41
+ * @returns Array of signatures of mounted images
42
+ */
43
+ async lookup(imageType = MobileImageMounterService.IMAGE_TYPE) {
44
+ const response = (await this.sendRequest({
45
+ Command: 'LookupImage',
46
+ ImageType: imageType,
47
+ }));
48
+ const signatures = response.ImageSignature || [];
49
+ return signatures.filter(Buffer.isBuffer);
50
+ }
51
+ /**
52
+ * Check if personalized image is mounted
53
+ * @returns True if personalized image is mounted
54
+ */
55
+ async isPersonalizedImageMounted() {
56
+ try {
57
+ return (await this.lookup()).length > 0;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * Mount personalized image for device (iOS >= 17)
65
+ * @param imageFilePath Path to the image file (.dmg)
66
+ * @param buildManifestFilePath Path to the build manifest file (.plist)
67
+ * @param trustCacheFilePath Path to the trust cache file (.trustcache)
68
+ * @param infoPlist Optional info plist dictionary
69
+ */
70
+ async mount(imageFilePath, buildManifestFilePath, trustCacheFilePath, infoPlist) {
71
+ if (await this.isPersonalizedImageMounted()) {
72
+ log.info('Personalized image is already mounted');
73
+ return;
74
+ }
75
+ const start = performance.now();
76
+ // Validate files and read content
77
+ await Promise.all([
78
+ this.assertIsFile(imageFilePath, MobileImageMounterService.FILE_TYPE_IMAGE),
79
+ this.assertIsFile(buildManifestFilePath, MobileImageMounterService.FILE_TYPE_BUILD_MANIFEST),
80
+ this.assertIsFile(trustCacheFilePath, MobileImageMounterService.FILE_TYPE_TRUST_CACHE),
81
+ ]);
82
+ const [image, trustCache, buildManifestContent] = await Promise.all([
83
+ fs.readFile(imageFilePath),
84
+ fs.readFile(trustCacheFilePath),
85
+ fs.readFile(buildManifestFilePath, 'utf8'),
86
+ ]);
87
+ const buildManifest = parseXmlPlist(buildManifestContent);
88
+ const manifest = await this.getOrRetrieveManifestFromTSS(image, buildManifest);
89
+ await this.uploadImage(MobileImageMounterService.IMAGE_TYPE, image, manifest);
90
+ const extras = {
91
+ ImageTrustCache: trustCache,
92
+ };
93
+ if (infoPlist) {
94
+ extras.ImageInfoPlist = infoPlist;
95
+ }
96
+ await this.mountImage(MobileImageMounterService.IMAGE_TYPE, manifest, extras);
97
+ const end = performance.now();
98
+ log.info(`Successfully mounted personalized image in ${(end - start).toFixed(2)} ms`);
99
+ }
100
+ /**
101
+ * Unmount image from device
102
+ * @param mountPath Mount path to unmount (defaults to '/System/Developer')
103
+ */
104
+ async unmountImage(mountPath = MobileImageMounterService.MOUNT_PATH) {
105
+ const response = (await this.sendRequest({
106
+ Command: 'UnmountImage',
107
+ MountPath: mountPath,
108
+ }));
109
+ if (response.Error === 'UnknownCommand') {
110
+ throw new Error('Unmount command is not supported on this iOS version');
111
+ }
112
+ if (response.DetailedError?.includes('There is no matching entry')) {
113
+ throw new Error(`No mounted image found at path: ${mountPath}`);
114
+ }
115
+ if (response.Error === 'InternalError') {
116
+ throw new Error(`Internal error occurred while unmounting: ${JSON.stringify(response)}`);
117
+ }
118
+ this.checkIfError(response);
119
+ log.info(`Successfully unmounted image from ${mountPath}`);
120
+ }
121
+ /**
122
+ * Query developer mode status (iOS 16+)
123
+ * @returns True if developer mode is enabled (defaults to true for older iOS)
124
+ */
125
+ async queryDeveloperModeStatus() {
126
+ try {
127
+ const response = await this.sendRequest({
128
+ Command: 'QueryDeveloperModeStatus',
129
+ });
130
+ this.checkIfError(response);
131
+ return Boolean(response.DeveloperModeStatus);
132
+ }
133
+ catch {
134
+ return true; // Default for older iOS versions
135
+ }
136
+ }
137
+ /**
138
+ * Query personalization nonce for personalized images
139
+ * @param personalizedImageType Optional personalized image type
140
+ * @returns Personalization nonce as Buffer
141
+ */
142
+ async queryNonce(personalizedImageType) {
143
+ const request = { Command: 'QueryNonce' };
144
+ if (personalizedImageType) {
145
+ request.PersonalizedImageType = personalizedImageType;
146
+ }
147
+ const response = await this.sendRequest(request);
148
+ this.checkIfError(response);
149
+ const nonce = response.PersonalizationNonce;
150
+ if (!Buffer.isBuffer(nonce)) {
151
+ throw new Error('Invalid nonce received from device');
152
+ }
153
+ return nonce;
154
+ }
155
+ /**
156
+ * Query personalization identifiers from the device
157
+ * @returns Personalization identifiers dictionary
158
+ */
159
+ async queryPersonalizationIdentifiers() {
160
+ const response = await this.sendRequest({
161
+ Command: 'QueryPersonalizationIdentifiers',
162
+ });
163
+ this.checkIfError(response);
164
+ return response.PersonalizationIdentifiers;
165
+ }
166
+ /**
167
+ * Copy devices info (only for mounted images)
168
+ * @returns List of mounted devices
169
+ */
170
+ async copyDevices() {
171
+ const response = await this.sendRequest({ Command: 'CopyDevices' });
172
+ return response.EntryList || [];
173
+ }
174
+ /**
175
+ * Query personalization manifest from device
176
+ * @param imageType The image type
177
+ * @param signature The image signature/hash
178
+ * @returns Personalization manifest as Buffer
179
+ */
180
+ async queryPersonalizationManifest(imageType, signature) {
181
+ try {
182
+ const response = await this.sendRequest({
183
+ Command: 'QueryPersonalizationManifest',
184
+ PersonalizedImageType: imageType,
185
+ ImageType: imageType,
186
+ ImageSignature: signature,
187
+ });
188
+ this.checkIfError(response);
189
+ const manifest = response.ImageSignature;
190
+ if (!manifest || !Buffer.isBuffer(manifest)) {
191
+ throw new Error('MissingManifestError: Personalization manifest not found on device');
192
+ }
193
+ return manifest;
194
+ }
195
+ catch (error) {
196
+ if (error instanceof Error &&
197
+ error.message.includes('MissingManifestError')) {
198
+ throw error;
199
+ }
200
+ throw new Error('MissingManifestError: Personalization manifest not found on device');
201
+ }
202
+ }
203
+ /**
204
+ * Upload image to device
205
+ * @param imageType The image type
206
+ * @param image The image data
207
+ * @param signature The image signature/manifest
208
+ * @param timeout Optional timeout for upload operation (defaults to 20000ms)
209
+ */
210
+ async uploadImage(imageType, image, signature, timeout = MobileImageMounterService.UPLOAD_IMAGE_TIMEOUT) {
211
+ const receiveBytesResult = (await this.sendRequest({
212
+ Command: 'ReceiveBytes',
213
+ ImageType: imageType,
214
+ ImageSize: image.length,
215
+ ImageSignature: signature,
216
+ }));
217
+ this.checkIfError(receiveBytesResult);
218
+ if (receiveBytesResult.Status !== 'ReceiveBytesAck') {
219
+ throw new Error(`Unexpected return from mobile_image_mounter: ${JSON.stringify(receiveBytesResult)}`);
220
+ }
221
+ const conn = await this.connectToMobileImageMounterService();
222
+ const socket = conn.getSocket();
223
+ await new Promise((resolve, reject) => {
224
+ socket.write(image, (error) => error ? reject(error) : resolve());
225
+ });
226
+ const uploadResult = await conn.receive(timeout);
227
+ if (uploadResult.Status !== 'Complete') {
228
+ throw new Error(`Image upload failed: ${JSON.stringify(uploadResult)}`);
229
+ }
230
+ log.debug('Image uploaded successfully');
231
+ }
232
+ /**
233
+ * Mount image on device
234
+ * @param imageType The image type
235
+ * @param signature The image signature/manifest
236
+ * @param extras Additional parameters for mounting
237
+ */
238
+ async mountImage(imageType, signature, extras) {
239
+ const request = {
240
+ Command: 'MountImage',
241
+ ImageType: imageType,
242
+ ImageSignature: signature,
243
+ ...extras,
244
+ };
245
+ const response = (await this.sendRequest(request));
246
+ if (response.DetailedError?.includes('is already mounted')) {
247
+ log.info('Image was already mounted');
248
+ return;
249
+ }
250
+ if (response.DetailedError?.includes('Developer mode is not enabled')) {
251
+ throw new Error('Developer mode is not enabled on this device');
252
+ }
253
+ this.checkIfError(response);
254
+ if (response.Status !== 'Complete') {
255
+ throw new Error(`Mount image failed: ${JSON.stringify(response)}`);
256
+ }
257
+ log.debug('Image mounted successfully');
258
+ }
259
+ async sendRequest(request, timeout) {
260
+ const isNewConnection = !this.connection || this.isConnectionDestroyed();
261
+ const conn = await this.connectToMobileImageMounterService();
262
+ const res = await conn.sendPlistRequest(request, timeout);
263
+ if (isNewConnection && res?.Request === 'StartService') {
264
+ return await conn.receive();
265
+ }
266
+ return res;
267
+ }
268
+ /**
269
+ * Calculate hash of a buffer asynchronously
270
+ * @param buffer The buffer to hash
271
+ * @returns Promise resolving to the hash digest
272
+ */
273
+ async hashLargeBufferAsync(buffer) {
274
+ return new Promise((resolve, reject) => {
275
+ const hash = createHash('sha384');
276
+ const stream = Readable.from(buffer);
277
+ stream.on('data', (chunk) => {
278
+ hash.update(chunk);
279
+ });
280
+ stream.on('end', () => {
281
+ resolve(hash.digest());
282
+ });
283
+ stream.on('error', (err) => {
284
+ reject(err);
285
+ });
286
+ });
287
+ }
288
+ async getOrRetrieveManifestFromTSS(image, buildManifest) {
289
+ try {
290
+ const imageHash = await this.hashLargeBufferAsync(image);
291
+ const manifest = await this.queryPersonalizationManifest('DeveloperDiskImage', imageHash);
292
+ log.debug('Successfully retrieved existing personalization manifest from device');
293
+ return manifest;
294
+ }
295
+ catch (error) {
296
+ if (error.message?.includes('MissingManifestError')) {
297
+ log.debug('Personalization manifest not found on device, using TSS...');
298
+ const identifiers = await this.queryPersonalizationIdentifiers();
299
+ const ecid = identifiers.UniqueChipID;
300
+ if (!ecid) {
301
+ throw new Error('Could not retrieve device ECID from personalization identifiers');
302
+ }
303
+ const manifest = await getManifestFromTSS(ecid, buildManifest, () => this.queryPersonalizationIdentifiers(), (type) => this.queryNonce(type));
304
+ log.debug('Successfully generated manifest from TSS');
305
+ return manifest;
306
+ }
307
+ throw error;
308
+ }
309
+ }
310
+ isConnectionDestroyed() {
311
+ try {
312
+ const socket = this.connection.getSocket();
313
+ return !socket || socket.destroyed;
314
+ }
315
+ catch {
316
+ return true;
317
+ }
318
+ }
319
+ async connectToMobileImageMounterService() {
320
+ if (this.connection && !this.isConnectionDestroyed()) {
321
+ return this.connection;
322
+ }
323
+ const newConnection = await this.startLockdownService({
324
+ serviceName: MobileImageMounterService.RSD_SERVICE_NAME,
325
+ port: this.address[1].toString(),
326
+ });
327
+ this.connection = newConnection;
328
+ return newConnection;
329
+ }
330
+ closeConnection() {
331
+ if (this.connection) {
332
+ try {
333
+ this.connection.close();
334
+ }
335
+ catch {
336
+ // Ignore close errors
337
+ }
338
+ this.connection = null;
339
+ }
340
+ }
341
+ checkIfError(response) {
342
+ if (response.Error) {
343
+ throw new Error(response.Error);
344
+ }
345
+ }
346
+ async assertIsFile(filePath, fileType) {
347
+ try {
348
+ const fileStat = await fs.stat(filePath);
349
+ if (!fileStat.isFile()) {
350
+ throw new Error(`Expected ${fileType} file, got non-file: ${filePath}`);
351
+ }
352
+ return fileStat;
353
+ }
354
+ catch (error) {
355
+ if (error.code === 'ENOENT') {
356
+ throw new Error(`${fileType} file not found: ${filePath}`);
357
+ }
358
+ throw error;
359
+ }
360
+ }
361
+ }
362
+ export default MobileImageMounterService;
363
+ export { MobileImageMounterService };
@@ -1,7 +1,8 @@
1
1
  import { RemoteXpcConnection } from './lib/remote-xpc/remote-xpc-connection.js';
2
- import type { DiagnosticsServiceWithConnection, NotificationProxyServiceWithConnection, SyslogService as SyslogServiceType } from './lib/types.js';
2
+ import type { DiagnosticsServiceWithConnection, MobileImageMounterServiceWithConnection, NotificationProxyServiceWithConnection, SyslogService as SyslogServiceType } from './lib/types.js';
3
3
  export declare function startDiagnosticsService(udid: string): Promise<DiagnosticsServiceWithConnection>;
4
4
  export declare function startNotificationProxyService(udid: string): Promise<NotificationProxyServiceWithConnection>;
5
+ export declare function startMobileImageMounterService(udid: string): Promise<MobileImageMounterServiceWithConnection>;
5
6
  export declare function startSyslogService(udid: string): Promise<SyslogServiceType>;
6
7
  export declare function createRemoteXPCConnection(udid: string): Promise<{
7
8
  remoteXPC: RemoteXpcConnection;
@@ -1 +1 @@
1
- {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/services.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAGhF,OAAO,KAAK,EACV,gCAAgC,EAChC,sCAAsC,EACtC,aAAa,IAAI,iBAAiB,EACnC,MAAM,gBAAgB,CAAC;AAQxB,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,sCAAsC,CAAC,CAYjD;AAED,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAG5B;AAED,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM;;;;;;;;GAO3D"}
1
+ {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/services.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAGhF,OAAO,KAAK,EACV,gCAAgC,EAChC,uCAAuC,EACvC,sCAAsC,EACtC,aAAa,IAAI,iBAAiB,EACnC,MAAM,gBAAgB,CAAC;AASxB,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gCAAgC,CAAC,CAY3C;AAED,wBAAsB,6BAA6B,CACjD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,sCAAsC,CAAC,CAYjD;AAED,wBAAsB,8BAA8B,CAClD,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,uCAAuC,CAAC,CAYlD;AAED,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAG5B;AAED,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM;;;;;;;;GAO3D"}
@@ -3,6 +3,7 @@ import { RemoteXpcConnection } from './lib/remote-xpc/remote-xpc-connection.js';
3
3
  import { TunnelManager } from './lib/tunnel/index.js';
4
4
  import { TunnelApiClient } from './lib/tunnel/tunnel-api-client.js';
5
5
  import DiagnosticsService from './services/ios/diagnostic-service/index.js';
6
+ import MobileImageMounterService from './services/ios/mobile-image-mounter/index.js';
6
7
  import { NotificationProxyService } from './services/ios/notification-proxy/index.js';
7
8
  import SyslogService from './services/ios/syslog-service/index.js';
8
9
  const APPIUM_XCUITEST_DRIVER_NAME = 'appium-xcuitest-driver';
@@ -29,6 +30,17 @@ export async function startNotificationProxyService(udid) {
29
30
  ]),
30
31
  };
31
32
  }
33
+ export async function startMobileImageMounterService(udid) {
34
+ const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
35
+ const mobileImageMounterService = remoteXPC.findService(MobileImageMounterService.RSD_SERVICE_NAME);
36
+ return {
37
+ remoteXPC: remoteXPC,
38
+ mobileImageMounterService: new MobileImageMounterService([
39
+ tunnelConnection.host,
40
+ parseInt(mobileImageMounterService.port, 10),
41
+ ]),
42
+ };
43
+ }
32
44
  export async function startSyslogService(udid) {
33
45
  const { tunnelConnection } = await createRemoteXPCConnection(udid);
34
46
  return new SyslogService([tunnelConnection.host, tunnelConnection.port]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "main": "build/src/index.js",
5
5
  "types": "build/src/index.d.ts",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "test:pair-record": "mocha test/integration/read-pair-record-test.ts --exit --timeout 1m",
30
30
  "test:diagnostics": "mocha test/integration/diagnostics-test.ts --exit --timeout 1m",
31
31
  "test:notification": "mocha test/integration/notification-proxy-test.ts --exit --timeout 1m",
32
+ "test:image-mounter": "mocha test/integration/mobile-image-mounter-test.ts --exit --timeout 1m",
32
33
  "test:unit": "mocha 'test/unit/**/*.ts' --exit --timeout 2m",
33
34
  "test:tunnel-creation": "sudo tsx scripts/test-tunnel-creation.ts",
34
35
  "test:tunnel-creation:lsof": "sudo tsx scripts/test-tunnel-creation.ts --keep-open"
@@ -70,8 +71,9 @@
70
71
  "@appium/support": "^7.0.0-rc.1",
71
72
  "@types/node": "^24.0.10",
72
73
  "@xmldom/xmldom": "^0.9.8",
73
- "npm-run-all2": "^8.0.4",
74
- "appium-ios-tuntap": "^0.x"
74
+ "appium-ios-tuntap": "^0.x",
75
+ "axios": "^1.12.0",
76
+ "npm-run-all2": "^8.0.4"
75
77
  },
76
78
  "files": [
77
79
  "src",
package/src/index.ts CHANGED
@@ -14,12 +14,14 @@ import { startCoreDeviceProxy } from './services/ios/tunnel-service/index.js';
14
14
 
15
15
  export type {
16
16
  DiagnosticsService,
17
+ MobileImageMounterService,
17
18
  SyslogService,
18
19
  SocketInfo,
19
20
  TunnelResult,
20
21
  TunnelRegistry,
21
22
  TunnelRegistryEntry,
22
23
  DiagnosticsServiceWithConnection,
24
+ MobileImageMounterServiceWithConnection,
23
25
  } from './lib/types.js';
24
26
  export {
25
27
  createUsbmux,
@@ -17,6 +17,10 @@ export function createPlist(obj: PlistDictionary): string {
17
17
  if (typeof value === 'string') {
18
18
  return `<string>${escapeXml(value)}</string>`;
19
19
  }
20
+ if (Buffer.isBuffer(value)) {
21
+ const base64Data = value.toString('base64');
22
+ return `<data>${base64Data}</data>`;
23
+ }
20
24
  if (Array.isArray(value)) {
21
25
  return `<array>${value.map((item) => convert(item)).join('')}</array>`;
22
26
  }