diff --git a/browser/modules/ExtensionsUI.sys.mjs b/browser/modules/ExtensionsUI.sys.mjs index 1286ef516ec1..d6aa6dfbd0f6 100644 --- a/browser/modules/ExtensionsUI.sys.mjs +++ b/browser/modules/ExtensionsUI.sys.mjs @@ -237,7 +237,11 @@ export var ExtensionsUI = { let strings = this._buildStrings(info); // If this is an update with no promptable permissions, just apply it - if (info.type == "update" && !strings.msgs.length) { + if ( + info.type == "update" && + !strings.msgs.length && + !strings.dataCollectionPermissions?.msg + ) { info.resolve(); return; } @@ -283,7 +287,7 @@ export var ExtensionsUI = { let strings = this._buildStrings(info); // If we don't prompt for any new permissions, just apply it - if (!strings.msgs.length) { + if (!strings.msgs.length && !strings.dataCollectionPermissions?.msg) { info.resolve(); return; } diff --git a/toolkit/components/extensions/Extension.sys.mjs b/toolkit/components/extensions/Extension.sys.mjs index 41481324ef8e..095cbd1e7ab3 100644 --- a/toolkit/components/extensions/Extension.sys.mjs +++ b/toolkit/components/extensions/Extension.sys.mjs @@ -1304,6 +1304,8 @@ export class ExtensionData { * Returns additional permissions that extensions is requesting based on its * manifest. For now, this is host_permissions (and content scripts) in mv3, * and the "technicalAndInteraction" optional data collection permission. + * + * @returns {null | Permissions} */ getRequestedPermissions() { if (this.type !== "extension") { @@ -1336,7 +1338,9 @@ export class ExtensionData { /** * Returns optional permissions from the manifest, including host permissions - * if originControls is true. + * if originControls is true, and optional data collection (if enabled). + * + * @returns {null | Permissions} */ get manifestOptionalPermissions() { if (this.type !== "extension") { @@ -1353,11 +1357,14 @@ export class ExtensionData { } } - // TODO: Bug 1955990 - add support for data collection permissions. + const data_collection = lazy.dataCollectionPermissionsEnabled + ? this.getDataCollectionPermissions().optional + : []; return { permissions: Array.from(permissions), origins: Array.from(origins), + data_collection, }; } @@ -1413,6 +1420,9 @@ export class ExtensionData { permissions: newPermissions.permissions.filter( perm => !oldPermissions.permissions.includes(perm) ), + data_collection: newPermissions.data_collection.filter( + perm => newPermissions.data_collection.includes(perm) && perm !== "none" + ), }; } @@ -1431,6 +1441,9 @@ export class ExtensionData { permissions: oldPermissions.permissions.filter(perm => newPermissions.permissions.includes(perm) ), + data_collection: oldPermissions.data_collection.filter( + perm => newPermissions.data_collection.includes(perm) && perm !== "none" + ), }; } @@ -2865,7 +2878,10 @@ export class ExtensionData { permissions.data_collection?.length ) { result.dataCollectionPermissions = - this._formatDataCollectionPermissions(permissions.data_collection); + this._formatDataCollectionPermissions( + permissions.data_collection, + type + ); } } @@ -2945,10 +2961,17 @@ export class ExtensionData { : "webext-perms-sideload-text-no-perms" ); break; - case "update": - headerId = "webext-perms-update-text"; + case "update": { + if (!lazy.dataCollectionPermissionsEnabled) { + headerId = "webext-perms-update-text"; + } else { + headerId = hasDataCollectionOnly + ? "webext-perms-update-data-collection-only-text" + : "webext-perms-update-data-collection-text"; + } acceptId = "webext-perms-update-accept"; break; + } case "optional": headerId = "webext-perms-optional-perms-header"; acceptId = "webext-perms-optional-perms-allow"; @@ -2981,7 +3004,7 @@ export class ExtensionData { * @returns {{msg: string, collectsTechnicalAndInteractionData: boolean}} An * object with information about data collection permissions for the UI. */ - static _formatDataCollectionPermissions(dataPermissions) { + static _formatDataCollectionPermissions(dataPermissions, type) { const dataCollectionPermissions = {}; const permissions = new Set(dataPermissions); @@ -3011,8 +3034,17 @@ export class ExtensionData { } } + let id; + switch (type) { + case "update": + id = "webext-perms-description-data-some-update"; + break; + default: + id = "webext-perms-description-data-some"; + } + const fluentIdAndArgs = { - id: "webext-perms-description-data-some", + id, args: { permissions: new Intl.ListFormat(undefined, { style: "narrow", diff --git a/toolkit/locales-preview/dataCollectionPermissions.ftl b/toolkit/locales-preview/dataCollectionPermissions.ftl index 6581848e8ea4..ed7cde6eca0a 100644 --- a/toolkit/locales-preview/dataCollectionPermissions.ftl +++ b/toolkit/locales-preview/dataCollectionPermissions.ftl @@ -14,6 +14,17 @@ webext-perms-description-data-none = The developer says this extension doesn’t # $permissions (String): a list of data collection permissions formatted with `Intl.ListFormat` using the "narrow" style. webext-perms-description-data-some = The developer says this extension collects: { $permissions }. +# Variables: +# $permissions (String): a list of data collection permissions formatted with `Intl.ListFormat` using the "narrow" style. +webext-perms-description-data-some-update = The developer says the extension will collect: { $permissions }. +# Variables: +# $extension (String): replaced with the localized name of the extension. +webext-perms-update-data-collection-text = { $extension } has been updated. You must approve new settings before the updated version will install. Choosing “Cancel” will maintain your current extension version. This extension will have permission to: + +# Variables: +# $extension (String): replaced with the localized name of the extension. +webext-perms-update-data-collection-only-text = { $extension } has been updated. You must approve new settings before the updated version will install. Choosing “Cancel” will maintain your current extension version. + ## Short form to be used in lists or in a string (`webext-perms-description-data-some`) ## that formats some of these permissions below using `Intl.ListFormat`. ## diff --git a/toolkit/mozapps/extensions/AddonManager.sys.mjs b/toolkit/mozapps/extensions/AddonManager.sys.mjs index ee0b4b66372e..b2dc36f0ac64 100644 --- a/toolkit/mozapps/extensions/AddonManager.sys.mjs +++ b/toolkit/mozapps/extensions/AddonManager.sys.mjs @@ -1248,7 +1248,11 @@ var AddonManagerInternal = { let difference = lazy.Extension.comparePermissions(oldPerms, newPerms); // If there are no new permissions, just go ahead with the update - if (!difference.origins.length && !difference.permissions.length) { + if ( + !difference.origins.length && + !difference.permissions.length && + !difference.data_collection.length + ) { return Promise.resolve(); } diff --git a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js index 4c830e6a8b83..6c5d17635b74 100644 --- a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js +++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js @@ -82,7 +82,11 @@ function installPromptHandler(info) { let difference = Extension.comparePermissions(oldPerms, newPerms); // If there are no new permissions, just proceed - if (!difference.origins.length && !difference.permissions.length) { + if ( + !difference.origins.length && + !difference.permissions.length && + !difference.data_collection.length + ) { return Promise.resolve(); } diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js index f3616cd08074..c26a68e0b3ee 100644 --- a/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js +++ b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js @@ -4,6 +4,10 @@ const { AddonTestUtils } = ChromeUtils.importESModule( "resource://testing-common/AddonTestUtils.sys.mjs" ); +ChromeUtils.defineESModuleGetters(this, { + PERMISSION_L10N: "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", +}); + AddonTestUtils.initMochitest(this); const server = AddonTestUtils.createHttpServer(); @@ -49,6 +53,7 @@ add_setup(async function () { function createTestExtension({ id = "test-pending-update@test", newManifest = {}, + oldManifest = {}, }) { function background() { browser.runtime.onUpdateAvailable.addListener(() => { @@ -64,6 +69,7 @@ function createTestExtension({ const manifest = { name: "Test Pending Update", + ...oldManifest, browser_specific_settings: { gecko: { id, update_url }, }, @@ -72,7 +78,16 @@ function createTestExtension({ let extension = ExtensionTestUtils.loadExtension({ background, - manifest, + manifest: { + ...oldManifest, + ...manifest, + browser_specific_settings: { + gecko: { + ...(oldManifest.browser_specific_settings?.gecko ?? {}), + ...manifest.browser_specific_settings.gecko, + }, + }, + }, // Use permanent so the add-on can be updated. useAddonManager: "permanent", }); @@ -309,3 +324,270 @@ add_task(async function test_pending_update_no_prompted_permission() { await closeView(win); await extension.unload(); }); + +add_task(async function test_pending_update_with_prompted_permission() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.dataCollectionPermissions.enabled", true]], + }); + + const TEST_CASES = [ + { + title: "With data collection", + data_collection_permissions: { + required: ["locationInfo"], + }, + verifyDialog(popupContentEl, { extensionId }) { + Assert.equal( + popupContentEl.querySelector(".popup-notification-description") + .textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-update-data-collection-only-text", + { extension: extensionId } + ), + "Expected header string without perms" + ); + Assert.equal( + popupContentEl.permsListEl.childElementCount, + 1, + "Expected a permission entry in the list" + ); + Assert.ok( + popupContentEl.permsListEl.querySelector( + "li.webext-data-collection-perm-granted" + ), + "Expected data collection item" + ); + Assert.equal( + popupContentEl.permsListEl.firstChild.textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-description-data-some-update", + { + permissions: "location", + } + ), + "Expected formatted data collection permission string" + ); + Assert.ok( + popupContentEl.hasAttribute("learnmoreurl"), + "Expected a learn more link" + ); + }, + }, + { + title: "With data collection and required permission", + permissions: ["bookmarks"], + data_collection_permissions: { + required: ["locationInfo", "healthInfo"], + }, + verifyDialog(popupContentEl, { extensionId }) { + Assert.equal( + popupContentEl.querySelector(".popup-notification-description") + .textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-update-data-collection-text", + { extension: extensionId } + ), + "Expected header string with perms" + ); + Assert.equal( + popupContentEl.permsListEl.childElementCount, + 2, + "Expected two permission entries in the list" + ); + Assert.equal( + popupContentEl.permsListEl.firstChild.textContent, + PERMISSION_L10N.formatValueSync("webext-perms-description-bookmarks"), + "Expected formatted permission string" + ); + Assert.equal( + popupContentEl.permsListEl.lastChild.textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-description-data-some-update", + { + permissions: "location, health information", + } + ), + "Expected formatted data collection permission string" + ); + Assert.ok( + popupContentEl.hasAttribute("learnmoreurl"), + "Expected a learn more link" + ); + }, + }, + { + title: "With data collection changing from required to optional", + permissions: ["bookmarks"], + old_data_collection_permissions: { + required: ["bookmarksInfo"], + }, + data_collection_permissions: { + required: ["locationInfo", "healthInfo"], + optional: ["bookmarksInfo"], + }, + verifyDialog(popupContentEl, { extensionId }) { + Assert.equal( + popupContentEl.querySelector(".popup-notification-description") + .textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-update-data-collection-text", + { extension: extensionId } + ), + "Expected header string with perms" + ); + Assert.equal( + popupContentEl.permsListEl.childElementCount, + 2, + "Expected two permission entries in the list" + ); + Assert.equal( + popupContentEl.permsListEl.childNodes[0].textContent, + PERMISSION_L10N.formatValueSync("webext-perms-description-bookmarks"), + "Expected formatted bookmarks permission string" + ); + Assert.equal( + popupContentEl.permsListEl.childNodes[1].textContent, + PERMISSION_L10N.formatValueSync( + "webext-perms-description-data-some-update", + { + permissions: "location, health information", + } + ), + "Expected formatted data collection permission string" + ); + Assert.ok( + popupContentEl.hasAttribute("learnmoreurl"), + "Expected a learn more link" + ); + }, + }, + ]; + + for (const { + title, + permissions, + data_collection_permissions, + old_data_collection_permissions, + verifyDialog, + } of TEST_CASES) { + info(title); + + const id = `@${title.toLowerCase().replaceAll(/[^\w]+/g, "-")}`; + const { extension } = createTestExtension({ + id, + oldManifest: { + browser_specific_settings: { + gecko: { + ...(old_data_collection_permissions + ? { data_collection_permissions: old_data_collection_permissions } + : {}), + }, + }, + }, + newManifest: { + name: id, + permissions, + browser_specific_settings: { + gecko: { + id, + data_collection_permissions, + }, + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgpage-ready"); + const win = await loadInitialView("extension"); + + const dialogPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ); + // This `promptPromise` is retrieving data from the prompt internals, while + // the `dialogPromise` will return the actual dialog element. + const promptPromise = promisePermissionPrompt(id); + win.checkForUpdates(); + const [popupContentEl, infoProps] = await Promise.all([ + dialogPromise, + promptPromise, + ]); + + verifyDialog(popupContentEl, { extensionId: id }); + + // Confirm the update, and proceed. + const waitForManagementUpdate = new Promise(resolve => { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + Management.once("update", resolve); + }); + infoProps.resolve(); + await promiseUpdateAvailable(extension); + await completePostponedUpdate({ id, win }); + // Ensure that the bootstrap scope update method has been executed + // successfully and emitted the update Management event. + info("Wait for the Management update to be emitted"); + await waitForManagementUpdate; + + await closeView(win); + await extension.unload(); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_pending_update_with_no_prompted_permission() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.dataCollectionPermissions.enabled", true]], + }); + + const TEST_CASES = [ + { + title: "No data collection", + data_collection_permissions: {}, + }, + { + title: "Explicit no data collection", + data_collection_permissions: { + required: ["none"], + }, + }, + { + title: "Optional data collection", + data_collection_permissions: { + optional: ["technicalAndInteraction"], + }, + }, + ]; + + for (const { title, data_collection_permissions } of TEST_CASES) { + info(title); + + const id = `@${title.toLowerCase().replaceAll(/[^\w]+/g, "-")}`; + const { extension } = createTestExtension({ + id, + newManifest: { + name: id, + browser_specific_settings: { + gecko: { + id, + data_collection_permissions, + }, + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgpage-ready"); + let win = await loadInitialView("extension"); + + win.checkForUpdates(); + await promiseUpdateAvailable(extension); + await completePostponedUpdate({ id, win }); + + await closeView(win); + await extension.unload(); + } + + await SpecialPowers.popPrefEnv(); +});