Add initial MV3 support

In order to prepare the migration towards Manifest Version 3, this
change adds a MV3 browser target for Chromium (chromium_mv3) and another
one for Edge (edge_mv3), which will build the extension using MV3.

This CL also changes several things to fix some bugs in the MV3 version
of the extension:

- Adds //src/common/actionApi.js to target |chrome.browserAction| (MV2)
  and |chrome.action| (MV3) accordingly using the version-agnostic
  object |actionApi|.
- Adds //src/common/sessionStorage.js to wrap the
  |chrome.storage.session| object in the case of MV3, and polyfill it in
  the case of MV2. (The polyfill isn't perfect, since it stores the data
  under the window object of the current page, so different pages will
  have access to completely different storage areas, which are cleared
  once the page is closed, not once the extension stops running.
  However, this is fine for our case.)

As of now, the extension built with MV3 might have bugs. Thus, the MV3
variant will not be used for builds until it has been stabilized.

Bug: translateselectedtext:4

Change-Id: Ib525339c055237b32b7352c490fee86b21555ed6
diff --git a/Makefile b/Makefile
index 166ca7b..ec00ad4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: node_deps clean_dist deps clean_deps serve_chromium serve_edge release release_chromium_stable release_chromium_beta release_edge build_test_extension clean_releases clean
+.PHONY: node_deps clean_dist deps clean_deps serve_chromium serve_chromium_mv3 serve_edge serve_edge_mv3 release release_chromium_stable release_chromium_beta release_edge build_test_extension clean_releases clean
 
 .DEFAULT_GOAL := release
 WEBPACK := ./node_modules/webpack-cli/bin/cli.js
@@ -20,9 +20,15 @@
 serve_chromium: deps
 	$(WEBPACK) --mode development --env browser_target=chromium --watch
 
+serve_chromium_mv3: deps
+	$(WEBPACK) --mode development --env browser_target=chromium_mv3 --watch
+
 serve_edge: deps
 	$(WEBPACK) --mode development --env browser_target=edge --watch
 
+serve_edge_mv3: deps
+	$(WEBPACK) --mode development --env browser_target=edge_mv3 --watch
+
 release: release_chromium_stable release_chromium_beta release_edge
 
 release_chromium_stable: deps
diff --git a/src/background.js b/src/background.js
index 0befddf..d3aa1ed 100644
--- a/src/background.js
+++ b/src/background.js
@@ -1,8 +1,7 @@
+import actionApi from './common/actionApi.js';
 import {isoLangs} from './common/consts.js';
 import Options from './common/options.js';
-
-window.contextMenuLangs = [];
-window.translator_tab = null;
+import ExtSessionStorage from './common/sessionStorage.js';
 
 function getTranslationUrl(lang, text) {
   var params = new URLSearchParams({
@@ -15,24 +14,29 @@
 }
 
 function translationClick(info, tab) {
-  Options.getOptions()
-      .then(options => {
+  let optionsPromise = Options.getOptions();
+  let ssPromise = ExtSessionStorage.get(['contextMenuLangs', 'translatorTab']);
+  Promise.all([optionsPromise, ssPromise])
+      .then(returnValues => {
+        const [options, sessionStorageItems] = returnValues;
         let url = getTranslationUrl(
-            window.contextMenuLangs[info.menuItemId], info.selectionText);
+            sessionStorageItems.contextMenuLangs?.[info.menuItemId],
+            info.selectionText);
         let settings_tab = {url};
-        if (window.translator_tab && options.uniqueTab == 'yep') {
-          chrome.tabs.update(window.translator_tab, settings_tab, tab => {
-            chrome.tabs.highlight(
-                {
-                  windowId: tab.windowId,
-                  tabs: tab.index,
-                },
-                () => {
-                  chrome.windows.update(tab.windowId, {
-                    focused: true,
-                  });
-                });
-          });
+        if (sessionStorageItems.translatorTab && options.uniqueTab == 'yep') {
+          chrome.tabs.update(
+              sessionStorageItems.translatorTab, settings_tab, tab => {
+                chrome.tabs.highlight(
+                    {
+                      windowId: tab.windowId,
+                      tabs: tab.index,
+                    },
+                    () => {
+                      chrome.windows.update(tab.windowId, {
+                        focused: true,
+                      });
+                    });
+              });
         } else if (options.uniqueTab == 'popup') {
           chrome.windows.create({
             type: 'popup',
@@ -42,19 +46,19 @@
           });
         } else {
           chrome.tabs.create(settings_tab, function(tab) {
-            let translator_window = tab.windowId;
-            window.translator_tab = tab.id;
+            ExtSessionStorage.set({translatorTab: tab.id});
           });
         }
       })
       .catch(err => {
-        console.error('Error retrieving options to handle translation', err);
+        console.error('Error handling translation click', err);
       });
 }
 
 function createMenus(options) {
   chrome.contextMenus.removeAll();
 
+  let contextMenuLangs = {};
   let langs = options.targetLangs;
   let isSingleEntry = Object.values(langs).length == 1;
 
@@ -86,7 +90,7 @@
       'parentId': parentEl,
       'contexts': ['selection']
     });
-    window.contextMenuLangs[id] = language;
+    contextMenuLangs[id] = language;
   }
 
   if (!isSingleEntry) {
@@ -103,17 +107,19 @@
       'contexts': ['selection']
     });
   }
