diff --git a/lib/mixins/connect.js b/lib/mixins/connect.ts similarity index 62% rename from lib/mixins/connect.js rename to lib/mixins/connect.ts index 98133ac2..6beefc66 100644 --- a/lib/mixins/connect.js +++ b/lib/mixins/connect.ts @@ -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; @@ -32,11 +34,11 @@ const SAFARI_VIEW_BUNDLE_ID = 'com.apple.SafariViewService'; const WILDCARD_BUNDLE_ID = '*'; /** - * - * @this {RemoteDebugger} - * @returns {Promise} + * 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 { this.log.debug('Sending connection key request'); // send but only wait to make sure the socket worked @@ -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} + * @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 { this.setup(); // initialize the rpc client @@ -91,7 +98,7 @@ 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; @@ -99,25 +106,36 @@ export async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { } /** - * - * @this {RemoteDebugger} - * @returns {Promise} + * 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 { 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} + * @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 { this.log.debug('Selecting application'); const timer = new timing.Timer().start(); @@ -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; @@ -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} + * @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 { const fullAppIdKey = _.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`; setAppIdKey(this, fullAppIdKey); setPageIdKey(this, pageIdKey); @@ -175,7 +200,7 @@ 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); @@ -183,16 +208,87 @@ export async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) { } /** + * 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} + * @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 { + const bundleIds: string[] = _.compact( [ getBundleId(this), ...(getAdditionalBundleIds(this) ?? []), @@ -200,9 +296,9 @@ async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { ] ); 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]; @@ -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); } @@ -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; } /** + * 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} 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; @@ -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}"`); @@ -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 - */ diff --git a/lib/mixins/message-handlers.js b/lib/mixins/message-handlers.js deleted file mode 100644 index d6e77e26..00000000 --- a/lib/mixins/message-handlers.js +++ /dev/null @@ -1,224 +0,0 @@ -import { events } from './events'; -import { - pageArrayFromDict, - appInfoFromDict, -} from '../utils'; -import _ from 'lodash'; -import { - setAppIdKey, - getAppDict, - getAppIdKey, - getBundleId, - getNavigatingToPage, - setCurrentState, - setConnectedDrivers, - getSkippedApps, -} from './property-accessors'; - -/* - * Generic callbacks used throughout the lifecycle of the Remote Debugger. - * These will be added to the prototype. - */ - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {string} appIdKey - * @param {Record} pageDict - * @returns {Promise} - */ -export async function onPageChange (err, appIdKey, pageDict) { - if (_.isEmpty(pageDict)) { - return; - } - - const currentPages = pageArrayFromDict(pageDict); - // save the page dict for this app - if (getAppDict(this)[appIdKey]) { - const previousPages = getAppDict(this)[appIdKey].pageArray; - // we have a pre-existing pageDict - if (previousPages && _.isEqual(previousPages, currentPages)) { - this.log.debug( - `Received page change notice for app '${appIdKey}' ` + - `but the listing has not changed. Ignoring.` - ); - return; - } - // keep track of the page dictionary - getAppDict(this)[appIdKey].pageArray = currentPages; - this.log.debug( - `Pages changed for ${appIdKey}: ${JSON.stringify(previousPages)} -> ${JSON.stringify(currentPages)}` - ); - } - - if (getNavigatingToPage(this)) { - // in the middle of navigating, so reporting a page change will cause problems - return; - } - - this.emit(events.EVENT_PAGE_CHANGE, { - appIdKey: appIdKey.replace('PID:', ''), - pageArray: currentPages, - }); -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {Record} dict - * @returns {Promise} - */ -export async function onAppConnect (err, dict) { - const appIdKey = dict.WIRApplicationIdentifierKey; - this.log.debug(`Notified that new application '${appIdKey}' has connected`); - updateAppsWithDict.bind(this)(dict); -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {import('@appium/types').StringRecord} dict - * @returns {void} - */ -export function onAppDisconnect (err, dict) { - const appIdKey = dict.WIRApplicationIdentifierKey; - this.log.debug(`Application '${appIdKey}' disconnected. Removing from app dictionary.`); - this.log.debug(`Current app is '${getAppIdKey(this)}'`); - - // get rid of the entry in our app dictionary, - // since it is no longer available - delete getAppDict(this)[appIdKey]; - - // if the disconnected app is the one we are connected to, try to find another - if (getAppIdKey(this) === appIdKey) { - this.log.debug(`No longer have app id. Attempting to find new one.`); - setAppIdKey(this, getDebuggerAppKey.bind(this)(/** @type {string} */ (getBundleId(this)))); - } - - if (_.isEmpty(getAppDict(this))) { - // this means we no longer have any apps. what the what? - this.log.debug('Main app disconnected. Disconnecting altogether.'); - this.emit(events.EVENT_DISCONNECT, true); - } -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {Record} dict - * @returns {Promise} - */ -export async function onAppUpdate (err, dict) { - this.log.debug(`Notified that an application has been updated`); - updateAppsWithDict.bind(this)(dict); -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {Record} drivers - * @returns {void} - */ -export function onConnectedDriverList (err, drivers) { - setConnectedDrivers(this, drivers.WIRDriverDictionaryKey); - this.log.debug(`Received connected driver list: ${JSON.stringify(this.connectedDrivers)}`); -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {Record} state - * @returns {void} - */ -export function onCurrentState (err, state) { - setCurrentState(this, state.WIRAutomationAvailabilityKey); - // This state changes when 'Remote Automation' in 'Settings app' > 'Safari' > 'Advanced' > 'Remote Automation' changes - // WIRAutomationAvailabilityAvailable or WIRAutomationAvailabilityNotAvailable - this.log.debug(`Received connected automation availability state: ${JSON.stringify(this.currentState)}`); -} - -/** - * @this {RemoteDebugger} - * @param {Error?} err - * @param {Record} apps - * @returns {Promise} - */ -export async function onConnectedApplicationList (err, apps) { - this.log.debug(`Received connected applications list: ${_.keys(apps).join(', ')}`); - - // translate the received information into an easier-to-manage - // hash with app id as key, and app info as value - let newDict = {}; - for (const dict of _.values(apps)) { - const [id, entry] = appInfoFromDict(dict); - if (getSkippedApps(this).includes(entry.name)) { - continue; - } - newDict[id] = entry; - } - // update the object's list of apps - _.defaults(getAppDict(this), newDict); -} - -/** - * - * @this {RemoteDebugger} - * @param {import('@appium/types').StringRecord} dict - * @returns {void} - */ -function updateAppsWithDict (dict) { - // get the dictionary entry into a nice form, and add it to the - // application dictionary - const [id, entry] = appInfoFromDict(dict); - if (getAppDict(this)[id]?.pageArray) { - // preserve the page dictionary for this entry - entry.pageArray = getAppDict(this)[id].pageArray; - } - getAppDict(this)[id] = entry; - - // try to get the app id from our connected apps - if (!getAppIdKey(this)) { - setAppIdKey(this, getDebuggerAppKey.bind(this)(/** @type {string} */ (getBundleId(this)))); - } -} - -/** - * Given a bundle id, finds the correct remote debugger app that is - * connected. - * - * @this {RemoteDebugger} - * @param {string} bundleId - * @returns {string|undefined} - */ -export function getDebuggerAppKey (bundleId) { - let appId; - for (const [key, data] of _.toPairs(getAppDict(this))) { - if (data.bundleId === bundleId) { - appId = key; - break; - } - } - // now we need to determine if we should pick a proxy for this instead - if (appId) { - this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`); - let proxyAppId; - for (const [key, data] of _.toPairs(getAppDict(this))) { - if (data.isProxy && data.hostId === appId) { - this.log.debug(`Found separate bundleId '${data.bundleId}' ` + - `acting as proxy for '${bundleId}', with app id '${key}'`); - // set the app id... the last one will be used, so just keep re-assigning - proxyAppId = key; - } - } - if (proxyAppId) { - appId = proxyAppId; - this.log.debug(`Using proxied app id '${appId}'`); - } - } - - return appId; -} - -/** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger - */ diff --git a/lib/mixins/message-handlers.ts b/lib/mixins/message-handlers.ts new file mode 100644 index 00000000..4bdd637c --- /dev/null +++ b/lib/mixins/message-handlers.ts @@ -0,0 +1,272 @@ +import { events } from './events'; +import { + pageArrayFromDict, + appInfoFromDict, +} from '../utils'; +import _ from 'lodash'; +import { + setAppIdKey, + getAppDict, + getAppIdKey, + getBundleId, + getNavigatingToPage, + setCurrentState, + setConnectedDrivers, + getSkippedApps, +} from './property-accessors'; +import type { RemoteDebugger } from '../remote-debugger'; +import type { StringRecord } from '@appium/types'; +import type { AppDict } from '../types'; + +/* + * Generic callbacks used throughout the lifecycle of the Remote Debugger. + * These will be added to the prototype. + */ + +/** + * Handles page change notifications from the remote debugger. + * Updates the page array for the specified application and emits a page change + * event if the pages have actually changed and navigation is not in progress. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param appIdKey - The application identifier key for which pages have changed. + * @param pageDict - Dictionary containing the new page information. + */ +export async function onPageChange( + this: RemoteDebugger, + err: Error | null | undefined, + appIdKey: string, + pageDict: StringRecord +): Promise { + if (_.isEmpty(pageDict)) { + return; + } + + const currentPages = pageArrayFromDict(pageDict); + // save the page dict for this app + if (getAppDict(this)[appIdKey]) { + const previousPages = getAppDict(this)[appIdKey].pageArray; + // we have a pre-existing pageDict + if (previousPages && _.isEqual(previousPages, currentPages)) { + this.log.debug( + `Received page change notice for app '${appIdKey}' ` + + `but the listing has not changed. Ignoring.` + ); + return; + } + // keep track of the page dictionary + getAppDict(this)[appIdKey].pageArray = currentPages; + this.log.debug( + `Pages changed for ${appIdKey}: ${JSON.stringify(previousPages)} -> ${JSON.stringify(currentPages)}` + ); + } + + if (getNavigatingToPage(this)) { + // in the middle of navigating, so reporting a page change will cause problems + return; + } + + this.emit(events.EVENT_PAGE_CHANGE, { + appIdKey: appIdKey.replace('PID:', ''), + pageArray: currentPages, + }); +} + +/** + * Handles notifications when a new application connects to the remote debugger. + * Updates the application dictionary with the new application information. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param dict - Dictionary containing the new application information including + * the WIRApplicationIdentifierKey. + */ +export async function onAppConnect( + this: RemoteDebugger, + err: Error | null | undefined, + dict: StringRecord +): Promise { + const appIdKey = dict.WIRApplicationIdentifierKey; + this.log.debug(`Notified that new application '${appIdKey}' has connected`); + updateAppsWithDict.bind(this)(dict); +} + +/** + * Handles notifications when an application disconnects from the remote debugger. + * Removes the application from the dictionary and attempts to find a replacement + * if the disconnected app was the currently selected one. Emits a disconnect event + * if no applications remain. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param dict - Dictionary containing the disconnected application information + * including the WIRApplicationIdentifierKey. + */ +export function onAppDisconnect( + this: RemoteDebugger, + err: Error | null | undefined, + dict: StringRecord +): void { + const appIdKey = dict.WIRApplicationIdentifierKey; + this.log.debug(`Application '${appIdKey}' disconnected. Removing from app dictionary.`); + this.log.debug(`Current app is '${getAppIdKey(this)}'`); + + // get rid of the entry in our app dictionary, + // since it is no longer available + delete getAppDict(this)[appIdKey]; + + // if the disconnected app is the one we are connected to, try to find another + if (getAppIdKey(this) === appIdKey) { + this.log.debug(`No longer have app id. Attempting to find new one.`); + setAppIdKey(this, getDebuggerAppKey.bind(this)(getBundleId(this) as string)); + } + + if (_.isEmpty(getAppDict(this))) { + // this means we no longer have any apps. what the what? + this.log.debug('Main app disconnected. Disconnecting altogether.'); + this.emit(events.EVENT_DISCONNECT, true); + } +} + +/** + * Handles notifications when an application's information is updated. + * Updates the application dictionary with the new information while preserving + * any existing page array data. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param dict - Dictionary containing the updated application information. + */ +export async function onAppUpdate( + this: RemoteDebugger, + err: Error | null | undefined, + dict: StringRecord +): Promise { + this.log.debug(`Notified that an application has been updated`); + updateAppsWithDict.bind(this)(dict); +} + +/** + * Handles notifications containing the list of connected drivers. + * Updates the internal connected drivers list with the received information. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param drivers - Dictionary containing the connected driver list with + * WIRDriverDictionaryKey. + */ +export function onConnectedDriverList( + this: RemoteDebugger, + err: Error | null | undefined, + drivers: StringRecord +): void { + setConnectedDrivers(this, drivers.WIRDriverDictionaryKey); + this.log.debug(`Received connected driver list: ${JSON.stringify(this.connectedDrivers)}`); +} + +/** + * Handles notifications about the current automation availability state. + * This state changes when 'Remote Automation' setting in Safari's advanced settings + * is toggled. The state can be either WIRAutomationAvailabilityAvailable or + * WIRAutomationAvailabilityNotAvailable. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param state - Dictionary containing the automation availability state with + * WIRAutomationAvailabilityKey. + */ +export function onCurrentState( + this: RemoteDebugger, + err: Error | null | undefined, + state: StringRecord +): void { + setCurrentState(this, state.WIRAutomationAvailabilityKey); + // This state changes when 'Remote Automation' in 'Settings app' > 'Safari' > 'Advanced' > 'Remote Automation' changes + // WIRAutomationAvailabilityAvailable or WIRAutomationAvailabilityNotAvailable + this.log.debug(`Received connected automation availability state: ${JSON.stringify(this.currentState)}`); +} + +/** + * Handles notifications containing the list of connected applications. + * Translates the received information into the application dictionary format, + * filtering out any applications that are in the skipped apps list. + * + * @param err - Error object if an error occurred, null or undefined otherwise. + * @param apps - Dictionary containing the connected applications list. + */ +export async function onConnectedApplicationList( + this: RemoteDebugger, + err: Error | null | undefined, + apps: StringRecord +): Promise { + this.log.debug(`Received connected applications list: ${_.keys(apps).join(', ')}`); + + // translate the received information into an easier-to-manage + // hash with app id as key, and app info as value + const newDict: AppDict = {}; + for (const dict of _.values(apps)) { + const [id, entry] = appInfoFromDict(dict); + if (getSkippedApps(this).includes(entry.name)) { + continue; + } + newDict[id] = entry; + } + // update the object's list of apps + _.defaults(getAppDict(this), newDict); +} + +/** + * Given a bundle ID, finds the correct remote debugger app identifier key + * that is currently connected. Also handles proxy applications that may act + * on behalf of the requested bundle ID. + * + * @param bundleId - The bundle identifier to search for. + * @returns The application identifier key if found, undefined otherwise. + * If a proxy application is found, returns the proxy's app ID instead. + */ +export function getDebuggerAppKey(this: RemoteDebugger, bundleId: string): string | undefined { + let appId: string | undefined; + for (const [key, data] of _.toPairs(getAppDict(this))) { + if (data.bundleId === bundleId) { + appId = key; + break; + } + } + // now we need to determine if we should pick a proxy for this instead + if (appId) { + this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`); + let proxyAppId: string | undefined; + for (const [key, data] of _.toPairs(getAppDict(this))) { + if (data.isProxy && data.hostId === appId) { + this.log.debug(`Found separate bundleId '${data.bundleId}' ` + + `acting as proxy for '${bundleId}', with app id '${key}'`); + // set the app id... the last one will be used, so just keep re-assigning + proxyAppId = key; + } + } + if (proxyAppId) { + appId = proxyAppId; + this.log.debug(`Using proxied app id '${appId}'`); + } + } + + return appId; +} + +/** + * Updates the application dictionary with information from the provided dictionary. + * Preserves existing page array data if the application already exists in the dictionary. + * Attempts to set the app ID key if one is not currently set. + * + * @param dict - Dictionary containing application information to add or update. + */ +function updateAppsWithDict(this: RemoteDebugger, dict: StringRecord): void { + // get the dictionary entry into a nice form, and add it to the + // application dictionary + const [id, entry] = appInfoFromDict(dict); + if (getAppDict(this)[id]?.pageArray) { + // preserve the page dictionary for this entry + entry.pageArray = getAppDict(this)[id].pageArray; + } + getAppDict(this)[id] = entry; + + // try to get the app id from our connected apps + if (!getAppIdKey(this)) { + setAppIdKey(this, getDebuggerAppKey.bind(this)(getBundleId(this) as string)); + } +} diff --git a/lib/mixins/navigate.js b/lib/mixins/navigate.ts similarity index 67% rename from lib/mixins/navigate.js rename to lib/mixins/navigate.ts index 39f08414..db0a65d6 100644 --- a/lib/mixins/navigate.js +++ b/lib/mixins/navigate.ts @@ -13,6 +13,8 @@ import { getPageIdKey, setNavigatingToPage, } from './property-accessors'; +import type { RemoteDebugger } from '../remote-debugger'; +import type { AppIdKey, PageIdKey } from '../types'; export const DEFAULT_PAGE_READINESS_TIMEOUT_MS = 20 * 1000; const PAGE_READINESS_CHECK_INTERVAL_MS = 50; @@ -27,32 +29,34 @@ const PAGE_LOAD_STRATEGY = Object.freeze({ }); /** - * @this {RemoteDebugger} - * @returns {void} + * Emits a frame detached event when a frame is detached from the page. + * This is typically called by the RPC client when receiving a Page.frameDetached event. */ -export function frameDetached () { +export function frameDetached(this: RemoteDebugger): void { this.emit(events.EVENT_FRAMES_DETACHED); } /** - * @this {RemoteDebugger} - * @returns {void} + * Cancels the current page load operation by unregistering from page readiness + * notifications and canceling any pending page load delay. */ -export function cancelPageLoad () { +export function cancelPageLoad(this: RemoteDebugger): void { this.log.debug('Unregistering from page readiness notifications'); setPageLoading(this, false); getPageLoadDelay(this)?.cancel(); } /** - * Return if current readState can be handles as page load completes - * for the given page load strategy. + * Determines if the current readyState indicates that page loading is completed + * based on the configured page load strategy. * - * @this {RemoteDebugger} - * @param {string} readyState - * @returns {boolean} + * @param readyState - The document readyState value ('loading', 'interactive', or 'complete'). + * @returns True if the page load is considered complete for the current strategy: + * - 'eager': returns true when readyState is not 'loading' + * - 'none': always returns true + * - 'normal' (default): returns true only when readyState is 'complete' */ -export function isPageLoadingCompleted (readyState) { +export function isPageLoadingCompleted(this: RemoteDebugger, readyState: string): boolean { const pageLoadStrategy = _.toLower(getPageLoadStartegy(this)); switch (pageLoadStrategy) { case PAGE_LOAD_STRATEGY.EAGER: @@ -67,11 +71,14 @@ export function isPageLoadingCompleted (readyState) { } /** - * @this {RemoteDebugger} - * @param {timing.Timer?} [startPageLoadTimer] - * @returns {Promise} + * Waits for the DOM to be ready by periodically checking the page readiness state. + * Uses exponential backoff for retry intervals and respects the configured page load + * strategy and timeout settings. + * + * @param startPageLoadTimer - Optional timer instance to use for tracking elapsed time. + * If not provided, a new timer will be created and started. */ -export async function waitForDom (startPageLoadTimer) { +export async function waitForDom(this: RemoteDebugger, startPageLoadTimer?: timing.Timer): Promise { const readinessTimeoutMs = this.pageLoadMs; this.log.debug(`Waiting up to ${readinessTimeoutMs}ms for the page to be ready`); const timer = startPageLoadTimer ?? new timing.Timer().start(); @@ -79,7 +86,6 @@ export async function waitForDom (startPageLoadTimer) { let isPageLoading = true; setPageLoading(this, true); setPageLoadDelay(this, util.cancellableDelay(readinessTimeoutMs)); - /** @type {B} */ const pageReadinessPromise = B.resolve((async () => { let retry = 0; while (isPageLoading) { @@ -118,7 +124,6 @@ export async function waitForDom (startPageLoadTimer) { retry++; } })()); - /** @type {B} */ const cancellationPromise = B.resolve((async () => { try { await getPageLoadDelay(this); @@ -135,11 +140,15 @@ export async function waitForDom (startPageLoadTimer) { } /** - * @this {RemoteDebugger} - * @param {number} [timeoutMs] - * @returns {Promise} + * Checks if the current page is ready by executing a JavaScript command to + * retrieve the document readyState and evaluating it against the page load strategy. + * + * @param timeoutMs - Optional timeout in milliseconds for the readyState check. + * If not provided, uses the configured page ready timeout. + * @returns A promise that resolves to true if the page is ready according to + * the page load strategy, false otherwise or if the check times out. */ -export async function checkPageIsReady (timeoutMs) { +export async function checkPageIsReady(this: RemoteDebugger, timeoutMs?: number): Promise { const readyCmd = 'document.readyState;'; const actualTimeoutMs = timeoutMs ?? getPageReadyTimeout(this); try { @@ -152,7 +161,7 @@ export async function checkPageIsReady (timeoutMs) { }) ); return this.isPageLoadingCompleted(readyState); - } catch (err) { + } catch (err: any) { if (err instanceof BTimeoutError) { this.log.debug(`Page readiness check timed out after ${actualTimeoutMs}ms`); } else { @@ -163,11 +172,14 @@ export async function checkPageIsReady (timeoutMs) { } /** - * @this {RemoteDebugger} - * @param {string} url - * @returns {Promise} + * Navigates to a new URL and waits for the page to be ready. + * Validates the URL format, waits for the page to be available, sends the navigation + * command, and monitors for the Page.loadEventFired event or timeout. + * + * @param url - The URL to navigate to. Must be a valid URL format. + * @throws TypeError if the provided URL is not a valid URL format. */ -export async function navToUrl (url) { +export async function navToUrl(this: RemoteDebugger, url: string): Promise { const {appIdKey, pageIdKey} = checkParams({ appIdKey: getAppIdKey(this), pageIdKey: getPageIdKey(this), @@ -183,22 +195,18 @@ export async function navToUrl (url) { this.log.debug(`Navigating to new URL: '${url}'`); setNavigatingToPage(this, true); await rpcClient.waitForPage( - /** @type {import('../types').AppIdKey} */ (appIdKey), - /** @type {import('../types').PageIdKey} */ (pageIdKey) + appIdKey as AppIdKey, + pageIdKey as PageIdKey ); const readinessTimeoutMs = this.pageLoadMs; - /** @type {(() => void)|undefined} */ - let onPageLoaded; - /** @type {NodeJS.Timeout|undefined|null} */ - let onPageLoadedTimeout; + let onPageLoaded: (() => void) | undefined; + let onPageLoadedTimeout: NodeJS.Timeout | undefined | null; setPageLoadDelay(this, util.cancellableDelay(readinessTimeoutMs)); setPageLoading(this, true); let isPageLoading = true; - // /** @type {Promise|null} */ const start = new timing.Timer().start(); - /** @type {B} */ - const pageReadinessPromise = new B((resolve) => { + const pageReadinessPromise = new B((resolve) => { onPageLoadedTimeout = setTimeout(() => { if (isPageLoading) { isPageLoading = false; @@ -231,7 +239,6 @@ export async function navToUrl (url) { pageIdKey, }); }); - /** @type {B} */ const cancellationPromise = B.resolve((async () => { try { await getPageLoadDelay(this); @@ -254,7 +261,3 @@ export async function navToUrl (url) { } } } - -/** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger - */