Browse Source

feat: add support for `chrome.tabs.query` (#39330)

* feat: add support for tabs.query

* fix: scope to webContents in current session

* test: add test for session behavior
Shelley Vohr 1 year ago
parent
commit
d9329042e2

+ 8 - 2
docs/api/extensions.md

@@ -91,8 +91,9 @@ The following events of `chrome.runtime` are supported:
 
 ### `chrome.storage`
 
-Only `chrome.storage.local` is supported; `chrome.storage.sync` and
-`chrome.storage.managed` are not.
+The following methods of `chrome.storage` are supported:
+
+- `chrome.storage.local`
 
 ### `chrome.tabs`
 
@@ -101,6 +102,8 @@ The following methods of `chrome.tabs` are supported:
 - `chrome.tabs.sendMessage`
 - `chrome.tabs.reload`
 - `chrome.tabs.executeScript`
+- `chrome.tabs.query` (partial support)
+  - supported properties: `url`, `title`, `audible`, `active`, `muted`.
 - `chrome.tabs.update` (partial support)
   - supported properties: `url`, `muted`.
 
@@ -117,6 +120,9 @@ The following methods of `chrome.management` are supported:
 - `chrome.management.getSelf`
 - `chrome.management.getPermissionWarningsById`
 - `chrome.management.getPermissionWarningsByManifest`
+
+The following events of `chrome.management` are supported:
+
 - `chrome.management.onEnabled`
 - `chrome.management.onDisabled`
 

+ 10 - 0
shell/browser/api/electron_api_web_contents.cc

@@ -4405,6 +4405,16 @@ WebContents* WebContents::FromID(int32_t id) {
   return GetAllWebContents().Lookup(id);
 }
 
+// static
+std::list<WebContents*> WebContents::GetWebContentsList() {
+  std::list<WebContents*> list;
+  for (auto iter = base::IDMap<WebContents*>::iterator(&GetAllWebContents());
+       !iter.IsAtEnd(); iter.Advance()) {
+    list.push_back(iter.GetCurrentValue());
+  }
+  return list;
+}
+
 // static
 gin::WrapperInfo WebContents::kWrapperInfo = {gin::kEmbedderNativeGin};
 

+ 1 - 0
shell/browser/api/electron_api_web_contents.h

@@ -134,6 +134,7 @@ class WebContents : public ExclusiveAccessContext,
   // if there is no associated wrapper.
   static WebContents* From(content::WebContents* web_contents);
   static WebContents* FromID(int32_t id);
+  static std::list<WebContents*> GetWebContentsList();
 
   // Get the V8 wrapper of the |web_contents|, or create one if not existed.
   //

+ 98 - 0
shell/browser/extensions/api/tabs/tabs_api.cc

@@ -7,6 +7,7 @@
 #include <memory>
 #include <utility>
 
+#include "base/strings/pattern.h"
 #include "chrome/common/url_constants.h"
 #include "components/url_formatter/url_fixer.h"
 #include "content/public/browser/navigation_entry.h"
@@ -16,7 +17,9 @@
 #include "extensions/common/mojom/host_id.mojom.h"
 #include "extensions/common/permissions/permissions_data.h"
 #include "shell/browser/api/electron_api_web_contents.h"
+#include "shell/browser/native_window.h"
 #include "shell/browser/web_contents_zoom_controller.h"
+#include "shell/browser/window_list.h"
 #include "shell/common/extensions/api/tabs.h"
 #include "third_party/blink/public/common/page/page_zoom.h"
 #include "url/gurl.h"
@@ -58,6 +61,13 @@ void ZoomModeToZoomSettings(WebContentsZoomController::ZoomMode zoom_mode,
   }
 }
 
+// Returns true if either |boolean| is disengaged, or if |boolean| and
+// |value| are equal. This function is used to check if a tab's parameters match
+// those of the browser.
+bool MatchesBool(const absl::optional<bool>& boolean, bool value) {
+  return !boolean || *boolean == value;
+}
+
 api::tabs::MutedInfo CreateMutedInfo(content::WebContents* contents) {
   DCHECK(contents);
   api::tabs::MutedInfo info;
@@ -65,6 +75,7 @@ api::tabs::MutedInfo CreateMutedInfo(content::WebContents* contents) {
   info.reason = api::tabs::MUTED_INFO_REASON_USER;
   return info;
 }
+
 }  // namespace
 
 ExecuteCodeInTabFunction::ExecuteCodeInTabFunction() : execute_tab_id_(-1) {}
@@ -214,6 +225,93 @@ ExtensionFunction::ResponseAction TabsReloadFunction::Run() {
   return RespondNow(NoArguments());
 }
 
