diff options
Diffstat (limited to 'devtools/client/shared/remote-debugging')
32 files changed, 2769 insertions, 0 deletions
diff --git a/devtools/client/shared/remote-debugging/adb/adb-addon.js b/devtools/client/shared/remote-debugging/adb/adb-addon.js new file mode 100644 index 0000000000..ddce411cb3 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-addon.js @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs", + // AddonManager is a singleton, never create two instances of it. + { loadInDevToolsLoader: false } +); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const PREF_ADB_EXTENSION_URL = "devtools.remote.adb.extensionURL"; +const PREF_ADB_EXTENSION_ID = "devtools.remote.adb.extensionID"; + +const ADB_ADDON_STATES = { + DOWNLOADING: "downloading", + INSTALLED: "installed", + INSTALLING: "installing", + PREPARING: "preparing", + UNINSTALLED: "uninstalled", + UNKNOWN: "unknown", +}; +exports.ADB_ADDON_STATES = ADB_ADDON_STATES; + +/** + * Wrapper around the ADB Extension providing ADB binaries for devtools remote debugging. + * Fires the following events: + * - "update": the status of the addon was updated + * - "failure": addon installation failed + * - "progress": addon download in progress + * + * AdbAddon::state can take any of the values from ADB_ADDON_STATES. + */ +class ADBAddon extends EventEmitter { + constructor() { + super(); + + this._status = ADB_ADDON_STATES.UNKNOWN; + + const addonsListener = {}; + addonsListener.onEnabled = + addonsListener.onDisabled = + addonsListener.onInstalled = + addonsListener.onUninstalled = + () => this.updateInstallStatus(); + AddonManager.addAddonListener(addonsListener); + + this.updateInstallStatus(); + } + + set status(value) { + if (this._status != value) { + this._status = value; + this.emit("update"); + } + } + + get status() { + return this._status; + } + + async _getAddon() { + const addonId = Services.prefs.getCharPref(PREF_ADB_EXTENSION_ID); + return AddonManager.getAddonByID(addonId); + } + + async updateInstallStatus() { + const addon = await this._getAddon(); + if (addon && !addon.userDisabled) { + this.status = ADB_ADDON_STATES.INSTALLED; + } else { + this.status = ADB_ADDON_STATES.UNINSTALLED; + } + } + + /** + * Returns the platform specific download link for the ADB extension. + */ + _getXpiLink() { + const platform = Services.appShell.hiddenDOMWindow.navigator.platform; + let OS = ""; + if (platform.includes("Win")) { + OS = "win32"; + } else if (platform.includes("Mac")) { + OS = "mac64"; + } else if (platform.includes("Linux")) { + if (platform.includes("x86_64")) { + OS = "linux64"; + } else { + OS = "linux"; + } + } + + const xpiLink = Services.prefs.getCharPref(PREF_ADB_EXTENSION_URL); + return xpiLink.replace(/#OS#/g, OS); + } + + /** + * Install and enable the adb extension. Returns a promise that resolves when ADB is + * enabled. + * + * @param {String} source + * String passed to the AddonManager for telemetry. + */ + async install(source) { + if (!source) { + throw new Error( + "Missing mandatory `source` parameter for adb-addon.install" + ); + } + + const addon = await this._getAddon(); + if (addon && !addon.userDisabled) { + this.status = ADB_ADDON_STATES.INSTALLED; + return; + } + this.status = ADB_ADDON_STATES.PREPARING; + if (addon?.userDisabled) { + await addon.enable(); + } else { + const install = await AddonManager.getInstallForURL(this._getXpiLink(), { + telemetryInfo: { source }, + }); + install.addListener(this); + install.install(); + } + } + + async uninstall() { + const addon = await this._getAddon(); + addon.uninstall(); + } + + installFailureHandler(install, message) { + this.status = ADB_ADDON_STATES.UNINSTALLED; + this.emit("failure", message); + } + + // Expected AddonManager install listener. + onDownloadStarted() { + this.status = ADB_ADDON_STATES.DOWNLOADING; + } + + // Expected AddonManager install listener. + onDownloadProgress(install) { + if (install.maxProgress == -1) { + this.emit("progress", -1); + } else { + this.emit("progress", install.progress / install.maxProgress); + } + } + + // Expected AddonManager install listener. + onDownloadCancelled(install) { + this.installFailureHandler(install, "Download cancelled"); + } + + // Expected AddonManager install listener. + onDownloadFailed(install) { + this.installFailureHandler(install, "Download failed"); + } + + // Expected AddonManager install listener. + onInstallStarted() { + this.status = ADB_ADDON_STATES.INSTALLING; + } + + // Expected AddonManager install listener. + onInstallCancelled(install) { + this.installFailureHandler(install, "Install cancelled"); + } + + // Expected AddonManager install listener. + onInstallFailed(install) { + this.installFailureHandler(install, "Install failed"); + } + + // Expected AddonManager install listener. + onInstallEnded({ addon }) { + addon.enable(); + } +} + +exports.adbAddon = new ADBAddon(); diff --git a/devtools/client/shared/remote-debugging/adb/adb-binary.js b/devtools/client/shared/remote-debugging/adb/adb-binary.js new file mode 100644 index 0000000000..4b73dbbee7 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-binary.js @@ -0,0 +1,244 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +loader.lazyGetter(this, "UNPACKED_ROOT_PATH", () => { + return PathUtils.join(PathUtils.localProfileDir, "adb"); +}); +loader.lazyGetter(this, "EXTENSION_ID", () => { + return Services.prefs.getCharPref("devtools.remote.adb.extensionID"); +}); +loader.lazyGetter(this, "ADB_BINARY_PATH", () => { + let adbBinaryPath = PathUtils.join(UNPACKED_ROOT_PATH, "adb"); + if (Services.appinfo.OS === "WINNT") { + adbBinaryPath += ".exe"; + } + return adbBinaryPath; +}); + +const MANIFEST = "manifest.json"; + +/** + * Read contents from a given uri in the devtools-adb-extension and parse the + * contents as JSON. + */ +async function readFromExtension(fileUri) { + return new Promise(resolve => { + lazy.NetUtil.asyncFetch( + { + uri: fileUri, + loadUsingSystemPrincipal: true, + }, + input => { + try { + const string = lazy.NetUtil.readInputStreamToString( + input, + input.available() + ); + resolve(JSON.parse(string)); + } catch (e) { + dumpn(`Could not read ${fileUri} in the extension: ${e}`); + resolve(null); + } + } + ); + }); +} + +/** + * Unpack file from the extension. + * Uses NetUtil to read and write, since it's required for reading. + * + * @param {string} file + * The path name of the file in the extension. + */ +async function unpackFile(file) { + const policy = lazy.ExtensionParent.WebExtensionPolicy.getByID(EXTENSION_ID); + if (!policy) { + return; + } + + // Assumes that destination dir already exists. + const basePath = file.substring(file.lastIndexOf("/") + 1); + const filePath = PathUtils.join(UNPACKED_ROOT_PATH, basePath); + await new Promise((resolve, reject) => { + lazy.NetUtil.asyncFetch( + { + uri: policy.getURL(file), + loadUsingSystemPrincipal: true, + }, + input => { + try { + // Since we have to use NetUtil to read, probably it's okay to use for + // writing, rather than bouncing to IOUtils...? + const outputFile = new lazy.FileUtils.File(filePath); + const output = lazy.FileUtils.openAtomicFileOutputStream(outputFile); + lazy.NetUtil.asyncCopy(input, output, resolve); + } catch (e) { + dumpn(`Could not unpack file ${file} in the extension: ${e}`); + reject(e); + } + } + ); + }); + // Mark binaries as executable. + await IOUtils.setPermissions(filePath, 0o744); +} + +/** + * Extract files in the extension into local profile directory and returns + * if it fails. + */ +async function extractFiles() { + const policy = lazy.ExtensionParent.WebExtensionPolicy.getByID(EXTENSION_ID); + if (!policy) { + return false; + } + const uri = policy.getURL("adb.json"); + const adbInfo = await readFromExtension(uri); + if (!adbInfo) { + return false; + } + + let filesForAdb; + try { + // The adbInfo is an object looks like this; + // + // { + // "Linux": { + // "x86": [ + // "linux/adb" + // ], + // "x86_64": [ + // "linux64/adb" + // ] + // }, + // ... + + // XPCOMABI looks this; x86_64-gcc3, so drop the compiler name. + let architecture = Services.appinfo.XPCOMABI.split("-")[0]; + if (architecture === "aarch64") { + // Fallback on x86 or x86_64 binaries for aarch64 - See Bug 1522149 + const hasX86Binary = !!adbInfo[Services.appinfo.OS].x86; + architecture = hasX86Binary ? "x86" : "x86_64"; + } + filesForAdb = adbInfo[Services.appinfo.OS][architecture]; + } catch (e) { + return false; + } + + // manifest.json isn't in adb.json but has to be unpacked for version + // comparison + filesForAdb.push(MANIFEST); + + await IOUtils.makeDirectory(UNPACKED_ROOT_PATH); + + for (const file of filesForAdb) { + try { + await unpackFile(file); + } catch (e) { + return false; + } + } + + return true; +} + +/** + * Read the manifest from inside the devtools-adb-extension. + * Uses NetUtil since data is packed inside the extension, not a local file. + */ +async function getManifestFromExtension() { + const policy = lazy.ExtensionParent.WebExtensionPolicy.getByID(EXTENSION_ID); + if (!policy) { + return null; + } + + const manifestUri = policy.getURL(MANIFEST); + return readFromExtension(manifestUri); +} + +/** + * Returns whether manifest.json has already been unpacked. + */ +async function isManifestUnpacked() { + const manifestPath = PathUtils.join(UNPACKED_ROOT_PATH, MANIFEST); + return IOUtils.exists(manifestPath); +} + +/** + * Read the manifest from the unpacked binary directory. + * Uses IOUtils since this is a local file. + */ +async function getManifestFromUnpacked() { + if (!(await isManifestUnpacked())) { + throw new Error("Manifest doesn't exist at unpacked path"); + } + + const manifestPath = PathUtils.join(UNPACKED_ROOT_PATH, MANIFEST); + const binary = await IOUtils.read(manifestPath); + const json = new TextDecoder().decode(binary); + let data; + try { + data = JSON.parse(json); + } catch (e) {} + return data; +} + +/** + * Check state of binary unpacking, including the location and manifest. + */ +async function isUnpacked() { + if (!(await isManifestUnpacked())) { + dumpn("Needs unpacking, no manifest found"); + return false; + } + + const manifestInExtension = await getManifestFromExtension(); + const unpackedManifest = await getManifestFromUnpacked(); + if (manifestInExtension.version != unpackedManifest.version) { + dumpn( + `Needs unpacking, extension version ${manifestInExtension.version} != ` + + `unpacked version ${unpackedManifest.version}` + ); + return false; + } + dumpn("Already unpacked"); + return true; +} + +/** + * Get a file object for the adb binary from the 'adb@mozilla.org' extension + * which has been already installed. + * + * @return {nsIFile} + * File object for the binary. + */ +async function getFileForBinary() { + if (!(await isUnpacked()) && !(await extractFiles())) { + return null; + } + + const file = new lazy.FileUtils.File(ADB_BINARY_PATH); + if (!file.exists()) { + return null; + } + return file; +} + +exports.getFileForBinary = getFileForBinary; diff --git a/devtools/client/shared/remote-debugging/adb/adb-client.js b/devtools/client/shared/remote-debugging/adb/adb-client.js new file mode 100644 index 0000000000..ff238d6392 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-client.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * A module to track device changes + * Adapted from adb.js at + * https://github.com/mozilla/adbhelper/tree/f44386c2d8cb7635a7d2c5a51191c89b886f8327 + */ + +"use strict"; + +const { + AdbSocket, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-socket.js"); +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); + +const OKAY = 0x59414b4f; +const FAIL = 0x4c494146; + +// Return buffer, which differs between Gecko versions +function getBuffer(packet) { + return packet.buffer ? packet.buffer : packet; +} + +// @param aPacket The packet to get the length from. +// @param aIgnoreResponse True if this packet has no OKAY/FAIL. +// @return A js object { length:...; data:... } +function unpackPacket(packet, ignoreResponse) { + const buffer = getBuffer(packet); + dumpn("Len buffer: " + buffer.byteLength); + if (buffer.byteLength === 4 && !ignoreResponse) { + dumpn("Packet empty"); + return { length: 0, data: "" }; + } + const lengthView = new Uint8Array(buffer, ignoreResponse ? 0 : 4, 4); + const decoder = new TextDecoder(); + const length = parseInt(decoder.decode(lengthView), 16); + const text = new Uint8Array(buffer, ignoreResponse ? 4 : 8, length); + return { length, data: decoder.decode(text) }; +} + +// Checks if the response is expected (defaults to OKAY). +// @return true if response equals expected. +function checkResponse(packet, expected = OKAY) { + const buffer = getBuffer(packet); + const view = new Uint32Array(buffer, 0, 1); + if (view[0] == FAIL) { + dumpn("Response: FAIL"); + } + dumpn("view[0] = " + view[0]); + return view[0] == expected; +} + +// @param aCommand A protocol-level command as described in +// http://androidxref.com/4.0.4/xref/system/core/adb/OVERVIEW.TXT and +// http://androidxref.com/4.0.4/xref/system/core/adb/SERVICES.TXT +// @return A 8 bit typed array. +function createRequest(command) { + let length = command.length.toString(16).toUpperCase(); + while (length.length < 4) { + length = "0" + length; + } + + const encoder = new TextEncoder(); + dumpn("Created request: " + length + command); + return encoder.encode(length + command); +} + +function connect() { + return new AdbSocket(); +} + +const client = { + getBuffer, + unpackPacket, + checkResponse, + createRequest, + connect, +}; + +module.exports = client; diff --git a/devtools/client/shared/remote-debugging/adb/adb-device.js b/devtools/client/shared/remote-debugging/adb/adb-device.js new file mode 100644 index 0000000000..def22f27c3 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-device.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + shell, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/index.js"); + +/** + * A Device instance is created and registered with the Devices module whenever + * ADB notices a new device is connected. + */ +class AdbDevice { + constructor(id) { + this.id = id; + } + + async initialize() { + const model = await shell(this.id, "getprop ro.product.model"); + this.model = model.trim(); + } + + get name() { + return this.model || this.id; + } + + async getRuntimeSocketPaths() { + // A matching entry looks like: + // 00000000: 00000002 00000000 00010000 0001 01 6551588 + // /data/data/org.mozilla.fennec/firefox-debugger-socket + const query = "cat /proc/net/unix"; + const rawSocketInfo = await shell(this.id, query); + + // Filter to lines with "firefox-debugger-socket" + let socketInfos = rawSocketInfo.split(/\r?\n/); + socketInfos = socketInfos.filter(l => + l.includes("firefox-debugger-socket") + ); + + // It's possible to have multiple lines with the same path, so de-dupe them + const socketPaths = new Set(); + for (const socketInfo of socketInfos) { + const socketPath = socketInfo.split(" ").pop(); + socketPaths.add(socketPath); + } + + return socketPaths; + } +} + +module.exports = AdbDevice; diff --git a/devtools/client/shared/remote-debugging/adb/adb-process.js b/devtools/client/shared/remote-debugging/adb/adb-process.js new file mode 100644 index 0000000000..ade509125e --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-process.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getFileForBinary, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-binary.js"); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +loader.lazyRequireGetter( + this, + "runCommand", + "resource://devtools/client/shared/remote-debugging/adb/commands/index.js", + true +); +loader.lazyRequireGetter( + this, + "check", + "resource://devtools/client/shared/remote-debugging/adb/adb-running-checker.js", + true +); + +// Waits until a predicate returns true or re-tries the predicate calls +// |retry| times, we wait for 100ms between each calls. +async function waitUntil(predicate, retry = 20) { + let count = 0; + while (count++ < retry) { + if (await predicate()) { + return true; + } + // Wait for 100 milliseconds. + await new Promise(resolve => setTimeout(resolve, 100)); + } + // Timed out after trying too many times. + return false; +} + +// Class representing the ADB process. +class AdbProcess extends EventEmitter { + constructor() { + super(); + + this._ready = false; + this._didRunInitially = false; + } + + get ready() { + return this._ready; + } + + _getAdbFile() { + if (this._adbFilePromise) { + return this._adbFilePromise; + } + this._adbFilePromise = getFileForBinary(); + return this._adbFilePromise; + } + + async _runProcess(process, params) { + return new Promise((resolve, reject) => { + process.runAsync( + params, + params.length, + { + observe(subject, topic, data) { + switch (topic) { + case "process-finished": + resolve(); + break; + case "process-failed": + reject(); + break; + } + }, + }, + false + ); + }); + } + + // We startup by launching adb in server mode, and setting + // the tcp socket preference to |true| + async start() { + const onSuccessfulStart = () => { + this._ready = true; + this.emit("adb-ready"); + }; + + const isAdbRunning = await check(); + if (isAdbRunning) { + dumpn("Found ADB process running, not restarting"); + onSuccessfulStart(); + return; + } + dumpn("Didn't find ADB process running, restarting"); + + this._didRunInitially = true; + const process = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + + // FIXME: Bug 1481691 - We should avoid extracting files every time. + const adbFile = await this._getAdbFile(); + process.init(adbFile); + // Hide command prompt window on Windows + process.startHidden = true; + process.noShell = true; + const params = ["start-server"]; + let isStarted = false; + try { + await this._runProcess(process, params); + isStarted = await waitUntil(check); + } catch (e) {} + + if (isStarted) { + onSuccessfulStart(); + } else { + this._ready = false; + throw new Error("ADB Process didn't start"); + } + } + + /** + * Stop the ADB server, but only if we started it. If it was started before + * us, we return immediately. + */ + async stop() { + if (!this._didRunInitially) { + return; // We didn't start the server, nothing to do + } + await this.kill(); + } + + /** + * Kill the ADB server. + */ + async kill() { + try { + await runCommand("host:kill"); + } catch (e) { + dumpn("Failed to send host:kill command"); + } + dumpn("adb server was terminated by host:kill"); + this._ready = false; + this._didRunInitially = false; + } +} + +exports.adbProcess = new AdbProcess(); diff --git a/devtools/client/shared/remote-debugging/adb/adb-running-checker.js b/devtools/client/shared/remote-debugging/adb/adb-running-checker.js new file mode 100644 index 0000000000..7f952ca39b --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-running-checker.js @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Uses host:version service to detect if ADB is running + * Modified from adb-file-transfer from original ADB + */ + +"use strict"; + +const client = require("resource://devtools/client/shared/remote-debugging/adb/adb-client.js"); +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); + +exports.check = async function check() { + let socket; + let state; + let timerID; + const TIMEOUT_TIME = 1000; + + dumpn("Asking for host:version"); + + return new Promise(resolve => { + // On MacOSX connecting to a port which is not started listening gets + // stuck (bug 1481963), to avoid the stuck, we do forcibly fail the + // connection after |TIMEOUT_TIME| elapsed. + timerID = setTimeout(() => { + socket.close(); + resolve(false); + }, TIMEOUT_TIME); + + function finish(returnValue) { + clearTimeout(timerID); + resolve(returnValue); + } + + const runFSM = function runFSM(packetData) { + dumpn("runFSM " + state); + switch (state) { + case "start": + const req = client.createRequest("host:version"); + socket.send(req); + state = "wait-version"; + break; + case "wait-version": + // TODO: Actually check the version number to make sure the daemon + // supports the commands we want to use + const { length, data } = client.unpackPacket(packetData); + dumpn("length: ", length, "data: ", data); + socket.close(); + const version = parseInt(data, 16); + if (version >= 31) { + finish(true); + } else { + dumpn("killing existing adb as we need version >= 31"); + finish(false); + } + break; + default: + dumpn("Unexpected State: " + state); + finish(false); + } + }; + + const setupSocket = function () { + socket.s.onerror = function (event) { + dumpn("running checker onerror"); + finish(false); + }; + + socket.s.onopen = function (event) { + dumpn("running checker onopen"); + state = "start"; + runFSM(); + }; + + socket.s.onclose = function (event) { + dumpn("running checker onclose"); + }; + + socket.s.ondata = function (event) { + dumpn("running checker ondata"); + runFSM(event.data); + }; + }; + + socket = client.connect(); + setupSocket(); + }); +}; diff --git a/devtools/client/shared/remote-debugging/adb/adb-runtime.js b/devtools/client/shared/remote-debugging/adb/adb-runtime.js new file mode 100644 index 0000000000..77d39d643e --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-runtime.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + prepareTCPConnection, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/index.js"); +const { + shell, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/index.js"); + +class AdbRuntime { + constructor(adbDevice, socketPath) { + this._adbDevice = adbDevice; + this._socketPath = socketPath; + // Set a default version name in case versionName cannot be parsed. + this._versionName = ""; + } + + async init() { + const packageName = this._packageName(); + const query = `dumpsys package ${packageName} | grep versionName`; + const versionNameString = await shell(this._adbDevice.id, query); + + // The versionName can have different formats depending on the channel + // - `versionName=Nightly 191016 06:01\n` on Nightly + // - `versionName=2.1.0\n` on Release + // We use a very flexible regular expression to accommodate for those + // different formats. + const matches = versionNameString.match(/versionName=(.*)\n/); + if (matches?.[1]) { + this._versionName = matches[1]; + } + } + + get id() { + return this._adbDevice.id + "|" + this._socketPath; + } + + get isFenix() { + // Firefox Release uses "org.mozilla.firefox" + // Firefox Beta uses "org.mozilla.firefox_beta" + // Firefox Nightly uses "org.mozilla.fenix" + const isFirefox = + this._packageName().includes("org.mozilla.firefox") || + this._packageName().includes("org.mozilla.fenix"); + + if (!isFirefox) { + return false; + } + + // Firefox Release (based on Fenix) is not released in all regions yet, so + // we should still check for Fennec using the version number. + // Note that Fennec's versionName followed Firefox versions (eg "68.11.0"). + // We can find the main version number in it. Fenix on the other hand has + // version names such as "Nightly 200730 06:21". + const mainVersion = Number(this.versionName.split(".")[0]); + const isFennec = mainVersion === 68; + + // Application is Fenix if this is a Firefox application with a version + // different from the Fennec version. + return !isFennec; + } + + get deviceId() { + return this._adbDevice.id; + } + + get deviceName() { + return this._adbDevice.name; + } + + get versionName() { + return this._versionName; + } + + get shortName() { + const packageName = this._packageName(); + + switch (packageName) { + case "org.mozilla.firefox": + if (!this.isFenix) { + // Old Fennec release + return "Firefox (Fennec)"; + } + // Official Firefox app, based on Fenix + return "Firefox"; + case "org.mozilla.firefox_beta": + // Official Firefox Beta app, based on Fenix + return "Firefox Beta"; + case "org.mozilla.fenix": + case "org.mozilla.fenix.nightly": + // Official Firefox Nightly app, based on Fenix + return "Firefox Nightly"; + default: + // Unknown package name + return `Firefox (${packageName})`; + } + } + + get socketPath() { + return this._socketPath; + } + + get name() { + return `${this.shortName} on Android (${this.deviceName})`; + } + + connect(connection) { + return prepareTCPConnection(this.deviceId, this._socketPath).then(port => { + connection.host = "localhost"; + connection.port = port; + connection.connect(); + }); + } + + _packageName() { + // If using abstract socket address, it is "@org.mozilla.firefox/..." + // If using path base socket, it is "/data/data/<package>..."" + // Until Fennec 62 only supports path based UNIX domain socket, but + // Fennec 63+ supports both path based and abstract socket. + return this._socketPath.startsWith("@") + ? this._socketPath.substr(1).split("/")[0] + : this._socketPath.split("/")[3]; + } +} +exports.AdbRuntime = AdbRuntime; diff --git a/devtools/client/shared/remote-debugging/adb/adb-socket.js b/devtools/client/shared/remote-debugging/adb/adb-socket.js new file mode 100644 index 0000000000..6b2a6d7a1c --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb-socket.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); + +function createTCPSocket(location, port, options) { + const { TCPSocket } = Cu.getGlobalForObject( + ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs") + ); + + return new TCPSocket(location, port, options); +} + +// Creates a socket connected to the adb instance. +// This instantiation is sync, and returns before we know if opening the +// connection succeeds. Callers must attach handlers to the s field. +class AdbSocket { + constructor() { + this.s = createTCPSocket("127.0.0.1", 5037, { binaryType: "arraybuffer" }); + } + + /** + * Dump the first few bytes of the given array to the console. + * + * @param {TypedArray} inputArray + * the array to dump + */ + _hexdump(inputArray) { + const decoder = new TextDecoder("windows-1252"); + const array = new Uint8Array(inputArray.buffer); + const s = decoder.decode(array); + const len = array.length; + let dbg = "len=" + len + " "; + const l = len > 20 ? 20 : len; + + for (let i = 0; i < l; i++) { + let c = array[i].toString(16); + if (c.length == 1) { + c = "0" + c; + } + dbg += c; + } + dbg += " "; + for (let i = 0; i < l; i++) { + const c = array[i]; + if (c < 32 || c > 127) { + dbg += "."; + } else { + dbg += s[i]; + } + } + dumpn(dbg); + } + + // debugging version of tcpsocket.send() + send(array) { + this._hexdump(array); + + this.s.send(array.buffer, array.byteOffset, array.byteLength); + } + + close() { + if (this.s.readyState === "open" || this.s.readyState === "connecting") { + this.s.close(); + } + } +} + +exports.AdbSocket = AdbSocket; diff --git a/devtools/client/shared/remote-debugging/adb/adb.js b/devtools/client/shared/remote-debugging/adb/adb.js new file mode 100644 index 0000000000..b4c2a9377f --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/adb.js @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { clearInterval, setInterval } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + adbProcess, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-process.js"); +const { + adbAddon, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-addon.js"); +const AdbDevice = require("resource://devtools/client/shared/remote-debugging/adb/adb-device.js"); +const { + AdbRuntime, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-runtime.js"); +const { + TrackDevicesCommand, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/track-devices.js"); +loader.lazyRequireGetter( + this, + "check", + "resource://devtools/client/shared/remote-debugging/adb/adb-running-checker.js", + true +); + +// Duration in milliseconds of the runtime polling. We resort to polling here because we +// have no event to know when a runtime started on an already discovered ADB device. +const UPDATE_RUNTIMES_INTERVAL = 3000; + +class Adb extends EventEmitter { + constructor() { + super(); + + this._trackDevicesCommand = new TrackDevicesCommand(); + + this._isTrackingDevices = false; + this._isUpdatingRuntimes = false; + + this._listeners = new Set(); + this._devices = new Map(); + this._runtimes = []; + + this._updateAdbProcess = this._updateAdbProcess.bind(this); + this._onDeviceConnected = this._onDeviceConnected.bind(this); + this._onDeviceDisconnected = this._onDeviceDisconnected.bind(this); + this._onNoDevicesDetected = this._onNoDevicesDetected.bind(this); + + this._trackDevicesCommand.on("device-connected", this._onDeviceConnected); + this._trackDevicesCommand.on( + "device-disconnected", + this._onDeviceDisconnected + ); + this._trackDevicesCommand.on( + "no-devices-detected", + this._onNoDevicesDetected + ); + adbAddon.on("update", this._updateAdbProcess); + } + + registerListener(listener) { + this._listeners.add(listener); + this.on("runtime-list-updated", listener); + this._updateAdbProcess(); + } + + unregisterListener(listener) { + this._listeners.delete(listener); + this.off("runtime-list-updated", listener); + this._updateAdbProcess(); + } + + async updateRuntimes() { + try { + const devices = [...this._devices.values()]; + const promises = devices.map(d => this._getDeviceRuntimes(d)); + const allRuntimes = await Promise.all(promises); + + this._runtimes = allRuntimes.flat(); + this.emit("runtime-list-updated"); + } catch (e) { + console.error(e); + } + } + + getRuntimes() { + return this._runtimes; + } + + getDevices() { + return [...this._devices.values()]; + } + + async isProcessStarted() { + return check(); + } + + async _startTracking() { + this._isTrackingDevices = true; + await adbProcess.start(); + + this._trackDevicesCommand.run(); + + // Device runtimes are detected by running a shell command and checking for + // "firefox-debugger-socket" in the list of currently running processes. + this._timer = setInterval( + this.updateRuntimes.bind(this), + UPDATE_RUNTIMES_INTERVAL + ); + } + + async _stopTracking() { + clearInterval(this._timer); + this._isTrackingDevices = false; + this._trackDevicesCommand.stop(); + + this._devices = new Map(); + this._runtimes = []; + this.updateRuntimes(); + } + + _shouldTrack() { + return adbAddon.status === "installed" && this._listeners.size > 0; + } + + /** + * This method will emit "runtime-list-ready" to notify the consumer that the list of + * runtimes is ready to be retrieved. + */ + async _updateAdbProcess() { + if (!this._isTrackingDevices && this._shouldTrack()) { + const onRuntimesUpdated = this.once("runtime-list-updated"); + this._startTracking(); + // If we are starting to track runtimes, the list of runtimes will only be ready + // once the first "runtime-list-updated" event has been processed. + await onRuntimesUpdated; + } else if (this._isTrackingDevices && !this._shouldTrack()) { + this._stopTracking(); + } + this.emit("runtime-list-ready"); + } + + async _onDeviceConnected(deviceId) { + const adbDevice = new AdbDevice(deviceId); + await adbDevice.initialize(); + this._devices.set(deviceId, adbDevice); + this.updateRuntimes(); + } + + _onDeviceDisconnected(deviceId) { + this._devices.delete(deviceId); + this.updateRuntimes(); + } + + _onNoDevicesDetected() { + this.updateRuntimes(); + } + + async _getDeviceRuntimes(device) { + const socketPaths = [...(await device.getRuntimeSocketPaths())]; + const runtimes = []; + for (const socketPath of socketPaths) { + const runtime = new AdbRuntime(device, socketPath); + await runtime.init(); + runtimes.push(runtime); + } + return runtimes; + } +} + +exports.adb = new Adb(); diff --git a/devtools/client/shared/remote-debugging/adb/commands/index.js b/devtools/client/shared/remote-debugging/adb/commands/index.js new file mode 100644 index 0000000000..1032f1a4b8 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/index.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + listDevices, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/list-devices.js"); +const { + prepareTCPConnection, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/prepare-tcp-connection.js"); +const { + runCommand, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/run-command.js"); +const { + shell, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/shell.js"); +const { + TrackDevicesCommand, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/track-devices.js"); + +module.exports = { + listDevices, + prepareTCPConnection, + runCommand, + shell, + TrackDevicesCommand, +}; diff --git a/devtools/client/shared/remote-debugging/adb/commands/list-devices.js b/devtools/client/shared/remote-debugging/adb/commands/list-devices.js new file mode 100644 index 0000000000..c04ba9eb1e --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/list-devices.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +/** + * The listDevices command is currently unused in DevTools. We are keeping it while + * working on RemoteDebugging NG, in case it becomes needed later. We will remove it from + * the codebase if unused at the end of the project. See Bug 1511779. + */ +const listDevices = function () { + dumpn("listDevices"); + + return this.runCommand("host:devices").then(function onSuccess(data) { + const lines = data.split("\n"); + const res = []; + lines.forEach(function (line) { + if (!line.length) { + return; + } + const [device] = line.split("\t"); + res.push(device); + }); + return res; + }); +}; +exports.listDevices = listDevices; diff --git a/devtools/client/shared/remote-debugging/adb/commands/moz.build b/devtools/client/shared/remote-debugging/adb/commands/moz.build new file mode 100644 index 0000000000..ecd3428a1b --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/moz.build @@ -0,0 +1,12 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "index.js", + "list-devices.js", + "prepare-tcp-connection.js", + "run-command.js", + "shell.js", + "track-devices.js", +) diff --git a/devtools/client/shared/remote-debugging/adb/commands/prepare-tcp-connection.js b/devtools/client/shared/remote-debugging/adb/commands/prepare-tcp-connection.js new file mode 100644 index 0000000000..dc9103f56b --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/prepare-tcp-connection.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + runCommand, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/run-command.js"); + +// sends adb forward deviceId, localPort and devicePort +const forwardPort = function (deviceId, localPort, devicePort) { + dumpn("forwardPort " + localPort + " -- " + devicePort); + // Send "host-serial:<serial-number>:<request>", + // with <request> set to "forward:<local>;<remote>" + // See https://android.googlesource.com/platform/system/core/+/jb-dev/adb/SERVICES.TXT + return runCommand( + `host-serial:${deviceId}:forward:${localPort};${devicePort}` + ).then(function onSuccess(data) { + return data; + }); +}; + +const getFreeTCPPort = function () { + const serv = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + serv.init(-1, true, -1); + const port = serv.port; + serv.close(); + return port; +}; + +// Prepare TCP connection for provided device id and socket path. +// The returned value is a port number of localhost for the connection. +const prepareTCPConnection = async function (deviceId, socketPath) { + const port = getFreeTCPPort(); + const local = `tcp:${port}`; + const remote = socketPath.startsWith("@") + ? `localabstract:${socketPath.substring(1)}` + : `localfilesystem:${socketPath}`; + await forwardPort(deviceId, local, remote); + return port; +}; +exports.prepareTCPConnection = prepareTCPConnection; diff --git a/devtools/client/shared/remote-debugging/adb/commands/run-command.js b/devtools/client/shared/remote-debugging/adb/commands/run-command.js new file mode 100644 index 0000000000..5fc521b5e7 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/run-command.js @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Wrapper around the ADB utility. + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +const { + adbProcess, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-process.js"); +const client = require("resource://devtools/client/shared/remote-debugging/adb/adb-client.js"); + +const OKAY = 0x59414b4f; + +// Asynchronously runs an adb command. +// @param command The command as documented in +// http://androidxref.com/4.0.4/xref/system/core/adb/SERVICES.TXT +const runCommand = function (command) { + dumpn("runCommand " + command); + return new Promise((resolve, reject) => { + if (!adbProcess.ready) { + setTimeout(function () { + reject("ADB_NOT_READY"); + }); + return; + } + + const socket = client.connect(); + + socket.s.onopen = function () { + dumpn("runCommand onopen"); + const req = client.createRequest(command); + socket.send(req); + }; + + socket.s.onerror = function () { + dumpn("runCommand onerror"); + reject("NETWORK_ERROR"); + }; + + socket.s.onclose = function () { + dumpn("runCommand onclose"); + }; + + socket.s.ondata = function (event) { + dumpn("runCommand ondata"); + const data = event.data; + + const packet = client.unpackPacket(data, false); + if (!client.checkResponse(data, OKAY)) { + socket.close(); + dumpn("Error: " + packet.data); + reject("PROTOCOL_ERROR"); + return; + } + + resolve(packet.data); + }; + }); +}; +exports.runCommand = runCommand; diff --git a/devtools/client/shared/remote-debugging/adb/commands/shell.js b/devtools/client/shared/remote-debugging/adb/commands/shell.js new file mode 100644 index 0000000000..03f4bfcf78 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/shell.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Wrapper around the ADB utility. + +"use strict"; + +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +const client = require("resource://devtools/client/shared/remote-debugging/adb/adb-client.js"); + +const OKAY = 0x59414b4f; + +const shell = async function (deviceId, command) { + if (!deviceId) { + throw new Error("ADB shell command needs the device id"); + } + + let state; + let stdout = ""; + + dumpn("shell " + command + " on " + deviceId); + + return new Promise((resolve, reject) => { + const shutdown = function () { + dumpn("shell shutdown"); + socket.close(); + reject("BAD_RESPONSE"); + }; + + const runFSM = function runFSM(data) { + dumpn("runFSM " + state); + let req; + let ignoreResponseCode = false; + switch (state) { + case "start": + state = "send-transport"; + runFSM(); + break; + case "send-transport": + req = client.createRequest("host:transport:" + deviceId); + socket.send(req); + state = "wait-transport"; + break; + case "wait-transport": + if (!client.checkResponse(data, OKAY)) { + shutdown(); + return; + } + state = "send-shell"; + runFSM(); + break; + case "send-shell": + req = client.createRequest("shell:" + command); + socket.send(req); + state = "rec-shell"; + break; + case "rec-shell": + if (!client.checkResponse(data, OKAY)) { + shutdown(); + return; + } + state = "decode-shell"; + if (client.getBuffer(data).byteLength == 4) { + break; + } + ignoreResponseCode = true; + // eslint-disable-next-lined no-fallthrough + case "decode-shell": + const decoder = new TextDecoder(); + const text = new Uint8Array( + client.getBuffer(data), + ignoreResponseCode ? 4 : 0 + ); + stdout += decoder.decode(text); + break; + default: + dumpn("shell Unexpected State: " + state); + reject("UNEXPECTED_STATE"); + } + }; + + const socket = client.connect(); + socket.s.onerror = function (event) { + dumpn("shell onerror"); + reject("SOCKET_ERROR"); + }; + + socket.s.onopen = function (event) { + dumpn("shell onopen"); + state = "start"; + runFSM(); + }; + + socket.s.onclose = function (event) { + resolve(stdout); + dumpn("shell onclose"); + }; + + socket.s.ondata = function (event) { + dumpn("shell ondata"); + runFSM(event.data); + }; + }); +}; + +exports.shell = shell; diff --git a/devtools/client/shared/remote-debugging/adb/commands/track-devices.js b/devtools/client/shared/remote-debugging/adb/commands/track-devices.js new file mode 100644 index 0000000000..2d796668ea --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/commands/track-devices.js @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Wrapper around the ADB utility. + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { dumpn } = require("resource://devtools/shared/DevToolsUtils.js"); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { + adbProcess, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-process.js"); +const client = require("resource://devtools/client/shared/remote-debugging/adb/adb-client.js"); + +const ADB_STATUS_OFFLINE = "offline"; +const OKAY = 0x59414b4f; + +// Start tracking devices connecting and disconnecting from the host. +// We can't reuse runCommand here because we keep the socket alive. +class TrackDevicesCommand extends EventEmitter { + run() { + this._waitForFirst = true; + // Hold device statuses. key: device id, value: status. + this._devices = new Map(); + this._socket = client.connect(); + + this._socket.s.onopen = this._onOpen.bind(this); + this._socket.s.onerror = this._onError.bind(this); + this._socket.s.onclose = this._onClose.bind(this); + this._socket.s.ondata = this._onData.bind(this); + } + + stop() { + if (this._socket) { + this._socket.close(); + + this._socket.s.onopen = null; + this._socket.s.onerror = null; + this._socket.s.onclose = null; + this._socket.s.ondata = null; + } + } + + _onOpen() { + dumpn("trackDevices onopen"); + const req = client.createRequest("host:track-devices"); + this._socket.send(req); + } + + _onError(event) { + dumpn("trackDevices onerror: " + event); + } + + _onClose() { + dumpn("trackDevices onclose"); + + // Report all devices as disconnected + this._disconnectAllDevices(); + + // When we lose connection to the server, + // and the adb is still on, we most likely got our server killed + // by local adb. So we do try to reconnect to it. + + // Give some time to the new adb to start + setTimeout(() => { + // Only try to reconnect/restart if the add-on is still enabled + if (adbProcess.ready) { + // try to connect to the new local adb server or spawn a new one + adbProcess.start().then(() => { + // Re-track devices + this.run(); + }); + } + }, 2000); + } + + _onData(event) { + dumpn("trackDevices ondata"); + const data = event.data; + dumpn("length=" + data.byteLength); + const dec = new TextDecoder(); + dumpn(dec.decode(new Uint8Array(data)).trim()); + + // check the OKAY or FAIL on first packet. + if (this._waitForFirst) { + if (!client.checkResponse(data, OKAY)) { + this._socket.close(); + return; + } + } + + const packet = client.unpackPacket(data, !this._waitForFirst); + this._waitForFirst = false; + + if (packet.data == "") { + // All devices got disconnected. + this._disconnectAllDevices(); + } else { + // One line per device, each line being $DEVICE\t(offline|device) + const lines = packet.data.split("\n"); + const newDevices = new Map(); + lines.forEach(function (line) { + if (!line.length) { + return; + } + + const [deviceId, status] = line.split("\t"); + newDevices.set(deviceId, status); + }); + + // Fire events if needed. + const deviceIds = new Set([ + ...this._devices.keys(), + ...newDevices.keys(), + ]); + for (const deviceId of deviceIds) { + const currentStatus = this._devices.get(deviceId); + const newStatus = newDevices.get(deviceId); + this._fireConnectionEventIfNeeded(deviceId, currentStatus, newStatus); + } + + // Update devices. + this._devices = newDevices; + } + } + + _disconnectAllDevices() { + if (this._devices.size === 0) { + // If no devices were detected, fire an event to let consumer resume. + this.emit("no-devices-detected"); + } else { + for (const [deviceId, status] of this._devices.entries()) { + if (status !== ADB_STATUS_OFFLINE) { + this.emit("device-disconnected", deviceId); + } + } + } + this._devices = new Map(); + } + + _fireConnectionEventIfNeeded(deviceId, currentStatus, newStatus) { + const isCurrentOnline = !!( + currentStatus && currentStatus !== ADB_STATUS_OFFLINE + ); + const isNewOnline = !!(newStatus && newStatus !== ADB_STATUS_OFFLINE); + + if (isCurrentOnline === isNewOnline) { + return; + } + + if (isNewOnline) { + this.emit("device-connected", deviceId); + } else { + this.emit("device-disconnected", deviceId); + } + } +} +exports.TrackDevicesCommand = TrackDevicesCommand; diff --git a/devtools/client/shared/remote-debugging/adb/moz.build b/devtools/client/shared/remote-debugging/adb/moz.build new file mode 100644 index 0000000000..9210dec08e --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/moz.build @@ -0,0 +1,24 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "commands", +] + +DevToolsModules( + "adb-addon.js", + "adb-binary.js", + "adb-client.js", + "adb-device.js", + "adb-process.js", + "adb-running-checker.js", + "adb-runtime.js", + "adb-socket.js", + "adb.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "about:debugging") + +XPCSHELL_TESTS_MANIFESTS += ["xpcshell/xpcshell.ini"] diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/.eslintrc.js b/devtools/client/shared/remote-debugging/adb/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..e4da98dd14 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/.eslintrc.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: "../../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/adb.py b/devtools/client/shared/remote-debugging/adb/xpcshell/adb.py new file mode 100644 index 0000000000..4b720a1f86 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/adb.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +A fake ADB binary +""" + +import os +import socketserver +import sys + +HOST = "127.0.0.1" +PORT = 5037 + + +class ADBRequestHandler(socketserver.BaseRequestHandler): + def sendData(self, data): + header = "OKAY%04x" % len(data) + all_data = header + data + total_length = len(all_data) + sent_length = 0 + # Make sure send all data to the client. + # Though the data length here is pretty small but sometimes when the + # client is on heavy load (e.g. MOZ_CHAOSMODE) we can't send the whole + # data at once. + while sent_length < total_length: + sent = self.request.send(all_data[sent_length:].encode("utf-8", "replace")) + sent_length = sent_length + sent + + def handle(self): + while True: + data = self.request.recv(4096).decode("utf-8", "replace") + if "host:kill" in data: + self.sendData("") + # Implicitly close all open sockets by exiting the program. + # This should be done ASAP, because upon receiving the OKAY, + # the client expects adb to have released the server's port. + os._exit(0) + break + elif "host:version" in data: + self.sendData("001F") + self.request.close() + break + elif "host:track-devices" in data: + self.sendData("1234567890\tdevice") + break + + +class ADBServer(socketserver.TCPServer): + def __init__(self, server_address): + # Create a socketserver with bind_and_activate 'False' to set + # allow_reuse_address before binding. + socketserver.TCPServer.__init__( + self, server_address, ADBRequestHandler, bind_and_activate=False + ) + + +if len(sys.argv) == 2 and sys.argv[1] == "start-server": + # daemonize + if os.fork() > 0: + sys.exit(0) + os.setsid() + if os.fork() > 0: + sys.exit(0) + + server = ADBServer((HOST, PORT)) + server.allow_reuse_address = True + server.server_bind() + server.server_activate() + server.serve_forever() diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/test_adb.js b/devtools/client/shared/remote-debugging/adb/xpcshell/test_adb.js new file mode 100644 index 0000000000..b2ea288604 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/test_adb.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs" +); +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +const { + getFileForBinary, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-binary.js"); +const { + check, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-running-checker.js"); +const { + adbProcess, +} = require("resource://devtools/client/shared/remote-debugging/adb/adb-process.js"); +const { + TrackDevicesCommand, +} = require("resource://devtools/client/shared/remote-debugging/adb/commands/index.js"); + +const ADB_JSON = { + Linux: { + x86: ["linux/adb"], + x86_64: ["linux64/adb"], + }, + Darwin: { + x86_64: ["mac64/adb"], + }, + WINNT: { + x86: ["win32/adb.exe", "win32/AdbWinApi.dll", "win32/AdbWinUsbApi.dll"], + x86_64: ["win32/adb.exe", "win32/AdbWinApi.dll", "win32/AdbWinUsbApi.dll"], + }, +}; +let extension_version = 1.0; + +ExtensionTestUtils.init(this); + +function readAdbMockContent() { + const adbMockFile = do_get_file("adb.py", false); + const s = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + s.init(adbMockFile, -1, -1, false); + try { + return NetUtil.readInputStreamToString(s, s.available()); + } finally { + s.close(); + } +} + +const adbMock = readAdbMockContent(); + +add_task(async function setup() { + // Prepare the profile directory where the adb extension will be installed. + do_get_profile(); +}); + +add_task(async function testAdbIsNotRunningInitially() { + const isAdbRunning = await check(); + // Assume that no adb server running. + ok(!isAdbRunning, "adb is not running initially"); +}); + +add_task(async function testNoAdbExtension() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + gecko: { id: "not-adb@mozilla.org" }, + }, + }, + }); + + await extension.startup(); + + const adbBinary = await getFileForBinary(); + equal(adbBinary, null); + + await extension.unload(); +}); + +add_task(async function testNoAdbJSON() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + // The extension id here and in later test cases should match the + // corresponding prefrece value. + gecko: { id: "adb@mozilla.org" }, + }, + }, + }); + + await extension.startup(); + + const adbBinary = await getFileForBinary(); + equal(adbBinary, null); + + await extension.unload(); +}); + +add_task(async function testNoTargetBinaries() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + gecko: { id: "adb@mozilla.org" }, + }, + }, + files: { + "adb.json": JSON.stringify(ADB_JSON), + }, + }); + + await extension.startup(); + + const adbBinary = await getFileForBinary(); + equal(adbBinary, null); + + await extension.unload(); +}); + +add_task(async function testExtract() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + gecko: { id: "adb@mozilla.org" }, + }, + }, + files: { + "adb.json": JSON.stringify(ADB_JSON), + "linux/adb": "adb", + "linux64/adb": "adb", + "mac64/adb": "adb", + "win32/adb.exe": "adb.exe", + "win32/AdbWinApi.dll": "AdbWinApi.dll", + "win32/AdbWinUsbApi.dll": "AdbWinUsbApi.dll", + }, + }); + + await extension.startup(); + + const adbBinary = await getFileForBinary(); + ok(await adbBinary.exists()); + + await extension.unload(); +}); + +add_task( + { + skip_if: () => mozinfo.os == "win", // bug 1482008 + }, + async function testStartAndStop() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + gecko: { id: "adb@mozilla.org" }, + }, + }, + files: { + "adb.json": JSON.stringify(ADB_JSON), + "linux/adb": adbMock, + "linux64/adb": adbMock, + "mac64/adb": adbMock, + "win32/adb.exe": adbMock, + "win32/AdbWinApi.dll": "dummy", + "win32/AdbWinUsbApi.dll": "dummy", + }, + }); + + await extension.startup(); + + // Call start() once and call stop() afterwards. + await adbProcess.start(); + ok(adbProcess.ready); + ok(await check(), "adb is now running"); + + await adbProcess.stop(); + ok(!adbProcess.ready); + ok(!(await check()), "adb is no longer running"); + + // Call start() twice and call stop() afterwards. + await adbProcess.start(); + await adbProcess.start(); + ok(adbProcess.ready); + ok(await check(), "adb is now running"); + + await adbProcess.stop(); + ok(!adbProcess.ready); + ok(!(await check()), "adb is no longer running"); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => mozinfo.os == "win", // bug 1482008 + }, + async function testTrackDevices() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: (extension_version++).toString(), + browser_specific_settings: { + gecko: { id: "adb@mozilla.org" }, + }, + }, + files: { + "adb.json": JSON.stringify(ADB_JSON), + "linux/adb": adbMock, + "linux64/adb": adbMock, + "mac64/adb": adbMock, + "win32/adb.exe": adbMock, + "win32/AdbWinApi.dll": "dummy", + "win32/AdbWinUsbApi.dll": "dummy", + }, + }); + + await extension.startup(); + + await adbProcess.start(); + ok(adbProcess.ready); + + ok(await check(), "adb is now running"); + + const receivedDeviceId = await new Promise(resolve => { + const trackDevicesCommand = new TrackDevicesCommand(); + trackDevicesCommand.on("device-connected", deviceId => { + resolve(deviceId); + }); + trackDevicesCommand.run(); + }); + + equal(receivedDeviceId, "1234567890"); + + await adbProcess.stop(); + ok(!adbProcess.ready); + + await extension.unload(); + } +); diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/test_prepare-tcp-connection.js b/devtools/client/shared/remote-debugging/adb/xpcshell/test_prepare-tcp-connection.js new file mode 100644 index 0000000000..09dad165f2 --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/test_prepare-tcp-connection.js @@ -0,0 +1,78 @@ +"use strict"; + +const DEVICE_ID = "FAKE_DEVICE_ID"; +const SOCKET_PATH = "fake/socket/path"; + +/** + * Test the prepare-tcp-connection command in isolation. + */ +add_task(async function testParseFileUri() { + info("Enable devtools.testing for this test to allow mocked modules"); + Services.prefs.setBoolPref("devtools.testing", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.testing"); + }); + + // Mocks are not supported for the regular DevTools loader. + info("Create a BrowserLoader to enable mocks in the test"); + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const mockedRequire = BrowserLoader({ + baseURI: "resource://devtools/client/shared/remote-debugging/adb", + window: {}, + }).require; + + // Prepare a mocked version of the run-command.js module to test + // prepare-tcp-connection in isolation. + info("Create a run-command module mock"); + let createdPort; + const mock = { + runCommand: command => { + // Check that we only receive the expected command and extract the port + // number. + + // The command should follow the pattern + // `host-serial:${deviceId}:forward:tcp:${localPort};${devicePort}` + // with devicePort = `localfilesystem:${socketPath}` + const socketPathRe = SOCKET_PATH.replace(/\//g, "\\/"); + const regexp = new RegExp( + `host-serial:${DEVICE_ID}:forward:tcp:(\\d+);localfilesystem:${socketPathRe}` + ); + const matches = regexp.exec(command); + equal(matches.length, 2, "The command is the expected"); + createdPort = matches[1]; + + // Finally return a Promise-like object. + return { + then: () => {}, + }; + }, + }; + + info("Enable the mocked run-command module"); + const { setMockedModule, removeMockedModule } = mockedRequire( + "devtools/shared/loader/browser-loader-mocks" + ); + setMockedModule( + mock, + "devtools/client/shared/remote-debugging/adb/commands/run-command" + ); + registerCleanupFunction(() => { + removeMockedModule( + "devtools/client/shared/remote-debugging/adb/commands/run-command" + ); + }); + + const { prepareTCPConnection } = mockedRequire( + "devtools/client/shared/remote-debugging/adb/commands/prepare-tcp-connection" + ); + + const port = await prepareTCPConnection(DEVICE_ID, SOCKET_PATH); + ok(!Number.isNaN(port), "prepareTCPConnection returned a valid port"); + equal( + port, + Number(createdPort), + "prepareTCPConnection returned the port sent to the host-serial command" + ); +}); diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell-head.js b/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell-head.js new file mode 100644 index 0000000000..733c0400da --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell-head.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); diff --git a/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell.ini b/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..d2b34c105a --- /dev/null +++ b/devtools/client/shared/remote-debugging/adb/xpcshell/xpcshell.ini @@ -0,0 +1,11 @@ +[DEFAULT] +tags = devtools +head = xpcshell-head.js +firefox-appdir = browser +skip-if = toolkit == 'android' || socketprocess_networking +support-files = + adb.py + +[test_adb.js] +run-sequentially = An extension having the same id is installed/uninstalled in different tests +[test_prepare-tcp-connection.js] diff --git a/devtools/client/shared/remote-debugging/constants.js b/devtools/client/shared/remote-debugging/constants.js new file mode 100644 index 0000000000..ad4d180548 --- /dev/null +++ b/devtools/client/shared/remote-debugging/constants.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const CONNECTION_TYPES = { + NETWORK: "network", + THIS_FIREFOX: "this-firefox", + UNKNOWN: "unknown", + USB: "usb", +}; + +const DEBUG_TARGET_TYPES = { + EXTENSION: "extension", + PROCESS: "process", + TAB: "tab", + WORKER: "worker", +}; + +module.exports = { + CONNECTION_TYPES, + DEBUG_TARGET_TYPES, +}; diff --git a/devtools/client/shared/remote-debugging/moz.build b/devtools/client/shared/remote-debugging/moz.build new file mode 100644 index 0000000000..da4494b1de --- /dev/null +++ b/devtools/client/shared/remote-debugging/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "adb", +] + +DevToolsModules( + "constants.js", + "remote-client-manager.js", + "version-checker.js", +) + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "about:debugging") diff --git a/devtools/client/shared/remote-debugging/remote-client-manager.js b/devtools/client/shared/remote-debugging/remote-client-manager.js new file mode 100644 index 0000000000..45e7a1be4a --- /dev/null +++ b/devtools/client/shared/remote-debugging/remote-client-manager.js @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); + +/** + * This class is designed to be a singleton shared by all DevTools to get access to + * existing clients created for remote debugging. + */ +class RemoteClientManager { + constructor() { + this._clients = new Map(); + this._runtimeInfoMap = new Map(); + this._onClientClosed = this._onClientClosed.bind(this); + } + + /** + * Store a remote client that is already connected. + * + * @param {String} id + * Remote runtime id (see devtools/client/aboutdebugging/src/types). + * @param {String} type + * Remote runtime type (see devtools/client/aboutdebugging/src/types). + * @param {DevToolsClient} client + * @param {Object} runtimeInfo + * See runtimeInfo type from client/aboutdebugging/src/types/runtime.js + */ + setClient(id, type, client, runtimeInfo) { + const key = this._getKey(id, type); + this._clients.set(key, client); + if (runtimeInfo) { + this._runtimeInfoMap.set(key, runtimeInfo); + } + client.once("closed", this._onClientClosed); + } + + // See JSDoc for id, type from setClient. + hasClient(id, type) { + return this._clients.has(this._getKey(id, type)); + } + + // See JSDoc for id, type from setClient. + getClient(id, type) { + return this._clients.get(this._getKey(id, type)); + } + + // See JSDoc for id, type from setClient. + removeClient(id, type) { + const key = this._getKey(id, type); + this._removeClientByKey(key); + } + + removeAllClients() { + const keys = [...this._clients.keys()]; + for (const key of keys) { + this._removeClientByKey(key); + } + } + + /** + * Retrieve a unique, url-safe key based on a runtime id and type. + */ + getRemoteId(id, type) { + return encodeURIComponent(this._getKey(id, type)); + } + + /** + * Retrieve a managed client for a remote id. The remote id should have been generated + * using getRemoteId. + */ + getClientByRemoteId(remoteId) { + const key = this._getKeyByRemoteId(remoteId); + return this._clients.get(key); + } + + /** + * Retrieve the runtime info for a remote id. To display metadata about a runtime, such + * as name, device name, version... this runtimeInfo should be used rather than calling + * APIs on the client. + */ + getRuntimeInfoByRemoteId(remoteId) { + const key = this._getKeyByRemoteId(remoteId); + return this._runtimeInfoMap.get(key); + } + + /** + * Retrieve a managed client for a remote id. The remote id should have been generated + * using getRemoteId. + */ + getConnectionTypeByRemoteId(remoteId) { + const key = this._getKeyByRemoteId(remoteId); + for (const type of Object.values(CONNECTION_TYPES)) { + if (key.endsWith(type)) { + return type; + } + } + return CONNECTION_TYPES.UNKNOWN; + } + + _getKey(id, type) { + return id + "-" + type; + } + + _getKeyByRemoteId(remoteId) { + if (!remoteId) { + // If no remote id was provided, return the key corresponding to the local + // this-firefox runtime. + const { THIS_FIREFOX } = CONNECTION_TYPES; + return this._getKey(THIS_FIREFOX, THIS_FIREFOX); + } + + return decodeURIComponent(remoteId); + } + + _removeClientByKey(key) { + const client = this._clients.get(key); + if (client) { + client.off("closed", this._onClientClosed); + this._clients.delete(key); + this._runtimeInfoMap.delete(key); + } + } + + /** + * Cleanup all closed clients when a "closed" notification is received from a client. + */ + _onClientClosed() { + const closedClientKeys = [...this._clients.keys()].filter(key => { + return this._clients.get(key)._transportClosed; + }); + + for (const key of closedClientKeys) { + this._removeClientByKey(key); + } + } +} + +// Expose a singleton of RemoteClientManager. +module.exports = { + remoteClientManager: new RemoteClientManager(), +}; diff --git a/devtools/client/shared/remote-debugging/test/xpcshell/.eslintrc.js b/devtools/client/shared/remote-debugging/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..86bd54c245 --- /dev/null +++ b/devtools/client/shared/remote-debugging/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/shared/remote-debugging/test/xpcshell/test_remote_client_manager.js b/devtools/client/shared/remote-debugging/test/xpcshell/test_remote_client_manager.js new file mode 100644 index 0000000000..e66c38b67d --- /dev/null +++ b/devtools/client/shared/remote-debugging/test/xpcshell/test_remote_client_manager.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + remoteClientManager, +} = require("resource://devtools/client/shared/remote-debugging/remote-client-manager.js"); +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); + +add_task(async function testRemoteClientManager() { + for (const type of Object.values(CONNECTION_TYPES)) { + const fakeClient = createFakeClient(); + const runtimeInfo = {}; + const clientId = "clientId"; + const remoteId = remoteClientManager.getRemoteId(clientId, type); + + const connectionType = + remoteClientManager.getConnectionTypeByRemoteId(remoteId); + equal( + connectionType, + type, + `[${type}]: Correct connection type was returned by getConnectionTypeByRemoteId` + ); + + equal( + remoteClientManager.hasClient(clientId, type), + false, + `[${type}]: hasClient returns false if no client was set` + ); + equal( + remoteClientManager.getClient(clientId, type), + null, + `[${type}]: getClient returns null if no client was set` + ); + equal( + remoteClientManager.getClientByRemoteId(remoteId), + null, + `[${type}]: getClientByRemoteId returns null if no client was set` + ); + equal( + remoteClientManager.getRuntimeInfoByRemoteId(remoteId), + null, + `[${type}]: getRuntimeInfoByRemoteId returns null if no client was set` + ); + + remoteClientManager.setClient(clientId, type, fakeClient, runtimeInfo); + equal( + remoteClientManager.hasClient(clientId, type), + true, + `[${type}]: hasClient returns true` + ); + equal( + remoteClientManager.getClient(clientId, type), + fakeClient, + `[${type}]: getClient returns the correct client` + ); + equal( + remoteClientManager.getClientByRemoteId(remoteId), + fakeClient, + `[${type}]: getClientByRemoteId returns the correct client` + ); + equal( + remoteClientManager.getRuntimeInfoByRemoteId(remoteId), + runtimeInfo, + `[${type}]: getRuntimeInfoByRemoteId returns the correct object` + ); + + remoteClientManager.removeClient(clientId, type); + equal( + remoteClientManager.hasClient(clientId, type), + false, + `[${type}]: hasClient returns false after removing the client` + ); + equal( + remoteClientManager.getClient(clientId, type), + null, + `[${type}]: getClient returns null after removing the client` + ); + equal( + remoteClientManager.getClientByRemoteId(remoteId), + null, + `[${type}]: getClientByRemoteId returns null after removing the client` + ); + equal( + remoteClientManager.getRuntimeInfoByRemoteId(), + null, + `[${type}]: getRuntimeInfoByRemoteId returns null after removing the client` + ); + } + + // Test various fallback scenarios for APIs relying on remoteId, when called without a + // remoteId, we expect to get the information for the local this-firefox runtime. + const { THIS_FIREFOX } = CONNECTION_TYPES; + const thisFirefoxClient = createFakeClient(); + const thisFirefoxInfo = {}; + remoteClientManager.setClient( + THIS_FIREFOX, + THIS_FIREFOX, + thisFirefoxClient, + thisFirefoxInfo + ); + + equal( + remoteClientManager.getClientByRemoteId(), + thisFirefoxClient, + `[fallback]: getClientByRemoteId returns this-firefox if remoteId is null` + ); + equal( + remoteClientManager.getRuntimeInfoByRemoteId(), + thisFirefoxInfo, + `[fallback]: getRuntimeInfoByRemoteId returns this-firefox if remoteId is null` + ); + + const otherRemoteId = remoteClientManager.getRemoteId( + "clientId", + CONNECTION_TYPES.USB + ); + equal( + remoteClientManager.getClientByRemoteId(otherRemoteId), + null, + `[fallback]: getClientByRemoteId does not fallback if remoteId is non-null` + ); + equal( + remoteClientManager.getRuntimeInfoByRemoteId(otherRemoteId), + null, + `[fallback]: getRuntimeInfoByRemoteId does not fallback if remoteId is non-null` + ); +}); + +add_task(async function testRemoteClientManagerWithUnknownType() { + const remoteId = remoteClientManager.getRemoteId( + "someClientId", + "NotARealType" + ); + const connectionType = + remoteClientManager.getConnectionTypeByRemoteId(remoteId); + equal( + connectionType, + CONNECTION_TYPES.UNKNOWN, + `Connection type UNKNOWN was returned by getConnectionTypeByRemoteId` + ); +}); + +function createFakeClient() { + const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + + const client = {}; + EventEmitter.decorate(client); + return client; +} diff --git a/devtools/client/shared/remote-debugging/test/xpcshell/test_version_checker.js b/devtools/client/shared/remote-debugging/test/xpcshell/test_version_checker.js new file mode 100644 index 0000000000..92f7d54a1e --- /dev/null +++ b/devtools/client/shared/remote-debugging/test/xpcshell/test_version_checker.js @@ -0,0 +1,159 @@ +/* global equal */ + +"use strict"; + +const { + _compareVersionCompatibility, + checkVersionCompatibility, + COMPATIBILITY_STATUS, +} = require("resource://devtools/client/shared/remote-debugging/version-checker.js"); + +const TEST_DATA = [ + { + description: "same build date and same version number", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "60.0", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same build date and older version in range (-1)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "59.0", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same build date and older version in range (-2)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "58.0", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same build date and older version in range (-2 Nightly)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "58.0a1", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same build date and older version out of range (-3)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "57.0", + expected: COMPATIBILITY_STATUS.TOO_OLD, + }, + { + description: "same build date and newer version out of range (+1)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190131000000", + runtimeVersion: "61.0", + expected: COMPATIBILITY_STATUS.TOO_RECENT, + }, + { + description: "same major version and build date in range (-10 days)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190121000000", + runtimeVersion: "60.0", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same major version and build date in range (+2 days)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190202000000", + runtimeVersion: "60.0", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "same major version and build date out of range (+8 days)", + localBuildId: "20190131000000", + localVersion: "60.0", + runtimeBuildId: "20190208000000", + runtimeVersion: "60.0", + expected: COMPATIBILITY_STATUS.TOO_RECENT, + }, + { + description: + "fennec 68 compatibility error not raised for 68 -> 68 Android", + localBuildId: "20190131000000", + localVersion: "68.0", + runtimeBuildId: "20190202000000", + runtimeVersion: "68.0", + runtimeOs: "Android", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: + "fennec 68 compatibility error not raised for 70 -> 68 Android", + localBuildId: "20190131000000", + localVersion: "70.0", + runtimeBuildId: "20190202000000", + runtimeVersion: "68.0", + runtimeOs: "Android", + expected: COMPATIBILITY_STATUS.COMPATIBLE, + }, + { + description: "fennec 68 compatibility error raised for 71 -> 68 Android", + localBuildId: "20190131000000", + localVersion: "71.0", + runtimeBuildId: "20190202000000", + runtimeVersion: "68.0", + runtimeOs: "Android", + expected: COMPATIBILITY_STATUS.TOO_OLD_FENNEC, + }, + { + description: + "fennec 68 compatibility error not raised for 71 -> 68 non-Android", + localBuildId: "20190131000000", + localVersion: "71.0", + runtimeBuildId: "20190202000000", + runtimeVersion: "68.0", + runtimeOs: "NotAndroid", + expected: COMPATIBILITY_STATUS.TOO_OLD, + }, +]; + +add_task(async function testVersionChecker() { + for (const testData of TEST_DATA) { + const localDescription = { + appbuildid: testData.localBuildId, + platformversion: testData.localVersion, + }; + + const runtimeDescription = { + appbuildid: testData.runtimeBuildId, + platformversion: testData.runtimeVersion, + os: testData.runtimeOs, + }; + + const report = _compareVersionCompatibility( + localDescription, + runtimeDescription + ); + equal( + report.status, + testData.expected, + "Expected status for test: " + testData.description + ); + } +}); + +add_task(async function testVersionCheckWithVeryOldClient() { + // Use an empty object as devtools client, calling any method on it will fail. + const emptyClient = {}; + const report = await checkVersionCompatibility(emptyClient); + equal( + report.status, + COMPATIBILITY_STATUS.TOO_OLD, + "Report status too old if devtools client is not implementing expected interface" + ); +}); diff --git a/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell-head.js b/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell-head.js new file mode 100644 index 0000000000..733c0400da --- /dev/null +++ b/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell-head.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); diff --git a/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell.ini b/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..0579b8a674 --- /dev/null +++ b/devtools/client/shared/remote-debugging/test/xpcshell/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +tags = devtools +head = xpcshell-head.js +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_remote_client_manager.js] +[test_version_checker.js] diff --git a/devtools/client/shared/remote-debugging/version-checker.js b/devtools/client/shared/remote-debugging/version-checker.js new file mode 100644 index 0000000000..7023a5bcd3 --- /dev/null +++ b/devtools/client/shared/remote-debugging/version-checker.js @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +const COMPATIBILITY_STATUS = { + COMPATIBLE: "compatible", + TOO_OLD: "too-old", + TOO_OLD_FENNEC: "too-old-fennec", + TOO_RECENT: "too-recent", +}; +exports.COMPATIBILITY_STATUS = COMPATIBILITY_STATUS; + +function getDateFromBuildID(buildID) { + // Build IDs are a timestamp in the yyyyMMddHHmmss format. + // Extract the year, month and day information. + const fields = buildID.match(/(\d{4})(\d{2})(\d{2})/); + // Date expects 0 - 11 for months + const month = Number.parseInt(fields[2], 10) - 1; + return new Date(fields[1], month, fields[3]); +} + +function getMajorVersion(platformVersion) { + // Retrieve the major platform version, i.e. if we are on Firefox 64.0a1, it will be 64. + return Number.parseInt(platformVersion.match(/\d+/)[0], 10); +} + +/** + * Compute the minimum and maximum supported version for remote debugging for the provided + * version of Firefox. Backward compatibility policy for devtools supports at most 2 + * versions older than the current version. + * + * @param {String} localVersion + * The version of the local Firefox instance, eg "67.0" + * @return {Object} + * - minVersion {String} the minimum supported version, eg "65.0a1" + * - maxVersion {String} the first unsupported version, eg "68.0a1" + */ +function computeMinMaxVersion(localVersion) { + // Retrieve the major platform version, i.e. if we are on Firefox 64.0a1, it will be 64. + const localMajorVersion = getMajorVersion(localVersion); + + return { + // Define the minimum officially supported version of Firefox when connecting to a + // remote runtime. (Use ".0a1" to support the very first nightly version) + // This matches the release channel's version when we are on nightly, + // or 2 versions before when we are on other channels. + minVersion: localMajorVersion - 2 + ".0a1", + // The maximum version is the first excluded from the support range. That's why we + // increase the current version by 1 and use ".0a1" to point to the first Nightly. + // We do not support forward compatibility at all. + maxVersion: localMajorVersion + 1 + ".0a1", + }; +} + +/** + * Tells if the remote device is using a supported version of Firefox. + * + * @param {DevToolsClient} devToolsClient + * DevToolsClient instance connected to the target remote Firefox. + * @return Object with the following attributes: + * * String status, one of COMPATIBILITY_STATUS + * COMPATIBLE if the runtime is compatible, + * TOO_RECENT if the runtime uses a too recent version, + * TOO_OLD if the runtime uses a too old version. + * * String minVersion + * The minimum supported version. + * * String runtimeVersion + * The remote runtime version. + * * String localID + * Build ID of local runtime. A date with like this: YYYYMMDD. + * * String deviceID + * Build ID of remote runtime. A date with like this: YYYYMMDD. + */ +async function checkVersionCompatibility(devToolsClient) { + const localDescription = { + appbuildid: Services.appinfo.appBuildID, + platformversion: AppConstants.MOZ_APP_VERSION, + }; + + try { + const deviceFront = await devToolsClient.mainRoot.getFront("device"); + const description = await deviceFront.getDescription(); + return _compareVersionCompatibility(localDescription, description); + } catch (e) { + // If we failed to retrieve the device description, assume we are trying to connect to + // a really old version of Firefox. + const localVersion = localDescription.platformversion; + const { minVersion } = computeMinMaxVersion(localVersion); + return { + minVersion, + runtimeVersion: "<55", + status: COMPATIBILITY_STATUS.TOO_OLD, + }; + } +} +exports.checkVersionCompatibility = checkVersionCompatibility; + +function _compareVersionCompatibility(localDescription, deviceDescription) { + const runtimeID = deviceDescription.appbuildid.substr(0, 8); + const localID = localDescription.appbuildid.substr(0, 8); + + const runtimeDate = getDateFromBuildID(runtimeID); + const localDate = getDateFromBuildID(localID); + + const runtimeVersion = deviceDescription.platformversion; + const localVersion = localDescription.platformversion; + + const { minVersion, maxVersion } = computeMinMaxVersion(localVersion); + const isTooOld = Services.vc.compare(runtimeVersion, minVersion) < 0; + const isTooRecent = Services.vc.compare(runtimeVersion, maxVersion) >= 0; + + const runtimeMajorVersion = getMajorVersion(runtimeVersion); + const localMajorVersion = getMajorVersion(localVersion); + const isSameMajorVersion = runtimeMajorVersion === localMajorVersion; + + let status; + if (isTooOld) { + if (runtimeMajorVersion === 68 && deviceDescription.os === "Android") { + status = COMPATIBILITY_STATUS.TOO_OLD_FENNEC; + } else { + status = COMPATIBILITY_STATUS.TOO_OLD; + } + } else if (isTooRecent) { + status = COMPATIBILITY_STATUS.TOO_RECENT; + } else if (isSameMajorVersion && runtimeDate - localDate > 7 * MS_PER_DAY) { + // If both local and remote runtimes have the same major version, compare build dates. + // This check is useful for Gecko developers as we might introduce breaking changes + // within a Nightly cycle. + // Still allow devices to be newer by up to a week. This accommodates those with local + // device builds, since their devices will almost always be newer than the client. + status = COMPATIBILITY_STATUS.TOO_RECENT; + } else { + status = COMPATIBILITY_STATUS.COMPATIBLE; + } + + return { + localID, + localVersion, + minVersion, + runtimeID, + runtimeVersion, + status, + }; +} +// Exported for tests. +exports._compareVersionCompatibility = _compareVersionCompatibility; |