diff --git a/docs/api/extensions.md b/docs/api/extensions.md index 2c1539d57d73..c9970d066c7e 100644 --- a/docs/api/extensions.md +++ b/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` diff --git a/shell/browser/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc index f1f21d35e66c..479a76a469a9 100644 --- a/shell/browser/api/electron_api_web_contents.cc +++ b/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::GetWebContentsList() { + std::list list; + for (auto iter = base::IDMap::iterator(&GetAllWebContents()); + !iter.IsAtEnd(); iter.Advance()) { + list.push_back(iter.GetCurrentValue()); + } + return list; +} + // static gin::WrapperInfo WebContents::kWrapperInfo = {gin::kEmbedderNativeGin}; diff --git a/shell/browser/api/electron_api_web_contents.h b/shell/browser/api/electron_api_web_contents.h index 9273cdf95ad6..fee6f4422458 100644 --- a/shell/browser/api/electron_api_web_contents.h +++ b/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 GetWebContentsList(); // Get the V8 wrapper of the |web_contents|, or create one if not existed. // diff --git a/shell/browser/extensions/api/tabs/tabs_api.cc b/shell/browser/extensions/api/tabs/tabs_api.cc index cde0d41b28cf..79f595121b6f 100644 --- a/shell/browser/extensions/api/tabs/tabs_api.cc +++ b/shell/browser/extensions/api/tabs/tabs_api.cc @@ -7,6 +7,7 @@ #include #include +#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& 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 params = + tabs::Query::Params::Create(args()); + EXTENSION_FUNCTION_VALIDATE(params); + + URLPatternSet url_patterns; + if (params->query_info.url) { + std::vector 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 audible = params->query_info.audible; + absl::optional 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 params = tabs::Get::Params::Create(args()); EXTENSION_FUNCTION_VALIDATE(params); diff --git a/shell/browser/extensions/api/tabs/tabs_api.h b/shell/browser/extensions/api/tabs/tabs_api.h index fd6c94f2c9f7..1c18dae41f99 100644 --- a/shell/browser/extensions/api/tabs/tabs_api.h +++ b/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 {} diff --git a/shell/common/extensions/api/tabs.json b/shell/common/extensions/api/tabs.json index c39e67a88c73..53a835f6cb58 100644 --- a/shell/common/extensions/api/tabs.json +++ b/shell/common/extensions/api/tabs.json @@ -3,6 +3,16 @@ "namespace": "tabs", "description": "Use the chrome.tabs 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 current window." + }, + "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 \"tabs\" permission." + }, + "url": { + "choices": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "optional": true, + "description": "Match tabs against one or more URL patterns. Fragment identifiers are not matched. This property is ignored if the extension does not have the \"tabs\" 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 current window." + }, + "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", diff --git a/spec/extensions-spec.ts b/spec/extensions-spec.ts index c55964c990d8..03da89c71583 100644 --- a/spec/extensions-spec.ts +++ b/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' + }); + } + }); + }); }); }); }); diff --git a/spec/fixtures/extensions/tabs-api-async/background.js b/spec/fixtures/extensions/tabs-api-async/background.js index 2e3eb8ebf5cd..9133bd4fb6f2 100644 --- a/spec/fixtures/extensions/tabs-api-async/background.js +++ b/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' }); diff --git a/spec/fixtures/extensions/tabs-api-async/main.js b/spec/fixtures/extensions/tabs-api-async/main.js index 4360681115ab..91030070bc01 100644 --- a/spec/fixtures/extensions/tabs-api-async/main.js +++ b/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));