From 6bed99e7729210538af2c7a73e47ddeedebdd541 Mon Sep 17 00:00:00 2001 From: Tomislav Jovanovic Date: Tue, 24 Jan 2023 00:47:13 +0000 Subject: [PATCH] Bug 1805523 - Implement temporary access state and attention, r=rpl Differential Revision: https://phabricator.services.mozilla.com/D165490 --- browser/base/content/browser-addons.js | 5 + browser/base/content/browser-unified-extensions.js | 4 +- .../components/extensions/parent/ext-browser.js | 6 + .../extensions/parent/ext-browserAction.js | 2 +- .../test/browser/browser_unified_extensions.js | 298 +++++++++++++++++++-- browser/modules/ExtensionsUI.jsm | 5 +- toolkit/components/extensions/ExtensionActions.jsm | 1 + .../components/extensions/ExtensionPermissions.jsm | 51 ++-- 8 files changed, 332 insertions(+), 40 deletions(-) diff --git a/browser/base/content/browser-addons.js b/browser/base/content/browser-addons.js index 91d3915f7978..b68c2434c8a6 100644 --- a/browser/base/content/browser-addons.js +++ b/browser/base/content/browser-addons.js @@ -1411,10 +1411,15 @@ var gUnifiedExtensions = { }, onPanelViewHiding(panelview) { + if (window.closed) { + return; + } const list = panelview.querySelector(".unified-extensions-list"); while (list.lastChild) { list.lastChild.remove(); } + // If temporary access was granted, (maybe) clear attention indicator. + requestAnimationFrame(() => this.updateAttention()); }, _panel: null, diff --git a/browser/base/content/browser-unified-extensions.js b/browser/base/content/browser-unified-extensions.js index f7cfd30454c8..b2cd44aa439a 100644 --- a/browser/base/content/browser-unified-extensions.js +++ b/browser/base/content/browser-unified-extensions.js @@ -120,7 +120,7 @@ customElements.define( #setStateMessage() { const messages = OriginControls.getStateMessageIDs({ policy: this.extension.policy, - uri: this.ownerGlobal.gBrowser.currentURI, + tab: this.ownerGlobal.gBrowser.selectedTab, }); if (!messages) { @@ -147,7 +147,7 @@ customElements.define( #hasAction() { const state = OriginControls.getState( this.extension.policy, - this.ownerGlobal.gBrowser.currentURI + this.ownerGlobal.gBrowser.selectedTab ); return state && state.whenClicked && !state.hasAccess; diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index 8256598fb513..ceb2322db9bb 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -1220,6 +1220,12 @@ class TabManager extends TabManagerBase { wrapTab(nativeTab) { return new Tab(this.extension, nativeTab, tabTracker.getId(nativeTab)); } + + getWrapper(nativeTab) { + if (!nativeTab.ownerGlobal.gBrowserInit.isAdoptingTab()) { + return super.getWrapper(nativeTab); + } + } } class WindowManager extends WindowManagerBase { diff --git a/browser/components/extensions/parent/ext-browserAction.js b/browser/components/extensions/parent/ext-browserAction.js index 4ceb25dc2e5f..96be811422c2 100644 --- a/browser/components/extensions/parent/ext-browserAction.js +++ b/browser/components/extensions/parent/ext-browserAction.js @@ -818,7 +818,7 @@ this.browserAction = class extends ExtensionAPIPersistent { let policy = WebExtensionPolicy.getByID(this.extension.id); let messages = OriginControls.getStateMessageIDs({ policy, - uri: node.ownerGlobal.gBrowser.currentURI, + tab: node.ownerGlobal.gBrowser.selectedTab, isAction: true, hasPopup: !!tabData.popup, }); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions.js b/browser/components/extensions/test/browser/browser_unified_extensions.js index 7268113ba887..f6e1f41b98cc 100644 --- a/browser/components/extensions/test/browser/browser_unified_extensions.js +++ b/browser/components/extensions/test/browser/browser_unified_extensions.js @@ -512,22 +512,31 @@ add_task( } ); +const NO_ACCESS = { id: "origin-controls-state-no-access", args: null }; +const ALWAYS_ON = { id: "origin-controls-state-always-on", args: null }; +const WHEN_CLICKED = { id: "origin-controls-state-when-clicked", args: null }; +const TEMP_ACCESS = { + id: "origin-controls-state-temporary-access", + args: null, +}; + +const HOVER_RUN_VISIT_ONLY = { + id: "origin-controls-state-hover-run-visit-only", + args: null, +}; +const HOVER_RUNNABLE_RUN_EXT = { + id: "origin-controls-state-runnable-hover-run", + args: null, +}; +const HOVER_RUNNABLE_OPEN_EXT = { + id: "origin-controls-state-runnable-hover-open", + args: null, +}; + add_task(async function test_messages_origin_controls() { - const NO_ACCESS = { id: "origin-controls-state-no-access", args: null }; - const ALWAYS_ON = { id: "origin-controls-state-always-on", args: null }; - const WHEN_CLICKED = { id: "origin-controls-state-when-clicked", args: null }; - const HOVER_RUN_VISIT_ONLY = { - id: "origin-controls-state-hover-run-visit-only", - args: null, - }; - const HOVER_RUNNABLE_RUN_EXT = { - id: "origin-controls-state-runnable-hover-run", - args: null, - }; - const HOVER_RUNNABLE_OPEN_EXT = { - id: "origin-controls-state-runnable-hover-open", - args: null, - }; + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); const TEST_CASES = [ { @@ -1061,3 +1070,262 @@ add_task(async function test_hover_message_when_button_updates_itself() { await extension.unload(); }); + +// Test the temporary access state messages and attention indicator. +add_task(async function test_temporary_access() { + const TEST_CASES = [ + { + title: "mv3 with active scripts and browser action", + manifest: { + manifest_version: 3, + action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with active scripts and no browser action", + manifest: { + manifest_version: 3, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + // TODO: This will need updating for bug 1807835. + disabled: false, + }, + }, + { + title: "mv3 with browser action and host_permission", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with browser action no host_permissions", + manifest: { + manifest_version: 3, + action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + // MV2 tests. + { + title: "mv2 with content scripts and browser action", + manifest: { + manifest_version: 2, + browser_action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with content scripts and no browser action", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + }, + { + title: "mv2 with browser action and host_permission", + manifest: { + manifest_version: 2, + browser_action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with browser action no host_permissions", + manifest: { + manifest_version: 2, + browser_action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + ]; + + let count = 1; + await Promise.all( + TEST_CASES.map(test => { + let id = `test-temp-access-${count++}@ext`; + test.extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: test.title, + browser_specific_settings: { gecko: { id } }, + ...test.manifest, + }, + files: { + "popup.html": "", + "script.js"() { + browser.test.sendMessage("cs-injected"); + }, + }, + background() { + let action = browser.action ?? browser.browserAction; + action?.onClicked.addListener(() => { + browser.test.sendMessage("action-onClicked"); + }); + }, + useAddonManager: "temporary", + }); + + return test.extension.startup(); + }) + ); + + async function checkButton(extension, expect, click = false) { + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension.id); + ok(item, `Expected item for ${extension.id}.`); + + let state = item.querySelector(".unified-extensions-item-message-default"); + ok(state, "Expected a default state message element."); + + is( + item.hasAttribute("attention"), + !!expect.attention, + "Expected attention badge." + ); + Assert.deepEqual( + document.l10n.getAttributes(state), + expect.state, + "Expected l10n attributes for the message." + ); + + let button = item.querySelector(".unified-extensions-item-action-button"); + is(button.disabled, !!expect.disabled, "Expect disabled item."); + + // If we should click, and button is not disabled. + if (click && !expect.disabled) { + let onClick = BrowserTestUtils.waitForEvent(button, "click"); + button.click(); + await onClick; + } else { + // Otherwise, just close the panel. + await closeExtensionsPanel(); + } + } + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com/" }, + async () => { + for (let { title, extension, before, messages, after } of TEST_CASES) { + info(`Test case: ${title}`); + await checkButton(extension, before, true); + + await Promise.all( + messages.map(msg => { + info(`Waiting for ${msg} from clicking the button.`); + return extension.awaitMessage(msg); + }) + ); + + await checkButton(extension, after); + await extension.unload(); + } + } + ); +}); diff --git a/browser/modules/ExtensionsUI.jsm b/browser/modules/ExtensionsUI.jsm index 966508817f26..50e8cf8122e5 100644 --- a/browser/modules/ExtensionsUI.jsm +++ b/browser/modules/ExtensionsUI.jsm @@ -634,8 +634,9 @@ var ExtensionsUI = { } let win = popup.ownerGlobal; - let uri = win.gBrowser.currentURI; - let state = lazy.OriginControls.getState(policy, uri); + let tab = win.gBrowser.selectedTab; + let uri = tab.linkedBrowser?.currentURI; + let state = lazy.OriginControls.getState(policy, tab); let doc = popup.ownerDocument; let whenClicked, alwaysOn, allDomains; diff --git a/toolkit/components/extensions/ExtensionActions.jsm b/toolkit/components/extensions/ExtensionActions.jsm index 6ea161b3d43b..e015c7068bd7 100644 --- a/toolkit/components/extensions/ExtensionActions.jsm +++ b/toolkit/components/extensions/ExtensionActions.jsm @@ -260,6 +260,7 @@ class PanelActionBase { if (!popupUrl) { this.dispatchClick(tab, clickInfo); } + this.updateOnChange(tab); return popupUrl; } diff --git a/toolkit/components/extensions/ExtensionPermissions.jsm b/toolkit/components/extensions/ExtensionPermissions.jsm index 23948ce517f7..fd75a0522c5c 100644 --- a/toolkit/components/extensions/ExtensionPermissions.jsm +++ b/toolkit/components/extensions/ExtensionPermissions.jsm @@ -426,24 +426,34 @@ var ExtensionPermissions = { }; var OriginControls = { + allDomains: new MatchPattern("*://*/*"), + /** * @typedef {object} OriginControlState - * @param {boolean} noAccess no options, can never access host. - * @param {boolean} whenClicked option to access host when clicked. - * @param {boolean} alwaysOn option to always access this host. - * @param {boolean} allDomains option to access to all domains. - * @param {boolean} hasAccess extension currently has access to host. + * @param {boolean} noAccess no options, can never access host. + * @param {boolean} whenClicked option to access host when clicked. + * @param {boolean} alwaysOn option to always access this host. + * @param {boolean} allDomains option to access to all domains. + * @param {boolean} hasAccess extension currently has access to host. + * @param {boolean} temporaryAccess extension has temporary access to the tab. */ /** - * Get origin controls state for a given extension on a given host. + * Get origin controls state for a given extension on a given tab. * * @param {WebExtensionPolicy} policy - * @param {nsIURI} uri + * @param {NativeTab} nativeTab * @returns {OriginControlState} Extension origin controls for this host include: */ - getState(policy, uri) { - let allDomains = new MatchPattern("*://*/*"); + getState(policy, nativeTab) { + // Note: don't use the nativeTab directly because it's different on mobile. + let tab = policy?.extension?.tabManager.getWrapper(nativeTab); + let temporaryAccess = tab?.hasActiveTabPermission; + let uri = tab?.browser.currentURI; + + if (!uri) { + return { noAccess: true }; + } // activeTab and the resulting whenClicked state is only applicable for MV2 // extensions with a browser action and MV3 extensions (with or without). @@ -460,7 +470,7 @@ var OriginControls = { } if ( - !allDomains.matches(uri) || + !this.allDomains.matches(uri) || WebExtensionPolicy.isRestrictedURI(uri) || (!couldRequest && !hasAccess && !activeTab) ) { @@ -468,15 +478,16 @@ var OriginControls = { } if (!couldRequest && !hasAccess && activeTab) { - return { whenClicked: true }; + return { whenClicked: true, temporaryAccess }; } - if (policy.allowedOrigins.subsumes(allDomains)) { + if (policy.allowedOrigins.subsumes(this.allDomains)) { return { allDomains: true, hasAccess }; } return { whenClicked: true, alwaysOn: true, + temporaryAccess, hasAccess, }; }, @@ -484,8 +495,8 @@ var OriginControls = { // Whether to show the attention indicator for extension on current tab. getAttention(policy, window) { if (policy?.manifestVersion >= 3) { - let state = this.getState(policy, window.gBrowser.currentURI); - return !!state.whenClicked && !state.hasAccess; + let state = this.getState(policy, window.gBrowser.selectedTab); + return !!state.whenClicked && !state.hasAccess && !state.temporaryAccess; } return false; }, @@ -524,7 +535,7 @@ var OriginControls = { * * @param {object} params * @param {WebExtensionPolicy} params.policy an extension's policy - * @param {nsIURI} params.uri an URI + * @param {NativeTab} params.tab the current tab * @param {boolean} params.isAction this should be true for * extensions with a browser * action, false otherwise. @@ -535,10 +546,8 @@ var OriginControls = { * @returns {FluentIdInfo?} An object with origin controls message IDs or * `null` when there is no message for the state. */ - getStateMessageIDs({ policy, uri, isAction = false, hasPopup = false }) { - const state = this.getState(policy, uri); - - // TODO: add support for temporary access. + getStateMessageIDs({ policy, tab, isAction = false, hasPopup = false }) { + const state = this.getState(policy, tab); const onHoverForAction = hasPopup ? "origin-controls-state-runnable-hover-open" @@ -560,7 +569,9 @@ var OriginControls = { if (state.whenClicked) { return { - default: "origin-controls-state-when-clicked", + default: state.temporaryAccess + ? "origin-controls-state-temporary-access" + : "origin-controls-state-when-clicked", onHover: "origin-controls-state-hover-run-visit-only", }; } -- 2.11.4.GIT