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/package-lock.json b/package-lock.json
index 77eb6b2..7a6ad3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
         "style-loader": "^3.2.1",
         "webpack": "^5.44.0",
         "webpack-cli": "^4.7.2",
+        "webpack-preprocessor-loader": "^1.1.4",
         "webpack-shell-plugin-next": "^2.2.2"
       }
     },
@@ -2187,6 +2188,15 @@
         "node": ">=10.0.0"
       }
     },
+    "node_modules/webpack-preprocessor-loader": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/webpack-preprocessor-loader/-/webpack-preprocessor-loader-1.1.4.tgz",
+      "integrity": "sha512-Ajjj0ns6hwCb5DemvDLp3v5JYHZ0B6+K+CsD3Q/HUc8CWlCTC4QgrUXhZ6oCZG+MDuw44suw2Q3UOZ19FbqzjA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.11.5"
+      }
+    },
     "node_modules/webpack-shell-plugin-next": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/webpack-shell-plugin-next/-/webpack-shell-plugin-next-2.2.2.tgz",
@@ -3848,6 +3858,12 @@
         "wildcard": "^2.0.0"
       }
     },
+    "webpack-preprocessor-loader": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/webpack-preprocessor-loader/-/webpack-preprocessor-loader-1.1.4.tgz",
+      "integrity": "sha512-Ajjj0ns6hwCb5DemvDLp3v5JYHZ0B6+K+CsD3Q/HUc8CWlCTC4QgrUXhZ6oCZG+MDuw44suw2Q3UOZ19FbqzjA==",
+      "dev": true
+    },
     "webpack-shell-plugin-next": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/webpack-shell-plugin-next/-/webpack-shell-plugin-next-2.2.2.tgz",
diff --git a/package.json b/package.json
index aab39ac..3263d41 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "style-loader": "^3.2.1",
     "webpack": "^5.44.0",
     "webpack-cli": "^4.7.2",
+    "webpack-preprocessor-loader": "^1.1.4",
     "webpack-shell-plugin-next": "^2.2.2"
   },
   "private": true,
diff --git a/src/background.js b/src/background.js
index a2bb071..7dc5faf 100644
--- a/src/background.js
+++ b/src/background.js
@@ -1,5 +1,6 @@
 // IMPORTANT: keep this file in sync with sw.js
-import {cleanUpOptions} from './common/optionsUtils.js';
+import {cleanUpOptPermissions} from './common/optionsPermissions.js';
+import {cleanUpOptions, disableItemsWithMissingPermissions} from './common/optionsUtils.js';
 import KillSwitchMechanism from './killSwitch/index.js';
 
 chrome.browserAction.onClicked.addListener(function() {
@@ -33,3 +34,34 @@
     killSwitchMechanism.updateKillSwitchStatus();
   }
 });
+
+// Clean up optional permissions and check that none are missing for enabled
+// features as soon as the extension starts and when the options change.
+cleanUpOptPermissions();
+
+chrome.storage.sync.onChanged.addListener(() => {
+  cleanUpOptPermissions();
+});
+
+chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+  if (sender.id !== chrome.runtime.id)
+    return console.warn(
+        'An unknown sender (' + sender.id +
+            ') sent a message to the extension: ',
+        msg);
+
+  console.assert(msg.message);
+  switch (msg.message) {
+    case 'runDisableItemsWithMissingPermissions':
+      console.assert(
+          msg.options?.items && msg.options?.permissionChecksFeatures);
+      disableItemsWithMissingPermissions(
+          msg.options?.items, msg.options?.permissionChecksFeatures)
+          .then(items => sendResponse({status: 'resolved', items}))
+          .catch(error => sendResponse({status: 'rejected', error}));
+      break;
+
+    default:
+      console.warn('Unknown message "' + msg.message + '".');
+  }
+});
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);
+      });
+}
diff --git a/src/common/optionsUtils.js b/src/common/optionsUtils.js
index 9ead0b2..f4009ed 100644
--- a/src/common/optionsUtils.js
+++ b/src/common/optionsUtils.js
@@ -1,3 +1,4 @@
+import {grantedOptPermissions, missingPermissions} from './optionsPermissions.js';
 import optionsPrototype from './optionsPrototype.json5';
 import specialOptions from './specialOptions.json5';
 
@@ -27,40 +28,147 @@
   return options;
 }
 
