appium-remote-debugger
Version:
Appium proxy for Remote Debugger protocol
678 lines (593 loc) • 23.2 kB
JavaScript
import RemoteMessages from './remote-messages';
import { waitForCondition } from 'asyncbox';
import log from '../logger';
import _ from 'lodash';
import B from 'bluebird';
import RpcMessageHandler from './rpc-message-handler';
import { util, timing } from '@appium/support';
const DATA_LOG_LENGTH = {length: 200};
const WAIT_FOR_TARGET_TIMEOUT = 10000;
const WAIT_FOR_TARGET_INTERVAL = 1000;
const MIN_PLATFORM_FOR_TARGET_BASED = '12.2';
// `Target.exists` protocol method was removed from WebKit in 13.4
const MIN_PLATFORM_NO_TARGET_EXISTS = '13.4';
function isTargetBased (isSafari, platformVersion) {
// On iOS 12.2 the messages get sent through the Target domain
// On iOS 13.0+, WKWebView also needs to follow the Target domain,
// so here only check the target OS version as the default behaviour.
const isHighVersion = util.compareVersions(platformVersion, '>=', MIN_PLATFORM_FOR_TARGET_BASED);
log.debug(`Checking which communication style to use (${isSafari ? '' : 'non-'}Safari on platform version '${platformVersion}')`);
log.debug(`Platform version equal or higher than '${MIN_PLATFORM_FOR_TARGET_BASED}': ${isHighVersion}`);
return isHighVersion;
}
export default class RpcClient {
/** @type {RpcMessageHandler|undefined} */
messageHandler;
/** @type {RemoteMessages|undefined} */
remoteMessages;
/** @type {boolean} */
connected;
constructor (opts = {}) {
this._targets = [];
this._shouldCheckForTarget = !!opts.shouldCheckForTarget;
const {
bundleId,
platformVersion = {},
isSafari = true,
logAllCommunication = false,
logAllCommunicationHexDump = false,
webInspectorMaxFrameLength,
socketChunkSize,
fullPageInitialization = false,
udid,
} = opts;
this.isSafari = isSafari;
this.isConnected = false;
this.connId = util.uuidV4();
this.senderId = util.uuidV4();
this.msgId = 0;
this.udid = udid;
this.logAllCommunication = logAllCommunication;
this.logAllCommunicationHexDump = logAllCommunicationHexDump;
this.socketChunkSize = socketChunkSize;
this.webInspectorMaxFrameLength = webInspectorMaxFrameLength;
this.fullPageInitialization = fullPageInitialization;
this.bundleId = bundleId;
this.platformVersion = platformVersion;
this._contexts = [];
this._targets = {};
// start with a best guess for the protocol
this.isTargetBased = isTargetBased(isSafari, this.platformVersion);
}
get contexts () {
return this._contexts;
}
get needsTarget () {
return this.shouldCheckForTarget && this.isTargetBased;
}
get targets () {
return this._targets;
}
get shouldCheckForTarget () {
return this._shouldCheckForTarget;
}
set shouldCheckForTarget (shouldCheckForTarget) {
this._shouldCheckForTarget = !!shouldCheckForTarget;
}
get isConnected () {
return this.connected;
}
set isConnected (connected) {
this.connected = !!connected;
}
on (event, listener) {
// @ts-ignore messageHandler must be defined here
this.messageHandler.on(event, listener);
return this;
}
once (event, listener) {
// @ts-ignore messageHandler must be defined here
this.messageHandler.once(event, listener);
return this;
}
off (event, listener) {
// @ts-ignore messageHandler must be defined here
this.messageHandler.off(event, listener);
return this;
}
set isTargetBased (isTargetBased) {
log.warn(`Setting communication protocol: using ${isTargetBased ? 'Target-based' : 'full Web Inspector protocol'} communication`);
this._isTargetBased = isTargetBased;
if (!this.remoteMessages) {
this.remoteMessages = new RemoteMessages(isTargetBased);
} else {
this.remoteMessages.isTargetBased = isTargetBased;
}
if (!this.messageHandler) {
this.messageHandler = new RpcMessageHandler(isTargetBased);
// add handlers for internal events
this.messageHandler.on('Target.targetCreated', this.addTarget.bind(this));
this.messageHandler.on('Target.didCommitProvisionalTarget', this.updateTarget.bind(this));
this.messageHandler.on('Target.targetDestroyed', this.removeTarget.bind(this));
this.messageHandler.on('Runtime.executionContextCreated', this.onExecutionContextCreated.bind(this));
this.messageHandler.on('Heap.garbageCollected', this.onGarbageCollected.bind(this));
} else {
this.messageHandler.isTargetBased = isTargetBased;
}
}
get isTargetBased () {
return this._isTargetBased;
}
/**
*
* @param {string} appIdKey
* @param {string} pageIdKey
* @param {boolean} [force]
* @returns {Promise<void>}
*/
async waitForTarget (appIdKey, pageIdKey, force = false) {
if (!force && !this.needsTarget) {
return;
}
if (this.getTarget(appIdKey, pageIdKey)) {
return;
}
// otherwise waiting is necessary to see what the target is
try {
await waitForCondition(() => !_.isEmpty(this.getTarget(appIdKey, pageIdKey)), {
waitMs: WAIT_FOR_TARGET_TIMEOUT,
intervalMs: WAIT_FOR_TARGET_INTERVAL,
error: 'No targets found, unable to communicate with device',
});
} catch (err) {
if (!err.message.includes('Condition unmet')) {
throw err;
}
throw new Error('No targets found, unable to communicate with device');
}
}
/**
*
* @param {string} command
* @param {Record<string, any>} [opts]
* @param {boolean} [waitForResponse]
* @returns {Promise<any>}
*/
async send (command, opts = {}, waitForResponse = true) {
const timer = new timing.Timer().start();
const {
appIdKey,
pageIdKey
} = opts;
try {
if (!_.isEmpty(appIdKey) && !_.isEmpty(pageIdKey)) {
await this.waitForTarget(appIdKey, pageIdKey);
}
return await this.sendToDevice(command, opts, waitForResponse);
} catch (err) {
let { message = '' } = err;
message = message.toLowerCase();
if (message.includes(`'target' domain was not found`)) {
log.info('The target device does not support Target based communication. ' +
'Will follow non-target based communication.');
this.isTargetBased = false;
return await this.sendToDevice(command, opts, waitForResponse);
} else if (message.includes(`domain was not found`) ||
message.includes(`some arguments of method`) ||
message.includes(`missing target`)) {
this.isTargetBased = true;
await this.waitForTarget(appIdKey, pageIdKey);
return await this.sendToDevice(command, opts, waitForResponse);
}
throw err;
} finally {
log.debug(`Sending to Web Inspector took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
}
}
/**
*
* @param {string} command
* @param {Record<string, any>} opts
* @param {boolean} [waitForResponse]
* @returns {Promise<any>}
*/
async sendToDevice (command, opts = {}, waitForResponse = true) {
return await new B(async (resolve, reject) => {
// promise to be resolved whenever remote debugger
// replies to our request
// keep track of the messages coming and going using a simple sequential id
const msgId = this.msgId++;
let wrapperMsgId = msgId;
if (this.isTargetBased) {
// for target-base communication, everything is wrapped up
wrapperMsgId = this.msgId++;
// acknowledge wrapper message
// @ts-ignore messageHandler must be defined
this.messageHandler.on(wrapperMsgId.toString(), function (err) {
if (err) {
reject(err);
}
});
}
const appIdKey = opts.appIdKey;
const pageIdKey = opts.pageIdKey;
const targetId = this.getTarget(appIdKey, pageIdKey);
// retrieve the correct command to send
const fullOpts = _.defaults({
connId: this.connId,
senderId: this.senderId,
targetId,
id: msgId,
}, opts);
// @ts-ignore remoteMessages must be defined
const cmd = this.remoteMessages.getRemoteCommand(command, fullOpts);
if (cmd?.__argument?.WIRSocketDataKey) {
// make sure the message being sent has all the information that is needed
if (_.isNil(cmd.__argument.WIRSocketDataKey.id)) {
cmd.__argument.WIRSocketDataKey.id = wrapperMsgId;
}
cmd.__argument.WIRSocketDataKey =
Buffer.from(JSON.stringify(cmd.__argument.WIRSocketDataKey));
}
let messageHandled = true;
if (!waitForResponse) {
// the promise will be resolved as soon as the socket has been sent
messageHandled = false;
// do not log receipts
// @ts-ignore messageHandler must be defined
this.messageHandler.once(msgId.toString(), function (err) {
if (err) {
// we are not waiting for this, and if it errors it is most likely
// a protocol change. Log and check during testing
log.error(`Received error from send that is not being waited for (id: ${msgId}): '${_.truncate(JSON.stringify(err), DATA_LOG_LENGTH)}'`);
// reject, though it is very rare that this will be triggered, since
// the promise is resolved directlty after send. On the off chance,
// though, it will alert of a protocol change.
reject(err);
}
});
// @ts-ignore messageHandler must be defined
} else if (this.messageHandler.listeners(cmd.__selector).length) {
// @ts-ignore messageHandler must be defined
this.messageHandler.prependOnceListener(cmd.__selector, function (err, ...args) {
if (err) {
return reject(err);
}
log.debug(`Received response from send (id: ${msgId}): '${_.truncate(JSON.stringify(args), DATA_LOG_LENGTH)}'`);
resolve(args);
});
} else if (cmd?.__argument?.WIRSocketDataKey) {
// @ts-ignore messageHandler must be defined
this.messageHandler.once(msgId.toString(), function (err, value) {
if (err) {
return reject(new Error(`Remote debugger error with code '${err.code}': ${err.message}`));
}
log.debug(`Received data response from send (id: ${msgId}): '${_.truncate(JSON.stringify(value), DATA_LOG_LENGTH)}'`);
resolve(value);
});
} else {
// nothing else is handling things, so just resolve when the message is sent
messageHandled = false;
}
const msg = `Sending '${cmd.__selector}' message` +
(appIdKey ? ` to app '${appIdKey}'` : '') +
(pageIdKey ? `, page '${pageIdKey}'` : '') +
(this.needsTarget && targetId ? `, target '${targetId}'` : '') +
` (id: ${msgId}): '${command}'`;
log.debug(msg);
try {
const res = await this.sendMessage(cmd);
if (!messageHandled) {
// There are no handlers waiting for a response before resolving,
// and no errors sending the message over the socket, so resolve
resolve(res);
}
} catch (err) {
return reject(err);
}
});
}
async connect () { // eslint-disable-line require-await
throw new Error(`Sub-classes need to implement a 'connect' function`);
}
async disconnect () { // eslint-disable-line require-await
this.messageHandler?.removeAllListeners();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async sendMessage (command) { // eslint-disable-line require-await
throw new Error(`Sub-classes need to implement a 'sendMessage' function`);
}
async receive (/* data */) { // eslint-disable-line require-await
throw new Error(`Sub-classes need to implement a 'receive' function`);
}
/**
*
* @param {Error?} err
* @param {string} app
* @param {Record<string, any>} targetInfo
* @returns {void}
*/
addTarget (err, app, targetInfo) {
if (_.isNil(targetInfo?.targetId)) {
log.warn(`Received 'Target.targetCreated' event for app '${app}' with no target. Skipping`);
return;
}
if (_.isEmpty(this.pendingTargetNotification) && !targetInfo.isProvisional) {
log.warn(`Received 'Target.targetCreated' event for app '${app}' with no pending request: ${JSON.stringify(targetInfo)}`);
return;
}
if (targetInfo.isProvisional) {
log.debug(`Provisional target created for app '${app}', '${targetInfo.targetId}'. Ignoring until target update event`);
return;
}
// @ts-ignore this.pendingTargetNotification must be defined here
const [appIdKey, pageIdKey] = this.pendingTargetNotification;
log.debug(`Target created for app '${appIdKey}' and page '${pageIdKey}': ${JSON.stringify(targetInfo)}`);
if (_.has(this.targets[appIdKey], pageIdKey)) {
log.debug(`There is already a target for this app and page ('${this.targets[appIdKey][pageIdKey]}'). This might cause problems`);
}
this.targets[app] = this.targets[app] || {};
this.targets[appIdKey][pageIdKey] = targetInfo.targetId;
}
/**
*
* @param {Error?} err
* @param {string} app
* @param {string} oldTargetId
* @param {string} newTargetId
* @returns {void}
*/
updateTarget (err, app, oldTargetId, newTargetId) {
log.debug(`Target updated for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'`);
if (!this.targets[app]) {
log.warn(`No existing target for app '${app}'. Not sure what to do`);
return;
}
// save this, to be used if/when the existing target is destroyed
this.targets[app].provisional = {
oldTargetId,
newTargetId,
};
}
/**
*
* @param {Error?} err
* @param {string} app
* @param {Record<string, any>} targetInfo
* @returns {void}
*/
removeTarget (err, app, targetInfo) {
if (_.isNil(targetInfo?.targetId)) {
log.debug(`Received 'Target.targetDestroyed' event with no target. Skipping`);
return;
}
log.debug(`Target destroyed for app '${app}': ${targetInfo.targetId}`);
// go through the targets and find the one that has a waiting provisional target
if (this.targets[app]?.provisional?.oldTargetId === targetInfo.targetId) {
const {oldTargetId, newTargetId} = this.targets[app].provisional;
delete this.targets[app].provisional;
// we do not know the page, so go through and find the existing target
const targets = this.targets[app];
for (const [page, targetId] of _.toPairs(targets)) {
if (targetId === oldTargetId) {
log.debug(`Found provisional target for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'. Updating`);
targets[page] = newTargetId;
return;
}
}
log.warn(`Provisional target for app '${app}' found, but no suitable existing target found. This may cause problems`);
log.warn(`Old target: '${oldTargetId}', new target: '${newTargetId}'. Existing targets: ${JSON.stringify(targets)}`);
}
// if there is no waiting provisional target, just get rid of the existing one
const targets = this.targets[app];
for (const [page, targetId] of _.toPairs(targets)) {
if (targetId === targetInfo.targetId) {
delete targets[page];
return;
}
}
log.debug(`Target '${targetInfo.targetId}' deleted for app '${app}', but no such target exists`);
}
/**
* @param {string} appIdKey
* @param {string} pageIdKey
* @returns {any}
*/
getTarget (appIdKey, pageIdKey) {
return (this.targets[appIdKey] || {})[pageIdKey];
}
/**
* @param {string} appIdKey
* @param {string} pageIdKey
* @returns {Promise<void>}
*/
async selectPage (appIdKey, pageIdKey) {
/** @type {[string, string]} */
this.pendingTargetNotification = [appIdKey, pageIdKey];
this.shouldCheckForTarget = false;
// go through the steps that the Desktop Safari system
// goes through to initialize the Web Inspector session
const sendOpts = {
appIdKey,
pageIdKey,
};
// highlight and then un-highlight the webview
for (const enabled of [true, false]) {
await this.send('indicateWebView', Object.assign({
enabled,
}, sendOpts), false);
}
await this.send('setSenderKey', sendOpts);
log.debug('Sender key set');
if (this.isTargetBased && util.compareVersions(this.platformVersion, '<', MIN_PLATFORM_NO_TARGET_EXISTS)) {
await this.send('Target.exists', sendOpts, false);
}
this.shouldCheckForTarget = true;
if (this.fullPageInitialization) {
await this.initializePageFull(appIdKey, pageIdKey);
} else {
await this.initializePage(appIdKey, pageIdKey);
}
}
/**
* Perform the minimal initialization to get the Web Inspector working
* @param {string} appIdKey
* @param {string} pageIdKey
* @returns {Promise<void>}
*/
async initializePage (appIdKey, pageIdKey) {
const sendOpts = {
appIdKey,
pageIdKey,
};
await this.send('Inspector.enable', sendOpts, false);
await this.send('Page.enable', sendOpts, false);
// go through the tasks to initialize
await this.send('Network.enable', sendOpts, false);
await this.send('Runtime.enable', sendOpts, false);
await this.send('Heap.enable', sendOpts, false);
await this.send('Debugger.enable', sendOpts, false);
await this.send('Console.enable', sendOpts, false);
await this.send('Inspector.initialized', sendOpts, false);
}
/**
* Mimic every step that Desktop Safari Develop tools uses to initialize a
* Web Inspector session
*
* @param {string} appIdKey
* @param {string} pageIdKey
* @returns {Promise<void>}
*/
async initializePageFull (appIdKey, pageIdKey) {
const sendOpts = {
appIdKey,
pageIdKey,
};
await this.send('Inspector.enable', sendOpts, false);
await this.send('Page.enable', sendOpts, false);
// go through the tasks to initialize
await this.send('Page.getResourceTree', sendOpts, false);
await this.send('Network.enable', sendOpts, false);
await this.send('Network.setResourceCachingDisabled', Object.assign({
disabled: false,
}, sendOpts), false);
await this.send('DOMStorage.enable', sendOpts, false);
await this.send('Database.enable', sendOpts, false);
await this.send('IndexedDB.enable', sendOpts, false);
await this.send('CSS.enable', sendOpts, false);
await this.send('Runtime.enable', sendOpts, false);
await this.send('Heap.enable', sendOpts, false);
await this.send('Memory.enable', sendOpts, false);
await this.send('ApplicationCache.enable', sendOpts, false);
await this.send('ApplicationCache.getFramesWithManifests', sendOpts, false);
await this.send('Timeline.setInstruments', Object.assign({
instruments: ['Timeline', 'ScriptProfiler', 'CPU'],
}, sendOpts), false);
await this.send('Timeline.setAutoCaptureEnabled', Object.assign({
enabled: false,
}, sendOpts), false);
await this.send('Debugger.enable', sendOpts, false);
await this.send('Debugger.setBreakpointsActive', Object.assign({
active: true,
}, sendOpts), false);
await this.send('Debugger.setPauseOnExceptions', Object.assign({
state: 'none',
}, sendOpts), false);
await this.send('Debugger.setPauseOnAssertions', Object.assign({
enabled: false,
}, sendOpts), false);
await this.send('Debugger.setAsyncStackTraceDepth', Object.assign({
depth: 200,
}, sendOpts), false);
await this.send('Debugger.setPauseForInternalScripts', Object.assign({
shouldPause: false,
}, sendOpts), false);
await this.send('LayerTree.enable', sendOpts, false);
await this.send('Worker.enable', sendOpts, false);
await this.send('Canvas.enable', sendOpts, false);
await this.send('Console.enable', sendOpts, false);
await this.send('DOM.getDocument', sendOpts, false);
const loggingChannels = await this.send('Console.getLoggingChannels', sendOpts);
for (const source of (loggingChannels.channels || []).map((entry) => entry.source)) {
await this.send('Console.setLoggingChannelLevel', Object.assign({
source,
level: 'verbose',
}, sendOpts), false);
}
await this.send('Inspector.initialized', sendOpts, false);
}
/**
*
* @param {string} appIdKey
* @returns {Promise<[string, Record<string, any>]>}
*/
async selectApp (appIdKey) {
return await new B((resolve, reject) => {
// local callback, temporarily added as callback to
// `_rpc_applicationConnected:` remote debugger response
// to handle the initial connection
const onAppChange = (err, dict) => {
if (err) {
return reject(err);
}
// from the dictionary returned, get the ids
const oldAppIdKey = dict.WIRHostApplicationIdentifierKey;
const correctAppIdKey = dict.WIRApplicationIdentifierKey;
// if this is a report of a proxy redirect from the remote debugger
// we want to update our dictionary and get a new app id
if (oldAppIdKey && correctAppIdKey !== oldAppIdKey) {
log.debug(`We were notified we might have connected to the wrong app. ` +
`Using id ${correctAppIdKey} instead of ${oldAppIdKey}`);
}
reject(new Error('New application has connected'));
};
// @ts-ignore messageHandler must be defined
this.messageHandler.prependOnceListener('_rpc_applicationConnected:', onAppChange);
// do the actual connecting to the app
return (async () => {
let pageDict, connectedAppIdKey;
try {
([connectedAppIdKey, pageDict] = await this.send('connectToApp', {
appIdKey
}));
} catch (err) {
log.warn(`Unable to connect to app: ${err.message}`);
reject(err);
}
// sometimes the connect logic happens, but with an empty dictionary
// which leads to the remote debugger getting disconnected, and into a loop
if (_.isEmpty(pageDict)) {
let msg = 'Empty page dictionary received';
log.debug(msg);
reject(new Error(msg));
} else {
resolve([connectedAppIdKey, pageDict]);
}
})();
});
}
/**
*
* @param {Error?} err
* @param {Record<string, any>} context
*/
onExecutionContextCreated (err, context) {
// { id: 2, isPageContext: true, name: '', frameId: '0.1' }
// right now we have no way to map contexts to apps/pages
// so just store
this.contexts.push(context.id);
}
onGarbageCollected () {
// just want to log that this is happening, as it can affect opertion
log.debug(`Web Inspector garbage collected`);
}
/**
*
* @param {Error?} err
* @param {Record<string, any>} scriptInfo
*/
onScriptParsed (err, scriptInfo) {
// { scriptId: '13', url: '', startLine: 0, startColumn: 0, endLine: 82, endColumn: 3 }
log.debug(`Script parsed: ${JSON.stringify(scriptInfo)}`);
}
}