Bug 1378647 - support creating lazy tabs from extensions, r?rpl,mikedeboer draft
authorShane Caraveo <scaraveo@mozilla.com>
Mon, 30 Jul 2018 12:15:32 -0300
changeset 824283 bb1d113b8a76efec9453a4c4d3fa1435bdd300ca
parent 823465 87bcafe428a4ad6017e59b915581ae00aa863407
push id117860
push usermixedpuppy@gmail.com
push dateMon, 30 Jul 2018 15:19:30 +0000
reviewersrpl, mikedeboer
bugs1378647
milestone63.0a1
Bug 1378647 - support creating lazy tabs from extensions, r?rpl,mikedeboer MozReview-Commit-ID: 9QMkNtCQG6P
browser/components/extensions/parent/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser_ext_tabs_discarded.js
browser/components/sessionstore/SessionStore.jsm
mobile/android/chrome/content/browser.js
mobile/android/components/extensions/ext-tabs.js
toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
--- a/browser/components/extensions/parent/ext-tabs.js
+++ b/browser/components/extensions/parent/ext-tabs.js
@@ -9,16 +9,18 @@ ChromeUtils.defineModuleGetter(this, "Ex
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                                "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "PromiseUtils",
                                "resource://gre/modules/PromiseUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "SessionStore",
                                "resource:///modules/sessionstore/SessionStore.jsm");
+ChromeUtils.defineModuleGetter(this, "Utils",
+                               "resource://gre/modules/sessionstore/Utils.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
   return Services.strings.createBundle("chrome://global/locale/extensions.properties");
 });
 
 var {
   ExtensionError,
 } = ExtensionUtils;
@@ -562,51 +564,81 @@ this.tabs = class extends ExtensionAPI {
                 if (!containerId) {
                   return Promise.reject({message: `No cookie store exists with ID ${createProperties.cookieStoreId}`});
                 }
 
                 options.userContextId = containerId;
               }
             }
 
-            // Make sure things like about:blank and data: URIs never inherit,
-            // and instead always get a NullPrincipal.
-            options.disallowInheritPrincipal = true;
+            // Only set disallowInheritPrincipal on non-discardable urls as it
+            // will override creating a lazy browser.  Setting triggeringPrincipal
+            // will ensure other cases are handled, but setting it may prevent
+            // creating about and data urls.
+            let discardable = url && !url.startsWith("about:");
+            if (!discardable) {
+              // Make sure things like about:blank and data: URIs never inherit,
+              // and instead always get a NullPrincipal.
+              options.disallowInheritPrincipal = true;
+            } else {
+              options.triggeringPrincipal = context.principal;
+            }
 
             tabListener.initTabReady();
             let currentTab = window.gBrowser.selectedTab;
 
             if (createProperties.openerTabId !== null) {
               options.ownerTab = tabTracker.getTab(createProperties.openerTabId);
               options.openerBrowser = options.ownerTab.linkedBrowser;
               if (options.ownerTab.ownerGlobal !== window) {
                 return Promise.reject({message: "Opener tab must be in the same window as the tab being created"});
               }
             }
 