-// Returns a promise which returns the values of options |options| which are
-// stored in the sync storage area.
-export function getOptions(options) {
-  // Once we only target MV3, this can be greatly simplified.
-  return new Promise((resolve, reject) => {
-    if (typeof options === 'string')
-      options = [options, '_forceDisabledFeatures'];
-    else if (Array.isArray(options))
-      options = [...options, '_forceDisabledFeatures'];
-    else if (options !== null)
-      console.error(
-          'Unexpected |options| parameter of type ' + (typeof options) +
-          ' (expected: string, array, or null).');
+// This piece of code is used as part of the getOptions computation, and so
+// isn't that useful. It's exported since we sometimes need to finish the
+// computation in a service worker, where we have access to the
+// chrome.permissions API.
+//
+// It accepts as an argument an object |items| with the same structure of the
+// items saved in the sync storage area, and an array |permissionChecksFeatures|
+// of features
+export function disableItemsWithMissingPermissions(
+    items, permissionChecksFeatures) {
+  return grantedOptPermissions().then(grantedPerms => {
+    let permissionChecksPromises = [];
+    for (const f of permissionChecksFeatures)
+      permissionChecksPromises.push(missingPermissions(f, grantedPerms));
 
-    chrome.storage.sync.get(options, items => {
-      if (chrome.runtime.lastError) return reject(chrome.runtime.lastError);
+    Promise.all(permissionChecksPromises).then(missingPerms => {
+      for (let i = 0; i < permissionChecksFeatures.length; i++)
+        if (missingPerms[i].length > 0)
+          items[permissionChecksFeatures[i]] = false;
 
-      // Handle applicable kill switches which force disable features
-      if (items?._forceDisabledFeatures) {
-        for (let feature of items?._forceDisabledFeatures) {
-          items[feature] = false;
-        }
-
-        delete items._forceDisabledFeatures;
-      }
-
-      resolve(items);
+      return items;
     });
   });
 }
 
+// Returns a promise which returns the values of options |options| which are
+// stored in the sync storage area.
+//
+// |requireOptionalPermissions| will determine whether to check if the required
+// optional permissions have been granted or not to the options which have such
+// requirements. If it is true, features with missing permissions will have
+// their value set to false.
+//
+// When a kill switch is active, affected options always have their value set to
+// false.
+
+// #!if !production
+let timerId = 0;
+let randomId = btoa(Math.random().toString()).substr(10, 5);
+// #!endif
+export function getOptions(options, requireOptionalPermissions = true) {
+  // #!if !production
+  let timeLabel = 'getOptions--' + randomId + '-' + (timerId++);
+  console.time(timeLabel);
+  // #!endif
+  // Once we only target MV3, this can be greatly simplified.
+  return new Promise((resolve, reject) => {
+           if (typeof options === 'string')
+             options = [options, '_forceDisabledFeatures'];
+           else if (Array.isArray(options))
+             options = [...options, '_forceDisabledFeatures'];
+           else if (options !== null)
+             return reject(new Error(
+                 'Unexpected |options| parameter of type ' + (typeof options) +
+                 ' (expected: string, array, or null).'));
+
+           chrome.storage.sync.get(options, items => {
+             if (chrome.runtime.lastError)
+               return reject(chrome.runtime.lastError);
+
+             // Handle applicable kill switches which force disable features
+             if (items?._forceDisabledFeatures) {
+               for (let feature of items?._forceDisabledFeatures) {
+                 items[feature] = false;
+               }
+
+               delete items._forceDisabledFeatures;
+             }
+
+             if (!requireOptionalPermissions) return resolve(items);
+
+             // Check whether some options have missing permissions which would
+             // force disable these features
+             let permissionChecksFeatures = [];
+             for (const [key, value] of Object.entries(items))
+               if ((key in optionsPrototype) && value &&
+                   optionsPrototype[key].requiredOptPermissions?.length)
+                 permissionChecksFeatures.push(key);
+
+             if (permissionChecksFeatures.length == 0) return resolve(items);
+
+             // 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/
+
+             // #!if !production
+             console.debug('We are about to start checking granted permissions');
+             console.timeLog(timeLabel);
+             // #!endif
+             if (!chrome.permissions) {
+               return chrome.runtime.sendMessage(
+                   {
+                     message: 'runDisableItemsWithMissingPermissions',
+                     options: {
+                       items,
+                       permissionChecksFeatures,
+                     },
+                   },
+                   response => {
+                     if (response === undefined)
+                       return reject(new Error(
+                           'An error ocurred while communicating with the service worker: ' +
+                           chrome.runtime.lastError.message));
+
+                     if (response.status == 'rejected')
+                       return reject(response.error);
+                     if (response.status == 'resolved')
+                       return resolve(response.items);
+                     return reject(new Error(
+                         'An unknown response was recieved from service worker.'));
+                   });
+             }
+
+             disableItemsWithMissingPermissions(items, permissionChecksFeatures)
+                 .then(finalItems => resolve(finalItems))
+                 .catch(err => reject(err));
+           });
+         })
+      // #!if !production
+      .then(items => {
+        console.group('getOptions(options); resolved; options: ', options);
+        console.timeEnd(timeLabel);
+        console.groupEnd();
+        return items;
+      })
+      .catch(err => {
+        console.group('getOptions(options); rejected; options: ', options);
+        console.timeEnd(timeLabel);
+        console.groupEnd();
+        throw err;
+      })
+      // #!endif
+      ;
+}
+
 // Returns a promise which returns whether the |option| option/feature is
-// currently enabled.
-export function isOptionEnabled(option) {
+// currently enabled. If the feature requires optional permissions to work,
+// |requireOptionalPermissions| will determine whether to check if the required
+// optional permissions have been granted or not.
+export function isOptionEnabled(option, requireOptionalPermissions = true) {
   return getOptions(option).then(options => {
     return options?.[option] === true;
   });
diff --git a/src/options/optionsCommon.js b/src/options/optionsCommon.js
index c76cd6c..b4bb42a 100644
--- a/src/options/optionsCommon.js
+++ b/src/options/optionsCommon.js
@@ -1,10 +1,13 @@
 import {getExtVersion, isFirefox, isReleaseVersion} from '../common/extUtils.js';
+import {ensureOptPermissions, grantedOptPermissions, missingPermissions} from '../common/optionsPermissions.js';
 import {cleanUpOptions, optionsPrototype, specialOptions} from '../common/optionsUtils.js';
+
 import optionsPage from './optionsPage.json5';
 
 var savedSuccessfullyTimeout = null;
 
 const exclusiveOptions = [['thread', 'threadall']];
+const kClickShouldEnableFeat = 'data-click-should-enable-feature';
 
 // Get a URL to a document which is part of the extension documentation (using
 // |ref| as the Git ref).
@@ -142,14 +145,25 @@
             optionsContainer.append(optionEl);
           }
 
-          // Add kill switch component after each option.
+          // Add optional permissions warning label and kill switch component
+          // after each option.
+          let optionalPermissionsWarningLabel = document.createElement('div');
+          optionalPermissionsWarningLabel.classList.add(
+              'optional-permissions-warning-label');
+          optionalPermissionsWarningLabel.setAttribute('hidden', '');
+          optionalPermissionsWarningLabel.setAttribute(
+              'data-feature', option.codename);
+          optionalPermissionsWarningLabel.setAttribute(
+              'data-i18n', 'optionalpermissionswarning_label');
+
           let killSwitchComponent = document.createElement('div');
           killSwitchComponent.classList.add('kill-switch-label');
           killSwitchComponent.setAttribute('hidden', '');
           killSwitchComponent.setAttribute('data-feature', option.codename);
           killSwitchComponent.setAttribute('data-i18n', 'killswitchenabled');
 
-          optionsContainer.append(killSwitchComponent);
+          optionsContainer.append(
+              optionalPermissionsWarningLabel, killSwitchComponent);
         }
       }
 
@@ -178,9 +192,6 @@
         document.getElementById('kill-switch-warning')
             .removeAttribute('hidden');
       }
-
-      // TODO(avm99963): show a message above each option that has been force
-      // disabled
     }
 
     for (var entry of Object.entries(optionsPrototype)) {
@@ -262,6 +273,84 @@
             }
           }));
     });