+ExtensionFunction::ResponseAction TabsQueryFunction::Run() {
+  absl::optional<tabs::Query::Params> params =
+      tabs::Query::Params::Create(args());
+  EXTENSION_FUNCTION_VALIDATE(params);
+
+  URLPatternSet url_patterns;
+  if (params->query_info.url) {
+    std::vector<std::string> url_pattern_strings;
+    if (params->query_info.url->as_string)
+      url_pattern_strings.push_back(*params->query_info.url->as_string);
+    else if (params->query_info.url->as_strings)
+      url_pattern_strings.swap(*params->query_info.url->as_strings);
+    // It is o.k. to use URLPattern::SCHEME_ALL here because this function does
+    // not grant access to the content of the tabs, only to seeing their URLs
+    // and meta data.
+    std::string error;
+    if (!url_patterns.Populate(url_pattern_strings, URLPattern::SCHEME_ALL,
+                               true, &error)) {
+      return RespondNow(Error(std::move(error)));
+    }
+  }
+
+  std::string title = params->query_info.title.value_or(std::string());
+  absl::optional<bool> audible = params->query_info.audible;
+  absl::optional<bool> muted = params->query_info.muted;
+
+  base::Value::List result;
+
+  // Filter out webContents that don't belong to the current browser context.
+  auto* bc = browser_context();
+  auto all_contents = electron::api::WebContents::GetWebContentsList();
+  all_contents.remove_if([&bc](electron::api::WebContents* wc) {
+    return (bc != wc->web_contents()->GetBrowserContext());
+  });
+
+  for (auto* contents : all_contents) {
+    if (!contents || !contents->web_contents())
+      continue;
+
+    auto* wc = contents->web_contents();
+
+    // Match webContents audible value.
+    if (!MatchesBool(audible, wc->IsCurrentlyAudible()))
+      continue;
+
+    // Match webContents muted value.
+    if (!MatchesBool(muted, wc->IsAudioMuted()))
+      continue;
+
+    // Match webContents active status.
+    if (!MatchesBool(params->query_info.active, contents->IsFocused()))
+      continue;
+
+    if (!title.empty() || !url_patterns.is_empty()) {
+      // "title" and "url" properties are considered privileged data and can
+      // only be checked if the extension has the "tabs" permission or it has
+      // access to the WebContents's origin. Otherwise, this tab is considered
+      // not matched.
+      if (!extension()->permissions_data()->HasAPIPermissionForTab(
+              contents->ID(), mojom::APIPermissionID::kTab) &&
+          !extension()->permissions_data()->HasHostPermission(wc->GetURL())) {
+        continue;
+      }
+
+      // Match webContents title.
+      if (!title.empty() &&
+          !base::MatchPattern(wc->GetTitle(), base::UTF8ToUTF16(title)))
+        continue;
+
+      // Match webContents url.
+      if (!url_patterns.is_empty() && !url_patterns.MatchesURL(wc->GetURL()))
+        continue;
+    }
+
+    tabs::Tab tab;
+    tab.id = contents->ID();
+    tab.url = wc->GetLastCommittedURL().spec();
+    tab.active = contents->IsFocused();
+    tab.audible = contents->IsCurrentlyAudible();
+    tab.muted_info = CreateMutedInfo(wc);
+
+    result.Append(tab.ToValue());
+  }
+
+  return RespondNow(WithArguments(std::move(result)));
+}
+
 ExtensionFunction::ResponseAction TabsGetFunction::Run() {
   absl::optional<tabs::Get::Params> params = tabs::Get::Params::Create(args());
   EXTENSION_FUNCTION_VALIDATE(params);

+ 8 - 0
shell/browser/extensions/api/tabs/tabs_api.h

@@ -55,6 +55,14 @@ class TabsReloadFunction : public ExtensionFunction {
   DECLARE_EXTENSION_FUNCTION("tabs.reload", TABS_RELOAD)
 };
 
+class TabsQueryFunction : public ExtensionFunction {
+  ~TabsQueryFunction() override {}
+
+  ResponseAction Run() override;
+
+  DECLARE_EXTENSION_FUNCTION("tabs.query", TABS_QUERY)
+};
+
 class TabsGetFunction : public ExtensionFunction {
   ~TabsGetFunction() override {}
 

+ 140 - 0
shell/common/extensions/api/tabs.json

@@ -3,6 +3,16 @@
     "namespace": "tabs",
     "description": "Use the <code>chrome.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.",
     "types": [
+      {
+        "id": "TabStatus",
+        "type": "string",
+        "enum": [
+          "unloaded",
+          "loading",
+          "complete"
+        ],
+        "description": "The tab's loading status."
+      },
       {
         "id": "MutedInfoReason",
         "type": "string",
@@ -210,6 +220,18 @@
             "description": "Used to return the default zoom level for the current tab in calls to tabs.getZoomSettings."
           }
         }
+      },
+      {
+        "id": "WindowType",
+        "type": "string",
+        "enum": [
+          "normal",
+          "popup",
+          "panel",
+          "app",
+          "devtools"
+        ],
+        "description": "The type of window."
       }
     ],
     "functions": [
@@ -489,6 +511,124 @@
           ]
         }
       },
