summaryrefslogtreecommitdiffstats
path: root/devtools/client/responsive/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/responsive/ui.js')
-rw-r--r--devtools/client/responsive/ui.js1075
1 files changed, 1075 insertions, 0 deletions
diff --git a/devtools/client/responsive/ui.js b/devtools/client/responsive/ui.js
new file mode 100644
index 0000000000..9e8b24973d
--- /dev/null
+++ b/devtools/client/responsive/ui.js
@@ -0,0 +1,1075 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getOrientation,
+} = require("resource://devtools/client/responsive/utils/orientation.js");
+const Constants = require("resource://devtools/client/responsive/constants.js");
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+
+loader.lazyRequireGetter(
+ this,
+ "throttlingProfiles",
+ "resource://devtools/client/shared/components/throttling/profiles.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "message",
+ "resource://devtools/client/responsive/utils/message.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "showNotification",
+ "resource://devtools/client/responsive/utils/notification.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PriorityLevels",
+ "resource://devtools/client/shared/components/NotificationBox.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "l10n",
+ "resource://devtools/client/responsive/utils/l10n.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "asyncStorage",
+ "resource://devtools/shared/async-storage.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "captureAndSaveScreenshot",
+ "resource://devtools/client/shared/screenshot.js",
+ true
+);
+
+const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions.";
+const RELOAD_NOTIFICATION_PREF =
+ "devtools.responsive.reloadNotification.enabled";
+
+function debug(msg) {
+ // console.log(`RDM manager: ${msg}`);
+}
+
+/**
+ * ResponsiveUI manages the responsive design tool for a specific tab. The
+ * actual tool itself lives in a separate chrome:// document that is loaded into
+ * the tab upon opening responsive design. This object acts a helper to
+ * integrate the tool into the surrounding browser UI as needed.
+ */
+class ResponsiveUI {
+ /**
+ * @param {ResponsiveUIManager} manager
+ * The ResponsiveUIManager instance.
+ * @param {ChromeWindow} window
+ * The main browser chrome window (that holds many tabs).
+ * @param {Tab} tab
+ * The specific browser <tab> element this responsive instance is for.
+ */
+ constructor(manager, window, tab) {
+ this.manager = manager;
+ // The main browser chrome window (that holds many tabs).
+ this.browserWindow = window;
+ // The specific browser tab this responsive instance is for.
+ this.tab = tab;
+
+ // Flag set when destruction has begun.
+ this.destroying = false;
+ // Flag set when destruction has ended.
+ this.destroyed = false;
+ // The iframe containing the RDM UI.
+ this.rdmFrame = null;
+
+ // Bind callbacks for resizers.
+ this.onResizeDrag = this.onResizeDrag.bind(this);
+ this.onResizeStart = this.onResizeStart.bind(this);
+ this.onResizeStop = this.onResizeStop.bind(this);
+
+ this.onTargetAvailable = this.onTargetAvailable.bind(this);
+
+ this.networkFront = null;
+ // Promise resovled when the UI init has completed.
+ this.inited = this.init();
+
+ EventEmitter.decorate(this);
+ }
+
+ get toolWindow() {
+ return this.rdmFrame.contentWindow;
+ }
+
+ get docShell() {
+ return this.toolWindow.docShell;
+ }
+
+ get viewportElement() {
+ return this.browserStackEl.querySelector("browser");
+ }
+
+ get currentTarget() {
+ return this.commands.targetCommand.targetFront;
+ }
+
+ get watcherFront() {
+ return this.resourceCommand.watcherFront;
+ }
+
+ /**
+ * Open RDM while preserving the state of the page.
+ */
+ async init() {
+ debug("Init start");
+
+ this.initRDMFrame();
+
+ // Hide the browser content temporarily while things move around to avoid displaying
+ // strange intermediate states.
+ this.hideBrowserUI();
+
+ // Watch for tab close and window close so we can clean up RDM synchronously
+ this.tab.addEventListener("TabClose", this);
+ this.browserWindow.addEventListener("unload", this);
+ this.rdmFrame.contentWindow.addEventListener("message", this);
+
+ this.tab.linkedBrowser.enterResponsiveMode();
+
+ // Listen to FullZoomChange events coming from the browser window,
+ // so that we can zoom the size of the viewport by the same amount.
+ this.browserWindow.addEventListener("FullZoomChange", this);
+
+ // Get the protocol ready to speak with responsive emulation actor
+ debug("Wait until RDP server connect");
+ await this.connectToServer();
+
+ // Restore the previous UI state.
+ await this.restoreUIState();
+
+ // Show the browser UI now that its state is ready.
+ this.showBrowserUI();
+
+ // Non-blocking message to tool UI to start any delayed init activities
+ message.post(this.toolWindow, "post-init");
+
+ debug("Init done");
+ }
+
+ /**
+ * Initialize the RDM iframe inside of the browser document.
+ */
+ initRDMFrame() {
+ const { document: doc, gBrowser } = this.browserWindow;
+ const rdmFrame = doc.createElement("iframe");
+ rdmFrame.src = "chrome://devtools/content/responsive/toolbar.xhtml";
+ rdmFrame.classList.add("rdm-toolbar");
+
+ // Create resizer handlers
+ const resizeHandle = doc.createElement("div");
+ resizeHandle.classList.add(
+ "rdm-viewport-resize-handle",
+ "viewport-resize-handle"
+ );
+ const resizeHandleX = doc.createElement("div");
+ resizeHandleX.classList.add(
+ "rdm-viewport-resize-handle",
+ "viewport-horizontal-resize-handle"
+ );
+ const resizeHandleY = doc.createElement("div");
+ resizeHandleY.classList.add(
+ "rdm-viewport-resize-handle",
+ "viewport-vertical-resize-handle"
+ );
+
+ this.browserContainerEl = gBrowser.getBrowserContainer(
+ gBrowser.getBrowserForTab(this.tab)
+ );
+ this.browserStackEl =
+ this.browserContainerEl.querySelector(".browserStack");
+
+ this.browserContainerEl.classList.add("responsive-mode");
+
+ // Prepend the RDM iframe inside of the current tab's browser container.
+ this.browserContainerEl.prepend(rdmFrame);
+
+ this.browserStackEl.append(resizeHandle);
+ this.browserStackEl.append(resizeHandleX);
+ this.browserStackEl.append(resizeHandleY);
+
+ // Wait for the frame script to be loaded.
+ message.wait(rdmFrame.contentWindow, "script-init").then(async () => {
+ // Notify the frame window that the Resposnive UI manager has begun initializing.
+ // At this point, we can render our React content inside the frame.
+ message.post(rdmFrame.contentWindow, "init");
+ // Wait for the tools to be rendered above the content. The frame script will
+ // then dispatch the necessary actions to the Redux store to give the toolbar the
+ // state it needs.
+ message.wait(rdmFrame.contentWindow, "init:done").then(() => {
+ rdmFrame.contentWindow.addInitialViewport({
+ userContextId: this.tab.userContextId,
+ });
+ });
+ });
+
+ this.rdmFrame = rdmFrame;
+
+ this.resizeHandle = resizeHandle;
+ this.resizeHandle.addEventListener("mousedown", this.onResizeStart);
+
+ this.resizeHandleX = resizeHandleX;
+ this.resizeHandleX.addEventListener("mousedown", this.onResizeStart);
+
+ this.resizeHandleY = resizeHandleY;
+ this.resizeHandleY.addEventListener("mousedown", this.onResizeStart);
+
+ this.resizeToolbarObserver = new this.browserWindow.ResizeObserver(
+ entries => {
+ for (const entry of entries) {
+ // If the toolbar needs extra space for the UA input, then set a class
+ // that will accomodate its height. We should also make sure to keep
+ // the width value we're toggling against in sync with the media-query
+ // in devtools/client/responsive/index.css
+ this.rdmFrame.classList.toggle(
+ "accomodate-ua",
+ entry.contentBoxSize[0].inlineSize < 520
+ );
+ }
+ }
+ );
+
+ this.resizeToolbarObserver.observe(this.browserStackEl);
+ }
+
+ /**
+ * Close RDM and restore page content back into a regular tab.
+ *
+ * @param object
+ * Destroy options, which currently includes a `reason` string.
+ * @return boolean
+ * Whether this call is actually destroying. False means destruction
+ * was already in progress.
+ */
+ async destroy(options) {
+ if (this.destroying) {
+ return false;
+ }
+ this.destroying = true;
+
+ // If our tab is about to be closed, there's not enough time to exit
+ // gracefully, but that shouldn't be a problem since the tab will go away.
+ // So, skip any waiting when we're about to close the tab.
+ const isTabDestroyed =
+ !this.tab.linkedBrowser || this.responsiveFront.isDestroyed();
+ const isWindowClosing = options?.reason === "unload" || isTabDestroyed;
+ const isTabContentDestroying =
+ isWindowClosing || options?.reason === "TabClose";
+
+ // Ensure init has finished before starting destroy
+ if (!isTabContentDestroying) {
+ await this.inited;
+
+ // Restore screen orientation of physical device.
+ await Promise.all([
+ this.updateScreenOrientation("landscape-primary", 0),
+ this.updateMaxTouchPointsEnabled(false),
+ ]);
+
+ // Hide browser UI to avoid displaying weird intermediate states while closing.
+ this.hideBrowserUI();
+
+ // Resseting the throtting needs to be done before the
+ // network events watching is stopped.
+ await this.updateNetworkThrottling();
+ }
+
+ this.tab.removeEventListener("TabClose", this);
+ this.browserWindow.removeEventListener("unload", this);
+ this.tab.linkedBrowser.leaveResponsiveMode();
+
+ this.browserWindow.removeEventListener("FullZoomChange", this);
+ this.rdmFrame.contentWindow.removeEventListener("message", this);
+
+ // Remove observers on the stack.
+ this.resizeToolbarObserver.unobserve(this.browserStackEl);
+
+ // Cleanup the frame content before disconnecting the frame element.
+ this.rdmFrame.contentWindow.destroy();
+
+ this.rdmFrame.remove();
+
+ // Clean up resize handlers
+ this.resizeHandle.remove();
+ this.resizeHandleX.remove();
+ this.resizeHandleY.remove();
+
+ this.browserContainerEl.classList.remove("responsive-mode");
+ this.browserStackEl.style.removeProperty("--rdm-width");
+ this.browserStackEl.style.removeProperty("--rdm-height");
+ this.browserStackEl.style.removeProperty("--rdm-zoom");
+
+ // Ensure the tab is reloaded if required when exiting RDM so that no emulated
+ // settings are left in a customized state.
+ if (!isTabContentDestroying) {
+ let reloadNeeded = false;
+ await this.updateDPPX(null);
+ reloadNeeded |=
+ (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
+
+ // Don't reload on the server if we're already doing a reload on the client
+ const reloadOnTouchSimulationChange =
+ this.reloadOnChange("touchSimulation") && !reloadNeeded;
+ await this.updateTouchSimulation(null, reloadOnTouchSimulationChange);
+ if (reloadNeeded) {
+ await this.reloadBrowser();
+ }
+
+ // Unwatch targets & resources as the last step. If we are not waching for
+ // any resource & target anymore, the JSWindowActors will be unregistered
+ // which will trigger an early destruction of the RDM target, before we
+ // could finalize the cleanup.
+ this.commands.targetCommand.unwatchTargets({
+ types: [this.commands.targetCommand.TYPES.FRAME],
+ onAvailable: this.onTargetAvailable,
+ });
+
+ this.resourceCommand.unwatchResources(
+ [this.resourceCommand.TYPES.NETWORK_EVENT],
+ { onAvailable: this.onNetworkResourceAvailable }
+ );
+
+ this.commands.targetCommand.destroy();
+ }
+
+ // Show the browser UI now.
+ this.showBrowserUI();
+
+ // Destroy local state
+ this.browserContainerEl = null;
+ this.browserStackEl = null;
+ this.browserWindow = null;
+ this.tab = null;
+ this.inited = null;
+ this.rdmFrame = null;
+ this.resizeHandle = null;
+ this.resizeHandleX = null;
+ this.resizeHandleY = null;
+ this.resizeToolbarObserver = null;
+
+ // Destroying the commands will close the devtools client used to speak with responsive emulation actor.
+ // The actor handles clearing any overrides itself, so it's not necessary to clear
+ // anything on shutdown client side.
+ const commandsDestroyed = this.commands.destroy();
+ if (!isTabContentDestroying) {
+ await commandsDestroyed;
+ }
+ this.commands = this.responsiveFront = null;
+ this.destroyed = true;
+
+ return true;
+ }
+
+ async connectToServer() {
+ this.commands = await CommandsFactory.forTab(this.tab);
+ this.resourceCommand = this.commands.resourceCommand;
+
+ await this.commands.targetCommand.startListening();
+
+ await this.commands.targetCommand.watchTargets({
+ types: [this.commands.targetCommand.TYPES.FRAME],
+ onAvailable: this.onTargetAvailable,
+ });
+
+ // To support network throttling the resource command
+ // needs to be watching for network resources.
+ await this.resourceCommand.watchResources(
+ [this.resourceCommand.TYPES.NETWORK_EVENT],
+ { onAvailable: this.onNetworkResourceAvailable }
+ );
+
+ this.networkFront = await this.watcherFront.getNetworkParentActor();
+ }
+
+ /**
+ * Show one-time notification about reloads for responsive emulation.
+ */
+ showReloadNotification() {
+ if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) {
+ showNotification(this.browserWindow, this.tab, {
+ msg: l10n.getFormatStr("responsive.reloadNotification.description2"),
+ });
+ Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false);
+ }
+ }
+
+ reloadOnChange(id) {
+ this.showReloadNotification();
+ const pref = RELOAD_CONDITION_PREF_PREFIX + id;
+ return Services.prefs.getBoolPref(pref, false);
+ }
+
+ hideBrowserUI() {
+ this.tab.linkedBrowser.style.visibility = "hidden";
+ this.resizeHandle.style.visibility = "hidden";
+ }
+
+ showBrowserUI() {
+ this.tab.linkedBrowser.style.removeProperty("visibility");
+ this.resizeHandle.style.removeProperty("visibility");
+ }
+
+ handleEvent(event) {
+ const { browserWindow, tab } = this;
+
+ switch (event.type) {
+ case "message":
+ this.handleMessage(event);
+ break;
+ case "FullZoomChange":
+ // Get the current device size and update to that size, which
+ // will pick up changes to the zoom.
+ const { width, height } = this.getViewportSize();
+ this.updateViewportSize(width, height);
+ break;
+ case "TabClose":
+ case "unload":
+ this.manager.closeIfNeeded(browserWindow, tab, {
+ reason: event.type,
+ });
+ break;
+ }
+ }
+
+ handleMessage(event) {
+ if (event.origin !== "chrome://devtools") {
+ return;
+ }
+
+ switch (event.data.type) {
+ case "change-device":
+ this.onChangeDevice(event);
+ break;
+ case "change-network-throttling":
+ this.onChangeNetworkThrottling(event);
+ break;
+ case "change-pixel-ratio":
+ this.onChangePixelRatio(event);
+ break;
+ case "change-touch-simulation":
+ this.onChangeTouchSimulation(event);
+ break;
+ case "change-user-agent":
+ this.onChangeUserAgent(event);
+ break;
+ case "exit":
+ this.onExit();
+ break;
+ case "remove-device-association":
+ this.onRemoveDeviceAssociation();
+ break;
+ case "viewport-orientation-change":
+ this.onRotateViewport(event);
+ break;
+ case "viewport-resize":
+ this.onResizeViewport(event);
+ break;
+ case "screenshot":
+ this.onScreenshot();
+ break;
+ case "toggle-left-alignment":
+ this.onToggleLeftAlignment(event);
+ break;
+ case "update-device-modal":
+ this.onUpdateDeviceModal(event);
+ break;
+ }
+ }
+
+ async onChangeDevice(event) {
+ const { pixelRatio, touch, userAgent } = event.data.device;
+ let reloadNeeded = false;
+ await this.updateDPPX(pixelRatio);
+
+ // Get the orientation values of the device we are changing to and update.
+ const { device, viewport } = event.data;
+ const { type, angle } = getOrientation(device, viewport);
+ await this.updateScreenOrientation(type, angle);
+ await this.updateMaxTouchPointsEnabled(touch);
+
+ reloadNeeded |=
+ (await this.updateUserAgent(userAgent)) &&
+ this.reloadOnChange("userAgent");
+
+ // Don't reload on the server if we're already doing a reload on the client
+ const reloadOnTouchSimulationChange =
+ this.reloadOnChange("touchSimulation") && !reloadNeeded;
+ await this.updateTouchSimulation(touch, reloadOnTouchSimulationChange);
+
+ if (reloadNeeded) {
+ this.reloadBrowser();
+ }
+
+ // Used by tests
+ this.emitForTests("device-changed", {
+ reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange,
+ });
+ }
+
+ async onChangeNetworkThrottling(event) {
+ const { enabled, profile } = event.data;
+ await this.updateNetworkThrottling(enabled, profile);
+ // Used by tests
+ this.emit("network-throttling-changed");
+ }
+
+ onChangePixelRatio(event) {
+ const { pixelRatio } = event.data;
+ this.updateDPPX(pixelRatio);
+ }
+
+ async onChangeTouchSimulation(event) {
+ const { enabled } = event.data;
+
+ await this.updateMaxTouchPointsEnabled(enabled);
+
+ await this.updateTouchSimulation(
+ enabled,
+ this.reloadOnChange("touchSimulation")
+ );
+
+ // Used by tests
+ this.emit("touch-simulation-changed");
+ }
+
+ async onChangeUserAgent(event) {
+ const { userAgent } = event.data;
+ const reloadNeeded =
+ (await this.updateUserAgent(userAgent)) &&
+ this.reloadOnChange("userAgent");
+ if (reloadNeeded) {
+ this.reloadBrowser();
+ }
+ this.emit("user-agent-changed");
+ }
+
+ onExit() {
+ const { browserWindow, tab } = this;
+ this.manager.closeIfNeeded(browserWindow, tab);
+ }
+
+ async onRemoveDeviceAssociation() {
+ let reloadNeeded = false;
+ await this.updateDPPX(null);
+ reloadNeeded |=
+ (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
+
+ // Don't reload on the server if we're already doing a reload on the client
+ const reloadOnTouchSimulationChange =
+ this.reloadOnChange("touchSimulation") && !reloadNeeded;
+ await this.updateTouchSimulation(null, reloadOnTouchSimulationChange);
+ if (reloadNeeded) {
+ this.reloadBrowser();
+ }
+ // Used by tests
+ this.emitForTests("device-association-removed", {
+ reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange,
+ });
+ }
+
+ /**
+ * Resizing the browser on mousemove
+ */
+ onResizeDrag({ screenX, screenY }) {
+ if (!this.isResizing || !this.rdmFrame.contentWindow) {
+ return;
+ }
+
+ const zoom = this.tab.linkedBrowser.fullZoom;
+
+ let deltaX = (screenX - this.lastScreenX) / zoom;
+ let deltaY = (screenY - this.lastScreenY) / zoom;
+
+ const leftAlignmentEnabled = Services.prefs.getBoolPref(
+ "devtools.responsive.leftAlignViewport.enabled",
+ false
+ );
+
+ if (!leftAlignmentEnabled) {
+ // The viewport is centered horizontally, so horizontal resize resizes
+ // by twice the distance the mouse was dragged - on left and right side.
+ deltaX = deltaX * 2;
+ }
+
+ if (this.ignoreX) {
+ deltaX = 0;
+ }
+ if (this.ignoreY) {
+ deltaY = 0;
+ }
+
+ const viewportSize = this.getViewportSize();
+
+ let width = Math.round(viewportSize.width + deltaX);
+ let height = Math.round(viewportSize.height + deltaY);
+
+ if (width < Constants.MIN_VIEWPORT_DIMENSION) {
+ width = Constants.MIN_VIEWPORT_DIMENSION;
+ } else if (width != viewportSize.width) {
+ this.lastScreenX = screenX;
+ }
+
+ if (height < Constants.MIN_VIEWPORT_DIMENSION) {
+ height = Constants.MIN_VIEWPORT_DIMENSION;
+ } else if (height != viewportSize.height) {
+ this.lastScreenY = screenY;
+ }
+
+ // Update the RDM store and viewport size with the new width and height.
+ this.rdmFrame.contentWindow.setViewportSize({ width, height });
+ this.updateViewportSize(width, height);
+
+ // Change the device selector back to an unselected device
+ if (this.rdmFrame.contentWindow.getAssociatedDevice()) {
+ this.rdmFrame.contentWindow.clearDeviceAssociation();
+ }
+ }
+
+ /**
+ * Start the process of resizing the browser.
+ */
+ onResizeStart({ target, screenX, screenY }) {
+ this.browserWindow.addEventListener("mousemove", this.onResizeDrag, true);
+ this.browserWindow.addEventListener("mouseup", this.onResizeStop, true);
+
+ this.isResizing = true;
+ this.lastScreenX = screenX;
+ this.lastScreenY = screenY;
+ this.ignoreX = target === this.resizeHandleY;
+ this.ignoreY = target === this.resizeHandleX;
+ }
+
+ /**
+ * Stop the process of resizing the browser.
+ */
+ onResizeStop() {
+ this.browserWindow.removeEventListener(
+ "mousemove",
+ this.onResizeDrag,
+ true
+ );
+ this.browserWindow.removeEventListener("mouseup", this.onResizeStop, true);
+
+ this.isResizing = false;
+ this.lastScreenX = 0;
+ this.lastScreenY = 0;
+ this.ignoreX = false;
+ this.ignoreY = false;
+
+ // Used by tests.
+ this.emit("viewport-resize-dragend");
+ }
+
+ onResizeViewport(event) {
+ const { width, height } = event.data;
+ this.updateViewportSize(width, height);
+ this.emit("viewport-resize", {
+ width,
+ height,
+ });
+ }
+
+ async onRotateViewport(event) {
+ const { orientationType: type, angle, isViewportRotated } = event.data;
+ await this.updateScreenOrientation(type, angle, isViewportRotated);
+ }
+
+ async onScreenshot() {
+ const messages = await captureAndSaveScreenshot(
+ this.currentTarget,
+ this.browserWindow
+ );
+
+ const priorityMap = {
+ error: PriorityLevels.PRIORITY_CRITICAL_HIGH,
+ warn: PriorityLevels.PRIORITY_WARNING_HIGH,
+ };
+ for (const { text, level } of messages) {
+ // captureAndSaveScreenshot returns "saved" messages, that indicate where the
+ // screenshot was saved. We don't want to display them as the download UI can be
+ // used to open the file.
+ if (level !== "warn" && level !== "error") {
+ continue;
+ }
+
+ showNotification(this.browserWindow, this.tab, {
+ msg: text,
+ priority: priorityMap[level],
+ });
+ }
+
+ message.post(this.rdmFrame.contentWindow, "screenshot-captured");
+ }
+
+ onToggleLeftAlignment(event) {
+ this.updateUIAlignment(event.data.leftAlignmentEnabled);
+ }
+
+ onUpdateDeviceModal(event) {
+ this.rdmFrame.classList.toggle("device-modal-opened", event.data.isOpen);
+ }
+
+ async hasDeviceState() {
+ const deviceState = await asyncStorage.getItem(
+ "devtools.responsive.deviceState"
+ );
+ return !!deviceState;
+ }
+
+ /**
+ * Restores the previous UI state.
+ */
+ async restoreUIState() {
+ const leftAlignmentEnabled = Services.prefs.getBoolPref(
+ "devtools.responsive.leftAlignViewport.enabled",
+ false
+ );
+
+ this.updateUIAlignment(leftAlignmentEnabled);
+
+ const height = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.height",
+ 0
+ );
+ const width = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.width",
+ 0
+ );
+ this.updateViewportSize(width, height);
+ }
+
+ /**
+ * Restores the previous actor state.
+ *
+ * @param {Boolean} isTargetSwitching
+ */
+ async restoreActorState(isTargetSwitching) {
+ // It's possible the target will switch to a page loaded in the
+ // parent-process (i.e: about:robots). When this happens, the values set
+ // on the BrowsingContext by RDM are not preserved. So we need to call
+ // enterResponsiveMode whenever there is a target switch.
+ this.tab.linkedBrowser.enterResponsiveMode();
+
+ // If the target follows the window global lifecycle, the configuration was already
+ // restored from the server during target switch, so we can stop here.
+ // This function is still called at startup to restore potential state from previous
+ // RDM session so we only stop here during target switching.
+ if (
+ isTargetSwitching &&
+ this.commands.targetCommand.targetFront.targetForm
+ .followWindowGlobalLifeCycle
+ ) {
+ return;
+ }
+
+ const hasDeviceState = await this.hasDeviceState();
+ if (hasDeviceState) {
+ // Return if there is a device state to restore, this will be done when the
+ // device list is loaded after the post-init.
+ return;
+ }
+
+ const height = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.height",
+ 0
+ );
+ const pixelRatio = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.pixelRatio",
+ 0
+ );
+ const touchSimulationEnabled = Services.prefs.getBoolPref(
+ "devtools.responsive.touchSimulation.enabled",
+ false
+ );
+ const userAgent = Services.prefs.getCharPref(
+ "devtools.responsive.userAgent",
+ ""
+ );
+ const width = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.width",
+ 0
+ );
+
+ // Restore the previously set orientation, or get it from the initial viewport if it
+ // wasn't set yet.
+ const { type, angle } =
+ this.commands.targetConfigurationCommand.configuration
+ .rdmPaneOrientation ||
+ this.getInitialViewportOrientation({
+ width,
+ height,
+ });
+
+ await this.updateDPPX(pixelRatio);
+ await this.updateScreenOrientation(type, angle);
+ await this.updateMaxTouchPointsEnabled(touchSimulationEnabled);
+
+ if (touchSimulationEnabled) {
+ await this.updateTouchSimulation(touchSimulationEnabled);
+ }
+
+ let reloadNeeded = false;
+ if (userAgent) {
+ reloadNeeded |=
+ (await this.updateUserAgent(userAgent)) &&
+ this.reloadOnChange("userAgent");
+ }
+ if (reloadNeeded) {
+ await this.reloadBrowser();
+ }
+ }
+
+ /**
+ * Set or clear the emulated device pixel ratio.
+ *
+ * @param {Number|null} dppx: The ratio to simulate. Set to null to disable the
+ * simulation and roll back to the original ratio
+ */
+ async updateDPPX(dppx = null) {
+ await this.commands.targetConfigurationCommand.updateConfiguration({
+ overrideDPPX: dppx,
+ });
+ }
+
+ /**
+ * Set or clear network throttling.
+ *
+ * @return boolean
+ * Whether a reload is needed to apply the change.
+ * (This is always immediate, so it's always false.)
+ */
+ async updateNetworkThrottling(enabled, profile) {
+ if (!enabled) {
+ await this.networkFront.clearNetworkThrottling();
+ return false;
+ }
+ const data = throttlingProfiles.find(({ id }) => id == profile);
+ const { download, upload, latency } = data;
+ await this.networkFront.setNetworkThrottling({
+ downloadThroughput: download,
+ uploadThroughput: upload,
+ latency,
+ });
+ return false;
+ }
+
+ /**
+ * Set or clear the emulated user agent.
+ *
+ * @param {String|null} userAgent: The user agent to set on the page. Set to null to revert
+ * the user agent to its original value
+ * @return {Boolean} Whether a reload is needed to apply the change.
+ */
+ async updateUserAgent(userAgent) {
+ const getConfigurationCustomUserAgent = () =>
+ this.commands.targetConfigurationCommand.configuration.customUserAgent ||
+ "";
+ const previousCustomUserAgent = getConfigurationCustomUserAgent();
+ await this.commands.targetConfigurationCommand.updateConfiguration({
+ customUserAgent: userAgent,
+ });
+
+ const updatedUserAgent = getConfigurationCustomUserAgent();
+ return previousCustomUserAgent !== updatedUserAgent;
+ }
+
+ /**
+ * Set or clear touch simulation. When setting to true, this method will
+ * additionally set meta viewport override.
+ * When setting to false, this method will clear all touch simulation and meta viewport
+ * overrides, returning to default behavior for both settings.
+ *
+ * @param {boolean} enabled
+ * @param {boolean} reloadOnTouchSimulationToggle: Set to true to trigger a page reload
+ * if the touch simulation state changes.
+ */
+ async updateTouchSimulation(enabled, reloadOnTouchSimulationToggle) {
+ await this.commands.targetConfigurationCommand.updateConfiguration({
+ touchEventsOverride: enabled ? "enabled" : null,
+ reloadOnTouchSimulationToggle,
+ });
+ }
+
+ /**
+ * Sets the screen orientation values of the simulated device.
+ *
+ * @param {String} type
+ * The orientation type to update the current device screen to.
+ * @param {Number} angle
+ * The rotation angle to update the current device screen to.
+ * @param {Boolean} isViewportRotated
+ * Whether or not the reason for updating the screen orientation is a result
+ * of actually rotating the device via the RDM toolbar. If true, then an
+ * "orientationchange" event is simulated. Otherwise, the screen orientation is
+ * updated because of changing devices, opening RDM, or the page has been
+ * reloaded/navigated to, so we should not be simulating "orientationchange".
+ */
+ async updateScreenOrientation(type, angle, isViewportRotated = false) {
+ await this.commands.targetConfigurationCommand.simulateScreenOrientationChange(
+ {
+ type,
+ angle,
+ isViewportRotated,
+ }
+ );
+ }
+
+ /**
+ * Sets whether or not maximum touch points are supported for the simulated device.
+ *
+ * @param {Boolean} touchSimulationEnabled
+ * Whether or not touch is enabled for the simulated device.
+ */
+ async updateMaxTouchPointsEnabled(touchSimulationEnabled) {
+ return this.commands.targetConfigurationCommand.updateConfiguration({
+ rdmPaneMaxTouchPoints: touchSimulationEnabled ? 1 : 0,
+ });
+ }
+
+ /**
+ * Sets whether or not the RDM UI should be left-aligned.
+ *
+ * @param {Boolean} leftAlignmentEnabled
+ * Whether or not the UI is left-aligned.
+ */
+ updateUIAlignment(leftAlignmentEnabled) {
+ this.browserContainerEl.classList.toggle(
+ "left-aligned",
+ leftAlignmentEnabled
+ );
+ }
+
+ /**
+ * Sets the browser element to be the given width and height.
+ *
+ * @param {Number} width
+ * The viewport's width.
+ * @param {Number} height
+ * The viewport's height.
+ */
+ updateViewportSize(width, height) {
+ const zoom = this.tab.linkedBrowser.fullZoom;
+
+ // Setting this with a variable on the stack instead of directly as width/height
+ // on the <browser> because we'll need to use this for the alert dialog as well.
+ this.browserStackEl.style.setProperty("--rdm-width", `${width}px`);
+ this.browserStackEl.style.setProperty("--rdm-height", `${height}px`);
+ this.browserStackEl.style.setProperty("--rdm-zoom", zoom);
+
+ // This is a bit premature, but we emit a content-resize event here. It
+ // would be preferrable to wait until the viewport is actually resized,
+ // but the "resize" event is not triggered by this style change. The
+ // content-resize message is only used by tests, and if needed those tests
+ // can use the testing function setViewportSizeAndAwaitReflow to ensure
+ // the viewport has had time to reach this size.
+ this.emit("content-resize", {
+ width,
+ height,
+ });
+ }
+
+ /**
+ * Helper for tests. Assumes a single viewport for now.
+ */
+ getViewportSize() {
+ // The getViewportSize function is loaded in index.js, and might not be
+ // available yet.
+ if (this.toolWindow.getViewportSize) {
+ return this.toolWindow.getViewportSize();
+ }
+
+ return { width: 0, height: 0 };
+ }
+
+ /**
+ * Helper for tests, etc. Assumes a single viewport for now.
+ */
+ async setViewportSize(size) {
+ await this.inited;
+
+ // Ensure that width and height are valid.
+ let { width, height } = size;
+ if (!size.width) {
+ width = this.getViewportSize().width;
+ }
+
+ if (!size.height) {
+ height = this.getViewportSize().height;
+ }
+
+ this.rdmFrame.contentWindow.setViewportSize({ width, height });
+ this.updateViewportSize(width, height);
+ }
+
+ /**
+ * Helper for tests/reloading the viewport. Assumes a single viewport for now.
+ */
+ getViewportBrowser() {
+ return this.tab.linkedBrowser;
+ }
+
+ /**
+ * Helper for contacting the viewport content. Assumes a single viewport for now.
+ */
+ getViewportMessageManager() {
+ return this.getViewportBrowser().messageManager;
+ }
+
+ /**
+ * Helper for getting the initial viewport orientation.
+ */
+ getInitialViewportOrientation(viewport) {
+ return getOrientation(viewport, viewport);
+ }
+
+ /**
+ * Helper for tests to get the browser's window.
+ */
+ getBrowserWindow() {
+ return this.browserWindow;
+ }
+
+ async onTargetAvailable({ targetFront, isTargetSwitching }) {
+ if (this.destroying) {
+ return;
+ }
+
+ if (targetFront.isTopLevel) {
+ this.responsiveFront = await targetFront.getFront("responsive");
+
+ if (this.destroying) {
+ return;
+ }
+
+ await this.restoreActorState(isTargetSwitching);
+ this.emitForTests("responsive-ui-target-switch-done");
+ }
+ }
+ // This just needed to setup watching for network resources,
+ // to support network throttling.
+ onNetworkResourceAvailable() {}
+
+ /**
+ * Reload the current tab.
+ */
+ async reloadBrowser() {
+ await this.commands.targetCommand.reloadTopLevelTarget();
+ }
+}
+
+module.exports = ResponsiveUI;