+
+    // Handle options which need optional permissions.
+    grantedOptPermissions()
+        .then(grantedPerms => {
+          for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
+            if (!optMeta.requiredOptPermissions?.length || !isOptionShown(opt))
+              continue;
+
+            let warningLabel = document.querySelector(
+                '.optional-permissions-warning-label[data-feature="' + opt +
+                '"]');
+
+            // Ensure we have the appropriate permissions when the checkbox
+            // switches from disabled to enabled.
+            //
+            // Also, if the checkbox was indeterminate because the feature was
+            // enabled but not all permissions had been granted, enable the
+            // feature in order to trigger the permission request again.
+            let checkbox = document.getElementById(opt);
+            if (!checkbox) {
+              console.error('Expected checkbox for feature "' + opt + '".');
+              continue;
+            }
+            checkbox.addEventListener('change', () => {
+              if (checkbox.hasAttribute(kClickShouldEnableFeat)) {
+                checkbox.removeAttribute(kClickShouldEnableFeat);
+                checkbox.checked = true;
+              }
+
+              if (checkbox.checked)
+                ensureOptPermissions(opt)
+                    .then(granted => {
+                      if (granted) {
+                        warningLabel.setAttribute('hidden', '');
+                        if (!document.querySelector(
+                                '.optional-permissions-warning-label:not([hidden])'))
+                          document
+                              .getElementById('optional-permissions-warning')
+                              .setAttribute('hidden', '');
+                      } else
+                        document.getElementById('blockdrafts').checked = false;
+                    })
+                    .catch(err => {
+                      console.error(
+                          'An error ocurred while ensuring that the optional ' +
+                              'permissions were granted after the checkbox ' +
+                              'was clicked for feature "' + opt + '":',
+                          err);
+                      document.getElementById('blockdrafts').checked = false;
+                    });
+            });
+
+            // Add warning message if some permissions are missing and the
+            // feature is enabled.
+            if (items[opt] === true) {
+              let shownHeaderMessage = false;
+              missingPermissions(opt, grantedPerms)
+                  .then(missingPerms => {
+                    console.log(missingPerms);
+                    if (missingPerms.length > 0) {
+                      checkbox.indeterminate = true;
+                      checkbox.setAttribute(kClickShouldEnableFeat, '');
+
+                      warningLabel.removeAttribute('hidden');
+
+                      if (!shownHeaderMessage) {
+                        shownHeaderMessage = true;
+                        document.getElementById('optional-permissions-warning')
+                            .removeAttribute('hidden');
+                      }
+                    }
+                  })
+                  .catch(err => console.error(err));
+            }
+          }
+        })
+        .catch(err => console.error(err));
+
     document.querySelector('#save').addEventListener('click', save);
   });
 });
diff --git a/src/static/_locales/en/messages.json b/src/static/_locales/en/messages.json
index 1fe26e9..9b4ec2e 100644
--- a/src/static/_locales/en/messages.json
+++ b/src/static/_locales/en/messages.json
@@ -23,6 +23,14 @@
     "message": "The previous option has been force disabled due to an issue.",
     "description": "Warning shown in the options page below an option, when it has been remotely force disabled via the kill switch mechanism."
   },
+  "options_optionalpermissionswarning_header": {
+    "message": "One or more features can't be used because they need additional permissions. These features have been highlighted below, click their checkboxes to grant the appropriate permissions.",
+    "description": "Warning shown at the top of the options page if a feature cannot be used because one or more required permissions haven't been granted to the extension."
+  },
+  "options_optionalpermissionswarning_label": {
+    "message": "The previous feature needs additional permissions to work.",
+    "description": "Warning shown in the options page below an option, when a feature needs more permissions to work."
+  },
   "options_featuredoptions": {
     "message": "Featured options",
     "description": "Heading for several options that can be enabled in the options page."
@@ -250,5 +258,9 @@
   "inject_threadlistavatars_private_thread_indicator_label": {
     "message": "Due to technical reasons, we can't load the avatars of threads published in private forums.",
     "description": "Helper text which appears when hovering an icon next to a thread, to explain its meaning."
+  },
+  "actionbadge_permissions_requested": {
+    "message": "Some features need additional permissions to work. Click to fix it.",
+    "description": "Tooltip for the extension icon when a feature is enabled but it needs several permissions to be granted."
   }
 }