+
+  return ExtSessionStorage.set({contextMenuLangs});
 }
 
 chrome.storage.onChanged.addListener((changes, areaName) => {
   if (areaName == 'sync') {
     Options.getOptions(/* readOnly = */ false)
         .then(options => {
-          createMenus(options);
+          return createMenus(options);
         })
         .catch(err => {
           console.error(
-              'Error retrieving options to set up the extension after a change ' +
+              'Error setting up the extension after a change ' +
                   'in the storage area.',
               err);
         });
@@ -132,11 +138,10 @@
         });
       }
 
-      createMenus(options);
+      return createMenus(options);
     })
     .catch(err => {
-      console.error(
-          'Error retrieving options to initialize the extension.', err);
+      console.error('Error initializing the extension.', err);
     });
 
 chrome.notifications.onClicked.addListener(notification_id => {
@@ -157,12 +162,26 @@
 });
 
 chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
-  if (tabId == window.translator_tab) {
-    translator_window = null;
-    window.translator_tab = null;
-  }
+  ExtSessionStorage.get('translatorTab')
+      .then(items => {
+        if (tabId == items.translatorTab) {
+          ExtSessionStorage.set({translatorTab: null});
+        }
+      })
+      .catch(err => console.log(err));
 });
 
-chrome.browserAction.onClicked.addListener(() => {
+actionApi.onClicked.addListener(() => {
   chrome.runtime.openOptionsPage();
 });
+
+chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
+  switch (request.action) {
+    case 'clearTranslatorTab':
+      ExtSessionStorage.set({translatorTab: null});
+      break;
+
+    default:
+      console.error(`Unknown action "${action}" received as a message.`);
+  }
+});
diff --git a/src/common/actionApi.js b/src/common/actionApi.js
new file mode 100644
index 0000000..b26318b
--- /dev/null
+++ b/src/common/actionApi.js
@@ -0,0 +1,5 @@
+// #!if ['chromium_mv3', 'edge_mv3'].includes(browser_target)
+export default chrome.action;
+// #!else
+export default chrome.browserAction;
+// #!endif
diff --git a/src/common/sessionStorage.js b/src/common/sessionStorage.js
new file mode 100644
index 0000000..db46c84
--- /dev/null
+++ b/src/common/sessionStorage.js
@@ -0,0 +1,7 @@
+// #!if ['chromium_mv3', 'edge_mv3'].includes(browser_target)
+import ExtSessionStorage from './sessionStorage_mv3.js'
+// #!else
+import ExtSessionStorage from './sessionStorage_mv2.js'
+// #!endif
+
+export default ExtSessionStorage;
diff --git a/src/common/sessionStorage_mv2.js b/src/common/sessionStorage_mv2.js
new file mode 100644
index 0000000..4ad7daf
--- /dev/null
+++ b/src/common/sessionStorage_mv2.js
@@ -0,0 +1,41 @@
+export default class ExtSessionStorage {
+  static set(items) {
+    return new Promise((res, rej) => {
+      if (window.extCustomStorage === undefined) window.extCustomStorage = {};
+
+      for (const [key, value] of Object.entries(items))
+        window.extCustomStorage[key] = value;
+
+      res();
+    });
+  }
+
+  static get(keys) {
+    return new Promise((res, rej) => {
+      if (window.extCustomStorage === undefined) window.extCustomStorage = {};
+
+      if (keys === undefined) {
+        res(window.extCustomStorage);
+        return;
+      }
+
+      if (typeof keys === 'string') {
+        const key = keys;
+        keys = [key];
+      }
+
+      if (Array.isArray(keys)) {
+        let returnObject = {};
+        for (const key of keys) {
+          returnObject[key] = window.extCustomStorage[key];
+        }
+        res(returnObject);
+        return;
+      }
+
+      rej(new Error(
+          'The keys passed are not a valid type ' +
+          '(undefined, string or array).'));
+    });
+  }
+}
diff --git a/src/common/sessionStorage_mv3.js b/src/common/sessionStorage_mv3.js
new file mode 100644
index 0000000..9def901
--- /dev/null
+++ b/src/common/sessionStorage_mv3.js
@@ -0,0 +1,9 @@
+export default class ExtSessionStorage {
+  static set(items) {
+    return chrome.storage.session.set(items);
+  }
+
+  static get(keys) {
+    return chrome.storage.session.get(keys);
+  }
+}
diff --git a/src/options/elements/options-editor/options-editor.js b/src/options/elements/options-editor/options-editor.js
index f6500ab..9592d27 100644
--- a/src/options/elements/options-editor/options-editor.js
+++ b/src/options/elements/options-editor/options-editor.js
@@ -58,8 +58,7 @@
 
   changeTabOption(value) {
     chrome.storage.sync.set({uniquetab: value}, function() {
-      var background = chrome.extension.getBackgroundPage();
-      background.translator_tab = null;
+      chrome.runtime.sendMessage({action: 'clearTranslatorTab'});
     });
   }
 }
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index aa781cb..10681ee 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -1,5 +1,10 @@
 {
+#if defined(CHROMIUM || EDGE)
   "manifest_version": 2,
+#endif
+#if defined(CHROMIUM_MV3 || EDGE_MV3)
+  "manifest_version": 3,
+#endif
   "name": "__MSG_appName__",
   "description": "__MSG_appDescription__",
   "author": "Adrià Vilanova Martínez (@avm99963)",
@@ -19,17 +24,34 @@
     "256": "icons/translate-256.png"
   },
   "background": {
+#if defined(CHROMIUM || EDGE)
     "scripts": [
       "background.bundle.js"
     ],
     "persistent": false
+#endif
+#if defined(CHROMIUM_MV3 || EDGE_MV3)
+    "service_worker": "background.bundle.js"
+#endif
   },
   "options_page": "options.html",
   "options_ui": {
-    "page": "options.html",
-    "chrome_style": true
+#if defined(CHROMIUM || EDGE)
+    "chrome_style": true,
+#endif
+    "page": "options.html"
   },
