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 375860 f14d25a1d13e034710815034c0f2cd5f5c9771cb
parent 375859 dd1ce3e4d5a1a67fd77b27c2e214cb919e541d99
child 375861 2dd20151319b1db8496edb17cdbc50d574d764eb
push id20402
push userbmo:jryans@gmail.com
push dateMon, 06 Jun 2016 20:31:20 +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,16 +28,17 @@ 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* () {
       // 1. Create a temporary, hidden tab to load the tool UI.
       let containerTab = gBrowser.addTab(containerURL, {
         skipAnimation: true,
       });
@@ -73,42 +75,52 @@ 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();
     }),
 
     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;
     },
 
   };
 }
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/browser/tunnel.js
@@ -0,0 +1,378 @@
+/* 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 { Task } = require("devtools/shared/task");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const { BrowserElementWebNavigation } = require("./web-navigation");
+
+function debug(msg) {
+  // console.log(msg);
+}
+
+/**
+ * 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,
+      });
+
+      // 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, so give
+      // it a turn of the event loop to complete.
+      yield DevToolsUtils.waitForTick();
+
+      // 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 progressListener = gBrowser._tabListeners.get(tab);
+      outer.webProgress.addProgressListener(progressListener);
+
+      // All of the browser state from content was swapped onto the inner browser.  Copy
+      // this state up to the outer browser.
+      // This list is taken from browser.xml's `swapDocShells`.  See also the list at
+      // /devtools/client/responsive.html/docs/browser-swap.md.
+      const browserState = [
+        "_securityUI",
+        "_documentURI",
+        "_documentContentType",
+        "_contentTitle",
+        "_characterSet",
+        "_contentPrincipal",
+        "_imageDocument",
+        "_fullZoom",
+        "_textZoom",
+        "_isSyntheticDocument",
+        "_innerWindowID",
+        "_manifestURI",
+      ];
+      for (let property of browserState) {
+        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);
+      };
+
+      // Force the browser UI to update by reading the state of the new
+      // properties we've set here.
+      gBrowser.setTabTitle(tab);
+      gBrowser.updateCurrentBrowser(true);
+    }),
+
+    stop() {
+      browserWindow = null;
+      gBrowser = null;
+
+      // Reset the XBL binding back to the default.
+      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 special tool UI that is loaded
+  // into the outer browser as part of the 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 as well.
+    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",
+    // 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",
+    // 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 mananger 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) {
+      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 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 messaging the WebNavigation frame script
+ * 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: