Bug 1580545 - Convert ResponsiveUI and ResponsiveUIManager to ES6 classes. r=mtigley
[gecko.git] / devtools / client / responsive / ui.js
blobfa56c44b41fbd048bee8ab769d803992441a41c2
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/. */
5 "use strict";
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(
13   this,
14   "DebuggerClient",
15   "devtools/shared/client/debugger-client",
16   true
18 loader.lazyRequireGetter(
19   this,
20   "DebuggerServer",
21   "devtools/server/debugger-server",
22   true
24 loader.lazyRequireGetter(
25   this,
26   "throttlingProfiles",
27   "devtools/client/shared/components/throttling/profiles"
29 loader.lazyRequireGetter(
30   this,
31   "swapToInnerBrowser",
32   "devtools/client/responsive/browser/swap",
33   true
35 loader.lazyRequireGetter(
36   this,
37   "message",
38   "devtools/client/responsive/utils/message"
40 loader.lazyRequireGetter(
41   this,
42   "showNotification",
43   "devtools/client/responsive/utils/notification",
44   true
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";
55 function debug(msg) {
56   // console.log(`RDM manager: ${msg}`);
59 /**
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.
64  */
65 class ResponsiveUI {
66   /**
67    * @param {ResponsiveUIManager} manager
68    *        The ResponsiveUIManager instance.
69    * @param {ChromeWindow} window
70    *        The main browser chrome window (that holds many tabs).
71    * @param {Tab} tab
72    *        The specific browser <tab> element this responsive instance is for.
73    */
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.
79     this.tab = tab;
81     // Flag set when destruction has begun.
82     this.destroying = false;
83     // Flag set when destruction has ended.
84     this.destroyed = false;
85     /**
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
90      * process.
91      */
92     this.toolWindow = null;
94     // Promise resovled when the UI init has completed.
95     this.inited = this.init();
97     EventEmitter.decorate(this);
98   }
100   /**
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
103    * a new window.
104    *
105    * For more details, see /devtools/docs/responsive-design-mode.md.
106    */
107   async init() {
108     debug("Init start");
110     const ui = this;
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({
119       tab: this.tab,
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({
127           uri: "about:blank",
128           userContextId: ui.tab.userContextId,
129         });
130         debug("Wait until browser mounted");
131         await message.wait(toolWindow, "browser-mounted");
132         return ui.getViewportBrowser();
133       },
134     });
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
178     // by now.
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");
185     debug("Init done");
186   }
188   /**
189    * Close RDM and restore page content back into a regular tab.
190    *
191    * @param object
192    *        Destroy options, which currently includes a `reason` string.
193    * @return boolean
194    *         Whether this call is actually destroying.  False means destruction
195    *         was already in progress.
196    */
197   async destroy(options) {
198     if (this.destroying) {
199       return false;
200     }
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 =
210       isWindowClosing ||
211       (options &&
212         (options.reason === "TabClose" ||
213           options.reason === "BeforeTabRemotenessChange"));
215     // Ensure init has finished before starting destroy
216     if (!isTabContentDestroying) {
217       await this.inited;
218     }
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");
229     }
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();
237       reloadNeeded |=
238         (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
239       reloadNeeded |=
240         (await this.updateTouchSimulation()) &&
241         this.reloadOnChange("touchSimulation");
242       if (reloadNeeded) {
243         this.getViewportBrowser().reload();
244       }
245     }
247     // Destroy local state
248     const swap = this.swap;
249     this.browserWindow = null;
250     this.tab = null;
251     this.inited = null;
252     this.toolWindow = null;
253     this.swap = 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) {
260       await clientClosed;
261     }
262     this.client = this.emulationFront = null;
264     if (!isWindowClosing) {
265       // Undo the swap and return the content back to a normal tab
266       swap.stop();
267     }
269     this.destroyed = true;
271     return true;
272   }
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");
283   }
285   /**
286    * Show one-time notification about reloads for emulation.
287    */
288   showReloadNotification() {
289     if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) {
290       showNotification(this.browserWindow, this.tab, {
291         msg: l10n.getFormatStr("responsive.reloadNotification.description2"),
292       });
293       Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false);
294     }
295   }
297   reloadOnChange(id) {
298     this.showReloadNotification();
299     const pref = RELOAD_CONDITION_PREF_PREFIX + id;
300     return Services.prefs.getBoolPref(pref, false);
301   }
303   handleEvent(event) {
304     const { browserWindow, tab, toolWindow } = this;
306     switch (event.type) {
307       case "message":
308         this.handleMessage(event);
309         break;
310       case "FullZoomChange":
311         const zoom = tab.linkedBrowser.fullZoom;
312         toolWindow.setViewportZoom(zoom);
313         break;
314       case "BeforeTabRemotenessChange":
315       case "TabClose":
316       case "unload":
317         this.manager.closeIfNeeded(browserWindow, tab, {
318           reason: event.type,
319         });
320         break;
321     }
322   }
324   handleMessage(event) {
325     if (event.origin !== "chrome://devtools") {
326       return;
327     }
329     switch (event.data.type) {
330       case "change-device":
331         this.onChangeDevice(event);
332         break;
333       case "change-network-throttling":
334         this.onChangeNetworkThrottling(event);
335         break;
336       case "change-pixel-ratio":
337         this.onChangePixelRatio(event);
338         break;
339       case "change-touch-simulation":
340         this.onChangeTouchSimulation(event);
341         break;
342       case "change-user-agent":
343         this.onChangeUserAgent(event);
344         break;
345       case "content-resize":
346         this.onContentResize(event);
347         break;
348       case "exit":
349         this.onExit();
350         break;
351       case "remove-device-association":
352         this.onRemoveDeviceAssociation();
353         break;
354       case "viewport-orientation-change":
355         this.onRotateViewport(event);
356         break;
357       case "viewport-resize":
358         this.onResizeViewport(event);
359         break;
360     }
361   }
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);
373     reloadNeeded |=
374       (await this.updateUserAgent(userAgent)) &&
375       this.reloadOnChange("userAgent");
376     reloadNeeded |=
377       (await this.updateTouchSimulation(touch)) &&
378       this.reloadOnChange("touchSimulation");
379     if (reloadNeeded) {
380       this.getViewportBrowser().reload();
381     }
382     // Used by tests
383     this.emit("device-changed");
384   }
386   async onChangeNetworkThrottling(event) {
387     const { enabled, profile } = event.data;
388     await this.updateNetworkThrottling(enabled, profile);
389     // Used by tests
390     this.emit("network-throttling-changed");
391   }
393   onChangePixelRatio(event) {
394     const { pixelRatio } = event.data;
395     this.updateDPPX(pixelRatio);
396   }
398   async onChangeTouchSimulation(event) {
399     const { enabled } = event.data;
400     const reloadNeeded =
401       (await this.updateTouchSimulation(enabled)) &&
402       this.reloadOnChange("touchSimulation");
403     if (reloadNeeded) {
404       this.getViewportBrowser().reload();
405     }
406     // Used by tests
407     this.emit("touch-simulation-changed");
408   }
410   async onChangeUserAgent(event) {
411     const { userAgent } = event.data;
412     const reloadNeeded =
413       (await this.updateUserAgent(userAgent)) &&
414       this.reloadOnChange("userAgent");
415     if (reloadNeeded) {
416       this.getViewportBrowser().reload();
417     }
418     this.emit("user-agent-changed");
419   }
421   onContentResize(event) {
422     const { width, height } = event.data;
423     this.emit("content-resize", {
424       width,
425       height,
426     });
427   }
429   onExit() {
430     const { browserWindow, tab } = this;
431     this.manager.closeIfNeeded(browserWindow, tab);
432   }
434   async onRemoveDeviceAssociation() {
435     let reloadNeeded = false;
436     await this.updateDPPX();
437     reloadNeeded |=
438       (await this.updateUserAgent()) && this.reloadOnChange("userAgent");
439     reloadNeeded |=
440       (await this.updateTouchSimulation()) &&
441       this.reloadOnChange("touchSimulation");
442     if (reloadNeeded) {
443       this.getViewportBrowser().reload();
444     }
445     // Used by tests
446     this.emit("device-association-removed");
447   }
449   onResizeViewport(event) {
450     const { width, height } = event.data;
451     this.emit("viewport-resize", {
452       width,
453       height,
454     });
455   }
457   async onRotateViewport(event) {
458     const { orientationType: type, angle, isViewportRotated } = event.data;
459     await this.updateScreenOrientation(type, angle, isViewportRotated);
460   }
462   /**
463    * Restores the previous state of RDM.
464    */
465   async restoreState() {
466     const deviceState = await asyncStorage.getItem(
467       "devtools.responsive.deviceState"
468     );
469     if (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.
472       return;
473     }
475     const height = Services.prefs.getIntPref(
476       "devtools.responsive.viewport.height",
477       0
478     );
479     const pixelRatio = Services.prefs.getIntPref(
480       "devtools.responsive.viewport.pixelRatio",
481       0
482     );
483     const touchSimulationEnabled = Services.prefs.getBoolPref(
484       "devtools.responsive.touchSimulation.enabled",
485       false
486     );
487     const userAgent = Services.prefs.getCharPref(
488       "devtools.responsive.userAgent",
489       ""
490     );
491     const width = Services.prefs.getIntPref(
492       "devtools.responsive.viewport.width",
493       0
494     );
496     let reloadNeeded = false;
497     const { type, angle } = this.getInitialViewportOrientation({
498       width,
499       height,
500     });
502     await this.updateDPPX(pixelRatio);
503     await this.updateScreenOrientation(type, angle);
505     if (touchSimulationEnabled) {
506       reloadNeeded |=
507         (await this.updateTouchSimulation(touchSimulationEnabled)) &&
508         this.reloadOnChange("touchSimulation");
509     }
510     if (userAgent) {
511       reloadNeeded |=
512         (await this.updateUserAgent(userAgent)) &&
513         this.reloadOnChange("userAgent");
514     }
515     if (reloadNeeded) {
516       this.getViewportBrowser().reload();
517     }
518   }
520   /**
521    * Set or clear the emulated device pixel ratio.
522    *
523    * @return boolean
524    *         Whether a reload is needed to apply the change.
525    *         (This is always immediate, so it's always false.)
526    */
527   async updateDPPX(dppx) {
528     if (!dppx) {
529       await this.emulationFront.clearDPPXOverride();
530       return false;
531     }
532     await this.emulationFront.setDPPXOverride(dppx);
533     return false;
534   }
536   /**
537    * Set or clear network throttling.
538    *
539    * @return boolean
540    *         Whether a reload is needed to apply the change.
541    *         (This is always immediate, so it's always false.)
542    */
543   async updateNetworkThrottling(enabled, profile) {
544     if (!enabled) {
545       await this.emulationFront.clearNetworkThrottling();
546       return false;
547     }
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,
553       latency,
554     });
555     return false;
556   }
558   /**
559    * Set or clear the emulated user agent.
560    *
561    * @return boolean
562    *         Whether a reload is needed to apply the change.
563    */
564   updateUserAgent(userAgent) {
565     if (!userAgent) {
566       return this.emulationFront.clearUserAgentOverride();
567     }
568     return this.emulationFront.setUserAgentOverride(userAgent);
569   }
571   /**
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.
577    *
578    * @return boolean
579    *         Whether a reload is needed to apply the override change(s).
580    */
581   async updateTouchSimulation(enabled) {
582     let reloadNeeded;
583     if (enabled) {
584       const metaViewportEnabled = Services.prefs.getBoolPref(
585         "devtools.responsive.metaViewport.enabled",
586         false
587       );
589       reloadNeeded = await this.emulationFront.setTouchEventsOverride(
590         Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED
591       );
593       if (metaViewportEnabled) {
594         reloadNeeded |= await this.emulationFront.setMetaViewportOverride(
595           Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_ENABLED
596         );
597       }
598     } else {
599       reloadNeeded = await this.emulationFront.clearTouchEventsOverride();
600       reloadNeeded |= await this.emulationFront.clearMetaViewportOverride();
601     }
602     return reloadNeeded;
603   }
605   /**
606    * Sets the screen orientation values of the simulated device.
607    *
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".
618    */
619   async updateScreenOrientation(type, angle, isViewportRotated = false) {
620     const targetFront = await this.client.mainRoot.getTab();
621     const simulateOrientationChangeSupported = await targetFront.actorHasMethod(
622       "emulation",
623       "simulateScreenOrientationChange"
624     );
626     // Ensure that simulateScreenOrientationChange is supported.
627     if (simulateOrientationChangeSupported) {
628       await this.emulationFront.simulateScreenOrientationChange(
629         type,
630         angle,
631         isViewportRotated
632       );
633     }
635     // Used by tests.
636     if (!isViewportRotated) {
637       this.emit("only-viewport-orientation-changed");
638     }
639   }
641   /**
642    * Helper for tests. Assumes a single viewport for now.
643    */
644   getViewportSize() {
645     return this.toolWindow.getViewportSize();
646   }
648   /**
649    * Helper for tests, etc. Assumes a single viewport for now.
650    */
651   async setViewportSize(size) {
652     await this.inited;
653     this.toolWindow.setViewportSize(size);
654   }
656   /**
657    * Helper for tests/reloading the viewport. Assumes a single viewport for now.
658    */
659   getViewportBrowser() {
660     return this.toolWindow.getViewportBrowser();
661   }
663   /**
664    * Helper for contacting the viewport content. Assumes a single viewport for now.
665    */
666   getViewportMessageManager() {
667     return this.getViewportBrowser().messageManager;
668   }
670   /**
671    * Helper for getting the initial viewport orientation.
672    */
673   getInitialViewportOrientation(viewport) {
674     return getOrientation(viewport, viewport);
675   }
678 module.exports = ResponsiveUI;