1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 const { Ci } = require("chrome");
8 const Services = require("Services");
9 const EventEmitter = require("devtools/shared/event-emitter");
10 const { getOrientation } = require("./utils/orientation");
12 loader.lazyRequireGetter(
15 "devtools/shared/client/debugger-client",
18 loader.lazyRequireGetter(
21 "devtools/server/debugger-server",
24 loader.lazyRequireGetter(
27 "devtools/client/shared/components/throttling/profiles"
29 loader.lazyRequireGetter(
32 "devtools/client/responsive/browser/swap",
35 loader.lazyRequireGetter(
38 "devtools/client/responsive/utils/message"
40 loader.lazyRequireGetter(
43 "devtools/client/responsive/utils/notification",
46 loader.lazyRequireGetter(this, "l10n", "devtools/client/responsive/utils/l10n");
47 loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
49 const TOOL_URL = "chrome://devtools/content/responsive/index.xhtml";
51 const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions.";
52 const RELOAD_NOTIFICATION_PREF =
53 "devtools.responsive.reloadNotification.enabled";
56 // console.log(`RDM manager: ${msg}`);
60 * ResponsiveUI manages the responsive design tool for a specific tab. The
61 * actual tool itself lives in a separate chrome:// document that is loaded into
62 * the tab upon opening responsive design. This object acts a helper to
63 * integrate the tool into the surrounding browser UI as needed.
67 * @param {ResponsiveUIManager} manager
68 * The ResponsiveUIManager instance.
69 * @param {ChromeWindow} window
70 * The main browser chrome window (that holds many tabs).
72 * The specific browser <tab> element this responsive instance is for.
74 constructor(manager, window, tab) {
75 this.manager = manager;
76 // The main browser chrome window (that holds many tabs).
77 this.browserWindow = window;
78 // The specific browser tab this responsive instance is for.
81 // Flag set when destruction has begun.
82 this.destroying = false;
83 // Flag set when destruction has ended.
84 this.destroyed = false;
86 * A window reference for the chrome:// document that displays the responsive
87 * design tool. It is safe to reference this window directly even with e10s,
88 * as the tool UI is always loaded in the parent process. The web content
89 * contained *within* the tool UI on the other hand is loaded in the child
92 this.toolWindow = null;
94 // Promise resovled when the UI init has completed.
95 this.inited = this.init();
97 EventEmitter.decorate(this);
101 * Open RDM while preserving the state of the page. We use `swapFrameLoaders`
102 * to ensure all in-page state is preserved, just like when you move a tab to
105 * For more details, see /devtools/docs/responsive-design-mode.md.
112 // Watch for tab close and window close so we can clean up RDM synchronously
113 this.tab.addEventListener("TabClose", this);
114 this.browserWindow.addEventListener("unload", this);
116 // Swap page content from the current tab into a viewport within RDM
117 debug("Create browser swapper");
118 this.swap = swapToInnerBrowser({
120 containerURL: TOOL_URL,
121 async getInnerBrowser(containerBrowser) {
122 const toolWindow = (ui.toolWindow = containerBrowser.contentWindow);
123 toolWindow.addEventListener("message", ui);
124 debug("Wait until init from inner");
125 await message.request(toolWindow, "init");
126 toolWindow.addInitialViewport({
128 userContextId: ui.tab.userContextId,
130 debug("Wait until browser mounted");
131 await message.wait(toolWindow, "browser-mounted");
132 return ui.getViewportBrowser();
135 debug("Wait until swap start");
136 await this.swap.start();
138 // Set the ui toolWindow to fullZoom and textZoom of 100%. Directly change
139 // the zoom levels of the toolwindow docshell. That doesn't affect the zoom
140 // of the RDM content, but it does send events that confuse the Zoom UI.
141 // So before we adjust the zoom levels of the toolWindow, we first cache
142 // the reported zoom levels of the RDM content, because we'll have to
143 // re-apply them to re-sync the Zoom UI.
145 // Cache the values now and we'll re-apply them near the end of this function.
146 // This is important since other steps here can also cause the Zoom UI update
147 // event to be sent for other browsers, and this means that the changes from
148 // our Zoom UI update event would be overwritten. After this function, future
149 // changes to zoom levels will send Zoom UI update events in an order that
150 // keeps the Zoom UI synchronized with the RDM content zoom levels.
151 const rdmContent = this.tab.linkedBrowser;
152 const rdmViewport = ui.toolWindow;
154 const fullZoom = rdmContent.fullZoom;
155 const textZoom = rdmContent.textZoom;
157 rdmViewport.docShell.contentViewer.fullZoom = 1;
158 rdmViewport.docShell.contentViewer.textZoom = 1;
160 // Listen to FullZoomChange events coming from the linkedBrowser,
161 // so that we can zoom the size of the viewport by the same amount.
162 rdmContent.addEventListener("FullZoomChange", this);
164 this.tab.addEventListener("BeforeTabRemotenessChange", this);
166 // Notify the inner browser to start the frame script
167 debug("Wait until start frame script");
168 await message.request(this.toolWindow, "start-frame-script");
170 // Get the protocol ready to speak with emulation actor
171 debug("Wait until RDP server connect");
172 await this.connectToServer();
174 // Restore the previous state of RDM.
175 await this.restoreState();
177 // Re-apply our cached zoom levels. Other Zoom UI update events have finished
179 rdmContent.fullZoom = fullZoom;
180 rdmContent.textZoom = textZoom;
182 // Non-blocking message to tool UI to start any delayed init activities
183 message.post(this.toolWindow, "post-init");
189 * Close RDM and restore page content back into a regular tab.
192 * Destroy options, which currently includes a `reason` string.
194 * Whether this call is actually destroying. False means destruction
195 * was already in progress.
197 async destroy(options) {
198 if (this.destroying) {
201 this.destroying = true;
203 // If our tab is about to be closed, there's not enough time to exit
204 // gracefully, but that shouldn't be a problem since the tab will go away.
205 // So, skip any waiting when we're about to close the tab.
206 const isTabDestroyed = !this.tab.linkedBrowser;
207 const isWindowClosing =
208 (options && options.reason === "unload") || isTabDestroyed;
209 const isTabContentDestroying =
212 (options.reason === "TabClose" ||
213 options.reason === "BeforeTabRemotenessChange"));
215 // Ensure init has finished before starting destroy
216 if (!isTabContentDestroying) {
220 this.tab.linkedBrowser.removeEventListener("FullZoomChange", this);
221 this.tab.removeEventListener("TabClose", this);
222 this.tab.removeEventListener("BeforeTabRemotenessChange", this);
223 this.browserWindow.removeEventListener("unload", this);
224 this.toolWindow.removeEventListener("message", this);
226 if (!isTabContentDestroying) {
227 // Notify the inner browser to stop the frame script
228 await message.request(this.toolWindow, "stop-frame-script");
231 // Ensure the tab is reloaded if required when exiting RDM so that no emulated
232 // settings are left in a customized state.
233 if (!isTabContentDestroying) {
234 let reloadNeeded = false;
235 await this.updateDPPX();
236 await this.updateNetworkThrottling();
238 (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
240 (await this.updateTouchSimulation()) &&
241 this.reloadOnChange("touchSimulation");
243 this.getViewportBrowser().reload();
247 // Destroy local state
248 const swap = this.swap;
249 this.browserWindow = null;
252 this.toolWindow = null;
255 // Close the debugger client used to speak with emulation actor.
256 // The actor handles clearing any overrides itself, so it's not necessary to clear
257 // anything on shutdown client side.
258 const clientClosed = this.client.close();
259 if (!isTabContentDestroying) {
262 this.client = this.emulationFront = null;
264 if (!isWindowClosing) {
265 // Undo the swap and return the content back to a normal tab
269 this.destroyed = true;
274 async connectToServer() {
275 // The client being instantiated here is separate from the toolbox. It is being used
276 // separately and has a life cycle that doesn't correspond to the toolbox.
277 DebuggerServer.init();
278 DebuggerServer.registerAllActors();
279 this.client = new DebuggerClient(DebuggerServer.connectPipe());
280 await this.client.connect();
281 const targetFront = await this.client.mainRoot.getTab();
282 this.emulationFront = await targetFront.getFront("emulation");
286 * Show one-time notification about reloads for emulation.
288 showReloadNotification() {
289 if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) {
290 showNotification(this.browserWindow, this.tab, {
291 msg: l10n.getFormatStr("responsive.reloadNotification.description2"),
293 Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false);
298 this.showReloadNotification();
299 const pref = RELOAD_CONDITION_PREF_PREFIX + id;
300 return Services.prefs.getBoolPref(pref, false);
304 const { browserWindow, tab, toolWindow } = this;
306 switch (event.type) {
308 this.handleMessage(event);
310 case "FullZoomChange":
311 const zoom = tab.linkedBrowser.fullZoom;
312 toolWindow.setViewportZoom(zoom);
314 case "BeforeTabRemotenessChange":
317 this.manager.closeIfNeeded(browserWindow, tab, {
324 handleMessage(event) {
325 if (event.origin !== "chrome://devtools") {
329 switch (event.data.type) {
330 case "change-device":
331 this.onChangeDevice(event);
333 case "change-network-throttling":
334 this.onChangeNetworkThrottling(event);
336 case "change-pixel-ratio":
337 this.onChangePixelRatio(event);
339 case "change-touch-simulation":
340 this.onChangeTouchSimulation(event);
342 case "change-user-agent":
343 this.onChangeUserAgent(event);
345 case "content-resize":
346 this.onContentResize(event);
351 case "remove-device-association":
352 this.onRemoveDeviceAssociation();
354 case "viewport-orientation-change":
355 this.onRotateViewport(event);
357 case "viewport-resize":
358 this.onResizeViewport(event);
363 async onChangeDevice(event) {
364 const { pixelRatio, touch, userAgent } = event.data.device;
365 let reloadNeeded = false;
366 await this.updateDPPX(pixelRatio);
368 // Get the orientation values of the device we are changing to and update.
369 const { device, viewport } = event.data;
370 const { type, angle } = getOrientation(device, viewport);
371 await this.updateScreenOrientation(type, angle);
374 (await this.updateUserAgent(userAgent)) &&
375 this.reloadOnChange("userAgent");
377 (await this.updateTouchSimulation(touch)) &&
378 this.reloadOnChange("touchSimulation");
380 this.getViewportBrowser().reload();
383 this.emit("device-changed");
386 async onChangeNetworkThrottling(event) {
387 const { enabled, profile } = event.data;
388 await this.updateNetworkThrottling(enabled, profile);
390 this.emit("network-throttling-changed");
393 onChangePixelRatio(event) {
394 const { pixelRatio } = event.data;
395 this.updateDPPX(pixelRatio);
398 async onChangeTouchSimulation(event) {
399 const { enabled } = event.data;
401 (await this.updateTouchSimulation(enabled)) &&
402 this.reloadOnChange("touchSimulation");
404 this.getViewportBrowser().reload();
407 this.emit("touch-simulation-changed");
410 async onChangeUserAgent(event) {
411 const { userAgent } = event.data;
413 (await this.updateUserAgent(userAgent)) &&
414 this.reloadOnChange("userAgent");
416 this.getViewportBrowser().reload();
418 this.emit("user-agent-changed");
421 onContentResize(event) {
422 const { width, height } = event.data;
423 this.emit("content-resize", {
430 const { browserWindow, tab } = this;
431 this.manager.closeIfNeeded(browserWindow, tab);
434 async onRemoveDeviceAssociation() {
435 let reloadNeeded = false;
436 await this.updateDPPX();
438 (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
440 (await this.updateTouchSimulation()) &&
441 this.reloadOnChange("touchSimulation");
443 this.getViewportBrowser().reload();
446 this.emit("device-association-removed");
449 onResizeViewport(event) {
450 const { width, height } = event.data;
451 this.emit("viewport-resize", {
457 async onRotateViewport(event) {
458 const { orientationType: type, angle, isViewportRotated } = event.data;
459 await this.updateScreenOrientation(type, angle, isViewportRotated);
463 * Restores the previous state of RDM.
465 async restoreState() {
466 const deviceState = await asyncStorage.getItem(
467 "devtools.responsive.deviceState"
470 // Return if there is a device state to restore, this will be done when the
471 // device list is loaded after the post-init.
475 const height = Services.prefs.getIntPref(
476 "devtools.responsive.viewport.height",
479 const pixelRatio = Services.prefs.getIntPref(
480 "devtools.responsive.viewport.pixelRatio",
483 const touchSimulationEnabled = Services.prefs.getBoolPref(
484 "devtools.responsive.touchSimulation.enabled",
487 const userAgent = Services.prefs.getCharPref(
488 "devtools.responsive.userAgent",
491 const width = Services.prefs.getIntPref(
492 "devtools.responsive.viewport.width",
496 let reloadNeeded = false;
497 const { type, angle } = this.getInitialViewportOrientation({
502 await this.updateDPPX(pixelRatio);
503 await this.updateScreenOrientation(type, angle);
505 if (touchSimulationEnabled) {
507 (await this.updateTouchSimulation(touchSimulationEnabled)) &&
508 this.reloadOnChange("touchSimulation");
512 (await this.updateUserAgent(userAgent)) &&
513 this.reloadOnChange("userAgent");
516 this.getViewportBrowser().reload();
521 * Set or clear the emulated device pixel ratio.
524 * Whether a reload is needed to apply the change.
525 * (This is always immediate, so it's always false.)
527 async updateDPPX(dppx) {
529 await this.emulationFront.clearDPPXOverride();
532 await this.emulationFront.setDPPXOverride(dppx);
537 * Set or clear network throttling.
540 * Whether a reload is needed to apply the change.
541 * (This is always immediate, so it's always false.)
543 async updateNetworkThrottling(enabled, profile) {
545 await this.emulationFront.clearNetworkThrottling();
548 const data = throttlingProfiles.find(({ id }) => id == profile);
549 const { download, upload, latency } = data;
550 await this.emulationFront.setNetworkThrottling({
551 downloadThroughput: download,
552 uploadThroughput: upload,
559 * Set or clear the emulated user agent.
562 * Whether a reload is needed to apply the change.
564 updateUserAgent(userAgent) {
566 return this.emulationFront.clearUserAgentOverride();
568 return this.emulationFront.setUserAgentOverride(userAgent);
572 * Set or clear touch simulation. When setting to true, this method will
573 * additionally set meta viewport override if the pref
574 * "devtools.responsive.metaViewport.enabled" is true. When setting to
575 * false, this method will clear all touch simulation and meta viewport
576 * overrides, returning to default behavior for both settings.
579 * Whether a reload is needed to apply the override change(s).
581 async updateTouchSimulation(enabled) {
584 const metaViewportEnabled = Services.prefs.getBoolPref(
585 "devtools.responsive.metaViewport.enabled",
589 reloadNeeded = await this.emulationFront.setTouchEventsOverride(
590 Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED
593 if (metaViewportEnabled) {
594 reloadNeeded |= await this.emulationFront.setMetaViewportOverride(
595 Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_ENABLED
599 reloadNeeded = await this.emulationFront.clearTouchEventsOverride();
600 reloadNeeded |= await this.emulationFront.clearMetaViewportOverride();
606 * Sets the screen orientation values of the simulated device.
608 * @param {String} type
609 * The orientation type to update the current device screen to.
610 * @param {Number} angle
611 * The rotation angle to update the current device screen to.
612 * @param {Boolean} isViewportRotated
613 * Whether or not the reason for updating the screen orientation is a result
614 * of actually rotating the device via the RDM toolbar. If true, then an
615 * "orientationchange" event is simulated. Otherwise, the screen orientation is
616 * updated because of changing devices, opening RDM, or the page has been
617 * reloaded/navigated to, so we should not be simulating "orientationchange".
619 async updateScreenOrientation(type, angle, isViewportRotated = false) {
620 const targetFront = await this.client.mainRoot.getTab();
621 const simulateOrientationChangeSupported = await targetFront.actorHasMethod(
623 "simulateScreenOrientationChange"
626 // Ensure that simulateScreenOrientationChange is supported.
627 if (simulateOrientationChangeSupported) {
628 await this.emulationFront.simulateScreenOrientationChange(
636 if (!isViewportRotated) {
637 this.emit("only-viewport-orientation-changed");
642 * Helper for tests. Assumes a single viewport for now.
645 return this.toolWindow.getViewportSize();
649 * Helper for tests, etc. Assumes a single viewport for now.
651 async setViewportSize(size) {
653 this.toolWindow.setViewportSize(size);
657 * Helper for tests/reloading the viewport. Assumes a single viewport for now.
659 getViewportBrowser() {
660 return this.toolWindow.getViewportBrowser();
664 * Helper for contacting the viewport content. Assumes a single viewport for now.
666 getViewportMessageManager() {
667 return this.getViewportBrowser().messageManager;
671 * Helper for getting the initial viewport orientation.
673 getInitialViewportOrientation(viewport) {
674 return getOrientation(viewport, viewport);
678 module.exports = ResponsiveUI;