Bug 1374477 - Add PageActions.jsm for Photon page actions. r?Gijs draft
authorDrew Willcoxon <adw@mozilla.com>
Sat, 29 Jul 2017 20:24:51 -0700
changeset 618100 e8cf7a82748c1770474367e3708df7f53753389b
parent 618027 2fba314d7de77ad8ab693a2ea0112c0cda5dd564
child 618101 5b86613b29de0e80697c1f96028c0577a3687fa3
push id71221
push userdwillcoxon@mozilla.com
push dateSun, 30 Jul 2017 03:26:37 +0000
reviewersGijs
bugs1374477
milestone56.0a1
Bug 1374477 - Add PageActions.jsm for Photon page actions. r?Gijs MozReview-Commit-ID: ASMQlPX5t1i
browser/components/nsBrowserGlue.js
browser/modules/PageActions.jsm
browser/modules/moz.build
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -29,16 +29,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
           AsyncPrefs: false, AsyncShutdown:false, AutoCompletePopup:false, BookmarkHTMLUtils:false,
           BookmarkJSONUtils:false, BrowserUITelemetry:false, BrowserUsageTelemetry:false,
           ContentClick:false, ContentPrefServiceParent:false, ContentSearch:false,
           DateTimePickerHelper:false, DirectoryLinksProvider:false,
           ExtensionsUI:false, Feeds:false,
           FileUtils:false, FormValidationHandler:false, Integration:false,
           LightweightThemeManager:false, LoginHelper:false, LoginManagerParent:false,
           NetUtil:false, NewTabUtils:false, OS:false,
+          PageActions:false,
           PageThumbs:false, PdfJs:false, PermissionUI:false, PlacesBackups:false,
           PlacesUtils:false, PluralForm:false, PrivateBrowsingUtils:false,
           ProcessHangMonitor:false, ReaderParent:false, RecentWindow:false,
           RemotePrompt:false, SessionStore:false,
           ShellService:false, SimpleServiceDiscovery:false, TabCrashHandler:false,
           UITour:false, UIState:false, UpdateListener:false, WebChannel:false,
           WindowsRegistry:false, webrtcUI:false */
 
@@ -74,16 +75,17 @@ let initializedModules = {};
   ["FormValidationHandler", "resource:///modules/FormValidationHandler.jsm"],
   ["Integration", "resource://gre/modules/Integration.jsm"],
   ["LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"],
   ["LoginHelper", "resource://gre/modules/LoginHelper.jsm"],
   ["LoginManagerParent", "resource://gre/modules/LoginManagerParent.jsm"],
   ["NetUtil", "resource://gre/modules/NetUtil.jsm"],
   ["NewTabUtils", "resource://gre/modules/NewTabUtils.jsm"],
   ["OS", "resource://gre/modules/osfile.jsm"],
+  ["PageActions", "resource:///modules/PageActions.jsm"],
   ["PageThumbs", "resource://gre/modules/PageThumbs.jsm"],
   ["PdfJs", "resource://pdf.js/PdfJs.jsm"],
   ["PermissionUI", "resource:///modules/PermissionUI.jsm"],
   ["PlacesBackups", "resource://gre/modules/PlacesBackups.jsm"],
   ["PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"],
   ["PluralForm", "resource://gre/modules/PluralForm.jsm"],
   ["PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"],
   ["ProcessHangMonitor", "resource:///modules/ProcessHangMonitor.jsm"],
