Bug 1957729 - Show data collection permissions in the update prompt. r=rpl,fluent-reviewers,bolsson

Differential Revision: https://phabricator.services.mozilla.com/D244067
This commit is contained in:
William Durand
2025-04-12 11:23:37 +00:00
parent c4855cb223
commit b7a8934207
6 changed files with 349 additions and 12 deletions

View File

@@ -237,7 +237,11 @@ export var ExtensionsUI = {
let strings = this._buildStrings(info); let strings = this._buildStrings(info);
// If this is an update with no promptable permissions, just apply it // 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(); info.resolve();
return; return;
} }
@@ -283,7 +287,7 @@ export var ExtensionsUI = {
let strings = this._buildStrings(info); let strings = this._buildStrings(info);
// If we don't prompt for any new permissions, just apply it // 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(); info.resolve();
return; return;
} }

View File

@@ -1304,6 +1304,8 @@ export class ExtensionData {
* Returns additional permissions that extensions is requesting based on its * Returns additional permissions that extensions is requesting based on its
* manifest. For now, this is host_permissions (and content scripts) in mv3, * manifest. For now, this is host_permissions (and content scripts) in mv3,
* and the "technicalAndInteraction" optional data collection permission. * and the "technicalAndInteraction" optional data collection permission.
*
* @returns {null | Permissions}
*/ */
getRequestedPermissions() { getRequestedPermissions() {
if (this.type !== "extension") { if (this.type !== "extension") {
@@ -1336,7 +1338,9 @@ export class ExtensionData {
/** /**
* Returns optional permissions from the manifest, including host permissions * 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() { get manifestOptionalPermissions() {
if (this.type !== "extension") { 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 { return {
permissions: Array.from(permissions), permissions: Array.from(permissions),
origins: Array.from(origins), origins: Array.from(origins),
data_collection,
}; };
} }
@@ -1413,6 +1420,9 @@ export class ExtensionData {
permissions: newPermissions.permissions.filter( permissions: newPermissions.permissions.filter(
perm => !oldPermissions.permissions.includes(perm) 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 => permissions: oldPermissions.permissions.filter(perm =>
newPermissions.permissions.includes(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 permissions.data_collection?.length
) { ) {
result.dataCollectionPermissions = 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" : "webext-perms-sideload-text-no-perms"
); );
break; break;
case "update": case "update": {
if (!lazy.dataCollectionPermissionsEnabled) {
headerId = "webext-perms-update-text"; 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"; acceptId = "webext-perms-update-accept";
break; break;
}
case "optional": case "optional":
headerId = "webext-perms-optional-perms-header"; headerId = "webext-perms-optional-perms-header";
acceptId = "webext-perms-optional-perms-allow"; acceptId = "webext-perms-optional-perms-allow";
@@ -2981,7 +3004,7 @@ export class ExtensionData {
* @returns {{msg: string, collectsTechnicalAndInteractionData: boolean}} An * @returns {{msg: string, collectsTechnicalAndInteractionData: boolean}} An
* object with information about data collection permissions for the UI. * object with information about data collection permissions for the UI.
*/ */
static _formatDataCollectionPermissions(dataPermissions) { static _formatDataCollectionPermissions(dataPermissions, type) {
const dataCollectionPermissions = {}; const dataCollectionPermissions = {};
const permissions = new Set(dataPermissions); 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 = { const fluentIdAndArgs = {
id: "webext-perms-description-data-some", id,
args: { args: {
permissions: new Intl.ListFormat(undefined, { permissions: new Intl.ListFormat(undefined, {
style: "narrow", style: "narrow",

View File

@@ -14,6 +14,17 @@ webext-perms-description-data-none = The developer says this extension doesnt
# $permissions (String): a list of data collection permissions formatted with `Intl.ListFormat` using the "narrow" style. # $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 }. 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`) ## 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`. ## that formats some of these permissions below using `Intl.ListFormat`.
## ##

View File

@@ -1248,7 +1248,11 @@ var AddonManagerInternal = {
let difference = lazy.Extension.comparePermissions(oldPerms, newPerms); let difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
// If there are no new permissions, just go ahead with the update // 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(); return Promise.resolve();
} }

View File

@@ -82,7 +82,11 @@ function installPromptHandler(info) {
let difference = Extension.comparePermissions(oldPerms, newPerms); let difference = Extension.comparePermissions(oldPerms, newPerms);
// If there are no new permissions, just proceed // 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(); return Promise.resolve();
} }

View File

@@ -4,6 +4,10 @@ const { AddonTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/AddonTestUtils.sys.mjs" "resource://testing-common/AddonTestUtils.sys.mjs"
); );
ChromeUtils.defineESModuleGetters(this, {
PERMISSION_L10N: "resource://gre/modules/ExtensionPermissionMessages.sys.mjs",
});
AddonTestUtils.initMochitest(this); AddonTestUtils.initMochitest(this);
const server = AddonTestUtils.createHttpServer(); const server = AddonTestUtils.createHttpServer();
@@ -49,6 +53,7 @@ add_setup(async function () {
function createTestExtension({ function createTestExtension({
id = "test-pending-update@test", id = "test-pending-update@test",
newManifest = {}, newManifest = {},
oldManifest = {},
}) { }) {
function background() { function background() {
browser.runtime.onUpdateAvailable.addListener(() => { browser.runtime.onUpdateAvailable.addListener(() => {
@@ -64,6 +69,7 @@ function createTestExtension({
const manifest = { const manifest = {
name: "Test Pending Update", name: "Test Pending Update",
...oldManifest,
browser_specific_settings: { browser_specific_settings: {
gecko: { id, update_url }, gecko: { id, update_url },
}, },
@@ -72,7 +78,16 @@ function createTestExtension({
let extension = ExtensionTestUtils.loadExtension({ let extension = ExtensionTestUtils.loadExtension({
background, 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. // Use permanent so the add-on can be updated.
useAddonManager: "permanent", useAddonManager: "permanent",
}); });
@@ -309,3 +324,270 @@ add_task(async function test_pending_update_no_prompted_permission() {
await closeView(win); await closeView(win);
await extension.unload(); 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();
});