manager.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  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/. */
  4. "use strict";
  5. const { Ci } = require("chrome");
  6. const promise = require("promise");
  7. const { Task } = require("devtools/shared/task");
  8. const EventEmitter = require("devtools/shared/event-emitter");
  9. const { getOwnerWindow } = require("sdk/tabs/utils");
  10. const { startup } = require("sdk/window/helpers");
  11. const message = require("./utils/message");
  12. const { swapToInnerBrowser } = require("./browser/swap");
  13. const { EmulationFront } = require("devtools/shared/fronts/emulation");
  14. const { getStr } = require("./utils/l10n");
  15. const TOOL_URL = "chrome://devtools/content/responsive.html/index.xhtml";
  16. loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true);
  17. loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true);
  18. loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true);
  19. loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
  20. loader.lazyRequireGetter(this, "throttlingProfiles",
  21. "devtools/client/shared/network-throttling-profiles");
  22. /**
  23. * ResponsiveUIManager is the external API for the browser UI, etc. to use when
  24. * opening and closing the responsive UI.
  25. *
  26. * While the HTML UI is in an experimental stage, the older ResponsiveUIManager
  27. * from devtools/client/responsivedesign/responsivedesign.jsm delegates to this
  28. * object when the pref "devtools.responsive.html.enabled" is true.
  29. */
  30. const ResponsiveUIManager = exports.ResponsiveUIManager = {
  31. activeTabs: new Map(),
  32. /**
  33. * Toggle the responsive UI for a tab.
  34. *
  35. * @param window
  36. * The main browser chrome window.
  37. * @param tab
  38. * The browser tab.
  39. * @param options
  40. * Other options associated with toggling. Currently includes:
  41. * - `command`: Whether initiated via GCLI command bar or toolbox button
  42. * @return Promise
  43. * Resolved when the toggling has completed. If the UI has opened,
  44. * it is resolved to the ResponsiveUI instance for this tab. If the
  45. * the UI has closed, there is no resolution value.
  46. */
  47. toggle(window, tab, options) {
  48. let action = this.isActiveForTab(tab) ? "close" : "open";
  49. let completed = this[action + "IfNeeded"](window, tab, options);
  50. completed.catch(console.error);
  51. return completed;
  52. },
  53. /**
  54. * Opens the responsive UI, if not already open.
  55. *
  56. * @param window
  57. * The main browser chrome window.
  58. * @param tab
  59. * The browser tab.
  60. * @param options
  61. * Other options associated with opening. Currently includes:
  62. * - `command`: Whether initiated via GCLI command bar or toolbox button
  63. * @return Promise
  64. * Resolved to the ResponsiveUI instance for this tab when opening is
  65. * complete.
  66. */
  67. openIfNeeded: Task.async(function* (window, tab, options) {
  68. if (!tab.linkedBrowser.isRemoteBrowser) {
  69. this.showRemoteOnlyNotification(window, tab, options);
  70. return promise.reject(new Error("RDM only available for remote tabs."));
  71. }
  72. if (!this.isActiveForTab(tab)) {
  73. this.initMenuCheckListenerFor(window);
  74. let ui = new ResponsiveUI(window, tab);
  75. this.activeTabs.set(tab, ui);
  76. yield this.setMenuCheckFor(tab, window);
  77. yield ui.inited;
  78. this.emit("on", { tab });
  79. }
  80. return this.getResponsiveUIForTab(tab);
  81. }),
  82. /**
  83. * Closes the responsive UI, if not already closed.
  84. *
  85. * @param window
  86. * The main browser chrome window.
  87. * @param tab
  88. * The browser tab.
  89. * @param options
  90. * Other options associated with closing. Currently includes:
  91. * - `command`: Whether initiated via GCLI command bar or toolbox button
  92. * - `reason`: String detailing the specific cause for closing
  93. * @return Promise
  94. * Resolved (with no value) when closing is complete.
  95. */
  96. closeIfNeeded: Task.async(function* (window, tab, options) {
  97. if (this.isActiveForTab(tab)) {
  98. let ui = this.activeTabs.get(tab);
  99. let destroyed = yield ui.destroy(options);
  100. if (!destroyed) {
  101. // Already in the process of destroying, abort.
  102. return;
  103. }
  104. this.activeTabs.delete(tab);
  105. if (!this.isActiveForWindow(window)) {
  106. this.removeMenuCheckListenerFor(window);
  107. }
  108. this.emit("off", { tab });
  109. yield this.setMenuCheckFor(tab, window);
  110. }
  111. }),
  112. /**
  113. * Returns true if responsive UI is active for a given tab.
  114. *
  115. * @param tab
  116. * The browser tab.
  117. * @return boolean
  118. */
  119. isActiveForTab(tab) {
  120. return this.activeTabs.has(tab);
  121. },
  122. /**
  123. * Returns true if responsive UI is active in any tab in the given window.
  124. *
  125. * @param window
  126. * The main browser chrome window.
  127. * @return boolean
  128. */
  129. isActiveForWindow(window) {
  130. return [...this.activeTabs.keys()].some(t => getOwnerWindow(t) === window);
  131. },
  132. /**
  133. * Return the responsive UI controller for a tab.
  134. *
  135. * @param tab
  136. * The browser tab.
  137. * @return ResponsiveUI
  138. * The UI instance for this tab.
  139. */
  140. getResponsiveUIForTab(tab) {
  141. return this.activeTabs.get(tab);
  142. },
  143. /**
  144. * Handle GCLI commands.
  145. *
  146. * @param window
  147. * The main browser chrome window.
  148. * @param tab
  149. * The browser tab.
  150. * @param command
  151. * The GCLI command name.
  152. * @param args
  153. * The GCLI command arguments.
  154. */
  155. handleGcliCommand(window, tab, command, args) {
  156. let completed;
  157. switch (command) {
  158. case "resize to":
  159. completed = this.openIfNeeded(window, tab, { command: true });
  160. this.activeTabs.get(tab).setViewportSize(args);
  161. break;
  162. case "resize on":
  163. completed = this.openIfNeeded(window, tab, { command: true });
  164. break;
  165. case "resize off":
  166. completed = this.closeIfNeeded(window, tab, { command: true });
  167. break;
  168. case "resize toggle":
  169. completed = this.toggle(window, tab, { command: true });
  170. break;
  171. default:
  172. }
  173. completed.catch(e => console.error(e));
  174. },
  175. handleMenuCheck({target}) {
  176. ResponsiveUIManager.setMenuCheckFor(target);
  177. },
  178. initMenuCheckListenerFor(window) {
  179. let { tabContainer } = window.gBrowser;
  180. tabContainer.addEventListener("TabSelect", this.handleMenuCheck);
  181. },
  182. removeMenuCheckListenerFor(window) {
  183. if (window && window.gBrowser && window.gBrowser.tabContainer) {
  184. let { tabContainer } = window.gBrowser;
  185. tabContainer.removeEventListener("TabSelect", this.handleMenuCheck);
  186. }
  187. },
  188. setMenuCheckFor: Task.async(function* (tab, window = getOwnerWindow(tab)) {
  189. yield startup(window);
  190. let menu = window.document.getElementById("menu_responsiveUI");
  191. if (menu) {
  192. menu.setAttribute("checked", this.isActiveForTab(tab));
  193. }
  194. }),
  195. showRemoteOnlyNotification(window, tab, { command } = {}) {
  196. // Default to using the browser's per-tab notification box
  197. let nbox = window.gBrowser.getNotificationBox(tab.linkedBrowser);
  198. // If opening was initiated by GCLI command bar or toolbox button, check for an open
  199. // toolbox for the tab. If one exists, use the toolbox's notification box so that the
  200. // message is placed closer to the action taken by the user.
  201. if (command) {
  202. let target = TargetFactory.forTab(tab);
  203. let toolbox = gDevTools.getToolbox(target);
  204. if (toolbox) {
  205. nbox = toolbox.notificationBox;
  206. }
  207. }
  208. let value = "devtools-responsive-error";
  209. if (nbox.getNotificationWithValue(value)) {
  210. // Notification already displayed
  211. return;
  212. }
  213. nbox.appendNotification(
  214. msg,
  215. value,
  216. null,
  217. nbox.PRIORITY_CRITICAL_MEDIUM,
  218. []);
  219. },
  220. };
  221. // GCLI commands in ../responsivedesign/resize-commands.js listen for events
  222. // from this object to know when the UI for a tab has opened or closed.
  223. EventEmitter.decorate(ResponsiveUIManager);
  224. /**
  225. * ResponsiveUI manages the responsive design tool for a specific tab. The
  226. * actual tool itself lives in a separate chrome:// document that is loaded into
  227. * the tab upon opening responsive design. This object acts a helper to
  228. * integrate the tool into the surrounding browser UI as needed.
  229. */
  230. function ResponsiveUI(window, tab) {
  231. this.browserWindow = window;
  232. this.tab = tab;
  233. this.inited = this.init();
  234. }
  235. ResponsiveUI.prototype = {
  236. /**
  237. * The main browser chrome window (that holds many tabs).
  238. */
  239. browserWindow: null,
  240. /**
  241. * The specific browser tab this responsive instance is for.
  242. */
  243. tab: null,
  244. /**
  245. * Promise resovled when the UI init has completed.
  246. */
  247. inited: null,
  248. /**
  249. * Flag set when destruction has begun.
  250. */
  251. destroying: false,
  252. /**
  253. * Flag set when destruction has ended.
  254. */
  255. destroyed: false,
  256. /**
  257. * A window reference for the chrome:// document that displays the responsive
  258. * design tool. It is safe to reference this window directly even with e10s,
  259. * as the tool UI is always loaded in the parent process. The web content
  260. * contained *within* the tool UI on the other hand is loaded in the child
  261. * process.
  262. */
  263. toolWindow: null,
  264. /**
  265. * Open RDM while preserving the state of the page. We use `swapFrameLoaders`
  266. * to ensure all in-page state is preserved, just like when you move a tab to
  267. * a new window.
  268. *
  269. * For more details, see /devtools/docs/responsive-design-mode.md.
  270. */
  271. init: Task.async(function* () {
  272. let ui = this;
  273. // Watch for tab close and window close so we can clean up RDM synchronously
  274. this.tab.addEventListener("TabClose", this);
  275. this.browserWindow.addEventListener("unload", this);
  276. // Swap page content from the current tab into a viewport within RDM
  277. this.swap = swapToInnerBrowser({
  278. tab: this.tab,
  279. containerURL: TOOL_URL,
  280. getInnerBrowser: Task.async(function* (containerBrowser) {
  281. let toolWindow = ui.toolWindow = containerBrowser.contentWindow;
  282. toolWindow.addEventListener("message", ui);
  283. yield message.request(toolWindow, "init");
  284. toolWindow.addInitialViewport("about:blank");
  285. yield message.wait(toolWindow, "browser-mounted");
  286. return ui.getViewportBrowser();
  287. })
  288. });
  289. yield this.swap.start();
  290. this.tab.addEventListener("BeforeTabRemotenessChange", this);
  291. // Notify the inner browser to start the frame script
  292. yield message.request(this.toolWindow, "start-frame-script");
  293. // Get the protocol ready to speak with emulation actor
  294. yield this.connectToServer();
  295. // Non-blocking message to tool UI to start any delayed init activities
  296. message.post(this.toolWindow, "post-init");
  297. }),
  298. /**
  299. * Close RDM and restore page content back into a regular tab.
  300. *
  301. * @param object
  302. * Destroy options, which currently includes a `reason` string.
  303. * @return boolean
  304. * Whether this call is actually destroying. False means destruction
  305. * was already in progress.
  306. */
  307. destroy: Task.async(function* (options) {
  308. if (this.destroying) {
  309. return false;
  310. }
  311. this.destroying = true;
  312. // If our tab is about to be closed, there's not enough time to exit
  313. // gracefully, but that shouldn't be a problem since the tab will go away.
  314. // So, skip any yielding when we're about to close the tab.
  315. let isWindowClosing = options && options.reason === "unload";
  316. let isTabContentDestroying =
  317. isWindowClosing || (options && (options.reason === "TabClose" ||
  318. options.reason === "BeforeTabRemotenessChange"));
  319. // Ensure init has finished before starting destroy
  320. if (!isTabContentDestroying) {
  321. yield this.inited;
  322. }
  323. this.tab.removeEventListener("TabClose", this);
  324. this.tab.removeEventListener("BeforeTabRemotenessChange", this);
  325. this.browserWindow.removeEventListener("unload", this);
  326. this.toolWindow.removeEventListener("message", this);
  327. if (!isTabContentDestroying) {
  328. // Notify the inner browser to stop the frame script
  329. yield message.request(this.toolWindow, "stop-frame-script");
  330. }
  331. // Destroy local state
  332. let swap = this.swap;
  333. this.browserWindow = null;
  334. this.tab = null;
  335. this.inited = null;
  336. this.toolWindow = null;
  337. this.swap = null;
  338. // Close the debugger client used to speak with emulation actor.
  339. // The actor handles clearing any overrides itself, so it's not necessary to clear
  340. // anything on shutdown client side.
  341. let clientClosed = this.client.close();
  342. if (!isTabContentDestroying) {
  343. yield clientClosed;
  344. }
  345. this.client = this.emulationFront = null;
  346. if (!isWindowClosing) {
  347. // Undo the swap and return the content back to a normal tab
  348. swap.stop();
  349. }
  350. this.destroyed = true;
  351. return true;
  352. }),
  353. connectToServer: Task.async(function* () {
  354. if (!DebuggerServer.initialized) {
  355. DebuggerServer.init();
  356. DebuggerServer.addBrowserActors();
  357. }
  358. this.client = new DebuggerClient(DebuggerServer.connectPipe());
  359. yield this.client.connect();
  360. let { tab } = yield this.client.getTab();
  361. this.emulationFront = EmulationFront(this.client, tab);
  362. }),
  363. handleEvent(event) {
  364. let { browserWindow, tab } = this;
  365. switch (event.type) {
  366. case "message":
  367. this.handleMessage(event);
  368. break;
  369. case "BeforeTabRemotenessChange":
  370. case "TabClose":
  371. case "unload":
  372. ResponsiveUIManager.closeIfNeeded(browserWindow, tab, {
  373. reason: event.type,
  374. });
  375. break;
  376. }
  377. },
  378. handleMessage(event) {
  379. if (event.origin !== "chrome://devtools") {
  380. return;
  381. }
  382. switch (event.data.type) {
  383. case "change-device":
  384. this.onChangeDevice(event);
  385. break;
  386. case "change-network-throtting":
  387. this.onChangeNetworkThrottling(event);
  388. break;
  389. case "change-pixel-ratio":
  390. this.onChangePixelRatio(event);
  391. break;
  392. case "change-touch-simulation":
  393. this.onChangeTouchSimulation(event);
  394. break;
  395. case "content-resize":
  396. this.onContentResize(event);
  397. break;
  398. case "exit":
  399. this.onExit();
  400. break;
  401. case "remove-device":
  402. this.onRemoveDevice(event);
  403. break;
  404. }
  405. },
  406. onChangeDevice: Task.async(function* (event) {
  407. let { userAgent, pixelRatio, touch } = event.data.device;
  408. yield this.updateUserAgent(userAgent);
  409. yield this.updateDPPX(pixelRatio);
  410. yield this.updateTouchSimulation(touch);
  411. // Used by tests
  412. this.emit("device-changed");
  413. }),
  414. onChangeNetworkThrottling: Task.async(function* (event) {
  415. let { enabled, profile } = event.data;
  416. yield this.updateNetworkThrottling(enabled, profile);
  417. // Used by tests
  418. this.emit("network-throttling-changed");
  419. }),
  420. onChangePixelRatio(event) {
  421. let { pixelRatio } = event.data;
  422. this.updateDPPX(pixelRatio);
  423. },
  424. onChangeTouchSimulation(event) {
  425. let { enabled } = event.data;
  426. this.updateTouchSimulation(enabled);
  427. },
  428. onContentResize(event) {
  429. let { width, height } = event.data;
  430. this.emit("content-resize", {
  431. width,
  432. height,
  433. });
  434. },
  435. onExit() {
  436. let { browserWindow, tab } = this;
  437. ResponsiveUIManager.closeIfNeeded(browserWindow, tab);
  438. },
  439. onRemoveDevice: Task.async(function* (event) {
  440. yield this.updateUserAgent();
  441. yield this.updateDPPX();
  442. yield this.updateTouchSimulation();
  443. // Used by tests
  444. this.emit("device-removed");
  445. }),
  446. updateDPPX: Task.async(function* (dppx) {
  447. if (!dppx) {
  448. yield this.emulationFront.clearDPPXOverride();
  449. return;
  450. }
  451. yield this.emulationFront.setDPPXOverride(dppx);
  452. }),
  453. updateNetworkThrottling: Task.async(function* (enabled, profile) {
  454. if (!enabled) {
  455. yield this.emulationFront.clearNetworkThrottling();
  456. return;
  457. }
  458. let data = throttlingProfiles.find(({ id }) => id == profile);
  459. let { download, upload, latency } = data;
  460. yield this.emulationFront.setNetworkThrottling({
  461. downloadThroughput: download,
  462. uploadThroughput: upload,
  463. latency,
  464. });
  465. }),
  466. updateUserAgent: Task.async(function* (userAgent) {
  467. if (!userAgent) {
  468. yield this.emulationFront.clearUserAgentOverride();
  469. return;
  470. }
  471. yield this.emulationFront.setUserAgentOverride(userAgent);
  472. }),
  473. updateTouchSimulation: Task.async(function* (enabled) {
  474. if (!enabled) {
  475. yield this.emulationFront.clearTouchEventsOverride();
  476. return;
  477. }
  478. let reloadNeeded = yield this.emulationFront.setTouchEventsOverride(
  479. Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED
  480. );
  481. if (reloadNeeded) {
  482. this.getViewportBrowser().reload();
  483. }
  484. }),
  485. /**
  486. * Helper for tests. Assumes a single viewport for now.
  487. */
  488. getViewportSize() {
  489. return this.toolWindow.getViewportSize();
  490. },
  491. /**
  492. * Helper for tests, GCLI, etc. Assumes a single viewport for now.
  493. */
  494. setViewportSize: Task.async(function* (size) {
  495. yield this.inited;
  496. this.toolWindow.setViewportSize(size);
  497. }),
  498. /**
  499. * Helper for tests/reloading the viewport. Assumes a single viewport for now.
  500. */
  501. getViewportBrowser() {
  502. return this.toolWindow.getViewportBrowser();
  503. },
  504. /**
  505. * Helper for contacting the viewport content. Assumes a single viewport for now.
  506. */
  507. getViewportMessageManager() {
  508. return this.getViewportBrowser().messageManager;
  509. },
  510. };
  511. EventEmitter.decorate(ResponsiveUI.prototype);