/* global promisify deepCopy */ /* exported msg API */ // deepCopy is only used if the script is executed in extension pages. 'use strict'; const msg = (() => { let isBg = false; if (chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) { isBg = true; window._msg = { id: 1, storage: new Map(), handler: null, clone: deepCopy }; } const runtimeSend = promisify(chrome.runtime.sendMessage.bind(chrome.runtime)); const tabSend = chrome.tabs && promisify(chrome.tabs.sendMessage.bind(chrome.tabs)); const tabQuery = chrome.tabs && promisify(chrome.tabs.query.bind(chrome.tabs)); let bg; const preparing = !isBg && chrome.runtime.getBackgroundPage && promisify(chrome.runtime.getBackgroundPage.bind(chrome.runtime))() .catch(() => null) .then(_bg => { bg = _bg; }); bg = isBg ? window : !preparing ? null : undefined; const EXTENSION_URL = chrome.runtime.getURL(''); let handler; const from_ = location.href.startsWith(EXTENSION_URL) ? 'extension' : 'content'; const RX_NO_RECEIVER = /Receiving end does not exist/; const RX_PORT_CLOSED = /The message port closed before a response was received/; return { send, sendTab, sendBg, broadcast, broadcastTab, broadcastExtension, broadcastError, on, onTab, onExtension, off, RX_NO_RECEIVER, RX_PORT_CLOSED }; function send(data, target = 'extension') { if (bg === undefined) { return preparing.then(() => send(data, target)); } const message = {type: 'direct', data, target, from: from_}; if (bg) { exchangeSet(message); } const request = runtimeSend(message).then(unwrapData); if (message.id) { return withCleanup(request, () => bg._msg.storage.delete(message.id)); } return request; } function sendTab(tabId, data, options, target = 'tab') { return tabSend(tabId, {type: 'direct', data, target, from: from_}, options) .then(unwrapData); } function sendBg(data) { if (bg === undefined) { return preparing.then(doSend); } return withPromiseError(doSend); function doSend() { if (bg) { if (!bg._msg.handler) { throw new Error('there is no bg handler'); } const handlers = bg._msg.handler.extension.concat(bg._msg.handler.both); // in FF, the object would become a dead object when the window // is closed, so we have to clone the object into background. return Promise.resolve(executeCallbacks(handlers, bg._msg.clone(data), {url: location.href})) .then(deepCopy); } return send(data); } } function broadcastError(err) { if (err.message && ( RX_NO_RECEIVER.test(err.message) || RX_PORT_CLOSED.test(err.message) )) { return; } console.warn(err); } function broadcast(data, filter) { return Promise.all([ send(data, 'both').catch(broadcastError), broadcastTab(data, filter, null, true, 'both') ]); } function broadcastTab(data, filter, options, ignoreExtension = false, target = 'tab') { return tabQuery({}) // TODO: send to activated tabs first? .then(tabs => { const requests = []; for (const tab of tabs) { const isExtension = tab.url.startsWith(EXTENSION_URL); if ( tab.discarded || // FIXME: use `URLS.supported`? !/^(http|ftp|file)/.test(tab.url) && !tab.url.startsWith('chrome://newtab/') && !isExtension || isExtension && ignoreExtension || filter && !filter(tab) ) { continue; } const dataObj = typeof data === 'function' ? data(tab) : data; if (!dataObj) { continue; } const message = {type: 'direct', data: dataObj, target, from: from_}; if (isExtension) { exchangeSet(message); } let request = tabSend(tab.id, message, options).then(unwrapData); if (message.id) { request = withCleanup(request, () => bg._msg.storage.delete(message.id)); } requests.push(request.catch(broadcastError)); } return Promise.all(requests); }); } function broadcastExtension(...args) { return send(...args).catch(broadcastError); } function on(fn) { initHandler(); handler.both.push(fn); } function onTab(fn) { initHandler(); handler.tab.push(fn); } function onExtension(fn) { initHandler(); handler.extension.push(fn); } function off(fn) { for (const type of ['both', 'tab', 'extension']) { const index = handler[type].indexOf(fn); if (index >= 0) { handler[type].splice(index, 1); } } } function initHandler() { if (handler) { return; } handler = { both: [], tab: [], extension: [] }; if (isBg) { bg._msg.handler = handler; } chrome.runtime.onMessage.addListener(handleMessage); } function executeCallbacks(callbacks, ...args) { let result; for (const fn of callbacks) { const data = withPromiseError(fn, ...args); if (data !== undefined && result === undefined) { result = data; } } return result; } function handleMessage(message, sender, sendResponse) { const handlers = message.target === 'tab' ? handler.tab.concat(handler.both) : message.target === 'extension' ? handler.extension.concat(handler.both) : handler.both.concat(handler.extension, handler.tab); if (!handlers.length) { return; } if (message.type === 'exchange') { const pending = exchangeGet(message, true); if (pending) { pending.then(response); return true; } } return response(); function response() { const result = executeCallbacks(handlers, message.data, sender); if (result === undefined) { return; } Promise.resolve(result) .then( data => ({ error: false, data }), err => ({ error: true, data: Object.assign({ message: err.message || String(err), // FIXME: do we want to pass the entire stack? stack: err.stack }, err) // this allows us to pass custom properties e.g. `err.index` }) ) .then(function doResponse(responseMessage) { if (message.from === 'extension' && bg === undefined) { return preparing.then(() => doResponse(responseMessage)); } if (message.from === 'extension' && bg) { exchangeSet(responseMessage); } else { responseMessage.type = 'direct'; } return responseMessage; }) .then(sendResponse); return true; } } function exchangeGet(message, keepStorage = false) { if (bg === undefined) { return preparing.then(() => exchangeGet(message, keepStorage)); } message.data = bg._msg.storage.get(message.id); if (keepStorage) { message.data = deepCopy(message.data); } else { bg._msg.storage.delete(message.id); } } function exchangeSet(message) { const id = bg._msg.id; bg._msg.storage.set(id, message.data); bg._msg.id++; message.type = 'exchange'; message.id = id; delete message.data; } function withPromiseError(fn, ...args) { try { return fn(...args); } catch (err) { return Promise.reject(err); } } function withCleanup(p, fn) { return p.then( result => { cleanup(); return result; }, err => { cleanup(); throw err; } ); function cleanup() { try { fn(); } catch (err) { // pass } } } // {type, error, data, id} function unwrapData(result) { if (result === undefined) { throw new Error('Receiving end does not exist'); } if (result.type === 'exchange') { const pending = exchangeGet(result); if (pending) { return pending.then(unwrap); } } return unwrap(); function unwrap() { if (result.error) { throw Object.assign(new Error(result.data.message), result.data); } return result.data; } } })(); const API = new Proxy({}, { get: (target, name) => (...args) => Promise.resolve(msg.sendBg({ method: 'invokeAPI', name, args })) });