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 = [ + '', + '', + '', + '', + '
', + '
', + '