/* global BG: true, onRuntimeMessage, applyOnMessage, handleUpdate, handleDelete */ 'use strict'; // keep message channel open for sendResponse in chrome.runtime.onMessage listener const KEEP_CHANNEL_OPEN = true; const FIREFOX = /Firefox/.test(navigator.userAgent); const OPERA = /OPR/.test(navigator.userAgent); const URLS = { ownOrigin: chrome.runtime.getURL(''), optionsUI: [ chrome.runtime.getURL('options.html'), 'chrome://extensions/?options=' + chrome.runtime.id, ], configureCommands: OPERA ? 'opera://settings/configureCommands' : 'chrome://extensions/configureCommands', // CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL // https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc browserWebStore: FIREFOX ? 'https://addons.mozilla.org/' : OPERA ? 'https://addons.opera.com/' : 'https://chrome.google.com/webstore/', // Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/ // TODO: remove when "minimum_chrome_version": "61" or higher chromeProtectsNTP: parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) >= 3161, supported: null, }; URLS.supported = new RegExp( '^(file|ftps?|http)://|' + `^https://(?!${ URLS.browserWebStore.split('://')[1].replace(/\./g, '\\.') })|` + (URLS.chromeProtectsNTP ? '^chrome://(?!newtab)/|' : '') + '^' + chrome.runtime.getURL('') ); let BG = chrome.extension.getBackgroundPage(); if (BG && !BG.getStyles && BG !== window) { // own page like editor/manage is being loaded on browser startup // before the background page has been fully initialized; // it'll be resolved in onBackgroundReady() instead BG = null; } if (!BG || BG !== window) { document.documentElement.classList.toggle('firefox', FIREFOX); document.documentElement.classList.toggle('opera', OPERA); // TODO: remove once our manifest's minimum_chrome_version is 50+ // Chrome 49 doesn't report own extension pages in webNavigation apparently if (navigator.userAgent.includes('Chrome/49.')) { getActiveTab().then(BG.updateIcon); } } function notifyAllTabs(msg) { const originalMessage = msg; if (msg.method === 'styleUpdated' || msg.method === 'styleAdded') { // apply/popup/manage use only meta for these two methods, // editor may need the full code but can fetch it directly, // so we send just the meta to avoid spamming lots of tabs with huge styles msg = Object.assign({}, msg, { style: getStyleWithNoCode(msg.style) }); } const affectsAll = !msg.affects || msg.affects.all; const affectsOwnOriginOnly = !affectsAll && (msg.affects.editor || msg.affects.manager); const affectsTabs = affectsAll || affectsOwnOriginOnly; const affectsIcon = affectsAll || msg.affects.icon; const affectsPopup = affectsAll || msg.affects.popup; const affectsSelf = affectsPopup || msg.prefs; if (affectsTabs || affectsIcon) { const notifyTab = tab => { // own pages will be notified via runtime.sendMessage later if ((affectsTabs || URLS.optionsUI.includes(tab.url)) && !(affectsSelf && tab.url.startsWith(URLS.ownOrigin)) // skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF && (!FIREFOX || tab.width)) { chrome.tabs.sendMessage(tab.id, msg); } if (affectsIcon && BG) { BG.updateIcon(tab); } }; // list all tabs including chrome-extension:// which can be ours Promise.all([ queryTabs(affectsOwnOriginOnly ? {url: URLS.ownOrigin + '*'} : {}), getActiveTab(), ]).then(([tabs, activeTab]) => { const activeTabId = activeTab && activeTab.id; for (const tab of tabs) { invokeOrPostpone(tab.id === activeTabId, notifyTab, tab); } }); } // notify self: the message no longer is sent to the origin in new Chrome if (typeof onRuntimeMessage !== 'undefined') { onRuntimeMessage(originalMessage); } // notify apply.js on own pages if (typeof applyOnMessage !== 'undefined') { applyOnMessage(originalMessage); } // notify background page and all open popups if (affectsSelf) { chrome.runtime.sendMessage(msg); } } function queryTabs(options = {}) { return new Promise(resolve => chrome.tabs.query(options, tabs => resolve(tabs))); } function getTab(id) { return new Promise(resolve => chrome.tabs.get(id, tab => !chrome.runtime.lastError && resolve(tab))); } function getOwnTab() { return new Promise(resolve => chrome.tabs.getCurrent(tab => resolve(tab))); } function getActiveTab() { return queryTabs({currentWindow: true, active: true}) .then(tabs => tabs[0]); } function getActiveTabRealURL() { return getActiveTab() .then(getTabRealURL); } function getTabRealURL(tab) { return new Promise(resolve => { if (tab.url !== 'chrome://newtab/' || URLS.chromeProtectsNTP) { resolve(tab.url); } else { chrome.webNavigation.getFrame({tabId: tab.id, frameId: 0, processId: -1}, frame => { resolve(frame && frame.url || ''); }); } }); } // opens a tab or activates the already opened one, // reuses the New Tab page if it's focused now function openURL({url, currentWindow = true}) { if (!url.includes('://')) { url = chrome.runtime.getURL(url); } return new Promise(resolve => { // [some] chromium forks don't handle their fake branded protocols url = url.replace(/^(opera|vivaldi)/, 'chrome'); // FF doesn't handle moz-extension:// URLs (bug) // API doesn't handle the hash-fragment part const urlQuery = url.startsWith('moz-extension') ? undefined : url.replace(/#.*/, ''); queryTabs({url: urlQuery, currentWindow}).then(tabs => { for (const tab of tabs) { if (tab.url === url) { activateTab(tab).then(resolve); return; } } getActiveTab().then(tab => { if (tab && tab.url === 'chrome://newtab/' // prevent redirecting incognito NTP to a chrome URL as it crashes Chrome && (!url.startsWith('chrome') || !tab.incognito)) { chrome.tabs.update({url}, resolve); } else { chrome.tabs.create(tab && !FIREFOX ? {url, openerTabId: tab.id} : {url}, resolve); } }); }); }); } function activateTab(tab) { return Promise.all([ new Promise(resolve => { chrome.tabs.update(tab.id, {active: true}, resolve); }), new Promise(resolve => { chrome.windows.update(tab.windowId, {focused: true}, resolve); }), ]); } function stringAsRegExp(s, flags) { return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags); } function ignoreChromeError() { chrome.runtime.lastError; // eslint-disable-line no-unused-expressions } function getStyleWithNoCode(style) { const stripped = Object.assign({}, style, {sections: []}); for (const section of style.sections) { stripped.sections.push(Object.assign({}, section, {code: null})); } return stripped; } // js engine can't optimize the entire function if it contains try-catch // so we should keep it isolated from normal code in a minimal wrapper // Update: might get fixed in V8 TurboFan in the future function tryCatch(func, ...args) { try { return func(...args); } catch (e) {} } function tryRegExp(regexp) { try { return new RegExp(regexp); } catch (e) {} } function tryJSONparse(jsonString) { try { return JSON.parse(jsonString); } catch (e) {} } const debounce = Object.assign((fn, delay, ...args) => { clearTimeout(debounce.timers.get(fn)); debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args)); }, { timers: new Map(), run(fn, ...args) { debounce.timers.delete(fn); fn(...args); }, unregister(fn) { clearTimeout(debounce.timers.get(fn)); debounce.timers.delete(fn); }, }); function deepCopy(obj) { return obj !== null && obj !== undefined && typeof obj === 'object' ? deepMerge(typeof obj.slice === 'function' ? [] : {}, obj) : obj; } function deepMerge(target, ...args) { const isArray = typeof target.slice === 'function'; for (const obj of args) { if (isArray && obj !== null && obj !== undefined) { for (const element of obj) { target.push(deepCopy(element)); } continue; } for (const k in obj) { const value = obj[k]; if (k in target && typeof value === 'object' && value !== null) { deepMerge(target[k], value); } else { target[k] = deepCopy(value); } } } return target; } function sessionStorageHash(name) { return { name, value: tryCatch(JSON.parse, sessionStorage[name]) || {}, set(k, v) { this.value[k] = v; this.updateStorage(); }, unset(k) { delete this.value[k]; this.updateStorage(); }, updateStorage() { sessionStorage[this.name] = JSON.stringify(this.value); } }; } function onBackgroundReady() { return BG && BG.getStyles ? Promise.resolve() : new Promise(function ping(resolve) { chrome.runtime.sendMessage({method: 'healthCheck'}, health => { if (health !== undefined) { BG = chrome.extension.getBackgroundPage(); resolve(); } else { setTimeout(ping, 0, resolve); } }); }); } // in case Chrome haven't yet loaded the bg page and displays our page like edit/manage function getStylesSafe(options) { return onBackgroundReady() .then(() => BG.getStyles(options)); } function saveStyleSafe(style) { return onBackgroundReady() .then(() => BG.saveStyle(BG.deepCopy(style))) .then(savedStyle => { if (style.notify === false) { handleUpdate(savedStyle, style); } return savedStyle; }); } function deleteStyleSafe({id, notify = true} = {}) { return onBackgroundReady() .then(() => BG.deleteStyle({id, notify})) .then(() => { if (!notify) { handleDelete(id); } return id; }); } function download(url) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.timeout = 10e3; xhr.onloadend = () => (xhr.status === 200 ? resolve(xhr.responseText) : reject(xhr.status)); const [mainUrl, query] = url.split('?'); xhr.open(query ? 'POST' : 'GET', mainUrl, true); xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); xhr.send(query); }); } function doTimeout(ms = 0, ...args) { return ms > 0 ? () => new Promise(resolve => setTimeout(resolve, ms, ...args)) : new Promise(resolve => setTimeout(resolve, 0, ...args)); } function invokeOrPostpone(isInvoke, fn, ...args) { return isInvoke ? fn(...args) : setTimeout(invokeOrPostpone, 0, true, fn, ...args); }