@@ -967,16 +969,18 @@ BrowserGlue.prototype = {
 
     PageThumbs.init();
 
     DirectoryLinksProvider.init();
     NewTabUtils.init();
     NewTabUtils.links.addProvider(DirectoryLinksProvider);
     AboutNewTab.init();
 
+    PageActions.init();
+
     this._firstWindowTelemetry(aWindow);
     this._firstWindowLoaded();
 
     this._mediaTelemetryIdleObserver = {
       browserGlue: this,
       observe(aSubject, aTopic, aData) {
         if (aTopic != "idle") {
           return;
new file mode 100644
--- /dev/null
+++ b/browser/modules/PageActions.jsm
@@ -0,0 +1,909 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [
+  "PageActions",
+  // PageActions.Action
+  // PageActions.Button
+  // PageActions.Subview
+  // PageActions.ACTION_ID_BOOKMARK_SEPARATOR
+];
+
+const { utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BinarySearch",
+  "resource://gre/modules/BinarySearch.jsm");
+
+
+const ACTION_ID_BOOKMARK_SEPARATOR = "bookmarkSeparator";
+const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator";
+
+const PREF_ACTION_IDS_IN_URLBAR = "browser.pageActions.actionIDsInUrlbar";
+
+
+this.PageActions = {
+  /**
+   * Inits.  Call to init.
+   */
+  init() {
+    let callbacks = this._deferredAddActionCalls;
+    delete this._deferredAddActionCalls;
+
+    let actionIDsInUrlbar = this._loadActionIDsInUrlbar();
+    this._actionIDsInUrlbar = actionIDsInUrlbar || [];
+
+    // Add the built-in actions, which are defined below in this file.
+    for (let options of gBuiltInActions) {
+      if (options._isSeparator || !this.actionForID(options.id)) {
+        this.addAction(new Action(options));
+      }
+    }
+
+    // These callbacks are deferred until init happens and all built-in actions
+    // are added.
+    while (callbacks && callbacks.length) {
+      callbacks.shift()();
+    }
+
+    if (!actionIDsInUrlbar) {
+      // The action IDs in the urlbar haven't been stored yet.  Compute them and
+      // store them now.  From now on, these stored IDs will determine the
+      // actions that are shown in the urlbar and the order they're shown in.
+      this._actionIDsInUrlbar =
+        this.actions.filter(a => a.shownInUrlbar).map(a => a.id);
+      this._storeActionIDsInUrlbar();
+    }
+  },
+
+  _deferredAddActionCalls: [],
+
+  /**
+   * The list of Action objects, sorted in the order in which they should be
+   * placed in the page action panel.  Not live.  (array of Action objects)
+   */
+  get actions() {
+    let actions = this._builtInActions.slice();
+    if (this._nonBuiltInActions.length) {
+      // There are non-built-in actions, so include them too.  Add a separator
+      // between the built-ins and non-built-ins so that the returned array
+      // looks like: [...built-ins, separator, ...non-built-ins]
+      actions.push(new Action({
+        id: ACTION_ID_BUILT_IN_SEPARATOR,
+        _isSeparator: true,
+      }));
+      actions.push(...this._nonBuiltInActions);
+    }
+    return actions;
+  },
+
+  /**
+   * Gets an action.
+   *
+   * @param  id (string, required)
+   *         The ID of the action to get.
+   * @return The Action object, or null if none.
+   */
+  actionForID(id) {
+    return this._actionsByID.get(id);
+  },
+
+  /**
+   * Registers an action.
+   *
+   * Actions are registered by their IDs.  An error is thrown if an action with
+   * the given ID has already been added.  Use actionForID() before calling this
+   * method if necessary.
+   *
+   * Be sure to call remove() on the action if the lifetime of the code that
+   * owns it is shorter than the browser's -- if it lives in an extension, for
+   * example.
+   *
+   * @param  action (Action, required)
+   *         The Action object to register.
+   * @return The given Action.
+   */
+  addAction(action) {
+    if (this._deferredAddActionCalls) {
+      // init() hasn't been called yet.  Defer all additions until it's called,
+      // at which time _deferredAddActionCalls will be deleted.
+      this._deferredAddActionCalls.push(() => this.addAction(action));
+      return action;
+    }
+
+    // The IDs of the actions in the panel and urlbar before which the new
+    // action shoud be inserted.  null means at the end, or it's irrelevant.
+    let panelInsertBeforeID = null;
+    let urlbarInsertBeforeID = null;
+
+    let placeBuiltInSeparator = false;
+
+    if (action.__isSeparator) {
+      this._builtInActions.push(action);
+    } else {
+      if (this.actionForID(action.id)) {
+        throw new Error(`An Action with ID '${action.id}' has already been added.`);
+      }
+      this._actionsByID.set(action.id, action);
+
+      // Insert the action into the appropriate list, either _builtInActions or
+      // _nonBuiltInActions, and find panelInsertBeforeID.
+
+      // Keep in mind that _insertBeforeActionID may be present but null, which
+      // means the action should be appended to the built-ins.
+      if ("__insertBeforeActionID" in action) {
+        // A "semi-built-in" action, probably an action from an extension
+        // bundled with the browser.  Right now we simply assume that no other
+        // consumers will use _insertBeforeActionID.
+        let index =
+          !action.__insertBeforeActionID ? -1 :
+          this._builtInActions.findIndex(a => {
+            return a.id == action.__insertBeforeActionID;
+          });
+        if (index < 0) {
+          // Append the action.
+          index = this._builtInActions.length;
+          if (this._nonBuiltInActions.length) {
+            panelInsertBeforeID = ACTION_ID_BUILT_IN_SEPARATOR;
+          }
+        } else {
+          panelInsertBeforeID = this._builtInActions[index].id;
+        }
+        this._builtInActions.splice(index, 0, action);
+      } else if (gBuiltInActions.find(a => a.id == action.id)) {
+        // A built-in action.  These are always added on init before all other
+        // actions, one after the other, so just push onto the array.
+        this._builtInActions.push(action);
+        if (this._nonBuiltInActions.length) {
+          panelInsertBeforeID = ACTION_ID_BUILT_IN_SEPARATOR;
+        }
+      } else {
+        // A non-built-in action, like a non-bundled extension potentially.
+        // Keep this list sorted by title.
+        let index = BinarySearch.insertionIndexOf((a1, a2) => {
+          return a1.title.localeCompare(a2.title);
+        }, this._nonBuiltInActions, action);
+        if (index < this._nonBuiltInActions.length) {
+          panelInsertBeforeID = this._nonBuiltInActions[index].id;
+        }
+        // If this is the first non-built-in, then the built-in separator must
+        // be placed between the built-ins and non-built-ins.
+        if (!this._nonBuiltInActions.length) {
+          placeBuiltInSeparator = true;
+        }
+        this._nonBuiltInActions.splice(index, 0, action);
+      }
+
+      if (this._actionIDsInUrlbar.includes(action.id)) {
+        // The action should be shown in the urlbar.  Set its shownInUrlbar to
+        // true, but set the private version so that onActionToggledShownInUrlbar
+        // isn't called, which happens when the public version is set.
+        action._shownInUrlbar = true;
+        urlbarInsertBeforeID = this.insertBeforeActionIDInUrlbar(action);
+      }
+    }
+
+    for (let win of browserWindows()) {
+      if (placeBuiltInSeparator) {
+        let sep = new Action({
+          id: ACTION_ID_BUILT_IN_SEPARATOR,
+          _isSeparator: true,
+        });
+        browserPageActions(win).placeAction(sep, null, null);
+      }
+      browserPageActions(win).placeAction(action, panelInsertBeforeID,
+                                          urlbarInsertBeforeID);
+    }
+
+    return action;
+  },
+
+  _builtInActions: [],
+  _nonBuiltInActions: [],
+  _actionsByID: new Map(),
+
+  /**
+   * Returns the ID of the action among the current registered actions in the
+   * urlbar before which the given action should be inserted, ignoring whether
+   * the given action's shownInUrlbar is true or false.
+   *
+   * @return The ID of the action before which the given action should be
+   *         inserted.  If the given action should be inserted last or it should
+   *         not be inserted at all, returns null.
+   */
+  insertBeforeActionIDInUrlbar(action) {
+    // First, find the index of the given action.
+    let index = this._actionIDsInUrlbar.findIndex(a => a.id == action.id);
+    if (index < 0) {
+      return null;
+    }
+    // Now start at the next index and find the ID of the first action that's
+    // currently registered.  Remember that IDs in _actionIDsInUrlbar may belong
+    // to actions that aren't currently registered.
+    for (let i = index + 1; i < this._actionIDsInUrlbar.length; i++) {
+      let id = this._actionIDsInUrlbar[i];
+      if (this.actionForID(id)) {
+        return id;
+      }
+    }
+    return null;
+  },
+
+  /**
+   * Call this when an action is removed.
+   *
+   * @param  action (Action object, required)
+   *         The action that was removed.
+   */
+  onActionRemoved(action) {
+    if (!this.actionForID(action.id)) {
+      // The action isn't present.  Not an error.
+      return;
+    }
+    this._actionsByID.delete(action.id);
+    for (let list of [this._nonBuiltInActions, this._builtInActions]) {
+      let index = list.findIndex(a => a.id == action.id);
+      if (index >= 0) {
+        list.splice(index, 1);
+        break;
+      }
+    }
+    this._updateActionIDsInUrlbar(action.id, false);
+    for (let win of browserWindows()) {
+      browserPageActions(win).removeAction(action);
+    }
+  },
+
+  /**
+   * Call this when an action's iconURL changes.
+   *
+   * @param  action (Action object, required)
+   *         The action whose iconURL property changed.
+   */
+  onActionSetIconURL(action) {
+    if (!this.actionForID(action.id)) {
+      // This may be called before the action has been added.
+      return;
+    }
+    for (let win of browserWindows()) {
+      browserPageActions(win).updateActionIconURL(action);
+    }
+  },
+
+  /**
+   * Call this when an action's title changes.
+   *
+   * @param  action (Action object, required)
+   *         The action whose title property changed.
+   */
+  onActionSetTitle(action) {
+    if (!this.actionForID(action.id)) {
+      // This may be called before the action has been added.
+      return;
+    }
+    for (let win of browserWindows()) {
+      browserPageActions(win).updateActionTitle(action);
+    }
+  },
+
+  /**
+   * Call this when an action's shownInUrlbar property changes.
+   *
+   * @param  action (Action object, required)
+   *         The action whose shownInUrlbar property changed.
+   */
+  onActionToggledShownInUrlbar(action) {
+    if (!this.actionForID(action.id)) {
+      // This may be called before the action has been added.
+      return;
+    }
+    this._updateActionIDsInUrlbar(action.id, action.shownInUrlbar);
+    let insertBeforeID = this.insertBeforeActionIDInUrlbar(action);
+    for (let win of browserWindows()) {
+      browserPageActions(win).placeActionInUrlbar(action, insertBeforeID);
+    }
+  },
+
+  /**
+   * Adds or removes the given action ID to or from _actionIDsInUrlbar.
+   *
+   * @param  actionID (string, required)
+   *         The action ID to add or remove.
+   * @param  shownInUrlbar (bool, required)
+   *         If true, the ID is added if it's not already present.  If false,
+   *         the ID is removed if it's not already absent.
+   */
+  _updateActionIDsInUrlbar(actionID, shownInUrlbar) {
+    let index = this._actionIDsInUrlbar.indexOf(actionID);
+    if (shownInUrlbar) {
+      if (index < 0) {
+        this._actionIDsInUrlbar.push(actionID);
+      }
+    } else if (index >= 0) {
+      this._actionIDsInUrlbar.splice(index, 1);
+    }
+    this._storeActionIDsInUrlbar();
+  },
+
+  _storeActionIDsInUrlbar() {
+    let json = JSON.stringify(this._actionIDsInUrlbar);
+    Services.prefs.setStringPref(PREF_ACTION_IDS_IN_URLBAR, json);
+  },
+
+  _loadActionIDsInUrlbar() {
+    try {
+      let json = Services.prefs.getStringPref(PREF_ACTION_IDS_IN_URLBAR);
+      let obj = JSON.parse(json);
+      if (Array.isArray(obj)) {
+        return obj;
+      }
+    } catch (ex) {}
+    return null;
+  },
+
+  _actionIDsInUrlbar: []
+};
+
+/**
+ * A single page action.
+ *
+ * @param  options (object, required)
+ *         An object with the following properties:
+ *         @param id (string, required)
+ *                The action's ID.  Treat this like the ID of a DOM node.
+ *         @param title (string, required)
+ *                The action's title.
+ *         @param iconURL (string, optional)
+ *                The URL of the action's icon.  Usually you want to specify an
+ *                icon in CSS, but this option is useful if that would be a pain
+ *                for some reason -- like your code is in an embedded
+ *                WebExtension.
+ *         @param nodeAttributes (object, optional)
+ *                An object of name-value pairs.  Each pair will be added as
+ *                an attribute to DOM nodes created for this action.
+ *         @param onCommand (function, optional)
+ *                Called when the action is clicked, but only if it has neither
+ *                a subview nor an iframe.  Passed the following arguments:
+ *                * event: The triggering event.
+ *                * buttonNode: The button node that was clicked.
+ *         @param onIframeShown (function, optional)
+ *                Called when the action's iframe is shown to the user.  Passed
+ *                the following arguments:
+ *                * iframeNode: The iframe.
+ *                * parentPanelNode: The panel node in which the iframe is
+ *                  shown.
+ *         @param onPlacedInPanel (function, optional)
+ *                Called when the action is added to the page action panel in
+ *                a browser window.  Passed the following arguments:
+ *                * buttonNode: The action's node in the page action panel.
+ *         @param onPlacedInUrlbar (function, optional)
+ *                Called when the action is added to the urlbar in a browser
+ *                window.  Passed the following arguments:
+ *                * buttonNode: The action's node in the urlbar.
+ *         @param onShowingInPanel (function, optional)
+ *                Called when a browser window's page action panel is showing.
+ *                Passed the following arguments:
+ *                * buttonNode: The action's node in the page action panel.
+ *         @param shownInUrlbar (bool, optional)
+ *                Pass true to show the action in the urlbar, false otherwise.
+ *                False by default.
+ *         @param subview (object, optional)
+ *                An options object suitable for passing to the Subview
+ *                constructor, if you'd like the action to have a subview.  See
+ *                the subview constructor for info on this object's properties.
+ *         @param tooltip (string, optional)
+ *                The action's button tooltip text.
+ *         @param urlbarIDOverride (string, optional)
+ *                Usually the ID of the action's button in the urlbar will be
+ *                generated automatically.  Pass a string for this property to
+ *                override that with your own ID.
+ *         @param wantsIframe (bool, optional)
+ *                Pass true to make an action that shows an iframe in a panel
+ *                when clicked.
+ */
+function Action(options) {
+  setProperties(this, options, {
+    id: true,
+    title: !options._isSeparator,
+    iconURL: false,
+    nodeAttributes: false,
+    onCommand: false,
+    onIframeShown: false,
+    onPlacedInPanel: false,
+    onPlacedInUrlbar: false,
+    onShowingInPanel: false,
+    shownInUrlbar: false,
+    subview: false,
+    tooltip: false,
+    urlbarIDOverride: false,
+    wantsIframe: false,
+
+    // private
+
+    // (string, optional)
+    // The ID of another action before which to insert this new action.  Applies
+    // to the page action panel only, not the urlbar.
+    _insertBeforeActionID: false,
+
+    // (bool, optional)
+    // True if this isn't really an action but a separator to be shown in the
+    // page action panel.
+    _isSeparator: false,
+
+    // (bool, optional)
+    // True if the action's urlbar button is defined in markup.  In that case, a
+    // node with the action's urlbar node ID should already exist in the DOM
+    // (either the auto-generated ID or urlbarIDOverride).  That node will be
+    // shown when the action is added to the urlbar and hidden when the action
+    // is removed from the urlbar.
+    _urlbarNodeInMarkup: false,
+  });
+  if (this._subview) {
+    this._subview = new Subview(options.subview);
+  }
+}
+
+Action.prototype = {
+  /**
+   * The action's icon URL (string, nullable)
+   */
+  get iconURL() {
+    return this._iconURL;
+  },
+  set iconURL(url) {
+    this._iconURL = url;
+    PageActions.onActionSetIconURL(this);
+    return this._iconURL;
+  },
+
+  /**
+   * The action's ID (string, nonnull)
+   */
+  get id() {
+    return this._id;
+  },
+
+  /**
+   * Attribute name => value mapping to set on nodes created for this action
+   * (object, nullable)
+   */
+  get nodeAttributes() {
+    return this._nodeAttributes;
+  },
+
+  /**
+   * True if the action is shown in the urlbar (bool, nonnull)
+   */
+  get shownInUrlbar() {
+    return this._shownInUrlbar || false;
+  },
+  set shownInUrlbar(shown) {
+    if (this.shownInUrlbar != shown) {
+      this._shownInUrlbar = shown;
+      PageActions.onActionToggledShownInUrlbar(this);
+    }
+    return this.shownInUrlbar;
+  },
+
+  /**
+   * The action's title (string, nonnull)
+   */
+  get title() {
+    return this._title;
+  },
+  set title(title) {
+    this._title = title || "";
+    PageActions.onActionSetTitle(this);
+    return this._title;
+  },
+
+  /**
+   * The action's tooltip (string, nullable)
+   */
+  get tooltip() {
+    return this._tooltip;
+  },
+
+  /**
+   * Override for the ID of the action's urlbar node (string, nullable)
+   */
+  get urlbarIDOverride() {
+    return this._urlbarIDOverride;
+  },
+
+  /**
+   * True if the action is shown in an iframe (bool, nonnull)
+   */
+  get wantsIframe() {
+    return this._wantsIframe || false;
+  },
+
+  /**
+   * A Subview object if the action wants a subview (Subview, nullable)
+   */
+  get subview() {
+    return this._subview;
+  },
+
+  /**
+   * Call this when the user activates the action.
+   *
+   * @param  event (DOM event, required)
+   *         The triggering event.
+   * @param  buttonNode (DOM node, required)
+   *         The action's panel or urlbar button node that was clicked.
+   */
+  onCommand(event, buttonNode) {
+    if (this._onCommand) {
+      this._onCommand(event, buttonNode);
+    }
+  },
+
+  /**
+   * Call this when the action's iframe is shown.
+   *
+   * @param  iframeNode (DOM node, required)
+   *         The iframe that's being shown.
+   * @param  parentPanelNode (DOM node, required)
+   *         The panel in which the iframe is shown.
+   */
+  onIframeShown(iframeNode, parentPanelNode) {
+    if (this._onIframeShown) {
+      this._onIframeShown(iframeNode, parentPanelNode);
+    }
+  },
+
+  /**
+   * Call this when a DOM node for the action is added to the page action panel.
+   *
+   * @param  buttonNode (DOM node, required)
+   *         The action's panel button node.
+   */
+  onPlacedInPanel(buttonNode) {
+    if (this._onPlacedInPanel) {
+      this._onPlacedInPanel(buttonNode);
+    }
+  },
+
+  /**
+   * Call this when a DOM node for the action is added to the urlbar.
+   *
+   * @param  buttonNode (DOM node, required)
+   *         The action's urlbar button node.
+   */
+  onPlacedInUrlbar(buttonNode) {
+    if (this._onPlacedInUrlbar) {
+      this._onPlacedInUrlbar(buttonNode);
+    }
+  },
+
+  /**
+   * Call this when the action's button is shown in the page action panel.
+   *
+   * @param  buttonNode (DOM node, required)
+   *         The action's panel button node.
+   */
+  onShowingInPanel(buttonNode) {
+    if (this._onShowingInPanel) {
+      this._onShowingInPanel(buttonNode);
+    }
+  },
+
+  /**
+   * Unregisters the action and removes its DOM nodes from all browser windows.
+   * Call this when your action lives in an extension and your extension is
+   * unloaded, for example.
+   */
+  remove() {
+    PageActions.onActionRemoved(this);
+  }
+};
+
+this.PageActions.Action = Action;
+
+
+/**
+ * A Subview represents a PanelUI panelview that your actions can show.
+ *
+ * @param  options (object, required)
+ *         An object with the following properties:
+ *         @param buttons (array, optional)
+ *                An array of buttons to show in the subview.  Each item in the
+ *                array must be an options object suitable for passing to the
+ *                Button constructor.  See the Button constructor for
+ *                information on these objects' properties.
+ *         @param onPlaced (function, optional)
+ *                Called when the subview is added to its parent panel in a
+ *                browser window.  Passed the following arguments:
+ *                * panelViewNode: The panelview node represented by this
+ *                  Subview.
+ *         @param onShowing (function, optional)
+ *                Called when the subview is showing in a browser window.
+ *                Passed the following arguments:
+ *                * panelViewNode: The panelview node represented by this
+ *                  Subview.
+ */
+function Subview(options) {
+  setProperties(this, options, {
+    buttons: false,
+    onPlaced: false,
+    onShowing: false,
+  });
+  this._buttons = (this._buttons || []).map(buttonOptions => {
+    return new Button(buttonOptions);
+  });
+}
+
+Subview.prototype = {
+  /**
+   * The subview's buttons (array of Button objects, nonnull)
+   */
+  get buttons() {
+    return this._buttons;
+  },
+
+  /**
+   * Call this when a DOM node for the subview is added to the DOM.
+   *
+   * @param  panelViewNode (DOM node, required)
+   *         The subview's panelview node.
+   */
+  onPlaced(panelViewNode) {
+    if (this._onPlaced) {
+      this._onPlaced(panelViewNode);
+    }
+  },
+
+  /**
+   * Call this when a DOM node for the subview is showing.
+   *
+   * @param  panelViewNode (DOM node, required)
+   *         The subview's panelview node.
+   */
+  onShowing(panelViewNode) {
+    if (this._onShowing) {
+      this._onShowing(panelViewNode);
+    }
+  }
+};
+
+this.PageActions.Subview = Subview;
+
+
+/**
+ * A button that can be shown in a subview.
+ *
+ * @param  options (object, required)
+ *         An object with the following properties:
+ *         @param id (string, required)
+ *                The button's ID.  This will not become the ID of a DOM node by
+ *                itself, but it will be used to generate DOM node IDs.  But in
+ *                terms of spaces and weird characters and such, do treat this
+ *                like a DOM node ID.
+ *         @param title (string, required)
+ *                The button's title.
+ *         @param disabled (bool, required)
+ *                Pass true to disable the button.
+ *         @param onCommand (function, optional)
+ *                Called when the button is clicked.  Passed the following
+ *                arguments:
+ *                * event: The triggering event.
+ *                * buttonNode: The node that was clicked.
+ *         @param shortcut (string, optional)
+ *                The button's shortcut text.
+ */
+function Button(options) {
+  setProperties(this, options, {
+    id: true,
+    title: true,
+    disabled: false,
+    onCommand: false,
+    shortcut: false,
+  });
+}
+
+Button.prototype = {
+  /**
+   * True if the button is disabled (bool, nonnull)
+   */
+  get disabled() {
+    return this._disabled || false;
+  },
+
+  /**
+   * The button's ID (string, nonnull)
+   */
+  get id() {
+    return this._id;
+  },
+
+  /**
+   * The button's shortcut (string, nullable)
+   */
+  get shortcut() {
+    return this._shortcut;
+  },
+
+  /**
+   * The button's title (string, nonnull)
+   */
+  get title() {
+    return this._title;
+  },
+
+  /**
+   * Call this when the user clicks the button.
+   *
+   * @param  event (DOM event, required)
+   *         The triggering event.
+   * @param  buttonNode (DOM node, required)
+   *         The button's DOM node that was clicked.
+   */
+  onCommand(event, buttonNode) {
+    if (this._onCommand) {
+      this._onCommand(event, buttonNode);
+    }
+  }
+};
+
+this.PageActions.Button = Button;
+
+
+// This is only necessary so that Pocket can specify it for
+// action._insertBeforeActionID.
+this.PageActions.ACTION_ID_BOOKMARK_SEPARATOR = ACTION_ID_BOOKMARK_SEPARATOR;
+
+
+// Sorted in the order in which they should appear in the page action panel.
+// Does not include the page actions of extensions bundled with the browser.
+// They're added by the relevant extension code.
+var gBuiltInActions = [
+
+  // bookmark
+  {
+    id: "bookmark",
+    urlbarIDOverride: "star-button-box",
+    _urlbarNodeInMarkup: true,
+    title: "",
+    shownInUrlbar: true,
+    nodeAttributes: {
+      observes: "bookmarkThisPageBroadcaster",
+    },
+    onShowingInPanel(buttonNode) {
+      browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
+    },
+    onCommand(event, buttonNode) {
+      browserPageActions(buttonNode).bookmark.onCommand(event, buttonNode);
+    },
+  },
+
+  // separator
+  {
+    id: ACTION_ID_BOOKMARK_SEPARATOR,
+    _isSeparator: true,
+  },
+
+  // copy URL
+  {
+    id: "copyURL",
+    title: "copyURL-title",
+    onPlacedInPanel(buttonNode) {
+      browserPageActions(buttonNode).copyURL.onPlacedInPanel(buttonNode);
+    },
+    onCommand(event, buttonNode) {
+      browserPageActions(buttonNode).copyURL.onCommand(event, buttonNode);
+    },
+  },
+
+  // email link
+  {
+    id: "emailLink",
+    title: "emailLink-title",
+    onPlacedInPanel(buttonNode) {
+      browserPageActions(buttonNode).emailLink.onPlacedInPanel(buttonNode);
+    },
+    onCommand(event, buttonNode) {
+      browserPageActions(buttonNode).emailLink.onCommand(event, buttonNode);
+    },
+  },
+
+  // send to device
+  {
+    id: "sendToDevice",
+    title: "sendToDevice-title",
+    onPlacedInPanel(buttonNode) {
+      browserPageActions(buttonNode).sendToDevice.onPlacedInPanel(buttonNode);
+    },
+    onShowingInPanel(buttonNode) {
+      browserPageActions(buttonNode).sendToDevice.onShowingInPanel(buttonNode);
+    },
+    subview: {
+      buttons: [
+        {
+          id: "notReady",
+          title: "sendToDevice-notReadyTitle",
+          disabled: true,
+        },
+      ],
+      onPlaced(panelViewNode) {
+        browserPageActions(panelViewNode).sendToDevice
+          .onSubviewPlaced(panelViewNode);
+      },
+      onShowing(panelViewNode) {
+        browserPageActions(panelViewNode).sendToDevice
+          .onShowingSubview(panelViewNode);
+      },
+    },
+  }
+];
+
+
+/**
+ * Gets a BrowserPageActions object in a browser window.
+ *
+ * @param  obj
+ *         Either a DOM node or a browser window.
+ * @return The BrowserPageActions object in the browser window related to the
+ *         given object.
+ */
+function browserPageActions(obj) {
+  if (obj.BrowserPageActions) {
+    return obj.BrowserPageActions;
+  }
+  return obj.ownerGlobal.BrowserPageActions;
+}
+
+/**
+ * A generator function for all open browser windows.
+ */
+function* browserWindows() {
+  let windows = Services.wm.getEnumerator("navigator:browser");
+  while (windows.hasMoreElements()) {
+    yield windows.getNext();
+  }
+}
+
+/**
+ * A simple function that sets properties on a given object while doing basic
+ * required-properties checking.  If a required property isn't specified in the
+ * given options object, or if the options object has properties that aren't in
+ * the given schema, then an error is thrown.
+ *
+ * @param  obj
+ *         The object to set properties on.
+ * @param  options
+ *         An options object supplied by the consumer.
+ * @param  schema
+ *         An object a property for each required and optional property.  The
+ *         keys are property names; the value of a key is a bool that is true if
+ *         the property is required.
+ */
+function setProperties(obj, options, schema) {
+  for (let name in schema) {
+    let required = schema[name];
+    if (required && !(name in options)) {
+      throw new Error(`'${name}' must be specified`);
+    }
+    let nameInObj = "_" + name;
+    if (name[0] == "_") {
+      // The property is "private".  If it's defined in the options, then define
+      // it on obj exactly as it's defined on options.
+      if (name in options) {
+        obj[nameInObj] = options[name];
+      }
+    } else {
+      // The property is "public".  Make sure the property is defined on obj.
+      obj[nameInObj] = options[name] || null;
+    }
+  }
+  for (let name in options) {
+    if (!(name in schema)) {
+      throw new Error(`Unrecognized option '${name}'`);
+    }
+  }
+}
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -144,16 +144,17 @@ EXTRA_JS_MODULES += [
     'DirectoryLinksProvider.jsm',
     'E10SUtils.jsm',
     'ExtensionsUI.jsm',
     'Feeds.jsm',
     'FormSubmitObserver.jsm',
     'FormValidationHandler.jsm',
     'LaterRun.jsm',
     'offlineAppCache.jsm',
+    'PageActions.jsm',
     'PermissionUI.jsm',
     'PluginContent.jsm',
     'ProcessHangMonitor.jsm',
     'ReaderParent.jsm',
     'RecentWindow.jsm',
     'RemotePrompt.jsm',
     'Sanitizer.jsm',
     'SitePermissions.jsm',