Skip to content

Commit dbd2a6d

Browse files
authored
feat: implement mcinstall service (#71)
* add mobile config * simplify errorCode and mark array as const
1 parent ba49590 commit dbd2a6d

File tree

5 files changed

+363
-0
lines changed

5 files changed

+363
-0
lines changed

src/lib/plist/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,14 @@ export const BPLIST_TYPE = {
5151
SET: 0xc0,
5252
DICT: 0xd0,
5353
};
54+
55+
// Supported file extensions for plist-related file
56+
export const SUPPORTED_EXTENSIONS = [
57+
'.pem',
58+
'.cer',
59+
'.crt',
60+
'.p12',
61+
'.pfx',
62+
'.mobileconfig',
63+
'.plist',
64+
] as const;

src/lib/types.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,57 @@ export interface NotificationProxyService extends BaseService {
212212
expectNotification(timeout?: number): Promise<PlistMessage>;
213213
}
214214

215+
/**
216+
* Represents the static side of MobileConfigService
217+
*/
218+
export interface MobileConfigService extends BaseService {
219+
/**
220+
* Connect to the mobile config service
221+
* @returns Promise resolving to the ServiceConnection instance
222+
*/
223+
connectToMobileConfigService(): Promise<ServiceConnection>;
224+
/**
225+
* Get all profiles of iOS devices
226+
* @returns {Promise<PlistDictionary>}
227+
* e.g.
228+
* {
229+
* OrderedIdentifiers: [ '2fac1c2b3d684843189b2981c718b0132854a847a' ],
230+
* ProfileManifest: {
231+
* '2fac1c2b3d684843189b2981c718b0132854a847a': {
232+
* Description: 'Charles Proxy CA (7 Dec 2020, MacBook-Pro.local)',
233+
* IsActive: true
234+
* }
235+
* },
236+
* ProfileMetadata: {
237+
* '2fac1c2b3d684843189b2981c718b0132854a847a': {
238+
* PayloadDisplayName: 'Charles Proxy CA (7 Dec 2020, MacBook-Pro.local)',
239+
* PayloadRemovalDisallowed: false,
240+
* PayloadUUID: 'B30005CC-BC73-4E42-8545-8DA6C44A8A71',
241+
* PayloadVersion: 1
242+
* }
243+
* },
244+
* Status: 'Acknowledged'
245+
* }
246+
*/
247+
getProfileList(): Promise<PlistDictionary>;
248+
/**
249+
* Install profile to iOS device
250+
* @param {String} path must be a certificate file .PEM .CER and more formats
251+
* e.g: /Downloads/charles-certificate.pem
252+
*/
253+
installProfileFromPath(path: string): Promise<void>;
254+
/**
255+
* Install profile to iOS device from buffer
256+
* @param {Buffer} payload must be a certificate file .PEM .CER and more formats
257+
*/
258+
installProfileFromBuffer(payload: Buffer): Promise<void>;
259+
/**
260+
* Remove profile from iOS device
261+
* @param {String} identifier Query identifier list through getProfileList method
262+
*/
263+
removeProfile(identifier: string): Promise<void>;
264+
}
265+
215266
/**
216267
* Represents the static side of DiagnosticsService
217268
*/
@@ -249,6 +300,17 @@ export interface NotificationProxyServiceWithConnection {
249300
remoteXPC: RemoteXpcConnection;
250301
}
251302

303+
/**
304+
* Represents a MobileConfigService instance with its associated RemoteXPC connection
305+
* This allows callers to properly manage the connection lifecycle
306+
*/
307+
export interface MobileConfigServiceWithConnection {
308+
/** The MobileConfigService instance */
309+
mobileConfigService: MobileConfigService;
310+
/** The RemoteXPC connection that can be used to close the connection */
311+
remoteXPC: RemoteXpcConnection;
312+
}
313+
252314
/**
253315
* Options for configuring syslog capture
254316
*/

src/services.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { TunnelManager } from './lib/tunnel/index.js';
55
import { TunnelApiClient } from './lib/tunnel/tunnel-api-client.js';
66
import type {
77
DiagnosticsServiceWithConnection,
8+
MobileConfigServiceWithConnection,
89
MobileImageMounterServiceWithConnection,
910
NotificationProxyServiceWithConnection,
1011
SyslogService as SyslogServiceType,
1112
} from './lib/types.js';
1213
import DiagnosticsService from './services/ios/diagnostic-service/index.js';
14+
import { MobileConfigService } from './services/ios/mobile-config/index.js';
1315
import MobileImageMounterService from './services/ios/mobile-image-mounter/index.js';
1416
import { NotificationProxyService } from './services/ios/notification-proxy/index.js';
1517
import SyslogService from './services/ios/syslog-service/index.js';
@@ -49,6 +51,21 @@ export async function startNotificationProxyService(
4951
};
5052
}
5153

