diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 2fce4be9..ed01c8df 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -116,8 +116,10 @@ class Backend { ['purgeDatabase', {handler: this._onApiPurgeDatabase.bind(this), async: true}], ['getMedia', {handler: this._onApiGetMedia.bind(this), async: true}], ['log', {handler: this._onApiLog.bind(this), async: false}], - ['logIndicatorClear', {handler: this._onApiLogIndicatorClear.bind(this), async: false}] + ['logIndicatorClear', {handler: this._onApiLogIndicatorClear.bind(this), async: false}], + ['createActionPort', {handler: this._onApiCreateActionPort.bind(this), async: false}] ]); + this._messageHandlersWithProgress = new Map(); this._commandHandlers = new Map([ ['search', this._onCommandSearch.bind(this)], @@ -787,8 +789,79 @@ class Backend { this._updateBadge(); } + _onApiCreateActionPort(params, sender) { + if (!sender || !sender.tab) { throw new Error('Invalid sender'); } + const tabId = sender.tab.id; + if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); } + + const frameId = sender.frameId; + const id = yomichan.generateId(16); + const portName = `action-port-${id}`; + + const port = chrome.tabs.connect(tabId, {name: portName, frameId}); + try { + this._createActionListenerPort(port, sender, this._messageHandlersWithProgress); + } catch (e) { + port.disconnect(); + throw e; + } + + return portName; + } + // Command handlers + _createActionListenerPort(port, sender, handlers) { + let hasStarted = false; + + const onProgress = (data) => { + try { + if (port === null) { return; } + port.postMessage({type: 'progress', data}); + } catch (e) { + // NOP + } + }; + + const onMessage = async ({action, params}) => { + if (hasStarted) { return; } + hasStarted = true; + port.onMessage.removeListener(onMessage); + + try { + port.postMessage({type: 'ack'}); + + const messageHandler = handlers.get(action); + if (typeof messageHandler === 'undefined') { + throw new Error('Invalid action'); + } + const {handler, async} = messageHandler; + + const promiseOrResult = handler(params, sender, onProgress); + const result = async ? await promiseOrResult : promiseOrResult; + port.postMessage({type: 'complete', data: result}); + } catch (e) { + if (port !== null) { + port.postMessage({type: 'error', data: e}); + } + cleanup(); + } + }; + + const cleanup = () => { + if (port === null) { return; } + if (!hasStarted) { + port.onMessage.removeListener(onMessage); + } + port.onDisconnect.removeListener(cleanup); + port = null; + handlers = null; + }; + + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(cleanup); + } + _getErrorLevelValue(errorLevel) { switch (errorLevel) { case 'info': return 0; diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index afd68aa2..bf85338e 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -152,6 +152,124 @@ function apiLogIndicatorClear() { return _apiInvoke('logIndicatorClear'); } +function _apiCreateActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let portNameResolve; + let portNameReject; + const portNamePromise = new Promise((resolve2, reject2) => { + portNameResolve = resolve2; + portNameReject = reject2; + }); + + const onConnect = async (port) => { + try { + const portName = await portNamePromise; + if (port.name !== portName || timer === null) { return; } + } catch (e) { + return; + } + + clearTimeout(timer); + timer = null; + + chrome.runtime.onConnect.removeListener(onConnect); + resolve(port); + }; + + const onError = (e) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + chrome.runtime.onConnect.removeListener(onConnect); + portNameReject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + _apiInvoke('createActionPort').then(portNameResolve, onError); + }); +} + +function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + case 'ack': + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + break; + case 'progress': + try { + onProgress(message.data); + } catch (e) { + // NOP + } + break; + case 'complete': + cleanup(); + resolve(message.data); + break; + case 'error': + cleanup(); + reject(jsonToError(message.data)); + break; + } + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + (async () => { + try { + port = await _apiCreateActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + port.postMessage({action, params}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; + } + })(); + }); +} + function _apiInvoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => {