├── .babelrc ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bower.json ├── chrome-extension-async.d.ts ├── chrome-extension-async.js ├── execute-async-function.d.ts ├── execute-async-function.es5.js ├── execute-async-function.js └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env" 4 | ] 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at keith.henry@evolutionjobs.co.uk. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Keith Henry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chrome Extension Async 2 | [![npm version](http://img.shields.io/npm/v/chrome-extension-async.svg)](https://www.npmjs.com/package/chrome-extension-async) 3 | [![bower version](https://img.shields.io/bower/v/chrome-extension-async.svg)](https://github.com/KeithHenry/chromeExtensionAsync/releases) 4 | 5 | Promise wrapper for the Chrome extension API so that it can be used with async/await rather than callbacks 6 | 7 | The [Extension API](https://developer.chrome.com/extensions) provided by Chrome uses callbacks. 8 | However, Chrome now supports `async` and `await` keywords. 9 | 10 | This library wraps Chrome extension API callback methods in promises, so that they can be called with `async` and `await`. 11 | 12 | Once activated against the Chrome API each callback function gets a `Promise` version. 13 | 14 | Chrome supports ES2017 syntax, so in extensions we can take full advantage of it. 15 | 16 | ## Installation 17 | Use bower 18 | ``` 19 | bower install chrome-extension-async 20 | ``` 21 | 22 | Or [npm](https://www.npmjs.com/package/chrome-extension-async) 23 | ``` 24 | npm i chrome-extension-async 25 | ``` 26 | 27 | Or [download](chrome-extension-async.js) `chrome-extension-async.js` file and include it directly: 28 | ```html 29 | 30 | ``` 31 | 32 | TypeScript definitions for the altered API are in [`chrome-extension-async.d.ts`](chrome-extension-async.d.ts) 33 | 34 | You must reference [`chrome-extension-async.js`](chrome-extension-async.js) before your code attempts to use the features of this, as it needs to run across the Chrome API before you call it. ` 156 | ``` 157 | 158 | This relies on a `chrome.runtime.onMessage.addListener` subscription, so it will fail if called from within a listener event. 159 | 160 | ### Create and Reload Tabs with `chrome.tabs.createAndWait` and `chrome.tabs.reloadAndWait` 161 | 162 | New in v3.4 is `chrome.tabs.createAndWait` and `chrome.tabs.reloadAndWait`. The normal `chrome.tabs.create` and `chrome.tabs.reload` functions execute their callbacks as soon as the tab is created, before the tab has finished loading. This makes it difficult to create or reload a tab, and then execute a content script on the page. `chrome.tabs.createAndWait` and `chrome.tabs.reloadAndWait` are an enhancement to the tabs API that waits until the tab has finished loading the url, and is ready to execute scripts. They pair great with `chrome.tabs.executeAsyncFunction`. 163 | 164 | They: 165 | 166 | - Call `chrome.tabs.create` or `chrome.tabs.reload`, await the results, and grab the tab's id. 167 | - Use `chrome.tabs.onUpdated.addListener` to listen for the 'completed' status for the tab's id. 168 | - Wrap the whole thing in a promise that resolves with the final result. 169 | - Use `chrome.tabs.onRemoved.addListener` and `chrome.tabs.onReplaced.addListener` to detect if the tab is removed or replaced before the loading finishes, and rejects the promise with an Error. 170 | - Use an auto-timeout feature. If the page doesn't load in the specified milliseconds, or one of the three listeners is never called, the promise will be rejected with an Error. The value of the timeout is configurable with an optional parameter. The default value is 12e4 milliseconds (2 minutes). 171 | 172 | `chrome.tabs.createAndWait` takes in the same parameters as [chrome.tabs.create](https://developer.chrome.com/extensions/tabs#method-create) except for the callback, and returns an object containing the same properties as the parameters passed to the callback for the [chrome.tabs.onUpdated](https://developer.chrome.com/extensions/tabs#event-onUpdated) event. 173 | 174 | ```javascript 175 | try { 176 | // Create a new tab and wait for it to finish loading. The url will take 5 seconds to finish loading. 177 | // Try closing the tab before it finishes loading, and you will see the error. 178 | const {tabId, changeInfo, tab} = await chrome.tabs.createAndWait({ url: "http://www.mocky.io/v2/5d59a32e3000006c2ed84c7a?mocky-delay=5000ms", active:true }); 179 | // Now that it is finished loading, it is ready to execute content scripts. 180 | const scriptResults = await chrome.tabs.executeAsyncFunction(tab.id, () => { alert('The tab finished loading.');} ); 181 | // Voila! In two lines you've created a new tab, and executed a content script on it! 182 | } 183 | catch (err) { 184 | alert(err); 185 | } 186 | ``` 187 | `chrome.tabs.reloadAndWait` takes in the same parameters as [chrome.tabs.reload](https://developer.chrome.com/extensions/tabs#method-reload) except for the callback, and returns an object containing the same properties as the parameters passed to the callback for the [chrome.tabs.onUpdated](https://developer.chrome.com/extensions/tabs#event-onUpdated) event. 188 | ```javascript 189 | try { 190 | // Get the current tab. 191 | const tabs = await chrome.tabs.query({active: true, currentWindow: true}); 192 | const currentTab = tabs[0]; 193 | // The second parameter, reloadProperties is optional, and here it is omitted. 194 | const {tabId, changeInfo, tab} = await chrome.tabs.reloadAndWait(currentTab.id); 195 | const scriptResults = await chrome.tabs.executeAsyncFunction(tab.id, () => { alert('The tab finished reloading.');} ); 196 | } 197 | catch (err) { 198 | alert(err); 199 | } 200 | ``` 201 | 202 | These functions are held in: [`execute-async-function.js`](execute-async-function.js) 203 | 204 | ## Supported APIs 205 | This only 'promisifies' API functions that use callbacks and are not marked as deprecated. 206 | No backwards compatibility is attempted. 207 | 208 | Each API is added manually as JS can't spot deprecated or functions with no callbacks itself. 209 | 210 | Supported API: 211 | 212 | - [chrome.alarms](https://developer.chrome.com/extensions/alarms) 213 | - [chrome.bookmarks](https://developer.chrome.com/extensions/bookmarks) 214 | - [chrome.browserAction](https://developer.chrome.com/extensions/browserAction) 215 | - [chrome.browsingData](https://developer.chrome.com/extensions/browsingData) 216 | - [chrome.commands](https://developer.chrome.com/extensions/commands#method-getAll) 217 | - [chrome.contentSettings ContentSetting](https://developer.chrome.com/extensions/contentSettings#type-ContentSetting) 218 | - [chrome.contextMenus](https://developer.chrome.com/extensions/contextMenus) 219 | - [chrome.cookies](https://developer.chrome.com/extensions/cookies) 220 | - [chrome.debugger](https://developer.chrome.com/extensions/debugger) 221 | - [chrome.desktopCapture](https://developer.chrome.com/extensions/desktopCapture) 222 | - [chrome.documentScan](https://developer.chrome.com/extensions/documentScan#method-scan) 223 | - [chrome.downloads](https://developer.chrome.com/extensions/downloads) 224 | - [chrome.enterprise.platformKeys](https://developer.chrome.com/extensions/fileBrowserHandler#method-selectFile) 225 | - [chrome.extension](https://developer.chrome.com/extensions/extension) 226 | - [chrome.fileBrowserHandler](https://developer.chrome.com/extensions/enterprise_platformKeys) 227 | - [chrome.fileSystemProvider](https://developer.chrome.com/extensions/fileSystemProvider) 228 | - [chrome.fontSettings](https://developer.chrome.com/extensions/fontSettings) 229 | - [chrome.gcm](https://developer.chrome.com/extensions/gcm) 230 | - [chrome.history](https://developer.chrome.com/extensions/history) 231 | - [chrome.i18n](https://developer.chrome.com/extensions/i18n) 232 | - [chrome.identity](https://developer.chrome.com/extensions/identity) 233 | - [chrome.idle](https://developer.chrome.com/extensions/idle#method-queryState) 234 | - [chrome.input.ime](https://developer.chrome.com/extensions/input_ime) 235 | - [chrome.management](https://developer.chrome.com/extensions/management) 236 | - [chrome.networking.config](https://developer.chrome.com/extensions/networking_config) 237 | - [chrome.notifications](https://developer.chrome.com/extensions/notifications) 238 | - [chrome.pageAction](https://developer.chrome.com/extensions/pageAction) 239 | - [chrome.pageCapture](https://developer.chrome.com/extensions/pageCapture#method-saveAsMHTML) 240 | - [chrome.permissions](https://developer.chrome.com/extensions/permissions) 241 | - [chrome.platformKeys](https://developer.chrome.com/extensions/platformKeys) 242 | - [chrome.runtime](https://developer.chrome.com/extensions/runtime) 243 | - [chrome.sessions](https://developer.chrome.com/extensions/sessions) 244 | - [chrome.socket](https://developer.chrome.com/extensions/socket) 245 | - [chrome.sockets.tcp](https://developer.chrome.com/extensions/sockets_tcp) 246 | - [chrome.sockets.tcpServer](https://developer.chrome.com/extensions/sockets_tcpServer) 247 | - [chrome.sockets.udp](https://developer.chrome.com/extensions/sockets_udp) 248 | - [chrome.storage StorageArea](https://developer.chrome.com/extensions/storage#type-StorageArea) 249 | - [chrome.system.cpu](https://developer.chrome.com/extensions/system_cpu) 250 | - [chrome.system.memory](https://developer.chrome.com/extensions/system_memory) 251 | - [chrome.system.storage](https://developer.chrome.com/extensions/system_storage) 252 | - [chrome.tabCapture](https://developer.chrome.com/extensions/tabCapture) 253 | - [chrome.tabs](https://developer.chrome.com/extensions/tabs) 254 | - [chrome.topSites](https://developer.chrome.com/extensions/topSites#method-get) 255 | - [chrome.tts](https://developer.chrome.com/extensions/tts) 256 | - [chrome.types](https://developer.chrome.com/extensions/types) 257 | - [chrome.wallpaper](https://developer.chrome.com/extensions/wallpaper#method-setWallpaper) 258 | - [chrome.webNavigation](https://developer.chrome.com/extensions/webNavigation) 259 | - [chrome.windows](https://developer.chrome.com/extensions/windows) 260 | 261 | Pull requests with additional API gratefully received. 262 | 263 | ## ES5 Build 264 | Note that you can use an `ES5` build version of "Chrome Extension Async". 265 | ``` 266 | execute-async-function.es5.js 267 | ``` 268 | Sometimes your application has a build process that requires you to use 3rd party libraries that published with `ES5` code. 269 | For example, [create-react-app](https://github.com/facebook/create-react-app) will [break the build and minification process](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#npm-run-build-fails-to-minify) if one of your dependencies is not published as standard `ES5` code. 270 | 271 | ## Release Notes 272 | 273 | ### v3.4 274 | v3.4 adds `chrome.tabs.createAndWait` and `chrome.tabs.reloadAndWait`; this is backwards compatible and opt-in functionality. 275 | 276 | #### v3.4.1 277 | Fixes an issue with the timeout message. 278 | 279 | ### v3.3 280 | v3.3 adds `execute-async-function.es5.js` transpiled ES5 version for toolchains that depend on the older JS syntax. 281 | 282 | #### v3.3.1 283 | This addresses a breaking change in `chrome.storage` and fixes _TypeError: Illegal invocation: Function must be called on an object of type StorageArea_ exceptions. 284 | 285 | #### v3.3.2 286 | Fixed bug calling `chrome.identity.getRedirectURL` 287 | 288 | ### v3.2 289 | v3.2 adds `chrome.tabs.executeAsyncFunction`; this is backwards compatible and opt-in functionality. 290 | 291 | ### v3 Changes 292 | v3 introduces a breaking change from v1 and v2: now the original Chrome API is wrapped by an identical method that can be called with either old or new syntax. 293 | Callbacks can still be used on the same methods, and will fire before the promise resolves. 294 | Any error thrown inside the callback function will cause the promise to reject. 295 | 296 | You can use both a callback and `await` if you want to work with existing API code, but also want the `try`-`catch` support: 297 | 298 | ```javascript 299 | async function startDoSomethingHybrid(callback) { 300 | try{ 301 | // Using await means any exception is passed to the catch, even from the callback 302 | await chrome.tabs.query({ active: true, currentWindow: true }, tabs => callback(tabs[0])); 303 | } 304 | catch(err) { 305 | // Handle errors thrown by the API or by the callback 306 | } 307 | } 308 | ``` 309 | 310 | Older versions added a `...Async` suffix to either the function (2.0.0) or the API class (1.0.0). These are still available on bower (but not npm) and are not maintained. 311 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-extension-async", 3 | "description": "Promise wrapper for the Chrome extension API so that it can be used with async and await rather than callbacks", 4 | "main": "chrome-extension-async.js", 5 | "authors": [ 6 | "Keith Henry " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "chrome", 11 | "chrome-extension", 12 | "extension", 13 | "promise", 14 | "async-await" 15 | ], 16 | "homepage": "https://github.com/KeithHenry/chromeExtensionAsync", 17 | "ignore": [ 18 | "**/.*", 19 | "node_modules", 20 | "bower_components", 21 | "test", 22 | "tests" 23 | ] 24 | } -------------------------------------------------------------------------------- /chrome-extension-async.js: -------------------------------------------------------------------------------- 1 | /** Wrap an API that uses callbacks with Promises 2 | * This expects the pattern function withCallback(arg1, arg2, ... argN, callback) 3 | * @author Keith Henry 4 | * @license MIT */ 5 | (function () { 6 | 'use strict'; 7 | 8 | /** Wrap a function with a callback with a Promise. 9 | * @param {function} f The function to wrap, should be pattern: withCallback(arg1, arg2, ... argN, callback). 10 | * @param {function} parseCB Optional function to parse multiple callback parameters into a single object. 11 | * @returns {Promise} Promise that resolves when the callback fires. */ 12 | function promisify(f, parseCB) { 13 | return (...args) => { 14 | let safeArgs = args; 15 | let callback; 16 | // The Chrome API functions all use arguments, so we can't use f.length to check 17 | 18 | // If there is a last arg 19 | if (args && args.length > 0) { 20 | 21 | // ... and the last arg is a function 22 | const last = args[args.length - 1]; 23 | if (typeof last === 'function') { 24 | // Trim the last callback arg if it's been passed 25 | safeArgs = args.slice(0, args.length - 1); 26 | callback = last; 27 | } 28 | } 29 | 30 | // Return a promise 31 | return new Promise((resolve, reject) => { 32 | try { 33 | // Try to run the original function, with the trimmed args list 34 | f(...safeArgs, (...cbArgs) => { 35 | 36 | // If a callback was passed at the end of the original arguments 37 | if (callback) { 38 | // Don't allow a bug in the callback to stop the promise resolving 39 | try { callback(...cbArgs); } 40 | catch (cbErr) { reject(cbErr); } 41 | } 42 | 43 | // Chrome extensions always fire the callback, but populate chrome.runtime.lastError with exception details 44 | if (chrome.runtime.lastError) 45 | // Return as an error for the awaited catch block 46 | reject(new Error(chrome.runtime.lastError.message || `Error thrown by API ${chrome.runtime.lastError}`)); 47 | else { 48 | if (parseCB) { 49 | const cbObj = parseCB(...cbArgs); 50 | resolve(cbObj); 51 | } 52 | else if (!cbArgs || cbArgs.length === 0) 53 | resolve(); 54 | else if (cbArgs.length === 1) 55 | resolve(cbArgs[0]); 56 | else 57 | resolve(cbArgs); 58 | } 59 | }); 60 | } 61 | catch (err) { reject(err); } 62 | }); 63 | } 64 | } 65 | 66 | /** Promisify all the known functions in the map 67 | * @param {object} api The Chrome native API to extend 68 | * @param {Array} apiMap Collection of sub-API and functions to promisify */ 69 | function applyMap(api, apiMap) { 70 | if (!api) 71 | // Not supported by current permissions 72 | return; 73 | 74 | for (let funcDef of apiMap) { 75 | let funcName; 76 | if (typeof funcDef === 'string') 77 | funcName = funcDef; 78 | else { 79 | funcName = funcDef.n; 80 | } 81 | 82 | if (!api.hasOwnProperty(funcName)) 83 | // Member not in API 84 | continue; 85 | 86 | const m = api[funcName]; 87 | if (typeof m === 'function') 88 | // This is a function, wrap in a promise 89 | api[funcName] = promisify(m.bind(api), funcDef.cb); 90 | else 91 | // Sub-API, recurse this func with the mapped props 92 | applyMap(m, funcDef.props); 93 | } 94 | } 95 | 96 | /** Apply promise-maps to the Chrome native API. 97 | * @param {object} apiMaps The API to apply. */ 98 | function applyMaps(apiMaps) { 99 | for (let apiName in apiMaps) { 100 | const callbackApi = chrome[apiName]; 101 | if (!callbackApi) 102 | // Not supported by current permissions 103 | continue; 104 | 105 | const apiMap = apiMaps[apiName]; 106 | applyMap(callbackApi, apiMap); 107 | } 108 | } 109 | 110 | // accessibilityFeatures https://developer.chrome.com/extensions/accessibilityFeatures 111 | const knownA11ySetting = ['get', 'set', 'clear']; 112 | 113 | // ContentSetting https://developer.chrome.com/extensions/contentSettings#type-ContentSetting 114 | const knownInContentSetting = ['clear', 'get', 'set', 'getResourceIdentifiers']; 115 | 116 | // StorageArea https://developer.chrome.com/extensions/storage#type-StorageArea 117 | const knownInStorageArea = ['get', 'getBytesInUse', 'set', 'remove', 'clear']; 118 | 119 | /** Map of API functions that follow the callback pattern that we can 'promisify' */ 120 | applyMaps({ 121 | accessibilityFeatures: [ // Todo: this should extend AccessibilityFeaturesSetting.prototype instead 122 | { n: 'spokenFeedback', props: knownA11ySetting }, 123 | { n: 'largeCursor', props: knownA11ySetting }, 124 | { n: 'stickyKeys', props: knownA11ySetting }, 125 | { n: 'highContrast', props: knownA11ySetting }, 126 | { n: 'screenMagnifier', props: knownA11ySetting }, 127 | { n: 'autoclick', props: knownA11ySetting }, 128 | { n: 'virtualKeyboard', props: knownA11ySetting }, 129 | { n: 'animationPolicy', props: knownA11ySetting }], 130 | alarms: ['get', 'getAll', 'clear', 'clearAll'], 131 | bookmarks: [ 132 | 'get', 'getChildren', 'getRecent', 'getTree', 'getSubTree', 133 | 'search', 'create', 'move', 'update', 'remove', 'removeTree'], 134 | browser: ['openTab'], 135 | browserAction: [ 136 | 'getTitle', 'setIcon', 'getPopup', 'getBadgeText', 'getBadgeBackgroundColor'], 137 | browsingData: [ 138 | 'settings', 'remove', 'removeAppcache', 'removeCache', 139 | 'removeCookies', 'removeDownloads', 'removeFileSystems', 140 | 'removeFormData', 'removeHistory', 'removeIndexedDB', 141 | 'removeLocalStorage', 'removePluginData', 'removePasswords', 142 | 'removeWebSQL'], 143 | commands: ['getAll'], 144 | contentSettings: [ // Todo: this should extend ContentSetting.prototype instead 145 | { n: 'cookies', props: knownInContentSetting }, 146 | { n: 'images', props: knownInContentSetting }, 147 | { n: 'javascript', props: knownInContentSetting }, 148 | { n: 'location', props: knownInContentSetting }, 149 | { n: 'plugins', props: knownInContentSetting }, 150 | { n: 'popups', props: knownInContentSetting }, 151 | { n: 'notifications', props: knownInContentSetting }, 152 | { n: 'fullscreen', props: knownInContentSetting }, 153 | { n: 'mouselock', props: knownInContentSetting }, 154 | { n: 'microphone', props: knownInContentSetting }, 155 | { n: 'camera', props: knownInContentSetting }, 156 | { n: 'unsandboxedPlugins', props: knownInContentSetting }, 157 | { n: 'automaticDownloads', props: knownInContentSetting }], 158 | contextMenus: ['update', 'remove', 'removeAll'], /* 'create' omitted intentionally, it does not follow standard asynchronous pattern */ 159 | cookies: ['get', 'getAll', 'set', 'remove', 'getAllCookieStores'], 160 | debugger: ['attach', 'detach', 'sendCommand', 'getTargets'], 161 | desktopCapture: ['chooseDesktopMedia'], 162 | // TODO: devtools.* 163 | documentScan: ['scan'], 164 | downloads: [ 165 | 'download', 'search', 'pause', 'resume', 'cancel', 166 | 'getFileIcon', 'erase', 'removeFile', 'acceptDanger'], 167 | enterprise: [{ n: 'platformKeys', props: ['getToken', 'getCertificates', 'importCertificate', 'removeCertificate'] }], 168 | extension: ['isAllowedIncognitoAccess', 'isAllowedFileSchemeAccess'], // mostly deprecated in favour of runtime 169 | fileBrowserHandler: ['selectFile'], 170 | fileSystemProvider: ['mount', 'unmount', 'getAll', 'get', 'notify'], 171 | fontSettings: [ 172 | 'setDefaultFontSize', 'getFont', 'getDefaultFontSize', 'getMinimumFontSize', 173 | 'setMinimumFontSize', 'getDefaultFixedFontSize', 'clearDefaultFontSize', 174 | 'setDefaultFixedFontSize', 'clearFont', 'setFont', 'clearMinimumFontSize', 175 | 'getFontList', 'clearDefaultFixedFontSize'], 176 | gcm: ['register', 'unregister', 'send'], 177 | history: ['search', 'getVisits', 'addUrl', 'deleteUrl', 'deleteRange', 'deleteAll'], 178 | i18n: ['getAcceptLanguages', 'detectLanguage'], 179 | identity: [ 180 | 'getAuthToken', 'getProfileUserInfo', 'removeCachedAuthToken', 'launchWebAuthFlow'], 181 | idle: ['queryState'], 182 | input: [{ 183 | n: 'ime', props: [ 184 | 'setMenuItems', 'commitText', 'setCandidates', 'setComposition', 'updateMenuItems', 185 | 'setCandidateWindowProperties', 'clearComposition', 'setCursorPosition', 'sendKeyEvents', 186 | 'deleteSurroundingText'] 187 | }], 188 | management: [ 189 | 'setEnabled', 'getPermissionWarningsById', 'get', 'getAll', 190 | 'getPermissionWarningsByManifest', 'launchApp', 'uninstall', 'getSelf', 191 | 'uninstallSelf', 'createAppShortcut', 'setLaunchType', 'generateAppForLink'], 192 | networking: [{ n: 'config', props: ['setNetworkFilter', 'finishAuthentication'] }], 193 | notifications: ['create', 'update', 'clear', 'getAll', 'getPermissionLevel'], 194 | pageAction: ['getTitle', 'setIcon', 'getPopup'], 195 | pageCapture: ['saveAsMHTML'], 196 | permissions: ['getAll', 'contains', 'request', 'remove'], 197 | platformKeys: ['selectClientCertificates', 'verifyTLSServerCertificate', 198 | { n: "getKeyPair", cb: (publicKey, privateKey) => { return { publicKey, privateKey }; } }], 199 | runtime: [ 200 | 'getBackgroundPage', 'openOptionsPage', 'setUninstallURL', 201 | 'restartAfterDelay', 'sendMessage', 202 | 'sendNativeMessage', 'getPlatformInfo', 'getPackageDirectoryEntry', 203 | { n: "requestUpdateCheck", cb: (status, details) => { return { status, details }; } }], 204 | scriptBadge: ['getPopup'], 205 | sessions: ['getRecentlyClosed', 'getDevices', 'restore'], 206 | storage: [ // Todo: this should extend StorageArea.prototype instead 207 | { n: 'sync', props: knownInStorageArea }, 208 | { n: 'local', props: knownInStorageArea }, 209 | { n: 'managed', props: knownInStorageArea }], 210 | socket: [ 211 | 'create', 'connect', 'bind', 'read', 'write', 'recvFrom', 'sendTo', 212 | 'listen', 'accept', 'setKeepAlive', 'setNoDelay', 'getInfo', 'getNetworkList'], 213 | sockets: [ 214 | { n: 'tcp', props: [ 215 | 'create','update','setPaused','setKeepAlive','setNoDelay','connect', 216 | 'disconnect','secure','send','close','getInfo','getSockets'] }, 217 | { n: 'tcpServer', props: [ 218 | 'create','update','setPaused','listen','disconnect','close','getInfo','getSockets'] }, 219 | { n: 'udp', props: [ 220 | 'create','update','setPaused','bind','send','close','getInfo', 221 | 'getSockets','joinGroup','leaveGroup','setMulticastTimeToLive', 222 | 'setMulticastLoopbackMode','getJoinedGroups','setBroadcast'] }], 223 | system: [ 224 | { n: 'cpu', props: ['getInfo'] }, 225 | { n: 'memory', props: ['getInfo'] }, 226 | { n: 'storage', props: ['getInfo', 'ejectDevice', 'getAvailableCapacity'] }], 227 | tabCapture: ['capture', 'getCapturedTabs'], 228 | tabs: [ 229 | 'get', 'getCurrent', 'sendMessage', 'create', 'duplicate', 230 | 'query', 'highlight', 'update', 'move', 'reload', 'remove', 231 | 'detectLanguage', 'captureVisibleTab', 'executeScript', 232 | 'insertCSS', 'setZoom', 'getZoom', 'setZoomSettings', 233 | 'getZoomSettings', 'discard'], 234 | topSites: ['get'], 235 | tts: ['isSpeaking', 'getVoices', 'speak'], 236 | types: ['set', 'get', 'clear'], 237 | vpnProvider: ['createConfig', 'destroyConfig', 'setParameters', 'sendPacket', 'notifyConnectionStateChanged'], 238 | wallpaper: ['setWallpaper'], 239 | webNavigation: ['getFrame', 'getAllFrames', 'handlerBehaviorChanged'], 240 | windows: ['get', 'getCurrent', 'getLastFocused', 'getAll', 'create', 'update', 'remove'] 241 | }); 242 | })(); 243 | -------------------------------------------------------------------------------- /execute-async-function.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace chrome.tabs { 2 | 3 | /** Similar to details for executeScript, but code property is not optional */ 4 | interface InjectAsyncDetails { 5 | /** JavaScript or CSS code to inject. 6 | * Warning: Be careful using the code parameter. Incorrect use of it may open your extension to cross site scripting attacks. */ 7 | code: string; 8 | } 9 | 10 | /** Execute an async function and return the result. 11 | * @param {number} tab Optional ID of the tab in which to run the script; defaults to the active tab of the current window. 12 | * @param {function|string|object} action The async function to inject into the page. 13 | * This must be marked as async or return a Promise. 14 | * This can be the details object expected by [executeScript]{@link https://developer.chrome.com/extensions/tabs#method-executeScript}, 15 | * in which case the code property MUST be populated with a promise-returning function. 16 | * @param {any[]} params Parameters to serialise and pass to the action (using JSON.stringify) 17 | * @returns {Promise} Resolves when the injected async script has finished executing and holds the result of the script. 18 | * Rejects if an error is encountered setting up the function, if an error is thrown by the executing script, or if it times out. */ 19 | export function executeAsyncFunction(tab: number, action: ((...p: any[]) => any) | string | InjectAsyncDetails, ...params: any[]): Promise; 20 | 21 | /** Creates a Promise that resolves only when the created tab is finished loading. 22 | * The normal chrome.tabs.create function executes its' callback before the tab finishes loading the page. 23 | * @param {object} createProperties same as the createProperties param for [chrome.tabs.create]{@link https://developer.chrome.com/extensions/tabs#method-create}. 24 | * @param {number} msTimeout Optional milliseconds to timeout when tab is loading 25 | * If this value is null or zero, it defaults to 120,000 ms (2 minutes). 26 | * @returns {Promise} Resolves when the created tab has finished loading and holds the result. 27 | * The result is an object containing the parameters passed to the callback for [chrome.tabs.onUpdated]{@link https://developer.chrome.com/extensions/tabs#event-onUpdated}. 28 | * Rejects if an error is encountered loading the tab, or if it times out. */ 29 | export function createAndWait(createProperties: object, msTimeout: number): Promise; 30 | 31 | /** Creates a Promise that resolves only when the tab is finished reloading. 32 | * The normal chrome.tabs.reload function executes its' callback before the tab finishes loading the page. 33 | * @param {integer} tabId same as the tabId parameter for [chrome.tabs.reload]{@link https://developer.chrome.com/extensions/tabs#method-reload}. 34 | * @param {object} reloadProperties Optional. same as the reloadProperties parameter for [chrome.tabs.reload]{@link https://developer.chrome.com/extensions/tabs#method-reload}. 35 | * @param {number} msTimeout Optional milliseconds to timeout when tab is loading 36 | * If this value is null or zero, it defaults to 120,000 ms (2 minutes). 37 | * @returns {Promise} Resolves when the tab has finished reloading and holds the result. 38 | * The result is an object containing the parameters passed to the callback for [chrome.tabs.onUpdated]{@link https://developer.chrome.com/extensions/tabs#event-onUpdated}. 39 | * Rejects if an error is encountered loading the tab, or if it times out. */ 40 | export function reloadAndWait(tabId: number, reloadProperties: object, msTimeout: number): Promise; 41 | } -------------------------------------------------------------------------------- /execute-async-function.es5.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 4 | 5 | /** Wrap an API that uses callbacks with Promises 6 | * This expects the pattern function withCallback(arg1, arg2, ... argN, callback) 7 | * @author Keith Henry 8 | * @license MIT */ 9 | (function () { 10 | 'use strict'; 11 | 12 | /** Wrap a function with a callback with a Promise. 13 | * @param {function} f The function to wrap, should be pattern: withCallback(arg1, arg2, ... argN, callback). 14 | * @param {function} parseCB Optional function to parse multiple callback parameters into a single object. 15 | * @returns {Promise} Promise that resolves when the callback fires. */ 16 | 17 | function promisify(f, parseCB) { 18 | return function () { 19 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 20 | args[_key] = arguments[_key]; 21 | } 22 | 23 | var safeArgs = args; 24 | var callback = void 0; 25 | // The Chrome API functions all use arguments, so we can't use f.length to check 26 | 27 | // If there is a last arg 28 | if (args && args.length > 0) { 29 | 30 | // ... and the last arg is a function 31 | var last = args[args.length - 1]; 32 | if (typeof last === 'function') { 33 | // Trim the last callback arg if it's been passed 34 | safeArgs = args.slice(0, args.length - 1); 35 | callback = last; 36 | } 37 | } 38 | 39 | // Return a promise 40 | return new Promise(function (resolve, reject) { 41 | try { 42 | // Try to run the original function, with the trimmed args list 43 | f.apply(undefined, _toConsumableArray(safeArgs).concat([function () { 44 | for (var _len2 = arguments.length, cbArgs = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 45 | cbArgs[_key2] = arguments[_key2]; 46 | } 47 | 48 | // If a callback was passed at the end of the original arguments 49 | if (callback) { 50 | // Don't allow a bug in the callback to stop the promise resolving 51 | try { 52 | callback.apply(undefined, cbArgs); 53 | } catch (cbErr) { 54 | reject(cbErr); 55 | } 56 | } 57 | 58 | // Chrome extensions always fire the callback, but populate chrome.runtime.lastError with exception details 59 | if (chrome.runtime.lastError) 60 | // Return as an error for the awaited catch block 61 | reject(new Error(chrome.runtime.lastError.message || 'Error thrown by API ' + chrome.runtime.lastError));else { 62 | if (parseCB) { 63 | var cbObj = parseCB.apply(undefined, cbArgs); 64 | resolve(cbObj); 65 | } else if (!cbArgs || cbArgs.length === 0) resolve();else if (cbArgs.length === 1) resolve(cbArgs[0]);else resolve(cbArgs); 66 | } 67 | }])); 68 | } catch (err) { 69 | reject(err); 70 | } 71 | }); 72 | }; 73 | } 74 | 75 | /** Promisify all the known functions in the map 76 | * @param {object} api The Chrome native API to extend 77 | * @param {Array} apiMap Collection of sub-API and functions to promisify */ 78 | function applyMap(api, apiMap) { 79 | if (!api) 80 | // Not supported by current permissions 81 | return; 82 | 83 | var _iteratorNormalCompletion = true; 84 | var _didIteratorError = false; 85 | var _iteratorError = undefined; 86 | 87 | try { 88 | for (var _iterator = apiMap[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { 89 | var funcDef = _step.value; 90 | 91 | var funcName = void 0; 92 | if (typeof funcDef === 'string') funcName = funcDef;else { 93 | funcName = funcDef.n; 94 | } 95 | 96 | if (!api.hasOwnProperty(funcName)) 97 | // Member not in API 98 | continue; 99 | 100 | var m = api[funcName]; 101 | if (typeof m === 'function') 102 | // This is a function, wrap in a promise 103 | api[funcName] = promisify(m, funcDef.cb);else 104 | // Sub-API, recurse this func with the mapped props 105 | applyMap(m, funcDef.props); 106 | } 107 | } catch (err) { 108 | _didIteratorError = true; 109 | _iteratorError = err; 110 | } finally { 111 | try { 112 | if (!_iteratorNormalCompletion && _iterator.return) { 113 | _iterator.return(); 114 | } 115 | } finally { 116 | if (_didIteratorError) { 117 | throw _iteratorError; 118 | } 119 | } 120 | } 121 | } 122 | 123 | /** Apply promise-maps to the Chrome native API. 124 | * @param {object} apiMaps The API to apply. */ 125 | function applyMaps(apiMaps) { 126 | for (var apiName in apiMaps) { 127 | var callbackApi = chrome[apiName]; 128 | if (!callbackApi) 129 | // Not supported by current permissions 130 | continue; 131 | 132 | var apiMap = apiMaps[apiName]; 133 | applyMap(callbackApi, apiMap); 134 | } 135 | } 136 | 137 | // accessibilityFeatures https://developer.chrome.com/extensions/accessibilityFeatures 138 | var knownA11ySetting = ['get', 'set', 'clear']; 139 | 140 | // ContentSetting https://developer.chrome.com/extensions/contentSettings#type-ContentSetting 141 | var knownInContentSetting = ['clear', 'get', 'set', 'getResourceIdentifiers']; 142 | 143 | // StorageArea https://developer.chrome.com/extensions/storage#type-StorageArea 144 | var knownInStorageArea = ['get', 'getBytesInUse', 'set', 'remove', 'clear']; 145 | 146 | /** Map of API functions that follow the callback pattern that we can 'promisify' */ 147 | applyMaps({ 148 | accessibilityFeatures: [// Todo: this should extend AccessibilityFeaturesSetting.prototype instead 149 | { n: 'spokenFeedback', props: knownA11ySetting }, { n: 'largeCursor', props: knownA11ySetting }, { n: 'stickyKeys', props: knownA11ySetting }, { n: 'highContrast', props: knownA11ySetting }, { n: 'screenMagnifier', props: knownA11ySetting }, { n: 'autoclick', props: knownA11ySetting }, { n: 'virtualKeyboard', props: knownA11ySetting }, { n: 'animationPolicy', props: knownA11ySetting }], 150 | alarms: ['get', 'getAll', 'clear', 'clearAll'], 151 | bookmarks: ['get', 'getChildren', 'getRecent', 'getTree', 'getSubTree', 'search', 'create', 'move', 'update', 'remove', 'removeTree'], 152 | browser: ['openTab'], 153 | browserAction: ['getTitle', 'setIcon', 'getPopup', 'getBadgeText', 'getBadgeBackgroundColor'], 154 | browsingData: ['settings', 'remove', 'removeAppcache', 'removeCache', 'removeCookies', 'removeDownloads', 'removeFileSystems', 'removeFormData', 'removeHistory', 'removeIndexedDB', 'removeLocalStorage', 'removePluginData', 'removePasswords', 'removeWebSQL'], 155 | commands: ['getAll'], 156 | contentSettings: [// Todo: this should extend ContentSetting.prototype instead 157 | { n: 'cookies', props: knownInContentSetting }, { n: 'images', props: knownInContentSetting }, { n: 'javascript', props: knownInContentSetting }, { n: 'location', props: knownInContentSetting }, { n: 'plugins', props: knownInContentSetting }, { n: 'popups', props: knownInContentSetting }, { n: 'notifications', props: knownInContentSetting }, { n: 'fullscreen', props: knownInContentSetting }, { n: 'mouselock', props: knownInContentSetting }, { n: 'microphone', props: knownInContentSetting }, { n: 'camera', props: knownInContentSetting }, { n: 'unsandboxedPlugins', props: knownInContentSetting }, { n: 'automaticDownloads', props: knownInContentSetting }], 158 | contextMenus: ['create', 'update', 'remove', 'removeAll'], 159 | cookies: ['get', 'getAll', 'set', 'remove', 'getAllCookieStores'], 160 | debugger: ['attach', 'detach', 'sendCommand', 'getTargets'], 161 | desktopCapture: ['chooseDesktopMedia'], 162 | // TODO: devtools.* 163 | documentScan: ['scan'], 164 | downloads: ['download', 'search', 'pause', 'resume', 'cancel', 'getFileIcon', 'erase', 'removeFile', 'acceptDanger'], 165 | enterprise: [{ n: 'platformKeys', props: ['getToken', 'getCertificates', 'importCertificate', 'removeCertificate'] }], 166 | extension: ['isAllowedIncognitoAccess', 'isAllowedFileSchemeAccess'], // mostly deprecated in favour of runtime 167 | fileBrowserHandler: ['selectFile'], 168 | fileSystemProvider: ['mount', 'unmount', 'getAll', 'get', 'notify'], 169 | fontSettings: ['setDefaultFontSize', 'getFont', 'getDefaultFontSize', 'getMinimumFontSize', 'setMinimumFontSize', 'getDefaultFixedFontSize', 'clearDefaultFontSize', 'setDefaultFixedFontSize', 'clearFont', 'setFont', 'clearMinimumFontSize', 'getFontList', 'clearDefaultFixedFontSize'], 170 | gcm: ['register', 'unregister', 'send'], 171 | history: ['search', 'getVisits', 'addUrl', 'deleteUrl', 'deleteRange', 'deleteAll'], 172 | i18n: ['getAcceptLanguages', 'detectLanguage'], 173 | identity: ['getAuthToken', 'getProfileUserInfo', 'removeCachedAuthToken', 'launchWebAuthFlow', 'getRedirectURL'], 174 | idle: ['queryState'], 175 | input: [{ 176 | n: 'ime', props: ['setMenuItems', 'commitText', 'setCandidates', 'setComposition', 'updateMenuItems', 'setCandidateWindowProperties', 'clearComposition', 'setCursorPosition', 'sendKeyEvents', 'deleteSurroundingText'] 177 | }], 178 | management: ['setEnabled', 'getPermissionWarningsById', 'get', 'getAll', 'getPermissionWarningsByManifest', 'launchApp', 'uninstall', 'getSelf', 'uninstallSelf', 'createAppShortcut', 'setLaunchType', 'generateAppForLink'], 179 | networking: [{ n: 'config', props: ['setNetworkFilter', 'finishAuthentication'] }], 180 | notifications: ['create', 'update', 'clear', 'getAll', 'getPermissionLevel'], 181 | pageAction: ['getTitle', 'setIcon', 'getPopup'], 182 | pageCapture: ['saveAsMHTML'], 183 | permissions: ['getAll', 'contains', 'request', 'remove'], 184 | platformKeys: ['selectClientCertificates', 'verifyTLSServerCertificate', { n: "getKeyPair", cb: function cb(publicKey, privateKey) { 185 | return { publicKey: publicKey, privateKey: privateKey }; 186 | } }], 187 | runtime: ['getBackgroundPage', 'openOptionsPage', 'setUninstallURL', 'restartAfterDelay', 'sendMessage', 'sendNativeMessage', 'getPlatformInfo', 'getPackageDirectoryEntry', { n: "requestUpdateCheck", cb: function cb(status, details) { 188 | return { status: status, details: details }; 189 | } }], 190 | scriptBadge: ['getPopup'], 191 | sessions: ['getRecentlyClosed', 'getDevices', 'restore'], 192 | storage: [// Todo: this should extend StorageArea.prototype instead 193 | { n: 'sync', props: knownInStorageArea }, { n: 'local', props: knownInStorageArea }, { n: 'managed', props: knownInStorageArea }], 194 | socket: ['create', 'connect', 'bind', 'read', 'write', 'recvFrom', 'sendTo', 'listen', 'accept', 'setKeepAlive', 'setNoDelay', 'getInfo', 'getNetworkList'], 195 | sockets: [{ n: 'tcp', props: ['create', 'update', 'setPaused', 'setKeepAlive', 'setNoDelay', 'connect', 'disconnect', 'secure', 'send', 'close', 'getInfo', 'getSockets'] }, { n: 'tcpServer', props: ['create', 'update', 'setPaused', 'listen', 'disconnect', 'close', 'getInfo', 'getSockets'] }, { n: 'udp', props: ['create', 'update', 'setPaused', 'bind', 'send', 'close', 'getInfo', 'getSockets', 'joinGroup', 'leaveGroup', 'setMulticastTimeToLive', 'setMulticastLoopbackMode', 'getJoinedGroups', 'setBroadcast'] }], 196 | system: [{ n: 'cpu', props: ['getInfo'] }, { n: 'memory', props: ['getInfo'] }, { n: 'storage', props: ['getInfo', 'ejectDevice', 'getAvailableCapacity'] }], 197 | tabCapture: ['capture', 'getCapturedTabs'], 198 | tabs: ['get', 'getCurrent', 'sendMessage', 'create', 'duplicate', 'query', 'highlight', 'update', 'move', 'reload', 'remove', 'detectLanguage', 'captureVisibleTab', 'executeScript', 'insertCSS', 'setZoom', 'getZoom', 'setZoomSettings', 'getZoomSettings', 'discard'], 199 | topSites: ['get'], 200 | tts: ['isSpeaking', 'getVoices', 'speak'], 201 | types: ['set', 'get', 'clear'], 202 | vpnProvider: ['createConfig', 'destroyConfig', 'setParameters', 'sendPacket', 'notifyConnectionStateChanged'], 203 | wallpaper: ['setWallpaper'], 204 | webNavigation: ['getFrame', 'getAllFrames', 'handlerBehaviorChanged'], 205 | windows: ['get', 'getCurrent', 'getLastFocused', 'getAll', 'create', 'update', 'remove'] 206 | }); 207 | })(); 208 | -------------------------------------------------------------------------------- /execute-async-function.js: -------------------------------------------------------------------------------- 1 | /** Inject and execute a single async function or promise in a tab, resolving with the result. 2 | * @author Keith Henry 3 | * @license MIT */ 4 | (function () { 5 | 'use strict'; 6 | 7 | /** Wrap the async function in an await and a runtime.sendMessage with the result 8 | * @param {function|string|object} action The async function to inject into the page. 9 | * @param {string} id Single use random ID. 10 | * @param {any[]} params Array of additional parameters to pass. 11 | * @returns {object} Execution details to pass to chrome.tabs.executeScript */ 12 | function setupDetails(action, id, params) { 13 | // Wrap the async function in an await and a runtime.sendMessage with the result 14 | // This should always call runtime.sendMessage, even if an error is thrown 15 | const wrapAsyncSendMessage = action => 16 | `(async function () { 17 | const result = { asyncFuncID: '${id}' }; 18 | try { 19 | result.content = await (${action})(${params.map(p => JSON.stringify(p)).join(',')}); 20 | } 21 | catch(x) { 22 | // Make an explicit copy of the Error properties 23 | result.error = { 24 | message: x.message, 25 | arguments: x.arguments, 26 | type: x.type, 27 | name: x.name, 28 | stack: x.stack 29 | }; 30 | } 31 | finally { 32 | // Always call sendMessage, as without it this might loop forever 33 | chrome.runtime.sendMessage(result); 34 | } 35 | })()`; 36 | 37 | // Apply this wrapper to the code passed 38 | let execArgs = {}; 39 | if (typeof action === 'function' || typeof action === 'string') 40 | // Passed a function or string, wrap it directly 41 | execArgs.code = wrapAsyncSendMessage(action); 42 | else if (action.code) { 43 | // Passed details object https://developer.chrome.com/extensions/tabs#method-executeScript 44 | execArgs = action; 45 | execArgs.code = wrapAsyncSendMessage(action.code); 46 | } 47 | else if (action.file) 48 | throw new Error(`Cannot execute ${action.file}. File based execute scripts are not supported.`); 49 | else 50 | throw new Error(`Cannot execute ${JSON.stringify(action)}, it must be a function, string, or have a code property.`); 51 | 52 | return execArgs; 53 | } 54 | 55 | /** Create a promise that resolves when chrome.runtime.onMessage fires with the id 56 | * @param {string} id ID for the message we're expecting. 57 | * Messages without the ID will not resolve this promise. 58 | * @returns {Promise} Promise that resolves when chrome.runtime.onMessage.addListener fires. */ 59 | function promisifyRuntimeMessage(id) { 60 | // We don't have a reject because the finally in the script wrapper should ensure this always gets called. 61 | return new Promise(resolve => { 62 | const listener = request => { 63 | // Check that the message sent is intended for this listener 64 | if (request && request.asyncFuncID === id) { 65 | 66 | // Remove this listener 67 | chrome.runtime.onMessage.removeListener(listener); 68 | resolve(request); 69 | } 70 | 71 | // Return false as we don't want to keep this channel open https://developer.chrome.com/extensions/runtime#event-onMessage 72 | return false; 73 | }; 74 | 75 | chrome.runtime.onMessage.addListener(listener); 76 | }); 77 | } 78 | 79 | /** Create a promise that resolves when chrome.tabs.onUpdated fires with the id 80 | * @param {string} id ID for the tab we're expecting. 81 | * Tabs without the ID will not resolve or reject this promise. 82 | * @param {number} msTimeout Optional milliseconds to timeout when tab is loading 83 | * If this value is null or zero, it defaults to 120,000 ms (2 minutes). 84 | * @returns {Promise} Promise that resolves when chrome.tabs.onUpdated.addListener fires. */ 85 | function promisifyTabUpdate(id, msTimeout) { 86 | 87 | let mainPromise = new Promise((resolve, reject) => { 88 | const tabUpdatedListener = (tabId, changeInfo, tab) => { 89 | // The onUpdated event is called multiple times during a single load. 90 | // the status of 'complete' is called only once, when it is finished. 91 | if (tabId === id && changeInfo.status === 'complete') { 92 | removeListeners(); 93 | resolve({tabId:tabId, changeInfo:changeInfo, tab:tab}); 94 | } 95 | }; 96 | 97 | // This will happen when the tab or window is closed before it finishes loading 98 | const tabRemovedListener = (tabId, removeInfo) => { 99 | if (tabId === id) { 100 | removeListeners(); 101 | reject(new Error(`The tab with id = ${tabId} was removed before it finished loading.`)); 102 | } 103 | } 104 | 105 | // This will happen when the tab is replaced. This is untested, not sure how to recreate it. 106 | const tabReplacedListener = (addedTabId, removedTabId) => { 107 | if (removedTabId === id) { 108 | removeListeners(); 109 | reject(new Error(`The tab with id = ${removedTabId} was replaced before it finished loading.`)); 110 | } 111 | } 112 | 113 | const removeListeners = () => { 114 | chrome.tabs.onUpdated.removeListener(tabUpdatedListener); 115 | chrome.tabs.onRemoved.removeListener(tabRemovedListener); 116 | chrome.tabs.onReplaced.removeListener(tabReplacedListener); 117 | } 118 | 119 | chrome.tabs.onUpdated.addListener(tabUpdatedListener); 120 | chrome.tabs.onRemoved.addListener(tabRemovedListener); 121 | chrome.tabs.onReplaced.addListener(tabReplacedListener); 122 | }); 123 | 124 | // Although I have onRemoved and onReplaced events watching to reject the promise, 125 | // there is nothing in the chrome extension api documentation that guarantees this will be an exhaustive approach. 126 | // So to account for the unknown, I am adding an auto-timeout feature to reject the promise after 2 minutes. 127 | let timeoutPromise = new Promise ( (resolve, reject) => { 128 | let millisecondsToTimeout = 12e4; // 12e4 = 2 minutes 129 | if (!!msTimeout && typeof msTimeout === 'number' && msTimeout > 0) { 130 | millisecondsToTimeout = msTimeout; 131 | } 132 | setTimeout(() => { 133 | reject(new Error(`The tab loading timed out after ${millisecondsToTimeout/1000} seconds.`)); 134 | }, millisecondsToTimeout); 135 | }); 136 | 137 | return Promise.race([mainPromise, timeoutPromise]); 138 | } 139 | 140 | /** Execute an async function and return the result. 141 | * @param {number} tab Optional ID of the tab in which to run the script; defaults to the active tab of the current window. 142 | * @param {function|string|object} action The async function to inject into the page. 143 | * This must be marked as async or return a Promise. 144 | * This can be the details object expected by [executeScript]{@link https://developer.chrome.com/extensions/tabs#method-executeScript}, 145 | * in which case the code property MUST be populated with a promise-returning function. 146 | * @param {any[]} params Parameters to serialise and pass to the action (using JSON.stringify) 147 | * @returns {Promise} Resolves when the injected async script has finished executing and holds the result of the script. 148 | * Rejects if an error is encountered setting up the function, if an error is thrown by the executing script, or if it times out. */ 149 | chrome.tabs.executeAsyncFunction = async function (tab, action, ...params) { 150 | 151 | // Generate a random 4-char key to avoid clashes if called multiple times 152 | const id = Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); 153 | 154 | // Write the script and serialise the params 155 | const details = setupDetails(action, id, params); 156 | 157 | // Add a listener so that we know when the async script finishes 158 | const message = promisifyRuntimeMessage(id); 159 | 160 | // This will return a serialised promise, which will be broken (http://stackoverflow.com/questions/43144485) 161 | await chrome.tabs.executeScript(tab, details); 162 | 163 | // Wait until we have the result message 164 | const { content, error } = await message; 165 | 166 | if (error) 167 | throw new Error(`Error thrown in execution script: ${error.message}. 168 | Stack: ${error.stack}`) 169 | 170 | return content; 171 | } 172 | 173 | /** Creates a Promise that resolves only when the created tab is finished loading. 174 | * The normal chrome.tabs.create function executes its' callback before the tab finishes loading the page. 175 | * @param {object} createProperties same as the createProperties param for [chrome.tabs.create]{@link https://developer.chrome.com/extensions/tabs#method-create}. 176 | * @param {number} msTimeout Optional milliseconds to timeout when tab is loading 177 | * If this value is null or zero, it defaults to 120,000 ms (2 minutes). 178 | * @returns {Promise} Resolves when the created tab has finished loading and holds the result. 179 | * The result is an object containing the parameters passed to the callback for [chrome.tabs.onUpdated]{@link https://developer.chrome.com/extensions/tabs#event-onUpdated}. 180 | * Rejects if an error is encountered loading the tab, or if it times out. */ 181 | chrome.tabs.createAndWait = async function(createProperties, msTimeout) { 182 | const tab = await chrome.tabs.create(createProperties); 183 | const tabLoadCompletePromise = promisifyTabUpdate(tab.id, msTimeout); 184 | const results = await tabLoadCompletePromise; 185 | return results; 186 | } 187 | 188 | /** Creates a Promise that resolves only when the tab is finished reloading. 189 | * The normal chrome.tabs.reload function executes its' callback before the tab finishes loading the page. 190 | * @param {integer} tabId same as the tabId parameter for [chrome.tabs.reload]{@link https://developer.chrome.com/extensions/tabs#method-reload}. 191 | * @param {object} reloadProperties Optional, same as the reloadProperties parameter for [chrome.tabs.reload]{@link https://developer.chrome.com/extensions/tabs#method-reload}. 192 | * @param {number} msTimeout Optional milliseconds to timeout when tab is loading 193 | * If this value is null or zero, it defaults to 120,000 ms (2 minutes). 194 | * @returns {Promise} Resolves when the tab has finished reloading and holds the result. 195 | * The result is an object containing the parameters passed to the callback for [chrome.tabs.onUpdated]{@link https://developer.chrome.com/extensions/tabs#event-onUpdated}. 196 | * Rejects if an error is encountered loading the tab, or if it times out. */ 197 | chrome.tabs.reloadAndWait = async function(tabId, reloadProperties, msTimeout) { 198 | await chrome.tabs.reload(tabId, reloadProperties); 199 | const tabLoadCompletePromise = promisifyTabUpdate(tabId, msTimeout); 200 | const results = await tabLoadCompletePromise; 201 | return results; 202 | } 203 | 204 | })(); 205 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-extension-async", 3 | "version": "3.4.1", 4 | "description": "Promise wrapper for the Chrome extension API so that it can be used with async and await rather than callbacks", 5 | "main": "chrome-extension-async.js", 6 | "scripts": { 7 | "build": "npx babel chrome-extension-async.js --out-file execute-async-function.es5.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/KeithHenry/chromeExtensionAsync.git" 12 | }, 13 | "author": "Keith Henry ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/KeithHenry/chromeExtensionAsync/issues" 17 | }, 18 | "homepage": "https://github.com/KeithHenry/chromeExtensionAsync#readme", 19 | "keywords": [ 20 | "chrome", 21 | "chrome-extension", 22 | "extension", 23 | "promise", 24 | "async-await" 25 | ], 26 | "devDependencies": { 27 | "babel-cli": "^6.26.0", 28 | "babel-preset-env": "^1.7.0", 29 | "npx": "^10.2.0" 30 | } 31 | } 32 | --------------------------------------------------------------------------------