Skip to content

Commit 3e5ecc8

Browse files
authored
feat(installation-proxy): use remotexpc when iOS>=18 (#2714)
1 parent 930b048 commit 3e5ecc8

File tree

5 files changed

+356
-29
lines changed

5 files changed

+356
-29
lines changed

docs/reference/execute-methods.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ applicationType | string | no | The type of applications to list. Either `System
336336

337337
#### Returned Result
338338

339-
A list of apps, where each item is a map where keys are bundle identifiers and values are maps of platform-specific app properties. Having `UIFileSharingEnabled` set to `true` in the app properties map means this app supports files upload and download into its `documents` container. Read the [File Transfer](../guides/file-transfer.md) guide for more details.
339+
A map where keys are bundle identifiers and values are maps of platform-specific app properties. Having `UIFileSharingEnabled` set to `true` in the app properties map means this app supports file upload and download into its `documents` container. Read the [File Transfer](../guides/file-transfer.md) guide for more details.
340340

341341
### mobile: clearApp
342342

lib/commands/app-management.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import _ from 'lodash';
22
import {fs, util} from 'appium/support';
33
import {errors} from 'appium/driver';
4-
import {services} from 'appium-ios-device';
54
import path from 'node:path';
65
import B from 'bluebird';
76
import {
87
SUPPORTED_EXTENSIONS,
98
onPostConfigureApp,
109
onDownloadApp,
1110
} from '../app-utils';
12-
import {requireRealDevice} from '../utils';
11+
import {requireRealDevice, isIos18OrNewer} from '../utils';
12+
import {InstallationProxyClient} from '../device/installation-proxy-client';
1313
import type {XCUITestDriver} from '../driver';
1414
import type {AppState} from './enum';
15+
import type {AppInfoMapping} from '../types';
1516
import type {Simulator} from 'appium-ios-simulator';
1617

1718
/**
@@ -298,19 +299,21 @@ export async function queryAppState(
298299
*
299300
* Read [Pushing/Pulling files](https://appium.io/docs/en/writing-running-appium/ios/ios-xctest-file-movement/) for more details.
300301
* @param applicationType - The type of applications to list.
301-
* @returns A list of apps where each item is a mapping of bundle identifiers to maps of platform-specific app properties.
302-
* @remarks Having `UIFileSharingEnabled` set to `true` in the return app properties map means this app supports file upload/download in its `documents` container.
302+
* @returns An object mapping bundle identifiers to app properties (e.g., CFBundleName, CFBundleVersion, etc.).
303+
* @remarks Having `UIFileSharingEnabled` set to `true` in the app properties means the app supports file upload/download in its `documents` container.
303304
* @group Real Device Only
304305
*/
305306
export async function mobileListApps(
306307
this: XCUITestDriver,
307308
applicationType: 'User' | 'System' = 'User',
308-
): Promise<Record<string, any>[]> {
309-
const service = await services.startInstallationProxyService(requireRealDevice(this, 'Listing apps').udid);
309+
): Promise<AppInfoMapping> {
310+
const device = requireRealDevice(this, 'Listing apps');
311+
const useRemoteXPC = isIos18OrNewer(this.opts);
312+
const client = await InstallationProxyClient.create(device.udid, useRemoteXPC);
310313
try {
311-
return await service.listApplications({applicationType});
314+
return await client.listApplications({applicationType});
312315
} finally {
313-
service.close();
316+
await client.close();
314317
}
315318
}
316319

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import {getRemoteXPCServices} from './remotexpc-utils';
2+
import {log} from '../logger';
3+
import {services} from 'appium-ios-device';
4+
import type {InstallationProxyService as IOSDeviceInstallationProxyService} from 'appium-ios-device';
5+
import type {
6+
InstallationProxyService as RemoteXPCInstallationProxyService,
7+
RemoteXpcConnection,
8+
} from 'appium-ios-remotexpc';
9+
import type {AppInfo, AppInfoMapping} from '../types';
10+
11+
/**
12+
* Progress response structure for installation/uninstallation operations
13+
*/
14+
interface ProgressResponse {
15+
PercentComplete?: number;
16+
Status?: string;
17+
Error?: string;
18+
ErrorDescription?: string;
19+
}
20+
21+
/**
22+
* Options for listing applications
23+
*/
24+
interface ListApplicationOptions {
25+
applicationType?: 'User' | 'System';
26+
returnAttributes?: string[];
27+
}
28+
29+
/**
30+
* Options for lookup applications
31+
*/
32+
interface LookupApplicationOptions {
33+
bundleIds: string | string[];
34+
returnAttributes?: string[];
35+
applicationType?: 'User' | 'System';
36+
}
37+
38+
/**
39+
* Unified Installation Proxy Client
40+
*
41+
* Provides a unified interface for app installation/management operations on iOS devices
42+
*/
43+
export class InstallationProxyClient {
44+
private constructor(
45+
private readonly service: RemoteXPCInstallationProxyService | IOSDeviceInstallationProxyService,
46+
private readonly remoteXPCConnection?: RemoteXpcConnection
47+
) {}
48+
49+
//#region Public Methods
50+
51+
/**
52+
* Create an InstallationProxy client for the device
53+
*
54+
* @param udid - Device UDID
55+
* @param useRemoteXPC - Whether to use RemoteXPC
56+
* @returns InstallationProxy client instance
57+
*/
58+
static async create(udid: string, useRemoteXPC: boolean): Promise<InstallationProxyClient> {
59+
if (useRemoteXPC) {
60+
const client = await InstallationProxyClient.withRemoteXpcConnection(async () => {
61+
const Services = await getRemoteXPCServices();
62+
const {installationProxyService, remoteXPC} = await Services.startInstallationProxyService(udid);
63+
return {
64+
service: installationProxyService,
65+
connection: remoteXPC,
66+
};
67+
});
68+
if (client) {
69+
return client;
70+
}
71+
}
72+
73+
const service = await services.startInstallationProxyService(udid);
74+
return new InstallationProxyClient(service);
75+
}
76+
77+
/**
78+
* List installed applications
79+
*
80+
* @param opts - Options for filtering and selecting attributes
81+
* @returns Object keyed by bundle ID
82+
*/
83+
async listApplications(opts?: ListApplicationOptions): Promise<AppInfoMapping> {
84+
if (!this.isRemoteXPC) {
85+
return await this.iosDeviceService.listApplications(opts);
86+
}
87+
88+
// RemoteXPC returns array, need to convert to object
89+
const apps = await this.remoteXPCService.browse({
90+
applicationType: opts?.applicationType || 'Any',
91+
// Use '*' to request all attributes when returnAttributes is not explicitly specified
92+
returnAttributes: opts?.returnAttributes || '*',
93+
});
94+
95+
// Convert array to object keyed by CFBundleIdentifier
96+
return apps.reduce((acc, app) => {
97+
if (app.CFBundleIdentifier) {
98+
acc[app.CFBundleIdentifier] = app as AppInfo;
99+
}
100+
return acc;
101+
}, {} as AppInfoMapping);
102+
}
103+
104+
/**
105+
* Look up application information for specific bundle IDs
106+
*
107+
* @param opts - Bundle IDs and options
108+
* @returns Object keyed by bundle ID
109+
*/
110+
async lookupApplications(opts: LookupApplicationOptions): Promise<AppInfoMapping> {
111+
if (!this.isRemoteXPC) {
112+
return await this.iosDeviceService.lookupApplications(opts);
113+
}
114+
115+
const bundleIds = Array.isArray(opts.bundleIds) ? opts.bundleIds : [opts.bundleIds];
116+
return await this.remoteXPCService.lookup(bundleIds, {
117+
returnAttributes: opts.returnAttributes,
118+
applicationType: opts.applicationType,
119+
}) as AppInfoMapping;
120+
}
121+
122+
/**
123+
* Install an application
124+
*
125+
* @param path - Path to ipa
126+
* @param clientOptions - Installation options
127+
* @param timeoutMs - Timeout in milliseconds
128+
* @returns Array of progress messages received during installation
129+
*/
130+
async installApplication(
131+
path: string,
132+
clientOptions?: Record<string, any>,
133+
timeoutMs?: number
134+
): Promise<ProgressResponse[]> {
135+
if (!this.isRemoteXPC) {
136+
return await this.iosDeviceService.installApplication(path, clientOptions, timeoutMs);
137+
}
138+
139+
return await this.executeWithProgressCollection(
140+
(progressHandler) => this.remoteXPCService.install(
141+
path,
142+
{...clientOptions, timeoutMs},
143+
progressHandler
144+
)
145+
);
146+
}
147+
148+
/**
149+
* Upgrade an application
150+
*
151+
* @param path - Path to app on device
152+
* @param clientOptions - Installation options
153+
* @param timeoutMs - Timeout in milliseconds
154+
* @returns Array of progress messages received during upgrade
155+
*/
156+
async upgradeApplication(
157+
path: string,
158+
clientOptions?: Record<string, any>,
159+
timeoutMs?: number
160+
): Promise<ProgressResponse[]> {
161+
if (!this.isRemoteXPC) {
162+
return await this.iosDeviceService.upgradeApplication(path, clientOptions, timeoutMs);
163+
}
164+
165+
return await this.executeWithProgressCollection(
166+
(progressHandler) => this.remoteXPCService.upgrade(
167+
path,
168+
{...clientOptions, timeoutMs},
169+
progressHandler
170+
)
171+
);
172+
}
173+
174+
/**
175+
* Uninstall an application
176+
*
177+
* @param bundleId - Bundle ID of app to uninstall
178+
* @param timeoutMs - Timeout in milliseconds
179+
* @returns Array of progress messages received during uninstallation
180+
*/
181+
async uninstallApplication(bundleId: string, timeoutMs?: number): Promise<ProgressResponse[]> {
182+
if (!this.isRemoteXPC) {
183+
return await this.iosDeviceService.uninstallApplication(bundleId, timeoutMs);
184+
}
185+
186+
return await this.executeWithProgressCollection(
187+
(progressHandler) => this.remoteXPCService.uninstall(
188+
bundleId,
189+
{timeoutMs},
190+
progressHandler
191+
)
192+
);
193+
}
194+
195+
/**
196+
* Close the client and cleanup resources
197+
*/
198+
async close(): Promise<void> {
199+
try {
200+
this.service.close();
201+
} catch (err: any) {
202+
log.debug(`Error closing installation proxy service: ${err.message}`);
203+
}
204+
205+
if (this.remoteXPCConnection) {
206+
try {
207+
await this.remoteXPCConnection.close();
208+
} catch (err: any) {
209+
log.warn(`Error closing RemoteXPC connection: ${err.message}`);
210+
}
211+
}
212+
}
213+
214+
//#endregion
215+
216+
//#region Private Methods
217+
218+
/**
219+
* Check if this client is using RemoteXPC
220+
*/
221+
private get isRemoteXPC(): boolean {
222+
return !!this.remoteXPCConnection;
223+
}
224+
225+
/**
226+
* Get the RemoteXPC service (throws if not RemoteXPC)
227+
*/
228+
private get remoteXPCService(): RemoteXPCInstallationProxyService {
229+
return this.service as RemoteXPCInstallationProxyService;
230+
}
231+
232+
/**
233+
* Get the ios-device service (throws if not ios-device)
234+
*/
235+
private get iosDeviceService(): IOSDeviceInstallationProxyService {
236+
return this.service as IOSDeviceInstallationProxyService;
237+
}
238+
239+
/**
240+
* Execute a RemoteXPC operation and collect progress messages to match ios-device behavior
241+
*
242+
* @param operation - Function that executes the RemoteXPC operation with a progress handler
243+
* @returns Array of progress messages
244+
*/
245+
private async executeWithProgressCollection(
246+
operation: (progressHandler: (percentComplete: number, status: string) => void) => Promise<void>
247+
): Promise<ProgressResponse[]> {
248+
const messages: ProgressResponse[] = [];
249+
await operation((percentComplete, status) => {
250+
messages.push({PercentComplete: percentComplete, Status: status});
251+
});
252+
return messages;
253+
}
254+
255+
/**
256+
* Helper to safely execute RemoteXPC operations with connection cleanup
257+
*/
258+
private static async withRemoteXpcConnection<T extends RemoteXPCInstallationProxyService | IOSDeviceInstallationProxyService>(
259+
operation: () => Promise<{service: T; connection: RemoteXpcConnection}>
260+
): Promise<InstallationProxyClient | null> {
261+
let remoteXPCConnection: RemoteXpcConnection | undefined;
262+
let succeeded = false;
263+
try {
264+
const {service, connection} = await operation();
265+
remoteXPCConnection = connection;
266+
const client = new InstallationProxyClient(service, remoteXPCConnection);
267+
succeeded = true;
268+
return client;
269+
} catch (err: any) {
270+
log.error(`Failed to create InstallationProxy client via RemoteXPC: ${err.message}, falling back to appium-ios-device`);
271+
return null;
272+
} finally {
273+
// Only close connection if we failed (if succeeded, the client owns it)
274+
if (!succeeded && remoteXPCConnection) {
275+
try {
276+
await remoteXPCConnection.close();
277+
} catch (closeErr: any) {
278+
log.debug(`Error closing RemoteXPC connection during cleanup: ${closeErr.message}`);
279+
}
280+
}
281+
}
282+
}
283+
284+
//#endregion
285+
}

0 commit comments

Comments
 (0)