appium-remote-debugger
Version:
Appium proxy for Remote Debugger protocol
379 lines • 19 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setConnectionKey = setConnectionKey;
exports.connect = connect;
exports.disconnect = disconnect;
exports.selectApp = selectApp;
exports.selectPage = selectPage;
exports.getPossibleDebuggerAppKeys = getPossibleDebuggerAppKeys;
const utils_1 = require("../utils");
const events_1 = require("./events");
const support_1 = require("@appium/support");
const asyncbox_1 = require("asyncbox");
const lodash_1 = __importDefault(require("lodash"));
const property_accessors_1 = require("./property-accessors");
const rpc_client_1 = require("../rpc/rpc-client");
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.
*/
async function setConnectionKey() {
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.
*/
async function connect(timeout = APP_CONNECT_TIMEOUT_MS) {
this.setup();
// initialize the rpc client
await this.initRpcClient();
const rpcClient = this.requireRpcClient();
// listen for basic debugger-level events
rpcClient.on('_rpc_reportSetup:', lodash_1.default.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 support_1.timing.Timer().start();
this.log.debug(`Waiting up to ${timeout}ms for applications to be reported`);
try {
await (0, asyncbox_1.waitForCondition)(() => !lodash_1.default.isEmpty((0, property_accessors_1.getAppDict)(this)), {
waitMs: timeout,
intervalMs: APP_CONNECT_INTERVAL_MS,
});
this.log.debug(`Retrieved ${support_1.util.pluralize('application', lodash_1.default.size((0, property_accessors_1.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) {
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.
*/
async function disconnect() {
await (0, property_accessors_1.getRcpClient)(this)?.disconnect();
this.emit(events_1.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.
*/
async function selectApp(currentUrl = null, maxTries = SELECT_APP_RETRIES, ignoreAboutBlankUrl = false) {
this.log.debug('Selecting application');
const timer = new support_1.timing.Timer().start();
if (lodash_1.default.isEmpty((0, property_accessors_1.getAppDict)(this))) {
this.log.debug('No applications currently connected.');
return [];
}
if (isAppIgnored(this)) {
return [];
}
const { appIdKey } = await searchForApp.bind(this)(currentUrl, maxTries, ignoreAboutBlankUrl);
if ((0, property_accessors_1.getAppIdKey)(this) !== appIdKey) {
this.log.debug(`Received altered app id, updating from '${(0, property_accessors_1.getAppIdKey)(this)}' to '${appIdKey}'`);
(0, property_accessors_1.setAppIdKey)(this, appIdKey);
}
logApplicationDictionary.bind(this)();
// translate the dictionary into a useful form, and return to sender
this.log.debug(`Finally selecting app ${(0, property_accessors_1.getAppIdKey)(this)}`);
const fullPageArray = [];
for (const [app, info] of lodash_1.default.toPairs((0, property_accessors_1.getAppDict)(this))) {
if (!lodash_1.default.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 = lodash_1.default.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.
*/
async function selectPage(appIdKey, pageIdKey, skipReadyCheck = false) {
const fullAppIdKey = lodash_1.default.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`;
(0, property_accessors_1.setAppIdKey)(this, fullAppIdKey);
(0, property_accessors_1.setPageIdKey)(this, pageIdKey);
this.log.debug(`Selecting page '${pageIdKey}' on app '${fullAppIdKey}' and forwarding socket setup`);
const timer = new support_1.timing.Timer().start();
const pageReadinessDetector = skipReadyCheck
? undefined
: {
timeoutMs: this.pageLoadMs,
readinessDetector: (readyState) => 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.
*/
function getPossibleDebuggerAppKeys(bundleIds) {
const appDict = (0, property_accessors_1.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 lodash_1.default.keys(appDict);
}
// go through the possible bundle identifiers
const possibleBundleIds = lodash_1.default.uniq([
utils_1.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 = [];
for (const bundleId of possibleBundleIds) {
// now we need to determine if we should pick a proxy for this instead
for (const appId of (0, utils_1.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 lodash_1.default.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 lodash_1.default.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(currentUrl, maxTries, ignoreAboutBlankUrl) {
const bundleIds = lodash_1.default.compact([
(0, property_accessors_1.getBundleId)(this),
...((0, property_accessors_1.getAdditionalBundleIds)(this) ?? []),
...((0, property_accessors_1.getIncludeSafari)(this) && !(0, property_accessors_1.getIsSafari)(this) ? [SAFARI_BUNDLE_ID] : []),
]);
let retryCount = 0;
return (await (0, asyncbox_1.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 = (0, property_accessors_1.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
(0, property_accessors_1.getAppDict)(this)[appIdKey].pageArray = (0, utils_1.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)((0, property_accessors_1.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) {
if (![rpc_client_1.NEW_APP_CONNECTED_ERROR, rpc_client_1.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.`);
}));
}
/**
* 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(appsDict, currentUrl = null, ignoreAboutBlankUrl = false) {
for (const appDict of lodash_1.default.values(appsDict)) {
if (!appDict || !appDict.isActive || !appDict.pageArray || lodash_1.default.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) {
const ignoredBundleIds = (0, property_accessors_1.getIgnoredBundleIds)(instance) ?? [];
if (ignoredBundleIds.length === 0) {
return false;
}
const ignoredSet = new Set(ignoredBundleIds);
const appBundleIds = new Set(lodash_1.default.values((0, property_accessors_1.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(', ')}. ` +
`${support_1.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.log.debug('Current applications available:');
for (const [app, info] of lodash_1.default.toPairs((0, property_accessors_1.getAppDict)(this))) {
this.log.debug(` Application: "${app}"`);
for (const [key, value] of lodash_1.default.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 lodash_1.default.toPairs(page)) {
this.log.debug(` ${prefix}${k}: ${JSON.stringify(v)}`);
prefix = ' ';
}
}
}
else {
const valueString = lodash_1.default.isFunction(value) ? '[Function]' : JSON.stringify(value);
this.log.debug(` ${key}: ${valueString}`);
}
}
}
}
//# sourceMappingURL=connect.js.map