diff --git a/src/static/options/chrome_style/chrome_style.css b/src/static/options/chrome_style/chrome_style.css
index 4d3c91b..3f8b076 100644
--- a/src/static/options/chrome_style/chrome_style.css
+++ b/src/static/options/chrome_style/chrome_style.css
@@ -84,7 +84,6 @@
 select,
 input[type='checkbox'],
 input[type='radio'] {
-  -webkit-appearance: none;
   -webkit-user-select: none;
   background-image: linear-gradient(#ededed, #ededed 38%, #dedede);
   border: 1px solid rgba(0, 0, 0, 0.25);
@@ -98,6 +97,12 @@
   text-shadow: 0 1px 0 rgb(240, 240, 240);
 }
 
+select,
+input[type='checkbox']:not(:indeterminate),
+input[type='radio'] {
+  -webkit-appearance: none;
+}
+
 :-webkit-any(button,
              input[type='button'],
              input[type='submit']),
@@ -173,7 +178,7 @@
 
 /* Checked ********************************************************************/
 
-input[type='checkbox']:checked::before {
+input[type='checkbox']:checked:not(:indeterminate)::before {
   -webkit-user-select: none;
   background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAQAAAADpb+tAAAAZ0lEQVR4AWNAA2xAiAXEM8xiMEAXVGJYz7AZCFEkmBi6wYKtEC4/gxqY9gILrmYQhwiXMWxkiAVyVoOFfSCCpkAmCK4Fk+1QA4GqekECUAMkka0KY9gIFvZDd5oawwyGBqACdIDqOwAQzBnTWnnU+gAAAABJRU5ErkJggg==);
   background-size: 100% 100%;
diff --git a/src/static/options/experiments.html b/src/static/options/experiments.html
index f5452fa..0f42f11 100644
--- a/src/static/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -12,6 +12,7 @@
       <h1 data-i18n="experiments_title"></h1>
       <p data-i18n="experiments_description"></p>
       <form>
+        <div id="optional-permissions-warning" hidden data-i18n="optionalpermissionswarning_header"></div>
         <div class="actions"><button id="save" data-i18n="save"></button></div>
       </form>
       <div id="save-indicator"></div>
diff --git a/src/static/options/options.css b/src/static/options/options.css
index ce79b81..68057ce 100644
--- a/src/static/options/options.css
+++ b/src/static/options/options.css
@@ -24,16 +24,22 @@
   cursor: pointer;
 }
 
-.option, #kill-switch-warning {
+.option,
+    #kill-switch-warning,
+    #optional-permissions-warning {
   margin: 4px 0;
   line-height: 1.8em;
 }
 
-#kill-switch-warning, .kill-switch-label {
+#kill-switch-warning,
+    .kill-switch-label,
+    #optional-permissions-warning,
+    .optional-permissions-warning-label {
   color: red;
 }
 
-.kill-switch-label {
+.kill-switch-label,
+    .optional-permissions-warning-label {
   margin: 0 0 8px 0;
   line-height: 1em;
 }
diff --git a/src/static/options/options.html b/src/static/options/options.html
index 8a0915a..36fd08a 100644
--- a/src/static/options/options.html
+++ b/src/static/options/options.html
@@ -39,6 +39,7 @@
         </a>
       </div>
       <form>
+        <div id="optional-permissions-warning" hidden data-i18n="optionalpermissionswarning_header"></div>
         <div id="kill-switch-warning" hidden data-i18n="killswitchwarning"></div>
         <div id="options-container"></div>
         <div class="actions"><button id="save" data-i18n="save"></button></div>
diff --git a/src/sw.js b/src/sw.js
index 73efa06..fce78d2 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,7 +1,8 @@
 // IMPORTANT: keep this file in sync with background.js
 import XMLHttpRequest from 'sw-xhr';
 
-import {cleanUpOptions} from './common/optionsUtils.js';
+import {cleanUpOptPermissions} from './common/optionsPermissions.js';
+import {cleanUpOptions, disableItemsWithMissingPermissions} from './common/optionsUtils.js';
 import KillSwitchMechanism from './killSwitch/index.js';
 
 // XMLHttpRequest is not present in service workers and is required by the
@@ -40,3 +41,34 @@
     killSwitchMechanism.updateKillSwitchStatus();
   }
 });
+
+// Clean up optional permissions and check that none are missing for enabled
+// features as soon as the extension starts and when the options change.
+cleanUpOptPermissions();
+
+chrome.storage.sync.onChanged.addListener(() => {
+  cleanUpOptPermissions();
+});
+
+chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+  if (sender.id !== chrome.runtime.id)
+    return console.warn(
+        'An unknown sender (' + sender.id +
+            ') sent a message to the extension: ',
+        msg);
+
+  console.assert(msg.message);
+  switch (msg.message) {
+    case 'runDisableItemsWithMissingPermissions':
+      console.assert(
+          msg.options?.items && msg.options?.permissionChecksFeatures);
+      disableItemsWithMissingPermissions(
+          msg.options?.items, msg.options?.permissionChecksFeatures)
+          .then(items => sendResponse({status: 'resolved', items}))
+          .catch(error => sendResponse({status: 'rejected', error}));
+      break;
+
+    default:
+      console.warn('Unknown message "' + msg.message + '".');
+  }
+});
diff --git a/webpack.config.js b/webpack.config.js
index 2c94b12..660d631 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -121,6 +121,20 @@
             },
           ],
         },
+        {
+          test: /\.js$/i,
+          use: [
+            {
+              loader: 'webpack-preprocessor-loader',
+              options: {
+                params: {
+                  browser_target: env.browser_target,
+                  production: args.mode == 'production',
+                },
+              },
+            },
+          ],
+        },
       ]
     },
   };