diff --git a/.jshintrc b/.jshintrc index 87cb055..6246bcd 100644 --- a/.jshintrc +++ b/.jshintrc @@ -2,11 +2,13 @@ "browser": true, "devel": true, "eqeqeq": true, - "esnext": true, + "esversion": 8, "globals": { "browser": false, // global variable in Firefox, Edge "self": false, "chrome": false, + "log": false, + "webext": false, "vAPI": false, "µMatrix": false }, diff --git a/dist/version b/dist/version index 88c5fb8..7594264 100644 --- a/dist/version +++ b/dist/version @@ -1 +1 @@ -1.4.0 +1.4.1.0 diff --git a/platform/chromium/manifest.json b/platform/chromium/manifest.json index 0993637..2ce4b5c 100644 --- a/platform/chromium/manifest.json +++ b/platform/chromium/manifest.json @@ -22,7 +22,7 @@ "content_scripts": [ { "matches": ["http://*/*", "https://*/*"], - "js": ["/js/vapi-client.js", "/js/contentscript-start.js"], + "js": ["/js/vapi.js", "/js/vapi-client.js", "/js/contentscript-start.js"], "run_at": "document_start", "all_frames": true }, diff --git a/platform/chromium/vapi-background.js b/platform/chromium/vapi-background.js index 6d7485e..22c5869 100644 --- a/platform/chromium/vapi-background.js +++ b/platform/chromium/vapi-background.js @@ -26,218 +26,422 @@ /******************************************************************************/ -(function() { +{ +// >>>>> start of local scope /******************************************************************************/ - -var vAPI = self.vAPI = self.vAPI || {}; - -var chrome = self.chrome; -var manifest = chrome.runtime.getManifest(); - -var noopFunc = function(){}; - -// https://code.google.com/p/chromium/issues/detail?id=410868#c8 -var resetLastError = function() { - void chrome.runtime.lastError; -}; - /******************************************************************************/ -// https://github.com/gorhill/uMatrix/issues/234 -// https://developer.chrome.com/extensions/privacy#property-network -chrome.privacy.network.networkPredictionEnabled.set({ value: false }); +const browser = self.browser; +const manifest = browser.runtime.getManifest(); + +vAPI.cantWebsocket = + browser.webRequest.ResourceType instanceof Object === false || + browser.webRequest.ResourceType.WEBSOCKET !== 'websocket'; + +vAPI.canWASM = vAPI.webextFlavor.soup.has('chromium') === false; +if ( vAPI.canWASM === false ) { + const csp = manifest.content_security_policy; + vAPI.canWASM = csp !== undefined && csp.indexOf("'wasm-eval'") !== -1; +} + +vAPI.supportsUserStylesheets = vAPI.webextFlavor.soup.has('user_stylesheet'); + +// The real actual webextFlavor value may not be set in stone, so listen +// for possible future changes. +window.addEventListener('webextFlavor', function() { + vAPI.supportsUserStylesheets = + vAPI.webextFlavor.soup.has('user_stylesheet'); +}, { once: true }); /******************************************************************************/ vAPI.app = { - name: manifest.name, - version: manifest.version + name: manifest.name.replace(/ dev\w+ build/, ''), + version: (( ) => { + let version = manifest.version; + const match = /(\d+\.\d+\.\d+)(?:\.(\d+))?/.exec(version); + if ( match && match[2] ) { + const v = parseInt(match[2], 10); + version = match[1] + (v < 100 ? 'b' + v : 'rc' + (v - 100)); + } + return version; + })(), + + intFromVersion: function(s) { + const parts = s.match(/(?:^|\.|b|rc)\d+/g); + if ( parts === null ) { return 0; } + let vint = 0; + for ( let i = 0; i < 4; i++ ) { + const pstr = parts[i] || ''; + let pint; + if ( pstr === '' ) { + pint = 0; + } else if ( pstr.startsWith('.') || pstr.startsWith('b') ) { + pint = parseInt(pstr.slice(1), 10); + } else if ( pstr.startsWith('rc') ) { + pint = parseInt(pstr.slice(2), 10) + 100; + } else { + pint = parseInt(pstr, 10); + } + vint = vint * 1000 + pint; + } + return vint; + }, + + restart: function() { + browser.runtime.reload(); + }, }; /******************************************************************************/ - -vAPI.app.start = function() { -}; - /******************************************************************************/ -vAPI.app.stop = function() { -}; +vAPI.storage = webext.storage.local; /******************************************************************************/ - -vAPI.app.restart = function() { - chrome.runtime.reload(); -}; - /******************************************************************************/ -// chrome.storage.local.get(null, function(bin){ console.debug('%o', bin); }); +// https://github.com/gorhill/uMatrix/issues/234 +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network -vAPI.storage = chrome.storage.local; -vAPI.cacheStorage = chrome.storage.local; +// https://github.com/gorhill/uBlock/issues/2048 +// Do not mess up with existing settings if not assigning them stricter +// values. + +vAPI.browserSettings = (( ) => { + // Not all platforms support `browser.privacy`. + const bp = webext.privacy; + if ( bp instanceof Object === false ) { return; } + + return { + // Whether the WebRTC-related privacy API is crashy is an open question + // only for Chromium proper (because it can be compiled without the + // WebRTC feature): hence avoid overhead of the evaluation (which uses + // an iframe) for platforms where it's a non-issue. + // https://github.com/uBlockOrigin/uBlock-issues/issues/9 + // Some Chromium builds are made to look like a Chrome build. + webRTCSupported: vAPI.webextFlavor.soup.has('chromium') === false || undefined, + + // Calling with `true` means IP address leak is not prevented. + // https://github.com/gorhill/uBlock/issues/533 + // We must first check wether this Chromium-based browser was compiled + // with WebRTC support. To do this, we use an iframe, this way the + // empty RTCPeerConnection object we create to test for support will + // be properly garbage collected. This prevents issues such as + // a computer unable to enter into sleep mode, as reported in the + // Chrome store: + // https://github.com/gorhill/uBlock/issues/533#issuecomment-167931681 + setWebrtcIPAddress: function(setting) { + // We don't know yet whether this browser supports WebRTC: find out. + if ( this.webRTCSupported === undefined ) { + // If asked to leave WebRTC setting alone at this point in the + // code, this means we never grabbed the setting in the first + // place. + if ( setting ) { return; } + this.webRTCSupported = { setting: setting }; + let iframe = document.createElement('iframe'); + const messageHandler = ev => { + if ( ev.origin !== self.location.origin ) { return; } + window.removeEventListener('message', messageHandler); + const setting = this.webRTCSupported.setting; + this.webRTCSupported = ev.data === 'webRTCSupported'; + this.setWebrtcIPAddress(setting); + iframe.parentNode.removeChild(iframe); + iframe = null; + }; + window.addEventListener('message', messageHandler); + iframe.src = 'is-webrtc-supported.html'; + document.body.appendChild(iframe); + return; + } + + // We are waiting for a response from our iframe. This makes the code + // safe to re-entrancy. + if ( typeof this.webRTCSupported === 'object' ) { + this.webRTCSupported.setting = setting; + return; + } + + // https://github.com/gorhill/uBlock/issues/533 + // WebRTC not supported: `webRTCMultipleRoutesEnabled` can NOT be + // safely accessed. Accessing the property will cause full browser + // crash. + if ( this.webRTCSupported !== true ) { return; } + + const bpn = bp.network; + + if ( setting ) { + bpn.webRTCIPHandlingPolicy.clear({ + scope: 'regular', + }); + } else { + // https://github.com/uBlockOrigin/uAssets/issues/333#issuecomment-289426678 + // Leverage virtuous side-effect of strictest setting. + // https://github.com/gorhill/uBlock/issues/3009 + // Firefox currently works differently, use + // `default_public_interface_only` for now. + bpn.webRTCIPHandlingPolicy.set({ + value: vAPI.webextFlavor.soup.has('chromium') + ? 'disable_non_proxied_udp' + : 'default_public_interface_only', + scope: 'regular', + }); + } + }, + + set: function(details) { + for ( const setting in details ) { + if ( details.hasOwnProperty(setting) === false ) { continue; } + switch ( setting ) { + case 'prefetching': + const enabled = !!details[setting]; + if ( enabled ) { + bp.network.networkPredictionEnabled.clear({ + scope: 'regular', + }); + } else { + bp.network.networkPredictionEnabled.set({ + value: false, + scope: 'regular', + }); + } + if ( vAPI.prefetching instanceof Function ) { + vAPI.prefetching(enabled); + } + break; + + case 'hyperlinkAuditing': + if ( !!details[setting] ) { + bp.websites.hyperlinkAuditingEnabled.clear({ + scope: 'regular', + }); + } else { + bp.websites.hyperlinkAuditingEnabled.set({ + value: false, + scope: 'regular', + }); + } + break; + + case 'webrtcIPAddress': + this.setWebrtcIPAddress(!!details[setting]); + break; + + default: + break; + } + } + } + }; +})(); /******************************************************************************/ - -vAPI.tabs = {}; - /******************************************************************************/ vAPI.isBehindTheSceneTabId = function(tabId) { - if ( typeof tabId === 'string' ) { debugger; } return tabId < 0; }; vAPI.unsetTabId = 0; vAPI.noTabId = -1; // definitely not any existing tab -vAPI.anyTabId = -2; // one of the existing tab -/******************************************************************************/ +// To ensure we always use a good tab id +const toTabId = function(tabId) { + return typeof tabId === 'number' && isNaN(tabId) === false + ? tabId + : 0; +}; -vAPI.tabs.registerListeners = function() { - var onNavigationClient = this.onNavigation || noopFunc; - var onUpdatedClient = this.onUpdated || noopFunc; - var onClosedClient = this.onClosed || noopFunc; +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webNavigation +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs - // https://developer.chrome.com/extensions/webNavigation - // [onCreatedNavigationTarget ->] - // onBeforeNavigate -> - // onCommitted -> - // onDOMContentLoaded -> - // onCompleted +vAPI.Tabs = class { + constructor() { + browser.webNavigation.onCreatedNavigationTarget.addListener(details => { + if ( typeof details.url !== 'string' ) { + details.url = ''; + } + if ( /^https?:\/\//.test(details.url) === false ) { + details.frameId = 0; + details.url = this.sanitizeURL(details.url); + this.onNavigation(details); + } + this.onCreated(details); + }); - // The chrome.webRequest.onBeforeRequest() won't be called for everything - // else than `http`/`https`. Thus, in such case, we will bind the tab as - // early as possible in order to increase the likelihood of a context - // properly setup if network requests are fired from within the tab. - // Example: Chromium + case #6 at - // http://raymondhill.net/ublock/popup.html - var reGoodForWebRequestAPI = /^https?:\/\//; + // Ensure the tab id is a valid one before propagating event to + // client code. "Invalid" yab id can occur when a browser chrome + // window is opened; for example, a detached dev tool window. + browser.webNavigation.onCommitted.addListener(async details => { + const tab = await this.get(details.tabId); + if ( tab.id === -1 ) { return; } + details.url = this.sanitizeURL(details.url); + this.onNavigation(details); + }); - var onCreatedNavigationTarget = function(details) { - //console.debug('onCreatedNavigationTarget: tab id %d = "%s"', details.tabId, details.url); - if ( reGoodForWebRequestAPI.test(details.url) ) { return; } - onNavigationClient(details); - }; + // https://github.com/gorhill/uBlock/issues/3073 + // Fall back to `tab.url` when `changeInfo.url` is not set. + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ( typeof changeInfo.url !== 'string' ) { + changeInfo.url = tab && tab.url; + } + if ( changeInfo.url ) { + changeInfo.url = this.sanitizeURL(changeInfo.url); + } + this.onUpdated(tabId, changeInfo, tab); + }); - var onUpdated = function(tabId, changeInfo, tab) { - onUpdatedClient(tabId, changeInfo, tab); - }; + browser.tabs.onActivated.addListener(details => { + this.onActivated(details); + }); - var onCommitted = function(details) { - // Important: do not call client if not top frame. - if ( details.frameId !== 0 ) { - return; + // https://github.com/uBlockOrigin/uBlock-issues/issues/151 + // https://github.com/uBlockOrigin/uBlock-issues/issues/680#issuecomment-515215220 + if ( browser.windows instanceof Object ) { + browser.windows.onFocusChanged.addListener(async windowId => { + if ( windowId === browser.windows.WINDOW_ID_NONE ) { return; } + const tabs = await vAPI.tabs.query({ active: true, windowId }); + if ( tabs.length === 0 ) { return; } + const tab = tabs[0]; + this.onActivated({ tabId: tab.id, windowId: tab.windowId }); + }); } - onNavigationClient(details); - //console.debug('onCommitted: tab id %d = "%s"', details.tabId, details.url); - }; - var onClosed = function(tabId) { - onClosedClient(tabId); - }; + browser.tabs.onRemoved.addListener((tabId, details) => { + this.onClosed(tabId, details); + }); + } - chrome.webNavigation.onCreatedNavigationTarget.addListener(onCreatedNavigationTarget); - chrome.webNavigation.onCommitted.addListener(onCommitted); - chrome.tabs.onUpdated.addListener(onUpdated); - chrome.tabs.onRemoved.addListener(onClosed); -}; - -/******************************************************************************/ - -// tabId: null, // active tab - -vAPI.tabs.get = function(tabId, callback) { - var onTabReady = function(tab) { - resetLastError(); - callback(tab); - }; - if ( tabId !== null ) { - chrome.tabs.get(tabId, onTabReady); - return; - } - var onTabReceived = function(tabs) { - resetLastError(); - var tab = null; - if ( Array.isArray(tabs) && tabs.length !== 0 ) { - tab = tabs[0]; + async executeScript() { + let result; + try { + result = await webext.tabs.executeScript(...arguments); } - callback(tab); - }; - chrome.tabs.query({ active: true, currentWindow: true }, onTabReceived); -}; - -/******************************************************************************/ - -// https://github.com/uBlockOrigin/uMatrix-issues/issues/9 - -vAPI.tabs.getAll = function(callback) { - chrome.tabs.query({}, callback); -}; - -/******************************************************************************/ - -// properties of the details object: -// url: 'URL', // the address that will be opened -// tabId: 1, // the tab is used if set, instead of creating a new one -// index: -1, // undefined: end of the list, -1: following tab, or after index -// active: false, // opens the tab in background - true and undefined: foreground -// select: true // if a tab is already opened with that url, then select it instead of opening a new one - -vAPI.tabs.open = function(details) { - var targetURL = details.url; - if ( typeof targetURL !== 'string' || targetURL === '' ) { - return null; - } - // extension pages - if ( /^[\w-]{2,}:/.test(targetURL) !== true ) { - targetURL = vAPI.getURL(targetURL); + catch(reason) { + } + return Array.isArray(result) ? result : []; } - // dealing with Chrome's asynchronous API - var wrapper = function() { + async get(tabId) { + if ( tabId === null ) { + return this.getCurrent(); + } + if ( tabId <= 0 ) { return null; } + let tab; + try { + tab = await webext.tabs.get(tabId); + } + catch(reason) { + } + return tab instanceof Object ? tab : null; + } + + async getCurrent() { + const tabs = await this.query({ active: true, currentWindow: true }); + return tabs.length !== 0 ? tabs[0] : null; + } + + async insertCSS() { + try { + await webext.tabs.insertCSS(...arguments); + } + catch(reason) { + } + } + + async query(queryInfo) { + let tabs; + try { + tabs = await webext.tabs.query(queryInfo); + } + catch(reason) { + } + return Array.isArray(tabs) ? tabs : []; + } + + async removeCSS() { + try { + await webext.tabs.removeCSS(...arguments); + } + catch(reason) { + } + } + + // Properties of the details object: + // - url: 'URL', => the address that will be opened + // - index: -1, => undefined: end of the list, -1: following tab, + // or after index + // - active: false, => opens the tab... in background: true, + // foreground: undefined + // - popup: 'popup' => open in a new window + + async create(url, details) { if ( details.active === undefined ) { details.active = true; } - var subWrapper = function() { - var _details = { - url: targetURL, + const subWrapper = async ( ) => { + const updateDetails = { + url: url, active: !!details.active }; // Opening a tab from incognito window won't focus the window // in which the tab was opened - var focusWindow = function(tab) { - if ( tab.active ) { - chrome.windows.update(tab.windowId, { focused: true }); + const focusWindow = tab => { + if ( tab.active && vAPI.windows instanceof Object ) { + vAPI.windows.update(tab.windowId, { focused: true }); } }; if ( !details.tabId ) { if ( details.index !== undefined ) { - _details.index = details.index; + updateDetails.index = details.index; } - - chrome.tabs.create(_details, focusWindow); + browser.tabs.create(updateDetails, focusWindow); return; } // update doesn't accept index, must use move - chrome.tabs.update(details.tabId, _details, function(tab) { - // if the tab doesn't exist - if ( vAPI.lastError() ) { - chrome.tabs.create(_details, focusWindow); - } else if ( details.index !== undefined ) { - chrome.tabs.move(tab.id, {index: details.index}); - } - }); + const tab = await vAPI.tabs.update( + toTabId(details.tabId), + updateDetails + ); + // if the tab doesn't exist + if ( tab === null ) { + browser.tabs.create(updateDetails, focusWindow); + } else if ( details.index !== undefined ) { + browser.tabs.move(tab.id, { index: details.index }); + } }; // Open in a standalone window - if ( details.popup === true ) { - chrome.windows.create({ url: details.url, type: 'popup' }); + // + // https://github.com/uBlockOrigin/uBlock-issues/issues/168#issuecomment-413038191 + // Not all platforms support vAPI.windows. + // + // For some reasons, some platforms do not honor the left,top + // position when specified. I found that further calling + // windows.update again with the same position _may_ help. + if ( details.popup !== undefined && vAPI.windows instanceof Object ) { + const createDetails = { url, type: details.popup }; + if ( details.box instanceof Object ) { + Object.assign(createDetails, details.box); + } + const win = await vAPI.windows.create(createDetails); + if ( win === null ) { return; } + if ( details.box instanceof Object === false ) { return; } + if ( + win.left === details.box.left && + win.top === details.box.top + ) { + return; + } + vAPI.windows.update(win.id, { + left: details.box.left, + top: details.box.top + }); return; } @@ -246,59 +450,246 @@ vAPI.tabs.open = function(details) { return; } - vAPI.tabs.get(null, function(tab) { - if ( tab ) { - details.index = tab.index + 1; - } else { - delete details.index; - } - - subWrapper(); - }); - }; - - if ( !details.select ) { - wrapper(); - return; - } - - chrome.tabs.query({ url: targetURL }, function(tabs) { - resetLastError(); - var tab = Array.isArray(tabs) && tabs[0]; - if ( tab ) { - chrome.tabs.update(tab.id, { active: true }, function(tab) { - chrome.windows.update(tab.windowId, { focused: true }); - }); + const tab = await vAPI.tabs.getCurrent(); + if ( tab !== null ) { + details.index = tab.index + 1; } else { - wrapper(); + details.index = undefined; } - }); -}; - -/******************************************************************************/ - -// Replace the URL of a tab. Noop if the tab does not exist. - -vAPI.tabs.replace = function(tabId, url) { - var targetURL = url; - - // extension pages - if ( /^[\w-]{2,}:/.test(targetURL) !== true ) { - targetURL = vAPI.getURL(targetURL); + subWrapper(); } - if ( typeof tabId !== 'number' || tabId < 0 ) { return; } + // Properties of the details object: + // - url: 'URL', => the address that will be opened + // - tabId: 1, => the tab is used if set, instead of creating a new one + // - index: -1, => undefined: end of the list, -1: following tab, or + // after index + // - active: false, => opens the tab in background - true and undefined: + // foreground + // - select: true, => if a tab is already opened with that url, then select + // it instead of opening a new one + // - popup: true => open in a new window - chrome.tabs.update(tabId, { url: targetURL }, resetLastError); + async open(details) { + let targetURL = details.url; + if ( typeof targetURL !== 'string' || targetURL === '' ) { + return null; + } + + // extension pages + if ( /^[\w-]{2,}:/.test(targetURL) !== true ) { + targetURL = vAPI.getURL(targetURL); + } + + if ( !details.select ) { + this.create(targetURL, details); + return; + } + + // https://github.com/gorhill/uBlock/issues/3053#issuecomment-332276818 + // Do not try to lookup uBO's own pages with FF 55 or less. + if ( + vAPI.webextFlavor.soup.has('firefox') && + vAPI.webextFlavor.major < 56 + ) { + this.create(targetURL, details); + return; + } + + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/query#Parameters + // "Note that fragment identifiers are not matched." + // Fragment identifiers ARE matched -- we need to remove the fragment. + const pos = targetURL.indexOf('#'); + const targetURLWithoutHash = pos === -1 + ? targetURL + : targetURL.slice(0, pos); + + const tabs = await vAPI.tabs.query({ url: targetURLWithoutHash }); + if ( tabs.length === 0 ) { + this.create(targetURL, details); + return; + } + let tab = tabs[0]; + const updateDetails = { active: true }; + // https://github.com/uBlockOrigin/uBlock-issues/issues/592 + if ( tab.url.startsWith(targetURL) === false ) { + updateDetails.url = targetURL; + } + tab = await vAPI.tabs.update(tab.id, updateDetails); + if ( vAPI.windows instanceof Object === false ) { return; } + vAPI.windows.update(tab.windowId, { focused: true }); + } + + async update() { + let tab; + try { + tab = await webext.tabs.update(...arguments); + } + catch (reason) { + } + return tab instanceof Object ? tab : null; + } + + // Replace the URL of a tab. Noop if the tab does not exist. + replace(tabId, url) { + tabId = toTabId(tabId); + if ( tabId === 0 ) { return; } + + let targetURL = url; + + // extension pages + if ( /^[\w-]{2,}:/.test(targetURL) !== true ) { + targetURL = vAPI.getURL(targetURL); + } + + vAPI.tabs.update(tabId, { url: targetURL }); + } + + async remove(tabId) { + tabId = toTabId(tabId); + if ( tabId === 0 ) { return; } + try { + await webext.tabs.remove(tabId); + } + catch (reason) { + } + } + + async reload(tabId, bypassCache = false) { + tabId = toTabId(tabId); + if ( tabId === 0 ) { return; } + try { + await webext.tabs.reload( + tabId, + { bypassCache: bypassCache === true } + ); + } + catch (reason) { + } + } + + async select(tabId) { + tabId = toTabId(tabId); + if ( tabId === 0 ) { return; } + const tab = await vAPI.tabs.update(tabId, { active: true }); + if ( tab === null ) { return; } + if ( vAPI.windows instanceof Object === false ) { return; } + vAPI.windows.update(tab.windowId, { focused: true }); + } + + // https://forums.lanik.us/viewtopic.php?f=62&t=32826 + // Chromium-based browsers: sanitize target URL. I've seen data: URI with + // newline characters in standard fields, possibly as a way of evading + // filters. As per spec, there should be no whitespaces in a data: URI's + // standard fields. + + sanitizeURL(url) { + if ( url.startsWith('data:') === false ) { return url; } + const pos = url.indexOf(','); + if ( pos === -1 ) { return url; } + const s = url.slice(0, pos); + if ( s.search(/\s/) === -1 ) { return url; } + return s.replace(/\s+/, '') + url.slice(pos); + } + + onActivated(/* details */) { + } + + onClosed(/* tabId, details */) { + } + + onCreated(/* details */) { + } + + onNavigation(/* details */) { + } + + onUpdated(/* tabId, changeInfo, tab */) { + } }; +/******************************************************************************/ /******************************************************************************/ -vAPI.tabs.reload = function(tabId, bypassCache) { - if ( typeof tabId !== 'number' || tabId < 0 ) { return; } - chrome.tabs.reload(tabId, { bypassCache: bypassCache === true }); -}; +if ( webext.windows instanceof Object ) { + vAPI.windows = { + get: async function() { + let win; + try { + win = await webext.windows.get(...arguments); + } + catch (reason) { + } + return win instanceof Object ? win : null; + }, + create: async function() { + let win; + try { + win = await webext.windows.create(...arguments); + } + catch (reason) { + } + return win instanceof Object ? win : null; + }, + update: async function() { + let win; + try { + win = await webext.windows.update(...arguments); + } + catch (reason) { + } + return win instanceof Object ? win : null; + }, + }; +} +/******************************************************************************/ +/******************************************************************************/ + +if ( webext.browserAction instanceof Object ) { + vAPI.browserAction = { + setTitle: async function() { + try { + await webext.browserAction.setTitle(...arguments); + } + catch (reason) { + } + }, + }; + // Not supported on Firefox for Android + if ( webext.browserAction.setIcon ) { + vAPI.browserAction.setBadgeTextColor = async function() { + try { + await webext.browserAction.setBadgeTextColor(...arguments); + } + catch (reason) { + } + }; + vAPI.browserAction.setBadgeBackgroundColor = async function() { + try { + await webext.browserAction.setBadgeBackgroundColor(...arguments); + } + catch (reason) { + } + }; + vAPI.browserAction.setBadgeText = async function() { + try { + await webext.browserAction.setBadgeText(...arguments); + } + catch (reason) { + } + }; + vAPI.browserAction.setIcon = async function() { + try { + await webext.browserAction.setIcon(...arguments); + } + catch (reason) { + } + }; + } +} + +/******************************************************************************/ /******************************************************************************/ // Must read: https://code.google.com/p/chromium/issues/detail?id=410868#c8 @@ -308,326 +699,734 @@ vAPI.tabs.reload = function(tabId, bypassCache) { // Since we may be called asynchronously, the tab id may not exist // anymore, so this ensures it does still exist. -vAPI.setIcon = (function() { - let onIconReady = function(tabId, badgeDetails) { - if ( vAPI.lastError() ) { return; } - if ( badgeDetails.text !== undefined ) { - chrome.browserAction.setBadgeText({ - tabId: tabId, - text: badgeDetails.text - }); - } - if ( badgeDetails.color !== undefined ) { - chrome.browserAction.setBadgeBackgroundColor({ - tabId: tabId, - color: badgeDetails.color - }); - } - }; +// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/browserAction#Browser_compatibility +// Firefox for Android does no support browser.browserAction.setIcon(). +// Performance: use ImageData for platforms supporting it. - return function(tabId, iconDetails, badgeDetails) { - if ( typeof tabId !== 'number' || tabId < 0 ) { return; } - chrome.browserAction.setIcon( - { tabId: tabId, path: iconDetails }, - function() { onIconReady(tabId, badgeDetails); } - ); +// https://github.com/uBlockOrigin/uBlock-issues/issues/32 +// Ensure ImageData for toolbar icon is valid before use. + +vAPI.setIcon = (( ) => { + const browserAction = vAPI.browserAction; + const titleTemplate = + browser.runtime.getManifest().browser_action.default_title + + ' ({badge})'; + const icons = [ + { path: { '16': 'img/icon_16-off.png', '32': 'img/icon_32-off.png' } }, + { path: { '16': 'img/icon_16.png', '32': 'img/icon_32.png' } }, + ]; + + (( ) => { + if ( browserAction.setIcon === undefined ) { return; } + + // The global badge text and background color. + if ( browserAction.setBadgeBackgroundColor !== undefined ) { + browserAction.setBadgeBackgroundColor({ color: '#666666' }); + } + if ( browserAction.setBadgeTextColor !== undefined ) { + browserAction.setBadgeTextColor({ color: '#FFFFFF' }); + } + + // As of 2018-05, benchmarks show that only Chromium benefits for sure + // from using ImageData. + // + // Chromium creates a new ImageData instance every call to setIcon + // with paths: + // https://cs.chromium.org/chromium/src/extensions/renderer/resources/set_icon.js?l=56&rcl=99be185c25738437ecfa0dafba72a26114196631 + // + // Firefox uses an internal cache for each setIcon's paths: + // https://searchfox.org/mozilla-central/rev/5ff2d7683078c96e4b11b8a13674daded935aa44/browser/components/extensions/parent/ext-browserAction.js#631 + if ( vAPI.webextFlavor.soup.has('chromium') === false ) { return; } + + const imgs = []; + for ( let i = 0; i < icons.length; i++ ) { + const path = icons[i].path; + for ( const key in path ) { + if ( path.hasOwnProperty(key) === false ) { continue; } + imgs.push({ i: i, p: key }); + } + } + + // https://github.com/uBlockOrigin/uBlock-issues/issues/296 + const safeGetImageData = function(ctx, w, h) { + let data; + try { + data = ctx.getImageData(0, 0, w, h); + } catch(ex) { + } + return data; + }; + + const onLoaded = function() { + for ( const img of imgs ) { + if ( img.r.complete === false ) { return; } + if ( img.r.naturalWidth === 0 ) { return; } + if ( img.r.naturalHeight === 0 ) { return; } + } + const ctx = document.createElement('canvas').getContext('2d'); + const iconData = [ null, null ]; + for ( const img of imgs ) { + const w = img.r.naturalWidth, h = img.r.naturalHeight; + ctx.width = w; ctx.height = h; + ctx.clearRect(0, 0, w, h); + ctx.drawImage(img.r, 0, 0); + if ( iconData[img.i] === null ) { iconData[img.i] = {}; } + const imgData = safeGetImageData(ctx, w, h); + if ( + imgData instanceof Object === false || + imgData.data instanceof Uint8ClampedArray === false || + imgData.data[0] !== 0 || + imgData.data[1] !== 0 || + imgData.data[2] !== 0 || + imgData.data[3] !== 0 + ) { + return; + } + iconData[img.i][img.p] = imgData; + } + for ( let i = 0; i < iconData.length; i++ ) { + if ( iconData[i] ) { + icons[i] = { imageData: iconData[i] }; + } + } + }; + for ( const img of imgs ) { + img.r = new Image(); + img.r.addEventListener('load', onLoaded, { once: true }); + img.r.src = icons[img.i].path[img.p]; + } + })(); + + // parts: bit 0 = icon + // bit 1 = badge text + // bit 2 = badge color + // bit 3 = hide badge + + return async function(tabId, details) { + tabId = toTabId(tabId); + if ( tabId === 0 ) { return; } + + const tab = await vAPI.tabs.get(tabId); + if ( tab === null ) { return; } + + const { parts, src, badge, color } = details; + + if ( browserAction.setIcon !== undefined ) { + if ( parts === undefined || (parts & 0b0001) !== 0 ) { + browserAction.setIcon( + Object.assign({ tabId: tab.id, path: src }) + ); + } + if ( (parts & 0b0010) !== 0 ) { + browserAction.setBadgeText({ + tabId: tab.id, + text: (parts & 0b1000) === 0 ? badge : '' + }); + } + if ( (parts & 0b0100) !== 0 ) { + browserAction.setBadgeBackgroundColor({ tabId: tab.id, color }); + } + } + + if ( browserAction.setTitle !== undefined ) { + browserAction.setTitle({ + tabId: tab.id, + title: titleTemplate.replace( + '{badge}', + badge !== '' ? badge : '0' + ) + }); + } + + if ( vAPI.contextMenu instanceof Object ) { + vAPI.contextMenu.onMustUpdate(tabId); + } }; })(); +browser.browserAction.onClicked.addListener(tab => { + vAPI.tabs.open({ + select: true, + url: 'popup.html?tabId=' + tab.id + '&responsive=1' + }); +}); + /******************************************************************************/ /******************************************************************************/ +// https://github.com/uBlockOrigin/uBlock-issues/issues/710 +// uBO uses only ports to communicate with its auxiliary pages and +// content scripts. Whether a message can trigger a privileged operation is +// decided based on whether the port from which a message is received is +// privileged, which is a status evaluated once, at port connection time. + vAPI.messaging = { ports: new Map(), - listeners: {}, + listeners: new Map(), defaultHandler: null, - NOOPFUNC: noopFunc, - UNHANDLED: 'vAPI.messaging.notHandled' -}; + PRIVILEGED_URL: vAPI.getURL(''), + NOOPFUNC: function(){}, + UNHANDLED: 'vAPI.messaging.notHandled', -/******************************************************************************/ + listen: function(details) { + this.listeners.set(details.name, { + fn: details.listener, + privileged: details.privileged === true + }); + }, -vAPI.messaging.listen = function(listenerName, callback) { - this.listeners[listenerName] = callback; -}; + onPortDisconnect: function(port) { + this.ports.delete(port.name); + }, -/******************************************************************************/ + onPortConnect: function(port) { + port.onDisconnect.addListener( + port => this.onPortDisconnect(port) + ); + port.onMessage.addListener( + (request, port) => this.onPortMessage(request, port) + ); + this.ports.set(port.name, { + port, + privileged: port.sender.url.startsWith(this.PRIVILEGED_URL) + }); + }, -vAPI.messaging.onPortMessage = (function() { - var messaging = vAPI.messaging; + setup: function(defaultHandler) { + if ( this.defaultHandler !== null ) { return; } + + if ( typeof defaultHandler !== 'function' ) { + defaultHandler = function() { + return this.UNHANDLED; + }; + } + this.defaultHandler = defaultHandler; + + browser.runtime.onConnect.addListener( + port => this.onPortConnect(port) + ); + + // https://bugzilla.mozilla.org/show_bug.cgi?id=1392067 + // Workaround: manually remove ports matching removed tab. + if ( + vAPI.webextFlavor.soup.has('firefox') && + vAPI.webextFlavor.major < 61 + ) { + browser.tabs.onRemoved.addListener(tabId => { + for ( const { port } of this.ports.values() ) { + const tab = port.sender && port.sender.tab; + if ( !tab ) { continue; } + if ( tab.id === tabId ) { + this.onPortDisconnect(port); + } + } + }); + } + }, + + broadcast: function(message) { + const messageWrapper = { broadcast: true, msg: message }; + for ( const { port } of this.ports.values() ) { + try { + port.postMessage(messageWrapper); + } catch(ex) { + this.ports.delete(port.name); + } + } + }, + + onFrameworkMessage: function(request, port, callback) { + const sender = port && port.sender; + if ( !sender ) { return; } + const tabId = sender.tab && sender.tab.id || undefined; + const msg = request.msg; + switch ( msg.what ) { + case 'connectionAccepted': + case 'connectionRefused': { + const toPort = this.ports.get(msg.fromToken); + if ( toPort !== undefined ) { + msg.tabId = tabId; + toPort.port.postMessage(request); + } else { + msg.what = 'connectionBroken'; + port.postMessage(request); + } + break; + } + case 'connectionRequested': + msg.tabId = tabId; + for ( const { port: toPort } of this.ports.values() ) { + if ( toPort === port ) { continue; } + toPort.postMessage(request); + } + break; + case 'connectionBroken': + case 'connectionCheck': + case 'connectionMessage': { + const toPort = this.ports.get( + port.name === msg.fromToken ? msg.toToken : msg.fromToken + ); + if ( toPort !== undefined ) { + msg.tabId = tabId; + toPort.port.postMessage(request); + } else { + msg.what = 'connectionBroken'; + port.postMessage(request); + } + break; + } + case 'extendClient': + vAPI.tabs.executeScript(tabId, { + file: '/js/vapi-client-extra.js', + }).then(( ) => { + callback(); + }); + break; + case 'userCSS': + if ( tabId === undefined ) { break; } + const details = { + code: undefined, + frameId: sender.frameId, + matchAboutBlank: true + }; + if ( vAPI.supportsUserStylesheets ) { + details.cssOrigin = 'user'; + } + if ( msg.add ) { + details.runAt = 'document_start'; + } + const promises = []; + for ( const cssText of msg.add ) { + details.code = cssText; + promises.push(vAPI.tabs.insertCSS(tabId, details)); + } + if ( typeof webext.tabs.removeCSS === 'function' ) { + for ( const cssText of msg.remove ) { + details.code = cssText; + promises.push(vAPI.tabs.removeCSS(tabId, details)); + } + } + Promise.all(promises).then(( ) => { + callback(); + }); + break; + } + }, // Use a wrapper to avoid closure and to allow reuse. - var CallbackWrapper = function(port, request) { - this.callback = this.proxy.bind(this); // bind once - this.init(port, request); - }; - - CallbackWrapper.prototype = { - init: function(port, request) { + CallbackWrapper: class { + constructor(messaging, port, msgId) { + this.messaging = messaging; + this.callback = this.proxy.bind(this); // bind once + this.init(port, msgId); + } + init(port, msgId) { this.port = port; - this.request = request; + this.msgId = msgId; return this; - }, - proxy: function(response) { + } + proxy(response) { // https://github.com/chrisaljoudi/uBlock/issues/383 - if ( messaging.ports.has(this.port.name) ) { + if ( this.messaging.ports.has(this.port.name) ) { this.port.postMessage({ - auxProcessId: this.request.auxProcessId, - channelName: this.request.channelName, - msg: response !== undefined ? response : null + msgId: this.msgId, + msg: response !== undefined ? response : null, }); } - // Mark for reuse - this.port = this.request = null; - callbackWrapperJunkyard.push(this); + // Store for reuse + this.port = null; + this.messaging.callbackWrapperJunkyard.push(this); } - }; + }, - var callbackWrapperJunkyard = []; + callbackWrapperJunkyard: [], - var callbackWrapperFactory = function(port, request) { - var wrapper = callbackWrapperJunkyard.pop(); - if ( wrapper ) { - return wrapper.init(port, request); - } - return new CallbackWrapper(port, request); - }; + callbackWrapperFactory: function(port, msgId) { + return this.callbackWrapperJunkyard.length !== 0 + ? this.callbackWrapperJunkyard.pop().init(port, msgId) + : new this.CallbackWrapper(this, port, msgId); + }, - // https://bugzilla.mozilla.org/show_bug.cgi?id=1392067 - // Workaround: manually remove ports matching removed tab. - chrome.tabs.onRemoved.addListener(function(tabId) { - for ( var port of messaging.ports.values() ) { - var tab = port.sender && port.sender.tab; - if ( !tab ) { continue; } - if ( tab.id === tabId ) { - vAPI.messaging.onPortDisconnect(port); - } - } - }); - - return function(request, port) { + onPortMessage: function(request, port) { // prepare response - var callback = this.NOOPFUNC; - if ( request.auxProcessId !== undefined ) { - callback = callbackWrapperFactory(port, request).callback; + let callback = this.NOOPFUNC; + if ( request.msgId !== undefined ) { + callback = this.callbackWrapperFactory(port, request.msgId).callback; + } + + // Content process to main process: framework handler. + if ( request.channel === 'vapi' ) { + this.onFrameworkMessage(request, port, callback); + return; } // Auxiliary process to main process: specific handler - var r = this.UNHANDLED, - listener = this.listeners[request.channelName]; - if ( typeof listener === 'function' ) { - r = listener(request.msg, port.sender, callback); + const fromDetails = this.ports.get(port.name); + if ( fromDetails === undefined ) { return; } + + const listenerDetails = this.listeners.get(request.channel); + let r = this.UNHANDLED; + if ( + (listenerDetails !== undefined) && + (listenerDetails.privileged === false || fromDetails.privileged) + + ) { + r = listenerDetails.fn(request.msg, port.sender, callback); } if ( r !== this.UNHANDLED ) { return; } // Auxiliary process to main process: default handler - r = this.defaultHandler(request.msg, port.sender, callback); - if ( r !== this.UNHANDLED ) { return; } + if ( fromDetails.privileged ) { + r = this.defaultHandler(request.msg, port.sender, callback); + if ( r !== this.UNHANDLED ) { return; } + } // Auxiliary process to main process: no handler - console.error( - 'vAPI.messaging.onPortMessage > unhandled request: %o', + log.info( + `vAPI.messaging.onPortMessage > unhandled request: ${JSON.stringify(request.msg)}`, request ); // Need to callback anyways in case caller expected an answer, or // else there is a memory leak on caller's side callback(); - }.bind(vAPI.messaging); + }, +}; + +/******************************************************************************/ +/******************************************************************************/ + +// https://github.com/gorhill/uBlock/issues/3474 +// https://github.com/gorhill/uBlock/issues/2823 +// Foil ability of web pages to identify uBO through +// its web accessible resources. +// https://github.com/gorhill/uBlock/issues/3497 +// Prevent web pages from interfering with uBO's element picker +// https://github.com/uBlockOrigin/uBlock-issues/issues/550 +// Support using a new secret for every network request. + +vAPI.warSecret = (( ) => { + const generateSecret = ( ) => { + return Math.floor(Math.random() * 982451653 + 982451653).toString(36); + }; + + const root = vAPI.getURL('/'); + const secrets = []; + let lastSecretTime = 0; + + const guard = function(details) { + const url = details.url; + const pos = secrets.findIndex(secret => + url.lastIndexOf(`?secret=${secret}`) !== -1 + ); + if ( pos === -1 ) { + return { redirectUrl: root }; + } + secrets.splice(pos, 1); + }; + + browser.webRequest.onBeforeRequest.addListener( + guard, + { + urls: [ root + 'web_accessible_resources/*' ] + }, + [ 'blocking' ] + ); + + return ( ) => { + if ( secrets.length !== 0 ) { + if ( (Date.now() - lastSecretTime) > 5000 ) { + secrets.splice(0); + } else if ( secrets.length > 256 ) { + secrets.splice(0, secrets.length - 192); + } + } + lastSecretTime = Date.now(); + const secret = generateSecret(); + secrets.push(secret); + return `?secret=${secret}`; + }; })(); /******************************************************************************/ -vAPI.messaging.onPortDisconnect = function(port) { - port.onDisconnect.removeListener(this.onPortDisconnect); - port.onMessage.removeListener(this.onPortMessage); - this.ports.delete(port.name); -}.bind(vAPI.messaging); - -/******************************************************************************/ - -vAPI.messaging.onPortConnect = function(port) { - port.onDisconnect.addListener(this.onPortDisconnect); - port.onMessage.addListener(this.onPortMessage); - this.ports.set(port.name, port); -}.bind(vAPI.messaging); - -/******************************************************************************/ - -vAPI.messaging.setup = function(defaultHandler) { - if ( this.defaultHandler !== null ) { return; } - - if ( typeof defaultHandler !== 'function' ) { - defaultHandler = function(){ - return vAPI.messaging.UNHANDLED; - }; - } - this.defaultHandler = defaultHandler; - - chrome.runtime.onConnect.addListener(this.onPortConnect); -}; - -/******************************************************************************/ - -vAPI.messaging.broadcast = function(message) { - var messageWrapper = { - broadcast: true, - msg: message - }; - for ( var port of this.ports.values() ) { - port.postMessage(messageWrapper); - } -}; - -/******************************************************************************/ -/******************************************************************************/ - -vAPI.net = { - listenerMap: new WeakMap(), - // legacy Chromium understands only these network request types. - validTypes: (function() { - let types = new Set([ - 'main_frame', - 'sub_frame', - 'stylesheet', - 'script', - 'image', - 'object', - 'xmlhttprequest', - 'other' - ]); - let wrrt = browser.webRequest.ResourceType; - if ( wrrt instanceof Object ) { - for ( let typeKey in wrrt ) { +vAPI.Net = class { + constructor() { + this.validTypes = new Set(); + { + const wrrt = browser.webRequest.ResourceType; + for ( const typeKey in wrrt ) { if ( wrrt.hasOwnProperty(typeKey) ) { - types.add(wrrt[typeKey]); + this.validTypes.add(wrrt[typeKey]); } } } + this.suspendableListener = undefined; + this.listenerMap = new WeakMap(); + this.suspendDepth = 0; + + browser.webRequest.onBeforeRequest.addListener( + details => { + this.normalizeDetails(details); + if ( this.suspendDepth !== 0 && details.tabId >= 0 ) { + return this.suspendOneRequest(details); + } + return this.onBeforeSuspendableRequest(details); + }, + this.denormalizeFilters({ urls: [ 'http://*/*', 'https://*/*' ] }), + [ 'blocking' ] + ); + } + setOptions(/* options */) { + } + normalizeDetails(/* details */) { + } + denormalizeFilters(filters) { + const urls = filters.urls || [ '' ]; + let types = filters.types; + if ( Array.isArray(types) ) { + types = this.denormalizeTypes(types); + } + if ( + (this.validTypes.has('websocket')) && + (types === undefined || types.indexOf('websocket') !== -1) && + (urls.indexOf('') === -1) + ) { + if ( urls.indexOf('ws://*/*') === -1 ) { + urls.push('ws://*/*'); + } + if ( urls.indexOf('wss://*/*') === -1 ) { + urls.push('wss://*/*'); + } + } + return { types, urls }; + } + denormalizeTypes(types) { return types; - })(), - denormalizeFilters: null, - normalizeDetails: null, - addListener: function(which, clientListener, filters, options) { - if ( typeof this.denormalizeFilters === 'function' ) { - filters = this.denormalizeFilters(filters); - } - let actualListener; - if ( typeof this.normalizeDetails === 'function' ) { - actualListener = function(details) { - vAPI.net.normalizeDetails(details); - return clientListener(details); - }; - this.listenerMap.set(clientListener, actualListener); - } + } + addListener(which, clientListener, filters, options) { + const actualFilters = this.denormalizeFilters(filters); + const actualListener = this.makeNewListenerProxy(clientListener); browser.webRequest[which].addListener( - actualListener || clientListener, - filters, + actualListener, + actualFilters, options ); - }, - removeListener: function(which, clientListener) { - let actualListener = this.listenerMap.get(clientListener); - if ( actualListener !== undefined ) { - this.listenerMap.delete(clientListener); + } + onBeforeSuspendableRequest(details) { + if ( this.suspendableListener === undefined ) { return; } + return this.suspendableListener(details); + } + setSuspendableListener(listener) { + this.suspendableListener = listener; + } + removeListener(which, clientListener) { + const actualListener = this.listenerMap.get(clientListener); + if ( actualListener === undefined ) { return; } + this.listenerMap.delete(clientListener); + browser.webRequest[which].removeListener(actualListener); + } + makeNewListenerProxy(clientListener) { + const actualListener = details => { + this.normalizeDetails(details); + return clientListener(details); + }; + this.listenerMap.set(clientListener, actualListener); + return actualListener; + } + suspendOneRequest() { + } + unsuspendAllRequests() { + } + suspend(force = false) { + if ( this.canSuspend() || force ) { + this.suspendDepth += 1; } - browser.webRequest[which].removeListener( - actualListener || clientListener - ); - }, -}; - -/******************************************************************************/ -/******************************************************************************/ - -vAPI.lastError = function() { - return chrome.runtime.lastError; -}; - -/******************************************************************************/ -/******************************************************************************/ - -vAPI.browserData = {}; - -/******************************************************************************/ - -// https://developer.chrome.com/extensions/browsingData - -vAPI.browserData.clearCache = function(callback) { - chrome.browsingData.removeCache({ since: 0 }, callback); -}; - -/******************************************************************************/ -/******************************************************************************/ - -// https://developer.chrome.com/extensions/cookies - -vAPI.cookies = {}; - -/******************************************************************************/ - -vAPI.cookies.start = function() { - var reallyRemoved = { - 'evicted': true, - 'expired': true, - 'explicit': true - }; - - var onChanged = function(changeInfo) { - if ( changeInfo.removed ) { - if ( reallyRemoved[changeInfo.cause] && typeof this.onRemoved === 'function' ) { - this.onRemoved(changeInfo.cookie); - } + } + unsuspend(all = false) { + if ( this.suspendDepth === 0 ) { return; } + if ( all ) { + this.suspendDepth = 0; + } else { + this.suspendDepth -= 1; + } + if ( this.suspendDepth !== 0 ) { return; } + this.unsuspendAllRequests(); + } + canSuspend() { + return false; + } + async benchmark() { + if ( typeof µMatrix !== 'object' ) { return; } + const requests = await µMatrix.loadBenchmarkDataset(); + if ( Array.isArray(requests) === false || requests.length === 0 ) { + console.info('No requests found to benchmark'); return; } - if ( typeof this.onChanged === 'function' ) { - this.onChanged(changeInfo.cookie); + const mappedTypes = new Map([ + [ 'document', 'main_frame' ], + [ 'subdocument', 'sub_frame' ], + ]); + console.info('vAPI.net.onBeforeSuspendableRequest()...'); + const t0 = self.performance.now(); + const promises = []; + const details = { + documentUrl: '', + tabId: -1, + parentFrameId: -1, + frameId: 0, + type: '', + url: '', + }; + for ( const request of requests ) { + details.documentUrl = request.frameUrl; + details.tabId = -1; + details.parentFrameId = -1; + details.frameId = 0; + details.type = mappedTypes.get(request.cpt) || request.cpt; + details.url = request.url; + if ( details.type === 'main_frame' ) { continue; } + promises.push(this.onBeforeSuspendableRequest(details)); + } + return Promise.all(promises).then(results => { + let blockCount = 0; + for ( const r of results ) { + if ( r !== undefined ) { blockCount += 1; } + } + const t1 = self.performance.now(); + const dur = t1 - t0; + console.info(`Evaluated ${requests.length} requests in ${dur.toFixed(0)} ms`); + console.info(`\tBlocked ${blockCount} requests`); + console.info(`\tAverage: ${(dur / requests.length).toFixed(3)} ms per request`); + }); + } +}; + +/******************************************************************************/ +/******************************************************************************/ + +// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/contextMenus#Browser_compatibility +// Firefox for Android does no support browser.contextMenus. + +vAPI.contextMenu = webext.menus && { + _callback: null, + _entries: [], + _createEntry: function(entry) { + webext.menus.create(JSON.parse(JSON.stringify(entry))); + }, + onMustUpdate: function() {}, + setEntries: function(entries, callback) { + entries = entries || []; + let n = Math.max(this._entries.length, entries.length); + for ( let i = 0; i < n; i++ ) { + const oldEntryId = this._entries[i]; + const newEntry = entries[i]; + if ( oldEntryId && newEntry ) { + if ( newEntry.id !== oldEntryId ) { + webext.menus.remove(oldEntryId); + this._createEntry(newEntry); + this._entries[i] = newEntry.id; + } + } else if ( oldEntryId && !newEntry ) { + webext.menus.remove(oldEntryId); + } else if ( !oldEntryId && newEntry ) { + this._createEntry(newEntry); + this._entries[i] = newEntry.id; + } + } + n = this._entries.length = entries.length; + callback = callback || null; + if ( callback === this._callback ) { + return; + } + if ( n !== 0 && callback !== null ) { + webext.menus.onClicked.addListener(callback); + this._callback = callback; + } else if ( n === 0 && this._callback !== null ) { + webext.menus.onClicked.removeListener(this._callback); + this._callback = null; + } + } +}; + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.commands = browser.commands; + +/******************************************************************************/ +/******************************************************************************/ + +// https://github.com/gorhill/uBlock/issues/531 +// Storage area dedicated to admin settings. Read-only. + +// https://github.com/gorhill/uBlock/commit/43a5ed735b95a575a9339b6e71a1fcb27a99663b#commitcomment-13965030 +// Not all Chromium-based browsers support managed storage. Merely testing or +// exception handling in this case does NOT work: I don't know why. The +// extension on Opera ends up in a non-sensical state, whereas vAPI become +// undefined out of nowhere. So only solution left is to test explicitly for +// Opera. +// https://github.com/gorhill/uBlock/issues/900 +// Also, UC Browser: http://www.upsieutoc.com/image/WXuH + +vAPI.adminStorage = (( ) => { + if ( webext.storage.managed instanceof Object === false ) { + return { + getItem: function() { + return Promise.resolve(); + }, + }; + } + return { + getItem: async function(key) { + let bin; + try { + bin = await webext.storage.managed.get(key); + } catch(ex) { + } + if ( bin instanceof Object ) { + return bin[key]; + } } }; - - chrome.cookies.onChanged.addListener(onChanged.bind(this)); -}; - -/******************************************************************************/ - -vAPI.cookies.getAll = function(callback) { - chrome.cookies.getAll({}, callback); -}; - -/******************************************************************************/ - -vAPI.cookies.remove = function(details, callback) { - chrome.cookies.remove(details, callback || noopFunc); -}; +})(); /******************************************************************************/ /******************************************************************************/ -vAPI.cloud = (function() { - // Not all platforms support `chrome.storage.sync`. - if ( chrome.storage.sync instanceof Object === false ) { - return; - } +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync - var chunkCountPerFetch = 16; // Must be a power of 2 +vAPI.cloud = (( ) => { + // Not all platforms support `webext.storage.sync`. + if ( webext.storage.sync instanceof Object === false ) { return; } - // Mind chrome.storage.sync.MAX_ITEMS (512 at time of writing) - var maxChunkCountPerItem = Math.floor(512 * 0.75) & ~(chunkCountPerFetch - 1); + // Currently, only Chromium supports the following constants -- these + // values will be assumed for platforms which do not define them. + // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/sync + // > You can store up to 100KB of data using this API + const MAX_ITEMS = + webext.storage.sync.MAX_ITEMS || 512; + const QUOTA_BYTES = + webext.storage.sync.QUOTA_BYTES || 102400; + const QUOTA_BYTES_PER_ITEM = + webext.storage.sync.QUOTA_BYTES_PER_ITEM || 8192; + + const chunkCountPerFetch = 16; // Must be a power of 2 + const maxChunkCountPerItem = Math.floor(MAX_ITEMS * 0.75) & ~(chunkCountPerFetch - 1); - // Mind chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 at time of writing) // https://github.com/gorhill/uBlock/issues/3006 - // For Firefox, we will use a lower ratio to allow for more overhead for - // the infrastructure. Unfortunately this leads to less usable space for - // actual data, but all of this is provided for free by browser vendors, - // so we need to accept and deal with these limitations. - var evalMaxChunkSize = function() { + // For Firefox, we will use a lower ratio to allow for more overhead for + // the infrastructure. Unfortunately this leads to less usable space for + // actual data, but all of this is provided for free by browser vendors, + // so we need to accept and deal with these limitations. + const evalMaxChunkSize = function() { return Math.floor( - (chrome.storage.sync.QUOTA_BYTES_PER_ITEM || 8192) * + QUOTA_BYTES_PER_ITEM * (vAPI.webextFlavor.soup.has('firefox') ? 0.6 : 0.75) ); }; - var maxChunkSize = evalMaxChunkSize(); + let maxChunkSize = evalMaxChunkSize(); // The real actual webextFlavor value may not be set in stone, so listen // for possible future changes. @@ -635,13 +1434,9 @@ vAPI.cloud = (function() { maxChunkSize = evalMaxChunkSize(); }, { once: true }); - // Mind chrome.storage.sync.QUOTA_BYTES (128 kB at time of writing) - // Firefox: - // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/sync - // > You can store up to 100KB of data using this API/ - var maxStorageSize = chrome.storage.sync.QUOTA_BYTES || 102400; + const maxStorageSize = QUOTA_BYTES; - var options = { + const options = { defaultDeviceName: window.navigator.platform, deviceName: vAPI.localStorage.getItem('deviceName') || '' }; @@ -653,57 +1448,53 @@ vAPI.cloud = (function() { // good thing given chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE // and chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR. - var getCoarseChunkCount = function(dataKey, callback) { - let bin = {}; + const getCoarseChunkCount = async function(dataKey) { + const keys = {}; for ( let i = 0; i < maxChunkCountPerItem; i += 16 ) { - bin[dataKey + i.toString()] = ''; + keys[dataKey + i.toString()] = ''; } - - chrome.storage.sync.get(bin, function(bin) { - if ( chrome.runtime.lastError ) { - callback(0, chrome.runtime.lastError.message); - return; - } - - var chunkCount = 0; - for ( let i = 0; i < maxChunkCountPerItem; i += 16 ) { - if ( bin[dataKey + i.toString()] === '' ) { break; } - chunkCount = i + 16; - } - - callback(chunkCount); - }); + let bin; + try { + bin = await webext.storage.sync.get(keys); + } catch (reason) { + return reason; + } + let chunkCount = 0; + for ( let i = 0; i < maxChunkCountPerItem; i += 16 ) { + if ( bin[dataKey + i.toString()] === '' ) { break; } + chunkCount = i + 16; + } + return chunkCount; }; - var deleteChunks = function(dataKey, start) { - var keys = []; + const deleteChunks = function(dataKey, start) { + const keys = []; // No point in deleting more than: // - The max number of chunks per item // - The max number of chunks per storage limit - var n = Math.min( + const n = Math.min( maxChunkCountPerItem, Math.ceil(maxStorageSize / maxChunkSize) ); - for ( var i = start; i < n; i++ ) { + for ( let i = start; i < n; i++ ) { keys.push(dataKey + i.toString()); } - chrome.storage.sync.remove(keys); + if ( keys.length !== 0 ) { + webext.storage.sync.remove(keys); + } }; - var start = function(/* dataKeys */) { - }; + const push = async function(dataKey, data) { - var push = function(dataKey, data, callback) { - - var bin = { + let bin = { 'source': options.deviceName || options.defaultDeviceName, 'tstamp': Date.now(), 'data': data, 'size': 0 }; bin.size = JSON.stringify(bin).length; - var item = JSON.stringify(bin); + const item = JSON.stringify(bin); // Chunkify taking into account QUOTA_BYTES_PER_ITEM: // https://developer.chrome.com/extensions/storage#property-sync @@ -711,81 +1502,83 @@ vAPI.cloud = (function() { // "storage, as measured by the JSON stringification of its value // "plus its key length." bin = {}; - var chunkCount = Math.ceil(item.length / maxChunkSize); - for ( var i = 0; i < chunkCount; i++ ) { + let chunkCount = Math.ceil(item.length / maxChunkSize); + for ( let i = 0; i < chunkCount; i++ ) { bin[dataKey + i.toString()] = item.substr(i * maxChunkSize, maxChunkSize); } - bin[dataKey + i.toString()] = ''; // Sentinel + bin[dataKey + chunkCount.toString()] = ''; // Sentinel - chrome.storage.sync.set(bin, function() { - var errorStr; - if ( chrome.runtime.lastError ) { - errorStr = chrome.runtime.lastError.message; - // https://github.com/gorhill/uBlock/issues/3006#issuecomment-332597677 - // - Delete all that was pushed in case of failure. - chunkCount = 0; - } - callback(errorStr); - - // Remove potentially unused trailing chunks - deleteChunks(dataKey, chunkCount); - }); - }; - - var pull = function(dataKey, callback) { - - var assembleChunks = function(bin) { - if ( chrome.runtime.lastError ) { - callback(null, chrome.runtime.lastError.message); - return; - } - - // Assemble chunks into a single string. - let json = [], jsonSlice; - let i = 0; - for (;;) { - jsonSlice = bin[dataKey + i.toString()]; - if ( jsonSlice === '' || jsonSlice === undefined ) { break; } - json.push(jsonSlice); - i += 1; - } - - let entry = null; - try { - entry = JSON.parse(json.join('')); - } catch(ex) { - } - callback(entry); - }; - - var fetchChunks = function(coarseCount, errorStr) { - if ( coarseCount === 0 || typeof errorStr === 'string' ) { - callback(null, errorStr); - return; - } - - var bin = {}; - for ( var i = 0; i < coarseCount; i++ ) { - bin[dataKey + i.toString()] = ''; - } - - chrome.storage.sync.get(bin, assembleChunks); - }; - - getCoarseChunkCount(dataKey, fetchChunks); - }; - - var getOptions = function(callback) { - if ( typeof callback !== 'function' ) { - return; + let result; + let errorStr; + try { + result = await webext.storage.sync.set(bin); + } catch (reason) { + errorStr = reason; } + + // https://github.com/gorhill/uBlock/issues/3006#issuecomment-332597677 + // - Delete all that was pushed in case of failure. + // - It's unknown whether such issue applies only to Firefox: + // until such cases are reported for other browsers, we will + // reset the (now corrupted) content of the cloud storage + // only on Firefox. + if ( errorStr !== undefined && vAPI.webextFlavor.soup.has('firefox') ) { + chunkCount = 0; + } + + // Remove potentially unused trailing chunks + deleteChunks(dataKey, chunkCount); + + return errorStr; + }; + + const pull = async function(dataKey) { + + const result = await getCoarseChunkCount(dataKey); + if ( typeof result !== 'number' ) { + return result; + } + const chunkKeys = {}; + for ( let i = 0; i < result; i++ ) { + chunkKeys[dataKey + i.toString()] = ''; + } + + let bin; + try { + bin = await webext.storage.sync.get(chunkKeys); + } catch (reason) { + return reason; + } + + // Assemble chunks into a single string. + // https://www.reddit.com/r/uMatrix/comments/8lc9ia/my_rules_tab_hangs_with_cloud_storage_support/ + // Explicit sentinel is not necessarily present: this can + // happen when the number of chunks is a multiple of + // chunkCountPerFetch. Hence why we must also test against + // undefined. + let json = [], jsonSlice; + let i = 0; + for (;;) { + jsonSlice = bin[dataKey + i.toString()]; + if ( jsonSlice === '' || jsonSlice === undefined ) { break; } + json.push(jsonSlice); + i += 1; + } + let entry = null; + try { + entry = JSON.parse(json.join('')); + } catch(ex) { + } + return entry; + }; + + const getOptions = function(callback) { + if ( typeof callback !== 'function' ) { return; } callback(options); }; - var setOptions = function(details, callback) { - if ( typeof details !== 'object' || details === null ) { - return; - } + const setOptions = function(details, callback) { + if ( typeof details !== 'object' || details === null ) { return; } if ( typeof details.deviceName === 'string' ) { vAPI.localStorage.setItem('deviceName', details.deviceName); @@ -795,18 +1588,62 @@ vAPI.cloud = (function() { getOptions(callback); }; - return { - start: start, - push: push, - pull: pull, - getOptions: getOptions, - setOptions: setOptions - }; + return { push, pull, getOptions, setOptions }; })(); /******************************************************************************/ /******************************************************************************/ -})(); +vAPI.browserData = {}; + +// https://developer.chrome.com/extensions/browsingData + +vAPI.browserData.clearCache = function(callback) { + browser.browsingData.removeCache({ since: 0 }, callback); +}; + +/******************************************************************************/ +/******************************************************************************/ + +// https://developer.chrome.com/extensions/cookies + +vAPI.cookies = { + start: function() { + const reallyRemoved = { + 'evicted': true, + 'expired': true, + 'explicit': true, + }; + + browser.cookies.onChanged.addListener(changeInfo => { + if ( changeInfo.removed ) { + if ( + reallyRemoved[changeInfo.cause] && + typeof this.onRemoved === 'function' + ) { + this.onRemoved(changeInfo.cookie); + } + return; + } + if ( typeof this.onChanged === 'function' ) { + this.onChanged(changeInfo.cookie); + } + }); + }, + + getAll: function() { + return webext.cookies.getAll({}); + }, + + remove: function(details) { + return webext.cookies.remove(details); + }, +}; + +/******************************************************************************/ +/******************************************************************************/ + +// <<<<< end of local scope +} /******************************************************************************/ diff --git a/platform/chromium/vapi-client-extra.js b/platform/chromium/vapi-client-extra.js new file mode 100644 index 0000000..f18280e --- /dev/null +++ b/platform/chromium/vapi-client-extra.js @@ -0,0 +1,308 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2019-present Raymond Hill + + 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 {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +// For non-background page + +'use strict'; + +/******************************************************************************/ + +// Direct messaging connection ability + +(( ) => { +// >>>>>>>> start of private namespace + +if ( + typeof vAPI !== 'object' || + vAPI.messaging instanceof Object === false || + vAPI.MessagingConnection instanceof Function +) { + return; +} + +const listeners = new Set(); +const connections = new Map(); + +vAPI.MessagingConnection = class { + constructor(handler, details) { + this.messaging = vAPI.messaging; + this.handler = handler; + this.id = details.id; + this.to = details.to; + this.toToken = details.toToken; + this.from = details.from; + this.fromToken = details.fromToken; + this.checkTimer = undefined; + // On Firefox it appears ports are not automatically disconnected + // when navigating to another page. + const ctor = vAPI.MessagingConnection; + if ( ctor.pagehide !== undefined ) { return; } + ctor.pagehide = ( ) => { + for ( const connection of connections.values() ) { + connection.disconnect(); + connection.handler( + connection.toDetails('connectionBroken') + ); + } + }; + window.addEventListener('pagehide', ctor.pagehide); + } + toDetails(what, payload) { + return { + what: what, + id: this.id, + from: this.from, + fromToken: this.fromToken, + to: this.to, + toToken: this.toToken, + payload: payload + }; + } + disconnect() { + if ( this.checkTimer !== undefined ) { + clearTimeout(this.checkTimer); + this.checkTimer = undefined; + } + connections.delete(this.id); + const port = this.messaging.getPort(); + if ( port === null ) { return; } + port.postMessage({ + channel: 'vapi', + msg: this.toDetails('connectionBroken'), + }); + } + checkAsync() { + if ( this.checkTimer !== undefined ) { + clearTimeout(this.checkTimer); + } + this.checkTimer = vAPI.setTimeout( + ( ) => { this.check(); }, + 499 + ); + } + check() { + this.checkTimer = undefined; + if ( connections.has(this.id) === false ) { return; } + const port = this.messaging.getPort(); + if ( port === null ) { return; } + port.postMessage({ + channel: 'vapi', + msg: this.toDetails('connectionCheck'), + }); + this.checkAsync(); + } + receive(details) { + switch ( details.what ) { + case 'connectionAccepted': + this.toToken = details.toToken; + this.handler(details); + this.checkAsync(); + break; + case 'connectionBroken': + connections.delete(this.id); + this.handler(details); + break; + case 'connectionMessage': + this.handler(details); + this.checkAsync(); + break; + case 'connectionCheck': + const port = this.messaging.getPort(); + if ( port === null ) { return; } + if ( connections.has(this.id) ) { + this.checkAsync(); + } else { + details.what = 'connectionBroken'; + port.postMessage({ channel: 'vapi', msg: details }); + } + break; + case 'connectionRefused': + connections.delete(this.id); + this.handler(details); + break; + } + } + send(payload) { + const port = this.messaging.getPort(); + if ( port === null ) { return; } + port.postMessage({ + channel: 'vapi', + msg: this.toDetails('connectionMessage', payload), + }); + } + + static addListener(listener) { + listeners.add(listener); + } + static async connectTo(from, to, handler) { + const port = vAPI.messaging.getPort(); + if ( port === null ) { return; } + const connection = new vAPI.MessagingConnection(handler, { + id: `${from}-${to}-${vAPI.sessionId}`, + to: to, + from: from, + fromToken: port.name + }); + connections.set(connection.id, connection); + port.postMessage({ + channel: 'vapi', + msg: { + what: 'connectionRequested', + id: connection.id, + from: from, + fromToken: port.name, + to: to, + } + }); + return connection.id; + } + static disconnectFrom(connectionId) { + const connection = connections.get(connectionId); + if ( connection === undefined ) { return; } + connection.disconnect(); + } + static sendTo(connectionId, payload) { + const connection = connections.get(connectionId); + if ( connection === undefined ) { return; } + connection.send(payload); + } + static canDestroyPort() { + return listeners.length === 0 && connections.size === 0; + } + static mustDestroyPort() { + if ( connections.size === 0 ) { return; } + for ( const connection of connections.values() ) { + connection.receive({ what: 'connectionBroken' }); + } + connections.clear(); + } + static canProcessMessage(details) { + if ( details.channel !== 'vapi' ) { return; } + switch ( details.msg.what ) { + case 'connectionAccepted': + case 'connectionBroken': + case 'connectionCheck': + case 'connectionMessage': + case 'connectionRefused': { + const connection = connections.get(details.msg.id); + if ( connection === undefined ) { break; } + connection.receive(details.msg); + return true; + } + case 'connectionRequested': + if ( listeners.length === 0 ) { return; } + const port = vAPI.messaging.getPort(); + if ( port === null ) { break; } + let listener, result; + for ( listener of listeners ) { + result = listener(details.msg); + if ( result !== undefined ) { break; } + } + if ( result === undefined ) { break; } + if ( result === true ) { + details.msg.what = 'connectionAccepted'; + details.msg.toToken = port.name; + const connection = new vAPI.MessagingConnection( + listener, + details.msg + ); + connections.set(connection.id, connection); + } else { + details.msg.what = 'connectionRefused'; + } + port.postMessage(details); + return true; + default: + break; + } + } +}; + +vAPI.messaging.extensions.push(vAPI.MessagingConnection); + +// <<<<<<<< end of private namespace +})(); + +/******************************************************************************/ + +// Broadcast listening ability + +(( ) => { +// >>>>>>>> start of private namespace + +if ( + typeof vAPI !== 'object' || + vAPI.messaging instanceof Object === false || + vAPI.broadcastListener instanceof Object +) { + return; +} + +const listeners = new Set(); + +vAPI.broadcastListener = { + add: function(listener) { + listeners.add(listener); + vAPI.messaging.getPort(); + }, + remove: function(listener) { + listeners.delete(listener); + }, + canDestroyPort() { + return listeners.size === 0; + }, + mustDestroyPort() { + listeners.clear(); + }, + canProcessMessage(details) { + if ( details.broadcast === false ) { return; } + for ( const listener of listeners ) { + listener(details.msg); + } + }, +}; + +vAPI.messaging.extensions.push(vAPI.broadcastListener); + +// <<<<<<<< end of private namespace +})(); + +/******************************************************************************/ + + + + + + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uBO never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/platform/chromium/vapi-client.js b/platform/chromium/vapi-client.js index 31faa0b..c8a934d 100644 --- a/platform/chromium/vapi-client.js +++ b/platform/chromium/vapi-client.js @@ -1,7 +1,8 @@ /******************************************************************************* - uMatrix - a browser extension to block requests. - Copyright (C) 2014-2018 The uMatrix/uBlock Origin authors + uBlock Origin - a browser extension to block requests. + Copyright (C) 2014-2015 The uBlock Origin authors + Copyright (C) 2014-present Raymond Hill 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 @@ -16,60 +17,61 @@ You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. - Home: https://github.com/gorhill/uMatrix + Home: https://github.com/gorhill/uBlock */ -// For non background pages +// For non-background page 'use strict'; /******************************************************************************/ -(function(self) { - -/******************************************************************************/ - -// https://bugs.chromium.org/p/project-zero/issues/detail?id=1225&desc=6#c10 -if ( self.vAPI === undefined || self.vAPI.uMatrix !== true ) { - self.vAPI = { uMatrix: true }; -} - -var vAPI = self.vAPI; -var chrome = self.chrome; - // https://github.com/chrisaljoudi/uBlock/issues/456 -// Already injected? -if ( vAPI.vapiClientInjected ) { - //console.debug('vapi-client.js already injected: skipping.'); - return; -} -vAPI.vapiClientInjected = true; +// Skip if already injected. -vAPI.sessionId = String.fromCharCode(Date.now() % 26 + 97) + - Math.random().toString(36).slice(2); +// >>>>>>>> start of HUGE-IF-BLOCK +if ( + typeof vAPI === 'object' && + vAPI.randomToken instanceof Function === false +) { + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.randomToken = function() { + const now = Date.now(); + return String.fromCharCode(now % 26 + 97) + + Math.floor((1 + Math.random()) * now).toString(36); +}; + +vAPI.sessionId = vAPI.randomToken(); +vAPI.setTimeout = vAPI.setTimeout || self.setTimeout.bind(self); /******************************************************************************/ -vAPI.shutdown = (function() { - var jobs = []; - - var add = function(job) { - jobs.push(job); - }; - - var exec = function() { - //console.debug('Shutting down...'); - var job; - while ( (job = jobs.pop()) ) { - job(); +vAPI.shutdown = { + jobs: [], + add: function(job) { + this.jobs.push(job); + }, + exec: function() { + // Shutdown asynchronously, to ensure shutdown jobs are called from + // the top context. + self.requestIdleCallback(( ) => { + const jobs = this.jobs.slice(); + this.jobs.length = 0; + while ( jobs.length !== 0 ) { + (jobs.pop())(); + } + }); + }, + remove: function(job) { + let pos; + while ( (pos = this.jobs.indexOf(job)) !== -1 ) { + this.jobs.splice(pos, 1); } - }; - - return { - add: add, - exec: exec - }; -})(); + } +}; /******************************************************************************/ @@ -77,9 +79,10 @@ vAPI.messaging = { port: null, portTimer: null, portTimerDelay: 10000, - listeners: new Set(), + extended: undefined, + extensions: [], + msgIdGenerator: 1, pending: new Map(), - auxProcessId: 1, shuttingDown: false, shutdown: function() { @@ -87,41 +90,53 @@ vAPI.messaging = { this.destroyPort(); }, + // https://github.com/uBlockOrigin/uBlock-issues/issues/403 + // Spurious disconnection can happen, so do not consider such events + // as world-ending, i.e. stay around. Except for embedded frames. + disconnectListener: function() { this.port = null; - vAPI.shutdown.exec(); + if ( window !== window.top ) { + vAPI.shutdown.exec(); + } }, disconnectListenerBound: null, messageListener: function(details) { - if ( !details ) { return; } - - // Sent to all listeners - if ( details.broadcast ) { - this.sendToListeners(details.msg); - return; - } + if ( details instanceof Object === false ) { return; } // Response to specific message previously sent - var listener; - if ( details.auxProcessId ) { - listener = this.pending.get(details.auxProcessId); - if ( listener !== undefined ) { - this.pending.delete(details.auxProcessId); - listener(details.msg); + if ( details.msgId !== undefined ) { + const resolver = this.pending.get(details.msgId); + if ( resolver !== undefined ) { + this.pending.delete(details.msgId); + resolver(details.msg); return; } } + + // Unhandled messages + this.extensions.every(ext => ext.canProcessMessage(details) !== true); + }, + messageListenerBound: null, + + canDestroyPort: function() { + return this.pending.size === 0 && + ( + this.extensions.length === 0 || + this.extensions.every(e => e.canDestroyPort()) + ); + }, + + mustDestroyPort: function() { + if ( this.extensions.length === 0 ) { return; } + this.extensions.forEach(e => e.mustDestroyPort()); + this.extensions.length = 0; }, - messageListenerCallback: null, portPoller: function() { this.portTimer = null; - if ( - this.port !== null && - this.listeners.size === 0 && - this.pending.size === 0 - ) { + if ( this.port !== null && this.canDestroyPort() ) { return this.destroyPort(); } this.portTimer = vAPI.setTimeout(this.portPollerBound, this.portTimerDelay); @@ -134,48 +149,50 @@ vAPI.messaging = { clearTimeout(this.portTimer); this.portTimer = null; } - var port = this.port; + const port = this.port; if ( port !== null ) { port.disconnect(); - port.onMessage.removeListener(this.messageListenerCallback); + port.onMessage.removeListener(this.messageListenerBound); port.onDisconnect.removeListener(this.disconnectListenerBound); this.port = null; } - this.listeners.clear(); + this.mustDestroyPort(); // service pending callbacks if ( this.pending.size !== 0 ) { - var pending = this.pending; + const pending = this.pending; this.pending = new Map(); - for ( var callback of pending.values() ) { - if ( typeof callback === 'function' ) { - callback(null); - } + for ( const resolver of pending.values() ) { + resolver(); } } }, createPort: function() { if ( this.shuttingDown ) { return null; } - if ( this.messageListenerCallback === null ) { - this.messageListenerCallback = this.messageListener.bind(this); + if ( this.messageListenerBound === null ) { + this.messageListenerBound = this.messageListener.bind(this); this.disconnectListenerBound = this.disconnectListener.bind(this); this.portPollerBound = this.portPoller.bind(this); } try { - this.port = chrome.runtime.connect({name: vAPI.sessionId}) || null; + this.port = browser.runtime.connect({name: vAPI.sessionId}) || null; } catch (ex) { this.port = null; } - if ( this.port !== null ) { - this.port.onMessage.addListener(this.messageListenerCallback); - this.port.onDisconnect.addListener(this.disconnectListenerBound); - this.portTimerDelay = 10000; - if ( this.portTimer === null ) { - this.portTimer = vAPI.setTimeout( - this.portPollerBound, - this.portTimerDelay - ); - } + // Not having a valid port at this point means the main process is + // not available: no point keeping the content scripts alive. + if ( this.port === null ) { + vAPI.shutdown.exec(); + return null; + } + this.port.onMessage.addListener(this.messageListenerBound); + this.port.onDisconnect.addListener(this.disconnectListenerBound); + this.portTimerDelay = 10000; + if ( this.portTimer === null ) { + this.portTimer = vAPI.setTimeout( + this.portPollerBound, + this.portTimerDelay + ); } return this.port; }, @@ -184,70 +201,68 @@ vAPI.messaging = { return this.port !== null ? this.port : this.createPort(); }, - send: function(channelName, message, callback) { + send: function(channel, msg) { // Too large a gap between the last request and the last response means // the main process is no longer reachable: memory leaks and bad // performance become a risk -- especially for long-lived, dynamic // pages. Guard against this. - if ( this.pending.size > 25 ) { + if ( this.pending.size > 50 ) { vAPI.shutdown.exec(); } - var port = this.getPort(); + const port = this.getPort(); if ( port === null ) { - if ( typeof callback === 'function' ) { callback(); } - return; + return Promise.resolve(); } - var auxProcessId; - if ( callback ) { - auxProcessId = this.auxProcessId++; - this.pending.set(auxProcessId, callback); - } - port.postMessage({ - channelName: channelName, - auxProcessId: auxProcessId, - msg: message + const msgId = this.msgIdGenerator++; + const promise = new Promise(resolve => { + this.pending.set(msgId, resolve); }); + port.postMessage({ channel, msgId, msg }); + return promise; }, - addListener: function(listener) { - this.listeners.add(listener); - this.getPort(); - }, - - removeListener: function(listener) { - this.listeners.delete(listener); - }, - - removeAllListeners: function() { - this.listeners.clear(); - }, - - sendToListeners: function(msg) { - for ( var listener of this.listeners ) { - listener(msg); + // Dynamically extend capabilities. + extend: function() { + if ( this.extended === undefined ) { + this.extended = vAPI.messaging.send('vapi', { + what: 'extendClient' + }).then(( ) => { + return self.vAPI instanceof Object && + this.extensions.length !== 0; + }).catch(( ) => { + }); } - } + return this.extended; + }, }; +vAPI.shutdown.add(( ) => { + vAPI.messaging.shutdown(); + window.vAPI = undefined; +}); + +/******************************************************************************/ /******************************************************************************/ -// No need to have vAPI client linger around after shutdown if -// we are not a top window (because element picker can still -// be injected in top window). -if ( window !== window.top ) { - vAPI.shutdown.add(function() { - vAPI = null; - }); } +// <<<<<<<< end of HUGE-IF-BLOCK -/******************************************************************************/ -vAPI.setTimeout = vAPI.setTimeout || function(callback, delay) { - setTimeout(function() { callback(); }, delay); -}; -/******************************************************************************/ -})(this); // jshint ignore: line -/******************************************************************************/ + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uBO never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/platform/chromium/vapi-common.js b/platform/chromium/vapi-common.js index 659a5d4..140c9ca 100644 --- a/platform/chromium/vapi-common.js +++ b/platform/chromium/vapi-common.js @@ -23,32 +23,14 @@ 'use strict'; -/******************************************************************************/ - -if ( self.browser instanceof Object ) { - self.chrome = self.browser; -} else { - self.browser = self.chrome; -} - /******************************************************************************/ /******************************************************************************/ -(function(self) { +vAPI.T0 = Date.now(); /******************************************************************************/ -// https://bugs.chromium.org/p/project-zero/issues/detail?id=1225&desc=6#c10 -if ( self.vAPI === undefined || self.vAPI.uMatrix !== true ) { - self.vAPI = { uMatrix: true }; -} - -var vAPI = self.vAPI; -var chrome = self.chrome; - -/******************************************************************************/ - -vAPI.setTimeout = vAPI.setTimeout || window.setTimeout.bind(window); +vAPI.setTimeout = vAPI.setTimeout || self.setTimeout.bind(self); /******************************************************************************/ @@ -57,113 +39,182 @@ vAPI.webextFlavor = { soup: new Set() }; -(function() { - var ua = navigator.userAgent, - flavor = vAPI.webextFlavor, - soup = flavor.soup; - var dispatch = function() { +(( ) => { + const ua = navigator.userAgent; + const flavor = vAPI.webextFlavor; + const soup = flavor.soup; + const dispatch = function() { window.dispatchEvent(new CustomEvent('webextFlavor')); }; // This is always true. - soup.add('ublock'); + soup.add('ublock').add('webext'); + + // Whether this is a dev build. + if ( /^\d+\.\d+\.\d+\D/.test(browser.runtime.getManifest().version) ) { + soup.add('devbuild'); + } if ( /\bMobile\b/.test(ua) ) { soup.add('mobile'); } // Asynchronous - var async = self.browser instanceof Object && - typeof self.browser.runtime.getBrowserInfo === 'function'; - if ( async ) { - self.browser.runtime.getBrowserInfo().then(function(info) { - flavor.major = parseInt(info.version, 10) || 0; + if ( + browser instanceof Object && + typeof browser.runtime.getBrowserInfo === 'function' + ) { + browser.runtime.getBrowserInfo().then(info => { + flavor.major = parseInt(info.version, 10) || 60; soup.add(info.vendor.toLowerCase()) .add(info.name.toLowerCase()); - if ( flavor.major >= 53 ) { soup.add('user_stylesheet'); } - if ( flavor.major >= 57 ) { soup.add('html_filtering'); } + if ( soup.has('firefox') && flavor.major < 57 ) { + soup.delete('html_filtering'); + } dispatch(); }); + if ( browser.runtime.getURL('').startsWith('moz-extension://') ) { + soup.add('mozilla') + .add('firefox') + .add('user_stylesheet') + .add('html_filtering'); + flavor.major = 60; + } + return; } - // Synchronous - var match = /Firefox\/([\d.]+)/.exec(ua); - if ( match !== null ) { + // Synchronous -- order of tests is important + let match; + if ( (match = /\bEdge\/(\d+)/.exec(ua)) !== null ) { flavor.major = parseInt(match[1], 10) || 0; - soup.add('mozilla') - .add('firefox'); - if ( flavor.major >= 53 ) { soup.add('user_stylesheet'); } - if ( flavor.major >= 57 ) { soup.add('html_filtering'); } - } else { - match = /OPR\/([\d.]+)/.exec(ua); - if ( match !== null ) { - var reEx = /Chrom(?:e|ium)\/([\d.]+)/; - if ( reEx.test(ua) ) { match = reEx.exec(ua); } - flavor.major = parseInt(match[1], 10) || 0; - soup.add('opera').add('chromium'); - } else { - match = /Chromium\/([\d.]+)/.exec(ua); - if ( match !== null ) { - flavor.major = parseInt(match[1], 10) || 0; - soup.add('chromium'); - } else { - match = /Chrome\/([\d.]+)/.exec(ua); - if ( match !== null ) { - flavor.major = parseInt(match[1], 10) || 0; - soup.add('google').add('chromium'); - } - } - } - // https://github.com/gorhill/uBlock/issues/3588 - if ( soup.has('chromium') && flavor.major >= 67 ) { - soup.add('user_stylesheet'); - } + soup.add('microsoft').add('edge'); + } else if ( (match = /\bOPR\/(\d+)/.exec(ua)) !== null ) { + const reEx = /\bChrom(?:e|ium)\/([\d.]+)/; + if ( reEx.test(ua) ) { match = reEx.exec(ua); } + flavor.major = parseInt(match[1], 10) || 0; + soup.add('opera').add('chromium'); + } else if ( (match = /\bChromium\/(\d+)/.exec(ua)) !== null ) { + flavor.major = parseInt(match[1], 10) || 0; + soup.add('chromium'); + } else if ( (match = /\bChrome\/(\d+)/.exec(ua)) !== null ) { + flavor.major = parseInt(match[1], 10) || 0; + soup.add('google').add('chromium'); + } else if ( (match = /\bSafari\/(\d+)/.exec(ua)) !== null ) { + flavor.major = parseInt(match[1], 10) || 0; + soup.add('apple').add('safari'); + } + + // https://github.com/gorhill/uBlock/issues/3588 + if ( soup.has('chromium') && flavor.major >= 66 ) { + soup.add('user_stylesheet'); } // Don't starve potential listeners - if ( !async ) { - vAPI.setTimeout(dispatch, 97); - } + vAPI.setTimeout(dispatch, 97); })(); /******************************************************************************/ -// http://www.w3.org/International/questions/qa-scripts#directions +{ + const punycode = self.punycode; + const reCommonHostnameFromURL = /^https?:\/\/([0-9a-z_][0-9a-z._-]*[0-9a-z])\//; + const reAuthorityFromURI = /^(?:[^:\/?#]+:)?(\/\/[^\/?#]+)/; + const reHostFromNakedAuthority = /^[0-9a-z._-]+[0-9a-z]$/i; + const reHostFromAuthority = /^(?:[^@]*@)?([^:]+)(?::\d*)?$/; + const reIPv6FromAuthority = /^(?:[^@]*@)?(\[[0-9a-f:]+\])(?::\d*)?$/i; + const reMustNormalizeHostname = /[^0-9a-z._-]/; -var setScriptDirection = function(language) { - document.body.setAttribute( - 'dir', - ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(language) !== -1 ? 'rtl' : 'ltr' - ); -}; + vAPI.hostnameFromURI = function(uri) { + let matches = reCommonHostnameFromURL.exec(uri); + if ( matches !== null ) { return matches[1]; } + matches = reAuthorityFromURI.exec(uri); + if ( matches === null ) { return ''; } + const authority = matches[1].slice(2); + if ( reHostFromNakedAuthority.test(authority) ) { + return authority.toLowerCase(); + } + matches = reHostFromAuthority.exec(authority); + if ( matches === null ) { + matches = reIPv6FromAuthority.exec(authority); + if ( matches === null ) { return ''; } + } + let hostname = matches[1]; + while ( hostname.endsWith('.') ) { + hostname = hostname.slice(0, -1); + } + if ( reMustNormalizeHostname.test(hostname) ) { + hostname = punycode.toASCII(hostname.toLowerCase()); + } + return hostname; + }; + + const reHostnameFromNetworkURL = + /^(?:http|ws|ftp)s?:\/\/([0-9a-z_][0-9a-z._-]*[0-9a-z])\//; + + vAPI.hostnameFromNetworkURL = function(url) { + const matches = reHostnameFromNetworkURL.exec(url); + return matches !== null ? matches[1] : ''; + }; + + const psl = self.publicSuffixList; + const reIPAddressNaive = /^\d+\.\d+\.\d+\.\d+$|^\[[\da-zA-Z:]+\]$/; + + vAPI.domainFromHostname = function(hostname) { + return reIPAddressNaive.test(hostname) + ? hostname + : psl.getDomain(hostname); + }; + + vAPI.domainFromURI = function(uri) { + return uri !== '' + ? vAPI.domainFromHostname(vAPI.hostnameFromURI(uri)) + : ''; + }; +} /******************************************************************************/ vAPI.download = function(details) { - if ( !details.url ) { - return; - } - - var a = document.createElement('a'); + if ( !details.url ) { return; } + const a = document.createElement('a'); a.href = details.url; a.setAttribute('download', details.filename || ''); + a.setAttribute('type', 'text/plain'); a.dispatchEvent(new MouseEvent('click')); }; /******************************************************************************/ -vAPI.getURL = chrome.runtime.getURL; +vAPI.getURL = browser.runtime.getURL; /******************************************************************************/ -vAPI.i18n = chrome.i18n.getMessage; +vAPI.i18n = browser.i18n.getMessage; -setScriptDirection(vAPI.i18n('@@ui_locale')); +// http://www.w3.org/International/questions/qa-scripts#directions +document.body.setAttribute( + 'dir', + ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(vAPI.i18n('@@ui_locale')) !== -1 + ? 'rtl' + : 'ltr' +); /******************************************************************************/ +// https://github.com/gorhill/uBlock/issues/3057 +// - webNavigation.onCreatedNavigationTarget become broken on Firefox when we +// try to make the popup panel close itself using the original +// `window.open('', '_self').close()`. + vAPI.closePopup = function() { - window.close(); + if ( vAPI.webextFlavor.soup.has('firefox') ) { + window.close(); + return; + } + + // TODO: try to figure why this was used instead of a plain window.close(). + // https://github.com/gorhill/uBlock/commit/b301ac031e0c2e9a99cb6f8953319d44e22f33d2#diff-bc664f26b9c453e0d43a9379e8135c6a + window.open('', '_self').close(); }; /******************************************************************************/ @@ -207,8 +258,22 @@ vAPI.localStorage = { } }; -/******************************************************************************/ -})(this); -/******************************************************************************/ + + + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uBO never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/platform/chromium/vapi-webrequest.js b/platform/chromium/vapi-webrequest.js index 034a1ec..db22579 100644 --- a/platform/chromium/vapi-webrequest.js +++ b/platform/chromium/vapi-webrequest.js @@ -25,40 +25,17 @@ /******************************************************************************/ -(function() { +(( ) => { + // https://github.com/uBlockOrigin/uBlock-issues/issues/407 + if ( vAPI.webextFlavor.soup.has('chromium') === false ) { return; } + const extToTypeMap = new Map([ ['eot','font'],['otf','font'],['svg','font'],['ttf','font'],['woff','font'],['woff2','font'], ['mp3','media'],['mp4','media'],['webm','media'], ['gif','image'],['ico','image'],['jpeg','image'],['jpg','image'],['png','image'],['webp','image'] ]); - // https://www.reddit.com/r/uBlockOrigin/comments/9vcrk3/bug_in_ubo_1173_betas_when_saving_files_hosted_on/ - // Some types can be mapped from 'other', thus include 'other' if and - // only if the caller is interested in at least one of those types. - const denormalizeTypes = function(aa) { - if ( aa.length === 0 ) { - return Array.from(vAPI.net.validTypes); - } - const out = new Set(); - let i = aa.length; - while ( i-- ) { - const type = aa[i]; - if ( vAPI.net.validTypes.has(type) ) { - out.add(type); - } - } - if ( out.has('other') === false ) { - for ( const type of extToTypeMap.values() ) { - if ( out.has(type) ) { - out.add('other'); - break; - } - } - } - return Array.from(out); - }; - - const headerValue = function(headers, name) { + const headerValue = (headers, name) => { let i = headers.length; while ( i-- ) { if ( headers[i].name.toLowerCase() === name ) { @@ -70,128 +47,131 @@ const parsedURL = new URL('https://www.example.org/'); - vAPI.net.normalizeDetails = function(details) { - let type = details.type; + // Extend base class to normalize as per platform. - // https://github.com/uBlockOrigin/uMatrix-issues/issues/156#issuecomment-494427094 - if ( type === 'main_frame' ) { - details.documentUrl = details.url; + vAPI.Net = class extends vAPI.Net { + constructor() { + super(); + this.suspendedTabIds = new Set(); } - // Chromium 63+ supports the `initiator` property, which contains - // the URL of the origin from which the network request was made. - else if ( - typeof details.initiator === 'string' && - details.initiator !== 'null' - ) { - details.documentUrl = details.initiator; - } - - // https://github.com/gorhill/uBlock/issues/1493 - // Chromium 49+/WebExtensions support a new request type: `ping`, - // which is fired as a result of using `navigator.sendBeacon`. - if ( type === 'ping' ) { - details.type = 'beacon'; - return; - } - - if ( type === 'imageset' ) { - details.type = 'image'; - return; - } - - // The rest of the function code is to normalize type - if ( type !== 'other' ) { return; } - - // Try to map known "extension" part of URL to request type. - parsedURL.href = details.url; - const path = parsedURL.pathname, - pos = path.indexOf('.', path.length - 6); - if ( pos !== -1 && (type = extToTypeMap.get(path.slice(pos + 1))) ) { - details.type = type; - return; - } - - // Try to extract type from response headers if present. - if ( details.responseHeaders ) { - type = headerValue(details.responseHeaders, 'content-type'); - if ( type.startsWith('font/') ) { - details.type = 'font'; - return; + normalizeDetails(details) { + // Chromium 63+ supports the `initiator` property, which contains + // the URL of the origin from which the network request was made. + if ( + typeof details.initiator === 'string' && + details.initiator !== 'null' + ) { + details.documentUrl = details.initiator; } - if ( type.startsWith('image/') ) { + + let type = details.type; + + if ( type === 'imageset' ) { details.type = 'image'; return; } - if ( type.startsWith('audio/') || type.startsWith('video/') ) { - details.type = 'media'; + + // The rest of the function code is to normalize type + if ( type !== 'other' ) { return; } + + // Try to map known "extension" part of URL to request type. + parsedURL.href = details.url; + const path = parsedURL.pathname, + pos = path.indexOf('.', path.length - 6); + if ( pos !== -1 && (type = extToTypeMap.get(path.slice(pos + 1))) ) { + details.type = type; return; } - } - }; - vAPI.net.denormalizeFilters = function(filters) { - const urls = filters.urls || [ '' ]; - let types = filters.types; - if ( Array.isArray(types) ) { - types = denormalizeTypes(types); - } - if ( - (vAPI.net.validTypes.has('websocket')) && - (types === undefined || types.indexOf('websocket') !== -1) && - (urls.indexOf('') === -1) - ) { - if ( urls.indexOf('ws://*/*') === -1 ) { - urls.push('ws://*/*'); - } - if ( urls.indexOf('wss://*/*') === -1 ) { - urls.push('wss://*/*'); + // Try to extract type from response headers if present. + if ( details.responseHeaders ) { + type = headerValue(details.responseHeaders, 'content-type'); + if ( type.startsWith('font/') ) { + details.type = 'font'; + return; + } + if ( type.startsWith('image/') ) { + details.type = 'image'; + return; + } + if ( type.startsWith('audio/') || type.startsWith('video/') ) { + details.type = 'media'; + return; + } } } - return { types, urls }; - }; -})(); - -/******************************************************************************/ - -// https://github.com/gorhill/uBlock/issues/2067 -// Experimental: Block everything until uBO is fully ready. - -vAPI.net.onBeforeReady = (function() { - let pendings; - - const handler = function(details) { - if ( pendings === undefined ) { return; } - if ( details.tabId < 0 ) { return; } - - pendings.add(details.tabId); - - //console.log(`Aborting tab ${details.tabId}: ${details.type} ${details.url}`); - - return { cancel: true }; - }; - - return { - experimental: true, - start: function() { - pendings = new Set(); - browser.webRequest.onBeforeRequest.addListener( - handler, - { urls: [ 'http://*/*', 'https://*/*' ] }, - [ 'blocking' ] - ); - }, - // https://github.com/gorhill/uBlock/issues/2067 - // Force-reload tabs for which network requests were blocked - // during launch. This can happen only if tabs were "suspended". - stop: function() { - if ( pendings === undefined ) { return; } - browser.webRequest.onBeforeRequest.removeListener(handler); - for ( const tabId of pendings ) { - //console.log(`Reloading tab ${tabId}`); + // https://www.reddit.com/r/uBlockOrigin/comments/9vcrk3/ + // Some types can be mapped from 'other', thus include 'other' if and + // only if the caller is interested in at least one of those types. + denormalizeTypes(types) { + if ( types.length === 0 ) { + return Array.from(this.validTypes); + } + const out = new Set(); + for ( const type of types ) { + if ( this.validTypes.has(type) ) { + out.add(type); + } + } + if ( out.has('other') === false ) { + for ( const type of extToTypeMap.values() ) { + if ( out.has(type) ) { + out.add('other'); + break; + } + } + } + return Array.from(out); + } + suspendOneRequest(details) { + this.suspendedTabIds.add(details.tabId); + return { cancel: true }; + } + unsuspendAllRequests() { + for ( const tabId of this.suspendedTabIds ) { vAPI.tabs.reload(tabId); } - pendings = undefined; - }, + this.suspendedTabIds.clear(); + } + }; +})(); + +/******************************************************************************/ + +// https://github.com/uBlockOrigin/uBlock-issues/issues/548 +// Use `X-DNS-Prefetch-Control` to workaround Chromium's disregard of the +// setting "Predict network actions to improve page load performance". + +vAPI.prefetching = (( ) => { + // https://github.com/uBlockOrigin/uBlock-issues/issues/407 + if ( vAPI.webextFlavor.soup.has('chromium') === false ) { return; } + + let listening = false; + + const onHeadersReceived = function(details) { + details.responseHeaders.push({ + name: 'X-DNS-Prefetch-Control', + value: 'off' + }); + return { responseHeaders: details.responseHeaders }; + }; + + return state => { + const wr = chrome.webRequest; + if ( state && listening ) { + wr.onHeadersReceived.removeListener(onHeadersReceived); + listening = false; + } else if ( !state && !listening ) { + wr.onHeadersReceived.addListener( + onHeadersReceived, + { + urls: [ 'http://*/*', 'https://*/*' ], + types: [ 'main_frame', 'sub_frame' ] + }, + [ 'blocking', 'responseHeaders' ] + ); + listening = true; + } }; })(); diff --git a/platform/chromium/vapi.js b/platform/chromium/vapi.js new file mode 100644 index 0000000..10fc4f9 --- /dev/null +++ b/platform/chromium/vapi.js @@ -0,0 +1,86 @@ +/******************************************************************************* + + uMatrix - a browser extension to block requests. + Copyright (C) 2017-present Raymond Hill + + 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 {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +/* global HTMLDocument, XMLDocument */ + +// For background page, auxiliary pages, and content scripts. + +/******************************************************************************/ + +if ( self.browser instanceof Object ) { + self.chrome = self.browser; +} else { + self.browser = self.chrome; +} + +/******************************************************************************/ + +// https://bugzilla.mozilla.org/show_bug.cgi?id=1408996#c9 +var vAPI = self.vAPI; // jshint ignore:line + +// https://github.com/chrisaljoudi/uBlock/issues/464 +// https://github.com/chrisaljoudi/uBlock/issues/1528 +// A XMLDocument can be a valid HTML document. + +// https://github.com/gorhill/uBlock/issues/1124 +// Looks like `contentType` is on track to be standardized: +// https://dom.spec.whatwg.org/#concept-document-content-type + +// https://forums.lanik.us/viewtopic.php?f=64&t=31522 +// Skip text/plain documents. + +if ( + ( + document instanceof HTMLDocument || + document instanceof XMLDocument && + document.createElement('div') instanceof HTMLDivElement + ) && + ( + /^image\/|^text\/plain/.test(document.contentType || '') === false + ) && + ( + self.vAPI instanceof Object === false || vAPI.uMatrix !== true + ) +) { + vAPI = self.vAPI = { uMatrix: true }; +} + + + + + + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uMatrix never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/platform/chromium/webext.js b/platform/chromium/webext.js new file mode 100644 index 0000000..749b389 --- /dev/null +++ b/platform/chromium/webext.js @@ -0,0 +1,176 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2019-present Raymond Hill + + 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 {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +// `webext` is a promisified api of `chrome`. Entries are added as +// the promisification of uBO progress. + +const webext = (( ) => { // jshint ignore:line +// >>>>> start of private scope + +const noopFunc = ( ) => { }; + +const promisifyNoFail = function(thisArg, fnName, outFn = r => r) { + const fn = thisArg[fnName]; + return function() { + return new Promise(resolve => { + fn.call(thisArg, ...arguments, function() { + if ( chrome.runtime.lastError instanceof Object ) { + void chrome.runtime.lastError.message; + } + resolve(outFn(...arguments)); + }); + }); + }; +}; + +const promisify = function(thisArg, fnName) { + const fn = thisArg[fnName]; + return function() { + return new Promise((resolve, reject) => { + fn.call(thisArg, ...arguments, function() { + const lastError = chrome.runtime.lastError; + if ( lastError instanceof Object ) { + return reject(lastError.message); + } + resolve(...arguments); + }); + }); + }; +}; + +const webext = { + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction + browserAction: { + setBadgeBackgroundColor: promisifyNoFail(chrome.browserAction, 'setBadgeBackgroundColor'), + setBadgeText: promisifyNoFail(chrome.browserAction, 'setBadgeText'), + setBadgeTextColor: noopFunc, + setIcon: promisifyNoFail(chrome.browserAction, 'setIcon'), + setTitle: promisifyNoFail(chrome.browserAction, 'setTitle'), + }, + cookies: { + getAll: promisifyNoFail(chrome.cookies, 'getAll'), + remove: promisifyNoFail(chrome.cookies, 'remove'), + }, + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus + /* + menus: { + create: function() { + return chrome.contextMenus.create(...arguments, ( ) => { + void chrome.runtime.lastError; + }); + }, + onClicked: chrome.contextMenus.onClicked, + remove: promisifyNoFail(chrome.contextMenus, 'remove'), + }, + */ + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy + privacy: { + }, + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage + storage: { + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/local + local: { + clear: promisify(chrome.storage.local, 'clear'), + get: promisify(chrome.storage.local, 'get'), + getBytesInUse: promisify(chrome.storage.local, 'getBytesInUse'), + remove: promisify(chrome.storage.local, 'remove'), + set: promisify(chrome.storage.local, 'set'), + }, + }, + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs + tabs: { + get: promisifyNoFail(chrome.tabs, 'get', tab => tab instanceof Object ? tab : null), + executeScript: promisifyNoFail(chrome.tabs, 'executeScript'), + insertCSS: promisifyNoFail(chrome.tabs, 'insertCSS'), + query: promisifyNoFail(chrome.tabs, 'query', tabs => Array.isArray(tabs) ? tabs : []), + reload: promisifyNoFail(chrome.tabs, 'reload'), + remove: promisifyNoFail(chrome.tabs, 'remove'), + update: promisifyNoFail(chrome.tabs, 'update', tab => tab instanceof Object ? tab : null), + }, + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webNavigation + webNavigation: { + getFrame: promisify(chrome.webNavigation, 'getFrame'), + }, + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows + windows: { + get: promisifyNoFail(chrome.windows, 'get', win => win instanceof Object ? win : null), + create: promisifyNoFail(chrome.windows, 'create', win => win instanceof Object ? win : null), + update: promisifyNoFail(chrome.windows, 'update', win => win instanceof Object ? win : null), + }, +}; + +// browser.privacy entries +{ + const settings = [ + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network + [ 'network', 'networkPredictionEnabled' ], + [ 'network', 'webRTCIPHandlingPolicy' ], + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/websites + [ 'websites', 'hyperlinkAuditingEnabled' ], + ]; + for ( const [ category, setting ] of settings ) { + let categoryEntry = webext.privacy[category]; + if ( categoryEntry instanceof Object === false ) { + categoryEntry = webext.privacy[category] = {}; + } + const settingEntry = categoryEntry[setting] = {}; + const thisArg = chrome.privacy[category][setting]; + settingEntry.clear = promisifyNoFail(thisArg, 'clear'); + settingEntry.get = promisifyNoFail(thisArg, 'get'); + settingEntry.set = promisifyNoFail(thisArg, 'set'); + } +} + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/managed +if ( chrome.storage.managed instanceof Object ) { + webext.storage.managed = { + get: promisify(chrome.storage.managed, 'get'), + }; +} + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync +if ( chrome.storage.sync instanceof Object ) { + webext.storage.sync = { + QUOTA_BYTES: chrome.storage.sync.QUOTA_BYTES, + QUOTA_BYTES_PER_ITEM: chrome.storage.sync.QUOTA_BYTES_PER_ITEM, + MAX_ITEMS: chrome.storage.sync.MAX_ITEMS, + MAX_WRITE_OPERATIONS_PER_HOUR: chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR, + MAX_WRITE_OPERATIONS_PER_MINUTE: chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE, + + clear: promisify(chrome.storage.sync, 'clear'), + get: promisify(chrome.storage.sync, 'get'), + getBytesInUse: promisify(chrome.storage.sync, 'getBytesInUse'), + remove: promisify(chrome.storage.sync, 'remove'), + set: promisify(chrome.storage.sync, 'set'), + }; +} + +// https://bugs.chromium.org/p/chromium/issues/detail?id=608854 +if ( chrome.tabs.removeCSS instanceof Function ) { + webext.tabs.removeCSS = promisifyNoFail(chrome.tabs, 'removeCSS'); +} + +return webext; + +// <<<<< end of private scope +})(); diff --git a/platform/firefox/manifest.json b/platform/firefox/manifest.json index 2f47a97..72af30e 100644 --- a/platform/firefox/manifest.json +++ b/platform/firefox/manifest.json @@ -46,6 +46,7 @@ "permissions": [ "browsingData", "cookies", + "dns", "privacy", "storage", "tabs", diff --git a/platform/firefox/vapi-cachestorage.js b/platform/firefox/vapi-cachestorage.js deleted file mode 100644 index b14fb2a..0000000 --- a/platform/firefox/vapi-cachestorage.js +++ /dev/null @@ -1,263 +0,0 @@ -/******************************************************************************* - - uMatrix - a browser extension to block requests. - Copyright (C) 2016-2017 The uBlock Origin 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 {http://www.gnu.org/licenses/}. - - Home: https://github.com/gorhill/uBlock -*/ - -/* global indexedDB, IDBDatabase */ - -'use strict'; - -/******************************************************************************/ - -// The code below has been originally manually imported from: -// Commit: https://github.com/nikrolls/uBlock-Edge/commit/d1538ea9bea89d507219d3219592382eee306134 -// Commit date: 29 October 2016 -// Commit author: https://github.com/nikrolls -// Commit message: "Implement cacheStorage using IndexedDB" - -// The original imported code has been subsequently modified as it was not -// compatible with Firefox. -// (a Promise thing, see https://github.com/dfahlander/Dexie.js/issues/317) -// Furthermore, code to migrate from browser.storage.local to vAPI.cacheStorage -// has been added, for seamless migration of cache-related entries into -// indexedDB. - -// Imported from uBlock Origin project. - -vAPI.cacheStorage = (function() { - const STORAGE_NAME = 'uMatrixCacheStorage'; - var db; - var pending = []; - - // prime the db so that it's ready asap for next access. - getDb(noopfn); - - return { get, set, remove, clear, getBytesInUse }; - - function get(input, callback) { - if ( typeof callback !== 'function' ) { return; } - if ( input === null ) { - return getAllFromDb(callback); - } - var toRead, output = {}; - if ( typeof input === 'string' ) { - toRead = [ input ]; - } else if ( Array.isArray(input) ) { - toRead = input; - } else /* if ( typeof input === 'object' ) */ { - toRead = Object.keys(input); - output = input; - } - return getFromDb(toRead, output, callback); - } - - function set(input, callback) { - putToDb(input, callback); - } - - function remove(key, callback) { - deleteFromDb(key, callback); - } - - function clear(callback) { - clearDb(callback); - } - - function getBytesInUse(keys, callback) { - // TODO: implement this - callback(0); - } - - function genericErrorHandler(error) { - console.error('[%s]', STORAGE_NAME, error); - } - - function noopfn() { - } - - function processPendings() { - var cb; - while ( (cb = pending.shift()) ) { - cb(db); - } - } - - function getDb(callback) { - if ( pending === undefined ) { - return callback(); - } - if ( pending.length !== 0 ) { - return pending.push(callback); - } - if ( db instanceof IDBDatabase ) { - return callback(db); - } - pending.push(callback); - if ( pending.length !== 1 ) { return; } - // https://github.com/gorhill/uBlock/issues/3156 - // I have observed that no event was fired in Tor Browser 7.0.7 + - // medium security level after the request to open the database was - // created. When this occurs, I have also observed that the `error` - // property was already set, so this means uBO can detect here whether - // the database can be opened successfully. A try-catch block is - // necessary when reading the `error` property because we are not - // allowed to read this propery outside of event handlers in newer - // implementation of IDBRequest (my understanding). - var req; - try { - req = indexedDB.open(STORAGE_NAME, 1); - if ( req.error ) { - console.log(req.error); - req = undefined; - } - } catch(ex) { - } - if ( req === undefined ) { - processPendings(); - pending = undefined; - return; - } - req.onupgradeneeded = function(ev) { - req = undefined; - db = ev.target.result; - db.onerror = db.onabort = genericErrorHandler; - var table = db.createObjectStore(STORAGE_NAME, { keyPath: 'key' }); - table.createIndex('value', 'value', { unique: false }); - }; - req.onsuccess = function(ev) { - req = undefined; - db = ev.target.result; - db.onerror = db.onabort = genericErrorHandler; - processPendings(); - }; - req.onerror = req.onblocked = function() { - req = undefined; - console.log(this.error); - processPendings(); - pending = undefined; - }; - } - - function getFromDb(keys, store, callback) { - if ( typeof callback !== 'function' ) { return; } - if ( keys.length === 0 ) { return callback(store); } - var gotOne = function() { - if ( typeof this.result === 'object' ) { - store[this.result.key] = this.result.value; - } - }; - getDb(function(db) { - if ( !db ) { return callback(); } - var transaction = db.transaction(STORAGE_NAME); - transaction.oncomplete = - transaction.onerror = - transaction.onabort = function() { - return callback(store); - }; - var table = transaction.objectStore(STORAGE_NAME); - for ( var key of keys ) { - var req = table.get(key); - req.onsuccess = gotOne; - req.onerror = noopfn; - req = undefined; - } - }); - } - - function getAllFromDb(callback) { - if ( typeof callback !== 'function' ) { - callback = noopfn; - } - getDb(function(db) { - if ( !db ) { return callback(); } - var output = {}; - var transaction = db.transaction(STORAGE_NAME); - transaction.oncomplete = - transaction.onerror = - transaction.onabort = function() { - callback(output); - }; - var table = transaction.objectStore(STORAGE_NAME), - req = table.openCursor(); - req.onsuccess = function(ev) { - var cursor = ev.target.result; - if ( !cursor ) { return; } - output[cursor.key] = cursor.value; - cursor.continue(); - }; - }); - } - - function putToDb(input, callback) { - if ( typeof callback !== 'function' ) { - callback = noopfn; - } - var keys = Object.keys(input); - if ( keys.length === 0 ) { return callback(); } - getDb(function(db) { - if ( !db ) { return callback(); } - var transaction = db.transaction(STORAGE_NAME, 'readwrite'); - transaction.oncomplete = - transaction.onerror = - transaction.onabort = callback; - var table = transaction.objectStore(STORAGE_NAME); - for ( var key of keys ) { - var entry = {}; - entry.key = key; - entry.value = input[key]; - table.put(entry); - entry = undefined; - } - }); - } - - function deleteFromDb(input, callback) { - if ( typeof callback !== 'function' ) { - callback = noopfn; - } - var keys = Array.isArray(input) ? input.slice() : [ input ]; - if ( keys.length === 0 ) { return callback(); } - getDb(function(db) { - if ( !db ) { return callback(); } - var transaction = db.transaction(STORAGE_NAME, 'readwrite'); - transaction.oncomplete = - transaction.onerror = - transaction.onabort = callback; - var table = transaction.objectStore(STORAGE_NAME); - for ( var key of keys ) { - table.delete(key); - } - }); - } - - function clearDb(callback) { - if ( typeof callback !== 'function' ) { - callback = noopfn; - } - getDb(function(db) { - if ( !db ) { return callback(); } - var req = db.transaction(STORAGE_NAME, 'readwrite') - .objectStore(STORAGE_NAME) - .clear(); - req.onsuccess = req.onerror = callback; - }); - } -}()); - -/******************************************************************************/ diff --git a/platform/firefox/vapi-webrequest.js b/platform/firefox/vapi-webrequest.js index 45b819c..3d32c40 100644 --- a/platform/firefox/vapi-webrequest.js +++ b/platform/firefox/vapi-webrequest.js @@ -25,12 +25,14 @@ /******************************************************************************/ -(function() { +(( ) => { + // https://github.com/uBlockOrigin/uBlock-issues/issues/407 + if ( vAPI.webextFlavor.soup.has('firefox') === false ) { return; } // https://github.com/gorhill/uBlock/issues/2950 // Firefox 56 does not normalize URLs to ASCII, uBO must do this itself. // https://bugzilla.mozilla.org/show_bug.cgi?id=945240 - const evalMustPunycode = function() { + const evalMustPunycode = ( ) => { return vAPI.webextFlavor.soup.has('firefox') && vAPI.webextFlavor.major < 57; }; @@ -43,142 +45,218 @@ mustPunycode = evalMustPunycode(); }, { once: true }); - const denormalizeTypes = function(aa) { - if ( aa.length === 0 ) { - return Array.from(vAPI.net.validTypes); - } - const out = new Set(); - let i = aa.length; - while ( i-- ) { - let type = aa[i]; - if ( vAPI.net.validTypes.has(type) ) { - out.add(type); - } - if ( type === 'image' && vAPI.net.validTypes.has('imageset') ) { - out.add('imageset'); - } - if ( type === 'sub_frame' ) { - out.add('object'); - } - } - return Array.from(out); - }; - const punycode = self.punycode; const reAsciiHostname = /^https?:\/\/[0-9a-z_.:@-]+[/?#]/; const parsedURL = new URL('about:blank'); - vAPI.net.normalizeDetails = function(details) { - if ( mustPunycode && !reAsciiHostname.test(details.url) ) { - parsedURL.href = details.url; - details.url = details.url.replace( - parsedURL.hostname, - punycode.toASCII(parsedURL.hostname) - ); + // Related issues: + // - https://github.com/gorhill/uBlock/issues/1327 + // - https://github.com/uBlockOrigin/uBlock-issues/issues/128 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=1503721 + + // Extend base class to normalize as per platform. + + vAPI.Net = class extends vAPI.Net { + constructor() { + super(); + this.pendingRequests = []; + this.cnames = new Map([ [ '', '' ] ]); + this.cnameIgnoreList = null; + this.cnameIgnore1stParty = true; + this.cnameIgnoreExceptions = true; + this.cnameIgnoreRootDocument = true; + this.cnameMaxTTL = 60; + this.cnameReplayFullURL = false; + this.cnameTimer = undefined; + this.canRevealCNAME = browser.dns instanceof Object; } - - const type = details.type; - - // https://github.com/gorhill/uBlock/issues/1493 - // Chromium 49+/WebExtensions support a new request type: `ping`, - // which is fired as a result of using `navigator.sendBeacon`. - if ( type === 'ping' ) { - details.type = 'beacon'; - return; + setOptions(options) { + super.setOptions(options); + this.cnameIgnoreList = this.regexFromStrList(options.cnameIgnoreList); + this.cnameIgnore1stParty = options.cnameIgnore1stParty !== false; + this.cnameIgnoreExceptions = options.cnameIgnoreExceptions !== false; + this.cnameIgnoreRootDocument = options.cnameIgnoreRootDocument !== false; + this.cnameMaxTTL = options.cnameMaxTTL || 120; + this.cnameReplayFullURL = options.cnameReplayFullURL === true; + this.cnames.clear(); this.cnames.set('', ''); } + normalizeDetails(details) { + if ( mustPunycode && !reAsciiHostname.test(details.url) ) { + parsedURL.href = details.url; + details.url = details.url.replace( + parsedURL.hostname, + punycode.toASCII(parsedURL.hostname) + ); + } - if ( type === 'imageset' ) { - details.type = 'image'; - return; - } + const type = details.type; - // https://github.com/uBlockOrigin/uBlock-issues/issues/345 - // Re-categorize an embedded object as a `sub_frame` if its - // content type is that of a HTML document. - if ( type === 'object' && Array.isArray(details.responseHeaders) ) { - for ( const header of details.responseHeaders ) { - if ( header.name.toLowerCase() === 'content-type' ) { - if ( header.value.startsWith('text/html') ) { - details.type = 'sub_frame'; + if ( type === 'imageset' ) { + details.type = 'image'; + return; + } + + // https://github.com/uBlockOrigin/uBlock-issues/issues/345 + // Re-categorize an embedded object as a `sub_frame` if its + // content type is that of a HTML document. + if ( type === 'object' && Array.isArray(details.responseHeaders) ) { + for ( const header of details.responseHeaders ) { + if ( header.name.toLowerCase() === 'content-type' ) { + if ( header.value.startsWith('text/html') ) { + details.type = 'sub_frame'; + } + break; } - break; } } } - }; - - vAPI.net.denormalizeFilters = function(filters) { - const urls = filters.urls || [ '' ]; - let types = filters.types; - if ( Array.isArray(types) ) { - types = denormalizeTypes(types); - } - if ( - (vAPI.net.validTypes.has('websocket')) && - (types === undefined || types.indexOf('websocket') !== -1) && - (urls.indexOf('') === -1) - ) { - if ( urls.indexOf('ws://*/*') === -1 ) { - urls.push('ws://*/*'); + denormalizeTypes(types) { + if ( types.length === 0 ) { + return Array.from(this.validTypes); } - if ( urls.indexOf('wss://*/*') === -1 ) { - urls.push('wss://*/*'); + const out = new Set(); + for ( const type of types ) { + if ( this.validTypes.has(type) ) { + out.add(type); + } + if ( type === 'image' && this.validTypes.has('imageset') ) { + out.add('imageset'); + } + if ( type === 'sub_frame' ) { + out.add('object'); + } } + return Array.from(out); } - return { types, urls }; - }; -})(); - -/******************************************************************************/ - -// Related issues: -// - https://github.com/gorhill/uBlock/issues/1327 -// - https://github.com/uBlockOrigin/uBlock-issues/issues/128 -// - https://bugzilla.mozilla.org/show_bug.cgi?id=1503721 - -vAPI.net.onBeforeReady = (function() { - let pendings; - - const handler = function(details) { - if ( pendings === undefined ) { return; } - if ( details.tabId < 0 ) { return; } - - //console.log(`Deferring tab ${details.tabId}: ${details.type} ${details.url}`); - - const pending = { - details: Object.assign({}, details), - resolve: undefined, - promise: undefined - }; - - pending.promise = new Promise(function(resolve) { - pending.resolve = resolve; - }); - - pendings.push(pending); - - return pending.promise; - }; - - return { - start: function() { - pendings = []; - browser.webRequest.onBeforeRequest.addListener( - handler, - { urls: [ 'http://*/*', 'https://*/*' ] }, - [ 'blocking' ] + processCanonicalName(hn, cn, details) { + const hnBeg = details.url.indexOf(hn); + if ( hnBeg === -1 ) { return; } + const oldURL = details.url; + let newURL = oldURL.slice(0, hnBeg) + cn; + const hnEnd = hnBeg + hn.length; + if ( this.cnameReplayFullURL ) { + newURL += oldURL.slice(hnEnd); + } else { + const pathBeg = oldURL.indexOf('/', hnEnd); + if ( pathBeg !== -1 ) { + newURL += oldURL.slice(hnEnd, pathBeg + 1); + } + } + details.url = newURL; + details.aliasURL = oldURL; + return super.onBeforeSuspendableRequest(details); + } + recordCanonicalName(hn, record) { + let cname = + typeof record.canonicalName === 'string' && + record.canonicalName !== hn + ? record.canonicalName + : ''; + if ( + cname !== '' && + this.cnameIgnore1stParty && + vAPI.domainFromHostname(cname) === vAPI.domainFromHostname(hn) + ) { + cname = ''; + } + if ( + cname !== '' && + this.cnameIgnoreList !== null && + this.cnameIgnoreList.test(cname) + ) { + cname = ''; + } + this.cnames.set(hn, cname); + if ( this.cnameTimer === undefined ) { + this.cnameTimer = self.setTimeout( + ( ) => { + this.cnameTimer = undefined; + this.cnames.clear(); this.cnames.set('', ''); + }, + this.cnameMaxTTL * 60000 + ); + } + return cname; + } + regexFromStrList(list) { + if ( + typeof list !== 'string' || + list.length === 0 || + list === 'unset' || + browser.dns instanceof Object === false + ) { + return null; + } + if ( list === '*' ) { + return /^./; + } + return new RegExp( + '(?:^|\.)(?:' + + list.trim() + .split(/\s+/) + .map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|') + + ')$' ); - }, - stop: function(resolver) { - if ( pendings === undefined ) { return; } - for ( const pending of pendings ) { - const details = pending.details; - vAPI.net.normalizeDetails(details); - //console.log(`Processing tab ${details.tabId}: ${details.type} ${details.url}`); - pending.resolve(resolver(details)); + } + onBeforeSuspendableRequest(details) { + const r = super.onBeforeSuspendableRequest(details); + if ( this.canRevealCNAME === false ) { return r; } + if ( r !== undefined ) { + if ( r.cancel === false ) { return; } + if ( + r.cancel === true || + r.redirectUrl !== undefined || + this.cnameIgnoreExceptions + ) { + return r; + } } - pendings = undefined; - }, + if ( + details.type === 'main_frame' && + this.cnameIgnoreRootDocument + ) { + return; + } + const hn = vAPI.hostnameFromNetworkURL(details.url); + const cname = this.cnames.get(hn); + if ( cname === '' ) { return; } + if ( cname !== undefined ) { + return this.processCanonicalName(hn, cname, details); + } + return browser.dns.resolve(hn, [ 'canonical_name' ]).then( + rec => { + const cname = this.recordCanonicalName(hn, rec); + if ( cname === '' ) { return; } + return this.processCanonicalName(hn, cname, details); + }, + ( ) => { + this.cnames.set(hn, ''); + } + ); + } + suspendOneRequest(details) { + const pending = { + details: Object.assign({}, details), + resolve: undefined, + promise: undefined + }; + pending.promise = new Promise(resolve => { + pending.resolve = resolve; + }); + this.pendingRequests.push(pending); + return pending.promise; + } + unsuspendAllRequests() { + const pendingRequests = this.pendingRequests; + this.pendingRequests = []; + for ( const entry of pendingRequests ) { + entry.resolve(this.onBeforeSuspendableRequest(entry.details)); + } + } + canSuspend() { + return true; + } }; })(); diff --git a/platform/firefox/webext.js b/platform/firefox/webext.js new file mode 100644 index 0000000..4729e7c --- /dev/null +++ b/platform/firefox/webext.js @@ -0,0 +1,24 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2019-present Raymond Hill + + 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 {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +const webext = browser; // jshint ignore:line diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index c370b03..317f6bc 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -73,8 +73,8 @@ "message": "script", "description": "HAS TO FIT IN MATRIX HEADER!" }, - "xhrPrettyName": { - "message": "XHR", + "fetchPrettyName": { + "message": "fetch", "description": "HAS TO FIT IN MATRIX HEADER!" }, "framePrettyName": { @@ -139,6 +139,10 @@ "message": "Spoof