Allow features to require optional permissions
Design doc:
https://docs.google.com/document/d/1OhL0Yh7SmWffXyjW_XVQOK95Fqh7gLltk1eEtnKN8Ds/edit
Fixed: twpowertools:86
Change-Id: Iccb22aac2b285307854b7a4c002e9702c24d57f2
diff --git a/src/common/optionsPermissions.js b/src/common/optionsPermissions.js
new file mode 100644
index 0000000..9a998ad
--- /dev/null
+++ b/src/common/optionsPermissions.js
@@ -0,0 +1,178 @@
+import optionsPrototype from './optionsPrototype.json5';
+import {getOptions} from './optionsUtils.js';
+
+// #!if browser_target == 'chromium_mv3'
+const actionApi = chrome.action;
+// #!else
+const actionApi = chrome.browserAction;
+// #!endif
+
+// Required permissions, including host permissions.
+//
+// IMPORTANT: This should be kept in sync with the "permissions" key in
+// //templates/manifest.gjson.
+const requiredPermissions = new Set([
+ 'storage',
+ 'alarms',
+]);
+
+// Returns an array of optional permissions needed by |feature|.
+export function requiredOptPermissions(feature) {
+ if (!(feature in optionsPrototype)) {
+ console.error('"' + feature + '" feature doesn\'t exist.');
+ return [];
+ }
+
+ return optionsPrototype[feature]?.requiredOptPermissions ?? [];
+}
+
+// Returns a promise resolving to an array of optional permissions needed by all
+// the current enabled features.
+export function currentRequiredOptPermissions() {
+ return getOptions(null, /* requireOptionalPermissions = */ false)
+ .then(options => {
+ let permissions = [];
+
+ // For each option
+ for (const [opt, optMeta] of Object.entries(optionsPrototype))
+ // If the option is enabled
+ if (options[opt])
+ // Add its required optional permissions to the list
+ permissions.push(...(optMeta.requiredOptPermissions ?? []));
+
+ return permissions;
+ });
+}
+
+// Ensures that all the optional permissions required by |feature| are granted,
+// and requests them otherwise. It returns a promise which resolves specifying
+// whether the permissions were granted or not.
+export function ensureOptPermissions(feature) {
+ return new Promise((resolve, reject) => {
+ let permissions = requiredOptPermissions(feature);
+
+ chrome.permissions.contains({permissions}, isAlreadyGranted => {
+ if (isAlreadyGranted) return resolve(true);
+
+ chrome.permissions.request({permissions}, granted => {
+ // If there was an error, reject the promise.
+ if (granted === undefined)
+ return reject(new Error(
+ chrome.runtime.lastError.message ??
+ 'An unknown error occurred while requesting the permisisons'));
+
+ // If the permission is granted we should maybe remove the warning
+ // badge.
+ if (granted) cleanUpOptPermissions(/* removeLeftoverPerms = */ false);
+
+ return resolve(granted);
+ });
+ });
+ });
+}
+
+// Returns a promise resolving to the list of currently granted optional
+// permissions (i.e. excluding required permissions).
+export function grantedOptPermissions() {
+ return new Promise((resolve, reject) => {
+ chrome.permissions.getAll(response => {
+ if (response === undefined)
+ return reject(new Error(
+ chrome.runtime.lastError.message ??
+ 'An unknown error occurred while calling chrome.permissions.getAll()'));
+
+ let optPermissions =
+ response.permissions.filter(p => !requiredPermissions.has(p));
+ resolve(optPermissions);
+ });
+ });
+}
+
+// Returns a promise resolving to an object with 2 properties:
+// - missingPermissions: an array of optional permissions which are required
+// by enabled features and haven't been granted yet.
+// - leftoverPermissions: an array of optional permissions which are granted
+// but are no longer needed.
+export function diffPermissions() {
+ return Promise
+ .all([
+ grantedOptPermissions(),
+ currentRequiredOptPermissions(),
+ ])
+ .then(perms => {
+ return {
+ missingPermissions: perms[1].filter(p => !perms[0].includes(p)),
+ leftoverPermissions: perms[0].filter(p => !perms[1].includes(p)),
+ };
+ })
+ .catch(cause => {
+ throw new Error(
+ 'Couldn\'t compute the missing and leftover permissions.', {cause});
+ });
+}
+
+// Returns a promise which resolves to the array of required optional
+// permissions of |feature| which are missing.
+//
+// Accepts an argument |grantedPermissions| with the array of granted
+// permissions, otherwise the function will call grantedOptPermissions() to
+// retrieve them. This can be used to prevent calling
+// chrome.permissions.getAll() repeteadly.
+export function missingPermissions(feature, grantedPermissions = null) {
+ let grantedOptPermissionsPromise;
+ if (grantedPermissions !== null)
+ grantedOptPermissionsPromise = new Promise((res, rej) => {
+ res(grantedPermissions);
+ });
+ else
+ grantedOptPermissionsPromise = grantedOptPermissions();
+
+ return Promise
+ .all([
+ grantedOptPermissionsPromise,
+ requiredOptPermissions(feature),
+ ])
+ .then(perms => {
+ return perms[1].filter(p => !perms[0].includes(p));
+ })
+ .catch(cause => {
+ throw new Error(
+ 'Couldn\'t compute the missing permissions for "' + feature + '",',
+ {cause});
+ });
+}
+
+// Deletes optional permissions which are no longer needed by the current
+// set of enabled features (if |removeLeftoverPerms| is set to true), and sets a
+// badge if some needed permissions are missing.
+export function cleanUpOptPermissions(removeLeftoverPerms = true) {
+ return diffPermissions()
+ .then(perms => {
+ let {missingPermissions, leftoverPermissions} = perms;
+
+ if (missingPermissions.length > 0) {
+ actionApi.setBadgeBackgroundColor({color: '#B71C1C'});
+ actionApi.setBadgeText({text: '!'});
+ // This is to work around https://crbug.com/1268098.
+ // TODO(avm99963): Remove when the bug is fixed.
+ // #!if browser_target !== 'chromium_mv3'
+ actionApi.setTitle({
+ title: chrome.i18n.getMessage('actionbadge_permissions_requested')
+ });
+ // #!endif
+ } else {
+ actionApi.setBadgeText({text: ''});
+ actionApi.setTitle({title: ''});
+ }
+
+ if (removeLeftoverPerms) {
+ chrome.permissions.remove({
+ permissions: leftoverPermissions,
+ });
+ }
+ })
+ .catch(err => {
+ console.error(
+ 'An error ocurred while cleaning optional permissions: ', err);
+ });
+}