/* exported getTab getActiveTab onTabReady stringAsRegExp openURL ignoreChromeError getStyleWithNoCode tryRegExp sessionStorageHash download deepEqual closeCurrentTab capitalize CHROME_HAS_BORDER_BUG */ /* global promisifyChrome */ 'use strict'; const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]); const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]); const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi'); let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]); // see PR #781 const CHROME_HAS_BORDER_BUG = CHROME >= 62 && CHROME <= 74; if (!CHROME && !chrome.browserAction.openPopup) { // in FF pre-57 legacy addons can override useragent so we assume the worst // until we know for sure in the async getBrowserInfo() // (browserAction.openPopup was added in 57) FIREFOX = browser.runtime.getBrowserInfo ? 51 : 50; // getBrowserInfo was added in FF 51 Promise.resolve(FIREFOX >= 51 ? browser.runtime.getBrowserInfo() : {version: 50}).then(info => { FIREFOX = parseFloat(info.version); document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54); }); } const URLS = { ownOrigin: chrome.runtime.getURL(''), // FIXME delete? optionsUI: [ chrome.runtime.getURL('options.html'), 'chrome://extensions/?options=' + chrome.runtime.id, ], configureCommands: OPERA ? 'opera://settings/configureCommands' : 'chrome://extensions/configureCommands', installUsercss: chrome.runtime.getURL('install-usercss.html'), // 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/', emptyTab: [ // Chrome and simple forks 'chrome://newtab/', // Opera 'chrome://startpage/', // Vivaldi 'chrome-extension://mpognobbkildjkofajifpdfhcoklimli/components/startpage/startpage.html', // Firefox 'about:home', 'about:newtab', ], // 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: CHROME >= 61, uso: 'https://userstyles.org/', usoJson: 'https://userstyles.org/styles/chrome/', usoArchive: 'https://33kk.github.io/uso-archive/', usoArchiveRaw: 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/', extractUsoArchiveId: url => url && url.startsWith(URLS.usoArchiveRaw) && parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]), extractGreasyForkId: url => /^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) && RegExp.$1, supported: url => ( url.startsWith('http') && (FIREFOX || !url.startsWith(URLS.browserWebStore)) || url.startsWith('ftp') || url.startsWith('file') || url.startsWith(URLS.ownOrigin) || !URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/') ), }; const IS_BG = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window; if (!IS_BG) { if (FIREFOX) { document.documentElement.classList.add('firefox'); } else if (OPERA) { document.documentElement.classList.add('opera'); } else { if (VIVALDI) document.documentElement.classList.add('vivaldi'); } } if (IS_BG) { window.API_METHODS = {}; } // FIXME: `localStorage` and `sessionStorage` may be disabled via dom.storage.enabled // Object.defineProperty(window, 'localStorage', {value: {}}); // Object.defineProperty(window, 'sessionStorage', {value: {}}); promisifyChrome({ tabs: ['create', 'get', 'getCurrent', 'move', 'query', 'update'], windows: ['create', 'update'], // Android doesn't have chrome.windows }); // FF57+ supports openerTabId, but not in Android // (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config) const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null; function getOwnTab() { return browser.tabs.getCurrent(); } function getActiveTab() { return browser.tabs.query({currentWindow: true, active: true}) .then(tabs => tabs[0]); } function urlToMatchPattern(url, ignoreSearch) { // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) { return undefined; } if (ignoreSearch) { return [ `${url.protocol}//${url.hostname}/${url.pathname}`, `${url.protocol}//${url.hostname}/${url.pathname}?*` ]; } // FIXME: is %2f allowed in pathname and search? return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`; } function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) { url = new URL(url); return browser.tabs.query({url: urlToMatchPattern(url, ignoreSearch), currentWindow}) // FIXME: is tab.url always normalized? .then(tabs => tabs.find(matchTab)); function matchTab(tab) { const tabUrl = new URL(tab.pendingUrl || tab.url); return tabUrl.protocol === url.protocol && tabUrl.username === url.username && tabUrl.password === url.password && tabUrl.hostname === url.hostname && tabUrl.port === url.port && tabUrl.pathname === url.pathname && (ignoreSearch || tabUrl.search === url.search) && (ignoreHash || tabUrl.hash === url.hash); } } /** * Opens a tab or activates an existing one, * reuses the New Tab page or about:blank if it's focused now * @param {Object} _ * @param {string} _.url - if relative, it's auto-expanded to the full extension URL * @param {number} [_.index] move the tab to this index in the tab strip, -1 = last * @param {number} [_.openerTabId] defaults to the active tab * @param {Boolean} [_.active=true] `true` to activate the tab * @param {Boolean|null} [_.currentWindow=true] `null` to check all windows * @param {chrome.windows.CreateData} [_.newWindow] creates a new window with these params if specified * @returns {Promise} Promise -> opened/activated tab */ async function openURL({ url, index, openerTabId, active = true, currentWindow = true, newWindow, }) { if (!url.includes('://')) { url = chrome.runtime.getURL(url); } let tab = await findExistingTab({url, currentWindow}); if (tab) { return activateTab(tab, { index, openerTabId, // when hash is different we can only set `url` if it has # otherwise the tab would reload url: url !== (tab.pendingUrl || tab.url) && url.includes('#') ? url : undefined, }); } if (newWindow && browser.windows) { return (await browser.windows.create(Object.assign({url}, newWindow)).tabs)[0]; } tab = await getActiveTab() || {url: ''}; if (isTabReplaceable(tab, url)) { return activateTab(tab, {url, openerTabId}); } const id = openerTabId == null ? tab.id : openerTabId; const opener = id != null && !tab.incognito && openerTabIdSupported && {openerTabId: id}; return browser.tabs.create(Object.assign({url, index, active}, opener)); } // replace empty tab (NTP or about:blank) // except when new URL is chrome:// or chrome-extension:// and the empty tab is // in incognito function isTabReplaceable(tab, newUrl) { if (!tab || !URLS.emptyTab.includes(tab.pendingUrl || tab.url)) { return false; } if (tab.incognito && newUrl.startsWith('chrome')) { return false; } return true; } function activateTab(tab, {url, index, openerTabId} = {}) { const options = {active: true}; if (url) { options.url = url; } if (openerTabId != null && openerTabIdSupported) { options.openerTabId = openerTabId; } return Promise.all([ browser.tabs.update(tab.id, options), browser.windows && browser.windows.update(tab.windowId, {focused: true}), index != null && browser.tabs.move(tab.id, {index}) ]) .then(() => tab); } function stringAsRegExp(s, flags) { return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags); } function ignoreChromeError() { // eslint-disable-next-line no-unused-expressions chrome.runtime.lastError; } function getStyleWithNoCode(style) { const stripped = deepCopy(style); for (const section of stripped.sections) section.code = null; stripped.sourceCode = 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, flags) { try { return new RegExp(regexp, flags); } 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) { if (!obj || typeof obj !== 'object') return obj; // N.B. the copy should be an explicit literal if (Array.isArray(obj)) { const copy = []; for (const v of obj) { copy.push(!v || typeof v !== 'object' ? v : deepCopy(v)); } return copy; } const copy = {}; const hasOwnProperty = Object.prototype.hasOwnProperty; for (const k in obj) { if (!hasOwnProperty.call(obj, k)) continue; const v = obj[k]; copy[k] = !v || typeof v !== 'object' ? v : deepCopy(v); } return copy; } function deepEqual(a, b, ignoredKeys) { if (!a || !b) return a === b; const type = typeof a; if (type !== typeof b) return false; if (type !== 'object') return a === b; if (Array.isArray(a)) { return Array.isArray(b) && a.length === b.length && a.every((v, i) => deepEqual(v, b[i], ignoredKeys)); } for (const key in a) { if (!Object.hasOwnProperty.call(a, key) || ignoredKeys && ignoredKeys.includes(key)) continue; if (!Object.hasOwnProperty.call(b, key)) return false; if (!deepEqual(a[key], b[key], ignoredKeys)) return false; } for (const key in b) { if (!Object.hasOwnProperty.call(b, key) || ignoredKeys && ignoredKeys.includes(key)) continue; if (!Object.hasOwnProperty.call(a, key)) return false; } return true; } 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); } }; } /** * @param {String} url * @param {Object} params * @param {String} [params.method] * @param {String|Object} [params.body] * @param {String} [params.responseType] arraybuffer, blob, document, json, text * @param {Number} [params.requiredStatusCode] resolved when matches, otherwise rejected * @param {Number} [params.timeout] ms * @param {Object} [params.headers] {name: value} * @returns {Promise} */ function download(url, { method = 'GET', body, responseType = 'text', requiredStatusCode = 200, timeout = 10e3, // connection timeout loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response) headers = { 'Content-type': 'application/x-www-form-urlencoded', }, } = {}) { const queryPos = url.indexOf('?'); if (queryPos > 0 && body === undefined) { method = 'POST'; body = url.slice(queryPos); url = url.slice(0, queryPos); } // * USO can't handle POST requests for style json // * XHR/fetch can't handle long URL // So we need to collapse all long variables and expand them in the response const usoVars = []; return new Promise((resolve, reject) => { let xhr; const u = new URL(collapseUsoVars(url)); const onTimeout = () => { if (xhr) xhr.abort(); reject(new Error('Timeout fetching ' + u.href)); }; let timer = setTimeout(onTimeout, timeout); const switchTimer = () => { clearTimeout(timer); timer = loadTimeout && setTimeout(onTimeout, loadTimeout); }; if (u.protocol === 'file:' && FIREFOX) { // TODO: maybe remove this since FF68+ can't do it anymore // https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path // FIXME: add FetchController when it is available. fetch(u.href, {mode: 'same-origin'}) .then(r => { switchTimer(); return r.status === 200 ? r.text() : Promise.reject(r.status); }) .catch(reject) .then(text => { clearTimeout(timer); resolve(text); }); return; } xhr = new XMLHttpRequest(); xhr.onreadystatechange = () => { if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) { xhr.onreadystatechange = null; switchTimer(); } }; xhr.onloadend = event => { clearTimeout(timer); if (event.type !== 'error' && ( xhr.status === requiredStatusCode || !requiredStatusCode || u.protocol === 'file:')) { resolve(expandUsoVars(xhr.response)); } else { reject(xhr.status); } }; xhr.onerror = xhr.onloadend; xhr.responseType = responseType; xhr.open(method, u.href, true); for (const key in headers) { xhr.setRequestHeader(key, headers[key]); } xhr.send(body); }); function collapseUsoVars(url) { if (queryPos < 0 || url.length < 2000 || !url.startsWith(URLS.usoJson) || !/^get$/i.test(method)) { return url; } const params = new URLSearchParams(url.slice(queryPos + 1)); for (const [k, v] of params.entries()) { if (v.length < 10 || v.startsWith('ik-')) continue; usoVars.push(v); params.set(k, `\x01${usoVars.length}\x02`); } return url.slice(0, queryPos + 1) + params.toString(); } function expandUsoVars(response) { if (!usoVars.length || !response) return response; const isText = typeof response === 'string'; const json = isText && tryJSONparse(response) || response; json.updateUrl = url; for (const section of json.sections || []) { const {code} = section; if (code.includes('\x01')) { section.code = code.replace(/\x01(\d+)\x02/g, (_, num) => usoVars[num - 1] || ''); } } return isText ? JSON.stringify(json) : json; } } function closeCurrentTab() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1409375 getOwnTab().then(tab => { if (tab) { chrome.tabs.remove(tab.id); } }); } function capitalize(s) { return s[0].toUpperCase() + s.slice(1); }