Let features request optional host permissions

Sometimes a feature requires additional host permissions to work. This
change allows features to request this.

This change has changed the structure of requiredOptPermissions in the
optionsPrototype.json5 file from being an array of string permissions to
being an object of type chrome.permissions.Permissions (an object
containing property permissions (an array of (string) permissions, as
before) and property origins (an array of (string) hosts).

Bug: twpowertools:86, twpowertools:87
Change-Id: Iacdc6f928876942e213488b12e12f950da7b7c05
diff --git a/src/common/optionsPermissions.js b/src/common/optionsPermissions.js
index 2f4b050..1bfd082 100644
--- a/src/common/optionsPermissions.js
+++ b/src/common/optionsPermissions.js
@@ -1,18 +1,32 @@
+import actionApi from './actionApi.js';
 import optionsPrototype from './optionsPrototype.json5';
 import {getOptions} from './optionsUtils.js';
-import actionApi from './actionApi.js';
 
 // 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',
-// #!if ['chromium', 'chromium_mv3'].includes(browser_target)
-  'declarativeNetRequestWithHostAccess',
-// #!endif
-]);
+// IMPORTANT: This should be kept in sync with the "permissions",
+// "host_permissions" and "content_scripts" keys in //templates/manifest.gjson.
+const requiredPermissions = {
+  permissions: new Set([
+    'storage', 'alarms',
+    // #!if ['chromium', 'chromium_mv3'].includes(browser_target)
+    'declarativeNetRequestWithHostAccess',
+    // #!endif
+  ]),
+  origins: new Set([
+    // Host permissions:
+    'https://support.google.com/*',
+
+    // Content scripts matches:
+    'https://support.google.com/s/community*',
+    'https://support.google.com/*/threads*',
+    'https://support.google.com/*/thread/*',
+    'https://support.google.com/*/profile/*',
+    'https://support.google.com/profile/*',
+  ]),
+};
+
+const permissionTypes = ['origins', 'permissions'];
 
 // Returns an array of optional permissions needed by |feature|.
 export function requiredOptPermissions(feature) {
@@ -24,19 +38,24 @@
   return optionsPrototype[feature]?.requiredOptPermissions ?? [];
 }
 
-// Returns a promise resolving to an array of optional permissions needed by all
-// the current enabled features.
+// Returns a promise resolving to the optional permissions needed by all the
+// current enabled features.
 export function currentRequiredOptPermissions() {
   return getOptions(null, /* requireOptionalPermissions = */ false)
       .then(options => {
-        let permissions = [];
+        let permissions = {
+          origins: [],
+          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 ?? []));
+            for (const type of permissionTypes)
+              permissions[type].push(
+                  ...(optMeta.requiredOptPermissions?.[type] ?? []));
 
         return permissions;
       });
