/** Wrap an API that uses callbacks with Promises * This expects the pattern function withCallback(arg1, arg2, ... argN, callback) * @author Keith Henry * @license MIT */ (function () { // before we start: don't do shit in browsers that aren't Google Chrome. // We might need to modify this for use in IE at a later date tho if(chrome === undefined) return; 'use strict'; /** Wrap a function with a callback with a Promise. * @param {function} f The function to wrap, should be pattern: withCallback(arg1, arg2, ... argN, callback). * @param {function} parseCB Optional function to parse multiple callback parameters into a single object. * @returns {Promise} Promise that resolves when the callback fires. */ function promisify(f, parseCB) { return (...args) => { let safeArgs = args; let callback; // The Chrome API functions all use arguments, so we can't use f.length to check // If there is a last arg if (args && args.length > 0) { // ... and the last arg is a function const last = args[args.length - 1]; if (typeof last === 'function') { // Trim the last callback arg if it's been passed safeArgs = args.slice(0, args.length - 1); callback = last; } } // Return a promise return new Promise((resolve, reject) => { try { // Try to run the original function, with the trimmed args list f(...safeArgs, (...cbArgs) => { // If a callback was passed at the end of the original arguments if (callback) { // Don't allow a bug in the callback to stop the promise resolving try { callback(...cbArgs); } catch (cbErr) { reject(cbErr); } } // Chrome extensions always fire the callback, but populate chrome.runtime.lastError with exception details if (chrome.runtime.lastError) // Return as an error for the awaited catch block reject(new Error(chrome.runtime.lastError.message || `Error thrown by API ${chrome.runtime.lastError}`)); else { if (parseCB) { const cbObj = parseCB(...cbArgs); resolve(cbObj); } else if (!cbArgs || cbArgs.length === 0) resolve(); else if (cbArgs.length === 1) resolve(cbArgs[0]); else resolve(cbArgs); } }); } catch (err) { reject(err); } }); } } /** Promisify all the known functions in the map * @param {object} api The Chrome native API to extend * @param {Array} apiMap Collection of sub-API and functions to promisify */ function applyMap(api, apiMap) { if (!api) // Not supported by current permissions return; for (let funcDef of apiMap) { let funcName; if (typeof funcDef === 'string') funcName = funcDef; else { funcName = funcDef.n; } if (!api.hasOwnProperty(funcName)) // Member not in API continue; const m = api[funcName]; if (typeof m === 'function') // This is a function, wrap in a promise api[funcName] = promisify(m, funcDef.cb); else // Sub-API, recurse this func with the mapped props applyMap(m, funcDef.props); } } /** Apply promise-maps to the Chrome native API. * @param {object} apiMaps The API to apply. */ function applyMaps(apiMaps) { for (let apiName in apiMaps) { const callbackApi = chrome[apiName]; if (!callbackApi) // Not supported by current permissions continue; const apiMap = apiMaps[apiName]; applyMap(callbackApi, apiMap); } } // accessibilityFeatures https://developer.chrome.com/extensions/accessibilityFeatures const knownA11ySetting = ['get', 'set', 'clear']; // ContentSetting https://developer.chrome.com/extensions/contentSettings#type-ContentSetting const knownInContentSetting = ['clear', 'get', 'set', 'getResourceIdentifiers']; // StorageArea https://developer.chrome.com/extensions/storage#type-StorageArea const knownInStorageArea = ['get', 'getBytesInUse', 'set', 'remove', 'clear']; /** Map of API functions that follow the callback pattern that we can 'promisify' */ applyMaps({ accessibilityFeatures: [ // Todo: this should extend AccessibilityFeaturesSetting.prototype instead { 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 }], alarms: ['get', 'getAll', 'clear', 'clearAll'], bookmarks: [ 'get', 'getChildren', 'getRecent', 'getTree', 'getSubTree', 'search', 'create', 'move', 'update', 'remove', 'removeTree'], browser: ['openTab'], browserAction: [ 'getTitle', 'setIcon', 'getPopup', 'getBadgeText', 'getBadgeBackgroundColor'], browsingData: [ 'settings', 'remove', 'removeAppcache', 'removeCache', 'removeCookies', 'removeDownloads', 'removeFileSystems', 'removeFormData', 'removeHistory', 'removeIndexedDB', 'removeLocalStorage', 'removePluginData', 'removePasswords', 'removeWebSQL'], commands: ['getAll'], contentSettings: [ // Todo: this should extend ContentSetting.prototype instead { 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 }], contextMenus: ['create', 'update', 'remove', 'removeAll'], cookies: ['get', 'getAll', 'set', 'remove', 'getAllCookieStores'], debugger: ['attach', 'detach', 'sendCommand', 'getTargets'], desktopCapture: ['chooseDesktopMedia'], // TODO: devtools.* documentScan: ['scan'], downloads: [ 'download', 'search', 'pause', 'resume', 'cancel', 'getFileIcon', 'erase', 'removeFile', 'acceptDanger'], enterprise: [{ n: 'platformKeys', props: ['getToken', 'getCertificates', 'importCertificate', 'removeCertificate'] }], extension: ['isAllowedIncognitoAccess', 'isAllowedFileSchemeAccess'], // mostly deprecated in favour of runtime fileBrowserHandler: ['selectFile'], fileSystemProvider: ['mount', 'unmount', 'getAll', 'get', 'notify'], fontSettings: [ 'setDefaultFontSize', 'getFont', 'getDefaultFontSize', 'getMinimumFontSize', 'setMinimumFontSize', 'getDefaultFixedFontSize', 'clearDefaultFontSize', 'setDefaultFixedFontSize', 'clearFont', 'setFont', 'clearMinimumFontSize', 'getFontList', 'clearDefaultFixedFontSize'], gcm: ['register', 'unregister', 'send'], history: ['search', 'getVisits', 'addUrl', 'deleteUrl', 'deleteRange', 'deleteAll'], i18n: ['getAcceptLanguages', 'detectLanguage'], identity: [ 'getAuthToken', 'getProfileUserInfo', 'removeCachedAuthToken', 'launchWebAuthFlow', 'getRedirectURL'], idle: ['queryState'], input: [{ n: 'ime', props: [ 'setMenuItems', 'commitText', 'setCandidates', 'setComposition', 'updateMenuItems', 'setCandidateWindowProperties', 'clearComposition', 'setCursorPosition', 'sendKeyEvents', 'deleteSurroundingText'] }], management: [ 'setEnabled', 'getPermissionWarningsById', 'get', 'getAll', 'getPermissionWarningsByManifest', 'launchApp', 'uninstall', 'getSelf', 'uninstallSelf', 'createAppShortcut', 'setLaunchType', 'generateAppForLink'], networking: [{ n: 'config', props: ['setNetworkFilter', 'finishAuthentication'] }], notifications: ['create', 'update', 'clear', 'getAll', 'getPermissionLevel'], pageAction: ['getTitle', 'setIcon', 'getPopup'], pageCapture: ['saveAsMHTML'], permissions: ['getAll', 'contains', 'request', 'remove'], platformKeys: ['selectClientCertificates', 'verifyTLSServerCertificate', { n: "getKeyPair", cb: (publicKey, privateKey) => { return { publicKey, privateKey }; } }], runtime: [ 'getBackgroundPage', 'openOptionsPage', 'setUninstallURL', 'restartAfterDelay', 'sendMessage', 'sendNativeMessage', 'getPlatformInfo', 'getPackageDirectoryEntry', { n: "requestUpdateCheck", cb: (status, details) => { return { status, details }; } }], scriptBadge: ['getPopup'], sessions: ['getRecentlyClosed', 'getDevices', 'restore'], storage: [ // Todo: this should extend StorageArea.prototype instead { n: 'sync', props: knownInStorageArea }, { n: 'local', props: knownInStorageArea }, { n: 'managed', props: knownInStorageArea }], socket: [ 'create', 'connect', 'bind', 'read', 'write', 'recvFrom', 'sendTo', 'listen', 'accept', 'setKeepAlive', 'setNoDelay', 'getInfo', 'getNetworkList'], 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'] }], system: [ { n: 'cpu', props: ['getInfo'] }, { n: 'memory', props: ['getInfo'] }, { n: 'storage', props: ['getInfo', 'ejectDevice', 'getAvailableCapacity'] }], tabCapture: ['capture', 'getCapturedTabs'], tabs: [ 'get', 'getCurrent', 'sendMessage', 'create', 'duplicate', 'query', 'highlight', 'update', 'move', 'reload', 'remove', 'detectLanguage', 'captureVisibleTab', 'executeScript', 'insertCSS', 'setZoom', 'getZoom', 'setZoomSettings', 'getZoomSettings', 'discard'], topSites: ['get'], tts: ['isSpeaking', 'getVoices', 'speak'], types: ['set', 'get', 'clear'], vpnProvider: ['createConfig', 'destroyConfig', 'setParameters', 'sendPacket', 'notifyConnectionStateChanged'], wallpaper: ['setWallpaper'], webNavigation: ['getFrame', 'getAllFrames', 'handlerBehaviorChanged'], windows: ['get', 'getCurrent', 'getLastFocused', 'getAll', 'create', 'update', 'remove'] }); })();