diff options
Diffstat (limited to 'devtools/client/responsive.html/manager.js')
-rw-r--r-- | devtools/client/responsive.html/manager.js | 597 |
1 files changed, 597 insertions, 0 deletions
diff --git a/devtools/client/responsive.html/manager.js b/devtools/client/responsive.html/manager.js new file mode 100644 index 000000000..a3fbed366 --- /dev/null +++ b/devtools/client/responsive.html/manager.js @@ -0,0 +1,597 @@ +/* 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 { Ci } = require("chrome"); +const promise = require("promise"); +const { Task } = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { getOwnerWindow } = require("sdk/tabs/utils"); +const { startup } = require("sdk/window/helpers"); +const message = require("./utils/message"); +const { swapToInnerBrowser } = require("./browser/swap"); +const { EmulationFront } = require("devtools/shared/fronts/emulation"); +const { getStr } = require("./utils/l10n"); + +const TOOL_URL = "chrome://devtools/content/responsive.html/index.xhtml"; + +loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true); +loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true); +loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true); +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); +loader.lazyRequireGetter(this, "throttlingProfiles", + "devtools/client/shared/network-throttling-profiles"); + +/** + * ResponsiveUIManager is the external API for the browser UI, etc. to use when + * opening and closing the responsive UI. + * + * While the HTML UI is in an experimental stage, the older ResponsiveUIManager + * from devtools/client/responsivedesign/responsivedesign.jsm delegates to this + * object when the pref "devtools.responsive.html.enabled" is true. + */ +const ResponsiveUIManager = exports.ResponsiveUIManager = { + activeTabs: new Map(), + + /** + * Toggle the responsive UI for a tab. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with toggling. Currently includes: + * - `command`: Whether initiated via GCLI command bar or toolbox button + * @return Promise + * Resolved when the toggling has completed. If the UI has opened, + * it is resolved to the ResponsiveUI instance for this tab. If the + * the UI has closed, there is no resolution value. + */ + toggle(window, tab, options) { + let action = this.isActiveForTab(tab) ? "close" : "open"; + let completed = this[action + "IfNeeded"](window, tab, options); + completed.catch(console.error); + return completed; + }, + + /** + * Opens the responsive UI, if not already open. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with opening. Currently includes: + * - `command`: Whether initiated via GCLI command bar or toolbox button + * @return Promise + * Resolved to the ResponsiveUI instance for this tab when opening is + * complete. + */ + openIfNeeded: Task.async(function* (window, tab, options) { + if (!tab.linkedBrowser.isRemoteBrowser) { + this.showRemoteOnlyNotification(window, tab, options); + return promise.reject(new Error("RDM only available for remote tabs.")); + } + // Remove this once we support this case in bug 1306975. + if (tab.linkedBrowser.hasAttribute("usercontextid")) { + this.showNoContainerTabsNotification(window, tab, options); + return promise.reject(new Error("RDM not available for container tabs.")); + } + if (!this.isActiveForTab(tab)) { + this.initMenuCheckListenerFor(window); + + let ui = new ResponsiveUI(window, tab); + this.activeTabs.set(tab, ui); + yield this.setMenuCheckFor(tab, window); + yield ui.inited; + this.emit("on", { tab }); + } + + return this.getResponsiveUIForTab(tab); + }), + + /** + * Closes the responsive UI, if not already closed. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with closing. Currently includes: + * - `command`: Whether initiated via GCLI command bar or toolbox button + * - `reason`: String detailing the specific cause for closing + * @return Promise + * Resolved (with no value) when closing is complete. + */ + closeIfNeeded: Task.async(function* (window, tab, options) { + if (this.isActiveForTab(tab)) { + let ui = this.activeTabs.get(tab); + let destroyed = yield ui.destroy(options); + if (!destroyed) { + // Already in the process of destroying, abort. + return; + } + this.activeTabs.delete(tab); + + if (!this.isActiveForWindow(window)) { + this.removeMenuCheckListenerFor(window); + } + this.emit("off", { tab }); + yield this.setMenuCheckFor(tab, window); + } + }), + + /** + * Returns true if responsive UI is active for a given tab. + * + * @param tab + * The browser tab. + * @return boolean + */ + isActiveForTab(tab) { + return this.activeTabs.has(tab); + }, + + /** + * Returns true if responsive UI is active in any tab in the given window. + * + * @param window + * The main browser chrome window. + * @return boolean + */ + isActiveForWindow(window) { + return [...this.activeTabs.keys()].some(t => getOwnerWindow(t) === window); + }, + + /** + * Return the responsive UI controller for a tab. + * + * @param tab + * The browser tab. + * @return ResponsiveUI + * The UI instance for this tab. + */ + getResponsiveUIForTab(tab) { + return this.activeTabs.get(tab); + }, + + /** + * Handle GCLI commands. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param command + * The GCLI command name. + * @param args + * The GCLI command arguments. + */ + handleGcliCommand(window, tab, command, args) { + let completed; + switch (command) { + case "resize to": + completed = this.openIfNeeded(window, tab, { command: true }); + this.activeTabs.get(tab).setViewportSize(args); + break; + case "resize on": + completed = this.openIfNeeded(window, tab, { command: true }); + break; + case "resize off": + completed = this.closeIfNeeded(window, tab, { command: true }); + break; + case "resize toggle": + completed = this.toggle(window, tab, { command: true }); + break; + default: + } + completed.catch(e => console.error(e)); + }, + + handleMenuCheck({target}) { + ResponsiveUIManager.setMenuCheckFor(target); + }, + + initMenuCheckListenerFor(window) { + let { tabContainer } = window.gBrowser; + tabContainer.addEventListener("TabSelect", this.handleMenuCheck); + }, + + removeMenuCheckListenerFor(window) { + if (window && window.gBrowser && window.gBrowser.tabContainer) { + let { tabContainer } = window.gBrowser; + tabContainer.removeEventListener("TabSelect", this.handleMenuCheck); + } + }, + + setMenuCheckFor: Task.async(function* (tab, window = getOwnerWindow(tab)) { + yield startup(window); + + let menu = window.document.getElementById("menu_responsiveUI"); + if (menu) { + menu.setAttribute("checked", this.isActiveForTab(tab)); + } + }), + + showRemoteOnlyNotification(window, tab, options) { + this.showErrorNotification(window, tab, options, getStr("responsive.remoteOnly")); + }, + + showNoContainerTabsNotification(window, tab, options) { + this.showErrorNotification(window, tab, options, + getStr("responsive.noContainerTabs")); + }, + + showErrorNotification(window, tab, { command } = {}, msg) { + // Default to using the browser's per-tab notification box + let nbox = window.gBrowser.getNotificationBox(tab.linkedBrowser); + + // If opening was initiated by GCLI command bar or toolbox button, check for an open + // toolbox for the tab. If one exists, use the toolbox's notification box so that the + // message is placed closer to the action taken by the user. + if (command) { + let target = TargetFactory.forTab(tab); + let toolbox = gDevTools.getToolbox(target); + if (toolbox) { + nbox = toolbox.notificationBox; + } + } + + let value = "devtools-responsive-error"; + if (nbox.getNotificationWithValue(value)) { + // Notification already displayed + return; + } + + nbox.appendNotification( + msg, + value, + null, + nbox.PRIORITY_CRITICAL_MEDIUM, + []); + }, +}; + +// GCLI commands in ../responsivedesign/resize-commands.js listen for events +// from this object to know when the UI for a tab has opened or closed. +EventEmitter.decorate(ResponsiveUIManager); + +/** + * 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. + */ +function ResponsiveUI(window, tab) { + this.browserWindow = window; + this.tab = tab; + this.inited = this.init(); +} + +ResponsiveUI.prototype = { + + /** + * The main browser chrome window (that holds many tabs). + */ + browserWindow: null, + + /** + * The specific browser tab this responsive instance is for. + */ + tab: null, + + /** + * Promise resovled when the UI init has completed. + */ + inited: null, + + /** + * Flag set when destruction has begun. + */ + destroying: false, + + /** + * Flag set when destruction has ended. + */ + destroyed: false, + + /** + * A window reference for the chrome:// document that displays the responsive + * design tool. It is safe to reference this window directly even with e10s, + * as the tool UI is always loaded in the parent process. The web content + * contained *within* the tool UI on the other hand is loaded in the child + * process. + */ + toolWindow: null, + + /** + * Open RDM while preserving the state of the page. We use `swapFrameLoaders` + * to ensure all in-page state is preserved, just like when you move a tab to + * a new window. + * + * For more details, see /devtools/docs/responsive-design-mode.md. + */ + init: Task.async(function* () { + let ui = this; + + // Watch for tab close and window close so we can clean up RDM synchronously + this.tab.addEventListener("TabClose", this); + this.browserWindow.addEventListener("unload", this); + + // Swap page content from the current tab into a viewport within RDM + this.swap = swapToInnerBrowser({ + tab: this.tab, + containerURL: TOOL_URL, + getInnerBrowser: Task.async(function* (containerBrowser) { + let toolWindow = ui.toolWindow = containerBrowser.contentWindow; + toolWindow.addEventListener("message", ui); + yield message.request(toolWindow, "init"); + toolWindow.addInitialViewport("about:blank"); + yield message.wait(toolWindow, "browser-mounted"); + return ui.getViewportBrowser(); + }) + }); + yield this.swap.start(); + + this.tab.addEventListener("BeforeTabRemotenessChange", this); + + // Notify the inner browser to start the frame script + yield message.request(this.toolWindow, "start-frame-script"); + + // Get the protocol ready to speak with emulation actor + yield this.connectToServer(); + + // Non-blocking message to tool UI to start any delayed init activities + message.post(this.toolWindow, "post-init"); + }), + + /** + * 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. + */ + destroy: Task.async(function* (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 yielding when we're about to close the tab. + let isWindowClosing = options && options.reason === "unload"; + let isTabContentDestroying = + isWindowClosing || (options && (options.reason === "TabClose" || + options.reason === "BeforeTabRemotenessChange")); + + // Ensure init has finished before starting destroy + if (!isTabContentDestroying) { + yield this.inited; + } + + this.tab.removeEventListener("TabClose", this); + this.tab.removeEventListener("BeforeTabRemotenessChange", this); + this.browserWindow.removeEventListener("unload", this); + this.toolWindow.removeEventListener("message", this); + + if (!isTabContentDestroying) { + // Notify the inner browser to stop the frame script + yield message.request(this.toolWindow, "stop-frame-script"); + } + + // Destroy local state + let swap = this.swap; + this.browserWindow = null; + this.tab = null; + this.inited = null; + this.toolWindow = null; + this.swap = null; + + // Close the debugger client used to speak with emulation actor. + // The actor handles clearing any overrides itself, so it's not necessary to clear + // anything on shutdown client side. + let clientClosed = this.client.close(); + if (!isTabContentDestroying) { + yield clientClosed; + } + this.client = this.emulationFront = null; + + if (!isWindowClosing) { + // Undo the swap and return the content back to a normal tab + swap.stop(); + } + + this.destroyed = true; + + return true; + }), + + connectToServer: Task.async(function* () { + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + this.client = new DebuggerClient(DebuggerServer.connectPipe()); + yield this.client.connect(); + let { tab } = yield this.client.getTab(); + this.emulationFront = EmulationFront(this.client, tab); + }), + + handleEvent(event) { + let { browserWindow, tab } = this; + + switch (event.type) { + case "message": + this.handleMessage(event); + break; + case "BeforeTabRemotenessChange": + case "TabClose": + case "unload": + ResponsiveUIManager.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-throtting": + this.onChangeNetworkThrottling(event); + break; + case "change-pixel-ratio": + this.onChangePixelRatio(event); + break; + case "change-touch-simulation": + this.onChangeTouchSimulation(event); + break; + case "content-resize": + this.onContentResize(event); + break; + case "exit": + this.onExit(); + break; + case "remove-device": + this.onRemoveDevice(event); + break; + } + }, + + onChangeDevice: Task.async(function* (event) { + let { userAgent, pixelRatio, touch } = event.data.device; + yield this.updateUserAgent(userAgent); + yield this.updateDPPX(pixelRatio); + yield this.updateTouchSimulation(touch); + // Used by tests + this.emit("device-changed"); + }), + + onChangeNetworkThrottling: Task.async(function* (event) { + let { enabled, profile } = event.data; + yield this.updateNetworkThrottling(enabled, profile); + // Used by tests + this.emit("network-throttling-changed"); + }), + + onChangePixelRatio(event) { + let { pixelRatio } = event.data; + this.updateDPPX(pixelRatio); + }, + + onChangeTouchSimulation(event) { + let { enabled } = event.data; + this.updateTouchSimulation(enabled); + }, + + onContentResize(event) { + let { width, height } = event.data; + this.emit("content-resize", { + width, + height, + }); + }, + + onExit() { + let { browserWindow, tab } = this; + ResponsiveUIManager.closeIfNeeded(browserWindow, tab); + }, + + onRemoveDevice: Task.async(function* (event) { + yield this.updateUserAgent(); + yield this.updateDPPX(); + yield this.updateTouchSimulation(); + // Used by tests + this.emit("device-removed"); + }), + + updateDPPX: Task.async(function* (dppx) { + if (!dppx) { + yield this.emulationFront.clearDPPXOverride(); + return; + } + yield this.emulationFront.setDPPXOverride(dppx); + }), + + updateNetworkThrottling: Task.async(function* (enabled, profile) { + if (!enabled) { + yield this.emulationFront.clearNetworkThrottling(); + return; + } + let data = throttlingProfiles.find(({ id }) => id == profile); + let { download, upload, latency } = data; + yield this.emulationFront.setNetworkThrottling({ + downloadThroughput: download, + uploadThroughput: upload, + latency, + }); + }), + + updateUserAgent: Task.async(function* (userAgent) { + if (!userAgent) { + yield this.emulationFront.clearUserAgentOverride(); + return; + } + yield this.emulationFront.setUserAgentOverride(userAgent); + }), + + updateTouchSimulation: Task.async(function* (enabled) { + if (!enabled) { + yield this.emulationFront.clearTouchEventsOverride(); + return; + } + let reloadNeeded = yield this.emulationFront.setTouchEventsOverride( + Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED + ); + if (reloadNeeded) { + this.getViewportBrowser().reload(); + } + }), + + /** + * Helper for tests. Assumes a single viewport for now. + */ + getViewportSize() { + return this.toolWindow.getViewportSize(); + }, + + /** + * Helper for tests, GCLI, etc. Assumes a single viewport for now. + */ + setViewportSize: Task.async(function* (size) { + yield this.inited; + this.toolWindow.setViewportSize(size); + }), + + /** + * Helper for tests/reloading the viewport. Assumes a single viewport for now. + */ + getViewportBrowser() { + return this.toolWindow.getViewportBrowser(); + }, + + /** + * Helper for contacting the viewport content. Assumes a single viewport for now. + */ + getViewportMessageManager() { + return this.getViewportBrowser().messageManager; + }, + +}; + +EventEmitter.decorate(ResponsiveUI.prototype); |