appium-remote-debugger
Version:
Appium proxy for Remote Debugger protocol
469 lines (432 loc) • 17.7 kB
text/typescript
import {pageArrayFromDict, WEB_CONTENT_BUNDLE_ID, appIdsForBundle} from '../utils';
import {events} from './events';
import {timing, util} from '@appium/support';
import {retryInterval, waitForCondition} from 'asyncbox';
import _ from 'lodash';
import {
setAppIdKey,
getAppDict,
getAppIdKey,
setPageIdKey,
getRcpClient,
getIsSafari,
getIncludeSafari,
getBundleId,
getAdditionalBundleIds,
getIgnoredBundleIds,
} from './property-accessors';
import {NEW_APP_CONNECTED_ERROR, EMPTY_PAGE_DICTIONARY_ERROR} from '../rpc/rpc-client';
import type {RemoteDebugger} from '../remote-debugger';
import type {AppDict, Page, AppIdKey, PageIdKey, AppPage} from '../types';
const APP_CONNECT_TIMEOUT_MS = 0;
const APP_CONNECT_INTERVAL_MS = 100;
const SELECT_APP_RETRIES = 20;
const SELECT_APP_RETRY_SLEEP_MS = 500;
const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
const BLANK_PAGE_URL = 'about:blank';
const WEB_CONTENT_PROCESS_BUNDLE_ID = 'process-com.apple.WebKit.WebContent';
const SAFARI_VIEW_PROCESS_BUNDLE_ID = 'process-SafariViewService';
const SAFARI_VIEW_BUNDLE_ID = 'com.apple.SafariViewService';
const WILDCARD_BUNDLE_ID = '*';
/**
* Sends a connection key request to the Web Inspector.
* This method only waits to ensure the socket connection works, as the response
* from Web Inspector can take a long time.
*/
export async function setConnectionKey(this: RemoteDebugger): Promise<void> {
this.log.debug('Sending connection key request');
// send but only wait to make sure the socket worked
// as response from Web Inspector can take a long time
await this.requireRpcClient().send('setConnectionKey', {}, false);
}
/**
* Establishes a connection to the remote debugger and initializes the RPC client.
* Sets up event listeners for debugger-level events and waits for applications
* to be reported if a timeout is specified.
*
* @param timeout - Maximum time in milliseconds to wait for applications to be reported.
* Defaults to 0 (no waiting). If provided, the method will wait up to
* this duration for applications to appear in the app dictionary.
* @returns A promise that resolves to the application dictionary containing all
* connected applications.
*/
export async function connect(
this: RemoteDebugger,
timeout: number = APP_CONNECT_TIMEOUT_MS,
): Promise<AppDict> {
this.setup();
// initialize the rpc client
await this.initRpcClient();
const rpcClient = this.requireRpcClient();
// listen for basic debugger-level events
rpcClient.on('_rpc_reportSetup:', _.noop);
rpcClient.on('_rpc_forwardGetListing:', this.onPageChange.bind(this));
rpcClient.on('_rpc_reportConnectedApplicationList:', this.onConnectedApplicationList.bind(this));
rpcClient.on('_rpc_applicationConnected:', this.onAppConnect.bind(this));
rpcClient.on('_rpc_applicationDisconnected:', this.onAppDisconnect.bind(this));
rpcClient.on('_rpc_applicationUpdated:', this.onAppUpdate.bind(this));
rpcClient.on('_rpc_reportConnectedDriverList:', this.onConnectedDriverList.bind(this));
rpcClient.on('_rpc_reportCurrentState:', this.onCurrentState.bind(this));
rpcClient.on('Page.frameDetached', this.frameDetached.bind(this));
await rpcClient.connect();
// get the connection information about the app
try {
await this.setConnectionKey();
if (timeout) {
const timer = new timing.Timer().start();
this.log.debug(`Waiting up to ${timeout}ms for applications to be reported`);
try {
await waitForCondition(() => !_.isEmpty(getAppDict(this)), {
waitMs: timeout,
intervalMs: APP_CONNECT_INTERVAL_MS,
});
this.log.debug(
`Retrieved ${util.pluralize('application', _.size(getAppDict(this)), true)} ` +
`within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
} catch {
this.log.debug(`Timed out waiting for applications to be reported`);
}
}
return this.appDict;
} catch (err: any) {
this.log.error(`Error setting connection key: ${err.message}`);
await this.disconnect();
throw err;
}
}
/**
* Disconnects from the remote debugger by closing the RPC client connection,
* emitting a disconnect event, and performing cleanup via teardown.
*/
export async function disconnect(this: RemoteDebugger): Promise<void> {
await getRcpClient(this)?.disconnect();
this.emit(events.EVENT_DISCONNECT, true);
this.teardown();
}
/**
* Selects an application from the available connected applications.
* Searches for an app matching the provided URL and bundle IDs, then returns
* all pages from the selected application.
*
* @param currentUrl - Optional URL to match when selecting an application.
* If provided, the method will try to find an app containing
* a page with this URL.
* @param maxTries - Maximum number of retry attempts when searching for an app.
* Defaults to SELECT_APP_RETRIES (20).
* @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be
* excluded from the results. Defaults to false.
* @returns A promise that resolves to an array of Page objects from the selected
* application. Returns an empty array if no applications are connected.
*/
export async function selectApp(
this: RemoteDebugger,
currentUrl: string | null = null,
maxTries: number = SELECT_APP_RETRIES,
ignoreAboutBlankUrl: boolean = false,
): Promise<Page[]> {
this.log.debug('Selecting application');
const timer = new timing.Timer().start();
if (_.isEmpty(getAppDict(this))) {
this.log.debug('No applications currently connected.');
return [];
}
if (isAppIgnored(this)) {
return [];
}
const {appIdKey} = await searchForApp.bind(this)(currentUrl, maxTries, ignoreAboutBlankUrl);
if (getAppIdKey(this) !== appIdKey) {
this.log.debug(
`Received altered app id, updating from '${getAppIdKey(this)}' to '${appIdKey}'`,
);
setAppIdKey(this, appIdKey);
}
logApplicationDictionary.bind(this)();
// translate the dictionary into a useful form, and return to sender
this.log.debug(`Finally selecting app ${getAppIdKey(this)}`);
const fullPageArray: Page[] = [];
for (const [app, info] of _.toPairs(getAppDict(this))) {
if (!_.isArray(info.pageArray) || !info.isActive) {
continue;
}
const id = app.replace('PID:', '');
for (const page of info.pageArray) {
if (!(ignoreAboutBlankUrl && page.url === BLANK_PAGE_URL)) {
const pageDict = _.clone(page);
pageDict.id = `${id}.${pageDict.id}`;
pageDict.bundleId = info.bundleId;
fullPageArray.push(pageDict);
}
}
}
this.log.debug(`Selected app after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
return fullPageArray;
}
/**
* Selects a specific page within an application and forwards socket setup.
* Optionally waits for the page to be ready based on the page load strategy.
*
* @param appIdKey - The application identifier key. Will be prefixed with 'PID:'
* if not already present.
* @param pageIdKey - The page identifier key to select.
* @param skipReadyCheck - If true, skips the page readiness check. Defaults to false.
* When false, the method will wait for the page to be ready
* according to the configured page load strategy.
*/
export async function selectPage(
this: RemoteDebugger,
appIdKey: AppIdKey,
pageIdKey: PageIdKey,
skipReadyCheck: boolean = false,
): Promise<void> {
const fullAppIdKey = _.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`;
setAppIdKey(this, fullAppIdKey);
setPageIdKey(this, pageIdKey);
this.log.debug(
`Selecting page '${pageIdKey}' on app '${fullAppIdKey}' and forwarding socket setup`,
);
const timer = new timing.Timer().start();
const pageReadinessDetector = skipReadyCheck
? undefined
: {
timeoutMs: this.pageLoadMs,
readinessDetector: (readyState: string) => this.isPageLoadingCompleted(readyState),
};
await this.requireRpcClient().selectPage(fullAppIdKey, pageIdKey, pageReadinessDetector);
this.log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
}
/**
* Finds app keys based on assigned bundle IDs from the app dictionary.
* When bundleIds includes a wildcard ('*'), returns all app keys in the app dictionary.
* Also handles proxy applications that may act on behalf of other bundle IDs.
*
* @param bundleIds - Array of bundle identifiers to match against. If the array
* contains a wildcard ('*'), all apps will be returned.
* @returns Array of application identifier keys that match the provided bundle IDs.
*/
export function getPossibleDebuggerAppKeys(this: RemoteDebugger, bundleIds: string[]): string[] {
const appDict = getAppDict(this);
if (bundleIds.includes(WILDCARD_BUNDLE_ID)) {
this.log.info(
'Returning all apps because the list of matching bundle identifiers includes a wildcard',
);
return _.keys(appDict);
}
// go through the possible bundle identifiers
const possibleBundleIds = _.uniq([
WEB_CONTENT_BUNDLE_ID,
WEB_CONTENT_PROCESS_BUNDLE_ID,
SAFARI_VIEW_PROCESS_BUNDLE_ID,
SAFARI_VIEW_BUNDLE_ID,
...bundleIds,
]);
this.log.debug(
`Checking for apps with matching bundle identifiers: ${possibleBundleIds.join(', ')}`,
);
const proxiedAppIds: string[] = [];
for (const bundleId of possibleBundleIds) {
// now we need to determine if we should pick a proxy for this instead
for (const appId of appIdsForBundle(bundleId, appDict)) {
if (proxiedAppIds.includes(appId)) {
continue;
}
proxiedAppIds.push(appId);
this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`);
for (const [key, data] of _.toPairs(appDict)) {
if (data.isProxy && data.hostId === appId && !proxiedAppIds.includes(key)) {
this.log.debug(
`Found separate bundleId '${data.bundleId}' ` +
`acting as proxy for '${bundleId}', with app id '${key}'`,
);
proxiedAppIds.push(key);
}
}
}
}
this.log.debug(
`You may also consider providing more values to 'additionalWebviewBundleIds' ` +
`capability to match other applications. Add a wildcard ('*') to match all apps.`,
);
return _.uniq(proxiedAppIds);
}
/**
* Searches for an application matching the given criteria by retrying with
* exponential backoff. Attempts to connect to apps matching the bundle IDs
* and optionally filters by URL.
*
* @param currentUrl - Optional URL to match when searching for a page.
* If provided, only apps containing a page with this URL
* will be considered.
* @param maxTries - Maximum number of retry attempts.
* @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be
* ignored during the search.
* @returns A promise that resolves to an AppPage object containing the matched
* app ID key and page dictionary.
* @throws Error if no valid webapp can be connected after all retry attempts.
*/
async function searchForApp(
this: RemoteDebugger,
currentUrl: string | null,
maxTries: number,
ignoreAboutBlankUrl: boolean,
): Promise<AppPage> {
const bundleIds: string[] = _.compact([
getBundleId(this),
...(getAdditionalBundleIds(this) ?? []),
...(getIncludeSafari(this) && !getIsSafari(this) ? [SAFARI_BUNDLE_ID] : []),
]);
let retryCount = 0;
return (await retryInterval(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => {
logApplicationDictionary.bind(this)();
const possibleAppIds = getPossibleDebuggerAppKeys.bind(this)(bundleIds);
this.log.debug(
`Trying out the possible app ids: ${possibleAppIds.join(', ')} (try #${retryCount + 1} of ${maxTries})`,
);
for (const attemptedAppIdKey of possibleAppIds) {
const appInfo = getAppDict(this)[attemptedAppIdKey];
if (!appInfo) {
continue;
}
if (
!appInfo.isActive ||
(!appInfo.isAutomationEnabled && appInfo.bundleId === SAFARI_BUNDLE_ID)
) {
this.log.debug(
`Skipping app '${attemptedAppIdKey}' because it is not ${appInfo.isActive ? 'enabled' : 'active'}`,
);
continue;
}
this.log.debug(`Attempting app '${attemptedAppIdKey}'`);
try {
const [appIdKey, pageDict] = await this.requireRpcClient().selectApp(attemptedAppIdKey);
// save the page array for this app
getAppDict(this)[appIdKey].pageArray = pageArrayFromDict(pageDict);
// if we are looking for a particular url, make sure we
// have the right page. Ignore empty or undefined urls.
// Ignore about:blank if requested.
const result = searchForPage.bind(this)(getAppDict(this), currentUrl, ignoreAboutBlankUrl);
if (result) {
return result;
}
if (currentUrl) {
this.log.debug(
`Received app, but expected url ('${currentUrl}') was not found. Trying again.`,
);
} else {
this.log.debug('Received app, but no match was found. Trying again.');
}
} catch (err: any) {
if (
![NEW_APP_CONNECTED_ERROR, EMPTY_PAGE_DICTIONARY_ERROR].some((msg) => msg === err.message)
) {
this.log.debug(err.stack);
}
this.log.warn(
`The application ${attemptedAppIdKey} is not connectable yet: ${err.message}`,
);
}
}
retryCount++;
throw new Error(
`Could not connect to a valid webapp. Make sure it is debuggable and has at least one active page.`,
);
})) as Promise<AppPage>;
}
/**
* Searches through the application dictionary to find a page matching the given URL.
* Only considers active applications with non-empty page arrays.
*
* @param appsDict - The application dictionary to search through.
* @param currentUrl - Optional URL to match. If provided, only pages with this exact
* URL or with this URL followed by '/' will be considered.
* @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be ignored.
* @returns An AppPage object if a matching page is found, null otherwise.
*/
function searchForPage(
this: RemoteDebugger,
appsDict: AppDict,
currentUrl: string | null = null,
ignoreAboutBlankUrl: boolean = false,
): AppPage | null {
for (const appDict of _.values(appsDict)) {
if (!appDict || !appDict.isActive || !appDict.pageArray || _.isEmpty(appDict.pageArray)) {
continue;
}
for (const page of appDict.pageArray) {
if (
(!ignoreAboutBlankUrl || page.url !== BLANK_PAGE_URL) &&
(!currentUrl || page.url === currentUrl || page.url === `${currentUrl}/`)
) {
return {
appIdKey: appDict.id,
pageDict: page,
};
}
}
}
return null;
}
/**
* Checks whether all apps in the app dictionary have bundle IDs that are in the
* configured ignore list and logs the result accordingly.
*
* Uses Set-based lookups for O(1) performance and computes the actual intersection
* of appDict bundle IDs with the ignore list for accurate log messages.
*
* @param instance - The RemoteDebugger instance.
* @returns `true` if webview search should be skipped (all apps are ignored),
* `false` if the search should proceed.
*/
function isAppIgnored(instance: RemoteDebugger): boolean {
const ignoredBundleIds = getIgnoredBundleIds(instance) ?? [];
if (ignoredBundleIds.length === 0) {
return false;
}
const ignoredSet = new Set(ignoredBundleIds);
const appBundleIds = new Set(_.values(getAppDict(instance)).map((app) => app.bundleId));
if (appBundleIds.size === 0) {
return false;
}
const nonIgnoredBundleIds = [...appBundleIds].filter((id) => !ignoredSet.has(id));
const actuallyIgnoredIds = [...appBundleIds].filter((id) => ignoredSet.has(id));
if (nonIgnoredBundleIds.length === 0) {
instance.log.info(
`All apps reported by Web Inspector have bundle IDs in the ignore list ` +
`(${actuallyIgnoredIds.join(', ')}). Skipping webview search.`,
);
return true;
}
if (actuallyIgnoredIds.length > 0) {
instance.log.debug(
`Ignoring apps with bundle IDs: ${actuallyIgnoredIds.join(', ')}. ` +
`${util.pluralize('app', nonIgnoredBundleIds.length, true)} remain for webview search.`,
);
}
return false;
}
/**
* Logs the current application dictionary to the debug log.
* Displays all applications, their properties, and their associated pages
* in a formatted structure.
*/
function logApplicationDictionary(this: RemoteDebugger): void {
this.log.debug('Current applications available:');
for (const [app, info] of _.toPairs(getAppDict(this))) {
this.log.debug(` Application: "${app}"`);
for (const [key, value] of _.toPairs(info)) {
if (key === 'pageArray' && Array.isArray(value) && value.length) {
this.log.debug(` ${key}:`);
for (const page of value) {
let prefix = '- ';
for (const [k, v] of _.toPairs(page)) {
this.log.debug(` ${prefix}${k}: ${JSON.stringify(v)}`);
prefix = ' ';
}
}
} else {
const valueString = _.isFunction(value) ? '[Function]' : JSON.stringify(value);
this.log.debug(` ${key}: ${valueString}`);
}
}
}
}