appium-ios-remotexpc 0.6.2 → 0.8.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,197 @@
1
+ import {
2
+ type PlistDictionary,
3
+ type SpringboardService as SpringboardInterface,
4
+ } from '../../../lib/types.js';
5
+ import { ServiceConnection } from '../../../service-connection.js';
6
+ import { BaseService } from '../base-service.js';
7
+
8
+ enum InterfaceOrientation {
9
+ PORTRAIT = 1, // 0 degrees (default)
10
+ PORTRAIT_UPSIDE_DOWN = 2, // 180 degrees
11
+ LANDSCAPE = 3, // 90 degrees clockwise
12
+ LANDSCAPE_HOME_TO_LEFT = 4, // 270 degrees clockwise
13
+ }
14
+
15
+ class SpringBoardService extends BaseService implements SpringboardInterface {
16
+ static readonly RSD_SERVICE_NAME =
17
+ 'com.apple.springboardservices.shim.remote';
18
+ private _conn: ServiceConnection | null = null;
19
+
20
+ constructor(address: [string, number]) {
21
+ super(address);
22
+ }
23
+
24
+ async getIconState(): Promise<PlistDictionary> {
25
+ try {
26
+ const req = {
27
+ command: 'getIconState',
28
+ formatVersion: '2',
29
+ };
30
+ return await this.sendRequestAndReceive(req);
31
+ } catch (error) {
32
+ if (error instanceof Error) {
33
+ throw new Error(`Failed to get Icon state: ${error.message}`);
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * TODO: This does not work currently due to a bug in Apple protocol implementation (maybe?)
41
+ * Uncomment tests when it is fixed
42
+ */
43
+ async setIconState(newState: PlistDictionary[] = []): Promise<void> {
44
+ try {
45
+ const req = {
46
+ command: 'setIconState',
47
+ iconState: newState,
48
+ };
49
+
50
+ await this.sendRequestAndReceive(req);
51
+ } catch (error) {
52
+ if (error instanceof Error) {
53
+ throw new Error(`Failed to set icon state: ${error.message}`);
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ async getIconPNGData(bundleID: string): Promise<Buffer> {
60
+ try {
61
+ const req = {
62
+ command: 'getIconPNGData',
63
+ bundleId: bundleID,
64
+ };
65
+ const res = await this.sendRequestAndReceive(req);
66
+ return res.pngData as Buffer;
67
+ } catch (error) {
68
+ if (error instanceof Error) {
69
+ throw new Error(`Failed to get Icon PNG data: ${error.message}`);
70
+ }
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * TODO: This does not work currently due to a bug in Apple protocol implementation
77
+ * Add tests when it is fixed
78
+ */
79
+ async getWallpaperInfo(wallpaperName: string): Promise<PlistDictionary> {
80
+ try {
81
+ const req = {
82
+ command: 'getWallpaperInfo',
83
+ wallpaperName,
84
+ };
85
+ return await this.sendRequestAndReceive(req);
86
+ } catch (error) {
87
+ if (error instanceof Error) {
88
+ throw new Error(`Failed to get wallpaper info: ${error.message}`);
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ async getWallpaperPreviewImage(
95
+ wallpaperName: 'homescreen' | 'lockscreen',
96
+ ): Promise<Buffer> {
97
+ try {
98
+ const req = {
99
+ command: 'getWallpaperPreviewImage',
100
+ wallpaperName,
101
+ };
102
+ const res = await this.sendRequestAndReceive(req);
103
+ return res.pngData as Buffer;
104
+ } catch (error) {
105
+ if (error instanceof Error) {
106
+ throw new Error(
107
+ `Failed to get wallpaper preview image: ${error.message}`,
108
+ );
109
+ }
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ async getHomescreenIconMetrics(): Promise<PlistDictionary> {
115
+ try {
116
+ const req = {
117
+ command: 'getHomeScreenIconMetrics',
118
+ };
119
+ return await this.sendRequestAndReceive(req);
120
+ } catch (error) {
121
+ if (error instanceof Error) {
122
+ throw new Error(
123
+ `Failed to get homescreen icon metrics: ${error.message}`,
124
+ );
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ async getInterfaceOrientation(): Promise<InterfaceOrientation> {
131
+ try {
132
+ const req = {
133
+ command: 'getInterfaceOrientation',
134
+ };
135
+ const res = await this.sendRequestAndReceive(req);
136
+ return res.interfaceOrientation as InterfaceOrientation;
137
+ } catch (error) {
138
+ if (error instanceof Error) {
139
+ throw new Error(
140
+ `Failed to get interface orientation: ${error.message}`,
141
+ );
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+ /**
147
+ * TODO: This does not work currently due to a bug in Apple protocol implementation
148
+ * Add tests when it is fixed
149
+ */
150
+ async getWallpaperPNGData(wallpaperName: string): Promise<Buffer> {
151
+ try {
152
+ const req = {
153
+ command: 'getHomeScreenWallpaperPNGData',
154
+ wallpaperName,
155
+ };
156
+ const res = await this.sendRequestAndReceive(req);
157
+ return res.pngData as Buffer;
158
+ } catch (error) {
159
+ if (error instanceof Error) {
160
+ throw new Error(`Failed to get wallpaper PNG data: ${error.message}`);
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ async connectToSpringboardService(): Promise<ServiceConnection> {
167
+ if (this._conn) {
168
+ return this._conn;
169
+ }
170
+ const service = this.getServiceConfig();
171
+ this._conn = await this.startLockdownService(service);
172
+ return this._conn;
173
+ }
174
+
175
+ private async sendRequestAndReceive(
176
+ request: PlistDictionary,
177
+ ): Promise<PlistDictionary> {
178
+ if (!this._conn) {
179
+ this._conn = await this.connectToSpringboardService();
180
+ }
181
+ // Skip StartService response
182
+ await this._conn.sendAndReceive(request);
183
+ return await this._conn.sendPlistRequest(request);
184
+ }
185
+
186
+ private getServiceConfig(): {
187
+ serviceName: string;
188
+ port: string;
189
+ } {
190
+ return {
191
+ serviceName: SpringBoardService.RSD_SERVICE_NAME,
192
+ port: this.address[1].toString(),
193
+ };
194
+ }
195
+ }
196
+
197
+ export { SpringBoardService, InterfaceOrientation };
@@ -0,0 +1,372 @@
1
+ import { logger } from '@appium/support';
2
+ import { randomUUID } from 'crypto';
3
+ import { EventEmitter } from 'events';
4
+
5
+ import type { PlistDictionary, PlistMessage } from '../../../lib/types.js';
6
+ import { ServiceConnection } from '../../../service-connection.js';
7
+ import { BaseService } from '../base-service.js';
8
+
9
+ const log = logger.getLogger('WebInspectorService');
10
+
11
+ /**
12
+ * Interface for WebInspector message structure
13
+ */
14
+ export interface WebInspectorMessage extends PlistDictionary {
15
+ __selector: string;
16
+ __argument: PlistDictionary;
17
+ }
18
+
19
+ /**
20
+ * WebInspectorService provides an API to:
21
+ * - Send messages to webinspectord
22
+ * - Listen to messages from webinspectord
23
+ * - Communicate with web views and Safari on iOS devices
24
+ *
25
+ * This service is used for web automation, inspection, and debugging.
26
+ */
27
+ export class WebInspectorService extends BaseService {
28
+ static readonly RSD_SERVICE_NAME = 'com.apple.webinspector.shim.remote';
29
+
30
+ // RPC method selectors
31
+ private static readonly RPC_REPORT_IDENTIFIER = '_rpc_reportIdentifier:';
32
+ private static readonly RPC_REQUEST_APPLICATION_LAUNCH =
33
+ '_rpc_requestApplicationLaunch:';
34
+ private static readonly RPC_GET_CONNECTED_APPLICATIONS =
35
+ '_rpc_getConnectedApplications:';
36
+ private static readonly RPC_FORWARD_GET_LISTING = '_rpc_forwardGetListing:';
37
+ private static readonly RPC_FORWARD_AUTOMATION_SESSION_REQUEST =
38
+ '_rpc_forwardAutomationSessionRequest:';
39
+ private static readonly RPC_FORWARD_SOCKET_SETUP = '_rpc_forwardSocketSetup:';
40
+ private static readonly RPC_FORWARD_SOCKET_DATA = '_rpc_forwardSocketData:';
41
+ private static readonly RPC_FORWARD_INDICATE_WEB_VIEW =
42
+ '_rpc_forwardIndicateWebView:';
43
+
44
+ private connection: ServiceConnection | null = null;
45
+ private messageEmitter: EventEmitter = new EventEmitter();
46
+ private isReceiving: boolean = false;
47
+ private readonly connectionId: string;
48
+ private receivePromise: Promise<void> | null = null;
49
+
50
+ constructor(address: [string, number]) {
51
+ super(address);
52
+ this.connectionId = randomUUID().toUpperCase();
53
+ }
54
+
55
+ /**
56
+ * Send a message to the WebInspector service
57
+ * @param selector The RPC selector (e.g., '_rpc_reportIdentifier:')
58
+ * @param args The arguments dictionary for the message
59
+ * @returns Promise that resolves when the message is sent
60
+ */
61
+ async sendMessage(
62
+ selector: string,
63
+ args: PlistDictionary = {},
64
+ ): Promise<void> {
65
+ const connection = await this.connectToWebInspectorService();
66
+
67
+ // Add connection identifier to all messages
68
+ const message: WebInspectorMessage = {
69
+ __selector: selector,
70
+ __argument: {
71
+ ...args,
72
+ WIRConnectionIdentifierKey: this.connectionId,
73
+ },
74
+ };
75
+
76
+ log.debug(`Sending WebInspector message: ${selector}`);
77
+
78
+ connection.sendPlist(message);
79
+ }
80
+
81
+ /**
82
+ * Listen to messages from the WebInspector service using async generator
83
+ * @yields PlistMessage - Messages received from the WebInspector service
84
+ */
85
+ async *listenMessage(): AsyncGenerator<PlistMessage, void, unknown> {
86
+ await this.connectToWebInspectorService();
87
+
88
+ // Start receiving messages in background if not already started
89
+ if (!this.isReceiving) {
90
+ this.startMessageReceiver();
91
+ }
92
+
93
+ const queue: PlistMessage[] = [];
94
+ let resolveNext: ((value: IteratorResult<PlistMessage>) => void) | null =
95
+ null;
96
+ let stopped = false;
97
+
98
+ const messageHandler = (message: PlistMessage) => {
99
+ if (resolveNext) {
100
+ resolveNext({ value: message, done: false });
101
+ resolveNext = null;
102
+ } else {
103
+ queue.push(message);
104
+ }
105
+ };
106
+
107
+ const stopHandler = () => {
108
+ stopped = true;
109
+ if (resolveNext) {
110
+ resolveNext({ value: undefined, done: true });
111
+ resolveNext = null;
112
+ }
113
+ };
114
+
115
+ this.messageEmitter.on('message', messageHandler);
116
+ this.messageEmitter.once('stop', stopHandler);
117
+
118
+ try {
119
+ while (!stopped) {
120
+ if (queue.length > 0) {
121
+ yield queue.shift()!;
122
+ } else {
123
+ const message = await new Promise<PlistMessage | null>((resolve) => {
124
+ if (stopped) {
125
+ resolve(null);
126
+ return;
127
+ }
128
+ resolveNext = (result) => {
129
+ resolve(result.done ? null : result.value);
130
+ };
131
+ });
132
+
133
+ if (message === null) {
134
+ break;
135
+ }
136
+
137
+ yield message;
138
+ }
139
+ }
140
+ } finally {
141
+ this.messageEmitter.off('message', messageHandler);
142
+ this.messageEmitter.off('stop', stopHandler);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Stop listening to messages
148
+ */
149
+ stopListening(): void {
150
+ this.isReceiving = false;
151
+ this.messageEmitter.emit('stop');
152
+ }
153
+
154
+ /**
155
+ * Close the connection and clean up resources
156
+ */
157
+ async close(): Promise<void> {
158
+ this.stopListening();
159
+
160
+ if (this.connection) {
161
+ await this.connection.close();
162
+ this.connection = null;
163
+ log.debug('WebInspector connection closed');
164
+ }
165
+
166
+ if (this.receivePromise) {
167
+ await this.receivePromise;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get the connection ID being used for this service
173
+ * @returns The connection identifier
174
+ */
175
+ getConnectionId(): string {
176
+ return this.connectionId;
177
+ }
178
+
179
+ /**
180
+ * Request application launch
181
+ * @param bundleId The bundle identifier of the application to launch
182
+ */
183
+ async requestApplicationLaunch(bundleId: string): Promise<void> {
184
+ await this.sendMessage(WebInspectorService.RPC_REQUEST_APPLICATION_LAUNCH, {
185
+ WIRApplicationBundleIdentifierKey: bundleId,
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Get connected applications
191
+ */
192
+ async getConnectedApplications(): Promise<void> {
193
+ await this.sendMessage(
194
+ WebInspectorService.RPC_GET_CONNECTED_APPLICATIONS,
195
+ {},
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Forward get listing for an application
201
+ * @param appId The application identifier
202
+ */
203
+ async forwardGetListing(appId: string): Promise<void> {
204
+ await this.sendMessage(WebInspectorService.RPC_FORWARD_GET_LISTING, {
205
+ WIRApplicationIdentifierKey: appId,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Forward automation session request
211
+ * @param sessionId The session identifier
212
+ * @param appId The application identifier
213
+ * @param capabilities Optional session capabilities
214
+ */
215
+ async forwardAutomationSessionRequest(
216
+ sessionId: string,
217
+ appId: string,
218
+ capabilities?: PlistDictionary,
219
+ ): Promise<void> {
220
+ const defaultCapabilities: PlistDictionary = {
221
+ 'org.webkit.webdriver.webrtc.allow-insecure-media-capture': true,
222
+ 'org.webkit.webdriver.webrtc.suppress-ice-candidate-filtering': false,
223
+ };
224
+
225
+ await this.sendMessage(
226
+ WebInspectorService.RPC_FORWARD_AUTOMATION_SESSION_REQUEST,
227
+ {
228
+ WIRApplicationIdentifierKey: appId,
229
+ WIRSessionIdentifierKey: sessionId,
230
+ WIRSessionCapabilitiesKey: {
231
+ ...defaultCapabilities,
232
+ ...(capabilities ?? {}),
233
+ },
234
+ },
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Forward socket setup for inspector connection
240
+ * @param sessionId The session identifier
241
+ * @param appId The application identifier
242
+ * @param pageId The page identifier
243
+ * @param automaticallyPause Whether to automatically pause (defaults to true)
244
+ */
245
+ async forwardSocketSetup(
246
+ sessionId: string,
247
+ appId: string,
248
+ pageId: number,
249
+ automaticallyPause: boolean = true,
250
+ ): Promise<void> {
251
+ const message: PlistDictionary = {
252
+ WIRApplicationIdentifierKey: appId,
253
+ WIRPageIdentifierKey: pageId,
254
+ WIRSenderKey: sessionId,
255
+ WIRMessageDataTypeChunkSupportedKey: 0,
256
+ };
257
+
258
+ if (!automaticallyPause) {
259
+ message.WIRAutomaticallyPause = false;
260
+ }
261
+
262
+ await this.sendMessage(
263
+ WebInspectorService.RPC_FORWARD_SOCKET_SETUP,
264
+ message,
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Forward socket data to a page
270
+ * @param sessionId The session identifier
271
+ * @param appId The application identifier
272
+ * @param pageId The page identifier
273
+ * @param data The data to send (will be JSON stringified)
274
+ */
275
+ async forwardSocketData(
276
+ sessionId: string,
277
+ appId: string,
278
+ pageId: number,
279
+ data: any,
280
+ ): Promise<void> {
281
+ const socketData = typeof data === 'string' ? data : JSON.stringify(data);
282
+
283
+ await this.sendMessage(WebInspectorService.RPC_FORWARD_SOCKET_DATA, {
284
+ WIRApplicationIdentifierKey: appId,
285
+ WIRPageIdentifierKey: pageId,
286
+ WIRSessionIdentifierKey: sessionId,
287
+ WIRSenderKey: sessionId,
288
+ WIRSocketDataKey: Buffer.from(socketData, 'utf-8'),
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Forward indicate web view
294
+ * @param appId The application identifier
295
+ * @param pageId The page identifier
296
+ * @param enable Whether to enable indication
297
+ */
298
+ async forwardIndicateWebView(
299
+ appId: string,
300
+ pageId: number,
301
+ enable: boolean,
302
+ ): Promise<void> {
303
+ await this.sendMessage(WebInspectorService.RPC_FORWARD_INDICATE_WEB_VIEW, {
304
+ WIRApplicationIdentifierKey: appId,
305
+ WIRPageIdentifierKey: pageId,
306
+ WIRIndicateEnabledKey: enable,
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Connect to the WebInspector service
312
+ * @returns Promise resolving to the ServiceConnection instance
313
+ */
314
+ private async connectToWebInspectorService(): Promise<ServiceConnection> {
315
+ if (this.connection) {
316
+ return this.connection;
317
+ }
318
+
319
+ const service = {
320
+ serviceName: WebInspectorService.RSD_SERVICE_NAME,
321
+ port: this.address[1].toString(),
322
+ };
323
+
324
+ this.connection = await this.startLockdownService(service);
325
+
326
+ // Consume the StartService response from RSDCheckin
327
+ const startServiceResponse = await this.connection.receive();
328
+ if (startServiceResponse?.Request !== 'StartService') {
329
+ log.warn(
330
+ `Expected StartService response, got: ${JSON.stringify(startServiceResponse)}`,
331
+ );
332
+ }
333
+
334
+ // Send initial identifier report
335
+ await this.sendMessage(WebInspectorService.RPC_REPORT_IDENTIFIER, {});
336
+
337
+ log.debug('Connected to WebInspector service');
338
+ return this.connection;
339
+ }
340
+
341
+ /**
342
+ * Start receiving messages from the WebInspector service in the background
343
+ */
344
+ private startMessageReceiver(): void {
345
+ if (this.isReceiving || !this.connection) {
346
+ return;
347
+ }
348
+
349
+ this.isReceiving = true;
350
+
351
+ this.receivePromise = (async () => {
352
+ try {
353
+ while (this.isReceiving && this.connection) {
354
+ try {
355
+ const message = await this.connection.receive();
356
+ this.messageEmitter.emit('message', message);
357
+ } catch (error) {
358
+ if (this.isReceiving) {
359
+ log.error('Error receiving message:', error);
360
+ this.messageEmitter.emit('error', error);
361
+ }
362
+ break;
363
+ }
364
+ }
365
+ } finally {
366
+ this.isReceiving = false;
367
+ }
368
+ })();
369
+ }
370
+ }
371
+
372
+ export default WebInspectorService;
package/src/services.ts CHANGED
@@ -8,13 +8,17 @@ import type {
8
8
  MobileConfigServiceWithConnection,
9
9
  MobileImageMounterServiceWithConnection,
10
10
  NotificationProxyServiceWithConnection,
11
+ SpringboardServiceWithConnection,
11
12
  SyslogService as SyslogServiceType,
13
+ WebInspectorServiceWithConnection,
12
14
  } from './lib/types.js';
13
15
  import DiagnosticsService from './services/ios/diagnostic-service/index.js';
14
16
  import { MobileConfigService } from './services/ios/mobile-config/index.js';
15
17
  import MobileImageMounterService from './services/ios/mobile-image-mounter/index.js';
16
18
  import { NotificationProxyService } from './services/ios/notification-proxy/index.js';
19
+ import { SpringBoardService } from './services/ios/springboard-service/index.js';
17
20
  import SyslogService from './services/ios/syslog-service/index.js';
21
+ import { WebInspectorService } from './services/ios/webinspector/index.js';
18
22
 
19
23
  const APPIUM_XCUITEST_DRIVER_NAME = 'appium-xcuitest-driver';
20
24
  const TUNNEL_REGISTRY_PORT = 'tunnelRegistryPort';
@@ -82,6 +86,22 @@ export async function startMobileImageMounterService(
82
86
  };
83
87
  }
84
88
 
89
+ export async function startSpringboardService(
90
+ udid: string,
91
+ ): Promise<SpringboardServiceWithConnection> {
92
+ const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
93
+ const springboardService = remoteXPC.findService(
94
+ SpringBoardService.RSD_SERVICE_NAME,
95
+ );
96
+ return {
97
+ remoteXPC: remoteXPC as RemoteXpcConnection,
98
+ springboardService: new SpringBoardService([
99
+ tunnelConnection.host,
100
+ parseInt(springboardService.port, 10),
101
+ ]),
102
+ };
103
+ }
104
+
85
105
  export async function startSyslogService(
86
106
  udid: string,
87
107
  ): Promise<SyslogServiceType> {
@@ -89,6 +109,22 @@ export async function startSyslogService(
89
109
  return new SyslogService([tunnelConnection.host, tunnelConnection.port]);
90
110
  }
91
111
 
112
+ export async function startWebInspectorService(
113
+ udid: string,
114
+ ): Promise<WebInspectorServiceWithConnection> {
115
+ const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
116
+ const webInspectorService = remoteXPC.findService(
117
+ WebInspectorService.RSD_SERVICE_NAME,
118
+ );
119
+ return {
120
+ remoteXPC: remoteXPC as RemoteXpcConnection,
121
+ webInspectorService: new WebInspectorService([
122
+ tunnelConnection.host,
123
+ parseInt(webInspectorService.port, 10),
124
+ ]),
125
+ };
126
+ }
127
+
92
128
  export async function createRemoteXPCConnection(udid: string) {
93
129
  const tunnelConnection = await getTunnelInformation(udid);
94
130
  const remoteXPC = await startService(