diff options
Diffstat (limited to 'ext/js/background')
| -rw-r--r-- | ext/js/background/backend.js | 2053 | ||||
| -rw-r--r-- | ext/js/background/background-main.js | 25 | ||||
| -rw-r--r-- | ext/js/background/profile-conditions.js | 276 | ||||
| -rw-r--r-- | ext/js/background/request-builder.js | 266 | 
4 files changed, 2620 insertions, 0 deletions
| diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js new file mode 100644 index 00000000..3bb23310 --- /dev/null +++ b/ext/js/background/backend.js @@ -0,0 +1,2053 @@ +/* + * Copyright (C) 2016-2021  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiConnect + * AudioDownloader + * ClipboardMonitor + * ClipboardReader + * DictionaryDatabase + * Environment + * JapaneseUtil + * JsonSchemaValidator + * Mecab + * MediaUtility + * ObjectPropertyAccessor + * OptionsUtil + * PermissionsUtil + * ProfileConditions + * RequestBuilder + * Translator + * wanakana + */ + +class Backend { +    constructor() { +        this._japaneseUtil = new JapaneseUtil(wanakana); +        this._environment = new Environment(); +        this._dictionaryDatabase = new DictionaryDatabase(); +        this._translator = new Translator({ +            japaneseUtil: this._japaneseUtil, +            database: this._dictionaryDatabase +        }); +        this._anki = new AnkiConnect(); +        this._mecab = new Mecab(); +        this._mediaUtility = new MediaUtility(); +        this._clipboardReader = new ClipboardReader({ +            // eslint-disable-next-line no-undef +            document: (typeof document === 'object' && document !== null ? document : null), +            pasteTargetSelector: '#clipboard-paste-target', +            imagePasteTargetSelector: '#clipboard-image-paste-target', +            mediaUtility: this._mediaUtility +        }); +        this._clipboardMonitor = new ClipboardMonitor({ +            japaneseUtil: this._japaneseUtil, +            clipboardReader: this._clipboardReader +        }); +        this._options = null; +        this._profileConditionsSchemaValidator = new JsonSchemaValidator(); +        this._profileConditionsSchemaCache = []; +        this._profileConditionsUtil = new ProfileConditions(); +        this._defaultAnkiFieldTemplates = null; +        this._requestBuilder = new RequestBuilder(); +        this._audioDownloader = new AudioDownloader({ +            japaneseUtil: this._japaneseUtil, +            requestBuilder: this._requestBuilder +        }); +        this._optionsUtil = new OptionsUtil(); + +        this._searchPopupTabId = null; +        this._searchPopupTabCreatePromise = null; + +        this._isPrepared = false; +        this._prepareError = false; +        this._preparePromise = null; +        const {promise, resolve, reject} = deferPromise(); +        this._prepareCompletePromise = promise; +        this._prepareCompleteResolve = resolve; +        this._prepareCompleteReject = reject; + +        this._defaultBrowserActionTitle = null; +        this._badgePrepareDelayTimer = null; +        this._logErrorLevel = null; +        this._permissions = null; +        this._permissionsUtil = new PermissionsUtil(); + +        this._messageHandlers = new Map([ +            ['requestBackendReadySignal',    {async: false, contentScript: true,  handler: this._onApiRequestBackendReadySignal.bind(this)}], +            ['optionsGet',                   {async: false, contentScript: true,  handler: this._onApiOptionsGet.bind(this)}], +            ['optionsGetFull',               {async: false, contentScript: true,  handler: this._onApiOptionsGetFull.bind(this)}], +            ['kanjiFind',                    {async: true,  contentScript: true,  handler: this._onApiKanjiFind.bind(this)}], +            ['termsFind',                    {async: true,  contentScript: true,  handler: this._onApiTermsFind.bind(this)}], +            ['textParse',                    {async: true,  contentScript: true,  handler: this._onApiTextParse.bind(this)}], +            ['getAnkiConnectVersion',        {async: true,  contentScript: true,  handler: this._onApGetAnkiConnectVersion.bind(this)}], +            ['isAnkiConnected',              {async: true,  contentScript: true,  handler: this._onApiIsAnkiConnected.bind(this)}], +            ['addAnkiNote',                  {async: true,  contentScript: true,  handler: this._onApiAddAnkiNote.bind(this)}], +            ['getAnkiNoteInfo',              {async: true,  contentScript: true,  handler: this._onApiGetAnkiNoteInfo.bind(this)}], +            ['injectAnkiNoteMedia',          {async: true,  contentScript: true,  handler: this._onApiInjectAnkiNoteMedia.bind(this)}], +            ['noteView',                     {async: true,  contentScript: true,  handler: this._onApiNoteView.bind(this)}], +            ['suspendAnkiCardsForNote',      {async: true,  contentScript: true,  handler: this._onApiSuspendAnkiCardsForNote.bind(this)}], +            ['commandExec',                  {async: false, contentScript: true,  handler: this._onApiCommandExec.bind(this)}], +            ['getExpressionAudioInfoList',   {async: true,  contentScript: true,  handler: this._onApiGetExpressionAudioInfoList.bind(this)}], +            ['sendMessageToFrame',           {async: false, contentScript: true,  handler: this._onApiSendMessageToFrame.bind(this)}], +            ['broadcastTab',                 {async: false, contentScript: true,  handler: this._onApiBroadcastTab.bind(this)}], +            ['frameInformationGet',          {async: true,  contentScript: true,  handler: this._onApiFrameInformationGet.bind(this)}], +            ['injectStylesheet',             {async: true,  contentScript: true,  handler: this._onApiInjectStylesheet.bind(this)}], +            ['getStylesheetContent',         {async: true,  contentScript: true,  handler: this._onApiGetStylesheetContent.bind(this)}], +            ['getEnvironmentInfo',           {async: false, contentScript: true,  handler: this._onApiGetEnvironmentInfo.bind(this)}], +            ['clipboardGet',                 {async: true,  contentScript: true,  handler: this._onApiClipboardGet.bind(this)}], +            ['getDisplayTemplatesHtml',      {async: true,  contentScript: true,  handler: this._onApiGetDisplayTemplatesHtml.bind(this)}], +            ['getZoom',                      {async: true,  contentScript: true,  handler: this._onApiGetZoom.bind(this)}], +            ['getDefaultAnkiFieldTemplates', {async: false, contentScript: true,  handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this)}], +            ['getDictionaryInfo',            {async: true,  contentScript: false, handler: this._onApiGetDictionaryInfo.bind(this)}], +            ['getDictionaryCounts',          {async: true,  contentScript: false, handler: this._onApiGetDictionaryCounts.bind(this)}], +            ['purgeDatabase',                {async: true,  contentScript: false, handler: this._onApiPurgeDatabase.bind(this)}], +            ['getMedia',                     {async: true,  contentScript: true,  handler: this._onApiGetMedia.bind(this)}], +            ['log',                          {async: false, contentScript: true,  handler: this._onApiLog.bind(this)}], +            ['logIndicatorClear',            {async: false, contentScript: true,  handler: this._onApiLogIndicatorClear.bind(this)}], +            ['createActionPort',             {async: false, contentScript: true,  handler: this._onApiCreateActionPort.bind(this)}], +            ['modifySettings',               {async: true,  contentScript: true,  handler: this._onApiModifySettings.bind(this)}], +            ['getSettings',                  {async: false, contentScript: true,  handler: this._onApiGetSettings.bind(this)}], +            ['setAllSettings',               {async: true,  contentScript: false, handler: this._onApiSetAllSettings.bind(this)}], +            ['getOrCreateSearchPopup',       {async: true,  contentScript: true,  handler: this._onApiGetOrCreateSearchPopup.bind(this)}], +            ['isTabSearchPopup',             {async: true,  contentScript: true,  handler: this._onApiIsTabSearchPopup.bind(this)}], +            ['triggerDatabaseUpdated',       {async: false, contentScript: true,  handler: this._onApiTriggerDatabaseUpdated.bind(this)}], +            ['testMecab',                    {async: true,  contentScript: true,  handler: this._onApiTestMecab.bind(this)}] +        ]); +        this._messageHandlersWithProgress = new Map([ +        ]); + +        this._commandHandlers = new Map([ +            ['toggleTextScanning', this._onCommandToggleTextScanning.bind(this)], +            ['openInfoPage',       this._onCommandOpenInfoPage.bind(this)], +            ['openSettingsPage',   this._onCommandOpenSettingsPage.bind(this)], +            ['openSearchPage',     this._onCommandOpenSearchPage.bind(this)], +            ['openPopupWindow',    this._onCommandOpenPopupWindow.bind(this)] +        ]); +    } + +    prepare() { +        if (this._preparePromise === null) { +            const promise = this._prepareInternal(); +            promise.then( +                (value) => { +                    this._isPrepared = true; +                    this._prepareCompleteResolve(value); +                }, +                (error) => { +                    this._prepareError = true; +                    this._prepareCompleteReject(error); +                } +            ); +            promise.finally(() => this._updateBadge()); +            this._preparePromise = promise; +        } +        return this._prepareCompletePromise; +    } + +    // Private + +    _prepareInternalSync() { +        if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { +            const onCommand = this._onWebExtensionEventWrapper(this._onCommand.bind(this)); +            chrome.commands.onCommand.addListener(onCommand); +        } + +        if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { +            const onZoomChange = this._onWebExtensionEventWrapper(this._onZoomChange.bind(this)); +            chrome.tabs.onZoomChange.addListener(onZoomChange); +        } + +        const onConnect = this._onWebExtensionEventWrapper(this._onConnect.bind(this)); +        chrome.runtime.onConnect.addListener(onConnect); + +        const onMessage = this._onMessageWrapper.bind(this); +        chrome.runtime.onMessage.addListener(onMessage); + +        const onPermissionsChanged = this._onWebExtensionEventWrapper(this._onPermissionsChanged.bind(this)); +        chrome.permissions.onAdded.addListener(onPermissionsChanged); +        chrome.permissions.onRemoved.addListener(onPermissionsChanged); +    } + +    async _prepareInternal() { +        try { +            this._prepareInternalSync(); + +            this._permissions = await this._permissionsUtil.getAllPermissions(); +            this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); +            this._badgePrepareDelayTimer = setTimeout(() => { +                this._badgePrepareDelayTimer = null; +                this._updateBadge(); +            }, 1000); +            this._updateBadge(); + +            yomichan.on('log', this._onLog.bind(this)); + +            await this._requestBuilder.prepare(); +            await this._environment.prepare(); +            this._clipboardReader.browser = this._environment.getInfo().browser; + +            try { +                await this._dictionaryDatabase.prepare(); +            } catch (e) { +                yomichan.logError(e); +            } + +            const deinflectionReasions = await this._fetchAsset('/data/deinflect.json', true); +            this._translator.prepare(deinflectionReasions); + +            await this._optionsUtil.prepare(); +            this._defaultAnkiFieldTemplates = (await this._fetchAsset('/data/templates/default-anki-field-templates.handlebars')).trim(); +            this._options = await this._optionsUtil.load(); + +            this._applyOptions('background'); + +            const options = this._getProfileOptions({current: true}); +            if (options.general.showGuide) { +                this._openWelcomeGuidePage(); +            } + +            this._clipboardMonitor.on('change', this._onClipboardTextChange.bind(this)); + +            this._sendMessageAllTabsIgnoreResponse('backendReady', {}); +            this._sendMessageIgnoreResponse({action: 'backendReady', params: {}}); +        } catch (e) { +            yomichan.logError(e); +            throw e; +        } finally { +            if (this._badgePrepareDelayTimer !== null) { +                clearTimeout(this._badgePrepareDelayTimer); +                this._badgePrepareDelayTimer = null; +            } +        } +    } + +    // Event handlers + +    async _onClipboardTextChange({text}) { +        const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}); +        if (text.length > maximumSearchLength) { +            text = text.substring(0, maximumSearchLength); +        } +        try { +            const {tab, created} = await this._getOrCreateSearchPopup(); +            await this._focusTab(tab); +            await this._updateSearchQuery(tab.id, text, !created); +        } catch (e) { +            // NOP +        } +    } + +    _onLog({level}) { +        const levelValue = this._getErrorLevelValue(level); +        if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } + +        this._logErrorLevel = level; +        this._updateBadge(); +    } + +    // WebExtension event handlers (with prepared checks) + +    _onWebExtensionEventWrapper(handler) { +        return (...args) => { +            if (this._isPrepared) { +                handler(...args); +                return; +            } + +            this._prepareCompletePromise.then( +                () => { handler(...args); }, +                () => {} // NOP +            ); +        }; +    } + +    _onMessageWrapper(message, sender, sendResponse) { +        if (this._isPrepared) { +            return this._onMessage(message, sender, sendResponse); +        } + +        this._prepareCompletePromise.then( +            () => { this._onMessage(message, sender, sendResponse); }, +            () => { sendResponse(); } +        ); +        return true; +    } + +    // WebExtension event handlers + +    _onCommand(command) { +        this._runCommand(command); +    } + +    _onMessage({action, params}, sender, callback) { +        const messageHandler = this._messageHandlers.get(action); +        if (typeof messageHandler === 'undefined') { return false; } + +        if (!messageHandler.contentScript) { +            try { +                this._validatePrivilegedMessageSender(sender); +            } catch (error) { +                callback({error: serializeError(error)}); +                return false; +            } +        } + +        return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); +    } + +    _onConnect(port) { +        try { +            let details; +            try { +                details = JSON.parse(port.name); +            } catch (e) { +                return; +            } +            if (details.name !== 'background-cross-frame-communication-port') { return; } + +            const senderTabId = (port.sender && port.sender.tab ? port.sender.tab.id : null); +            if (typeof senderTabId !== 'number') { +                throw new Error('Port does not have an associated tab ID'); +            } +            const senderFrameId = port.sender.frameId; +            if (typeof senderFrameId !== 'number') { +                throw new Error('Port does not have an associated frame ID'); +            } +            let {targetTabId, targetFrameId} = details; +            if (typeof targetTabId !== 'number') { +                targetTabId = senderTabId; +            } + +            const details2 = { +                name: 'cross-frame-communication-port', +                sourceTabId: senderTabId, +                sourceFrameId: senderFrameId +            }; +            let forwardPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(details2)}); + +            const cleanup = () => { +                this._checkLastError(chrome.runtime.lastError); +                if (forwardPort !== null) { +                    forwardPort.disconnect(); +                    forwardPort = null; +                } +                if (port !== null) { +                    port.disconnect(); +                    port = null; +                } +            }; + +            port.onMessage.addListener((message) => { forwardPort.postMessage(message); }); +            forwardPort.onMessage.addListener((message) => { port.postMessage(message); }); +            port.onDisconnect.addListener(cleanup); +            forwardPort.onDisconnect.addListener(cleanup); +        } catch (e) { +            port.disconnect(); +            yomichan.logError(e); +        } +    } + +    _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { +        this._sendMessageTabIgnoreResponse(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}); +    } + +    _onPermissionsChanged() { +        this._checkPermissions(); +    } + +    // Message handlers + +    _onApiRequestBackendReadySignal(_params, sender) { +        // tab ID isn't set in background (e.g. browser_action) +        const data = {action: 'backendReady', params: {}}; +        if (typeof sender.tab === 'undefined') { +            this._sendMessageIgnoreResponse(data); +            return false; +        } else { +            this._sendMessageTabIgnoreResponse(sender.tab.id, data); +            return true; +        } +    } + +    _onApiOptionsGet({optionsContext}) { +        return this._getProfileOptions(optionsContext); +    } + +    _onApiOptionsGetFull() { +        return this._getOptionsFull(); +    } + +    async _onApiKanjiFind({text, optionsContext}) { +        const options = this._getProfileOptions(optionsContext); +        const {general: {maxResults}} = options; +        const findKanjiOptions = this._getTranslatorFindKanjiOptions(options); +        const definitions = await this._translator.findKanji(text, findKanjiOptions); +        definitions.splice(maxResults); +        return definitions; +    } + +    async _onApiTermsFind({text, details, optionsContext}) { +        const options = this._getProfileOptions(optionsContext); +        const {general: {resultOutputMode: mode, maxResults}} = options; +        const findTermsOptions = this._getTranslatorFindTermsOptions(details, options); +        const [definitions, length] = await this._translator.findTerms(mode, text, findTermsOptions); +        definitions.splice(maxResults); +        return {length, definitions}; +    } + +    async _onApiTextParse({text, optionsContext}) { +        const options = this._getProfileOptions(optionsContext); +        const results = []; + +        if (options.parsing.enableScanningParser) { +            results.push({ +                source: 'scanning-parser', +                id: 'scan', +                content: await this._textParseScanning(text, options) +            }); +        } + +        if (options.parsing.enableMecabParser) { +            const mecabResults = await this._textParseMecab(text, options); +            for (const [mecabDictName, mecabDictResults] of mecabResults) { +                results.push({ +                    source: 'mecab', +                    dictionary: mecabDictName, +                    id: `mecab-${mecabDictName}`, +                    content: mecabDictResults +                }); +            } +        } + +        return results; +    } + +    async _onApGetAnkiConnectVersion() { +        return await this._anki.getVersion(); +    } + +    async _onApiIsAnkiConnected() { +        return await this._anki.isConnected(); +    } + +    async _onApiAddAnkiNote({note}) { +        return await this._anki.addNote(note); +    } + +    async _onApiGetAnkiNoteInfo({notes}) { +        const results = []; +        const cannotAdd = []; +        const canAddArray = await this._anki.canAddNotes(notes); + +        for (let i = 0; i < notes.length; ++i) { +            const note = notes[i]; +            const canAdd = canAddArray[i]; +            const info = {canAdd, noteIds: null}; +            results.push(info); +            if (!canAdd) { +                cannotAdd.push({note, info}); +            } +        } + +        if (cannotAdd.length > 0) { +            const cannotAddNotes = cannotAdd.map(({note}) => note); +            const noteIdsArray = await this._anki.findNoteIds(cannotAddNotes); +            for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) { +                const noteIds = noteIdsArray[i]; +                if (noteIds.length > 0) { +                    cannotAdd[i].info.noteIds = noteIds; +                } +            } +        } + +        return results; +    } + +    async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails}) { +        return await this._injectAnkNoteMedia( +            this._anki, +            timestamp, +            definitionDetails, +            audioDetails, +            screenshotDetails, +            clipboardDetails +        ); +    } + +    async _onApiNoteView({noteId}) { +        return await this._anki.guiBrowseNote(noteId); +    } + +    async _onApiSuspendAnkiCardsForNote({noteId}) { +        const cardIds = await this._anki.findCardsForNote(noteId); +        const count = cardIds.length; +        if (count > 0) { +            const okay = await this._anki.suspendCards(cardIds); +            if (!okay) { return 0; } +        } +        return count; +    } + +    _onApiCommandExec({command, params}) { +        return this._runCommand(command, params); +    } + +    async _onApiGetExpressionAudioInfoList({source, expression, reading, details}) { +        return await this._audioDownloader.getExpressionAudioInfoList(source, expression, reading, details); +    } + +    _onApiSendMessageToFrame({frameId: targetFrameId, action, params}, sender) { +        if (!(sender && sender.tab)) { +            return false; +        } + +        const tabId = sender.tab.id; +        const frameId = sender.frameId; +        this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}, {frameId: targetFrameId}); +        return true; +    } + +    _onApiBroadcastTab({action, params}, sender) { +        if (!(sender && sender.tab)) { +            return false; +        } + +        const tabId = sender.tab.id; +        const frameId = sender.frameId; +        this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}); +        return true; +    } + +    _onApiFrameInformationGet(params, sender) { +        const tab = sender.tab; +        const tabId = tab ? tab.id : void 0; +        const frameId = sender.frameId; +        return Promise.resolve({tabId, frameId}); +    } + +    _onApiInjectStylesheet({type, value}, sender) { +        return this._injectStylesheet(type, value, sender); +    } + +    async _onApiGetStylesheetContent({url}) { +        if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { +            throw new Error('Invalid URL'); +        } +        return await this._fetchAsset(url); +    } + +    _onApiGetEnvironmentInfo() { +        return this._environment.getInfo(); +    } + +    async _onApiClipboardGet() { +        return this._clipboardReader.getText(); +    } + +    async _onApiGetDisplayTemplatesHtml() { +        return await this._fetchAsset('/display-templates.html'); +    } + +    _onApiGetZoom(params, sender) { +        if (!sender || !sender.tab) { +            return Promise.reject(new Error('Invalid tab')); +        } + +        return new Promise((resolve, reject) => { +            const tabId = sender.tab.id; +            if (!( +                chrome.tabs !== null && +                typeof chrome.tabs === 'object' && +                typeof chrome.tabs.getZoom === 'function' +            )) { +                // Not supported +                resolve({zoomFactor: 1.0}); +                return; +            } +            chrome.tabs.getZoom(tabId, (zoomFactor) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve({zoomFactor}); +                } +            }); +        }); +    } + +    _onApiGetDefaultAnkiFieldTemplates() { +        return this._defaultAnkiFieldTemplates; +    } + +    async _onApiGetDictionaryInfo() { +        return await this._dictionaryDatabase.getDictionaryInfo(); +    } + +    async _onApiGetDictionaryCounts({dictionaryNames, getTotal}) { +        return await this._dictionaryDatabase.getDictionaryCounts(dictionaryNames, getTotal); +    } + +    async _onApiPurgeDatabase() { +        await this._dictionaryDatabase.purge(); +        this._triggerDatabaseUpdated('dictionary', 'purge'); +    } + +    async _onApiGetMedia({targets}) { +        return await this._dictionaryDatabase.getMedia(targets); +    } + +    _onApiLog({error, level, context}) { +        yomichan.log(deserializeError(error), level, context); +    } + +    _onApiLogIndicatorClear() { +        if (this._logErrorLevel === null) { return; } +        this._logErrorLevel = null; +        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 = generateId(16); +        const details = { +            name: 'action-port', +            id +        }; + +        const port = chrome.tabs.connect(tabId, {name: JSON.stringify(details), frameId}); +        try { +            this._createActionListenerPort(port, sender, this._messageHandlersWithProgress); +        } catch (e) { +            port.disconnect(); +            throw e; +        } + +        return details; +    } + +    _onApiModifySettings({targets, source}) { +        return this._modifySettings(targets, source); +    } + +    _onApiGetSettings({targets}) { +        const results = []; +        for (const target of targets) { +            try { +                const result = this._getSetting(target); +                results.push({result: clone(result)}); +            } catch (e) { +                results.push({error: serializeError(e)}); +            } +        } +        return results; +    } + +    async _onApiSetAllSettings({value, source}) { +        this._optionsUtil.validate(value); +        this._options = clone(value); +        await this._saveOptions(source); +    } + +    async _onApiGetOrCreateSearchPopup({focus=false, text=null}) { +        const {tab, created} = await this._getOrCreateSearchPopup(); +        if (focus === true || (focus === 'ifCreated' && created)) { +            await this._focusTab(tab); +        } +        if (typeof text === 'string') { +            await this._updateSearchQuery(tab.id, text, !created); +        } +        return {tabId: tab.id, windowId: tab.windowId}; +    } + +    async _onApiIsTabSearchPopup({tabId}) { +        const baseUrl = chrome.runtime.getURL('/search.html'); +        const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url.startsWith(baseUrl)) : null; +        return (tab !== null); +    } + +    _onApiTriggerDatabaseUpdated({type, cause}) { +        this._triggerDatabaseUpdated(type, cause); +    } + +    async _onApiTestMecab() { +        if (!this._mecab.isEnabled()) { +            throw new Error('MeCab not enabled'); +        } + +        let permissionsOkay = false; +        try { +            permissionsOkay = await this._permissionsUtil.hasPermissions({permissions: ['nativeMessaging']}); +        } catch (e) { +            // NOP +        } +        if (!permissionsOkay) { +            throw new Error('Insufficient permissions'); +        } + +        const disconnect = !this._mecab.isConnected(); +        try { +            const version = await this._mecab.getVersion(); +            if (version === null) { +                throw new Error('Could not connect to native MeCab component'); +            } + +            const localVersion = this._mecab.getLocalVersion(); +            if (version !== localVersion) { +                throw new Error(`MeCab component version not supported: ${version}`); +            } +        } finally { +            // Disconnect if the connection was previously disconnected +            if (disconnect && this._mecab.isEnabled() && this._mecab.isActive()) { +                this._mecab.disconnect(); +            } +        } + +        return true; +    } + +    // Command handlers + +    async _onCommandOpenSearchPage(params) { +        const {mode='existingOrNewTab', query} = params || {}; + +        const baseUrl = chrome.runtime.getURL('/search.html'); +        const queryParams = {}; +        if (query && query.length > 0) { queryParams.query = query; } +        const queryString = new URLSearchParams(queryParams).toString(); +        let url = baseUrl; +        if (queryString.length > 0) { +            url += `?${queryString}`; +        } + +        const predicate = ({url: url2}) => { +            if (url2 === null || !url2.startsWith(baseUrl)) { return false; } +            const parsedUrl = new URL(url2); +            const baseUrl2 = `${parsedUrl.origin}${parsedUrl.pathname}`; +            const mode2 = parsedUrl.searchParams.get('mode'); +            return baseUrl2 === baseUrl && (mode2 === mode || (!mode2 && mode === 'existingOrNewTab')); +        }; + +        const openInTab = async () => { +            const tab = await this._findTabs(1000, false, predicate, false); +            if (tab !== null) { +                await this._focusTab(tab); +                if (queryParams.query) { +                    await this._updateSearchQuery(tab.id, queryParams.query, true); +                } +                return true; +            } +        }; + +        switch (mode) { +            case 'existingOrNewTab': +                try { +                    if (await openInTab()) { return; } +                } catch (e) { +                    // NOP +                } +                await this._createTab(url); +                return; +            case 'newTab': +                await this._createTab(url); +                return; +        } +    } + +    async _onCommandOpenInfoPage() { +        await this._openInfoPage(); +    } + +    async _onCommandOpenSettingsPage(params) { +        const {mode='existingOrNewTab'} = params || {}; +        await this._openSettingsPage(mode); +    } + +    async _onCommandToggleTextScanning() { +        const options = this._getProfileOptions({current: true}); +        await this._modifySettings([{ +            action: 'set', +            path: 'general.enable', +            value: !options.general.enable, +            scope: 'profile', +            optionsContext: {current: true} +        }], 'backend'); +    } + +    async _onCommandOpenPopupWindow() { +        await this._onApiGetOrCreateSearchPopup({focus: true}); +    } + +    // Utilities + +    async _modifySettings(targets, source) { +        const results = []; +        for (const target of targets) { +            try { +                const result = this._modifySetting(target); +                results.push({result: clone(result)}); +            } catch (e) { +                results.push({error: serializeError(e)}); +            } +        } +        await this._saveOptions(source); +        return results; +    } + +    _getOrCreateSearchPopup() { +        if (this._searchPopupTabCreatePromise === null) { +            const promise = this._getOrCreateSearchPopup2(); +            this._searchPopupTabCreatePromise = promise; +            promise.then(() => { this._searchPopupTabCreatePromise = null; }); +        } +        return this._searchPopupTabCreatePromise; +    } + +    async _getOrCreateSearchPopup2() { +        // Use existing tab +        const baseUrl = chrome.runtime.getURL('/search.html'); +        const urlPredicate = (url) => url !== null && url.startsWith(baseUrl); +        if (this._searchPopupTabId !== null) { +            const tab = await this._checkTabUrl(this._searchPopupTabId, urlPredicate); +            if (tab !== null) { +                return {tab, created: false}; +            } +            this._searchPopupTabId = null; +        } + +        // Find existing tab +        const existingTabInfo = await this._findSearchPopupTab(urlPredicate); +        if (existingTabInfo !== null) { +            const existingTab = existingTabInfo.tab; +            this._searchPopupTabId = existingTab.id; +            return {tab: existingTab, created: false}; +        } + +        // chrome.windows not supported (e.g. on Firefox mobile) +        if (!isObject(chrome.windows)) { +            throw new Error('Window creation not supported'); +        } + +        // Create a new window +        const options = this._getProfileOptions({current: true}); +        const createData = this._getSearchPopupWindowCreateData(baseUrl, options); +        const {popupWindow: {windowState}} = options; +        const popupWindow = await this._createWindow(createData); +        if (windowState !== 'normal') { +            await this._updateWindow(popupWindow.id, {state: windowState}); +        } + +        const {tabs} = popupWindow; +        if (tabs.length === 0) { +            throw new Error('Created window did not contain a tab'); +        } + +        const tab = tabs[0]; +        await this._waitUntilTabFrameIsReady(tab.id, 0, 2000); + +        await this._sendMessageTabPromise( +            tab.id, +            {action: 'setMode', params: {mode: 'popup'}}, +            {frameId: 0} +        ); + +        this._searchPopupTabId = tab.id; +        return {tab, created: true}; +    } + +    async _findSearchPopupTab(urlPredicate) { +        const predicate = async ({url, tab}) => { +            if (!urlPredicate(url)) { return false; } +            try { +                const mode = await this._sendMessageTabPromise( +                    tab.id, +                    {action: 'getMode', params: {}}, +                    {frameId: 0} +                ); +                return mode === 'popup'; +            } catch (e) { +                return false; +            } +        }; +        return await this._findTabs(1000, false, predicate, true); +    } + +    _getSearchPopupWindowCreateData(url, options) { +        const {popupWindow: {width, height, left, top, useLeft, useTop, windowType}} = options; +        return { +            url, +            width, +            height, +            left: useLeft ? left : void 0, +            top: useTop ? top : void 0, +            type: windowType, +            state: 'normal' +        }; +    } + +    _createWindow(createData) { +        return new Promise((resolve, reject) => { +            chrome.windows.create( +                createData, +                (result) => { +                    const error = chrome.runtime.lastError; +                    if (error) { +                        reject(new Error(error.message)); +                    } else { +                        resolve(result); +                    } +                } +            ); +        }); +    } + +    _updateWindow(windowId, updateInfo) { +        return new Promise((resolve, reject) => { +            chrome.windows.update( +                windowId, +                updateInfo, +                (result) => { +                    const error = chrome.runtime.lastError; +                    if (error) { +                        reject(new Error(error.message)); +                    } else { +                        resolve(result); +                    } +                } +            ); +        }); +    } + +    _updateSearchQuery(tabId, text, animate) { +        return this._sendMessageTabPromise( +            tabId, +            {action: 'updateSearchQuery', params: {text, animate}}, +            {frameId: 0} +        ); +    } + +    _applyOptions(source) { +        const options = this._getProfileOptions({current: true}); +        this._updateBadge(); + +        this._anki.server = options.anki.server; +        this._anki.enabled = options.anki.enable; + +        this._mecab.setEnabled(options.parsing.enableMecabParser); + +        if (options.clipboard.enableBackgroundMonitor) { +            this._clipboardMonitor.start(); +        } else { +            this._clipboardMonitor.stop(); +        } + +        this._sendMessageAllTabsIgnoreResponse('optionsUpdated', {source}); +    } + +    _getOptionsFull(useSchema=false) { +        const options = this._options; +        return useSchema ? this._optionsUtil.createValidatingProxy(options) : options; +    } + +    _getProfileOptions(optionsContext, useSchema=false) { +        return this._getProfile(optionsContext, useSchema).options; +    } + +    _getProfile(optionsContext, useSchema=false) { +        const options = this._getOptionsFull(useSchema); +        const profiles = options.profiles; +        if (optionsContext.current) { +            return profiles[options.profileCurrent]; +        } +        if (typeof optionsContext.index === 'number') { +            return profiles[optionsContext.index]; +        } +        const profile = this._getProfileFromContext(options, optionsContext); +        return profile !== null ? profile : profiles[options.profileCurrent]; +    } + +    _getProfileFromContext(options, optionsContext) { +        optionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); + +        let index = 0; +        for (const profile of options.profiles) { +            const conditionGroups = profile.conditionGroups; + +            let schema; +            if (index < this._profileConditionsSchemaCache.length) { +                schema = this._profileConditionsSchemaCache[index]; +            } else { +                schema = this._profileConditionsUtil.createSchema(conditionGroups); +                this._profileConditionsSchemaCache.push(schema); +            } + +            if (conditionGroups.length > 0 && this._profileConditionsSchemaValidator.isValid(optionsContext, schema)) { +                return profile; +            } +            ++index; +        } + +        return null; +    } + +    _clearProfileConditionsSchemaCache() { +        this._profileConditionsSchemaCache = []; +        this._profileConditionsSchemaValidator.clearCache(); +    } + +    _checkLastError() { +        // NOP +    } + +    _runCommand(command, params) { +        const handler = this._commandHandlers.get(command); +        if (typeof handler !== 'function') { return false; } + +        handler(params); +        return true; +    } + +    async _textParseScanning(text, options) { +        const jp = this._japaneseUtil; +        const {scanning: {length: scanningLength}, parsing: {readingMode}} = options; +        const findTermsOptions = this._getTranslatorFindTermsOptions({wildcard: null}, options); +        const results = []; +        while (text.length > 0) { +            const term = []; +            const [definitions, sourceLength] = await this._translator.findTerms( +                'simple', +                text.substring(0, scanningLength), +                findTermsOptions +            ); +            if (definitions.length > 0 && sourceLength > 0) { +                const {expression, reading} = definitions[0]; +                const source = text.substring(0, sourceLength); +                for (const {text: text2, furigana} of jp.distributeFuriganaInflected(expression, reading, source)) { +                    const reading2 = jp.convertReading(text2, furigana, readingMode); +                    term.push({text: text2, reading: reading2}); +                } +                text = text.substring(source.length); +            } else { +                const reading = jp.convertReading(text[0], '', readingMode); +                term.push({text: text[0], reading}); +                text = text.substring(1); +            } +            results.push(term); +        } +        return results; +    } + +    async _textParseMecab(text, options) { +        const jp = this._japaneseUtil; +        const {parsing: {readingMode}} = options; + +        let parseTextResults; +        try { +            parseTextResults = await this._mecab.parseText(text); +        } catch (e) { +            return []; +        } + +        const results = []; +        for (const {name, lines} of parseTextResults) { +            const result = []; +            for (const line of lines) { +                for (const {expression, reading, source} of line) { +                    const term = []; +                    for (const {text: text2, furigana} of jp.distributeFuriganaInflected( +                        expression.length > 0 ? expression : source, +                        jp.convertKatakanaToHiragana(reading), +                        source +                    )) { +                        const reading2 = jp.convertReading(text2, furigana, readingMode); +                        term.push({text: text2, reading: reading2}); +                    } +                    result.push(term); +                } +                result.push([{text: '\n', reading: ''}]); +            } +            results.push([name, result]); +        } +        return results; +    } + +    _createActionListenerPort(port, sender, handlers) { +        let hasStarted = false; +        let messageString = ''; + +        const onProgress = (...data) => { +            try { +                if (port === null) { return; } +                port.postMessage({type: 'progress', data}); +            } catch (e) { +                // NOP +            } +        }; + +        const onMessage = (message) => { +            if (hasStarted) { return; } + +            try { +                const {action, data} = message; +                switch (action) { +                    case 'fragment': +                        messageString += data; +                        break; +                    case 'invoke': +                        { +                            hasStarted = true; +                            port.onMessage.removeListener(onMessage); + +                            const messageData = JSON.parse(messageString); +                            messageString = null; +                            onMessageComplete(messageData); +                        } +                        break; +                } +            } catch (e) { +                cleanup(e); +            } +        }; + +        const onMessageComplete = async (message) => { +            try { +                const {action, params} = message; +                port.postMessage({type: 'ack'}); + +                const messageHandler = handlers.get(action); +                if (typeof messageHandler === 'undefined') { +                    throw new Error('Invalid action'); +                } +                const {handler, async, contentScript} = messageHandler; + +                if (!contentScript) { +                    this._validatePrivilegedMessageSender(sender); +                } + +                const promiseOrResult = handler(params, sender, onProgress); +                const result = async ? await promiseOrResult : promiseOrResult; +                port.postMessage({type: 'complete', data: result}); +            } catch (e) { +                cleanup(e); +            } +        }; + +        const onDisconnect = () => { +            cleanup(null); +        }; + +        const cleanup = (error) => { +            if (port === null) { return; } +            if (error !== null) { +                port.postMessage({type: 'error', data: serializeError(error)}); +            } +            if (!hasStarted) { +                port.onMessage.removeListener(onMessage); +            } +            port.onDisconnect.removeListener(onDisconnect); +            port = null; +            handlers = null; +        }; + +        port.onMessage.addListener(onMessage); +        port.onDisconnect.addListener(onDisconnect); +    } + +    _getErrorLevelValue(errorLevel) { +        switch (errorLevel) { +            case 'info': return 0; +            case 'debug': return 0; +            case 'warn': return 1; +            case 'error': return 2; +            default: return 0; +        } +    } + +    _getModifySettingObject(target) { +        const scope = target.scope; +        switch (scope) { +            case 'profile': +                if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); } +                return this._getProfileOptions(target.optionsContext, true); +            case 'global': +                return this._getOptionsFull(true); +            default: +                throw new Error(`Invalid scope: ${scope}`); +        } +    } + +    _getSetting(target) { +        const options = this._getModifySettingObject(target); +        const accessor = new ObjectPropertyAccessor(options); +        const {path} = target; +        if (typeof path !== 'string') { throw new Error('Invalid path'); } +        return accessor.get(ObjectPropertyAccessor.getPathArray(path)); +    } + +    _modifySetting(target) { +        const options = this._getModifySettingObject(target); +        const accessor = new ObjectPropertyAccessor(options); +        const action = target.action; +        switch (action) { +            case 'set': +            { +                const {path, value} = target; +                if (typeof path !== 'string') { throw new Error('Invalid path'); } +                const pathArray = ObjectPropertyAccessor.getPathArray(path); +                accessor.set(pathArray, value); +                return accessor.get(pathArray); +            } +            case 'delete': +            { +                const {path} = target; +                if (typeof path !== 'string') { throw new Error('Invalid path'); } +                accessor.delete(ObjectPropertyAccessor.getPathArray(path)); +                return true; +            } +            case 'swap': +            { +                const {path1, path2} = target; +                if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } +                if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } +                accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); +                return true; +            } +            case 'splice': +            { +                const {path, start, deleteCount, items} = target; +                if (typeof path !== 'string') { throw new Error('Invalid path'); } +                if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } +                if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } +                if (!Array.isArray(items)) { throw new Error('Invalid items'); } +                const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); +                if (!Array.isArray(array)) { throw new Error('Invalid target type'); } +                return array.splice(start, deleteCount, ...items); +            } +            default: +                throw new Error(`Unknown action: ${action}`); +        } +    } + +    _validatePrivilegedMessageSender(sender) { +        const url = sender.url; +        if (!(typeof url === 'string' && yomichan.isExtensionUrl(url))) { +            throw new Error('Invalid message sender'); +        } +    } + +    _getBrowserIconTitle() { +        return ( +            isObject(chrome.browserAction) && +            typeof chrome.browserAction.getTitle === 'function' ? +                new Promise((resolve) => chrome.browserAction.getTitle({}, resolve)) : +                Promise.resolve('') +        ); +    } + +    _updateBadge() { +        let title = this._defaultBrowserActionTitle; +        if (title === null || !isObject(chrome.browserAction)) { +            // Not ready or invalid +            return; +        } + +        let text = ''; +        let color = null; +        let status = null; + +        if (this._logErrorLevel !== null) { +            switch (this._logErrorLevel) { +                case 'error': +                    text = '!!'; +                    color = '#f04e4e'; +                    status = 'Error'; +                    break; +                default: // 'warn' +                    text = '!'; +                    color = '#f0ad4e'; +                    status = 'Warning'; +                    break; +            } +        } else if (!this._isPrepared) { +            if (this._prepareError) { +                text = '!!'; +                color = '#f04e4e'; +                status = 'Error'; +            } else if (this._badgePrepareDelayTimer === null) { +                text = '...'; +                color = '#f0ad4e'; +                status = 'Loading'; +            } +        } else { +            const options = this._getProfileOptions({current: true}); +            if (!options.general.enable) { +                text = 'off'; +                color = '#555555'; +                status = 'Disabled'; +            } else if (!this._hasRequiredPermissionsForSettings(options)) { +                text = '!'; +                color = '#f0ad4e'; +                status = 'Some settings require additional permissions'; +            } else if (!this._isAnyDictionaryEnabled(options)) { +                text = '!'; +                color = '#f0ad4e'; +                status = 'No dictionaries installed'; +            } +        } + +        if (color !== null && typeof chrome.browserAction.setBadgeBackgroundColor === 'function') { +            chrome.browserAction.setBadgeBackgroundColor({color}); +        } +        if (text !== null && typeof chrome.browserAction.setBadgeText === 'function') { +            chrome.browserAction.setBadgeText({text}); +        } +        if (typeof chrome.browserAction.setTitle === 'function') { +            if (status !== null) { +                title = `${title} - ${status}`; +            } +            chrome.browserAction.setTitle({title}); +        } +    } + +    _isAnyDictionaryEnabled(options) { +        for (const {enabled} of Object.values(options.dictionaries)) { +            if (enabled) { +                return true; +            } +        } +        return false; +    } + +    _anyOptionsMatches(predicate) { +        for (const {options} of this._options.profiles) { +            const value = predicate(options); +            if (value) { return value; } +        } +        return false; +    } + +    async _getTabUrl(tabId) { +        try { +            const {url} = await this._sendMessageTabPromise( +                tabId, +                {action: 'getUrl', params: {}}, +                {frameId: 0} +            ); +            if (typeof url === 'string') { +                return url; +            } +        } catch (e) { +            // NOP +        } +        return null; +    } + +    _getAllTabs() { +        return new Promise((resolve, reject) => { +            chrome.tabs.query({}, (tabs) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(tabs); +                } +            }); +        }); +    } + +    async _findTabs(timeout, multiple, predicate, predicateIsAsync) { +        // This function works around the need to have the "tabs" permission to access tab.url. +        const tabs = await this._getAllTabs(); + +        let done = false; +        const checkTab = async (tab, add) => { +            const url = await this._getTabUrl(tab.id); + +            if (done) { return; } + +            let okay = false; +            const item = {tab, url}; +            try { +                okay = predicate(item); +                if (predicateIsAsync) { okay = await okay; } +            } catch (e) { +                // NOP +            } + +            if (okay && !done) { +                if (add(item)) { +                    done = true; +                } +            } +        }; + +        if (multiple) { +            const results = []; +            const add = (value) => { +                results.push(value); +                return false; +            }; +            const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); +            await Promise.race([ +                Promise.all(checkTabPromises), +                promiseTimeout(timeout) +            ]); +            return results; +        } else { +            const {promise, resolve} = deferPromise(); +            let result = null; +            const add = (value) => { +                result = value; +                resolve(); +                return true; +            }; +            const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); +            await Promise.race([ +                promise, +                Promise.all(checkTabPromises), +                promiseTimeout(timeout) +            ]); +            resolve(); +            return result; +        } +    } + +    async _focusTab(tab) { +        await new Promise((resolve, reject) => { +            chrome.tabs.update(tab.id, {active: true}, () => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(); +                } +            }); +        }); + +        if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { +            // Windows not supported (e.g. on Firefox mobile) +            return; +        } + +        try { +            const tabWindow = await new Promise((resolve, reject) => { +                chrome.windows.get(tab.windowId, {}, (value) => { +                    const e = chrome.runtime.lastError; +                    if (e) { +                        reject(new Error(e.message)); +                    } else { +                        resolve(value); +                    } +                }); +            }); +            if (!tabWindow.focused) { +                await new Promise((resolve, reject) => { +                    chrome.windows.update(tab.windowId, {focused: true}, () => { +                        const e = chrome.runtime.lastError; +                        if (e) { +                            reject(new Error(e.message)); +                        } else { +                            resolve(); +                        } +                    }); +                }); +            } +        } catch (e) { +            // Edge throws exception for no reason here. +        } +    } + +    _waitUntilTabFrameIsReady(tabId, frameId, timeout=null) { +        return new Promise((resolve, reject) => { +            let timer = null; +            let onMessage = (message, sender) => { +                if ( +                    !sender.tab || +                    sender.tab.id !== tabId || +                    sender.frameId !== frameId || +                    !isObject(message) || +                    message.action !== 'yomichanReady' +                ) { +                    return; +                } + +                cleanup(); +                resolve(); +            }; +            const cleanup = () => { +                if (timer !== null) { +                    clearTimeout(timer); +                    timer = null; +                } +                if (onMessage !== null) { +                    chrome.runtime.onMessage.removeListener(onMessage); +                    onMessage = null; +                } +            }; + +            chrome.runtime.onMessage.addListener(onMessage); + +            this._sendMessageTabPromise(tabId, {action: 'isReady'}, {frameId}) +                .then( +                    (value) => { +                        if (!value) { return; } +                        cleanup(); +                        resolve(); +                    }, +                    () => {} // NOP +                ); + +            if (timeout !== null) { +                timer = setTimeout(() => { +                    timer = null; +                    cleanup(); +                    reject(new Error('Timeout')); +                }, timeout); +            } +        }); +    } + +    async _fetchAsset(url, json=false) { +        const response = await fetch(chrome.runtime.getURL(url), { +            method: 'GET', +            mode: 'no-cors', +            cache: 'default', +            credentials: 'omit', +            redirect: 'follow', +            referrerPolicy: 'no-referrer' +        }); +        if (!response.ok) { +            throw new Error(`Failed to fetch ${url}: ${response.status}`); +        } +        return await (json ? response.json() : response.text()); +    } + +    _sendMessageIgnoreResponse(...args) { +        const callback = () => this._checkLastError(chrome.runtime.lastError); +        chrome.runtime.sendMessage(...args, callback); +    } + +    _sendMessageTabIgnoreResponse(...args) { +        const callback = () => this._checkLastError(chrome.runtime.lastError); +        chrome.tabs.sendMessage(...args, callback); +    } + +    _sendMessageAllTabsIgnoreResponse(action, params) { +        const callback = () => this._checkLastError(chrome.runtime.lastError); +        chrome.tabs.query({}, (tabs) => { +            for (const tab of tabs) { +                chrome.tabs.sendMessage(tab.id, {action, params}, callback); +            } +        }); +    } + +    _sendMessageTabPromise(...args) { +        return new Promise((resolve, reject) => { +            const callback = (response) => { +                try { +                    resolve(yomichan.getMessageResponseResult(response)); +                } catch (error) { +                    reject(error); +                } +            }; + +            chrome.tabs.sendMessage(...args, callback); +        }); +    } + +    async _checkTabUrl(tabId, urlPredicate) { +        let tab; +        try { +            tab = await this._getTabById(tabId); +        } catch (e) { +            return null; +        } + +        const url = await this._getTabUrl(tabId); +        const isValidTab = urlPredicate(url); +        return isValidTab ? tab : null; +    } + +    async _getScreenshot(tabId, frameId, format, quality) { +        const tab = await this._getTabById(tabId); +        const {windowId} = tab; + +        let token = null; +        try { +            if (typeof tabId === 'number' && typeof frameId === 'number') { +                const action = 'setAllVisibleOverride'; +                const params = {value: false, priority: 0, awaitFrame: true}; +                token = await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); +            } + +            return await new Promise((resolve, reject) => { +                chrome.tabs.captureVisibleTab(windowId, {format, quality}, (result) => { +                    const e = chrome.runtime.lastError; +                    if (e) { +                        reject(new Error(e.message)); +                    } else { +                        resolve(result); +                    } +                }); +            }); +        } finally { +            if (token !== null) { +                const action = 'clearAllVisibleOverride'; +                const params = {token}; +                try { +                    await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); +                } catch (e) { +                    // NOP +                } +            } +        } +    } + +    async _downloadDefinitionAudio(sources, expression, reading, details) { +        return await this._audioDownloader.downloadExpressionAudio(sources, expression, reading, details); +    } + +    async _injectAnkNoteMedia(ankiConnect, timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails) { +        let screenshotFileName = null; +        let clipboardImageFileName = null; +        let clipboardText = null; +        let audioFileName = null; +        const errors = []; + +        try { +            if (screenshotDetails !== null) { +                screenshotFileName = await this._injectAnkNoteScreenshot(ankiConnect, timestamp, definitionDetails, screenshotDetails); +            } +        } catch (e) { +            errors.push(serializeError(e)); +        } + +        try { +            if (clipboardDetails !== null && clipboardDetails.image) { +                clipboardImageFileName = await this._injectAnkNoteClipboardImage(ankiConnect, timestamp, definitionDetails); +            } +        } catch (e) { +            errors.push(serializeError(e)); +        } + +        try { +            if (clipboardDetails !== null && clipboardDetails.text) { +                clipboardText = await this._clipboardReader.getText(); +            } +        } catch (e) { +            errors.push(serializeError(e)); +        } + +        try { +            if (audioDetails !== null) { +                audioFileName = await this._injectAnkNoteAudio(ankiConnect, timestamp, definitionDetails, audioDetails); +            } +        } catch (e) { +            errors.push(serializeError(e)); +        } + +        return { +            result: { +                screenshotFileName, +                clipboardImageFileName, +                clipboardText, +                audioFileName +            }, +            errors +        }; +    } + +    async _injectAnkNoteAudio(ankiConnect, timestamp, definitionDetails, details) { +        const {type, expression, reading} = definitionDetails; +        if ( +            type === 'kanji' || +            typeof expression !== 'string' || +            typeof reading !== 'string' || +            (expression.length === 0 && reading.length === 0) +        ) { +            return null; +        } + +        const {sources, customSourceUrl, customSourceType} = details; +        let data; +        let contentType; +        try { +            ({data, contentType} = await this._downloadDefinitionAudio( +                sources, +                expression, +                reading, +                { +                    textToSpeechVoice: null, +                    customSourceUrl, +                    customSourceType, +                    binary: true, +                    disableCache: true +                } +            )); +        } catch (e) { +            // No audio +            return null; +        } + +        let extension = this._mediaUtility.getFileExtensionFromAudioMediaType(contentType); +        if (extension === null) { extension = '.mp3'; } +        let fileName = this._generateAnkiNoteMediaFileName('yomichan_audio', extension, timestamp, definitionDetails); +        fileName = fileName.replace(/\]/g, ''); +        await ankiConnect.storeMediaFile(fileName, data); + +        return fileName; +    } + +    async _injectAnkNoteScreenshot(ankiConnect, timestamp, definitionDetails, details) { +        const {tabId, frameId, format, quality} = details; +        const dataUrl = await this._getScreenshot(tabId, frameId, format, quality); + +        const {mediaType, data} = this._getDataUrlInfo(dataUrl); +        const extension = this._mediaUtility.getFileExtensionFromImageMediaType(mediaType); +        if (extension === null) { +            throw new Error('Unknown media type for screenshot image'); +        } + +        const fileName = this._generateAnkiNoteMediaFileName('yomichan_browser_screenshot', extension, timestamp, definitionDetails); +        await ankiConnect.storeMediaFile(fileName, data); + +        return fileName; +    } + +    async _injectAnkNoteClipboardImage(ankiConnect, timestamp, definitionDetails) { +        const dataUrl = await this._clipboardReader.getImage(); +        if (dataUrl === null) { +            return null; +        } + +        const {mediaType, data} = this._getDataUrlInfo(dataUrl); +        const extension = this._mediaUtility.getFileExtensionFromImageMediaType(mediaType); +        if (extension === null) { +            throw new Error('Unknown media type for clipboard image'); +        } + +        const fileName = this._generateAnkiNoteMediaFileName('yomichan_clipboard_image', extension, timestamp, definitionDetails); +        await ankiConnect.storeMediaFile(fileName, data); + +        return fileName; +    } + +    _generateAnkiNoteMediaFileName(prefix, extension, timestamp, definitionDetails) { +        let fileName = prefix; + +        switch (definitionDetails.type) { +            case 'kanji': +                { +                    const {character} = definitionDetails; +                    if (character) { fileName += `_${character}`; } +                } +                break; +            default: +                { +                    const {reading, expression} = definitionDetails; +                    if (reading) { fileName += `_${reading}`; } +                    if (expression) { fileName += `_${expression}`; } +                } +                break; +        } + +        fileName += `_${this._ankNoteDateToString(new Date(timestamp))}`; +        fileName += extension; + +        fileName = this._replaceInvalidFileNameCharacters(fileName); + +        return fileName; +    } + +    _replaceInvalidFileNameCharacters(fileName) { +        // eslint-disable-next-line no-control-regex +        return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-'); +    } + +    _ankNoteDateToString(date) { +        const year = date.getUTCFullYear(); +        const month = date.getUTCMonth().toString().padStart(2, '0'); +        const day = date.getUTCDate().toString().padStart(2, '0'); +        const hours = date.getUTCHours().toString().padStart(2, '0'); +        const minutes = date.getUTCMinutes().toString().padStart(2, '0'); +        const seconds = date.getUTCSeconds().toString().padStart(2, '0'); +        return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; +    } + +    _getDataUrlInfo(dataUrl) { +        const match = /^data:([^,]*?)(;base64)?,/.exec(dataUrl); +        if (match === null) { +            throw new Error('Invalid data URL'); +        } + +        let mediaType = match[1]; +        if (mediaType.length === 0) { mediaType = 'text/plain'; } + +        let data = dataUrl.substring(match[0].length); +        if (typeof match[2] === 'undefined') { data = btoa(data); } + +        return {mediaType, data}; +    } + +    _triggerDatabaseUpdated(type, cause) { +        this._translator.clearDatabaseCaches(); +        this._sendMessageAllTabsIgnoreResponse('databaseUpdated', {type, cause}); +    } + +    async _saveOptions(source) { +        this._clearProfileConditionsSchemaCache(); +        const options = this._getOptionsFull(); +        await this._optionsUtil.save(options); +        this._applyOptions(source); +    } + +    _getTranslatorFindTermsOptions(details, options) { +        const {wildcard} = details; +        const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); +        const { +            general: {mainDictionary}, +            scanning: {alphanumeric}, +            translation: { +                convertHalfWidthCharacters, +                convertNumericCharacters, +                convertAlphabeticCharacters, +                convertHiraganaToKatakana, +                convertKatakanaToHiragana, +                collapseEmphaticSequences, +                textReplacements: textReplacementsOptions +            } +        } = options; +        const textReplacements = this._getTranslatorTextReplacements(textReplacementsOptions); +        return { +            wildcard, +            mainDictionary, +            alphanumeric, +            convertHalfWidthCharacters, +            convertNumericCharacters, +            convertAlphabeticCharacters, +            convertHiraganaToKatakana, +            convertKatakanaToHiragana, +            collapseEmphaticSequences, +            textReplacements, +            enabledDictionaryMap +        }; +    } + +    _getTranslatorFindKanjiOptions(options) { +        const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); +        return {enabledDictionaryMap}; +    } + +    _getTranslatorEnabledDictionaryMap(options) { +        const enabledDictionaryMap = new Map(); +        for (const [title, {enabled, priority, allowSecondarySearches}] of Object.entries(options.dictionaries)) { +            if (!enabled) { continue; } +            enabledDictionaryMap.set(title, {priority, allowSecondarySearches}); +        } +        return enabledDictionaryMap; +    } + +    _getTranslatorTextReplacements(textReplacementsOptions) { +        const textReplacements = []; +        for (const group of textReplacementsOptions.groups) { +            const textReplacementsEntries = []; +            for (let {pattern, ignoreCase, replacement} of group) { +                try { +                    pattern = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); +                } catch (e) { +                    // Invalid pattern +                    continue; +                } +                textReplacementsEntries.push({pattern, replacement}); +            } +            if (textReplacementsEntries.length > 0) { +                textReplacements.push(textReplacementsEntries); +            } +        } +        if (textReplacements.length === 0 || textReplacementsOptions.searchOriginal) { +            textReplacements.unshift(null); +        } +        return textReplacements; +    } + +    async _openWelcomeGuidePage() { +        await this._createTab(chrome.runtime.getURL('/welcome.html')); +    } + +    async _openInfoPage() { +        await this._createTab(chrome.runtime.getURL('/info.html')); +    } + +    async _openSettingsPage(mode) { +        const {useSettingsV2} = this._options.global; +        const manifest = chrome.runtime.getManifest(); +        const url = chrome.runtime.getURL(useSettingsV2 ? manifest.options_ui.page : '/settings-old.html'); +        switch (mode) { +            case 'existingOrNewTab': +                if (useSettingsV2) { +                    const predicate = ({url: url2}) => (url2 !== null && url2.startsWith(url)); +                    const tab = await this._findTabs(1000, false, predicate, false); +                    if (tab !== null) { +                        await this._focusTab(tab); +                    } else { +                        await this._createTab(url); +                    } +                } else { +                    await new Promise((resolve, reject) => { +                        chrome.runtime.openOptionsPage(() => { +                            const e = chrome.runtime.lastError; +                            if (e) { +                                reject(new Error(e.message)); +                            } else { +                                resolve(); +                            } +                        }); +                    }); +                } +                break; +            case 'newTab': +                await this._createTab(url); +                break; +        } +    } + +    _createTab(url) { +        return new Promise((resolve, reject) => { +            chrome.tabs.create({url}, (tab) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(tab); +                } +            }); +        }); +    } + +    _injectStylesheet(type, value, target) { +        if (isObject(chrome.tabs) && typeof chrome.tabs.insertCSS === 'function') { +            return this._injectStylesheetMV2(type, value, target); +        } else if (isObject(chrome.scripting) && typeof chrome.scripting.insertCSS === 'function') { +            return this._injectStylesheetMV3(type, value, target); +        } else { +            return Promise.reject(new Error('insertCSS function not available')); +        } +    } + +    _injectStylesheetMV2(type, value, target) { +        return new Promise((resolve, reject) => { +            if (!target.tab) { +                reject(new Error('Invalid tab')); +                return; +            } + +            const tabId = target.tab.id; +            const frameId = target.frameId; +            const details = ( +                type === 'file' ? +                { +                    file: value, +                    runAt: 'document_start', +                    cssOrigin: 'author', +                    allFrames: false, +                    matchAboutBlank: true +                } : +                { +                    code: value, +                    runAt: 'document_start', +                    cssOrigin: 'user', +                    allFrames: false, +                    matchAboutBlank: true +                } +            ); +            if (typeof frameId === 'number') { +                details.frameId = frameId; +            } + +            chrome.tabs.insertCSS(tabId, details, () => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(); +                } +            }); +        }); +    } + +    _injectStylesheetMV3(type, value, target) { +        return new Promise((resolve, reject) => { +            if (!target.tab) { +                reject(new Error('Invalid tab')); +                return; +            } + +            const tabId = target.tab.id; +            const frameId = target.frameId; +            const details = ( +                type === 'file' ? +                {origin: chrome.scripting.StyleOrigin.AUTHOR, files: [value]} : +                {origin: chrome.scripting.StyleOrigin.USER,   css: value} +            ); +            details.target = { +                tabId, +                allFrames: false +            }; +            if (typeof frameId === 'number') { +                details.target.frameIds = [frameId]; +            } + +            chrome.scripting.insertCSS(details, () => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(); +                } +            }); +        }); +    } + +    _getTabById(tabId) { +        return new Promise((resolve, reject) => { +            chrome.tabs.get( +                tabId, +                (result) => { +                    const e = chrome.runtime.lastError; +                    if (e) { +                        reject(new Error(e.message)); +                    } else { +                        resolve(result); +                    } +                } +            ); +        }); +    } + +    async _checkPermissions() { +        this._permissions = await this._permissionsUtil.getAllPermissions(); +        this._updateBadge(); +    } + +    _hasRequiredPermissionsForSettings(options) { +        return this._permissions === null || this._permissionsUtil.hasRequiredPermissionsForOptions(this._permissions, options); +    } +} diff --git a/ext/js/background/background-main.js b/ext/js/background/background-main.js new file mode 100644 index 00000000..01e57d0f --- /dev/null +++ b/ext/js/background/background-main.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020-2021  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * Backend + */ + +(() => { +    const backend = new Backend(); +    backend.prepare(); +})(); diff --git a/ext/js/background/profile-conditions.js b/ext/js/background/profile-conditions.js new file mode 100644 index 00000000..8e6c7163 --- /dev/null +++ b/ext/js/background/profile-conditions.js @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2020-2021  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +/** + * Utility class to help processing profile conditions. + */ +class ProfileConditions { +    /** +     * Creates a new instance. +     */ +    constructor() { +        this._splitPattern = /[,;\s]+/; +        this._descriptors = new Map([ +            [ +                'popupLevel', +                { +                    operators: new Map([ +                        ['equal',              this._createSchemaPopupLevelEqual.bind(this)], +                        ['notEqual',           this._createSchemaPopupLevelNotEqual.bind(this)], +                        ['lessThan',           this._createSchemaPopupLevelLessThan.bind(this)], +                        ['greaterThan',        this._createSchemaPopupLevelGreaterThan.bind(this)], +                        ['lessThanOrEqual',    this._createSchemaPopupLevelLessThanOrEqual.bind(this)], +                        ['greaterThanOrEqual', this._createSchemaPopupLevelGreaterThanOrEqual.bind(this)] +                    ]) +                } +            ], +            [ +                'url', +                { +                    operators: new Map([ +                        ['matchDomain', this._createSchemaUrlMatchDomain.bind(this)], +                        ['matchRegExp', this._createSchemaUrlMatchRegExp.bind(this)] +                    ]) +                } +            ], +            [ +                'modifierKeys', +                { +                    operators: new Map([ +                        ['are', this._createSchemaModifierKeysAre.bind(this)], +                        ['areNot', this._createSchemaModifierKeysAreNot.bind(this)], +                        ['include', this._createSchemaModifierKeysInclude.bind(this)], +                        ['notInclude', this._createSchemaModifierKeysNotInclude.bind(this)] +                    ]) +                } +            ] +        ]); +    } + +    /** +     * Creates a new JSON schema descriptor for the given set of condition groups. +     * @param conditionGroups An array of condition groups in the following format: +     *  conditionGroups = [ +     *      { +     *          conditions: [ +     *              { +     *                  type: (condition type: string), +     *                  operator: (condition sub-type: string), +     *                  value: (value to compare against: string) +     *              }, +     *              ... +     *          ] +     *      }, +     *      ... +     *  ] +     */ +    createSchema(conditionGroups) { +        const anyOf = []; +        for (const {conditions} of conditionGroups) { +            const allOf = []; +            for (const {type, operator, value} of conditions) { +                const conditionDescriptor = this._descriptors.get(type); +                if (typeof conditionDescriptor === 'undefined') { continue; } + +                const createSchema = conditionDescriptor.operators.get(operator); +                if (typeof createSchema === 'undefined') { continue; } + +                const schema = createSchema(value); +                allOf.push(schema); +            } +            switch (allOf.length) { +                case 0: break; +                case 1: anyOf.push(allOf[0]); break; +                default: anyOf.push({allOf}); break; +            } +        } +        switch (anyOf.length) { +            case 0: return {}; +            case 1: return anyOf[0]; +            default: return {anyOf}; +        } +    } + +    /** +     * Creates a normalized version of the context object to test, +     * assigning dependent fields as needed. +     * @param context A context object which is used during schema validation. +     * @returns A normalized context object. +     */ +    normalizeContext(context) { +        const normalizedContext = Object.assign({}, context); +        const {url} = normalizedContext; +        if (typeof url === 'string') { +            try { +                normalizedContext.domain = new URL(url).hostname; +            } catch (e) { +                // NOP +            } +        } +        return normalizedContext; +    } + +    // Private + +    _split(value) { +        return value.split(this._splitPattern); +    } + +    _stringToNumber(value) { +        const number = Number.parseFloat(value); +        return Number.isFinite(number) ? number : 0; +    } + +    // popupLevel schema creation functions + +    _createSchemaPopupLevelEqual(value) { +        value = this._stringToNumber(value); +        return { +            required: ['depth'], +            properties: { +                depth: {const: value} +            } +        }; +    } + +    _createSchemaPopupLevelNotEqual(value) { +        return { +            not: [this._createSchemaPopupLevelEqual(value)] +        }; +    } + +    _createSchemaPopupLevelLessThan(value) { +        value = this._stringToNumber(value); +        return { +            required: ['depth'], +            properties: { +                depth: {type: 'number', exclusiveMaximum: value} +            } +        }; +    } + +    _createSchemaPopupLevelGreaterThan(value) { +        value = this._stringToNumber(value); +        return { +            required: ['depth'], +            properties: { +                depth: {type: 'number', exclusiveMinimum: value} +            } +        }; +    } + +    _createSchemaPopupLevelLessThanOrEqual(value) { +        value = this._stringToNumber(value); +        return { +            required: ['depth'], +            properties: { +                depth: {type: 'number', maximum: value} +            } +        }; +    } + +    _createSchemaPopupLevelGreaterThanOrEqual(value) { +        value = this._stringToNumber(value); +        return { +            required: ['depth'], +            properties: { +                depth: {type: 'number', minimum: value} +            } +        }; +    } + +    // url schema creation functions + +    _createSchemaUrlMatchDomain(value) { +        const oneOf = []; +        for (let domain of this._split(value)) { +            if (domain.length === 0) { continue; } +            domain = domain.toLowerCase(); +            oneOf.push({const: domain}); +        } +        return { +            required: ['domain'], +            properties: { +                domain: {oneOf} +            } +        }; +    } + +    _createSchemaUrlMatchRegExp(value) { +        return { +            required: ['url'], +            properties: { +                url: {type: 'string', pattern: value, patternFlags: 'i'} +            } +        }; +    } + +    // modifierKeys schema creation functions + +    _createSchemaModifierKeysAre(value) { +        return this._createSchemaModifierKeysGeneric(value, true, false); +    } + +    _createSchemaModifierKeysAreNot(value) { +        return { +            not: [this._createSchemaModifierKeysGeneric(value, true, false)] +        }; +    } + +    _createSchemaModifierKeysInclude(value) { +        return this._createSchemaModifierKeysGeneric(value, false, false); +    } + +    _createSchemaModifierKeysNotInclude(value) { +        return this._createSchemaModifierKeysGeneric(value, false, true); +    } + +    _createSchemaModifierKeysGeneric(value, exact, none) { +        const containsList = []; +        for (const modifierKey of this._split(value)) { +            if (modifierKey.length === 0) { continue; } +            containsList.push({ +                contains: { +                    const: modifierKey +                } +            }); +        } +        const containsListCount = containsList.length; +        const modifierKeysSchema = { +            type: 'array' +        }; +        if (exact) { +            modifierKeysSchema.maxItems = containsListCount; +        } +        if (none) { +            if (containsListCount > 0) { +                modifierKeysSchema.not = containsList; +            } +        } else { +            modifierKeysSchema.minItems = containsListCount; +            if (containsListCount > 0) { +                modifierKeysSchema.allOf = containsList; +            } +        } +        return { +            required: ['modifierKeys'], +            properties: { +                modifierKeys: modifierKeysSchema +            } +        }; +    } +} diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js new file mode 100644 index 00000000..dda5825d --- /dev/null +++ b/ext/js/background/request-builder.js @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2020-2021  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +class RequestBuilder { +    constructor() { +        this._extraHeadersSupported = null; +        this._onBeforeSendHeadersExtraInfoSpec = ['blocking', 'requestHeaders', 'extraHeaders']; +        this._textEncoder = new TextEncoder(); +        this._ruleIds = new Set(); +    } + +    async prepare() { +        try { +            await this._clearDynamicRules(); +        } catch (e) { +            // NOP +        } +    } + +    async fetchAnonymous(url, init) { +        if (isObject(chrome.declarativeNetRequest)) { +            return await this._fetchAnonymousDeclarative(url, init); +        } +        const originURL = this._getOriginURL(url); +        const modifications = [ +            ['cookie', null], +            ['origin', {name: 'Origin', value: originURL}] +        ]; +        return await this._fetchModifyHeaders(url, init, modifications); +    } + +    // Private + +    async _fetchModifyHeaders(url, init, modifications) { +        const matchURL = this._getMatchURL(url); + +        let done = false; +        const callback = (details) => { +            if (done || details.url !== url) { return {}; } +            done = true; + +            const requestHeaders = details.requestHeaders; +            this._modifyHeaders(requestHeaders, modifications); +            return {requestHeaders}; +        }; +        const filter = { +            urls: [matchURL], +            types: ['xmlhttprequest'] +        }; + +        let needsCleanup = false; +        try { +            this._onBeforeSendHeadersAddListener(callback, filter); +            needsCleanup = true; +        } catch (e) { +            // NOP +        } + +        try { +            return await fetch(url, init); +        } finally { +            if (needsCleanup) { +                try { +                    chrome.webRequest.onBeforeSendHeaders.removeListener(callback); +                } catch (e) { +                    // NOP +                } +            } +        } +    } + +    _onBeforeSendHeadersAddListener(callback, filter) { +        const extraInfoSpec = this._onBeforeSendHeadersExtraInfoSpec; +        for (let i = 0; i < 2; ++i) { +            try { +                chrome.webRequest.onBeforeSendHeaders.addListener(callback, filter, extraInfoSpec); +                if (this._extraHeadersSupported === null) { +                    this._extraHeadersSupported = true; +                } +                break; +            } catch (e) { +                // Firefox doesn't support the 'extraHeaders' option and will throw the following error: +                // Type error for parameter extraInfoSpec (Error processing 2: Invalid enumeration value "extraHeaders") for webRequest.onBeforeSendHeaders. +                if (this._extraHeadersSupported !== null || !`${e.message}`.includes('extraHeaders')) { +                    throw e; +                } +            } + +            // addListener failed; remove 'extraHeaders' from extraInfoSpec. +            this._extraHeadersSupported = false; +            const index = extraInfoSpec.indexOf('extraHeaders'); +            if (index >= 0) { extraInfoSpec.splice(index, 1); } +        } +    } + +    _getMatchURL(url) { +        const url2 = new URL(url); +        return `${url2.protocol}//${url2.host}${url2.pathname}`; +    } + +    _getOriginURL(url) { +        const url2 = new URL(url); +        return `${url2.protocol}//${url2.host}`; +    } + +    _modifyHeaders(headers, modifications) { +        modifications = new Map(modifications); + +        for (let i = 0, ii = headers.length; i < ii; ++i) { +            const header = headers[i]; +            const name = header.name.toLowerCase(); +            const modification = modifications.get(name); +            if (typeof modification === 'undefined') { continue; } + +            modifications.delete(name); + +            if (modification === null) { +                headers.splice(i, 1); +                --i; +                --ii; +            } else { +                headers[i] = modification; +            } +        } + +        for (const header of modifications.values()) { +            if (header !== null) { +                headers.push(header); +            } +        } +    } + +    async _clearDynamicRules() { +        if (!isObject(chrome.declarativeNetRequest)) { return; } + +        const rules = this._getDynamicRules(); + +        if (rules.length === 0) { return; } + +        const removeRuleIds = []; +        for (const {id} of rules) { +            removeRuleIds.push(id); +        } + +        await this._updateDynamicRules({removeRuleIds}); +    } + +    async _fetchAnonymousDeclarative(url, init) { +        const id = this._getNewRuleId(); +        const originUrl = this._getOriginURL(url); +        url = encodeURI(decodeURI(url)); + +        this._ruleIds.add(id); +        try { +            const addRules = [{ +                id, +                priority: 1, +                condition: { +                    urlFilter: `|${this._escapeDnrUrl(url)}|`, +                    resourceTypes: ['xmlhttprequest'] +                }, +                action: { +                    type: 'modifyHeaders', +                    requestHeaders: [ +                        { +                            operation: 'remove', +                            header: 'Cookie' +                        }, +                        { +                            operation: 'set', +                            header: 'Origin', +                            value: originUrl +                        } +                    ], +                    responseHeaders: [ +                        { +                            operation: 'remove', +                            header: 'Set-Cookie' +                        } +                    ] +                } +            }]; + +            await this._updateDynamicRules({addRules}); +            try { +                return await fetch(url, init); +            } finally { +                await this._tryUpdateDynamicRules({removeRuleIds: [id]}); +            } +        } finally { +            this._ruleIds.delete(id); +        } +    } + +    _getDynamicRules() { +        return new Promise((resolve, reject) => { +            chrome.declarativeNetRequest.getDynamicRules((result) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(result); +                } +            }); +        }); +    } + +    _updateDynamicRules(options) { +        return new Promise((resolve, reject) => { +            chrome.declarativeNetRequest.updateDynamicRules(options, () => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(); +                } +            }); +        }); +    } + +    async _tryUpdateDynamicRules(options) { +        try { +            await this._updateDynamicRules(options); +            return true; +        } catch (e) { +            return false; +        } +    } + +    _getNewRuleId() { +        let id = 1; +        while (this._ruleIds.has(id)) { +            const pre = id; +            ++id; +            if (id === pre) { throw new Error('Could not generate an id'); } +        } +        return id; +    } + +    _escapeDnrUrl(url) { +        return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char)); +    } + +    _urlEncodeUtf8(text) { +        const array = this._textEncoder.encode(text); +        let result = ''; +        for (const byte of array) { +            result += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; +        } +        return result; +    } +} |