diff --git a/README.md b/README.md index 5d6a5e8..9254c67 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ satori-enhancements is a WebExtension built for Jagiellonian University's to improve usability, ease of use and add some useful features, that should have been there since the beginning (but for some reason, they aren't). -![Satori Enhancements results page](screenshots/results.png) +![Satori Enhancements results page](screenshots/results-2x.png) ## Features diff --git a/ext/html/offscreen.html b/ext/html/offscreen.html new file mode 100644 index 0000000..c707b01 --- /dev/null +++ b/ext/html/offscreen.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/ext/js/background.js b/ext/js/background.js index cd56dd4..fce50f2 100644 --- a/ext/js/background.js +++ b/ext/js/background.js @@ -1,3 +1,7 @@ +if (typeof importScripts !== 'undefined') { + importScripts('../vendor/browser-polyfill.js', 'config.js', 'common.js'); +} + (function () { 'use strict'; @@ -16,6 +20,21 @@ const contestProblemList = {}; + /** + * The offscreen document being currently created, if any. + */ + let offscreenBeingCreated; + + /** + * Return the web browser name this extension is running on. + * @returns {string} 'chrome' or 'firefox' depending on the browser + */ + function currentBrowser() { + return browser.runtime.getURL('').startsWith('chrome-extension://') + ? 'chrome' + : 'firefox'; + } + function displayStatusNotification(submitID, problemCode, status) { browser.notifications.create({ type: 'basic', @@ -30,31 +49,32 @@ * variable. * @see setUpLastContestRedirect */ - function retrieveLastContestID() { - storage.get('lastContestID').then((response) => { - lastContestID = response.lastContestID; - }); + async function retrieveLastContestID() { + const response = await storage.get('lastContestID'); + lastContestID = response.lastContestID; } /** - * Save last contest ID both in Storage and our local variable. + * Save last contest ID both in Storage and our local variable + * and update redirect rules. * @param {string} contestID last contest ID * @see setUpLastContestRedirect */ function saveLastContestID(contestID) { storage.set({ lastContestID: contestID }); lastContestID = contestID; + updateLastContestRedirectRules(); } /** - * Display extension's page action. - * @param tab tab object + * Display extension's action. */ - function enablePageAction(tab) { - browser.pageAction.show(tab.id); - browser.pageAction.onClicked.addListener(() => - browser.runtime.openOptionsPage(), - ); + function enableAction() { + const listener = () => { + browser.runtime.openOptionsPage(); + }; + + browser.action.onClicked.addListener(listener); } /** @@ -114,13 +134,48 @@ } /** - * Add an onBeforeRequest listener that redirects to the last contest + * Sets up a declarative net request rules to redirect to the last contest * if the user just entered Satori webpage. + */ + function updateLastContestRedirectRules() { + const addRules = []; + if (typeof lastContestID !== 'undefined' && lastContestID !== null) { + addRules.push( + ...[ + { id: 1, urlFilter: '||satori.tcs.uj.edu.pl/|' }, + { id: 2, urlFilter: '||satori.tcs.uj.edu.pl/news' }, + ].map(({ id, urlFilter }) => ({ + id, + condition: { + urlFilter, + // Redirect only if the user just opened Satori on a given tab + excludedTabIds: [...satoriTabs.values()], + resourceTypes: ['main_frame'], + }, + action: { + type: 'redirect', + redirect: { + url: SATORI_URL_CONTEST + lastContestID + '/', + }, + }, + })), + ); + } + browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [1, 2], + addRules, + }); + } + + /** + * Enables the last contest redirect. * * Also, the function adds webNavigation.onCommitted and tabs.onRemoved * listeners to keep the list of Satori tabs. */ function setUpLastContestRedirect() { + updateLastContestRedirectRules(); + // Store which tabs have Satori open browser.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { if ( @@ -132,52 +187,30 @@ } else { satoriTabs.delete(tabId); } + updateLastContestRedirectRules(); }); - 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'], - ); + browser.tabs.onRemoved.addListener((tabId) => { + satoriTabs.delete(tabId); + updateLastContestRedirectRules(); + }); } /** * Add highlight.js CSS to the page using the selected style. */ - function injectHighlightJsCss(tab) { - storage + async function injectHighlightJsCss(tab) { + await storage .get({ [HIGHLIGHT_JS_STYLE_KEY]: DEFAULT_SETTINGS[HIGHLIGHT_JS_STYLE_KEY], }) - .then((response) => { + .then(async (response) => { let style = response.highlightJsStyle; if (style !== 'none') { - browser.tabs.insertCSS(tab.id, { - file: `vendor/bower/hjsstyles/${style}.css`, + await browser.scripting.insertCSS({ + files: [`vendor/bower/hjsstyles/${style}.css`], + target: { tabId: tab.id }, }); } }); @@ -272,9 +305,22 @@ if (!response.ok) { throw new Error(`HTTP Status ${response.status}`); } - contestProblemList[contestID] = parseProblemList( - $.parseHTML(await response.text()), - ); + const responseText = await response.text(); + + if (currentBrowser() === 'chrome') { + await setupOffscreenDocument('/html/offscreen.html'); + const parseResponse = await chrome.runtime.sendMessage({ + type: 'parseProblemList', + target: 'offscreen', + data: responseText, + }); + await chrome.offscreen.closeDocument(); + + contestProblemList[contestID] = parseResponse; + } else { + contestProblemList[contestID] = + parseProblemList(responseText); + } } catch (error) { console.error(error); } @@ -282,6 +328,35 @@ return contestProblemList[contestID] ?? {}; } + /** + * Setup offscreen document for parsing HTML + * @param path path to the offscreen document + * @returns {Promise} promise that resolves when the document is ready + */ + async function setupOffscreenDocument(path) { + const offscreenUrl = chrome.runtime.getURL(path); + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: ['OFFSCREEN_DOCUMENT'], + documentUrls: [offscreenUrl], + }); + + if (existingContexts.length > 0) { + return; + } + + if (offscreenBeingCreated) { + await offscreenBeingCreated; + } else { + offscreenBeingCreated = chrome.offscreen.createDocument({ + url: path, + reasons: [chrome.offscreen.Reason.DOM_PARSER], + justification: 'Parse DOM', + }); + await offscreenBeingCreated; + offscreenBeingCreated = null; + } + } + /** * Save joined contest list in Storage * @param {array} contestList contest list @@ -298,21 +373,25 @@ return (await storage.get('contestList')).contestList ?? []; } - retrieveLastContestID(); - setUpLastContestRedirect(); + retrieveLastContestID().then(() => { + setUpLastContestRedirect(); + }); setUpSessionCookies(); - browser.runtime.onMessage.addListener((request, sender) => { - if (request.action === 'enablePageAction') { - enablePageAction(sender.tab); + browser.runtime.onMessage.addListener(async (request, sender) => { + if (request.action === 'enableAction') { + enableAction(sender.tab); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'saveLastContestID') { saveLastContestID(getContestID(sender.url)); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'displayStatusNotification') { displayStatusNotification( request.submitID, request.problemCode, request.problemStatus, ); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'modifyContestItemList') { modifyContestItemList( request.listName, @@ -320,6 +399,7 @@ request.value, request.add, ); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'getContestItemList') { return new Promise((resolve) => { getContestItemList( @@ -329,16 +409,21 @@ ); }); } else if (request.action === 'injectHighlightJsCss') { - injectHighlightJsCss(sender.tab); + await injectHighlightJsCss(sender.tab); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'saveContestProblemList') { saveContestProblemList(request.contestID, request.problems); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'getContestProblemList') { return getProblemList(request.contestID); } else if (request.action === 'setJoinedContestList') { saveJoinedContestList(request.contestList); + return new Promise((resolve) => resolve(null)); } else if (request.action === 'getJoinedContestList') { return getJoinedContestList(); } - return new Promise((resolve) => resolve(null)); + + console.warn(`Unexpected message type received: '${request.action}'.`); + return false; }); })(); diff --git a/ext/js/common.js b/ext/js/common.js index eb8c55f..5e76a97 100644 --- a/ext/js/common.js +++ b/ext/js/common.js @@ -27,7 +27,9 @@ if (typeof module !== 'undefined') { }; } -function parseProblemList(jqueryHandles) { +function parseProblemList(htmlText) { + const jqueryHandles = $.parseHTML(htmlText); + return Object.fromEntries( jqueryHandles.flatMap((el) => [ diff --git a/ext/js/general.js b/ext/js/general.js index 16a5f0f..a80285d 100644 --- a/ext/js/general.js +++ b/ext/js/general.js @@ -1,11 +1,11 @@ (function () { 'use strict'; - browser.runtime.sendMessage({ action: 'enablePageAction' }); + browser.runtime.sendMessage({ action: 'enableAction' }); - 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.js b/ext/js/offscreen.js new file mode 100644 index 0000000..20c0aa1 --- /dev/null +++ b/ext/js/offscreen.js @@ -0,0 +1,12 @@ +browser.runtime.onMessage.addListener(async (request) => { + if (request.target !== 'offscreen') { + return false; + } + + if (request.type === 'parseProblemList') { + return parseProblemList(request.data); + } + + console.warn(`Unexpected message type received: '${request.type}'.`); + return false; +}); diff --git a/ext/manifest.json b/ext/manifest.json index 76d490b..e5a11c2 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,16 +1,16 @@ { - "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": { "id": "{a7a2f4f4-8648-4221-8e88-af047e0fe5ca}", - "strict_min_version": "42.0" + "strict_min_version": "132.0" } }, @@ -21,36 +21,43 @@ }, "options_ui": { - "page": "options.html", - "chrome_style": true, - "browser_style": true + "page": "options.html" }, "permissions": [ "storage", "notifications", "webRequest", - "webRequestBlocking", + "declarativeNetRequestWithHostAccess", "cookies", - "*://satori.tcs.uj.edu.pl/*" + "scripting", + "offscreen" ], - "page_action": { + "host_permissions": ["*://satori.tcs.uj.edu.pl/*"], + + "action": { "default_icon": "icon128.png", "default_title": "Satori Enhancements" }, "background": { + "service_worker": "js/background.js", "scripts": [ "vendor/browser-polyfill.js", - "vendor/bower/jquery.min.js", "js/config.js", + "vendor/bower/jquery.min.js", "js/common.js", "js/background.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/scss/options.scss b/ext/scss/options.scss index aee1589..a607e06 100644 --- a/ext/scss/options.scss +++ b/ext/scss/options.scss @@ -1,3 +1,19 @@ +html, +body { + font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.5; + color: #333; + background-color: #fff; +} + +body { + margin: 1em; +} + +h2 { + font-weight: 400; +} + h2:not(:first-child) { margin-top: 35px; } 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 diff --git a/gulpfile.js b/gulpfile.js index 394cae9..1d1ee62 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -37,6 +37,7 @@ let distFiles = [ 'icon48.png', 'icon128.png', 'options.html', + 'html/**/*', 'css/**/*', 'js/**/*', 'images/**/*', diff --git a/screenshots/options-1280.png b/screenshots/options-1280.png deleted file mode 100644 index 0e48c59..0000000 Binary files a/screenshots/options-1280.png and /dev/null differ diff --git a/screenshots/options-chrome-1x.png b/screenshots/options-chrome-1x.png new file mode 100644 index 0000000..8fc6a30 Binary files /dev/null and b/screenshots/options-chrome-1x.png differ diff --git a/screenshots/options-chrome-2x.png b/screenshots/options-chrome-2x.png new file mode 100644 index 0000000..93dc7f9 Binary files /dev/null and b/screenshots/options-chrome-2x.png differ diff --git a/screenshots/options-firefox-1x.png b/screenshots/options-firefox-1x.png new file mode 100644 index 0000000..baa6844 Binary files /dev/null and b/screenshots/options-firefox-1x.png differ diff --git a/screenshots/options-firefox-2x.png b/screenshots/options-firefox-2x.png new file mode 100644 index 0000000..154cffe Binary files /dev/null and b/screenshots/options-firefox-2x.png differ diff --git a/screenshots/options.png b/screenshots/options.png deleted file mode 100644 index c68dbcc..0000000 Binary files a/screenshots/options.png and /dev/null differ diff --git a/screenshots/problem-1x.png b/screenshots/problem-1x.png new file mode 100644 index 0000000..370bce3 Binary files /dev/null and b/screenshots/problem-1x.png differ diff --git a/screenshots/problem-2x.png b/screenshots/problem-2x.png new file mode 100644 index 0000000..8cb5ec1 Binary files /dev/null and b/screenshots/problem-2x.png differ diff --git a/screenshots/problems-1280.png b/screenshots/problems-1x.png similarity index 100% rename from screenshots/problems-1280.png rename to screenshots/problems-1x.png diff --git a/screenshots/problems.png b/screenshots/problems-2x.png similarity index 100% rename from screenshots/problems.png rename to screenshots/problems-2x.png diff --git a/screenshots/results-1280.png b/screenshots/results-1280.png deleted file mode 100644 index 5d4ca80..0000000 Binary files a/screenshots/results-1280.png and /dev/null differ diff --git a/screenshots/results-1x.png b/screenshots/results-1x.png new file mode 100644 index 0000000..0d315db Binary files /dev/null and b/screenshots/results-1x.png differ diff --git a/screenshots/results-2x.png b/screenshots/results-2x.png new file mode 100644 index 0000000..94524d1 Binary files /dev/null and b/screenshots/results-2x.png differ diff --git a/screenshots/results.png b/screenshots/results.png deleted file mode 100644 index 1300adc..0000000 Binary files a/screenshots/results.png and /dev/null differ