From 86644487042912e265b8a433dbb19c631e7630a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Fri, 25 Oct 2024 01:48:46 +0200 Subject: [PATCH] feat: migrate to Manifest V3 --- ext/js/general.js | 6 +- ext/js/offscreen.html | 2 + ext/js/offscreen.js | 48 + ext/js/results.js | 3 + ext/js/service-worker.js | 368 ++++++ ext/manifest.json | 32 +- ext/vendor/browser-polyfill.js | 1913 +++++++++++++++++++------------- 7 files changed, 1587 insertions(+), 785 deletions(-) create mode 100644 ext/js/offscreen.html create mode 100644 ext/js/offscreen.js create mode 100644 ext/js/service-worker.js diff --git a/ext/js/general.js b/ext/js/general.js index 16a5f0f..75efb05 100644 --- a/ext/js/general.js +++ b/ext/js/general.js @@ -3,9 +3,9 @@ browser.runtime.sendMessage({ action: 'enablePageAction' }); - const BANNER_URL = browser.extension.getURL('images/satori_banner.png'); - const TCS_LOGO_URL = browser.extension.getURL('images/tcslogo.svg'); - const ALT_TCS_LOGO_URL = browser.extension.getURL('images/alttcslogo.png'); + const BANNER_URL = browser.runtime.getURL('images/satori_banner.png'); + const TCS_LOGO_URL = browser.runtime.getURL('images/tcslogo.svg'); + const ALT_TCS_LOGO_URL = browser.runtime.getURL('images/alttcslogo.png'); let storage = browser.storage.sync || browser.storage.local; diff --git a/ext/js/offscreen.html b/ext/js/offscreen.html new file mode 100644 index 0000000..177e040 --- /dev/null +++ b/ext/js/offscreen.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ext/js/offscreen.js b/ext/js/offscreen.js new file mode 100644 index 0000000..e26ae88 --- /dev/null +++ b/ext/js/offscreen.js @@ -0,0 +1,48 @@ +importScripts( + "../vendor/browser-polyfill.js", + "config.js", + "../vendor/bower/jquery.min.js" +); + +browser.runtime.onMessage.addListener(handleMessages); + +async function handleMessages(message) { + if (message.target !== 'offscreen') { + return false; + } + + if (message.type === 'parseProblemList') { + parseProblemList(message.data); + } else { + console.warn(`Unexpected message type received: '${message.type}'.`); + return false; + } +} + +function parseProblemList(htmlText) { + const jqueryHandles = $.parseHTML(htmlText); + + return Object.fromEntries( + jqueryHandles.flatMap((el) => + [ + ...$(el).find('#content table.results tr:not(:first-of-type)'), + ].map((tr) => [ + $(tr).find('td:nth-child(1)').text(), + { + title: $(tr).find('td:nth-child(2)').text(), + href: $(tr).find('td:nth-child(2) a').attr('href'), + pdfHref: $(tr).find('td:nth-child(3) a').attr('href'), + submitHref: $(tr).find('td:nth-child(5) a').attr('href'), + }, + ]), + ), + ); +} + +function sendToBackground(type, data) { + browser.runtime.sendMessage({ + type, + target: 'background', + data + }); +} \ No newline at end of file diff --git a/ext/js/results.js b/ext/js/results.js index 3fe8cca..2800c79 100644 --- a/ext/js/results.js +++ b/ext/js/results.js @@ -17,7 +17,10 @@ let problemStatus = tr.find('td:last').text(); let url = document.location.href; + console.log("XD"); const contestID = getContestID(url); + console.log("XD2"); + /** * Get submit extension if possible diff --git a/ext/js/service-worker.js b/ext/js/service-worker.js new file mode 100644 index 0000000..32c3739 --- /dev/null +++ b/ext/js/service-worker.js @@ -0,0 +1,368 @@ +importScripts( + "../vendor/browser-polyfill.js", + "config.js", +); + +(function () { + 'use strict'; + + const SATORI_URL_CONTEST = SATORI_URL_HTTPS + 'contest/'; + + let storage = browser.storage.sync || browser.storage.local; + + let lastContestID; + /** + * A set that stores information about which tabs have Satori open. It is + * used to redirect the user to the last contest (if they are already + * browsing the website, then we shouldn't redirect). + * @type {Set} + */ + let satoriTabs = new Set(); + + const contestProblemList = {}; + + function displayStatusNotification(submitID, problemCode, status) { + browser.notifications.create({ + type: 'basic', + title: 'Submit ' + submitID + ' (' + problemCode + ') status', + message: 'New problem status: ' + status, + iconUrl: 'icon48.png', + }); + } + + /** + * Retrieve the ID of the last visited contest and store it in lastContestID + * variable. + * @see setUpLastContestRedirect + */ + function retrieveLastContestID() { + storage.get('lastContestID').then((response) => { + lastContestID = response.lastContestID; + }); + } + + /** + * Save last contest ID both in Storage and our local variable. + * @param {string} contestID last contest ID + * @see setUpLastContestRedirect + */ + function saveLastContestID(contestID) { + storage.set({ lastContestID: contestID }); + lastContestID = contestID; + } + + /** + * Display extension's page action. + * @param tab tab object + */ + function enablePageAction(tab) { + const listener = () => { + browser.runtime.openOptionsPage(); + }; + + if (chrome.action !== undefined) { + chrome.action.onClicked.addListener(listener); + } else { + browser.pageAction.show(tab.id); + browser.pageAction.onClicked.addListener(listener); + } + } + + /** + * Add or remove a value to given list bound of a specified contest. + * @param {string} listName name of the list to look for in the storage + * @param {string} contestID ID of the contest to modify its list + * @param {string} value value to add or remove + * @param {boolean} add whether to add or remove given element + * @see getContestItemList + */ + function modifyContestItemList(listName, contestID, value, add) { + storage + .get({ + [listName]: {}, + }) + .then((items) => { + let list = items[listName]; + if (!(contestID in list)) { + list[contestID] = []; + } + + let s = new Set(list[contestID]); + if (add) { + s.add(value); + } else { + s.delete(value); + } + list[contestID] = [...s]; + + storage.set({ [listName]: list }); + }); + } + + /** + * Retrieve a list bound to given contest. + * + * This function is used e.g. to store the list of problem hidden in for a + * contest. + * + * @param {string} listName name of the list to get + * @param {string} contestID ID of the contest to get the list for + * @param {function} sendResponse function to call with the data retrieved + * @see modifyContestItemList + */ + function getContestItemList(listName, contestID, sendResponse) { + storage + .get({ + [listName]: {}, + }) + .then((items) => { + if (contestID in items[listName]) { + sendResponse(items[listName][contestID]); + } else { + sendResponse([]); + } + }); + } + + /** + * Add an onBeforeRequest listener that redirects to the last contest + * if the user just entered Satori webpage. + * + * Also, the function adds webNavigation.onCommitted and tabs.onRemoved + * listeners to keep the list of Satori tabs. + */ + function setUpLastContestRedirect() { + // Store which tabs have Satori open + browser.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { + if ( + tab.url && + (tab.url.startsWith(SATORI_URL_HTTP) || + tab.url.startsWith(SATORI_URL_HTTPS)) + ) { + satoriTabs.add(tabId); + } else { + satoriTabs.delete(tabId); + } + }); + + browser.tabs.onRemoved.addListener((tabId) => satoriTabs.delete(tabId)); + + // browser.webRequest.onBeforeRequest.addListener( + // function (details) { + // if ( + // typeof lastContestID === 'undefined' || + // lastContestID === null || + // satoriTabs.has(details.tabId) + // ) { + // // If we haven't saved any contest yet, then do nothing + // // Also, don't redirect if the user is already browsing + // // Satori + // return; + // } + // + // return { + // redirectUrl: SATORI_URL_CONTEST + lastContestID + '/', + // }; + // }, + // { + // urls: [ + // '*://satori.tcs.uj.edu.pl/', + // '*://satori.tcs.uj.edu.pl/news', + // ], + // types: ['main_frame'], + // }, + // ['blocking'], + // ); + } + + /** + * Add highlight.js CSS to the page using the selected style. + */ + async function injectHighlightJsCss(tab) { + await storage + .get({ + [HIGHLIGHT_JS_STYLE_KEY]: + DEFAULT_SETTINGS[HIGHLIGHT_JS_STYLE_KEY], + }) + .then(async (response) => { + let style = response.highlightJsStyle; + if (style !== 'none') { + await browser.scripting.insertCSS({ + files: [`vendor/bower/hjsstyles/${style}.css`], + target: { tabId: tab.id }, + }); + } + }); + } + + const satoriDomain = new URL(SATORI_URL_HTTPS).hostname; + + function getKeepSignedInDuration() { + return storage + .get({ + [KEEP_SIGNED_IN_DURATION_KEY]: + DEFAULT_SETTINGS[KEEP_SIGNED_IN_DURATION_KEY], + }) + .then((response) => { + const duration = response[KEEP_SIGNED_IN_DURATION_KEY]; + if (duration === 'none') { + return null; + } + return parseInt(duration, 10); + }); + } + + function updateCookie(cookie) { + return getKeepSignedInDuration().then((duration) => { + if (duration === null) { + return; + } + const newCookie = { + expirationDate: + Math.round(Date.now() / 1000) + duration * 24 * 60 * 60, + httpOnly: cookie.httpOnly, + name: cookie.name, + path: cookie.path, + sameSite: cookie.sameSite, + secure: cookie.secure, + storeId: cookie.storeId, + value: cookie.value, + url: SATORI_URL_HTTPS, + }; + return browser.cookies.set(newCookie); + }); + } + + function getTokenCookies() { + return browser.cookies.getAll({ + domain: satoriDomain, + name: 'satori_token', + path: '/', + }); + } + + function updateExistingCookie() { + return getTokenCookies().then((cookies) => { + if (cookies.length === 0) { + return; + } + if (cookies.length > 1) { + console.warn('Too many satori_token cookies'); + return; + } + const [cookie] = cookies; + if (cookie.expirationDate === undefined && cookie.value !== '') { + return updateCookie(cookie); + } + }); + } + + function setUpSessionCookies() { + updateExistingCookie().catch(console.error); + browser.cookies.onChanged.addListener(({ removed, cookie }) => { + if ( + !removed && + cookie.domain === satoriDomain && + cookie.name === 'satori_token' && + cookie.path === '/' + ) { + updateExistingCookie().catch(console.error); + } + }); + } + + function saveContestProblemList(contestID, problems) { + contestProblemList[contestID] = problems; + } + + async function getProblemList(contestID) { + if (!contestProblemList[contestID]) { + try { + const response = await fetch( + `${SATORI_URL_HTTPS}contest/${contestID}/problems`, + ); + if (!response.ok) { + throw new Error(`HTTP Status ${response.status}`); + } + + await chrome.offscreen.createDocument({ + url: "offscreen.html", + reasons: [chrome.offscreen.Reason.DOM_PARSER], + justification: 'Parse DOM' + }); + const parseResponse = await chrome.runtime.sendMessage({ + type: 'parseProblemList', + target: 'offscreen', + data: await response.text() + }); + await chrome.offscreen.closeDocument(); + console.log(parseResponse); + + contestProblemList[contestID] = parseResponse; + } catch (error) { + console.error(error); + } + } + return contestProblemList[contestID] ?? {}; + } + + /** + * Save joined contest list in Storage + * @param {array} contestList contest list + */ + function saveJoinedContestList(contestList) { + storage.set({ contestList }); + } + + /** + * Save joined contest list in Storage + * @return {array} contest list + */ + async function getJoinedContestList() { + return (await storage.get('contestList')).contestList ?? []; + } + + retrieveLastContestID(); + setUpLastContestRedirect(); + setUpSessionCookies(); + + browser.runtime.onMessage.addListener(async (request, sender) => { + if (request.action === 'enablePageAction') { + enablePageAction(sender.tab); + } else if (request.action === 'saveLastContestID') { + saveLastContestID(getContestID(sender.url)); + } else if (request.action === 'displayStatusNotification') { + displayStatusNotification( + request.submitID, + request.problemCode, + request.problemStatus, + ); + } else if (request.action === 'modifyContestItemList') { + modifyContestItemList( + request.listName, + getContestID(sender.url), + request.value, + request.add, + ); + } else if (request.action === 'getContestItemList') { + return new Promise((resolve) => { + getContestItemList( + request.listName, + getContestID(sender.url), + resolve, + ); + }); + } else if (request.action === 'injectHighlightJsCss') { + await injectHighlightJsCss(sender.tab); + } else if (request.action === 'saveContestProblemList') { + saveContestProblemList(request.contestID, request.problems); + } else if (request.action === 'getContestProblemList') { + return getProblemList(request.contestID); + } else if (request.action === 'setJoinedContestList') { + saveJoinedContestList(request.contestList); + } else if (request.action === 'getJoinedContestList') { + return getJoinedContestList(); + } + return new Promise((resolve) => resolve(null)); + }); +})(); diff --git a/ext/manifest.json b/ext/manifest.json index 76d490b..32011c3 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,11 +1,11 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "Satori Enhancements", "description": "Adds a few useful enhancements to Satori Online Judge website.", - "version": "1.5", + "version": "2.0", - "minimum_chrome_version": "62", + "minimum_chrome_version": "130", "browser_specific_settings": { "gecko": { @@ -21,36 +21,36 @@ }, "options_ui": { - "page": "options.html", - "chrome_style": true, - "browser_style": true + "page": "options.html" }, "permissions": [ "storage", "notifications", "webRequest", - "webRequestBlocking", "cookies", + "scripting", + "offscreen" + ], + "host_permissions": [ "*://satori.tcs.uj.edu.pl/*" ], - "page_action": { + "action": { "default_icon": "icon128.png", "default_title": "Satori Enhancements" }, "background": { - "scripts": [ - "vendor/browser-polyfill.js", - "vendor/bower/jquery.min.js", - "js/config.js", - "js/common.js", - "js/background.js" - ] + "service_worker": "js/service-worker.js" }, - "web_accessible_resources": ["images/*.png", "images/*.svg"], + "web_accessible_resources": [ + { + "resources": ["images/*.png", "images/*.svg"], + "matches": ["*://satori.tcs.uj.edu.pl/*"] + } + ], "content_scripts": [ { diff --git a/ext/vendor/browser-polyfill.js b/ext/vendor/browser-polyfill.js index d29c925..5f7f24b 100644 --- a/ext/vendor/browser-polyfill.js +++ b/ext/vendor/browser-polyfill.js @@ -1,843 +1,1224 @@ -/* webextension-polyfill - v0.1.0 - Tue Dec 27 2016 17:28:07 */ -/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ -/* vim: set sts=2 sw=2 et tw=80: */ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -"use strict"; +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define("webextension-polyfill", ["module"], factory); + } else if (typeof exports !== "undefined") { + factory(module); + } else { + var mod = { + exports: {} + }; + factory(mod); + global.browser = mod.exports; + } +})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (module) { + /* webextension-polyfill - v0.12.0 - Tue May 14 2024 18:01:29 */ + /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ + /* vim: set sts=2 sw=2 et tw=80: */ + /* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + "use strict"; -if (typeof browser === "undefined") { - // Wrapping the bulk of this polyfill in a one-time-use function is a minor - // optimization for Firefox. Since Spidermonkey does not fully parse the - // contents of a function until the first time it's called, and since it will - // never actually need to be called, this allows the polyfill to be included - // in Firefox nearly for free. - const wrapAPIs = () => { - const apiMetadata = { - "alarms": { - "clear": { - "minArgs": 0, - "maxArgs": 1 - }, - "clearAll": { - "minArgs": 0, - "maxArgs": 0 - }, - "get": { - "minArgs": 0, - "maxArgs": 1 - }, - "getAll": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "bookmarks": { - "create": { - "minArgs": 1, - "maxArgs": 1 - }, - "export": { - "minArgs": 0, - "maxArgs": 0 - }, - "get": { - "minArgs": 1, - "maxArgs": 1 - }, - "getChildren": { - "minArgs": 1, - "maxArgs": 1 - }, - "getRecent": { - "minArgs": 1, - "maxArgs": 1 - }, - "getTree": { - "minArgs": 0, - "maxArgs": 0 - }, - "getSubTree": { - "minArgs": 1, - "maxArgs": 1 - }, - "import": { - "minArgs": 0, - "maxArgs": 0 - }, - "move": { - "minArgs": 2, - "maxArgs": 2 - }, - "remove": { - "minArgs": 1, - "maxArgs": 1 - }, - "removeTree": { - "minArgs": 1, - "maxArgs": 1 - }, - "search": { - "minArgs": 1, - "maxArgs": 1 - }, - "update": { - "minArgs": 2, - "maxArgs": 2 - } - }, - "browserAction": { - "getBadgeBackgroundColor": { - "minArgs": 1, - "maxArgs": 1 - }, - "getBadgeText": { - "minArgs": 1, - "maxArgs": 1 - }, - "getPopup": { - "minArgs": 1, - "maxArgs": 1 - }, - "getTitle": { - "minArgs": 1, - "maxArgs": 1 - }, - "setIcon": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "commands": { - "getAll": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "contextMenus": { - "update": { - "minArgs": 2, - "maxArgs": 2 - }, - "remove": { - "minArgs": 1, - "maxArgs": 1 - }, - "removeAll": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "cookies": { - "get": { - "minArgs": 1, - "maxArgs": 1 - }, - "getAll": { - "minArgs": 1, - "maxArgs": 1 - }, - "getAllCookieStores": { - "minArgs": 0, - "maxArgs": 0 - }, - "remove": { - "minArgs": 1, - "maxArgs": 1 - }, - "set": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "downloads": { - "download": { - "minArgs": 1, - "maxArgs": 1 - }, - "cancel": { - "minArgs": 1, - "maxArgs": 1 - }, - "erase": { - "minArgs": 1, - "maxArgs": 1 - }, - "getFileIcon": { - "minArgs": 1, - "maxArgs": 2 - }, - "open": { - "minArgs": 1, - "maxArgs": 1 - }, - "pause": { - "minArgs": 1, - "maxArgs": 1 - }, - "removeFile": { - "minArgs": 1, - "maxArgs": 1 - }, - "resume": { - "minArgs": 1, - "maxArgs": 1 - }, - "search": { - "minArgs": 1, - "maxArgs": 1 - }, - "show": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "extension": { - "isAllowedFileSchemeAccess": { - "minArgs": 0, - "maxArgs": 0 - }, - "isAllowedIncognitoAccess": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "history": { - "addUrl": { - "minArgs": 1, - "maxArgs": 1 - }, - "getVisits": { - "minArgs": 1, - "maxArgs": 1 - }, - "deleteAll": { - "minArgs": 0, - "maxArgs": 0 - }, - "deleteRange": { - "minArgs": 1, - "maxArgs": 1 - }, - "deleteUrl": { - "minArgs": 1, - "maxArgs": 1 - }, - "search": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "i18n": { - "detectLanguage": { - "minArgs": 1, - "maxArgs": 1 - }, - "getAcceptLanguages": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "idle": { - "queryState": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "management": { - "get": { - "minArgs": 1, - "maxArgs": 1 - }, - "getAll": { - "minArgs": 0, - "maxArgs": 0 - }, - "getSelf": { - "minArgs": 0, - "maxArgs": 0 - }, - "uninstallSelf": { - "minArgs": 0, - "maxArgs": 1 - } - }, - "notifications": { - "clear": { - "minArgs": 1, - "maxArgs": 1 - }, - "create": { - "minArgs": 1, - "maxArgs": 2 - }, - "getAll": { - "minArgs": 0, - "maxArgs": 0 - }, - "getPermissionLevel": { - "minArgs": 0, - "maxArgs": 0 - }, - "update": { - "minArgs": 2, - "maxArgs": 2 - } - }, - "pageAction": { - "getPopup": { - "minArgs": 1, - "maxArgs": 1 - }, - "getTitle": { - "minArgs": 1, - "maxArgs": 1 - }, - "setIcon": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "runtime": { - "getBackgroundPage": { - "minArgs": 0, - "maxArgs": 0 - }, - "getBrowserInfo": { - "minArgs": 0, - "maxArgs": 0 - }, - "getPlatformInfo": { - "minArgs": 0, - "maxArgs": 0 - }, - "openOptionsPage": { - "minArgs": 0, - "maxArgs": 0 - }, - "requestUpdateCheck": { - "minArgs": 0, - "maxArgs": 0 - }, - "sendMessage": { - "minArgs": 1, - "maxArgs": 3 - }, - "sendNativeMessage": { - "minArgs": 2, - "maxArgs": 2 - }, - "setUninstallURL": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "storage": { - "local": { + if (!(globalThis.chrome && globalThis.chrome.runtime && globalThis.chrome.runtime.id)) { + throw new Error("This script should only be loaded in a browser extension."); + } + if (!(globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.id)) { + const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = "The message port closed before a response was received."; + + // Wrapping the bulk of this polyfill in a one-time-use function is a minor + // optimization for Firefox. Since Spidermonkey does not fully parse the + // contents of a function until the first time it's called, and since it will + // never actually need to be called, this allows the polyfill to be included + // in Firefox nearly for free. + const wrapAPIs = extensionAPIs => { + // NOTE: apiMetadata is associated to the content of the api-metadata.json file + // at build time by replacing the following "include" with the content of the + // JSON file. + const apiMetadata = { + "alarms": { "clear": { + "minArgs": 0, + "maxArgs": 1 + }, + "clearAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "bookmarks": { + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getChildren": { + "minArgs": 1, + "maxArgs": 1 + }, + "getRecent": { + "minArgs": 1, + "maxArgs": 1 + }, + "getSubTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTree": { + "minArgs": 0, + "maxArgs": 0 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "browserAction": { + "disable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "enable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "getBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1 + }, + "getBadgeText": { + "minArgs": 1, + "maxArgs": 1 + }, + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "openPopup": { + "minArgs": 0, + "maxArgs": 0 + }, + "setBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setBadgeText": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "browsingData": { + "remove": { + "minArgs": 2, + "maxArgs": 2 + }, + "removeCache": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCookies": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeDownloads": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFormData": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeHistory": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeLocalStorage": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePasswords": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePluginData": { + "minArgs": 1, + "maxArgs": 1 + }, + "settings": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "commands": { + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "contextMenus": { + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "cookies": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAllCookieStores": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "devtools": { + "inspectedWindow": { + "eval": { + "minArgs": 1, + "maxArgs": 2, + "singleCallbackArg": false + } + }, + "panels": { + "create": { + "minArgs": 3, + "maxArgs": 3, + "singleCallbackArg": true + }, + "elements": { + "createSidebarPane": { + "minArgs": 1, + "maxArgs": 1 + } + } + } + }, + "downloads": { + "cancel": { + "minArgs": 1, + "maxArgs": 1 + }, + "download": { + "minArgs": 1, + "maxArgs": 1 + }, + "erase": { + "minArgs": 1, + "maxArgs": 1 + }, + "getFileIcon": { + "minArgs": 1, + "maxArgs": 2 + }, + "open": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "pause": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFile": { + "minArgs": 1, + "maxArgs": 1 + }, + "resume": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "extension": { + "isAllowedFileSchemeAccess": { + "minArgs": 0, + "maxArgs": 0 + }, + "isAllowedIncognitoAccess": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "history": { + "addUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "deleteRange": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "getVisits": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "i18n": { + "detectLanguage": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAcceptLanguages": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "identity": { + "launchWebAuthFlow": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "idle": { + "queryState": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "management": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getSelf": { + "minArgs": 0, + "maxArgs": 0 + }, + "setEnabled": { + "minArgs": 2, + "maxArgs": 2 + }, + "uninstallSelf": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "notifications": { + "clear": { + "minArgs": 1, + "maxArgs": 1 + }, + "create": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPermissionLevel": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "pageAction": { + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "hide": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "permissions": { + "contains": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "request": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "runtime": { + "getBackgroundPage": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPlatformInfo": { + "minArgs": 0, + "maxArgs": 0 + }, + "openOptionsPage": { "minArgs": 0, "maxArgs": 0 }, + "requestUpdateCheck": { + "minArgs": 0, + "maxArgs": 0 + }, + "sendMessage": { + "minArgs": 1, + "maxArgs": 3 + }, + "sendNativeMessage": { + "minArgs": 2, + "maxArgs": 2 + }, + "setUninstallURL": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "sessions": { + "getDevices": { + "minArgs": 0, + "maxArgs": 1 + }, + "getRecentlyClosed": { + "minArgs": 0, + "maxArgs": 1 + }, + "restore": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "storage": { + "local": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "managed": { + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "sync": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + } + }, + "tabs": { + "captureVisibleTab": { + "minArgs": 0, + "maxArgs": 2 + }, + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "detectLanguage": { + "minArgs": 0, + "maxArgs": 1 + }, + "discard": { + "minArgs": 0, + "maxArgs": 1 + }, + "duplicate": { + "minArgs": 1, + "maxArgs": 1 + }, + "executeScript": { + "minArgs": 1, + "maxArgs": 2 + }, "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getCurrent": { + "minArgs": 0, + "maxArgs": 0 + }, + "getZoom": { "minArgs": 0, "maxArgs": 1 }, - "getBytesInUse": { + "getZoomSettings": { "minArgs": 0, "maxArgs": 1 }, - "remove": { + "goBack": { + "minArgs": 0, + "maxArgs": 1 + }, + "goForward": { + "minArgs": 0, + "maxArgs": 1 + }, + "highlight": { "minArgs": 1, "maxArgs": 1 }, - "set": { + "insertCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "query": { "minArgs": 1, "maxArgs": 1 + }, + "reload": { + "minArgs": 0, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "sendMessage": { + "minArgs": 2, + "maxArgs": 3 + }, + "setZoom": { + "minArgs": 1, + "maxArgs": 2 + }, + "setZoomSettings": { + "minArgs": 1, + "maxArgs": 2 + }, + "update": { + "minArgs": 1, + "maxArgs": 2 } }, - "managed": { + "topSites": { "get": { "minArgs": 0, + "maxArgs": 0 + } + }, + "webNavigation": { + "getAllFrames": { + "minArgs": 1, "maxArgs": 1 }, - "getBytesInUse": { - "minArgs": 0, + "getFrame": { + "minArgs": 1, "maxArgs": 1 } }, - "sync": { - "clear": { + "webRequest": { + "handlerBehaviorChanged": { "minArgs": 0, "maxArgs": 0 + } + }, + "windows": { + "create": { + "minArgs": 0, + "maxArgs": 1 }, "get": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { "minArgs": 0, "maxArgs": 1 }, - "getBytesInUse": { + "getCurrent": { "minArgs": 0, "maxArgs": 1 }, - "remove": { - "minArgs": 1, + "getLastFocused": { + "minArgs": 0, "maxArgs": 1 }, - "set": { + "remove": { "minArgs": 1, "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 } } - }, - "tabs": { - "create": { - "minArgs": 1, - "maxArgs": 1 - }, - "captureVisibleTab": { - "minArgs": 0, - "maxArgs": 2 - }, - "detectLanguage": { - "minArgs": 0, - "maxArgs": 1 - }, - "duplicate": { - "minArgs": 1, - "maxArgs": 1 - }, - "executeScript": { - "minArgs": 1, - "maxArgs": 2 - }, - "get": { - "minArgs": 1, - "maxArgs": 1 - }, - "getCurrent": { - "minArgs": 0, - "maxArgs": 0 - }, - "getZoom": { - "minArgs": 0, - "maxArgs": 1 - }, - "getZoomSettings": { - "minArgs": 0, - "maxArgs": 1 - }, - "highlight": { - "minArgs": 1, - "maxArgs": 1 - }, - "insertCSS": { - "minArgs": 1, - "maxArgs": 2 - }, - "move": { - "minArgs": 2, - "maxArgs": 2 - }, - "reload": { - "minArgs": 0, - "maxArgs": 2 - }, - "remove": { - "minArgs": 1, - "maxArgs": 1 - }, - "query": { - "minArgs": 1, - "maxArgs": 1 - }, - "removeCSS": { - "minArgs": 1, - "maxArgs": 2 - }, - "sendMessage": { - "minArgs": 2, - "maxArgs": 3 - }, - "setZoom": { - "minArgs": 1, - "maxArgs": 2 - }, - "setZoomSettings": { - "minArgs": 1, - "maxArgs": 2 - }, - "update": { - "minArgs": 1, - "maxArgs": 2 - } - }, - "webNavigation": { - "getAllFrames": { - "minArgs": 1, - "maxArgs": 1 - }, - "getFrame": { - "minArgs": 1, - "maxArgs": 1 - } - }, - "webRequest": { - "handlerBehaviorChanged": { - "minArgs": 0, - "maxArgs": 0 - } - }, - "windows": { - "create": { - "minArgs": 0, - "maxArgs": 1 - }, - "get": { - "minArgs": 1, - "maxArgs": 2 - }, - "getAll": { - "minArgs": 0, - "maxArgs": 1 - }, - "getCurrent": { - "minArgs": 0, - "maxArgs": 1 - }, - "getLastFocused": { - "minArgs": 0, - "maxArgs": 1 - }, - "remove": { - "minArgs": 1, - "maxArgs": 1 - }, - "update": { - "minArgs": 2, - "maxArgs": 2 - } - } - }; - - /** - * A WeakMap subclass which creates and stores a value for any key which does - * not exist when accessed, but behaves exactly as an ordinary WeakMap - * otherwise. - * - * @param {function} createItem - * A function which will be called in order to create the value for any - * key which does not exist, the first time it is accessed. The - * function receives, as its only argument, the key being created. - */ - class DefaultWeakMap extends WeakMap { - constructor(createItem, items = undefined) { - super(items); - this.createItem = createItem; + }; + if (Object.keys(apiMetadata).length === 0) { + throw new Error("api-metadata.json has not been included in browser-polyfill"); } - get(key) { - if (!this.has(key)) { - this.set(key, this.createItem(key)); + /** + * A WeakMap subclass which creates and stores a value for any key which does + * not exist when accessed, but behaves exactly as an ordinary WeakMap + * otherwise. + * + * @param {function} createItem + * A function which will be called in order to create the value for any + * key which does not exist, the first time it is accessed. The + * function receives, as its only argument, the key being created. + */ + class DefaultWeakMap extends WeakMap { + constructor(createItem, items = undefined) { + super(items); + this.createItem = createItem; + } + get(key) { + if (!this.has(key)) { + this.set(key, this.createItem(key)); + } + return super.get(key); } - - return super.get(key); } - } - /** - * Returns true if the given object is an object with a `then` method, and can - * therefore be assumed to behave as a Promise. - * - * @param {*} value The value to test. - * @returns {boolean} True if the value is thenable. - */ - const isThenable = value => { - return value && typeof value === "object" && typeof value.then === "function"; - }; - - /** - * Creates and returns a function which, when called, will resolve or reject - * the given promise based on how it is called: - * - * - If, when called, `chrome.runtime.lastError` contains a non-null object, - * the promise is rejected with that value. - * - If the function is called with exactly one argument, the promise is - * resolved to that value. - * - Otherwise, the promise is resolved to an array containing all of the - * function's arguments. - * - * @param {object} promise - * An object containing the resolution and rejection functions of a - * promise. - * @param {function} promise.resolve - * The promise's resolution function. - * @param {function} promise.rejection - * The promise's rejection function. - * - * @returns {function} - * The generated callback function. - */ - const makeCallback = promise => { - return (...callbackArgs) => { - if (chrome.runtime.lastError) { - promise.reject(chrome.runtime.lastError); - } else if (callbackArgs.length === 1) { - promise.resolve(callbackArgs[0]); - } else { - promise.resolve(callbackArgs); - } + /** + * Returns true if the given object is an object with a `then` method, and can + * therefore be assumed to behave as a Promise. + * + * @param {*} value The value to test. + * @returns {boolean} True if the value is thenable. + */ + const isThenable = value => { + return value && typeof value === "object" && typeof value.then === "function"; }; - }; - /** - * Creates a wrapper function for a method with the given name and metadata. - * - * @param {string} name - * The name of the method which is being wrapped. - * @param {object} metadata - * Metadata about the method being wrapped. - * @param {integer} metadata.minArgs - * The minimum number of arguments which must be passed to the - * function. If called with fewer than this number of arguments, the - * wrapper will raise an exception. - * @param {integer} metadata.maxArgs - * The maximum number of arguments which may be passed to the - * function. If called with more than this number of arguments, the - * wrapper will raise an exception. - * - * @returns {function(object, ...*)} - * The generated wrapper function. - */ - const wrapAsyncFunction = (name, metadata) => { - const pluralizeArguments = (numArgs) => numArgs == 1 ? "argument" : "arguments"; + /** + * Creates and returns a function which, when called, will resolve or reject + * the given promise based on how it is called: + * + * - If, when called, `chrome.runtime.lastError` contains a non-null object, + * the promise is rejected with that value. + * - If the function is called with exactly one argument, the promise is + * resolved to that value. + * - Otherwise, the promise is resolved to an array containing all of the + * function's arguments. + * + * @param {object} promise + * An object containing the resolution and rejection functions of a + * promise. + * @param {function} promise.resolve + * The promise's resolution function. + * @param {function} promise.reject + * The promise's rejection function. + * @param {object} metadata + * Metadata about the wrapped method which has created the callback. + * @param {boolean} metadata.singleCallbackArg + * Whether or not the promise is resolved with only the first + * argument of the callback, alternatively an array of all the + * callback arguments is resolved. By default, if the callback + * function is invoked with only a single argument, that will be + * resolved to the promise, while all arguments will be resolved as + * an array if multiple are given. + * + * @returns {function} + * The generated callback function. + */ + const makeCallback = (promise, metadata) => { + return (...callbackArgs) => { + if (extensionAPIs.runtime.lastError) { + promise.reject(new Error(extensionAPIs.runtime.lastError.message)); + } else if (metadata.singleCallbackArg || callbackArgs.length <= 1 && metadata.singleCallbackArg !== false) { + promise.resolve(callbackArgs[0]); + } else { + promise.resolve(callbackArgs); + } + }; + }; + const pluralizeArguments = numArgs => numArgs == 1 ? "argument" : "arguments"; - return function asyncFunctionWrapper(target, ...args) { - if (args.length < metadata.minArgs) { - throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); - } + /** + * Creates a wrapper function for a method with the given name and metadata. + * + * @param {string} name + * The name of the method which is being wrapped. + * @param {object} metadata + * Metadata about the method being wrapped. + * @param {integer} metadata.minArgs + * The minimum number of arguments which must be passed to the + * function. If called with fewer than this number of arguments, the + * wrapper will raise an exception. + * @param {integer} metadata.maxArgs + * The maximum number of arguments which may be passed to the + * function. If called with more than this number of arguments, the + * wrapper will raise an exception. + * @param {boolean} metadata.singleCallbackArg + * Whether or not the promise is resolved with only the first + * argument of the callback, alternatively an array of all the + * callback arguments is resolved. By default, if the callback + * function is invoked with only a single argument, that will be + * resolved to the promise, while all arguments will be resolved as + * an array if multiple are given. + * + * @returns {function(object, ...*)} + * The generated wrapper function. + */ + const wrapAsyncFunction = (name, metadata) => { + return function asyncFunctionWrapper(target, ...args) { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + return new Promise((resolve, reject) => { + if (metadata.fallbackToNoCallback) { + // This API method has currently no callback on Chrome, but it return a promise on Firefox, + // and so the polyfill will try to call it with a callback first, and it will fallback + // to not passing the callback if the first call fails. + try { + target[name](...args, makeCallback({ + resolve, + reject + }, metadata)); + } catch (cbError) { + console.warn(`${name} API method doesn't seem to support the callback parameter, ` + "falling back to call it without a callback: ", cbError); + target[name](...args); - if (args.length > metadata.maxArgs) { - throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); - } + // Update the API method metadata, so that the next API calls will not try to + // use the unsupported callback anymore. + metadata.fallbackToNoCallback = false; + metadata.noCallback = true; + resolve(); + } + } else if (metadata.noCallback) { + target[name](...args); + resolve(); + } else { + target[name](...args, makeCallback({ + resolve, + reject + }, metadata)); + } + }); + }; + }; - return new Promise((resolve, reject) => { - target[name](...args, makeCallback({resolve, reject})); + /** + * Wraps an existing method of the target object, so that calls to it are + * intercepted by the given wrapper function. The wrapper function receives, + * as its first argument, the original `target` object, followed by each of + * the arguments passed to the original method. + * + * @param {object} target + * The original target object that the wrapped method belongs to. + * @param {function} method + * The method being wrapped. This is used as the target of the Proxy + * object which is created to wrap the method. + * @param {function} wrapper + * The wrapper function which is called in place of a direct invocation + * of the wrapped method. + * + * @returns {Proxy} + * A Proxy object for the given method, which invokes the given wrapper + * method in its place. + */ + const wrapMethod = (target, method, wrapper) => { + return new Proxy(method, { + apply(targetMethod, thisObj, args) { + return wrapper.call(thisObj, target, ...args); + } }); }; - }; + let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); - /** - * Wraps an existing method of the target object, so that calls to it are - * intercepted by the given wrapper function. The wrapper function receives, - * as its first argument, the original `target` object, followed by each of - * the arguments passed to the orginal method. - * - * @param {object} target - * The original target object that the wrapped method belongs to. - * @param {function} method - * The method being wrapped. This is used as the target of the Proxy - * object which is created to wrap the method. - * @param {function} wrapper - * The wrapper function which is called in place of a direct invocation - * of the wrapped method. - * - * @returns {Proxy} - * A Proxy object for the given method, which invokes the given wrapper - * method in its place. - */ - const wrapMethod = (target, method, wrapper) => { - return new Proxy(method, { - apply(targetMethod, thisObj, args) { - return wrapper.call(thisObj, target, ...args); - }, - }); - }; + /** + * Wraps an object in a Proxy which intercepts and wraps certain methods + * based on the given `wrappers` and `metadata` objects. + * + * @param {object} target + * The target object to wrap. + * + * @param {object} [wrappers = {}] + * An object tree containing wrapper functions for special cases. Any + * function present in this object tree is called in place of the + * method in the same location in the `target` object tree. These + * wrapper methods are invoked as described in {@see wrapMethod}. + * + * @param {object} [metadata = {}] + * An object tree containing metadata used to automatically generate + * Promise-based wrapper functions for asynchronous. Any function in + * the `target` object tree which has a corresponding metadata object + * in the same location in the `metadata` tree is replaced with an + * automatically-generated wrapper function, as described in + * {@see wrapAsyncFunction} + * + * @returns {Proxy} + */ + const wrapObject = (target, wrappers = {}, metadata = {}) => { + let cache = Object.create(null); + let handlers = { + has(proxyTarget, prop) { + return prop in target || prop in cache; + }, + get(proxyTarget, prop, receiver) { + if (prop in cache) { + return cache[prop]; + } + if (!(prop in target)) { + return undefined; + } + let value = target[prop]; + if (typeof value === "function") { + // This is a method on the underlying object. Check if we need to do + // any wrapping. - let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); + if (typeof wrappers[prop] === "function") { + // We have a special-case wrapper for this method. + value = wrapMethod(target, target[prop], wrappers[prop]); + } else if (hasOwnProperty(metadata, prop)) { + // This is an async method that we have metadata for. Create a + // Promise wrapper for it. + let wrapper = wrapAsyncFunction(prop, metadata[prop]); + value = wrapMethod(target, target[prop], wrapper); + } else { + // This is a method that we don't know or care about. Return the + // original method, bound to the underlying object. + value = value.bind(target); + } + } else if (typeof value === "object" && value !== null && (hasOwnProperty(wrappers, prop) || hasOwnProperty(metadata, prop))) { + // This is an object that we need to do some wrapping for the children + // of. Create a sub-object wrapper for it with the appropriate child + // metadata. + value = wrapObject(value, wrappers[prop], metadata[prop]); + } else if (hasOwnProperty(metadata, "*")) { + // Wrap all properties in * namespace. + value = wrapObject(value, wrappers[prop], metadata["*"]); + } else { + // We don't need to do any wrapping for this property, + // so just forward all access to the underlying object. + Object.defineProperty(cache, prop, { + configurable: true, + enumerable: true, + get() { + return target[prop]; + }, + set(value) { + target[prop] = value; + } + }); + return value; + } + cache[prop] = value; + return value; + }, + set(proxyTarget, prop, value, receiver) { + if (prop in cache) { + cache[prop] = value; + } else { + target[prop] = value; + } + return true; + }, + defineProperty(proxyTarget, prop, desc) { + return Reflect.defineProperty(cache, prop, desc); + }, + deleteProperty(proxyTarget, prop) { + return Reflect.deleteProperty(cache, prop); + } + }; - /** - * Wraps an object in a Proxy which intercepts and wraps certain methods - * based on the given `wrappers` and `metadata` objects. - * - * @param {object} target - * The target object to wrap. - * - * @param {object} [wrappers = {}] - * An object tree containing wrapper functions for special cases. Any - * function present in this object tree is called in place of the - * method in the same location in the `target` object tree. These - * wrapper methods are invoked as described in {@see wrapMethod}. - * - * @param {object} [metadata = {}] - * An object tree containing metadata used to automatically generate - * Promise-based wrapper functions for asynchronous. Any function in - * the `target` object tree which has a corresponding metadata object - * in the same location in the `metadata` tree is replaced with an - * automatically-generated wrapper function, as described in - * {@see wrapAsyncFunction} - * - * @returns {Proxy} - */ - const wrapObject = (target, wrappers = {}, metadata = {}) => { - let cache = Object.create(null); + // Per contract of the Proxy API, the "get" proxy handler must return the + // original value of the target if that value is declared read-only and + // non-configurable. For this reason, we create an object with the + // prototype set to `target` instead of using `target` directly. + // Otherwise we cannot return a custom object for APIs that + // are declared read-only and non-configurable, such as `chrome.devtools`. + // + // The proxy handlers themselves will still use the original `target` + // instead of the `proxyTarget`, so that the methods and properties are + // dereferenced via the original targets. + let proxyTarget = Object.create(target); + return new Proxy(proxyTarget, handlers); + }; - let handlers = { - has(target, prop) { - return prop in target || prop in cache; + /** + * Creates a set of wrapper functions for an event object, which handles + * wrapping of listener functions that those messages are passed. + * + * A single wrapper is created for each listener function, and stored in a + * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener` + * retrieve the original wrapper, so that attempts to remove a + * previously-added listener work as expected. + * + * @param {DefaultWeakMap} wrapperMap + * A DefaultWeakMap object which will create the appropriate wrapper + * for a given listener function when one does not exist, and retrieve + * an existing one when it does. + * + * @returns {object} + */ + const wrapEvent = wrapperMap => ({ + addListener(target, listener, ...args) { + target.addListener(wrapperMap.get(listener), ...args); + }, + hasListener(target, listener) { + return target.hasListener(wrapperMap.get(listener)); }, + removeListener(target, listener) { + target.removeListener(wrapperMap.get(listener)); + } + }); + const onRequestFinishedWrappers = new DefaultWeakMap(listener => { + if (typeof listener !== "function") { + return listener; + } - get(target, prop, receiver) { - if (prop in cache) { - return cache[prop]; - } + /** + * Wraps an onRequestFinished listener function so that it will return a + * `getContent()` property which returns a `Promise` rather than using a + * callback API. + * + * @param {object} req + * The HAR entry object representing the network request. + */ + return function onRequestFinished(req) { + const wrappedReq = wrapObject(req, {} /* wrappers */, { + getContent: { + minArgs: 0, + maxArgs: 0 + } + }); + listener(wrappedReq); + }; + }); + const onMessageWrappers = new DefaultWeakMap(listener => { + if (typeof listener !== "function") { + return listener; + } - if (!(prop in target)) { - return undefined; + /** + * Wraps a message listener function so that it may send responses based on + * its return value, rather than by returning a sentinel value and calling a + * callback. If the listener function returns a Promise, the response is + * sent when the promise either resolves or rejects. + * + * @param {*} message + * The message sent by the other end of the channel. + * @param {object} sender + * Details about the sender of the message. + * @param {function(*)} sendResponse + * A callback which, when called with an arbitrary argument, sends + * that value as a response. + * @returns {boolean} + * True if the wrapped listener returned a Promise, which will later + * yield a response. False otherwise. + */ + return function onMessage(message, sender, sendResponse) { + let didCallSendResponse = false; + let wrappedSendResponse; + let sendResponsePromise = new Promise(resolve => { + wrappedSendResponse = function (response) { + didCallSendResponse = true; + resolve(response); + }; + }); + let result; + try { + result = listener(message, sender, wrappedSendResponse); + } catch (err) { + result = Promise.reject(err); } + const isResultThenable = result !== true && isThenable(result); - let value = target[prop]; - - if (typeof value === "function") { - // This is a method on the underlying object. Check if we need to do - // any wrapping. + // If the listener didn't returned true or a Promise, or called + // wrappedSendResponse synchronously, we can exit earlier + // because there will be no response sent from this listener. + if (result !== true && !isResultThenable && !didCallSendResponse) { + return false; + } - if (typeof wrappers[prop] === "function") { - // We have a special-case wrapper for this method. - value = wrapMethod(target, target[prop], wrappers[prop]); - } else if (hasOwnProperty(metadata, prop)) { - // This is an async method that we have metadata for. Create a - // Promise wrapper for it. - let wrapper = wrapAsyncFunction(prop, metadata[prop]); - value = wrapMethod(target, target[prop], wrapper); - } else { - // This is a method that we don't know or care about. Return the - // original method, bound to the underlying object. - value = value.bind(target); - } - } else if (typeof value === "object" && value !== null && - (hasOwnProperty(wrappers, prop) || - hasOwnProperty(metadata, prop))) { - // This is an object that we need to do some wrapping for the children - // of. Create a sub-object wrapper for it with the appropriate child - // metadata. - value = wrapObject(value, wrappers[prop], metadata[prop]); - } else { - // We don't need to do any wrapping for this property, - // so just forward all access to the underlying object. - Object.defineProperty(cache, prop, { - configurable: true, - enumerable: true, - get() { - return target[prop]; - }, - set(value) { - target[prop] = value; - }, + // A small helper to send the message if the promise resolves + // and an error if the promise rejects (a wrapped sendMessage has + // to translate the message into a resolved promise or a rejected + // promise). + const sendPromisedResult = promise => { + promise.then(msg => { + // send the message value. + sendResponse(msg); + }, error => { + // Send a JSON representation of the error if the rejected value + // is an instance of error, or the object itself otherwise. + let message; + if (error && (error instanceof Error || typeof error.message === "string")) { + message = error.message; + } else { + message = "An unexpected error occurred"; + } + sendResponse({ + __mozWebExtensionPolyfillReject__: true, + message + }); + }).catch(err => { + // Print an error on the console if unable to send the response. + console.error("Failed to send onMessage rejected reply", err); }); + }; - return value; + // If the listener returned a Promise, send the resolved value as a + // result, otherwise wait the promise related to the wrappedSendResponse + // callback to resolve and send it as a response. + if (isResultThenable) { + sendPromisedResult(result); + } else { + sendPromisedResult(sendResponsePromise); } - cache[prop] = value; - return value; - }, - - set(target, prop, value, receiver) { - if (prop in cache) { - cache[prop] = value; + // Let Chrome know that the listener is replying. + return true; + }; + }); + const wrappedSendMessageCallback = ({ + reject, + resolve + }, reply) => { + if (extensionAPIs.runtime.lastError) { + // Detect when none of the listeners replied to the sendMessage call and resolve + // the promise to undefined as in Firefox. + // See https://github.com/mozilla/webextension-polyfill/issues/130 + if (extensionAPIs.runtime.lastError.message === CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE) { + resolve(); } else { - target[prop] = value; + reject(new Error(extensionAPIs.runtime.lastError.message)); + } + } else if (reply && reply.__mozWebExtensionPolyfillReject__) { + // Convert back the JSON representation of the error into + // an Error instance. + reject(new Error(reply.message)); + } else { + resolve(reply); + } + }; + const wrappedSendMessage = (name, metadata, apiNamespaceObj, ...args) => { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + return new Promise((resolve, reject) => { + const wrappedCb = wrappedSendMessageCallback.bind(null, { + resolve, + reject + }); + args.push(wrappedCb); + apiNamespaceObj.sendMessage(...args); + }); + }; + const staticWrappers = { + devtools: { + network: { + onRequestFinished: wrapEvent(onRequestFinishedWrappers) } - return true; }, - - defineProperty(target, prop, desc) { - return Reflect.defineProperty(cache, prop, desc); + runtime: { + onMessage: wrapEvent(onMessageWrappers), + onMessageExternal: wrapEvent(onMessageWrappers), + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { + minArgs: 1, + maxArgs: 3 + }) + }, + tabs: { + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { + minArgs: 2, + maxArgs: 3 + }) + } + }; + const settingMetadata = { + clear: { + minArgs: 1, + maxArgs: 1 + }, + get: { + minArgs: 1, + maxArgs: 1 + }, + set: { + minArgs: 1, + maxArgs: 1 + } + }; + apiMetadata.privacy = { + network: { + "*": settingMetadata }, - - deleteProperty(target, prop) { - return Reflect.deleteProperty(cache, prop); + services: { + "*": settingMetadata }, - }; - - return new Proxy(target, handlers); - }; - - /** - * Creates a set of wrapper functions for an event object, which handles - * wrapping of listener functions that those messages are passed. - * - * A single wrapper is created for each listener function, and stored in a - * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener` - * retrieve the original wrapper, so that attempts to remove a - * previously-added listener work as expected. - * - * @param {DefaultWeakMap} wrapperMap - * A DefaultWeakMap object which will create the appropriate wrapper - * for a given listener function when one does not exist, and retrieve - * an existing one when it does. - * - * @returns {object} - */ - const wrapEvent = wrapperMap => ({ - addListener(target, listener, ...args) { - target.addListener(wrapperMap.get(listener), ...args); - }, - - hasListener(target, listener) { - return target.hasListener(wrapperMap.get(listener)); - }, - - removeListener(target, listener) { - target.removeListener(wrapperMap.get(listener)); - }, - }); - - const onMessageWrappers = new DefaultWeakMap(listener => { - if (typeof listener !== "function") { - return listener; - } - - /** - * Wraps a message listener function so that it may send responses based on - * its return value, rather than by returning a sentinel value and calling a - * callback. If the listener function returns a Promise, the response is - * sent when the promise either resolves or rejects. - * - * @param {*} message - * The message sent by the other end of the channel. - * @param {object} sender - * Details about the sender of the message. - * @param {function(*)} sendResponse - * A callback which, when called with an arbitrary argument, sends - * that value as a response. - * @returns {boolean} - * True if the wrapped listener returned a Promise, which will later - * yield a response. False otherwise. - */ - return function onMessage(message, sender, sendResponse) { - let result = listener(message, sender); - - if (isThenable(result)) { - result.then(sendResponse, error => { - console.error(error); - sendResponse(error); - }); - - return true; - } else if (result !== undefined) { - sendResponse(result); + websites: { + "*": settingMetadata } }; - }); - - const staticWrappers = { - runtime: { - onMessage: wrapEvent(onMessageWrappers), - }, + return wrapObject(extensionAPIs, staticWrappers, apiMetadata); }; - return wrapObject(chrome, staticWrappers, apiMetadata); - }; - - this.browser = wrapAPIs(); -} + // The build process adds a UMD wrapper around this file, which makes the + // `module` variable available. + module.exports = wrapAPIs(chrome); + } else { + module.exports = globalThis.browser; + } +}); \ No newline at end of file