Bug 1240900 - Connect primary browser UI to the viewport. r=ochameau draft
authorJ. Ryan Stinnett <jryans@gmail.com>
Fri, 13 May 2016 16:52:58 -0500
changeset 376532 e8a93699aab96a93d4f244a27f386a8cac176f5d
parent 375763 557f6bc6b70bd0dd1edaf601722c7d3997df8dc8
child 376533 dd5c1be1c8982c6f6dfef224b47306f8c88db615
push id20602
push userbmo:jryans@gmail.com
push dateWed, 08 Jun 2016 07:10:41 +0000
reviewersochameau
bugs1240900
milestone49.0a1
Bug 1240900 - Connect primary browser UI to the viewport. r=ochameau The primary browser navigational UI should now behave as if it's connected to the page content in the viewport, including things like: * Content page's URL is displayed in location bar * Content page's title is displayed on the tab * Back / forward navigates the viewport * Entering a location navigates the viewport * Page loading progress is displayed in the status bar as usual MozReview-Commit-ID: FzxWEwj13sJ
devtools/client/responsive.html/browser/moz.build
devtools/client/responsive.html/browser/swap.js
devtools/client/responsive.html/browser/tunnel.js
devtools/client/responsive.html/browser/web-navigation.js
devtools/client/responsive.html/test/browser/browser.ini
devtools/client/responsive.html/test/browser/browser_navigation.js
devtools/client/responsive.html/test/browser/browser_page_state.js
devtools/client/responsive.html/test/browser/doc_page_state.html
devtools/client/responsive.html/test/browser/head.js
devtools/docs/responsive-design-mode.md
--- a/devtools/client/responsive.html/browser/moz.build
+++ b/devtools/client/responsive.html/browser/moz.build
@@ -1,9 +1,11 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'swap.js',
+    'tunnel.js',
+    'web-navigation.js',
 )