+      {
+        "name": "query",
+        "type": "function",
+        "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.",
+        "parameters": [
+          {
+            "type": "object",
+            "name": "queryInfo",
+            "properties": {
+              "active": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are active in their windows."
+              },
+              "pinned": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are pinned."
+              },
+              "audible": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are audible."
+              },
+              "muted": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are muted."
+              },
+              "highlighted": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are highlighted."
+              },
+              "discarded": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are discarded. A discarded tab is one whose content has been unloaded from memory, but is still visible in the tab strip. Its content is reloaded the next time it is activated."
+              },
+              "autoDiscardable": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs can be discarded automatically by the browser when resources are low."
+              },
+              "currentWindow": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are in the <a href='windows#current-window'>current window</a>."
+              },
+              "lastFocusedWindow": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the tabs are in the last focused window."
+              },
+              "status": {
+                "$ref": "TabStatus",
+                "optional": true,
+                "description": "The tab loading status."
+              },
+              "title": {
+                "type": "string",
+                "optional": true,
+                "description": "Match page titles against a pattern. This property is ignored if the extension does not have the <code>\"tabs\"</code> permission."
+              },
+              "url": {
+                "choices": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "type": "array",
+                    "items": {
+                      "type": "string"
+                    }
+                  }
+                ],
+                "optional": true,
+                "description": "Match tabs against one or more <a href='match_patterns'>URL patterns</a>. Fragment identifiers are not matched. This property is ignored if the extension does not have the <code>\"tabs\"</code> permission."
+              },
+              "groupId": {
+                "type": "integer",
+                "optional": true,
+                "minimum": -1,
+                "description": "The ID of the group that the tabs are in, or $(ref:tabGroups.TAB_GROUP_ID_NONE) for ungrouped tabs."
+              },
+              "windowId": {
+                "type": "integer",
+                "optional": true,
+                "minimum": -2,
+                "description": "The ID of the parent window, or $(ref:windows.WINDOW_ID_CURRENT) for the <a href='windows#current-window'>current window</a>."
+              },
+              "windowType": {
+                "$ref": "WindowType",
+                "optional": true,
+                "description": "The type of window the tabs are in."
+              },
+              "index": {
+                "type": "integer",
+                "optional": true,
+                "minimum": 0,
+                "description": "The position of the tabs within their windows."
+              }
+            }
+          }
+        ],
+        "returns_async": {
+          "name": "callback",
+          "parameters": [
+            {
+              "name": "result",
+              "type": "array",
+              "items": {
+                "$ref": "Tab"
+              }
+            }
+          ]
+        }
+      },
       {
         "name": "update",
         "type": "function",

+ 60 - 0
spec/extensions-spec.ts

@@ -967,6 +967,66 @@ describe('chrome extensions', () => {
           reason: 'user'
         });
       });
+
+      describe('query', () => {
+        it('can query for a tab with specific properties', async () => {
+          await w.loadURL(url);
+
+          expect(w.webContents.isAudioMuted()).to.be.false('muted');
+          w.webContents.setAudioMuted(true);
+          expect(w.webContents.isAudioMuted()).to.be.true('not muted');
+
+          const message = { method: 'query', args: [{ muted: true }] };
+          w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
+
+          const [, , responseString] = await once(w.webContents, 'console-message');
+          const response = JSON.parse(responseString);
+          expect(response).to.have.lengthOf(1);
+
+          const tab = response[0];
+          expect(tab.mutedInfo).to.deep.equal({
+            muted: true,
+            reason: 'user'
+          });
+        });
+
+        it('only returns tabs in the same session', async () => {
+          await w.loadURL(url);
+          w.webContents.setAudioMuted(true);
+
+          const sameSessionWin = new BrowserWindow({
+            show: false,
+            webPreferences: {
+              session: customSession
+            }
+          });
+
+          sameSessionWin.webContents.setAudioMuted(true);
+
+          const newSession = session.fromPartition(`persist:${uuid.v4()}`);
+          const differentSessionWin = new BrowserWindow({
+            show: false,
+            webPreferences: {
+              session: newSession
+            }
+          });
+
+          differentSessionWin.webContents.setAudioMuted(true);
+
+          const message = { method: 'query', args: [{ muted: true }] };
+          w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`);
+
+          const [, , responseString] = await once(w.webContents, 'console-message');
+          const response = JSON.parse(responseString);
+          expect(response).to.have.lengthOf(2);
+          for (const tab of response) {
+            expect(tab.mutedInfo).to.deep.equal({
+              muted: true,
+              reason: 'user'
+            });
+          }
+        });
+      });
     });
   });
 });

+ 6 - 0
spec/fixtures/extensions/tabs-api-async/background.js

@@ -38,6 +38,12 @@ const handleRequest = (request, sender, sendResponse) => {
       break;
     }
 
+    case 'query': {
+      const [params] = args;
+      chrome.tabs.query(params).then(sendResponse);
+      break;
+    }
+
     case 'reload': {
       chrome.tabs.reload(tabId).then(() => {
         sendResponse({ status: 'reloaded' });

+ 5 - 0
spec/fixtures/extensions/tabs-api-async/main.js

@@ -15,6 +15,11 @@ const testMap = {
       console.log(JSON.stringify(response));
     });
   },
+  query (params) {
+    chrome.runtime.sendMessage({ method: 'query', args: [params] }, response => {
+      console.log(JSON.stringify(response));
+    });
+  },
   getZoom () {
     chrome.runtime.sendMessage({ method: 'getZoom', args: [] }, response => {
       console.log(JSON.stringify(response));