summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/emulation
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/emulation')
-rw-r--r--devtools/server/actors/emulation/moz.build10
-rw-r--r--devtools/server/actors/emulation/responsive.js83
-rw-r--r--devtools/server/actors/emulation/touch-simulator.js309
3 files changed, 402 insertions, 0 deletions
diff --git a/devtools/server/actors/emulation/moz.build b/devtools/server/actors/emulation/moz.build
new file mode 100644
index 0000000000..cf229e6fe1
--- /dev/null
+++ b/devtools/server/actors/emulation/moz.build
@@ -0,0 +1,10 @@
+# -*- 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/.
+
+DevToolsModules(
+ "responsive.js",
+ "touch-simulator.js",
+)
diff --git a/devtools/server/actors/emulation/responsive.js b/devtools/server/actors/emulation/responsive.js
new file mode 100644
index 0000000000..829579cab6
--- /dev/null
+++ b/devtools/server/actors/emulation/responsive.js
@@ -0,0 +1,83 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ responsiveSpec,
+} = require("resource://devtools/shared/specs/responsive.js");
+
+/**
+ * This actor overrides various browser features to simulate different environments to
+ * test how pages perform under various conditions.
+ *
+ * The design below, which saves the previous value of each property before setting, is
+ * needed because it's possible to have multiple copies of this actor for a single page.
+ * When some instance of this actor changes a property, we want it to be able to restore
+ * that property to the way it was found before the change.
+ *
+ * A subtle aspect of the code below is that all get* methods must return non-undefined
+ * values, so that the absence of a previous value can be distinguished from the value for
+ * "no override" for each of the properties.
+ */
+class ResponsiveActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, responsiveSpec);
+ this.targetActor = targetActor;
+ this.docShell = targetActor.docShell;
+ }
+
+ destroy() {
+ this.targetActor = null;
+ this.docShell = null;
+
+ super.destroy();
+ }
+
+ get win() {
+ return this.docShell.chromeEventHandler.ownerGlobal;
+ }
+
+ /* Touch events override */
+
+ _previousTouchEventsOverride = undefined;
+
+ /**
+ * Set the current element picker state.
+ *
+ * True means the element picker is currently active and we should not be emulating
+ * touch events.
+ * False means the element picker is not active and it is ok to emulate touch events.
+ *
+ * This actor method is meant to be called by the DevTools front-end. The reason for
+ * this is the following:
+ * RDM is the only current consumer of the touch simulator. RDM instantiates this actor
+ * on its own, whether or not the Toolbox is opened. That means it does so in its own
+ * DevTools Server instance.
+ * When the Toolbox is running, it uses a different DevToolsServer. Therefore, it is not
+ * possible for the touch simulator to know whether the picker is active or not. This
+ * state has to be sent by the client code of the Toolbox to this actor.
+ * If a future use case arises where we want to use the touch simulator from the Toolbox
+ * too, then we could add code in here to detect the picker mode as described in
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1409085#c3
+
+ * @param {Boolean} state
+ * @param {String} pickerType
+ */
+ setElementPickerState(state, pickerType) {
+ this.targetActor.touchSimulator.setElementPickerState(state, pickerType);
+ }
+
+ /**
+ * Dispatches an "orientationchange" event.
+ */
+ async dispatchOrientationChangeEvent() {
+ const { CustomEvent } = this.win;
+ const orientationChangeEvent = new CustomEvent("orientationchange");
+ this.win.dispatchEvent(orientationChangeEvent);
+ }
+}
+
+exports.ResponsiveActor = ResponsiveActor;
diff --git a/devtools/server/actors/emulation/touch-simulator.js b/devtools/server/actors/emulation/touch-simulator.js
new file mode 100644
index 0000000000..4d4b6b4c6e
--- /dev/null
+++ b/devtools/server/actors/emulation/touch-simulator.js
@@ -0,0 +1,309 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "PICKER_TYPES",
+ "resource://devtools/shared/picker-constants.js"
+);
+
+var isClickHoldEnabled = Services.prefs.getBoolPref(
+ "ui.click_hold_context_menus"
+);
+var clickHoldDelay = Services.prefs.getIntPref(
+ "ui.click_hold_context_menus.delay",
+ 500
+);
+
+// Touch state constants are derived from values defined in: nsIDOMWindowUtils.idl
+const TOUCH_CONTACT = 0x02;
+const TOUCH_REMOVE = 0x04;
+
+const TOUCH_STATES = {
+ touchstart: TOUCH_CONTACT,
+ touchmove: TOUCH_CONTACT,
+ touchend: TOUCH_REMOVE,
+};
+
+const EVENTS_TO_HANDLE = [
+ "mousedown",
+ "mousemove",
+ "mouseup",
+ "touchstart",
+ "touchend",
+ "mouseenter",
+ "mouseover",
+ "mouseout",
+ "mouseleave",
+];
+
+const kStateHover = 0x00000004; // ElementState::HOVER
+
+/**
+ * Simulate touch events for platforms where they aren't generally available.
+ */
+class TouchSimulator {
+ /**
+ * @param {ChromeEventHandler} simulatorTarget: The object we'll use to listen for click
+ * and touch events to handle.
+ */
+ constructor(simulatorTarget) {
+ this.simulatorTarget = simulatorTarget;
+ this._currentPickerMap = new Map();
+ }
+
+ enabled = false;
+
+ start() {
+ if (this.enabled) {
+ // Simulator is already started
+ return;
+ }
+
+ EVENTS_TO_HANDLE.forEach(evt => {
+ // Only listen trusted events to prevent messing with
+ // event dispatched manually within content documents
+ this.simulatorTarget.addEventListener(evt, this, true, false);
+ });
+
+ this.enabled = true;
+ }
+
+ stop() {
+ if (!this.enabled) {
+ // Simulator isn't running
+ return;
+ }
+ EVENTS_TO_HANDLE.forEach(evt => {
+ this.simulatorTarget.removeEventListener(evt, this, true);
+ });
+ this.enabled = false;
+ }
+
+ _isPicking() {
+ const types = Object.values(PICKER_TYPES);
+ return types.some(type => this._currentPickerMap.get(type));
+ }
+
+ /**
+ * Set the state value for one of DevTools pickers (either eyedropper or
+ * element picker).
+ * If any content picker is currently active, we should not be emulating
+ * touch events. Otherwise it is ok to emulate touch events.
+ * In theory only one picker can ever be active at a time, but tracking the
+ * different pickers independantly avoids race issues in the client code.
+ *
+ * @param {Boolean} state
+ * True if the picker is currently active, false otherwise.
+ * @param {String} pickerType
+ * One of PICKER_TYPES.
+ */
+ setElementPickerState(state, pickerType) {
+ if (!Object.values(PICKER_TYPES).includes(pickerType)) {
+ throw new Error(
+ "Unsupported type in setElementPickerState: " + pickerType
+ );
+ }
+ this._currentPickerMap.set(pickerType, state);
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(evt) {
+ // Bail out if devtools is in pick mode in the same tab.
+ if (this._isPicking()) {
+ return;
+ }
+
+ const content = this.getContent(evt.target);
+ if (!content) {
+ return;
+ }
+
+ // App touchstart & touchend should also be dispatched on the system app
+ // to match on-device behavior.
+ if (evt.type.startsWith("touch")) {
+ const sysFrame = content.realFrameElement;
+ if (!sysFrame) {
+ return;
+ }
+ const sysDocument = sysFrame.ownerDocument;
+ const sysWindow = sysDocument.defaultView;
+
+ const touchEvent = sysDocument.createEvent("touchevent");
+ const touch = evt.touches[0] || evt.changedTouches[0];
+ const point = sysDocument.createTouch(
+ sysWindow,
+ sysFrame,
+ 0,
+ touch.pageX,
+ touch.pageY,
+ touch.screenX,
+ touch.screenY,
+ touch.clientX,
+ touch.clientY,
+ 1,
+ 1,
+ 0,
+ 0
+ );
+
+ const touches = sysDocument.createTouchList(point);
+ const targetTouches = touches;
+ const changedTouches = touches;
+ touchEvent.initTouchEvent(
+ evt.type,
+ true,
+ true,
+ sysWindow,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ touches,
+ targetTouches,
+ changedTouches
+ );
+ sysFrame.dispatchEvent(touchEvent);
+ return;
+ }
+
+ // Ignore all but real mouse event coming from physical mouse
+ // (especially ignore mouse event being dispatched from a touch event)
+ if (
+ evt.button ||
+ evt.inputSource != evt.MOZ_SOURCE_MOUSE ||
+ evt.isSynthesized
+ ) {
+ return;
+ }
+
+ const eventTarget = this.target;
+ let type = "";
+ switch (evt.type) {
+ case "mouseenter":
+ case "mouseover":
+ case "mouseout":
+ case "mouseleave":
+ // Don't propagate events which are not related to touch events
+ evt.stopPropagation();
+ evt.preventDefault();
+
+ // We don't want to trigger any visual changes to elements whose content can
+ // be modified via hover states. We can avoid this by removing the element's
+ // content state.
+ InspectorUtils.removeContentState(evt.target, kStateHover);
+ break;
+
+ case "mousedown":
+ this.target = evt.target;
+
+ // If the click-hold feature is enabled, start a timeout to convert long clicks
+ // into contextmenu events.
+ // Just don't do it if the event occurred on a scrollbar.
+ if (isClickHoldEnabled && !evt.originalTarget.closest("scrollbar")) {
+ this._contextMenuTimeout = this.sendContextMenu(evt);
+ }
+
+ this.startX = evt.pageX;
+ this.startY = evt.pageY;
+
+ // Capture events so if a different window show up the events
+ // won't be dispatched to something else.
+ evt.target.setCapture(false);
+
+ type = "touchstart";
+ break;
+
+ case "mousemove":
+ if (!eventTarget) {
+ // Don't propagate mousemove event when touchstart event isn't fired
+ evt.stopPropagation();
+ return;
+ }
+
+ type = "touchmove";
+ break;
+
+ case "mouseup":
+ if (!eventTarget) {
+ return;
+ }
+ this.target = null;
+
+ content.clearTimeout(this._contextMenuTimeout);
+ type = "touchend";
+
+ // Only register click listener after mouseup to ensure
+ // catching only real user click. (Especially ignore click
+ // being dispatched on form submit)
+ if (evt.detail == 1) {
+ this.simulatorTarget.addEventListener("click", this, {
+ capture: true,
+ once: true,
+ });
+ }
+ break;
+ }
+
+ const target = eventTarget || this.target;
+ if (target && type) {
+ this.synthesizeNativeTouch(content, evt.screenX, evt.screenY, type);
+ }
+
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ }
+
+ sendContextMenu({ target, clientX, clientY, screenX, screenY }) {
+ const view = target.ownerGlobal;
+ const { MouseEvent } = view;
+ const evt = new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ view,
+ screenX,
+ screenY,
+ clientX,
+ clientY,
+ });
+ const content = this.getContent(target);
+ const timeout = content.setTimeout(() => {
+ target.dispatchEvent(evt);
+ }, clickHoldDelay);
+
+ return timeout;
+ }
+
+ /**
+ * Synthesizes a native touch action on a given target element.
+ *
+ * @param {Window} win
+ * The target window.
+ * @param {Number} screenX
+ * The `x` screen coordinate relative to the screen origin.
+ * @param {Number} screenY
+ * The `y` screen coordinate relative to the screen origin.
+ * @param {String} type
+ * A key appearing in the TOUCH_STATES associative array.
+ */
+ synthesizeNativeTouch(win, screenX, screenY, type) {
+ // Native events work in device pixels.
+ const utils = win.windowUtils;
+ const deviceScale = win.devicePixelRatio;
+ const pt = { x: screenX * deviceScale, y: screenY * deviceScale };
+
+ utils.sendNativeTouchPoint(0, TOUCH_STATES[type], pt.x, pt.y, 1, 90, null);
+ return true;
+ }
+
+ getContent(target) {
+ const win = target?.ownerDocument ? target.ownerGlobal : null;
+ return win;
+ }
+}
+
+exports.TouchSimulator = TouchSimulator;