@@ -49,10 +68,10 @@
   return new Promise((resolve, reject) => {
     let permissions = requiredOptPermissions(feature);
 
-    chrome.permissions.contains({permissions}, isAlreadyGranted => {
+    chrome.permissions.contains(permissions, isAlreadyGranted => {
       if (isAlreadyGranted) return resolve(true);
 
-      chrome.permissions.request({permissions}, granted => {
+      chrome.permissions.request(permissions, granted => {
         // If there was an error, reject the promise.
         if (granted === undefined)
           return reject(new Error(
@@ -69,8 +88,8 @@
   });
 }
 
-// Returns a promise resolving to the list of currently granted optional
-// permissions (i.e. excluding required permissions).
+// Returns a promise resolving to the currently granted optional permissions
+// (i.e. excluding required permissions).
 export function grantedOptPermissions() {
   return new Promise((resolve, reject) => {
     chrome.permissions.getAll(response => {
@@ -79,18 +98,20 @@
             chrome.runtime.lastError.message ??
             'An unknown error occurred while calling chrome.permissions.getAll()'));
 
-      let optPermissions =
-          response.permissions.filter(p => !requiredPermissions.has(p));
+      let optPermissions = {};
+      for (const type of permissionTypes)
+        optPermissions[type] =
+            response[type].filter(p => !requiredPermissions[type].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.
+//   - missingPermissions: optional permissions which are required by enabled
+//     features and haven't been granted yet.
+//   - leftoverPermissions: optional permissions which are granted but are no
+//     longer needed.
 export function diffPermissions() {
   return Promise
       .all([
@@ -98,10 +119,17 @@
         currentRequiredOptPermissions(),
       ])
       .then(perms => {
-        return {
-          missingPermissions: perms[1].filter(p => !perms[0].includes(p)),
-          leftoverPermissions: perms[0].filter(p => !perms[1].includes(p)),
+        let diff = {
+          missingPermissions: {},
+          leftoverPermissions: {},
         };
+        for (const type of permissionTypes) {
+          diff.missingPermissions[type] =
+              perms[1][type].filter(p => !perms[0][type].includes(p));
+          diff.leftoverPermissions[type] =
+              perms[0][type].filter(p => !perms[1][type].includes(p));
+        }
+        return diff;
       })
       .catch(cause => {
         throw new Error(
@@ -109,13 +137,12 @@
       });
 }
 
-// Returns a promise which resolves to the array of required optional
-// permissions of |feature| which are missing.
+// Returns a promise which resolves to the 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.
+// Accepts an argument |grantedPermissions| with the 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)
@@ -131,7 +158,11 @@
         requiredOptPermissions(feature),
       ])
       .then(perms => {
-        return perms[1].filter(p => !perms[0].includes(p));
+        let missingPerms = {};
+        for (const type of permissionTypes)
+          missingPerms[type] =
+              perms[1][type].filter(p => !perms[0][type].includes(p))
+          return missingPerms;
       })
       .catch(cause => {
         throw new Error(
@@ -140,6 +171,15 @@
       });
 }
 
+// Returns true if permissions (a chrome.permissions.Permissions object) is
+// empty (that is, if their properties have empty arrays).
+export function isPermissionsObjectEmpty(permissions) {
+  for (const type of permissionTypes) {
+    if ((permissions[type]?.length ?? 0) > 0) return false;
+  }
+  return true;
+}
+
 // 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.
@@ -148,25 +188,19 @@
       .then(perms => {
         let {missingPermissions, leftoverPermissions} = perms;
 
-        if (missingPermissions.length > 0) {
+        if (!isPermissionsObjectEmpty(missingPermissions)) {
           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,
-          });
+          chrome.permissions.remove(leftoverPermissions);
         }
       })
       .catch(err => {
diff --git a/src/common/optionsUtils.js b/src/common/optionsUtils.js
index f4009ed..d8045f6 100644
--- a/src/common/optionsUtils.js
+++ b/src/common/optionsUtils.js
@@ -1,4 +1,4 @@
-import {grantedOptPermissions, missingPermissions} from './optionsPermissions.js';
+import {grantedOptPermissions, isPermissionsObjectEmpty, missingPermissions} from './optionsPermissions.js';
 import optionsPrototype from './optionsPrototype.json5';
 import specialOptions from './specialOptions.json5';
 
@@ -45,7 +45,7 @@
 
     Promise.all(permissionChecksPromises).then(missingPerms => {
       for (let i = 0; i < permissionChecksFeatures.length; i++)
-        if (missingPerms[i].length > 0)
+        if (!isPermissionsObjectEmpty(missingPerms[i]))
           items[permissionChecksFeatures[i]] = false;
 
       return items;
@@ -112,10 +112,12 @@
              // If we don't have access to the chrome.permissions API (content
              // scripts don't have access to it[1]), do the final piece of
              // computation in the service worker/background script.
-             // [1]: https://developer.chrome.com/docs/extensions/mv3/content_scripts/
+             // [1]:
+             // https://developer.chrome.com/docs/extensions/mv3/content_scripts/
 
              // #!if !production
-             console.debug('We are about to start checking granted permissions');
+             console.debug(
+                 'We are about to start checking granted permissions');
              console.timeLog(timeLabel);
              // #!endif
              if (!chrome.permissions) {
diff --git a/src/options/optionsCommon.js b/src/options/optionsCommon.js
index 99874e5..f0b20bf 100644
--- a/src/options/optionsCommon.js
+++ b/src/options/optionsCommon.js
@@ -1,5 +1,5 @@
 import {getExtVersion, isProdVersion} from '../common/extUtils.js';
-import {ensureOptPermissions, grantedOptPermissions, missingPermissions} from '../common/optionsPermissions.js';
+import {ensureOptPermissions, grantedOptPermissions, isPermissionsObjectEmpty, missingPermissions} from '../common/optionsPermissions.js';
 import {cleanUpOptions, optionsPrototype, specialOptions} from '../common/optionsUtils.js';
 
 import optionsPage from './optionsPage.json5';
@@ -250,9 +250,10 @@
             for (const mode of threadPageModes) {
               let modeOption = document.createElement('option');
               modeOption.value = mode;
-              modeOption.textContent =
-                  chrome.i18n.getMessage('options_interopthreadpage_mode_' + mode);
-              if (items.interopthreadpage_mode == mode) modeOption.selected = true;
+              modeOption.textContent = chrome.i18n.getMessage(
+                  'options_interopthreadpage_mode_' + mode);
+              if (items.interopthreadpage_mode == mode)
+                modeOption.selected = true;
               select.appendChild(modeOption);
             }
 
@@ -293,7 +294,7 @@
     grantedOptPermissions()
         .then(grantedPerms => {
           for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
-            if (!optMeta.requiredOptPermissions?.length || !isOptionShown(opt))
+            if (!optMeta.requiredOptPermissions || !isOptionShown(opt))
               continue;
 
             let warningLabel = document.querySelector(
@@ -346,8 +347,7 @@
               let shownHeaderMessage = false;
               missingPermissions(opt, grantedPerms)
                   .then(missingPerms => {
-                    console.log(missingPerms);
-                    if (missingPerms.length > 0) {
+                    if (!isPermissionsObjectEmpty(missingPerms)) {
                       checkbox.indeterminate = true;
                       checkbox.setAttribute(kClickShouldEnableFeat, '');
 
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index b4f0281..e1fa94f 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -143,9 +143,12 @@
     "service_worker": "bg.bundle.js"
 #endif
   },
-#if defined(CHROMIUM || CHROMIUM_MV3)
+#if defined(CHROMIUM)
   "minimum_chrome_version": "96",
 #endif
+#if defined(CHROMIUM_MV3)
+  "minimum_chrome_version": "100",
+#endif
 #if defined(GECKO)
   "browser_specific_settings": {
     "gecko": {