-            if (createProperties.index != null) {
-              options.index = createProperties.index;
+            // Simple properties
+            const properties = ["index", "pinned", "title"];
+            for (let prop of properties) {
+              if (createProperties[prop] != null) {
+                options[prop] = createProperties[prop];
+              }
             }
 
-            if (createProperties.pinned != null) {
-              options.pinned = createProperties.pinned;
+            let active = createProperties.active !== null ?
+                         createProperties.active : !createProperties.discarded;
+            if (createProperties.discarded) {
+              if (active) {
+                return Promise.reject({message: `Active tabs cannot be created and discarded.`});
+              }
+              if (createProperties.pinned) {
+                return Promise.reject({message: `Pinned tabs cannot be created and discarded.`});
+              }
+              if (!discardable) {
+                return Promise.reject({message: `Cannot create a discarded new tab or "about" urls.`});
+              }
+              options.createLazyBrowser = true;
+            } else if (createProperties.title) {
+              return Promise.reject({message: `Title may only be set for discarded tabs.`});
             }
 
             let nativeTab = window.gBrowser.addTab(url || window.BROWSER_NEW_TAB_URL, options);
+            if (createProperties.discarded) {
+              SessionStore.setTabState(nativeTab, {
+                entries: [{
+                  url: url,
+                  title: options.title,
+                  triggeringPrincipal_base64: Utils.serializePrincipal(context.principal),
+                }],
+              });
+            }
 
-            let active = true;
-            if (createProperties.active !== null) {
-              active = createProperties.active;
-            }
             if (active) {
               window.gBrowser.selectedTab = nativeTab;
-            }
-
-            if (active && !url) {
-              window.focusAndSelectUrlBar();
+              if (!url) {
+                window.focusAndSelectUrlBar();
+              }
             }
 
             if (createProperties.url &&
                 createProperties.url !== window.BROWSER_NEW_TAB_URL) {
               // We can't wait for a location change event for about:newtab,
               // since it may be pre-rendered, in which case its initial
               // location change event has already fired.
 
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -579,17 +579,29 @@
               },
               "cookieStoreId": {
                 "type": "string",
                 "optional": true,
                 "description": "The CookieStoreId for the tab that opened this tab."
               },
               "openInReaderMode": {
                 "type": "boolean",
-                "optional": true, "description": "Whether the document in the tab should be opened in reader mode."}
+                "optional": true,
+                "description": "Whether the document in the tab should be opened in reader mode."
+              },
+              "discarded": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tab is marked as 'discarded' when created."
+              },
+              "title": {
+                "type": "string",
+                "optional": true,
+                "description": "The title used for display if the tab is created in discarded mode."
+              }
             }
           },
           {
             "type": "function",
             "name": "callback",
             "optional": true,
             "parameters": [
               {
--- a/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js
@@ -6,70 +6,43 @@
 const {Utils} = ChromeUtils.import("resource://gre/modules/sessionstore/Utils.jsm", {});
 const triggeringPrincipal_base64 = Utils.SERIALIZED_SYSTEMPRINCIPAL;
 
 let lazyTabState = {entries: [{url: "http://example.com/", triggeringPrincipal_base64, title: "Example Domain"}]};
 
 add_task(async function test_discarded() {
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
-      "permissions": ["tabs"],
+      "permissions": ["tabs", "webNavigation"],
     },
 
     background: async function() {
-      let onCreatedTabData = [];
-      let discardedEventData = [];
-
-      async function finishTest() {
-        browser.test.assertEq(0, discardedEventData.length, "number of discarded events fired");
-
-        onCreatedTabData.sort((data1, data2) => data1.index - data2.index);
-        browser.test.assertEq(false, onCreatedTabData[0].discarded, "non-lazy tab onCreated discard property");
-        browser.test.assertEq(true, onCreatedTabData[1].discarded, "lazy tab onCreated discard property");
-
-        let tabs = await browser.tabs.query({currentWindow: true});
-        tabs.sort((tab1, tab2) => tab1.index - tab2.index);
-
-        browser.test.assertEq(false, tabs[1].discarded, "non-lazy tab query discard property");
-        browser.test.assertEq(true, tabs[2].discarded, "lazy tab query discard property");
-
-        let updatedTab = await browser.tabs.update(tabs[2].id, {active: true});
+      browser.webNavigation.onCompleted.addListener(async (details) => {
+        browser.test.log(`webNav onCompleted received for ${details.tabId}`);
+        let updatedTab = await browser.tabs.get(details.tabId);
         browser.test.assertEq(false, updatedTab.discarded, "lazy to non-lazy update discard property");
-        browser.test.assertEq(false, discardedEventData[0], "lazy to non-lazy onUpdated discard property");
-
         browser.test.notifyPass("test-finished");
-      }
-
-      browser.tabs.onUpdated.addListener(function(tabId, updatedInfo) {
-        if ("discarded" in updatedInfo) {
-          discardedEventData.push(updatedInfo.discarded);
-        }
-      });
+      }, {url: [{hostContains: "example.com"}]});
 
       browser.tabs.onCreated.addListener(function(tab) {
-        onCreatedTabData.push({discarded: tab.discarded, index: tab.index});
-        if (onCreatedTabData.length == 2) {
-          finishTest();
-        }
+        browser.test.assertEq(true, tab.discarded, "non-lazy tab onCreated discard property");
+        browser.tabs.update(tab.id, {active: true});
       });
     },
   });
 
   await extension.startup();
 
-  let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
-
-  let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank", {createLazyBrowser: true});
-  SessionStore.setTabState(tab2, JSON.stringify(lazyTabState));
+  let testTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {createLazyBrowser: true});
+  SessionStore.setTabState(testTab, lazyTabState);
 
   await extension.awaitFinish("test-finished");
   await extension.unload();
 
-  BrowserTestUtils.removeTab(tab1);
-  BrowserTestUtils.removeTab(tab2);
+  BrowserTestUtils.removeTab(testTab);
 });
 
 // If discard is called immediately after creating a new tab, the new tab may not have loaded,
 // and the sessionstore for that tab is not ready for discarding.  The result was a corrupted
 // sessionstore for the tab, which when the tab was activated, resulted in a tab with partial
 // state.
 add_task(async function test_create_then_discard() {
   let extension = ExtensionTestUtils.loadExtension({
@@ -100,8 +73,46 @@ add_task(async function test_create_then
       createdTab = await browser.tabs.create({url: "http://example.com/", active: false});
       browser.tabs.discard(createdTab.id);
     },
   });
   await extension.startup();
   await extension.awaitFinish("test-finished");
   await extension.unload();
 });