54+
export async function startMobileConfigService(
55+
udid: string,
56+
): Promise<MobileConfigServiceWithConnection> {
57+
const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
58+
const mobileConfigService = remoteXPC.findService(
59+
MobileConfigService.RSD_SERVICE_NAME,
60+
);
61+
return {
62+
remoteXPC: remoteXPC as RemoteXpcConnection,
63+
mobileConfigService: new MobileConfigService([
64+
tunnelConnection.host,
65+
parseInt(mobileConfigService.port, 10),
66+
]),
67+
};
68+
}
5269
export async function startMobileImageMounterService(
5370
udid: string,
5471
): Promise<MobileImageMounterServiceWithConnection> {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { fs, logger } from '@appium/support';
2+
import path from 'node:path';
3+
4+
import { SUPPORTED_EXTENSIONS } from '../../../lib/plist/constants.js';
5+
import { createPlist, parsePlist } from '../../../lib/plist/index.js';
6+
import {
7+
type MobileConfigService as MobileConfigServiceInterface,
8+
type PlistDictionary,
9+
} from '../../../lib/types.js';
10+
import { ServiceConnection } from '../../../service-connection.js';
11+
import { BaseService } from '../base-service.js';
12+
13+
const ERROR_CLOUD_CONFIGURATION_ALREADY_PRESENT = 14002;
14+
const log = logger.getLogger('MobileConfigService');
15+
16+
/**
17+
* MobileConfigService provides an API to:
18+
* - Install configuration profiles
19+
* - Remove configuration profiles
20+
* - List installed configuration profiles
21+
*/
22+
class MobileConfigService
23+
extends BaseService
24+
implements MobileConfigServiceInterface
25+
{
26+
static readonly RSD_SERVICE_NAME = 'com.apple.mobile.MCInstall.shim.remote';
27+
private _conn: ServiceConnection | null = null;
28+
29+
constructor(address: [string, number]) {
30+
super(address);
31+
}
32+
/**
33+
* Connect to the mobile config service
34+
* @returns Promise resolving to the ServiceConnection instance
35+
*/
36+
async connectToMobileConfigService(): Promise<ServiceConnection> {
37+
if (this._conn) {
38+
return this._conn;
39+
}
40+
41+
const service = this.getServiceConfig();
42+
this._conn = await this.startLockdownService(service);
43+
return this._conn;
44+
}
45+
46+
/**
47+
* Get all profiles of iOS devices
48+
* @returns {Promise<PlistDictionary>}
49+
* e.g.
50+
* {
51+
* OrderedIdentifiers: [ '2fac1c2b3d684843189b2981c718b0132854a847a' ],
52+
* ProfileManifest: {
53+
* '2fac1c2b3d684843189b2981c718b0132854a847a': {
54+
* Description: 'Charles Proxy CA (7 Dec 2020, MacBook-Pro.local)',
55+
* IsActive: true
56+
* }
57+
* },
58+
* ProfileMetadata: {
59+
* '2fac1c2b3d684843189b2981c718b0132854a847a': {
60+
* PayloadDisplayName: 'Charles Proxy CA (7 Dec 2020, MacBook-Pro.local)',
61+
* PayloadRemovalDisallowed: false,
62+
* PayloadUUID: 'B30005CC-BC73-4E42-8545-8DA6C44A8A71',
63+
* PayloadVersion: 1
64+
* }
65+
* },
66+
* Status: 'Acknowledged'
67+
* }
68+
*/
69+
async getProfileList(): Promise<PlistDictionary> {
70+
const req = {
71+
RequestType: 'GetProfileList',
72+
};
73+
74+
return this._sendPlistAndReceive(req);
75+
}
76+
77+
/**
78+
* Install profile from path to iOS device
79+
* The phone must be unlocked before installing the profile
80+
* @param {String} filePath must be a certificate file .PEM .CER and more formats
81+
* e.g: /Downloads/charles-certificate.pem
82+
*/
83+
async installProfileFromPath(filePath: string): Promise<void> {
84+
// Check if file exists
85+
try {
86+
await fs.access(filePath);
87+
} catch (error) {
88+
throw new Error(`Profile filepath does not exist: ${filePath}`);
89+
}
90+
91+
const fileExtension = path.extname(filePath).toLowerCase();
92+
if (
93+
!fileExtension ||
94+
!SUPPORTED_EXTENSIONS.includes(fileExtension as any)
95+
) {
96+
throw new Error(
97+
`Unsupported file format. Supported formats: ${SUPPORTED_EXTENSIONS.join(', ')}`,
98+
);
99+
}
100+
101+
const payload = await fs.readFile(filePath);
102+
await this.installProfileFromBuffer(payload);
103+
}
104+
105+
/**
106+
* Install profile to iOS device from buffer
107+
* The phone must be unlocked before installing the profile
108+
* @param {Buffer} payload must be a certificate file buffer .PEM .CER and more formats
109+
*/
110+
async installProfileFromBuffer(payload: Buffer): Promise<void> {
111+
try {
112+
const req = {
113+
RequestType: 'InstallProfile',
114+
Payload: parsePlist(payload),
115+
};
116+
await this._sendPlistAndReceive(req);
117+
} catch (error) {
118+
if (error instanceof Error) {
119+
throw new Error(`Failed to install profile: ${error.message}`);
120+
}
121+
throw error;
122+
}
123+
}
124+
/**
125+
* Remove profile from iOS device
126+
* @param {String} identifier Query identifier list through getProfileList method
127+
*/
128+
async removeProfile(identifier: string): Promise<void> {
129+
const profileList = await this.getProfileList();
130+
if (!profileList?.ProfileMetadata) {
131+
return;
132+
}
133+
134+
const profileMetadata = profileList.ProfileMetadata as Record<string, any>;
135+
if (!(identifier in profileMetadata)) {
136+
// Get available identifiers from OrderedIdentifiers array or ProfileMetadata keys
137+
let availableIdentifiers: string[];
138+
if (
139+
profileList.OrderedIdentifiers &&
140+
Array.isArray(profileList.OrderedIdentifiers)
141+
) {
142+
availableIdentifiers = profileList.OrderedIdentifiers as string[];
143+
} else {
144+
availableIdentifiers = Object.keys(profileMetadata);
145+
}
146+
147+
throw new Error(
148+
`Trying to remove not installed profile: ${identifier}. Expected one of: ${availableIdentifiers.join(', ')}`,
149+
);
150+
}
151+
152+
const meta = profileMetadata[identifier];
153+
const payloadData = {
154+
PayloadIdentifier: identifier,
155+
PayloadType: 'Configuration',
156+
PayloadUUID: meta.PayloadUUID,
157+
PayloadVersion: meta.PayloadVersion,
158+
};
159+
160+
const req = {
161+
RequestType: 'RemoveProfile',
162+
ProfileIdentifier: createPlist(payloadData),
163+
};
164+
165+
log.info(req);
166+
167+
await this._sendPlistAndReceive(req);
168+
}
169+
170+
private async _sendPlistAndReceive(
171+
req: PlistDictionary,
172+
): Promise<PlistDictionary> {
173+
if (!this._conn) {
174+
this._conn = await this.connectToMobileConfigService();
175+
}
176+
// Ignore first response as it is just status check
177+
await this._conn.sendAndReceive(req);
178+
const res = await this._conn.sendAndReceive(req);
179+
if (res.Status !== 'Acknowledged') {
180+
const errorCode = (res.ErrorChain as any[])?.[0]?.ErrorCode;
181+
if (Number.isInteger(errorCode)) {
182+
if (errorCode === ERROR_CLOUD_CONFIGURATION_ALREADY_PRESENT) {
183+
throw new Error(
184+
'A cloud configuration is already present on device. You must first erase the device to install a new configuration.',
185+
);
186+
}
187+
}
188+
throw new Error(`Invalid response: ${JSON.stringify(res)}`);
189+
}
190+
return res;
191+
}
192+
193+
private getServiceConfig(): {
194+
serviceName: string;
195+
port: string;
196+
} {
197+
return {
198+
serviceName: MobileConfigService.RSD_SERVICE_NAME,
199+
port: this.address[1].toString(),
200+
};
201+
}
202+
}
203+
204+
export { MobileConfigService };

0 commit comments

Comments
 (0)