diff --git a/platform/chromium/vapi-background.js b/platform/chromium/vapi-background.js index 473d4fb..41a0d13 100644 --- a/platform/chromium/vapi-background.js +++ b/platform/chromium/vapi-background.js @@ -865,6 +865,183 @@ vAPI.cookies.remove = function(details, callback) { /******************************************************************************/ /******************************************************************************/ +vAPI.cloud = (function() { + var chunkCountPerFetch = 16; // Must be a power of 2 + + // Mind chrome.storage.sync.MAX_ITEMS (512 at time of writing) + var maxChunkCountPerItem = Math.floor(512 * 0.75) & ~(chunkCountPerFetch - 1); + + // Mind chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 at time of writing) + var maxChunkSize = Math.floor(chrome.storage.sync.QUOTA_BYTES_PER_ITEM * 0.75); + + // Mind chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 at time of writing) + var maxStorageSize = chrome.storage.sync.QUOTA_BYTES; + + var options = { + defaultDeviceName: window.navigator.platform, + deviceName: window.localStorage.getItem('deviceName') || '' + }; + + // This is used to find out a rough count of how many chunks exists: + // We "poll" at specific index in order to get a rough idea of how + // large is the stored string. + // This allows reading a single item with only 2 sync operations -- a + // good thing given chrome.storage.syncMAX_WRITE_OPERATIONS_PER_MINUTE + // and chrome.storage.syncMAX_WRITE_OPERATIONS_PER_HOUR. + + var getCoarseChunkCount = function(dataKey, callback) { + var bin = {}; + for ( var i = 0; i < maxChunkCountPerItem; i += 16 ) { + bin[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 ( var i = 0; i < maxChunkCountPerItem; i += 16 ) { + if ( bin[dataKey + i.toString()] === '' ) { + break; + } + chunkCount = i + 16; + } + + callback(chunkCount); + }); + }; + + var deleteChunks = function(dataKey, start) { + var 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( + maxChunkCountPerItem, + Math.ceil(maxStorageSize / maxChunkSize) + ); + for ( var i = start; i < n; i++ ) { + keys.push(dataKey + i.toString()); + } + chrome.storage.sync.remove(keys); + }; + + var start = function(/* dataKeys */) { + }; + + var push = function(dataKey, data, callback) { + var bin = { + 'source': options.deviceName || options.defaultDeviceName, + 'tstamp': Date.now(), + 'data': data, + 'size': 0 + }; + bin.size = JSON.stringify(bin).length; + var item = JSON.stringify(bin); + + // Chunkify taking into account QUOTA_BYTES_PER_ITEM: + // https://developer.chrome.com/extensions/storage#property-sync + // "The maximum size (in bytes) of each individual item in sync + // "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++ ) { + bin[dataKey + i.toString()] = item.substr(i * maxChunkSize, maxChunkSize); + } + bin[dataKey + i.toString()] = ''; // Sentinel + + chrome.storage.sync.set(bin, function() { + var errorStr; + if ( chrome.runtime.lastError ) { + errorStr = chrome.runtime.lastError.message; + } + 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. + var json = [], jsonSlice; + var i = 0; + for (;;) { + jsonSlice = bin[dataKey + i.toString()]; + if ( jsonSlice === '' ) { + break; + } + json.push(jsonSlice); + i += 1; + } + + var 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; + } + callback(options); + }; + + var setOptions = function(details, callback) { + if ( typeof details !== 'object' || details === null ) { + return; + } + + if ( typeof details.deviceName === 'string' ) { + window.localStorage.setItem('deviceName', details.deviceName); + options.deviceName = details.deviceName; + } + + getOptions(callback); + }; + + return { + start: start, + push: push, + pull: pull, + getOptions: getOptions, + setOptions: setOptions + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/platform/firefox/vapi-background.js b/platform/firefox/vapi-background.js index 8f26c6a..0b61aaf 100644 --- a/platform/firefox/vapi-background.js +++ b/platform/firefox/vapi-background.js @@ -2851,6 +2851,125 @@ vAPI.punycodeURL = function(url) { /******************************************************************************/ /******************************************************************************/ +vAPI.cloud = (function() { + var extensionBranchPath = 'extensions.' + location.host; + var cloudBranchPath = extensionBranchPath + '.cloudStorage'; + + // https://github.com/gorhill/uBlock/issues/80#issuecomment-132081658 + // We must use get/setComplexValue in order to properly handle strings + // with unicode characters. + var iss = Ci.nsISupportsString; + var argstr = Components.classes['@mozilla.org/supports-string;1'] + .createInstance(iss); + + var options = { + defaultDeviceName: '', + deviceName: '' + }; + + // User-supplied device name. + try { + options.deviceName = Services.prefs + .getBranch(extensionBranchPath + '.') + .getComplexValue('deviceName', iss) + .data; + } catch(ex) { + } + + var getDefaultDeviceName = function() { + var name = ''; + try { + name = Services.prefs + .getBranch('services.sync.client.') + .getComplexValue('name', iss) + .data; + } catch(ex) { + } + + return name || window.navigator.platform || window.navigator.oscpu; + }; + + var start = function(dataKeys) { + var extensionBranch = Services.prefs.getBranch(extensionBranchPath + '.'); + var syncBranch = Services.prefs.getBranch('services.sync.prefs.sync.'); + + // Mark config entries as syncable + argstr.data = ''; + var dataKey; + for ( var i = 0; i < dataKeys.length; i++ ) { + dataKey = dataKeys[i]; + if ( extensionBranch.prefHasUserValue('cloudStorage.' + dataKey) === false ) { + extensionBranch.setComplexValue('cloudStorage.' + dataKey, iss, argstr); + } + syncBranch.setBoolPref(cloudBranchPath + '.' + dataKey, true); + } + }; + + var push = function(datakey, data, callback) { + var branch = Services.prefs.getBranch(cloudBranchPath + '.'); + var bin = { + 'source': options.deviceName || getDefaultDeviceName(), + 'tstamp': Date.now(), + 'data': data, + 'size': 0 + }; + bin.size = JSON.stringify(bin).length; + argstr.data = JSON.stringify(bin); + branch.setComplexValue(datakey, iss, argstr); + if ( typeof callback === 'function' ) { + callback(); + } + }; + + var pull = function(datakey, callback) { + var result = null; + var branch = Services.prefs.getBranch(cloudBranchPath + '.'); + try { + var json = branch.getComplexValue(datakey, iss).data; + if ( typeof json === 'string' ) { + result = JSON.parse(json); + } + } catch(ex) { + } + callback(result); + }; + + var getOptions = function(callback) { + if ( typeof callback !== 'function' ) { + return; + } + options.defaultDeviceName = getDefaultDeviceName(); + callback(options); + }; + + var setOptions = function(details, callback) { + if ( typeof details !== 'object' || details === null ) { + return; + } + + var branch = Services.prefs.getBranch(extensionBranchPath + '.'); + + if ( typeof details.deviceName === 'string' ) { + argstr.data = details.deviceName; + branch.setComplexValue('deviceName', iss, argstr); + options.deviceName = details.deviceName; + } + + getOptions(callback); + }; + + return { + start: start, + push: push, + pull: pull, + getOptions: getOptions, + setOptions: setOptions + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + vAPI.browserData = {}; /******************************************************************************/ diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 9ef53b2..bed0364 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -314,7 +314,10 @@ "message": "Collapse placeholder of blocked elements", "description": "English: Collapse placeholder of blocked elements" }, - + "settingsCloudStorageEnabled" : { + "message": "Enable cloud storage support", + "description": "" + }, "privacyPageTitle" : { "message": "uMatrix – Privacy", @@ -680,6 +683,31 @@ "description":"Appears in Firefox's add-on preferences" }, + "cloudPush": { + "message": "Export to cloud storage", + "description": "tooltip" + }, + "cloudPull": { + "message": "Import from cloud storage", + "description": "tooltip" + }, + "cloudNoData": { + "message": "...\n...", + "description": "" + }, + "cloudDeviceNamePrompt": { + "message": "This device name:", + "description": "used as a prompt for the user to provide a custom device name" + }, + "genericSubmit": { + "message": "Submit", + "description": "for generic 'submit' buttons" + }, + "genericRevert": { + "message": "Revert", + "description": "for generic 'revert' buttons" + }, + "errorCantConnectTo":{ "message":"Network error: Unable to connect to {{url}}", "description":"" diff --git a/src/css/cloud-ui.css b/src/css/cloud-ui.css new file mode 100644 index 0000000..b96ad88 --- /dev/null +++ b/src/css/cloud-ui.css @@ -0,0 +1,92 @@ +#cloudWidget { + background: url("../img/cloud.png") hsl(216, 100%, 93%); + border-radius: 3px; + margin: 0.5em 0; + padding: 1em; + position: relative; + } +#cloudWidget.hide { + display: none; + } +#cloudWidget > button { + font-size: 160%; + padding: 0.1em 0.2em; + } +#cloudPull[disabled] { + visibility: hidden; + } +#cloudPush:after , +#cloudPull:before { + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + line-height: 1; + vertical-align: baseline; + display: inline-block; + } +body[dir="ltr"] #cloudPush:after { + content: '\f0ee'; + } +body[dir="rtl"] #cloudPush:after { + content: '\f0ee'; + } +body[dir="ltr"] #cloudPull:before { + content: '\f0ed'; + } +body[dir="rtl"] #cloudPull:before { + content: '\f0ed'; + } +#cloudWidget > span { + color: gray; + display: inline-block; + font-size: 90%; + margin: 0 1em; + padding: 0; + vertical-align: bottom; + white-space: pre; + } +#cloudWidget > .nodata { + } +#cloudWidget > #cloudCog { + cursor: pointer; + display: inline-block; + font-size: 110%; + margin: 0; + opacity: 0.5; + padding: 4px; + position: absolute; + top: 0; + } +body[dir="ltr"] #cloudWidget > #cloudCog { + right: 0; + } +body[dir="rtl"] #cloudWidget > #cloudCog { + left: 0; + } +#cloudWidget > #cloudCog:hover { + opacity: 1; + } +#cloudWidget > #cloudOptions { + align-items: center; + -webkit-align-items: center; + background-color: rgba(0, 0, 0, 0.75); + bottom: 0; + display: none; + justify-content: center; + -webkit-justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 2000; + } +#cloudWidget > #cloudOptions.show { + display: flex; + display: -webkit-flex; + } +#cloudWidget > #cloudOptions > div { + background-color: white; + border-radius: 3px; + padding: 1em; + text-align: center; + } diff --git a/src/css/user-rules.css b/src/css/user-rules.css index 055f169..a36aa6f 100644 --- a/src/css/user-rules.css +++ b/src/css/user-rules.css @@ -17,6 +17,7 @@ div > p:last-child { margin: 0; padding: 0; position: relative; + vertical-align: top; white-space: normal; width: calc(50% - 2px); } diff --git a/src/img/cloud.png b/src/img/cloud.png new file mode 100644 index 0000000..6c78dde Binary files /dev/null and b/src/img/cloud.png differ diff --git a/src/js/background.js b/src/js/background.js index b477328..6e7bc34 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -104,6 +104,7 @@ return { autoUpdate: false, clearBrowserCache: true, clearBrowserCacheAfter: 60, + cloudStorageEnabled: false, collapseBlocked: false, colorBlindFriendly: false, deleteCookies: false, diff --git a/src/js/cloud-ui.js b/src/js/cloud-ui.js new file mode 100644 index 0000000..f129dc7 --- /dev/null +++ b/src/js/cloud-ui.js @@ -0,0 +1,201 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2015 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 +*/ + +/* global uDom */ +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +self.cloud = { + options: {}, + datakey: '', + data: undefined, + onPush: null, + onPull: null +}; + +/******************************************************************************/ + +var widget = uDom.nodeFromId('cloudWidget'); +if ( widget === null ) { + return; +} + +self.cloud.datakey = widget.getAttribute('data-cloud-entry') || ''; +if ( self.cloud.datakey === '' ) { + return; +} + +/******************************************************************************/ + +var messager = vAPI.messaging.channel('cloud-ui.js'); + +/******************************************************************************/ + +var onCloudDataReceived = function(entry) { + if ( typeof entry !== 'object' || entry === null ) { + return; + } + + self.cloud.data = entry.data; + + uDom.nodeFromId('cloudPull').removeAttribute('disabled'); + + var timeOptions = { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short' + }; + + var time = new Date(entry.tstamp); + widget.querySelector('span').textContent = + entry.source + '\n' + + time.toLocaleString('fullwide', timeOptions); +}; + +/******************************************************************************/ + +var fetchCloudData = function() { + messager.send( + { + what: 'cloudPull', + datakey: self.cloud.datakey + }, + onCloudDataReceived + ); +}; + +/******************************************************************************/ + +var pushData = function() { + if ( typeof self.cloud.onPush !== 'function' ) { + return; + } + messager.send( + { + what: 'cloudPush', + datakey: self.cloud.datakey, + data: self.cloud.onPush() + }, + fetchCloudData + ); +}; + +/******************************************************************************/ + +var pullData = function(ev) { + if ( typeof self.cloud.onPull === 'function' ) { + self.cloud.onPull(self.cloud.data, ev.shiftKey); + } +}; + +/******************************************************************************/ + +var openOptions = function() { + var input = uDom.nodeFromId('cloudDeviceName'); + input.value = self.cloud.options.deviceName; + input.setAttribute('placeholder', self.cloud.options.defaultDeviceName); + uDom.nodeFromId('cloudOptions').classList.add('show'); +}; + +/******************************************************************************/ + +var closeOptions = function(ev) { + var root = uDom.nodeFromId('cloudOptions'); + if ( ev.target !== root ) { + return; + } + root.classList.remove('show'); +}; + +/******************************************************************************/ + +var submitOptions = function() { + var onOptions = function(options) { + if ( typeof options !== 'object' || options === null ) { + return; + } + self.cloud.options = options; + }; + + messager.send({ + what: 'cloudSetOptions', + options: { + deviceName: uDom.nodeFromId('cloudDeviceName').value + } + }, onOptions); + uDom.nodeFromId('cloudOptions').classList.remove('show'); +}; + +/******************************************************************************/ + +var onInitialize = function(options) { + if ( typeof options !== 'object' || options === null ) { + return; + } + + if ( !options.enabled ) { + return; + } + self.cloud.options = options; + + fetchCloudData(); + + var html = [ + '', + '', + '', + '', + '
', + '
', + '

', + '

', + '

', + '
', + ].join(''); + + vAPI.insertHTML(widget, html); + vAPI.i18n.render(widget); + widget.classList.remove('hide'); + + uDom('#cloudPush').on('click', pushData); + uDom('#cloudPull').on('click', pullData); + uDom('#cloudCog').on('click', openOptions); + uDom('#cloudOptions').on('click', closeOptions); + uDom('#cloudOptionsSubmit').on('click', submitOptions); +}; + +messager.send({ what: 'cloudGetOptions' }, onInitialize); + +/******************************************************************************/ + +// https://www.youtube.com/watch?v=aQFp67VoiDA + +})(); diff --git a/src/js/i18n.js b/src/js/i18n.js index 3b54c60..81f793b 100644 --- a/src/js/i18n.js +++ b/src/js/i18n.js @@ -19,6 +19,8 @@ Home: https://github.com/gorhill/uMatrix */ +/* global vAPI, uDom */ + /******************************************************************************/ // This file should always be included at the end of the `body` tag, so as @@ -30,46 +32,32 @@ /******************************************************************************/ -var text; - -var nodeList = document.querySelectorAll('[data-i18n]'); -var i = nodeList.length; -var node; -while ( i-- ) { - node = nodeList[i]; - vAPI.insertHTML(node, vAPI.i18n(node.getAttribute('data-i18n'))); -} - -// copy text of

if any to document title -node = document.querySelector('h1'); -if ( node !== null ) { - document.title = node.textContent; -} - -// Tool tips -nodeList = document.querySelectorAll('[data-i18n-tip]'); -i = nodeList.length; -while ( i-- ) { - node = nodeList[i]; - node.setAttribute('data-tip', vAPI.i18n(node.getAttribute('data-i18n-tip'))); -} -nodeList = document.querySelectorAll('[title]'); -i = nodeList.length; -while ( i-- ) { - node = nodeList[i]; - text = node.getAttribute('title'); - node.setAttribute('title', vAPI.i18n(text) || text); -} - -nodeList = document.querySelectorAll('input[placeholder]'); -i = nodeList.length; -while ( i-- ) { - node = nodeList[i]; - node.setAttribute( - 'placeholder', - vAPI.i18n(node.getAttribute('placeholder')) || '' - ); -} +// Helper to deal with the i18n'ing of HTML files. +vAPI.i18n.render = function(context) { + uDom('[data-i18n]', context).forEach(function(elem) { + elem.html(vAPI.i18n(elem.attr('data-i18n'))); + }); + + uDom('[title]', context).forEach(function(elem) { + var title = vAPI.i18n(elem.attr('title')); + if ( title ) { + elem.attr('title', title); + } + }); + + uDom('[placeholder]', context).forEach(function(elem) { + elem.attr('placeholder', vAPI.i18n(elem.attr('placeholder'))); + }); + + uDom('[data-i18n-tip]', context).forEach(function(elem) { + elem.attr( + 'data-tip', + vAPI.i18n(elem.attr('data-i18n-tip')).replace(/
/g, '\n').replace(/\n{3,}/g, '\n\n') + ); + }); +}; + +vAPI.i18n.render(); /******************************************************************************/ diff --git a/src/js/messaging.js b/src/js/messaging.js index c1dc615..f14157d 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -567,6 +567,68 @@ vAPI.messaging.listen('contentscript-end.js', onMessage); /******************************************************************************/ /******************************************************************************/ +// cloud-ui.js + +(function() { + +'use strict'; + +/******************************************************************************/ + +var µm = µMatrix; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + case 'cloudGetOptions': + vAPI.cloud.getOptions(function(options) { + options.enabled = µm.userSettings.cloudStorageEnabled === true; + callback(options); + }); + return; + + case 'cloudSetOptions': + vAPI.cloud.setOptions(request.options, callback); + return; + + case 'cloudPull': + return vAPI.cloud.pull(request.datakey, callback); + + case 'cloudPush': + return vAPI.cloud.push(request.datakey, request.data, callback); + + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + // For when cloud storage is disabled. + case 'cloudPull': + // fallthrough + case 'cloudPush': + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +/******************************************************************************/ + +vAPI.messaging.listen('cloud-ui.js', onMessage); + +})(); + +/******************************************************************************/ +/******************************************************************************/ + // settings.js (function() { diff --git a/src/js/user-rules.js b/src/js/user-rules.js index 426767a..45edb9f 100644 --- a/src/js/user-rules.js +++ b/src/js/user-rules.js @@ -59,13 +59,17 @@ var processUserRules = function(response) { i = rules.length; while ( i-- ) { rule = rules[i].trim(); - permanentRules[rule] = allRules[rule] = true; + if ( rule.length !== 0 ) { + permanentRules[rule] = allRules[rule] = true; + } } rules = response.temporaryRules.split(/\n+/); i = rules.length; while ( i-- ) { rule = rules[i].trim(); - temporaryRules[rule] = allRules[rule] = true; + if ( rule.length !== 0 ) { + temporaryRules[rule] = allRules[rule] = true; + } } rules = Object.keys(allRules).sort(directiveSort); for ( i = 0; i < rules.length; i++ ) { @@ -78,7 +82,7 @@ var processUserRules = function(response) { } else if ( onLeft ) { permanentList.push('
  • ', rule); temporaryList.push('
  • ', rule); - } else { + } else if ( onRight ) { permanentList.push('
  •  '); temporaryList.push('
  • ', rule); } @@ -153,7 +157,7 @@ var fromNoScript = function(content) { /******************************************************************************/ -function handleImportFilePicker() { +var handleImportFilePicker = function() { var fileReaderOnLoadHandler = function() { if ( typeof this.result !== 'string' || this.result === '' ) { return; @@ -181,7 +185,7 @@ function handleImportFilePicker() { var fr = new FileReader(); fr.onload = fileReaderOnLoadHandler; fr.readAsText(file); -} +}; /******************************************************************************/ @@ -281,6 +285,26 @@ var temporaryRulesToggler = function(ev) { /******************************************************************************/ +self.cloud.onPush = function() { + return rulesFromHTML('#diff .left li'); +}; + +self.cloud.onPull = function(data, append) { + if ( typeof data !== 'string' ) { + return; + } + if ( append ) { + data = rulesFromHTML('#diff .right li') + '\n' + data; + } + var request = { + 'what': 'setUserRules', + 'temporaryRules': data + }; + messager.send(request, processUserRules); +}; + +/******************************************************************************/ + uDom.onLoad(function() { // Handle user interaction uDom('#importButton').on('click', startImportFilePicker); diff --git a/src/settings.html b/src/settings.html index dfda725..4c894e7 100644 --- a/src/settings.html +++ b/src/settings.html @@ -44,6 +44,9 @@ ul > li {
  • +
  • + + diff --git a/src/user-rules.html b/src/user-rules.html index 851280a..854a18b 100644 --- a/src/user-rules.html +++ b/src/user-rules.html @@ -5,11 +5,14 @@ uMatrix — Your rules + +
    +
    @@ -42,6 +45,7 @@ +