From 10fb572982284044ab02732ff9aa6b25465d459b Mon Sep 17 00:00:00 2001 From: Glen Chiacchieri Date: Wed, 1 Sep 2021 16:35:56 -0400 Subject: [PATCH] 3.0.0 - manifest v3 support --- ExtPay.dev.js | 37 +- README.md | 59 +- dist/ExtPay.common.js | 37 +- dist/ExtPay.js | 37 +- dist/ExtPay.module.js | 37 +- package.json | 6 +- rollup.config.dev.js | 14 +- rollup.config.release.js | 7 +- .../ExtPay.js | 41 +- sample-extension-mv2/README.md | 6 + .../background.js | 1 + .../manifest.json | 6 +- .../popup.html | 0 .../popup.js | 0 sample-extension-mv3/ExtPay.js | 1560 +++++++++++++++++ .../README.md | 0 sample-extension-mv3/background.js | 8 + sample-extension-mv3/manifest.json | 25 + sample-extension-mv3/popup.html | 16 + sample-extension-mv3/popup.js | 14 + 20 files changed, 1789 insertions(+), 122 deletions(-) rename {sample-extension => sample-extension-mv2}/ExtPay.js (97%) create mode 100644 sample-extension-mv2/README.md rename {sample-extension => sample-extension-mv2}/background.js (85%) rename {sample-extension => sample-extension-mv2}/manifest.json (76%) rename {sample-extension => sample-extension-mv2}/popup.html (100%) rename {sample-extension => sample-extension-mv2}/popup.js (100%) create mode 100644 sample-extension-mv3/ExtPay.js rename {sample-extension => sample-extension-mv3}/README.md (100%) create mode 100644 sample-extension-mv3/background.js create mode 100644 sample-extension-mv3/manifest.json create mode 100644 sample-extension-mv3/popup.html create mode 100644 sample-extension-mv3/popup.js diff --git a/ExtPay.dev.js b/ExtPay.dev.js index 3436fa8..0591138 100644 --- a/ExtPay.dev.js +++ b/ExtPay.dev.js @@ -258,19 +258,7 @@ You can copy and paste this to your manifest.json file to fix this error: polling = false; } - browser.runtime.onMessage.addListener(function(message, sender, send_response) { - if (message == 'fetch-user') { - // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. - // It's possible attackers could trigger this but that is basically harmless. It would just query the user. - poll_user_paid() - } else if (message == 'trial-start') { - // no need to poll since the trial confirmation page has already set trialStartedAt - fetch_user() - } else if (message == 'extpay-extinfo' && browser.management) { - // get this message from content scripts which can't access browser.management - return browser.management.getSelf() - } - }); + return { getUser: function() { @@ -319,13 +307,22 @@ You can copy and paste this to your manifest.json file to fix this error: trial_callbacks.push(callback) } }, - // paymentPageLink: function() { - // return new Promise((resolve, reject) => { - // browser.storage.sync.get(['extensionpay_api_key'], function(storage) { - // resolve(`${EXTENSION_URL}?api_key=${storage.extensionpay_api_key}`) - // }) - // }) - // } + startBackground: function() { + browser.runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message) + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid() + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user() + } else if (message == 'extpay-extinfo' && browser.management) { + // get this message from content scripts which can't access browser.management + return browser.management.getSelf() + } + }); + } } } diff --git a/README.md b/README.md index 88a5bc3..2237633 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,12 @@ npm install extpay --save ## 2. Configure your `manifest.json` -ExtPay needs the following configuration in your V2 `manifest.json`: +ExtPay needs the following configuration in your `manifest.json` (for both manifest v2 and v3): + ```json { - "manifest_version": 2, "permissions": [ - "storage" + "storage" ] } ``` @@ -39,15 +39,46 @@ ExtPay will not show a scary permission warning when users try to install your e If you have a `"content_security_policy"` in your manifest or get a `Refused to connect to 'https://extensionpay.com...'` error, you'll have to add `connect-src https://extensionpay.com` to your extension's content security policy. See Mozilla's documentation for more details. -Manifest V3 support coming soon. - ## 3. Add `ExtPay` to `background.js` (required!) -You need to put `ExtPay` in your background file, often named something like `background.js`. If you don't include `ExtPay` in your background file it won't work correctly. +You need to put `ExtPay` in your background file, often named something like `background.js`. If you don't include `ExtPay` in your background file it won't work correctly. If you're using a bundler you can `import 'ExtPay'` or `require('ExtPay')` right in your `background.js`. + +With either Manifest V3 or Manifest V2 you'll need to **[sign up and register an extension](https://extensionpay.com)**. When you register an extension you'll create an extension id that you'll use when initializing `ExtPay`. We'll use `sample-extension` as the extension id in the following examples. + +### Manifest V3 + +```json +{ + "background": { + "service_worker": ["background.js"] + } +} +``` -If you're not using a bundler, add `ExtPay.js` to `manifest.json`: ```js +// background.js + +importScripts('ExtPay.js') // or `import` / `require` if using a bundler + +var extpay = ExtPay('sample-extension'); // Careful! See note below +extpay.startBackground(); +``` + +**Note about service workers**: In the example above `extpay` will become undefined when accessed in service worker callbacks. To use `extpay` in service worker callbacks, redeclare it like so: + +```js +chrome.storage.local.get('foo', function() { + var extpay = ExtPay('sample-extension'); + // ... +}) +``` +Make sure not to use `extpay.startBackground()` in callbacks — it should only be called once. + +### Manifest V2 + +If you're not using a bundler, add `ExtPay.js` to `manifest.json`: +```json { "background": { "scripts": ["ExtPay.js", "background.js"] @@ -55,14 +86,14 @@ If you're not using a bundler, add `ExtPay.js` to `manifest.json`: } ``` -Then initialize ExtPay with your extension's unique `extension-id`, which you get by **[signing up and registering an extension](https://extensionpay.com)**. In the example below, the `extension-id` is `sample-extension`. ```js // background.js const extpay = ExtPay('sample-extension') +extpay.startBackground(); ``` -If you're using a bundler you can `import 'ExtPay'` or `require('ExtPay')` right in your `background.js`. + ## 4. Use `extpay.getUser()` to check a user's paid status @@ -134,7 +165,6 @@ To use this feature, you will need to include the following content script confi ```json { - "manifest_version": 2, "content_scripts": [ { "matches": ["https://extensionpay.com/*"], @@ -194,7 +224,6 @@ You can also use `extpay.onTrialStarted.addListener()` to run functions when the ```json { - "manifest_version": 2, "content_scripts": [ { "matches": ["https://extensionpay.com/*"], @@ -204,3 +233,11 @@ You can also use `extpay.onTrialStarted.addListener()` to run functions when the ] } ``` + + +## Contributing + +1. `npm install` +2. Edit `ExtPay.dev.js` +3. `npm run dev` +4. `npm run dist` before committing changes. diff --git a/dist/ExtPay.common.js b/dist/ExtPay.common.js index 0375698..fb61638 100644 --- a/dist/ExtPay.common.js +++ b/dist/ExtPay.common.js @@ -258,19 +258,7 @@ You can copy and paste this to your manifest.json file to fix this error: polling = false; } - browser.runtime.onMessage.addListener(function(message, sender, send_response) { - if (message == 'fetch-user') { - // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. - // It's possible attackers could trigger this but that is basically harmless. It would just query the user. - poll_user_paid(); - } else if (message == 'trial-start') { - // no need to poll since the trial confirmation page has already set trialStartedAt - fetch_user(); - } else if (message == 'extpay-extinfo' && browser.management) { - // get this message from content scripts which can't access browser.management - return browser.management.getSelf() - } - }); + return { getUser: function() { @@ -319,13 +307,22 @@ You can copy and paste this to your manifest.json file to fix this error: trial_callbacks.push(callback); } }, - // paymentPageLink: function() { - // return new Promise((resolve, reject) => { - // browser.storage.sync.get(['extensionpay_api_key'], function(storage) { - // resolve(`${EXTENSION_URL}?api_key=${storage.extensionpay_api_key}`) - // }) - // }) - // } + startBackground: function() { + browser.runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message); + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid(); + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user(); + } else if (message == 'extpay-extinfo' && browser.management) { + // get this message from content scripts which can't access browser.management + return browser.management.getSelf() + } + }); + } } } diff --git a/dist/ExtPay.js b/dist/ExtPay.js index 24ec469..3bd684f 100644 --- a/dist/ExtPay.js +++ b/dist/ExtPay.js @@ -1487,19 +1487,7 @@ You can copy and paste this to your manifest.json file to fix this error: polling = false; } - browserPolyfill.runtime.onMessage.addListener(function(message, sender, send_response) { - if (message == 'fetch-user') { - // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. - // It's possible attackers could trigger this but that is basically harmless. It would just query the user. - poll_user_paid(); - } else if (message == 'trial-start') { - // no need to poll since the trial confirmation page has already set trialStartedAt - fetch_user(); - } else if (message == 'extpay-extinfo' && browserPolyfill.management) { - // get this message from content scripts which can't access browser.management - return browserPolyfill.management.getSelf() - } - }); + return { getUser: function() { @@ -1548,13 +1536,22 @@ You can copy and paste this to your manifest.json file to fix this error: trial_callbacks.push(callback); } }, - // paymentPageLink: function() { - // return new Promise((resolve, reject) => { - // browser.storage.sync.get(['extensionpay_api_key'], function(storage) { - // resolve(`${EXTENSION_URL}?api_key=${storage.extensionpay_api_key}`) - // }) - // }) - // } + startBackground: function() { + browserPolyfill.runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message); + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid(); + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user(); + } else if (message == 'extpay-extinfo' && browserPolyfill.management) { + // get this message from content scripts which can't access browser.management + return browserPolyfill.management.getSelf() + } + }); + } } } diff --git a/dist/ExtPay.module.js b/dist/ExtPay.module.js index 2f6bf0c..8875a92 100644 --- a/dist/ExtPay.module.js +++ b/dist/ExtPay.module.js @@ -256,19 +256,7 @@ You can copy and paste this to your manifest.json file to fix this error: polling = false; } - runtime.onMessage.addListener(function(message, sender, send_response) { - if (message == 'fetch-user') { - // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. - // It's possible attackers could trigger this but that is basically harmless. It would just query the user. - poll_user_paid(); - } else if (message == 'trial-start') { - // no need to poll since the trial confirmation page has already set trialStartedAt - fetch_user(); - } else if (message == 'extpay-extinfo' && management) { - // get this message from content scripts which can't access browser.management - return management.getSelf() - } - }); + return { getUser: function() { @@ -317,13 +305,22 @@ You can copy and paste this to your manifest.json file to fix this error: trial_callbacks.push(callback); } }, - // paymentPageLink: function() { - // return new Promise((resolve, reject) => { - // browser.storage.sync.get(['extensionpay_api_key'], function(storage) { - // resolve(`${EXTENSION_URL}?api_key=${storage.extensionpay_api_key}`) - // }) - // }) - // } + startBackground: function() { + runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message); + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid(); + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user(); + } else if (message == 'extpay-extinfo' && management) { + // get this message from content scripts which can't access browser.management + return management.getSelf() + } + }); + } } } diff --git a/package.json b/package.json index f96a6cb..16b9132 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "extpay", - "version": "2.4.1", + "version": "3.0.0", "description": "The JavaScript library for https://extensionpay.com - payments for browser extensions, no server needed.", "main": "./dist/ExtPay.common.js", "module": "./dist/ExtPay.module.js", @@ -8,8 +8,8 @@ "unpkg": "./dist/ExtPay.js", "jsdelivr": "./dist/ExtPay.js", "scripts": { - "replace_localhost": "sed -i '' \"s|http://localhost:3000|https://extensionpay\\.com|g\" sample-extension/manifest.json", - "replace_extpay": "sed -i '' \"s|https://extensionpay\\.com|http://localhost:3000|g\" sample-extension/manifest.json", + "replace_localhost": "sed -i '' \"s|http://localhost:3000|https://extensionpay\\.com|g\" sample-extension-mv2/manifest.json sample-extension-mv3/manifest.json", + "replace_extpay": "sed -i '' \"s|https://extensionpay\\.com|http://localhost:3000|g\" sample-extension-mv2/manifest.json sample-extension-mv3/manifest.json", "dev": "npm run replace_extpay && rollup -w -c rollup.config.dev.js || npm run replace_localhost", "dist": "rollup -c rollup.config.release.js && npm run replace_localhost" }, diff --git a/rollup.config.dev.js b/rollup.config.dev.js index a06b82a..abd5222 100644 --- a/rollup.config.dev.js +++ b/rollup.config.dev.js @@ -4,12 +4,22 @@ const rollup_resolve = require('@rollup/plugin-node-resolve').nodeResolve export default { input: 'ExtPay.dev.js', output: [{ - file: 'sample-extension/ExtPay.js', + file: 'sample-extension-mv2/ExtPay.js', format: 'iife', name: 'ExtPay' }, { - file: 'test-extension/ExtPay.js', + file: 'sample-extension-mv3/ExtPay.js', + format: 'iife', + name: 'ExtPay' + }, + { + file: 'dev-extension-mv3/ExtPay.js', + format: 'iife', + name: 'ExtPay' + }, + { + file: 'dev-extension-mv2/ExtPay.js', format: 'iife', name: 'ExtPay' }], diff --git a/rollup.config.release.js b/rollup.config.release.js index 63d612c..ac9fb09 100644 --- a/rollup.config.release.js +++ b/rollup.config.release.js @@ -13,7 +13,12 @@ export default [ name: 'ExtPay' }, { - file: 'sample-extension/ExtPay.js', + file: 'sample-extension-mv2/ExtPay.js', + format: 'iife', + name: 'ExtPay' + }, + { + file: 'sample-extension-mv3/ExtPay.js', format: 'iife', name: 'ExtPay' }], diff --git a/sample-extension/ExtPay.js b/sample-extension-mv2/ExtPay.js similarity index 97% rename from sample-extension/ExtPay.js rename to sample-extension-mv2/ExtPay.js index 24ec469..ee5ad79 100644 --- a/sample-extension/ExtPay.js +++ b/sample-extension-mv2/ExtPay.js @@ -1238,7 +1238,7 @@ var ExtPay = (function () { // and pass it on to the background page to query if the user has paid. if (typeof window !== 'undefined') { window.addEventListener('message', (event) => { - if (event.origin !== 'https://extensionpay.com') return; + if (event.origin !== 'http://localhost:3000') return; if (event.source != window) return; if (event.data === 'fetch-user' || event.data === 'trial-start') { browserPolyfill.runtime.sendMessage(event.data); @@ -1248,7 +1248,7 @@ var ExtPay = (function () { function ExtPay(extension_id) { - const HOST = `https://extensionpay.com`; + const HOST = `http://localhost:3000`; const EXTENSION_URL = `${HOST}/extension/${extension_id}`; function timeout(ms) { @@ -1487,19 +1487,7 @@ You can copy and paste this to your manifest.json file to fix this error: polling = false; } - browserPolyfill.runtime.onMessage.addListener(function(message, sender, send_response) { - if (message == 'fetch-user') { - // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. - // It's possible attackers could trigger this but that is basically harmless. It would just query the user. - poll_user_paid(); - } else if (message == 'trial-start') { - // no need to poll since the trial confirmation page has already set trialStartedAt - fetch_user(); - } else if (message == 'extpay-extinfo' && browserPolyfill.management) { - // get this message from content scripts which can't access browser.management - return browserPolyfill.management.getSelf() - } - }); + return { getUser: function() { @@ -1548,13 +1536,22 @@ You can copy and paste this to your manifest.json file to fix this error: trial_callbacks.push(callback); } }, - // paymentPageLink: function() { - // return new Promise((resolve, reject) => { - // browser.storage.sync.get(['extensionpay_api_key'], function(storage) { - // resolve(`${EXTENSION_URL}?api_key=${storage.extensionpay_api_key}`) - // }) - // }) - // } + startBackground: function() { + browserPolyfill.runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message); + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid(); + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user(); + } else if (message == 'extpay-extinfo' && browserPolyfill.management) { + // get this message from content scripts which can't access browser.management + return browserPolyfill.management.getSelf() + } + }); + } } } diff --git a/sample-extension-mv2/README.md b/sample-extension-mv2/README.md new file mode 100644 index 0000000..5b6a1b6 --- /dev/null +++ b/sample-extension-mv2/README.md @@ -0,0 +1,6 @@ +# [ExtensionPay.com](https://extensionpay.com) Sample Extension + +This is the [ExtensionPay](https://extensionpay.com) sample extension with Manifest v2. Use this as a template or minimal example of how to use the [ExtPay JavaScript library](../) in your extension. + +### To install on Chrome +Open [chrome://extensions/](chrome://extensions/) and make sure `Developer Mode` is enabled in the top right corner. Then click the `Load unpacked` button and navigate to the sample extension folder. Once installed, click the extension's icon ("E") in the browser's top right toolbar to see popup.html. (It may be in the "puzzle piece" menu.) diff --git a/sample-extension/background.js b/sample-extension-mv2/background.js similarity index 85% rename from sample-extension/background.js rename to sample-extension-mv2/background.js index 6495337..065f45f 100644 --- a/sample-extension/background.js +++ b/sample-extension-mv2/background.js @@ -1,5 +1,6 @@ // this line is required in background.js to use ExtPay! var extpay = ExtPay('sample-extension'); +extpay.startBackground(); extpay.getUser().then(user => { console.log(user) diff --git a/sample-extension/manifest.json b/sample-extension-mv2/manifest.json similarity index 76% rename from sample-extension/manifest.json rename to sample-extension-mv2/manifest.json index 3886704..548691a 100644 --- a/sample-extension/manifest.json +++ b/sample-extension-mv2/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, - "name": "ExtensionPay Sample Extension", - "short_name": "extensionpaysample", + "name": "ExtensionPay Sample Extension (Manifest v2)", + "short_name": "extensionpaysamplemv2", "version": "1.0", "author": "ExtensionPay", "description": "See how to use the ExtensionPay.com JS API to charge for you browser extensions.", @@ -10,7 +10,7 @@ }, "content_scripts": [ { - "matches": ["https://extensionpay.com/*"], + "matches": ["http://localhost:3000/*"], "js": ["ExtPay.js"], "run_at": "document_start" } diff --git a/sample-extension/popup.html b/sample-extension-mv2/popup.html similarity index 100% rename from sample-extension/popup.html rename to sample-extension-mv2/popup.html diff --git a/sample-extension/popup.js b/sample-extension-mv2/popup.js similarity index 100% rename from sample-extension/popup.js rename to sample-extension-mv2/popup.js diff --git a/sample-extension-mv3/ExtPay.js b/sample-extension-mv3/ExtPay.js new file mode 100644 index 0000000..ee5ad79 --- /dev/null +++ b/sample-extension-mv3/ExtPay.js @@ -0,0 +1,1560 @@ +var ExtPay = (function () { + 'use strict'; + + var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + + function createCommonjsModule(fn) { + var module = { exports: {} }; + return fn(module, module.exports), module.exports; + } + + var browserPolyfill = createCommonjsModule(function (module, exports) { + (function (global, factory) { + { + factory(module); + } + })(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : commonjsGlobal, function (module) { + + if (typeof browser === "undefined" || Object.getPrototypeOf(browser) !== Object.prototype) { + const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = "The message port closed before a response was received."; + const SEND_RESPONSE_DEPRECATION_WARNING = "Returning a Promise is the preferred way to send a reply from an onMessage/onMessageExternal listener, as the sendResponse will be removed from the specs (See https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage)"; // 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 + }, + "getZoomSettings": { + "minArgs": 0, + "maxArgs": 1 + }, + "goBack": { + "minArgs": 0, + "maxArgs": 1 + }, + "goForward": { + "minArgs": 0, + "maxArgs": 1 + }, + "highlight": { + "minArgs": 1, + "maxArgs": 1 + }, + "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 + } + }, + "topSites": { + "get": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "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 + } + } + }; + + if (Object.keys(apiMetadata).length === 0) { + throw new Error("api-metadata.json has not been included in browser-polyfill"); + } + /** + * 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); + } + + } + /** + * 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. + * @param {object} metadata + * Metadata about the wrapped method which has created the callback. + * @param {integer} metadata.maxResolvedArgs + * The maximum number of arguments which may be passed to the + * callback created by the wrapped async function. + * + * @returns {function} + * The generated callback function. + */ + + + const makeCallback = (promise, metadata) => { + return (...callbackArgs) => { + if (extensionAPIs.runtime.lastError) { + promise.reject(extensionAPIs.runtime.lastError); + } 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"; + /** + * 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 {integer} metadata.maxResolvedArgs + * The maximum number of arguments which may be passed to the + * callback created by the wrapped async function. + * + * @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); // 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)); + } + }); + }; + }; + /** + * 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 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. + 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); + } + + }; // 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); + }; + /** + * 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)); + } + + }); // Keep track if the deprecation warning has been logged at least once. + + + let loggedSendResponseDeprecationWarning = false; + 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 didCallSendResponse = false; + let wrappedSendResponse; + let sendResponsePromise = new Promise(resolve => { + wrappedSendResponse = function (response) { + if (!loggedSendResponseDeprecationWarning) { + console.warn(SEND_RESPONSE_DEPRECATION_WARNING, new Error().stack); + loggedSendResponseDeprecationWarning = true; + } + + didCallSendResponse = true; + resolve(response); + }; + }); + let result; + + try { + result = listener(message, sender, wrappedSendResponse); + } catch (err) { + result = Promise.reject(err); + } + + const isResultThenable = result !== true && isThenable(result); // 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; + } // 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); + }); + }; // 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); + } // 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 { + reject(extensionAPIs.runtime.lastError); + } + } 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 = { + 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 + }, + services: { + "*": settingMetadata + }, + websites: { + "*": settingMetadata + } + }; + return wrapObject(extensionAPIs, staticWrappers, apiMetadata); + }; + + if (typeof chrome != "object" || !chrome || !chrome.runtime || !chrome.runtime.id) { + throw new Error("This script should only be loaded in a browser extension."); + } // The build process adds a UMD wrapper around this file, which makes the + // `module` variable available. + + + module.exports = wrapAPIs(chrome); + } else { + module.exports = browser; + } + }); + + }); + + // Sign up at https://extensionpay.com to use this library. AGPLv3 licensed. + + + // For running as a content script. Receive a message from the successful payments page + // and pass it on to the background page to query if the user has paid. + if (typeof window !== 'undefined') { + window.addEventListener('message', (event) => { + if (event.origin !== 'http://localhost:3000') return; + if (event.source != window) return; + if (event.data === 'fetch-user' || event.data === 'trial-start') { + browserPolyfill.runtime.sendMessage(event.data); + } + }, false); + } + + function ExtPay(extension_id) { + + const HOST = `http://localhost:3000`; + const EXTENSION_URL = `${HOST}/extension/${extension_id}`; + + function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function get(key) { + try { + return await browserPolyfill.storage.sync.get(key) + } catch(e) { + // if sync not available (like with Firefox temp addons), fall back to local + return await browserPolyfill.storage.local.get(key) + } + } + async function set(dict) { + try { + return await browserPolyfill.storage.sync.set(dict) + } catch(e) { + // if sync not available (like with Firefox temp addons), fall back to local + return await browserPolyfill.storage.local.set(dict) + } + } + + // ----- start configuration checks + browserPolyfill.management && browserPolyfill.management.getSelf().then(async (ext_info) => { + if (!ext_info.permissions.includes('storage')) { + var permissions = ext_info.hostPermissions.concat(ext_info.permissions); + throw `ExtPay Setup Error: please include the "storage" permission in manifest.json["permissions"] or else ExtensionPay won't work correctly. + +You can copy and paste this to your manifest.json file to fix this error: + +"permissions": [ + ${permissions.map(x => `" ${x}"`).join(',\n')}${permissions.length > 0 ? ',' : ''} + "storage" +] +` + } + + }); + // ----- end configuration checks + + // run on "install" + get(['extensionpay_installed_at', 'extensionpay_user']).then(async (storage) => { + if (storage.extensionpay_installed_at) return; + + // Migration code: before v2.1 installedAt came from the server + // so use that stored datetime instead of making a new one. + const user = storage.extensionpay_user; + const date = user ? user.installedAt : (new Date()).toISOString(); + await set({'extensionpay_installed_at': date}); + }); + + const paid_callbacks = []; + const trial_callbacks = []; + + async function create_key() { + var body = {}; + var ext_info; + if (browserPolyfill.management) { + ext_info = await browserPolyfill.management.getSelf(); + } else if (browserPolyfill.runtime) { + ext_info = await browserPolyfill.runtime.sendMessage('extpay-extinfo'); // ask background page for ext info + } else { + throw 'ExtPay needs to be run in a browser extension context' + } + + if (ext_info.installType == 'development') { + body.development = true; + } + + const resp = await fetch(`${EXTENSION_URL}/api/new-key`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw resp.status, `${HOST}/home` + } + const api_key = await resp.json(); + await set({extensionpay_api_key: api_key}); + return api_key; + } + + async function get_key() { + const storage = await get(['extensionpay_api_key']); + if (storage.extensionpay_api_key) { + return storage.extensionpay_api_key; + } + return null; + } + + const datetime_re = /^\d\d\d\d-\d\d-\d\dT/; + + async function fetch_user() { + var storage = await get(['extensionpay_user', 'extensionpay_installed_at']); + const api_key = await get_key(); + if (!api_key) { + return { + paid: false, + paidAt: null, + installedAt: storage.extensionpay_installed_at ? new Date(storage.extensionpay_installed_at) : new Date(), // sometimes this function gets called before the initial install time can be flushed to storage + trialStartedAt: null, + } + } + + const resp = await fetch(`${EXTENSION_URL}/api/user?api_key=${api_key}`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + } + }); + // TODO: think harder about error states and what users will want (bad connection, server error, id not found) + if (!resp.ok) throw 'ExtPay error while fetching user: '+resp + + const user_data = await resp.json(); + + const parsed_user = {}; + for (var [key, value] of Object.entries(user_data)) { + if (value && value.match && value.match(datetime_re)) { + value = new Date(value); + } + parsed_user[key] = value; + } + parsed_user.installedAt = new Date(storage.extensionpay_installed_at); + + + if (parsed_user.paidAt) { + if (!storage.extensionpay_user || (storage.extensionpay_user && !storage.extensionpay_user.paidAt)) { + paid_callbacks.forEach(cb => cb(parsed_user)); + } + } + if (parsed_user.trialStartedAt) { + if (!storage.extensionpay_user || (storage.extensionpay_user && !storage.extensionpay_user.trialStartedAt)) { + trial_callbacks.forEach(cb => cb(parsed_user)); + } + + } + await set({extensionpay_user: user_data}); + + return parsed_user; + } + + async function payment_page_link() { + var api_key = await get_key(); + if (!api_key) { + api_key = await create_key(); + } + return `${EXTENSION_URL}?api_key=${api_key}` + } + + async function open_payment_page() { + const url = await payment_page_link(); + if (browserPolyfill.windows) { + try { + browserPolyfill.windows.create({ + url: url, + type: "popup", + focused: true, + width: 500, + height: 800, + left: 450 + }); + } catch(e) { + // firefox doesn't support 'focused' + browserPolyfill.windows.create({ + url: url, + type: "popup", + width: 500, + height: 800, + left: 450 + }); + } + } else { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/open + window.open(url, null, "toolbar=no,location=no,directories=no,status=no,menubar=no,width=500,height=800,left=450"); + } + } + + async function open_trial_page(period) { + // let user have period string like '1 week' e.g. "start your 1 week free trial" + + var api_key = await get_key(); + if (!api_key) { + api_key = await create_key(); + } + var url = `${EXTENSION_URL}/trial?api_key=${api_key}`; + if (period) { + url += `&period=${period}`; + } + + if (browserPolyfill.windows) { + try { + browserPolyfill.windows.create({ + url, + type: "popup", + focused: true, + width: 500, + height: 650, + left: 450 + }); + } catch(e) { + // firefox doesn't support 'focused' + browserPolyfill.windows.create({ + url, + type: "popup", + width: 500, + height: 650, + left: 450 + }); + } + } else { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/open + // for opening from a content script + window.open(url, null, "toolbar=no,location=no,directories=no,status=no,menubar=no,width=500,height=800,left=450"); + } + + } + + + var polling = false; + async function poll_user_paid() { + // keep trying to fetch user in case stripe webhook is late + if (polling) return; + polling = true; + var user = await fetch_user(); + for (var i=0; i < 2*60; ++i) { + if (user.paidAt) { + polling = false; + return user; + } + await timeout(1000); + user = await fetch_user(); + } + polling = false; + } + + + + return { + getUser: function() { + return fetch_user() + }, + onPaid: { + addListener: function(callback) { + const content_script_template = `"content_scripts": [ + { + "matches": ["${HOST}/*"], + "js": ["ExtPay.js"], + "run_at": "document_start" + }]`; + const manifest = browserPolyfill.runtime.getManifest(); + if (!manifest.content_scripts) { + throw `ExtPay setup error: To use the onPaid callback handler, please include ExtPay as a content script in your manifest.json. You can copy the example below into your manifest.json or check the docs: https://github.com/Glench/ExtPay#2-configure-your-manifestjson + + ${content_script_template}` + } + const extpay_content_script_entry = manifest.content_scripts.find(obj => { + // removing port number because firefox ignores content scripts with port number + return obj.matches.includes(HOST.replace(':3000', '')+'/*') + }); + if (!extpay_content_script_entry) { + throw `ExtPay setup error: To use the onPaid callback handler, please include ExtPay as a content script in your manifest.json matching "${HOST}/*". You can copy the example below into your manifest.json or check the docs: https://github.com/Glench/ExtPay#2-configure-your-manifestjson + + ${content_script_template}` + } else { + if (!extpay_content_script_entry.run_at || extpay_content_script_entry.run_at !== 'document_start') { + throw `ExtPay setup error: To use the onPaid callback handler, please make sure the ExtPay content script in your manifest.json runs at document start. You can copy the example below into your manifest.json or check the docs: https://github.com/Glench/ExtPay#2-configure-your-manifestjson + + ${content_script_template}` + } + } + + paid_callbacks.push(callback); + }, + // removeListener: function(callback) { + // // TODO + // } + }, + openPaymentPage: open_payment_page, + openTrialPage: open_trial_page, + onTrialStarted: { + addListener: function(callback) { + trial_callbacks.push(callback); + } + }, + startBackground: function() { + browserPolyfill.runtime.onMessage.addListener(function(message, sender, send_response) { + console.log('service worker got message! Here it is:', message); + if (message == 'fetch-user') { + // Only called via extensionpay.com/extension/[extension-id]/paid -> content_script when user successfully pays. + // It's possible attackers could trigger this but that is basically harmless. It would just query the user. + poll_user_paid(); + } else if (message == 'trial-start') { + // no need to poll since the trial confirmation page has already set trialStartedAt + fetch_user(); + } else if (message == 'extpay-extinfo' && browserPolyfill.management) { + // get this message from content scripts which can't access browser.management + return browserPolyfill.management.getSelf() + } + }); + } + } + } + + return ExtPay; + +}()); diff --git a/sample-extension/README.md b/sample-extension-mv3/README.md similarity index 100% rename from sample-extension/README.md rename to sample-extension-mv3/README.md diff --git a/sample-extension-mv3/background.js b/sample-extension-mv3/background.js new file mode 100644 index 0000000..38a24c5 --- /dev/null +++ b/sample-extension-mv3/background.js @@ -0,0 +1,8 @@ +importScripts('ExtPay.js') +// this line is required in background.js to use ExtPay! +var extpay = ExtPay('sample-extension'); +extpay.startBackground(); + +extpay.getUser().then(user => { + console.log(user) +}) diff --git a/sample-extension-mv3/manifest.json b/sample-extension-mv3/manifest.json new file mode 100644 index 0000000..c5c465d --- /dev/null +++ b/sample-extension-mv3/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "ExtensionPay Sample Extension (Manifest v3)", + "short_name": "extensionpaysamplemv3", + "version": "1.0", + "author": "ExtensionPay", + "description": "See how to use the ExtensionPay.com JS API to charge for you browser extensions.", + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": ["http://localhost:3000/*"], + "js": ["ExtPay.js"], + "run_at": "document_start" + } + ], + "action": { + "default_popup": "popup.html" + }, + "permissions": [ + "storage" + ] +} + diff --git a/sample-extension-mv3/popup.html b/sample-extension-mv3/popup.html new file mode 100644 index 0000000..7972897 --- /dev/null +++ b/sample-extension-mv3/popup.html @@ -0,0 +1,16 @@ + + + + + + + +
+

ExtensionPay.com Sample Extension

+

User hasn't paid :(

+ +
+ + + + diff --git a/sample-extension-mv3/popup.js b/sample-extension-mv3/popup.js new file mode 100644 index 0000000..df745f4 --- /dev/null +++ b/sample-extension-mv3/popup.js @@ -0,0 +1,14 @@ +const extpay = ExtPay('sample-extension') + +document.querySelector('button').addEventListener('click', extpay.openPaymentPage) + +extpay.getUser().then(user => { + if (user.paid) { + document.querySelector('p').innerHTML = 'User has paid! 🎉' + document.querySelector('button').remove() + } +}).catch(err => { + document.querySelector('p').innerHTML = "Error fetching data :( Check that your ExtensionPay id is correct and you're connected to the internet" +}) + +// extpay.onPaid(function() { console.log('popup paid')});