+#if defined(CHROMIUM || EDGE)
   "browser_action": {},
+#endif
+#if defined(CHROMIUM_MV3 || EDGE_MV3)
+  "action": {},
+#endif
   "default_locale": "en",
+#if defined(CHROMIUM || EDGE)
   "minimum_chrome_version": "86.0.4198.0"
+#endif
+#if defined(CHROMIUM_MV3 || EDGE_MV3)
+  "minimum_chrome_version": "96.0.4664.45"
+#endif
 }
diff --git a/tools/release.bash b/tools/release.bash
index 91f6b20..2546e85 100644
--- a/tools/release.bash
+++ b/tools/release.bash
@@ -15,7 +15,8 @@
     -c, --channel  indicates the channel of the release. Can be "beta"
                    or "stable". Defaults to "stable".
     -b, --browser  indicates the target browser for the release. Can be
-                   "chromium" or "edge". Defaults to "chromium".
+                   "chromium", "chromium_mv3, "edge" or "edge_mv3".
+                   Defaults to "chromium".
     -f, --fast     indicates that the release shouldn't generate the
                    i18n credits JSON file.
 
@@ -63,7 +64,7 @@
   exit
 fi
 
-if [[ $browser != "chromium" && $browser != "edge" ]]; then
+if [[ $browser != "chromium" && $browser != "chromium_mv3" && $browser != "edge" && $browser != "edge_mv3" ]]; then
   echo "browser parameter value is incorrect." >&2
   usage
   exit