Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 153 additions & 113 deletions lib/mixins/connect.js → lib/mixins/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
getAdditionalBundleIds,
} 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;
Expand All @@ -32,11 +34,11 @@ const SAFARI_VIEW_BUNDLE_ID = 'com.apple.SafariViewService';
const WILDCARD_BUNDLE_ID = '*';

/**
*
* @this {RemoteDebugger}
* @returns {Promise<void>}
* 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 () {
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
Expand All @@ -45,12 +47,17 @@ export async function setConnectionKey () {
}

/**
* 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.
*
* @this {RemoteDebugger}
* @param {number} [timeout=APP_CONNECT_TIMEOUT_MS]
* @returns {Promise<import('../types').AppDict>}
* @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 (timeout = APP_CONNECT_TIMEOUT_MS) {
export async function connect(this: RemoteDebugger, timeout: number = APP_CONNECT_TIMEOUT_MS): Promise<AppDict> {
this.setup();

// initialize the rpc client
Expand Down Expand Up @@ -91,33 +98,44 @@ export async function connect (timeout = APP_CONNECT_TIMEOUT_MS) {
}
}
return this.appDict;
} catch (err) {
} catch (err: any) {
this.log.error(`Error setting connection key: ${err.message}`);
await this.disconnect();
throw err;
}
}

/**
*
* @this {RemoteDebugger}
* @returns {Promise<void>}
* Disconnects from the remote debugger by closing the RPC client connection,
* emitting a disconnect event, and performing cleanup via teardown.
*/
export async function disconnect () {
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.
*
* @this {RemoteDebugger}
* @param {string?} [currentUrl=null]
* @param {number} [maxTries=SELECT_APP_RETRIES]
* @param {boolean} [ignoreAboutBlankUrl=false]
* @returns {Promise<import('../types').Page[]>}
* @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 (currentUrl = null, maxTries = SELECT_APP_RETRIES, ignoreAboutBlankUrl = false) {
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();
Expand All @@ -135,8 +153,7 @@ export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIE
// translate the dictionary into a useful form, and return to sender
this.log.debug(`Finally selecting app ${getAppIdKey(this)}`);

/** @type {import('../types').Page[]} */
const fullPageArray = [];
const fullPageArray: Page[] = [];
for (const [app, info] of _.toPairs(getAppDict(this))) {
if (!_.isArray(info.pageArray) || !info.isActive) {
continue;
Expand All @@ -157,14 +174,22 @@ export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIE
}

/**
* 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.
*
* @this {RemoteDebugger}
* @param {import('../types').AppIdKey} appIdKey
* @param {import('../types').PageIdKey} pageIdKey
* @param {boolean} [skipReadyCheck]
* @returns {Promise<void>}
* @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 (appIdKey, pageIdKey, skipReadyCheck = false) {
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);
Expand All @@ -175,34 +200,105 @@ export async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) {

const pageReadinessDetector = skipReadyCheck ? undefined : {
timeoutMs: this.pageLoadMs,
readinessDetector: (/** @type {string} */ readyState) => this.isPageLoadingCompleted(readyState),
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.
*
* @this {RemoteDebugger}
* @param {string?} currentUrl
* @param {number} maxTries
* @param {boolean} ignoreAboutBlankUrl
* @returns {Promise<import('../types').AppPage>}
* @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.
*/
async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) {
/** @type {string[]} */
const bundleIds = _.compact(
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 /** @type {import('../types').AppPage} */ (await retryInterval(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => {
return await retryInterval(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => {
logApplicationDictionary.bind(this)();
const possibleAppIds = getPossibleDebuggerAppKeys.bind(this)(/** @type {string[]} */ (bundleIds));
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];
Expand Down Expand Up @@ -236,7 +332,7 @@ async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) {
} else {
this.log.debug('Received app, but no match was found. Trying again.');
}
} catch (err) {
} catch (err: any) {
if (![NEW_APP_CONNECTED_ERROR, EMPTY_PAGE_DICTIONARY_ERROR].some((msg) => msg === err.message)) {
this.log.debug(err.stack);
}
Expand All @@ -247,18 +343,25 @@ async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) {
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.
*
* @this {RemoteDebugger}
* @param {Record<string, import('../types').AppInfo>} appsDict
* @param {string?} currentUrl
* @param {boolean} [ignoreAboutBlankUrl]
* @returns {import('../types').AppPage?}
* @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) {
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;
Expand All @@ -278,10 +381,11 @@ function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false
}

/**
* @this {RemoteDebugger}
* @returns {void}
* Logs the current application dictionary to the debug log.
* Displays all applications, their properties, and their associated pages
* in a formatted structure.
*/
function logApplicationDictionary () {
function logApplicationDictionary(this: RemoteDebugger): void {
this.log.debug('Current applications available:');
for (const [app, info] of _.toPairs(getAppDict(this))) {
this.log.debug(` Application: "${app}"`);
Expand All @@ -302,67 +406,3 @@ function logApplicationDictionary () {
}
}
}

/**
* Find app keys based on assigned bundleIds from appDict
* When bundleIds includes a wildcard ('*'), returns all appKeys in appDict.
*
* @this {RemoteDebugger}
* @param {string[]} bundleIds
* @returns {string[]}
*/
export function getPossibleDebuggerAppKeys(bundleIds) {
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(', ')}`
);
/** @type {string[]} */
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 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);
}

/**
* @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger
*/
Loading
Loading