appium-ios-remotexpc 0.3.2 → 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.
- package/CHANGELOG.md +16 -0
- package/build/src/index.d.ts +1 -1
- package/build/src/index.d.ts.map +1 -1
- package/build/src/lib/plist/binary-plist-creator.d.ts.map +1 -1
- package/build/src/lib/plist/binary-plist-creator.js +17 -5
- package/build/src/lib/plist/binary-plist-parser.d.ts.map +1 -1
- package/build/src/lib/plist/binary-plist-parser.js +19 -9
- package/build/src/lib/plist/plist-creator.d.ts.map +1 -1
- package/build/src/lib/plist/plist-creator.js +4 -0
- package/build/src/lib/tss/index.d.ts +71 -0
- package/build/src/lib/tss/index.d.ts.map +1 -0
- package/build/src/lib/tss/index.js +243 -0
- package/build/src/lib/types.d.ts +79 -0
- package/build/src/lib/types.d.ts.map +1 -1
- package/build/src/services/index.d.ts +2 -1
- package/build/src/services/index.d.ts.map +1 -1
- package/build/src/services/index.js +2 -1
- package/build/src/services/ios/mobile-image-mounter/index.d.ts +122 -0
- package/build/src/services/ios/mobile-image-mounter/index.d.ts.map +1 -0
- package/build/src/services/ios/mobile-image-mounter/index.js +363 -0
- package/build/src/services.d.ts +2 -1
- package/build/src/services.d.ts.map +1 -1
- package/build/src/services.js +12 -0
- package/package.json +5 -3
- package/src/index.ts +2 -0
- package/src/lib/plist/binary-plist-creator.ts +20 -5
- package/src/lib/plist/binary-plist-parser.ts +22 -12
- package/src/lib/plist/plist-creator.ts +4 -0
- package/src/lib/tss/index.ts +338 -0
- package/src/lib/types.ts +98 -0
- package/src/services/index.ts +2 -0
- package/src/services/ios/mobile-image-mounter/index.ts +525 -0
- package/src/services.ts +18 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { MobileImageMounterService as MobileImageMounterServiceInterface, PlistDictionary } from '../../../lib/types.js';
|
|
2
|
+
import { BaseService } from '../base-service.js';
|
|
3
|
+
/**
|
|
4
|
+
* Base interface for service responses
|
|
5
|
+
*/
|
|
6
|
+
interface BaseResponse {
|
|
7
|
+
Status?: string;
|
|
8
|
+
Error?: string;
|
|
9
|
+
DetailedError?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Interface for image-related responses
|
|
13
|
+
*/
|
|
14
|
+
export interface ImageResponse extends BaseResponse {
|
|
15
|
+
ImagePresent?: boolean;
|
|
16
|
+
ImageSignature?: Buffer[] | Buffer;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* MobileImageMounterService provides an API to:
|
|
20
|
+
* - Mount Developer Disk Images on iOS devices
|
|
21
|
+
* - Lookup mounted images and their signatures
|
|
22
|
+
* - Check if personalized images are mounted
|
|
23
|
+
* - Unmount images when needed
|
|
24
|
+
*/
|
|
25
|
+
declare class MobileImageMounterService extends BaseService implements MobileImageMounterServiceInterface {
|
|
26
|
+
static readonly RSD_SERVICE_NAME = "com.apple.mobile.mobile_image_mounter.shim.remote";
|
|
27
|
+
private static readonly FILE_TYPE_IMAGE;
|
|
28
|
+
private static readonly FILE_TYPE_BUILD_MANIFEST;
|
|
29
|
+
private static readonly FILE_TYPE_TRUST_CACHE;
|
|
30
|
+
private static readonly IMAGE_TYPE;
|
|
31
|
+
private static readonly MOUNT_PATH;
|
|
32
|
+
private static readonly UPLOAD_IMAGE_TIMEOUT;
|
|
33
|
+
private connection;
|
|
34
|
+
constructor(address: [string, number]);
|
|
35
|
+
/**
|
|
36
|
+
* Clean up resources when service is no longer needed
|
|
37
|
+
*/
|
|
38
|
+
cleanup(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Lookup mounted images by type
|
|
41
|
+
* @param imageType Type of image to lookup (defaults to 'Personalized')
|
|
42
|
+
* @returns Array of signatures of mounted images
|
|
43
|
+
*/
|
|
44
|
+
lookup(imageType?: string): Promise<Buffer[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Check if personalized image is mounted
|
|
47
|
+
* @returns True if personalized image is mounted
|
|
48
|
+
*/
|
|
49
|
+
isPersonalizedImageMounted(): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Mount personalized image for device (iOS >= 17)
|
|
52
|
+
* @param imageFilePath Path to the image file (.dmg)
|
|
53
|
+
* @param buildManifestFilePath Path to the build manifest file (.plist)
|
|
54
|
+
* @param trustCacheFilePath Path to the trust cache file (.trustcache)
|
|
55
|
+
* @param infoPlist Optional info plist dictionary
|
|
56
|
+
*/
|
|
57
|
+
mount(imageFilePath: string, buildManifestFilePath: string, trustCacheFilePath: string, infoPlist?: PlistDictionary): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Unmount image from device
|
|
60
|
+
* @param mountPath Mount path to unmount (defaults to '/System/Developer')
|
|
61
|
+
*/
|
|
62
|
+
unmountImage(mountPath?: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Query developer mode status (iOS 16+)
|
|
65
|
+
* @returns True if developer mode is enabled (defaults to true for older iOS)
|
|
66
|
+
*/
|
|
67
|
+
queryDeveloperModeStatus(): Promise<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* Query personalization nonce for personalized images
|
|
70
|
+
* @param personalizedImageType Optional personalized image type
|
|
71
|
+
* @returns Personalization nonce as Buffer
|
|
72
|
+
*/
|
|
73
|
+
queryNonce(personalizedImageType?: string): Promise<Buffer>;
|
|
74
|
+
/**
|
|
75
|
+
* Query personalization identifiers from the device
|
|
76
|
+
* @returns Personalization identifiers dictionary
|
|
77
|
+
*/
|
|
78
|
+
queryPersonalizationIdentifiers(): Promise<PlistDictionary>;
|
|
79
|
+
/**
|
|
80
|
+
* Copy devices info (only for mounted images)
|
|
81
|
+
* @returns List of mounted devices
|
|
82
|
+
*/
|
|
83
|
+
copyDevices(): Promise<any[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Query personalization manifest from device
|
|
86
|
+
* @param imageType The image type
|
|
87
|
+
* @param signature The image signature/hash
|
|
88
|
+
* @returns Personalization manifest as Buffer
|
|
89
|
+
*/
|
|
90
|
+
queryPersonalizationManifest(imageType: string, signature: Buffer): Promise<Buffer>;
|
|
91
|
+
/**
|
|
92
|
+
* Upload image to device
|
|
93
|
+
* @param imageType The image type
|
|
94
|
+
* @param image The image data
|
|
95
|
+
* @param signature The image signature/manifest
|
|
96
|
+
* @param timeout Optional timeout for upload operation (defaults to 20000ms)
|
|
97
|
+
*/
|
|
98
|
+
uploadImage(imageType: string, image: Buffer, signature: Buffer, timeout?: number): Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* Mount image on device
|
|
101
|
+
* @param imageType The image type
|
|
102
|
+
* @param signature The image signature/manifest
|
|
103
|
+
* @param extras Additional parameters for mounting
|
|
104
|
+
*/
|
|
105
|
+
mountImage(imageType: string, signature: Buffer, extras?: Record<string, any>): Promise<void>;
|
|
106
|
+
private sendRequest;
|
|
107
|
+
/**
|
|
108
|
+
* Calculate hash of a buffer asynchronously
|
|
109
|
+
* @param buffer The buffer to hash
|
|
110
|
+
* @returns Promise resolving to the hash digest
|
|
111
|
+
*/
|
|
112
|
+
private hashLargeBufferAsync;
|
|
113
|
+
private getOrRetrieveManifestFromTSS;
|
|
114
|
+
private isConnectionDestroyed;
|
|
115
|
+
private connectToMobileImageMounterService;
|
|
116
|
+
private closeConnection;
|
|
117
|
+
private checkIfError;
|
|
118
|
+
private assertIsFile;
|
|
119
|
+
}
|
|
120
|
+
export default MobileImageMounterService;
|
|
121
|
+
export { MobileImageMounterService };
|
|
122
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/services/ios/mobile-image-mounter/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,yBAAyB,IAAI,kCAAkC,EAC/D,eAAe,EAChB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAIjD;;GAEG;AACH,UAAU,YAAY;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;CACpC;AAED;;;;;;GAMG;AACH,cAAM,yBACJ,SAAQ,WACR,YAAW,kCAAkC;IAE7C,MAAM,CAAC,QAAQ,CAAC,gBAAgB,uDACsB;IAGtD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAW;IAClD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAoB;IACpE,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAiB;IAC9D,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAkB;IACpD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAuB;IACzD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAGrD,OAAO,CAAC,UAAU,CAAkC;gBAExC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC;IAIrC;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B;;;;OAIG;IACG,MAAM,CACV,SAAS,SAAuC,GAC/C,OAAO,CAAC,MAAM,EAAE,CAAC;IAUpB;;;OAGG;IACG,0BAA0B,IAAI,OAAO,CAAC,OAAO,CAAC;IAQpD;;;;;;OAMG;IACG,KAAK,CACT,aAAa,EAAE,MAAM,EACrB,qBAAqB,EAAE,MAAM,EAC7B,kBAAkB,EAAE,MAAM,EAC1B,SAAS,CAAC,EAAE,eAAe,GAC1B,OAAO,CAAC,IAAI,CAAC;IAgEhB;;;OAGG;IACG,YAAY,CAChB,SAAS,SAAuC,GAC/C,OAAO,CAAC,IAAI,CAAC;IAsBhB;;;OAGG;IACG,wBAAwB,IAAI,OAAO,CAAC,OAAO,CAAC;IAYlD;;;;OAIG;IACG,UAAU,CAAC,qBAAqB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAgBjE;;;OAGG;IACG,+BAA+B,IAAI,OAAO,CAAC,eAAe,CAAC;IAQjE;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAKnC;;;;;OAKG;IACG,4BAA4B,CAChC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC;IAgClB;;;;;;OAMG;IACG,WAAW,CACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,OAAO,SAAiD,GACvD,OAAO,CAAC,IAAI,CAAC;IAiChB;;;;;OAKG;IACG,UAAU,CACd,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC3B,OAAO,CAAC,IAAI,CAAC;YA4BF,WAAW;IAczB;;;;OAIG;YACW,oBAAoB;YAmBpB,4BAA4B;IAyC1C,OAAO,CAAC,qBAAqB;YASf,kCAAkC;IAchD,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,YAAY;YAMN,YAAY;CAiB3B;AAED,eAAe,yBAAyB,CAAC;AACzC,OAAO,EAAE,yBAAyB,EAAE,CAAC"}
|
|
@@ -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 };
|
package/build/src/services.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/src/services.js
CHANGED
|
@@ -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
|
+
"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
|
-
"
|
|
74
|
-
"
|
|
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,
|
|
@@ -59,7 +59,11 @@ class BinaryPlistCreator {
|
|
|
59
59
|
const objectData: Buffer[] = [];
|
|
60
60
|
|
|
61
61
|
for (const value of this._objectTable) {
|
|
62
|
-
|
|
62
|
+
// Calculate offset including the header length
|
|
63
|
+
objectOffsets.push(
|
|
64
|
+
BPLIST_MAGIC_AND_VERSION.length +
|
|
65
|
+
this._calculateObjectDataLength(objectData),
|
|
66
|
+
);
|
|
63
67
|
objectData.push(this._createObjectData(value));
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -341,11 +345,22 @@ class BinaryPlistCreator {
|
|
|
341
345
|
// Check if string can be ASCII
|
|
342
346
|
// eslint-disable-next-line no-control-regex
|
|
343
347
|
const isAscii = /^[\x00-\x7F]*$/.test(value);
|
|
344
|
-
const stringBuffer = isAscii
|
|
345
|
-
? Buffer.from(value, 'ascii')
|
|
346
|
-
: Buffer.from(value, 'utf16le');
|
|
347
348
|
|
|
348
|
-
|
|
349
|
+
let stringBuffer: Buffer;
|
|
350
|
+
if (isAscii) {
|
|
351
|
+
stringBuffer = Buffer.from(value, 'ascii');
|
|
352
|
+
} else {
|
|
353
|
+
// Unicode strings should be stored as UTF-16BE in binary plists
|
|
354
|
+
const utf16leBuffer = Buffer.from(value, 'utf16le');
|
|
355
|
+
stringBuffer = Buffer.alloc(utf16leBuffer.length);
|
|
356
|
+
|
|
357
|
+
// Convert UTF-16LE to UTF-16BE
|
|
358
|
+
for (let i = 0; i < utf16leBuffer.length; i += 2) {
|
|
359
|
+
stringBuffer[i] = utf16leBuffer[i + 1]; // High byte
|
|
360
|
+
stringBuffer[i + 1] = utf16leBuffer[i]; // Low byte
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
349
364
|
const length = isAscii ? value.length : stringBuffer.length / 2;
|
|
350
365
|
let header: Buffer;
|
|
351
366
|
|
|
@@ -270,15 +270,21 @@ class BinaryPlistParser {
|
|
|
270
270
|
* @returns The parsed string
|
|
271
271
|
*/
|
|
272
272
|
private _parseUnicodeString(startOffset: number, objLength: number): string {
|
|
273
|
-
// Unicode strings are stored as UTF-16BE
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
273
|
+
// Unicode strings are stored as UTF-16BE in binary plists
|
|
274
|
+
const bytesToRead = objLength * 2;
|
|
275
|
+
const stringBuffer = this._buffer.slice(
|
|
276
|
+
startOffset,
|
|
277
|
+
startOffset + bytesToRead,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Convert UTF-16BE to UTF-16LE for proper decoding
|
|
281
|
+
const utf16leBuffer = Buffer.alloc(bytesToRead);
|
|
282
|
+
for (let i = 0; i < bytesToRead; i += 2) {
|
|
283
|
+
utf16leBuffer[i] = stringBuffer[i + 1]; // Low byte
|
|
284
|
+
utf16leBuffer[i + 1] = stringBuffer[i]; // High byte
|
|
280
285
|
}
|
|
281
|
-
|
|
286
|
+
|
|
287
|
+
return utf16leBuffer.toString('utf16le');
|
|
282
288
|
}
|
|
283
289
|
|
|
284
290
|
/**
|
|
@@ -476,8 +482,10 @@ class BinaryPlistParser {
|
|
|
476
482
|
obj.startOffset + j * this._objectRefSize,
|
|
477
483
|
);
|
|
478
484
|
const refValue = this._objectTable[refIdx];
|
|
479
|
-
//
|
|
480
|
-
if (
|
|
485
|
+
// Handle TempObjects correctly - they should be resolved by the time we get here
|
|
486
|
+
if (this._isTempObject(refValue)) {
|
|
487
|
+
array.push(refValue.value);
|
|
488
|
+
} else {
|
|
481
489
|
array.push(refValue);
|
|
482
490
|
}
|
|
483
491
|
}
|
|
@@ -511,8 +519,10 @@ class BinaryPlistParser {
|
|
|
511
519
|
);
|
|
512
520
|
}
|
|
513
521
|
|
|
514
|
-
//
|
|
515
|
-
if (
|
|
522
|
+
// Handle TempObjects correctly - they should be resolved by the time we get here
|
|
523
|
+
if (this._isTempObject(value)) {
|
|
524
|
+
dict[key] = value.value;
|
|
525
|
+
} else {
|
|
516
526
|
dict[key] = value;
|
|
517
527
|
}
|
|
518
528
|
}
|