+
+add_task(async function test_create_discarded() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs", "webNavigation"],
+    },
+
+    background: async function() {
+      let tabOpts = {
+        url: "http://example.com/",
+        active: false,
+        discarded: true,
+        title: "discarded tab",
+      };
+
+      browser.webNavigation.onCompleted.addListener(async (details) => {
+        let activeTab = await browser.tabs.get(details.tabId);
+        browser.test.assertEq(tabOpts.url, activeTab.url, "restored tab url matches active tab url");
+        browser.test.assertEq("mochitest index /", activeTab.title, "restored tab title is correct");
+        browser.tabs.remove(details.tabId);
+        browser.test.notifyPass("test-finished");
+      }, {url: [{hostContains: "example.com"}]});
+
+      browser.tabs.onCreated.addListener(tab => {
+        browser.test.assertEq(tabOpts.active, tab.active, "lazy tab is not active");
+        browser.test.assertEq(tabOpts.discarded, tab.discarded, "lazy tab is discarded");
+        browser.test.assertEq(tabOpts.url, tab.url, "lazy tab url is correct");
+        browser.test.assertEq(tabOpts.title, tab.title, "lazy tab title is correct");
+        browser.tabs.update(tab.id, {active: true});
+      });
+
+      browser.tabs.create(tabOpts);
+    },
+  });
+  await extension.startup();
+  await extension.awaitFinish("test-finished");
+  await extension.unload();
+});
--- a/browser/components/sessionstore/SessionStore.jsm
+++ b/browser/components/sessionstore/SessionStore.jsm
@@ -2381,17 +2381,20 @@ var SessionStoreInternal = {
     return JSON.stringify(tabState);
   },
 
   setTabState(aTab, aState) {
     // Remove the tab state from the cache.
     // Note that we cannot simply replace the contents of the cache
     // as |aState| can be an incomplete state that will be completed
     // by |restoreTabs|.
-    let tabState = JSON.parse(aState);
+    let tabState = aState;
+    if (typeof tabState == "string") {
+      tabState = JSON.parse(aState);
+    }
     if (!tabState) {
       throw Components.Exception("Invalid state string: not JSON", Cr.NS_ERROR_INVALID_ARG);
     }
     if (typeof tabState != "object") {
       throw Components.Exception("Not an object", Cr.NS_ERROR_INVALID_ARG);
     }
     if (!("entries" in tabState)) {
       throw Components.Exception("Invalid state object: no entries", Cr.NS_ERROR_INVALID_ARG);
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -3760,21 +3760,23 @@ Tab.prototype = {
     this.browser.addEventListener("VideoBindingCast", this, true, true);
 
     Services.obs.addObserver(this, "audioFocusChanged", false);
     Services.obs.addObserver(this, "before-first-paint");
     Services.obs.addObserver(this, "media-playback");
 
     // Always initialise new tabs with basic session store data to avoid
     // problems with functions that always expect it to be present
+    let triggeringPrincipal_base64 = aParams.triggeringPrincipal ?
+      Utils.serializePrincipal(aParams.triggeringPrincipal) : Utils.SERIALIZED_SYSTEMPRINCIPAL;
     this.browser.__SS_data = {
       entries: [{
         url: uri,
         title: truncate(title, MAX_TITLE_LENGTH),
-        triggeringPrincipal_base64: Utils.SERIALIZED_SYSTEMPRINCIPAL
+        triggeringPrincipal_base64,
       }],
       index: 1,
       desktopMode: this.desktopMode,
       isPrivate: isPrivate,
       tabId: this.id,
       parentId: this.parentId
     };
 
@@ -3786,30 +3788,35 @@ Tab.prototype = {
 
     if (aParams.delayLoad) {
       // If this is a zombie tab, mark the browser for delay loading, which will
       // restore the tab when selected using the session data added above
       this.browser.__SS_restore = true;
       this.browser.setAttribute("pending", "true");
     } else {
       let flags = "flags" in aParams ? aParams.flags : Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+      if (aParams.disallowInheritPrincipal) {
+        flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
+      }
+
       let postData = ("postData" in aParams && aParams.postData) ? aParams.postData.value : null;
       let referrerURI = "referrerURI" in aParams ? aParams.referrerURI : null;
       let charset = "charset" in aParams ? aParams.charset : null;
 
       // The search term the user entered to load the current URL
       this.userRequested = "userRequested" in aParams ? aParams.userRequested : "";
       this.isSearch = "isSearch" in aParams ? aParams.isSearch : false;
 
       try {
         this.browser.loadURI(aURL, {
           flags,
           referrerURI,
           charset,
           postData,
+          triggeringPrincipal: aParams.triggeringPrincipal,
         });
       } catch(e) {
         let message = {
           type: "Content:LoadError",
           tabID: this.id
         };
         GlobalEventDispatcher.sendRequest(message);
         dump("Handled load error: " + e);
--- a/mobile/android/components/extensions/ext-tabs.js
+++ b/mobile/android/components/extensions/ext-tabs.js
@@ -271,19 +271,23 @@ this.tabs = class extends ExtensionAPI {
             active = createProperties.active;
           }
           options.selected = active;
 
           if (createProperties.index !== null) {
             options.tabIndex = createProperties.index;
           }
 
-          // Make sure things like about:blank and data: URIs never inherit,
+          // Make sure things like about:blank URIs never inherit,
           // and instead always get a NullPrincipal.
-          options.disallowInheritPrincipal = true;
+          if (url && url.startsWith("about:")) {
+            options.disallowInheritPrincipal = true;
+          } else {
+            options.triggeringPrincipal = context.principal;
+          }
 
           options.parentId = BrowserApp.selectedTab.id;
 
           tabListener.initTabReady();
           let nativeTab = BrowserApp.addTab(url, options);
 
           if (createProperties.url) {
             tabListener.initializingTabs.add(nativeTab);
--- a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
@@ -297,22 +297,27 @@ add_task(async function test_webRequest_
 
   await tabExt.unload();
 });
 
 add_task(async function test_webRequest_tabId_browser() {
   async function background(url) {
     let tabId;
     browser.test.onMessage.addListener(async (msg, expected) => {
-      await browser.tabs.remove(tabId);
-      browser.test.sendMessage("done");
+      if (msg == "create") {
+        let tab = await browser.tabs.create({url});
+        tabId = tab.id;
+        return;
+      }
+      if (msg == "done") {
+        await browser.tabs.remove(tabId);
+        browser.test.sendMessage("done");
+      }
     });
-
-    let tab = await browser.tabs.create({url});
-    tabId = tab.id;
+    browser.test.sendMessage("origin", browser.runtime.getURL("/"));
   }
 
   let pageUrl = `${SimpleTest.getTestFileURL("file_sample.html")}?nocache=${Math.random()}`;
   let tabExt = ExtensionTestUtils.loadExtension({
     manifest: {
       permissions: [
         "tabs",
       ],
@@ -329,23 +334,25 @@ add_task(async function test_webRequest_
   if (AppConstants.platform != "android") {
     expect["favicon.ico"] = {
       type: "image",
       origin: pageUrl,
       cached: true,
     };
   }
 
-  // expecting origin == undefined
-  extension.sendMessage("set-expected", {expect});
+  await tabExt.startup();
+  let origin = await tabExt.awaitMessage("origin");
+
+  // expecting origin == extension baseUrl
+  extension.sendMessage("set-expected", {expect, origin});
   await extension.awaitMessage("continue");
 
-  // open a tab from a system principal
-  await tabExt.startup();
-
+  // open a tab from an extension principal
+  tabExt.sendMessage("create");
   await extension.awaitMessage("done");
   tabExt.sendMessage("done");
   await tabExt.awaitMessage("done");
   await tabExt.unload();
 });
 
 add_task(async function test_webRequest_frames() {
   let expect = {