--- a/devtools/client/responsive.html/browser/swap.js
+++ b/devtools/client/responsive.html/browser/swap.js
@@ -1,16 +1,17 @@
 /* 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 promise = require("promise");
 const { Task } = require("devtools/shared/task");
+const { tunnelToInnerBrowser } = require("./tunnel");
 
 /**
  * Swap page content from an existing tab into a new browser within a container
  * page.  Page state is preserved by using `swapFrameLoaders`, just like when
  * you move a tab to a new window.  This provides a seamless transition for the
  * user since the page is not reloaded.
  *
  * See /devtools/docs/responsive-design-mode.md for a high level overview of how
@@ -27,20 +28,24 @@ const { Task } = require("devtools/share
  * @param getInnerBrowser
  *        Function that returns a Promise to the inner browser within the
  *        container page.  It is called with the outer browser that loaded the
  *        container page.
  */
 function swapToInnerBrowser({ tab, containerURL, getInnerBrowser }) {
   let gBrowser = tab.ownerDocument.defaultView.gBrowser;
   let innerBrowser;
+  let tunnel;
 
   return {
 
     start: Task.async(function* () {
+      // Freeze navigation temporarily to avoid "blinking" in the location bar.
+      freezeNavigationState(tab);
+
       // 1. Create a temporary, hidden tab to load the tool UI.
       let containerTab = gBrowser.addTab(containerURL, {
         skipAnimation: true,
       });
       gBrowser.hideTab(containerTab);
       let containerBrowser = containerTab.linkedBrowser;
 
       // 2. Mark the tool tab browser's docshell as active so the viewport frame
@@ -73,52 +78,101 @@ function swapToInnerBrowser({ tab, conta
       //    must be loaded in the parent process, and we're about to swap the
       //    tool UI into this tab.
       gBrowser.updateBrowserRemoteness(tab.linkedBrowser, false);
 
       // 6. Swap the tool UI (with viewport showing the content) into the
       //    original browser tab and close the temporary tab used to load the
       //    tool via `swapBrowsersAndCloseOther`.
       gBrowser.swapBrowsersAndCloseOther(tab, containerTab);
+
+      // 7. Start a tunnel from the tool tab's browser to the viewport browser
+      //    so that some browser UI functions, like navigation, are connected to
+      //    the content in the viewport, instead of the tool page.
+      tunnel = tunnelToInnerBrowser(tab.linkedBrowser, innerBrowser);
+      yield tunnel.start();
+
+      // Force the browser UI to match the new state of the tab and browser.
+      thawNavigationState(tab);
+      gBrowser.setTabTitle(tab);
+      gBrowser.updateCurrentBrowser(true);
     }),
 
     stop() {
-      // 1. Create a temporary, hidden tab to hold the content.
+      // 1. Stop the tunnel between outer and inner browsers.
+      tunnel.stop();
+      tunnel = null;
+
+      // 2. Create a temporary, hidden tab to hold the content.
       let contentTab = gBrowser.addTab("about:blank", {
         skipAnimation: true,
       });
       gBrowser.hideTab(contentTab);
       let contentBrowser = contentTab.linkedBrowser;
 
-      // 2. Mark the content tab browser's docshell as active so the frame
+      // 3. Mark the content tab browser's docshell as active so the frame
       //    is created eagerly and will be ready to swap.
       contentBrowser.docShellIsActive = true;
 
-      // 3. Swap tab content from the browser within the viewport in the tool UI
+      // 4. Swap tab content from the browser within the viewport in the tool UI
       //    to the regular browser tab, preserving all state via
       //    `gBrowser._swapBrowserDocShells`.
       gBrowser._swapBrowserDocShells(contentTab, innerBrowser);
       innerBrowser = null;
 
-      // 4. Force the original browser tab to be remote since web content is
+      // 5. Force the original browser tab to be remote since web content is
       //    loaded in the child process, and we're about to swap the content
       //    into this tab.
       gBrowser.updateBrowserRemoteness(tab.linkedBrowser, true);
 
-      // 5. Swap the content into the original browser tab and close the
+      // 6. Swap the content into the original browser tab and close the
       //    temporary tab used to hold the content via
       //    `swapBrowsersAndCloseOther`.
       gBrowser.swapBrowsersAndCloseOther(tab, contentTab);
       gBrowser = null;
     },
 
   };
 }
 
 /**
+ * Browser navigation properties we'll freeze temporarily to avoid "blinking" in the
+ * location bar, etc. caused by the containerURL peeking through before the swap is
+ * complete.
+ */
+const NAVIGATION_PROPERTIES = [
+  "currentURI",
+  "contentTitle",
+  "securityUI",
+];
+
+function freezeNavigationState(tab) {
+  // Browser navigation properties we'll freeze temporarily to avoid "blinking" in the
+  // location bar, etc. caused by the containerURL peeking through before the swap is
+  // complete.
+  for (let property of NAVIGATION_PROPERTIES) {
+    let value = tab.linkedBrowser[property];
+    Object.defineProperty(tab.linkedBrowser, property, {
+      get() {
+        return value;
+      },
+      configurable: true,
+      enumerable: true,
+    });
+  }
+}
+
+function thawNavigationState(tab) {
+  // Thaw out the properties we froze at the beginning now that the swap is complete.
+  for (let property of NAVIGATION_PROPERTIES) {
+    delete tab.linkedBrowser[property];
+  }
+}
+
+/**
  * Browser elements that are passed to `gBrowser._swapBrowserDocShells` are
  * expected to have certain properties that currently exist only on
  * <xul:browser> elements.  In particular, <iframe mozbrowser> elements don't
  * have them.
  *
  * Rather than duplicate the swapping code used by the browser to work around
  * this, we stub out the missing properties needed for the swap to complete.
  */
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/browser/tunnel.js
@@ -0,0 +1,411 @@
+/* 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 Services = require("Services");
+const { Task } = require("devtools/shared/task");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const { BrowserElementWebNavigation } = require("./web-navigation");
+
+function debug(msg) {
+  // console.log(msg);
+}
+
+/**
+ * Properties swapped between browsers by browser.xml's `swapDocShells`.  See also the
+ * list at /devtools/client/responsive.html/docs/browser-swap.md.
+ */
+const SWAPPED_BROWSER_STATE = [
+  "_securityUI",
+  "_documentURI",
+  "_documentContentType",
+  "_contentTitle",
+  "_characterSet",
+  "_contentPrincipal",
+  "_imageDocument",
+  "_fullZoom",
+  "_textZoom",
+  "_isSyntheticDocument",
+  "_innerWindowID",
+  "_manifestURI",
+];
+
+/**
+ * This module takes an "outer" <xul:browser> from a browser tab as described by
+ * Firefox's tabbrowser.xml and wires it up to an "inner" <iframe mozbrowser>
+ * browser element containing arbitrary page content of interest.
+ *
+ * The inner <iframe mozbrowser> element is _just_ the page content.  It is not
+ * enough to to replace <xul:browser> on its own.  <xul:browser> comes along
+ * with lots of associated functionality via XBL bindings defined for such
+ * elements in browser.xml and remote-browser.xml, and the Firefox UI depends on
+ * these various things to make the UI function.
+ *
+ * By mapping various methods, properties, and messages from the outer browser
+ * to the inner browser, we can control the content inside the inner browser
+ * using the standard Firefox UI elements for navigation, reloading, and more.
+ *
+ * The approaches used in this module were chosen to avoid needing changes to
+ * the core browser for this specialized use case.  If we start to increase
+ * usage of <iframe mozbrowser> in the core browser, we should avoid this module
+ * and instead refactor things to work with mozbrowser directly.
+ *
+ * For the moment though, this serves as a sufficient path to connect the
+ * Firefox UI to a mozbrowser.
+ *
+ * @param outer
+ *        A <xul:browser> from a regular browser tab.
+ * @param inner
+ *        A <iframe mozbrowser> containing page content to be wired up to the
+ *        primary browser UI via the outer browser.
+ */
+function tunnelToInnerBrowser(outer, inner) {
+  let browserWindow = outer.ownerDocument.defaultView;
+  let gBrowser = browserWindow.gBrowser;
+  let mmTunnel;
+
+  return {
+
+    start: Task.async(function* () {
+      if (outer.isRemoteBrowser) {
+        throw new Error("The outer browser must be non-remote.");
+      }
+      if (!inner.isRemoteBrowser) {
+        throw new Error("The inner browser must be remote.");
+      }
+
+      // The `permanentKey` property on a <xul:browser> is used to index into various maps
+      // held by the session store.  When you swap content around with
+      // `_swapBrowserDocShells`, these keys are also swapped so they follow the content.
+      // This means the key that matches the content is on the inner browser.  Since we
+      // want the browser UI to believe the page content is part of the outer browser, we
+      // copy the content's `permanentKey` up to the outer browser.
+      copyPermanentKey(outer, inner);
+
+      // Replace the outer browser's native messageManager with a message manager tunnel
+      // which we can use to route messages of interest to the inner browser instead.
+      // Note: The _actual_ messageManager accessible from
+      // `browser.frameLoader.messageManager` is not overridable and is left unchanged.
+      // Only the XBL getter `browser.messageManager` is overridden.  Browser UI code
+      // always uses this getter instead of `browser.frameLoader.messageManager` directly,
+      // so this has the effect of overriding the message manager for browser UI code.
+      mmTunnel = new MessageManagerTunnel(outer, inner);
+      Object.defineProperty(outer, "messageManager", {
+        value: mmTunnel,
+        writable: false,
+        configurable: true,
+        enumerable: true,
+      });
+
+      // We are tunneling to an inner browser with a specific remoteness, so it is simpler
+      // for the logic of the browser UI to assume this tab has taken on that remoteness,
+      // even though it's not true.  Since the actions the browser UI performs are sent
+      // down to the inner browser by this tunnel, the tab's remoteness effectively is the
+      // remoteness of the inner browser.
+      Object.defineProperty(outer, "isRemoteBrowser", {
+        get() {
+          return true;
+        },
+        configurable: true,
+        enumerable: true,
+      });
+
+      // Clear out any cached state that references the current non-remote XBL binding,
+      // such as form fill controllers.  Otherwise they will remain in place and leak the
+      // outer docshell.
+      outer.destroy();
+      // The XBL binding for remote browsers uses the message manager for many actions in
+      // the UI and that works well here, since it gives us one main thing we need to
+      // route to the inner browser (the messages), instead of having to tweak many
+      // different browser properties.  It is safe to alter a XBL binding dynamically.
+      // The content within is not reloaded.
+      outer.style.MozBinding = "url(chrome://browser/content/tabbrowser.xml" +
+                               "#tabbrowser-remote-browser)";
+
+      // The constructor of the new XBL binding is run asynchronously and there is no
+      // event to signal its completion.  Spin an event loop to watch for properties that
+      // are set by the contructor.
+      while (!outer._remoteWebNavigation) {
+        Services.tm.currentThread.processNextEvent(true);
+      }
+
+      // Replace the `webNavigation` object with our own version which tries to use
+      // mozbrowser APIs where possible.  This replaces the webNavigation object that the
+      // remote-browser.xml binding creates.  We do not care about it's original value
+      // because stop() will remove the remote-browser.xml binding and these will no
+      // longer be used.
+      let webNavigation = new BrowserElementWebNavigation(inner);
+      webNavigation.copyStateFrom(inner._remoteWebNavigationImpl);
+      outer._remoteWebNavigation = webNavigation;
+      outer._remoteWebNavigationImpl = webNavigation;
+
+      // Now that we've flipped to the remote browser XBL binding, add `progressListener`
+      // onto the remote version of `webProgress`.  Normally tabbrowser.xml does this step
+      // when it creates a new browser, etc.  Since we manually changed the XBL binding
+      // above, it caused a fresh webProgress object to be created which does not have any
+      // listeners added.  So, we get the listener that gBrowser is using for the tab and
+      // reattach it here.
+      let tab = gBrowser.getTabForBrowser(outer);
+      let filteredProgressListener = gBrowser._tabFilters.get(tab);
+      outer.webProgress.addProgressListener(filteredProgressListener);
+
+      // All of the browser state from content was swapped onto the inner browser.  Pull
+      // this state up to the outer browser.
+      for (let property of SWAPPED_BROWSER_STATE) {
+        outer[property] = inner[property];
+      }
+
+      // Wants to access the content's `frameLoader`, so we'll redirect it to
+      // inner browser.
+      Object.defineProperty(outer, "hasContentOpener", {
+        get() {
+          return inner.frameLoader.tabParent.hasContentOpener;
+        },
+        configurable: true,
+        enumerable: true,
+      });
+
+      // Wants to access the content's `frameLoader`, so we'll redirect it to
+      // inner browser.
+      Object.defineProperty(outer, "docShellIsActive", {
+        get() {
+          return inner.frameLoader.tabParent.docShellIsActive;
+        },
+        set(value) {
+          inner.frameLoader.tabParent.docShellIsActive = value;
+        },
+        configurable: true,
+        enumerable: true,
+      });
+
+      // Wants to access the content's `frameLoader`, so we'll redirect it to
+      // inner browser.
+      outer.setDocShellIsActiveAndForeground = value => {
+        inner.frameLoader.tabParent.setDocShellIsActiveAndForeground(value);
+      };
+    }),
+
+    stop() {
+      let tab = gBrowser.getTabForBrowser(outer);
+      let filteredProgressListener = gBrowser._tabFilters.get(tab);
+      browserWindow = null;
+      gBrowser = null;
+
+      // The browser's state has changed over time while the tunnel was active.  Push the
+      // the current state down to the inner browser, so that it follows the content in
+      // case that browser will be swapped elsewhere.
+      for (let property of SWAPPED_BROWSER_STATE) {
+        inner[property] = outer[property];
+      }
+
+      // Remove the progress listener we added manually.
+      outer.webProgress.removeProgressListener(filteredProgressListener);
+
+      // Reset the XBL binding back to the default.
+      outer.destroy();
+      outer.style.MozBinding = "";
+
+      // Reset overridden XBL properties and methods.  Deleting the override
+      // means it will fallback to the original XBL binding definitions which
+      // are on the prototype.
+      delete outer.messageManager;
+      delete outer.isRemoteBrowser;
+      delete outer.hasContentOpener;
+      delete outer.docShellIsActive;
+      delete outer.setDocShellIsActiveAndForeground;
+
+      mmTunnel.destroy();
+      mmTunnel = null;
+
+      // Invalidate outer's permanentKey so that SessionStore stops associating
+      // things that happen to the outer browser with the content inside in the
+      // inner browser.
+      outer.permanentKey = { id: "zombie" };
+    },
+
+  };
+}
+
+exports.tunnelToInnerBrowser = tunnelToInnerBrowser;
+
+function copyPermanentKey(outer, inner) {
+  // When we're in the process of swapping content around, we end up receiving a
+  // SessionStore:update message which lists the container page that is loaded into the
+  // outer browser (that we're hiding the inner browser within) as part of its history.
+  // We want SessionStore's view of the history for our tab to only have the page content
+  // of the inner browser, so we wait until the one errant message has gone by, and then
+  // we copy the permanentKey after that.
+  let outerMM = outer.frameLoader.messageManager;
+  let onHistoryEntry = message => {
+    let history = message.data.data.history;
+    if (!history || !history.entries) {
+      // Wait for a message that contains history data
+      return;
+    }
+    outerMM.removeMessageListener("SessionStore:update", onHistoryEntry);
+    debug("Got session update for outer browser");
+    // Wait until the next tick so that SessionStore has received this same message too.
+    // This message will be delivered to SessionStore's `receiveMessage` handler by the
+    // platform layer right after this one returns.  We are in fact _depending on_
+    // SessionStore to receive this one message first _before_ we copy the `permanentKey`
+    // below.  That's because this message contains the container page URL that we don't
+    // want SessionStore to record.  So, it will receive this session update, and try to
+    // record it in a map keyed by the outer browser's `permanentKey`.  Since the outer
+    // browser has a nonsense value as its `permanentKey` until the copy takes place
+    // below, this means SessionStore drops this session update as invalid data, which is
+    // great for us, since we don't want it to be known anyway.  We then copy the
+    // `permanentKey` across in the next tick so that all future session updates (which
+    // will be about actual content page navigation we _want_ to record) will be stored.
+    DevToolsUtils.executeSoon(() => {
+      debug("Copy inner permanentKey to outer browser");
+      outer.permanentKey = inner.permanentKey;
+    });
+  };
+  outerMM.addMessageListener("SessionStore:update", onHistoryEntry);
+}
+
+/**
+ * This module allows specific messages of interest to be directed from the
+ * outer browser to the inner browser (and vice versa) in a targetted fashion
+ * without having to touch the original code paths that use them.
+ */
+function MessageManagerTunnel(outer, inner) {
+  if (outer.isRemoteBrowser) {
+    throw new Error("The outer browser must be non-remote.");
+  }
+  this.outer = outer;
+  this.inner = inner;
+  this.init();
+}
+
+MessageManagerTunnel.prototype = {
+
+  /**
+   * Most message manager methods are left alone and are just passed along to
+   * the outer browser's real message manager.  `sendAsyncMessage` is only one
+   * with special behavior.
+   */
+  PASS_THROUGH_METHODS: [
+    "addMessageListener",
+    "loadFrameScript",
+    "killChild",
+    "assertPermission",
+    "assertContainApp",
+    "assertAppHasPermission",
+    "assertAppHasStatus",
+    "removeDelayedFrameScript",
+    "getDelayedFrameScripts",
+    "loadProcessScript",
+    "removeDelayedProcessScript",
+    "getDelayedProcessScripts",
+    "removeMessageListener",
+    "addWeakMessageListener",
+    "removeWeakMessageListener",
+  ],
+
+  OUTER_TO_INNER_MESSAGES: [
+    // Messages sent from remote-browser.xml
+    "Browser:PurgeSessionHistory",
+    "InPermitUnload",
+    "PermitUnload",
+    // Messages sent from browser.js
+    "Browser:Reload",
+    // Messages sent from SelectParentHelper.jsm
+    "Forms:DismissedDropDown",
+    "Forms:MouseOut",
+    "Forms:MouseOver",
+    "Forms:SelectDropDownItem",
+    // Messages sent from SessionStore.jsm
+    "SessionStore:flush",
+  ],
+
+  INNER_TO_OUTER_MESSAGES: [
+    // Messages sent to RemoteWebProgress.jsm
+    "Content:LoadURIResult",
+    "Content:LocationChange",
+    "Content:ProgressChange",
+    "Content:SecurityChange",
+    "Content:StateChange",
+    "Content:StatusChange",
+    // Messages sent to remote-browser.xml
+    "DOMTitleChanged",
+    "ImageDocumentLoaded",
+    "Forms:ShowDropDown",
+    "Forms:HideDropDown",
+    "InPermitUnload",
+    "PermitUnload",
+    // Messages sent to SelectParentHelper.jsm
+    "Forms:UpdateDropDown",
+    // Messages sent to browser.js
+    "PageVisibility:Hide",
+    "PageVisibility:Show",
+    // Messages sent to SessionStore.jsm
+    "SessionStore:update",
+    // Messages sent to BrowserTestUtils.jsm
+    "browser-test-utils:loadEvent",
+  ],
+
+  get outerParentMM() {
+    return this.outer.frameLoader.messageManager;
+  },
+
+  get outerChildMM() {
+    // This is only possible because we require the outer browser to be
+    // non-remote, so we're able to reach into its window and use the child
+    // side message manager there.
+    let docShell = this.outer.frameLoader.docShell;
+    return docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                   .getInterface(Ci.nsIContentFrameMessageManager);
+  },
+
+  get innerParentMM() {
+    return this.inner.frameLoader.messageManager;
+  },
+
+  sendAsyncMessage(name, ...args) {
+    debug(`Calling sendAsyncMessage for ${name}`);
+
+    if (!this.OUTER_TO_INNER_MESSAGES.includes(name)) {
+      debug(`Should ${name} go to inner?`);
+      this.outerParentMM.sendAsyncMessage(name, ...args);
+      return;
+    }
+
+    debug(`${name} outer -> inner`);
+    this.innerParentMM.sendAsyncMessage(name, ...args);
+  },
+
+  init() {
+    for (let method of this.PASS_THROUGH_METHODS) {
+      // Workaround bug 449811 to ensure a fresh binding each time through the loop
+      let _method = method;
+      this[_method] = (...args) => {
+        return this.outerParentMM[_method](...args);
+      };
+    }
+
+    for (let message of this.INNER_TO_OUTER_MESSAGES) {
+      this.innerParentMM.addMessageListener(message, this);
+    }
+  },
+
+  destroy() {
+    for (let message of this.INNER_TO_OUTER_MESSAGES) {
+      this.innerParentMM.removeMessageListener(message, this);
+    }
+  },
+
+  receiveMessage({ name, data, objects, principal }) {
+    if (!this.INNER_TO_OUTER_MESSAGES.includes(name)) {
+      debug(`Received unexpected message ${name}`);
+      return;
+    }
+
+    debug(`${name} inner -> outer`);
+    this.outerChildMM.sendAsyncMessage(name, data, objects, principal);
+  },
+
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/browser/web-navigation.js
@@ -0,0 +1,167 @@
+/* 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, Cu, Cr } = require("chrome");
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+const Services = require("Services");
+const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
+
+function readInputStreamToString(stream) {
+  return NetUtil.readInputStreamToString(stream, stream.available());
+}
+
+/**
+ * This object aims to provide the nsIWebNavigation interface for mozbrowser elements.
+ * nsIWebNavigation is one of the interfaces expected on <xul:browser>s, so this wrapper
+ * helps mozbrowser elements support this.
+ *
+ * It attempts to use the mozbrowser API wherever possible, however some methods don't
+ * exist yet, so we fallback to the WebNavigation frame script messages in those cases.
+ * Ideally the mozbrowser API would eventually be extended to cover all properties and
+ * methods used here.
+ *
+ * This is largely copied from RemoteWebNavigation.js, which uses the message manager to
+ * perform all actions.
+ */
+function BrowserElementWebNavigation(browser) {
+  this._browser = browser;
+}
+
+BrowserElementWebNavigation.prototype = {
+
+  QueryInterface: XPCOMUtils.generateQI([
+    Ci.nsIWebNavigation,
+    Ci.nsISupports
+  ]),
+
+  LOAD_FLAGS_MASK: 65535,
+  LOAD_FLAGS_NONE: 0,
+  LOAD_FLAGS_IS_REFRESH: 16,
+  LOAD_FLAGS_IS_LINK: 32,
+  LOAD_FLAGS_BYPASS_HISTORY: 64,
+  LOAD_FLAGS_REPLACE_HISTORY: 128,
+  LOAD_FLAGS_BYPASS_CACHE: 256,
+  LOAD_FLAGS_BYPASS_PROXY: 512,
+  LOAD_FLAGS_CHARSET_CHANGE: 1024,
+  LOAD_FLAGS_STOP_CONTENT: 2048,
+  LOAD_FLAGS_FROM_EXTERNAL: 4096,
+  LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP: 8192,
+  LOAD_FLAGS_FIRST_LOAD: 16384,
+  LOAD_FLAGS_ALLOW_POPUPS: 32768,
+  LOAD_FLAGS_BYPASS_CLASSIFIER: 65536,
+  LOAD_FLAGS_FORCE_ALLOW_COOKIES: 131072,
+
+  STOP_NETWORK: 1,
+  STOP_CONTENT: 2,
+  STOP_ALL: 3,
+
+  get _mm() {
+    return this._browser.frameLoader.messageManager;
+  },
+
+  canGoBack: false,
+  canGoForward: false,
+
+  goBack() {
+    this._browser.goBack();
+  },
+
+  goForward() {
+    this._browser.goForward();
+  },
+
+  gotoIndex(index) {
+    // No equivalent in the current BrowserElement API
+    this._sendMessage("WebNavigation:GotoIndex", { index });
+  },
+
+  loadURI(uri, flags, referrer, postData, headers) {
+    // No equivalent in the current BrowserElement API
+    this.loadURIWithOptions(uri, flags, referrer,
+                            Ci.nsIHttpChannel.REFERRER_POLICY_DEFAULT,
+                            postData, headers, null);
+  },
+
+  loadURIWithOptions(uri, flags, referrer, referrerPolicy, postData, headers,
+                     baseURI) {
+    // No equivalent in the current BrowserElement API
+    this._sendMessage("WebNavigation:LoadURI", {
+      uri,
+      flags,
+      referrer: referrer ? referrer.spec : null,
+      referrerPolicy: referrerPolicy,
+      postData: postData ? readInputStreamToString(postData) : null,
+      headers: headers ? readInputStreamToString(headers) : null,
+      baseURI: baseURI ? baseURI.spec : null,
+    });
+  },
+
+  reload(flags) {
+    let hardReload = false;
+    if (flags & this.LOAD_FLAGS_BYPASS_PROXY ||
+        flags & this.LOAD_FLAGS_BYPASS_CACHE) {
+      hardReload = true;
+    }
+    this._browser.reload(hardReload);
+  },
+
+  stop(flags) {
+    // No equivalent in the current BrowserElement API
+    this._sendMessage("WebNavigation:Stop", { flags });
+  },
+
+  get document() {
+    return this._browser.contentDocument;
+  },
+
+  _currentURI: null,
+  get currentURI() {
+    if (!this._currentURI) {
+      this._currentURI = Services.io.newURI("about:blank", null, null);
+    }
+    return this._currentURI;
+  },
+  set currentURI(uri) {
+    this._browser.src = uri.spec;
+  },
+
+  referringURI: null,
+
+  // Bug 1233803 - accessing the sessionHistory of remote browsers should be
+  // done in content scripts.
+  get sessionHistory() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+  set sessionHistory(value) {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  _sendMessage(message, data) {
+    try {
+      this._mm.sendAsyncMessage(message, data);
+    } catch (e) {
+      Cu.reportError(e);
+    }
+  },
+
+  swapBrowser(browser) {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  copyStateFrom(otherWebNavigation) {
+    const state = [
+      "canGoBack",
+      "canGoForward",
+      "_currentURI",
+    ];
+    for (let property of state) {
+      this[property] = otherWebNavigation[property];
+    }
+  },
+
+};
+
+exports.BrowserElementWebNavigation = BrowserElementWebNavigation;
--- a/devtools/client/responsive.html/test/browser/browser.ini
+++ b/devtools/client/responsive.html/test/browser/browser.ini
@@ -14,15 +14,16 @@ support-files =
 [browser_device_modal_exit.js]
 [browser_device_modal_submit.js]
 [browser_device_width.js]
 [browser_exit_button.js]
 [browser_frame_script_active.js]
 [browser_menu_item_01.js]
 [browser_menu_item_02.js]
 [browser_mouse_resize.js]
+[browser_navigation.js]
 [browser_page_state.js]
 [browser_resize_cmd.js]
 skip-if = true # GCLI target confused after swap, will fix in bug 1240907
 [browser_screenshot_button.js]
 [browser_shutdown_close_sync.js]
 [browser_touch_simulation.js]
 [browser_viewport_basics.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_navigation.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the primary browser navigation UI to verify it's connected to the viewport.
+
+const DUMMY_1_URL = "http://example.com/";
+const TEST_URL = `${URL_ROOT}doc_page_state.html`;
+const DUMMY_2_URL = "http://example.com/browser/";
+const DUMMY_3_URL = "http://example.com/browser/devtools/";
+
+add_task(function* () {
+  // Load up a sequence of pages:
+  // 0. DUMMY_1_URL
+  // 1. TEST_URL
+  // 2. DUMMY_2_URL
+  let tab = yield addTab(DUMMY_1_URL);
+  let browser = tab.linkedBrowser;
+  yield load(browser, TEST_URL);
+  yield load(browser, DUMMY_2_URL);
+
+  // Check session history state
+  let history = yield getSessionHistory(browser);
+  is(history.index, 2, "At page 2 in history");
+  is(history.entries.length, 3, "3 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+  is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+  // Go back one so we're at the test page
+  yield back(browser);
+
+  // Check session history state
+  history = yield getSessionHistory(browser);
+  is(history.index, 1, "At page 1 in history");
+  is(history.entries.length, 3, "3 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+  is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+  yield openRDM(tab);
+
+  ok(browser.webNavigation.canGoBack, "Going back is allowed");
+  ok(browser.webNavigation.canGoForward, "Going forward is allowed");
+  is(browser.documentURI.spec, TEST_URL, "documentURI matches page 1");
+  is(browser.contentTitle, "Page State Test", "contentTitle matches page 1");
+
+  yield forward(browser);
+
+  ok(browser.webNavigation.canGoBack, "Going back is allowed");
+  ok(!browser.webNavigation.canGoForward, "Going forward is not allowed");
+  is(browser.documentURI.spec, DUMMY_2_URL, "documentURI matches page 2");
+  is(browser.contentTitle, "mochitest index /browser/", "contentTitle matches page 2");
+
+  yield back(browser);
+  yield back(browser);
+
+  ok(!browser.webNavigation.canGoBack, "Going back is not allowed");
+  ok(browser.webNavigation.canGoForward, "Going forward is allowed");
+  is(browser.documentURI.spec, DUMMY_1_URL, "documentURI matches page 0");
+  is(browser.contentTitle, "mochitest index /", "contentTitle matches page 0");
+
+  let receivedStatusChanges = new Promise(resolve => {
+    let statusChangesSeen = 0;
+    let statusChangesExpected = 2;
+    let progressListener = {
+      onStatusChange(webProgress, request, status, message) {
+        info(message);
+        if (++statusChangesSeen == statusChangesExpected) {
+          gBrowser.removeProgressListener(progressListener);
+          ok(true, `${statusChangesExpected} status changes while loading`);
+          resolve();
+        }
+      }
+    };
+    gBrowser.addProgressListener(progressListener);
+  });
+  yield load(browser, DUMMY_3_URL);
+  yield receivedStatusChanges;
+
+  ok(browser.webNavigation.canGoBack, "Going back is allowed");
+  ok(!browser.webNavigation.canGoForward, "Going forward is not allowed");
+  is(browser.documentURI.spec, DUMMY_3_URL, "documentURI matches page 3");
+  is(browser.contentTitle, "mochitest index /browser/devtools/",
+     "contentTitle matches page 3");
+
+  yield closeRDM(tab);
+
+  // Check session history state
+  history = yield getSessionHistory(browser);
+  is(history.index, 1, "At page 1 in history");
+  is(history.entries.length, 2, "2 pages in history");
+  is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+  is(history.entries[1].uri, DUMMY_3_URL, "Page 1 URL matches");
+
+  yield removeTab(tab);
+});
--- a/devtools/client/responsive.html/test/browser/browser_page_state.js
+++ b/devtools/client/responsive.html/test/browser/browser_page_state.js
@@ -12,37 +12,29 @@ const DUMMY_2_URL = "http://example.com/
 
 add_task(function* () {
   // Load up a sequence of pages:
   // 0. DUMMY_1_URL
   // 1. TEST_URL
   // 2. DUMMY_2_URL
   let tab = yield addTab(DUMMY_1_URL);
   let browser = tab.linkedBrowser;
-
-  let loaded = BrowserTestUtils.browserLoaded(browser, false, TEST_URL);
-  browser.loadURI(TEST_URL, null, null);
-  yield loaded;
-
-  loaded = BrowserTestUtils.browserLoaded(browser, false, DUMMY_2_URL);
-  browser.loadURI(DUMMY_2_URL, null, null);
-  yield loaded;
+  yield load(browser, TEST_URL);
+  yield load(browser, DUMMY_2_URL);
 
   // Check session history state
   let history = yield getSessionHistory(browser);
   is(history.index, 2, "At page 2 in history");
   is(history.entries.length, 3, "3 pages in history");
   is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
   is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
   is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
 
   // Go back one so we're at the test page
-  let shown = waitForPageShow(browser);
-  browser.goBack();
-  yield shown;
+  yield back(browser);
 
   // Check session history state
   history = yield getSessionHistory(browser);
   is(history.index, 1, "At page 1 in history");
   is(history.entries.length, 3, "3 pages in history");
   is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
   is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
   is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
@@ -77,46 +69,8 @@ add_task(function* () {
   is(history.index, 1, "At page 1 in history");
   is(history.entries.length, 3, "3 pages in history");
   is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
   is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
   is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
 
   yield removeTab(tab);
 });
-
-function getSessionHistory(browser) {
-  return ContentTask.spawn(browser, {}, function* () {
-    /* eslint-disable no-undef */
-    let { interfaces: Ci } = Components;
-    let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
-    let sessionHistory = webNav.sessionHistory;
-    let result = {
-      index: sessionHistory.index,
-      entries: []
-    };
-
-    for (let i = 0; i < sessionHistory.count; i++) {
-      let entry = sessionHistory.getEntryAtIndex(i, false);
-      result.entries.push({
-        uri: entry.URI.spec,
-        title: entry.title
-      });
-    }
-
-    return result;
-    /* eslint-enable no-undef */
-  });
-}
-
-function waitForPageShow(browser) {
-  let mm = browser.messageManager;
-  return new Promise(resolve => {
-    let onShow = message => {
-      if (message.target != browser) {
-        return;
-      }
-      mm.removeMessageListener("PageVisibility:Show", onShow);
-      resolve();
-    };
-    mm.addMessageListener("PageVisibility:Show", onShow);
-  });
-}
--- a/devtools/client/responsive.html/test/browser/doc_page_state.html
+++ b/devtools/client/responsive.html/test/browser/doc_page_state.html
@@ -1,13 +1,16 @@
 <!doctype html>
 <html>
-  <style>
-    body {
-      height: 100vh;
-      background: red;
-    }
-    body.modified {
-      background: green;
-    }
-  </style>
+  <head>
+    <title>Page State Test</title>
+    <style>
+      body {
+        height: 100vh;
+        background: red;
+      }
+      body.modified {
+        background: green;
+      }
+    </style>
+  </head>
   <body onclick="this.classList.add('modified')"/>
 </html>
--- a/devtools/client/responsive.html/test/browser/head.js
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -148,8 +148,64 @@ function openDeviceModal(ui) {
   EventUtils.synthesizeMouseAtCenter(select, {type: "mousedown"},
     ui.toolWindow);
   EventUtils.synthesizeMouseAtCenter(editDeviceOption, {type: "mouseup"},
     ui.toolWindow);
 
   ok(!modal.classList.contains("hidden"),
     "The device modal is displayed.");
 }
+
+function getSessionHistory(browser) {
+  return ContentTask.spawn(browser, {}, function* () {
+    /* eslint-disable no-undef */
+    let { interfaces: Ci } = Components;
+    let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
+    let sessionHistory = webNav.sessionHistory;
+    let result = {
+      index: sessionHistory.index,
+      entries: []
+    };
+
+    for (let i = 0; i < sessionHistory.count; i++) {
+      let entry = sessionHistory.getEntryAtIndex(i, false);
+      result.entries.push({
+        uri: entry.URI.spec,
+        title: entry.title
+      });
+    }
+
+    return result;
+    /* eslint-enable no-undef */
+  });
+}
+
+function waitForPageShow(browser) {
+  let mm = browser.messageManager;
+  return new Promise(resolve => {
+    let onShow = message => {
+      if (message.target != browser) {
+        return;
+      }
+      mm.removeMessageListener("PageVisibility:Show", onShow);
+      resolve();
+    };
+    mm.addMessageListener("PageVisibility:Show", onShow);
+  });
+}
+
+function load(browser, url) {
+  let loaded = BrowserTestUtils.browserLoaded(browser, false, url);
+  browser.loadURI(url, null, null);
+  return loaded;
+}
+
+function back(browser) {
+  let shown = waitForPageShow(browser);
+  browser.goBack();
+  return shown;
+}
+
+function forward(browser) {
+  let shown = waitForPageShow(browser);
+  browser.goForward();
+  return shown;
+}
--- a/devtools/docs/responsive-design-mode.md
+++ b/devtools/docs/responsive-design-mode.md
@@ -28,31 +28,35 @@ 4. Swap tab content from the regular bro
    viewport in the tool UI, preserving all state via
    `gBrowser._swapBrowserDocShells`.
 5. Force the original browser tab to be non-remote since the tool UI must be
    loaded in the parent process, and we're about to swap the tool UI into
    this tab.
 6. Swap the tool UI (with viewport showing the content) into the original
    browser tab and close the temporary tab used to load the tool via
    `swapBrowsersAndCloseOther`.
+7. Start a tunnel from the tool tab's browser to the viewport browser
+   so that some browser UI functions, like navigation, are connected to
+   the content in the viewport, instead of the tool page.
 
 ## Closing RDM During Current Firefox Session
 
 To close RDM, we follow a similar process to the one from opening RDM so we can
 restore the content back to a normal tab.
 
-1. Create a temporary, hidden tab to hold the content.
-2. Mark the content tab browser's docshell as active so the frame is created
+1. Stop the tunnel between outer and inner browsers.
+2. Create a temporary, hidden tab to hold the content.
+3. Mark the content tab browser's docshell as active so the frame is created
    eagerly and will be ready to swap.
-3. Swap tab content from the browser within the viewport in the tool UI to the
+4. Swap tab content from the browser within the viewport in the tool UI to the
    regular browser tab, preserving all state via
    `gBrowser._swapBrowserDocShells`.
-4. Force the original browser tab to be remote since web content is loaded in
+5. Force the original browser tab to be remote since web content is loaded in
    the child process, and we're about to swap the content into this tab.
-5. Swap the content into the original browser tab and close the temporary tab
+6. Swap the content into the original browser tab and close the temporary tab
    used to hold the content via `swapBrowsersAndCloseOther`.
 
 ## Session Restore
 
 When restarting Firefox and restoring a user's browsing session, we must
 correctly restore the tab history.  If the RDM tool was opened when the session
 was captured, then it would be acceptable to either: