From 6d0e8044ebca06a3de8d7063a0aa989735bab343 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 3 Aug 2023 18:34:02 +0200 Subject: [PATCH] fix: update `chrome.tabs` for Manifest v3 (#39317) --- shell/common/extensions/api/tabs.json | 376 ++++++++++++------ spec/extensions-spec.ts | 118 +++++- spec/fixtures/extensions/chrome-api/main.js | 1 + .../extensions/tabs-api-async/background.js | 52 +++ .../extensions/tabs-api-async/main.js | 45 +++ .../extensions/tabs-api-async/manifest.json | 15 + 6 files changed, 481 insertions(+), 126 deletions(-) create mode 100644 spec/fixtures/extensions/tabs-api-async/background.js create mode 100644 spec/fixtures/extensions/tabs-api-async/main.js create mode 100644 spec/fixtures/extensions/tabs-api-async/manifest.json diff --git a/shell/common/extensions/api/tabs.json b/shell/common/extensions/api/tabs.json index 14d83b015a..c39e67a88c 100644 --- a/shell/common/extensions/api/tabs.json +++ b/shell/common/extensions/api/tabs.json @@ -3,13 +3,23 @@ "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": "MutedInfoReason", + { + "id": "MutedInfoReason", "type": "string", "description": "An event that caused a muted state change.", "enum": [ - {"name": "user", "description": "A user input action set the muted state."}, - {"name": "capture", "description": "Tab capture was started, forcing a muted state change."}, - {"name": "extension", "description": "An extension, identified by the extensionId field, set the muted state."} + { + "name": "user", + "description": "A user input action set the muted state." + }, + { + "name": "capture", + "description": "Tab capture was started, forcing a muted state change." + }, + { + "name": "extension", + "description": "An extension, identified by the extensionId field, set the muted state." + } ] }, { @@ -37,29 +47,112 @@ "id": "Tab", "type": "object", "properties": { - "id": {"type": "integer", "minimum": -1, "optional": true, "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a tab may not be assigned an ID; for example, when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to chrome.tabs.TAB_ID_NONE for apps and devtools windows."}, - // TODO(kalman): Investigate how this is ending up as -1 (based on window type? a bug?) and whether it should be optional instead. - "index": {"type": "integer", "minimum": -1, "description": "The zero-based index of the tab within its window."}, - "groupId": {"type": "integer", "minimum": -1, "description": "The ID of the group that the tab belongs to."}, - "windowId": {"type": "integer", "minimum": 0, "description": "The ID of the window that contains the tab."}, - "openerTabId": {"type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."}, - "selected": {"type": "boolean", "description": "Whether the tab is selected.", "deprecated": "Please use $(ref:tabs.Tab.highlighted)."}, - "highlighted": {"type": "boolean", "description": "Whether the tab is highlighted."}, - "active": {"type": "boolean", "description": "Whether the tab is active in its window. Does not necessarily mean the window is focused."}, - "pinned": {"type": "boolean", "description": "Whether the tab is pinned."}, - "audible": {"type": "boolean", "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the 'speaker audio' indicator is showing."}, - "discarded": {"type": "boolean", "description": "Whether the tab is 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", "description": "Whether the tab can be discarded automatically by the browser when resources are low."}, - "mutedInfo": {"$ref": "MutedInfo", "optional": true, "description": "The tab's muted state and the reason for the last state change."}, - "url": {"type": "string", "optional": true, "description": "The last committed URL of the main frame of the tab. This property is only present if the extension's manifest includes the \"tabs\" permission and may be an empty string if the tab has not yet committed. See also $(ref:Tab.pendingUrl)."}, - "pendingUrl": {"type": "string", "optional": true, "description": "The URL the tab is navigating to, before it has committed. This property is only present if the extension's manifest includes the \"tabs\" permission and there is a pending navigation."}, - "title": {"type": "string", "optional": true, "description": "The title of the tab. This property is only present if the extension's manifest includes the \"tabs\" permission."}, - "favIconUrl": {"type": "string", "optional": true, "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the \"tabs\" permission. It may also be an empty string if the tab is loading."}, - "status": {"type": "string", "optional": true, "description": "Either loading or complete."}, - "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."}, - "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."}, - "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."}, - "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a tab obtained from the $(ref:sessions) API."} + "id": { + "type": "integer", + "minimum": -1, + "optional": true, + "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a tab may not be assigned an ID; for example, when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to chrome.tabs.TAB_ID_NONE for apps and devtools windows." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The zero-based index of the tab within its window." + }, + "groupId": { + "type": "integer", + "minimum": -1, + "description": "The ID of the group that the tab belongs to." + }, + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the window that contains the tab." + }, + "openerTabId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists." + }, + "selected": { + "type": "boolean", + "description": "Whether the tab is selected.", + "deprecated": "Please use $(ref:tabs.Tab.highlighted)." + }, + "highlighted": { + "type": "boolean", + "description": "Whether the tab is highlighted." + }, + "active": { + "type": "boolean", + "description": "Whether the tab is active in its window. Does not necessarily mean the window is focused." + }, + "pinned": { + "type": "boolean", + "description": "Whether the tab is pinned." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the 'speaker audio' indicator is showing." + }, + "discarded": { + "type": "boolean", + "description": "Whether the tab is 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", + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "The tab's muted state and the reason for the last state change." + }, + "url": { + "type": "string", + "optional": true, + "description": "The last committed URL of the main frame of the tab. This property is only present if the extension's manifest includes the \"tabs\" permission and may be an empty string if the tab has not yet committed. See also $(ref:Tab.pendingUrl)." + }, + "pendingUrl": { + "type": "string", + "optional": true, + "description": "The URL the tab is navigating to, before it has committed. This property is only present if the extension's manifest includes the \"tabs\" permission and there is a pending navigation." + }, + "title": { + "type": "string", + "optional": true, + "description": "The title of the tab. This property is only present if the extension's manifest includes the \"tabs\" permission." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the \"tabs\" permission. It may also be an empty string if the tab is loading." + }, + "status": { + "type": "string", + "optional": true, + "description": "Either loading or complete." + }, + "incognito": { + "type": "boolean", + "description": "Whether the tab is in an incognito window." + }, + "width": { + "type": "integer", + "optional": true, + "description": "The width of the tab in pixels." + }, + "height": { + "type": "integer", + "optional": true, + "description": "The height of the tab in pixels." + }, + "sessionId": { + "type": "string", + "optional": true, + "description": "The session ID used to uniquely identify a tab obtained from the $(ref:sessions) API." + } } }, { @@ -125,7 +218,13 @@ "type": "function", "description": "Reload a tab.", "parameters": [ - {"type": "integer", "name": "tabId", "minimum": 0, "optional": true, "description": "The ID of the tab to reload; defaults to the selected tab of the current window."}, + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to reload; defaults to the selected tab of the current window." + }, { "type": "object", "name": "reloadProperties", @@ -134,12 +233,16 @@ "bypassCache": { "type": "boolean", "optional": true, - "description": "Whether using any local cache. Default is false." + "description": "Whether to bypass local caching. Defaults to false." } } - }, - {"type": "function", "name": "callback", "optional": true, "parameters": []} - ] + } + ], + "returns_async": { + "name": "callback", + "optional": true, + "parameters": [] + } }, { "name": "get", @@ -150,15 +253,17 @@ "type": "integer", "name": "tabId", "minimum": 0 - }, - { - "type": "function", - "name": "callback", - "parameters": [ - {"name": "tab", "$ref": "Tab"} - ] } - ] + ], + "returns_async": { + "name": "callback", + "parameters": [ + { + "name": "tab", + "$ref": "Tab" + } + ] + } }, { "name": "connect", @@ -175,12 +280,21 @@ "type": "object", "name": "connectInfo", "properties": { - "name": { "type": "string", "optional": true, "description": "Is passed into onConnect for content scripts that are listening for the connection event." }, + "name": { + "type": "string", + "optional": true, + "description": "Is passed into onConnect for content scripts that are listening for the connection event." + }, "frameId": { "type": "integer", "optional": true, "minimum": 0, "description": "Open a port to a specific frame identified by frameId instead of all frames in the tab." + }, + "documentId": { + "type": "string", + "optional": true, + "description": "Open a port to a specific document identified by documentId instead of all frames in the tab." } }, "optional": true @@ -193,7 +307,9 @@ }, { "name": "executeScript", + "deprecated": "Replaced by $(ref:scripting.executeScript) in Manifest V3.", "type": "function", + "description": "Injects JavaScript code into a page. For details, see the programmatic injection section of the content scripts doc.", "parameters": [ { "type": "integer", @@ -206,26 +322,25 @@ "$ref": "extensionTypes.InjectDetails", "name": "details", "description": "Details of the script to run. Either the code or the file property must be set, but both may not be set at the same time." - }, - { - "type": "function", - "name": "callback", - "optional": true, - "description": "Called after all the JavaScript has been executed.", - "parameters": [ - { - "name": "result", - "optional": true, - "type": "array", - "items": { - "type": "any", - "minimum": 0 - }, - "description": "The result of the script in every injected frame." - } - ] } - ] + ], + "returns_async": { + "name": "callback", + "optional": true, + "description": "Called after all the JavaScript has been executed.", + "parameters": [ + { + "name": "result", + "optional": true, + "type": "array", + "items": { + "type": "any", + "minimum": 0 + }, + "description": "The result of the script in every injected frame." + } + ] + } }, { "name": "sendMessage", @@ -252,23 +367,27 @@ "optional": true, "minimum": 0, "description": "Send a message to a specific frame identified by frameId instead of all frames in the tab." + }, + "documentId": { + "type": "string", + "optional": true, + "description": "Send a message to a specific document identified by documentId instead of all frames in the tab." } }, "optional": true - }, - { - "type": "function", - "name": "responseCallback", - "optional": true, - "parameters": [ - { - "name": "response", - "type": "any", - "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback is called with no arguments and $(ref:runtime.lastError) is set to the error message." - } - ] } - ] + ], + "returns_async": { + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback is called with no arguments and $(ref:runtime.lastError) is set to the error message." + } + ] + } }, { "name": "setZoom", @@ -286,15 +405,14 @@ "type": "number", "name": "zoomFactor", "description": "The new zoom factor. A value of 0 sets the tab to its current default zoom factor. Values greater than 0 specify a (possibly non-default) zoom factor for the tab." - }, - { - "type": "function", - "name": "callback", - "optional": true, - "description": "Called after the zoom factor has been changed.", - "parameters": [] } - ] + ], + "returns_async": { + "name": "callback", + "optional": true, + "description": "Called after the zoom factor has been changed.", + "parameters": [] + } }, { "name": "getZoom", @@ -307,20 +425,19 @@ "minimum": 0, "optional": true, "description": "The ID of the tab to get the current zoom factor from; defaults to the active tab of the current window." - }, - { - "type": "function", - "name": "callback", - "description": "Called with the tab's current zoom factor after it has been fetched.", - "parameters": [ - { - "type": "number", - "name": "zoomFactor", - "description": "The tab's current zoom factor." - } - ] } - ] + ], + "returns_async": { + "name": "callback", + "description": "Called with the tab's current zoom factor after it has been fetched.", + "parameters": [ + { + "type": "number", + "name": "zoomFactor", + "description": "The tab's current zoom factor." + } + ] + } }, { "name": "setZoomSettings", @@ -338,15 +455,14 @@ "$ref": "ZoomSettings", "name": "zoomSettings", "description": "Defines how zoom changes are handled and at what scope." - }, - { - "type": "function", - "name": "callback", - "optional": true, - "description": "Called after the zoom settings are changed.", - "parameters": [] } - ] + ], + "returns_async": { + "name": "callback", + "optional": true, + "description": "Called after the zoom settings are changed.", + "parameters": [] + } }, { "name": "getZoomSettings", @@ -359,20 +475,19 @@ "optional": true, "minimum": 0, "description": "The ID of the tab to get the current zoom settings from; defaults to the active tab of the current window." - }, - { - "type": "function", - "name": "callback", - "description": "Called with the tab's current zoom settings.", - "parameters": [ - { - "$ref": "ZoomSettings", - "name": "zoomSettings", - "description": "The tab's current zoom settings." - } - ] } - ] + ], + "returns_async": { + "name": "callback", + "description": "Called with the tab's current zoom settings.", + "parameters": [ + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "The tab's current zoom settings." + } + ] + } }, { "name": "update", @@ -454,17 +569,28 @@ "name": "onZoomChange", "type": "function", "description": "Fired when a tab is zoomed.", - "parameters": [{ - "type": "object", - "name": "ZoomChangeInfo", - "properties": { - "tabId": {"type": "integer", "minimum": 0}, - "oldZoomFactor": {"type": "number"}, - "newZoomFactor": {"type": "number"}, - "zoomSettings": {"$ref": "ZoomSettings"} + "parameters": [ + { + "type": "object", + "name": "ZoomChangeInfo", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0 + }, + "oldZoomFactor": { + "type": "number" + }, + "newZoomFactor": { + "type": "number" + }, + "zoomSettings": { + "$ref": "ZoomSettings" + } + } } - }] + ] } ] } -] +] \ No newline at end of file diff --git a/spec/extensions-spec.ts b/spec/extensions-spec.ts index 5a0f5ddff2..cc8b49c6c2 100644 --- a/spec/extensions-spec.ts +++ b/spec/extensions-spec.ts @@ -373,7 +373,7 @@ describe('chrome extensions', () => { const message = { method: 'executeScript', args: ['1 + 2'] }; w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); - const [,, responseString] = await once(w.webContents, 'console-message'); + const [, , responseString] = await once(w.webContents, 'console-message'); const response = JSON.parse(responseString); expect(response).to.equal(3); @@ -835,5 +835,121 @@ describe('chrome extensions', () => { ]); }); }); + + describe('chrome.tabs', () => { + let customSession: Session; + let w = null as unknown as BrowserWindow; + + before(async () => { + customSession = session.fromPartition(`persist:${uuid.v4()}`); + await customSession.loadExtension(path.join(fixtures, 'extensions', 'tabs-api-async')); + }); + + beforeEach(() => { + w = new BrowserWindow({ + show: false, + webPreferences: { + session: customSession, + nodeIntegration: true + } + }); + }); + + afterEach(closeAllWindows); + + it('getZoom', async () => { + await w.loadURL(url); + + const message = { method: 'getZoom' }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const [,, responseString] = await once(w.webContents, 'console-message'); + + const response = JSON.parse(responseString); + expect(response).to.equal(1); + }); + + it('setZoom', async () => { + await w.loadURL(url); + + const message = { method: 'setZoom', args: [2] }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const [,, responseString] = await once(w.webContents, 'console-message'); + + const response = JSON.parse(responseString); + expect(response).to.deep.equal(2); + }); + + it('getZoomSettings', async () => { + await w.loadURL(url); + + const message = { method: 'getZoomSettings' }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const [,, responseString] = await once(w.webContents, 'console-message'); + + const response = JSON.parse(responseString); + expect(response).to.deep.equal({ + defaultZoomFactor: 1, + mode: 'automatic', + scope: 'per-origin' + }); + }); + + it('setZoomSettings', async () => { + await w.loadURL(url); + + const message = { method: 'setZoomSettings', args: [{ mode: 'disabled' }] }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const [,, responseString] = await once(w.webContents, 'console-message'); + + const response = JSON.parse(responseString); + expect(response).to.deep.equal({ + defaultZoomFactor: 1, + mode: 'disabled', + scope: 'per-tab' + }); + }); + + it('get', async () => { + await w.loadURL(url); + + const message = { method: 'get' }; + 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.property('active').that.is.a('boolean'); + expect(response).to.have.property('autoDiscardable').that.is.a('boolean'); + expect(response).to.have.property('discarded').that.is.a('boolean'); + expect(response).to.have.property('groupId').that.is.a('number'); + expect(response).to.have.property('highlighted').that.is.a('boolean'); + expect(response).to.have.property('id').that.is.a('number'); + expect(response).to.have.property('incognito').that.is.a('boolean'); + expect(response).to.have.property('index').that.is.a('number'); + expect(response).to.have.property('pinned').that.is.a('boolean'); + expect(response).to.have.property('selected').that.is.a('boolean'); + expect(response).to.have.property('url').that.is.a('string'); + expect(response).to.have.property('windowId').that.is.a('number'); + }); + + it('reload', async () => { + await w.loadURL(url); + + const message = { method: 'reload' }; + w.webContents.executeJavaScript(`window.postMessage('${JSON.stringify(message)}', '*')`); + + const consoleMessage = once(w.webContents, 'console-message'); + const finish = once(w.webContents, 'did-finish-load'); + + await Promise.all([consoleMessage, finish]).then(([[,, responseString]]) => { + const response = JSON.parse(responseString); + expect(response.status).to.equal('reloaded'); + }); + }); + }); }); }); diff --git a/spec/fixtures/extensions/chrome-api/main.js b/spec/fixtures/extensions/chrome-api/main.js index 14331534b7..e24784d9fb 100644 --- a/spec/fixtures/extensions/chrome-api/main.js +++ b/spec/fixtures/extensions/chrome-api/main.js @@ -49,4 +49,5 @@ const dispatchTest = (event) => { const { method, args = [] } = JSON.parse(event.data); testMap[method](...args); }; + window.addEventListener('message', dispatchTest, false); diff --git a/spec/fixtures/extensions/tabs-api-async/background.js b/spec/fixtures/extensions/tabs-api-async/background.js new file mode 100644 index 0000000000..32290c36fb --- /dev/null +++ b/spec/fixtures/extensions/tabs-api-async/background.js @@ -0,0 +1,52 @@ +/* global chrome */ + +const handleRequest = (request, sender, sendResponse) => { + const { method, args = [] } = request; + const tabId = sender.tab.id; + + switch (method) { + case 'getZoom': { + chrome.tabs.getZoom(tabId).then(sendResponse); + break; + } + + case 'setZoom': { + const [zoom] = args; + chrome.tabs.setZoom(tabId, zoom).then(async () => { + const updatedZoom = await chrome.tabs.getZoom(tabId); + sendResponse(updatedZoom); + }); + break; + } + + case 'getZoomSettings': { + chrome.tabs.getZoomSettings(tabId).then(sendResponse); + break; + } + + case 'setZoomSettings': { + const [settings] = args; + chrome.tabs.setZoomSettings(tabId, { mode: settings.mode }).then(async () => { + const zoomSettings = await chrome.tabs.getZoomSettings(tabId); + sendResponse(zoomSettings); + }); + break; + } + + case 'get': { + chrome.tabs.get(tabId).then(sendResponse); + break; + } + + case 'reload': { + chrome.tabs.reload(tabId).then(() => { + sendResponse({ status: 'reloaded' }); + }); + } + } +}; + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + handleRequest(request, sender, sendResponse); + return true; +}); diff --git a/spec/fixtures/extensions/tabs-api-async/main.js b/spec/fixtures/extensions/tabs-api-async/main.js new file mode 100644 index 0000000000..8c23bb00d0 --- /dev/null +++ b/spec/fixtures/extensions/tabs-api-async/main.js @@ -0,0 +1,45 @@ +/* global chrome */ + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + sendResponse(request); +}); + +const testMap = { + getZoomSettings () { + chrome.runtime.sendMessage({ method: 'getZoomSettings' }, response => { + console.log(JSON.stringify(response)); + }); + }, + setZoomSettings (settings) { + chrome.runtime.sendMessage({ method: 'setZoomSettings', args: [settings] }, response => { + console.log(JSON.stringify(response)); + }); + }, + getZoom () { + chrome.runtime.sendMessage({ method: 'getZoom', args: [] }, response => { + console.log(JSON.stringify(response)); + }); + }, + setZoom (zoom) { + chrome.runtime.sendMessage({ method: 'setZoom', args: [zoom] }, response => { + console.log(JSON.stringify(response)); + }); + }, + get () { + chrome.runtime.sendMessage({ method: 'get' }, response => { + console.log(JSON.stringify(response)); + }); + }, + reload () { + chrome.runtime.sendMessage({ method: 'reload' }, response => { + console.log(JSON.stringify(response)); + }); + } +}; + +const dispatchTest = (event) => { + const { method, args = [] } = JSON.parse(event.data); + testMap[method](...args); +}; + +window.addEventListener('message', dispatchTest, false); diff --git a/spec/fixtures/extensions/tabs-api-async/manifest.json b/spec/fixtures/extensions/tabs-api-async/manifest.json new file mode 100644 index 0000000000..58081152de --- /dev/null +++ b/spec/fixtures/extensions/tabs-api-async/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "tabs-api-async", + "version": "1.0", + "content_scripts": [ + { + "matches": [ ""], + "js": ["main.js"], + "run_at": "document_start" + } + ], + "background": { + "service_worker": "background.js" + }, + "manifest_version": 3 +}