diff --git a/background/background.js b/background/background.js index e4d958c1..634559f2 100644 --- a/background/background.js +++ b/background/background.js @@ -153,6 +153,14 @@ navigatorUtil.onUrlChange(({url, tabId, frameId}) => { } }); +prefs.subscribe([ + 'show-badge', + 'disableAll', + 'badgeDisabled', + 'badgeNormal', + 'iconset', +], () => debounce(updateAllTabsIcon)); + // ************************************************************************* chrome.runtime.onInstalled.addListener(({reason}) => { if (reason !== 'update') return; @@ -211,11 +219,10 @@ if (chrome.contextMenus) { } item = Object.assign({id}, item); delete item.presentIf; - const prefValue = prefs.readOnlyValues[id]; item.title = chrome.i18n.getMessage(item.title); - if (!item.type && typeof prefValue === 'boolean') { + if (!item.type && typeof prefs.defaults[id] === 'boolean') { item.type = 'checkbox'; - item.checked = prefValue; + item.checked = prefs.get(id); } if (!item.contexts) { item.contexts = ['browser_action']; @@ -239,7 +246,7 @@ if (chrome.contextMenus) { }; const keys = Object.keys(contextMenus); - prefs.subscribe(keys.filter(id => typeof prefs.readOnlyValues[id] === 'boolean'), toggleCheckmark); + prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark); prefs.subscribe(keys.filter(id => contextMenus[id].presentIf), togglePresence); createContextMenus(keys); } @@ -307,6 +314,21 @@ window.addEventListener('storageReady', function _() { // FIXME: implement exposeIframes in apply.js +// register hotkeys +if (FIREFOX && browser.commands && browser.commands.update) { + const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.')); + prefs.subscribe(hotkeyPrefs, (name, value) => { + try { + name = name.split('.')[1]; + if (value.trim()) { + browser.commands.update({name, shortcut: value}); + } else { + browser.commands.reset(name); + } + } catch (e) {} + }); +} + function webNavUsercssInstallerFF(data) { const {tabId} = data; Promise.all([ @@ -447,3 +469,9 @@ function onRuntimeMessage(msg, sender) { const context = {msg, sender}; return fn.apply(context, msg.args); } + +function updateAllTabsIcon() { + return queryTabs().then(tabs => + tabs.map(t => updateIcon({tab: t})) + ); +} diff --git a/content/apply.js b/content/apply.js index 8a229661..9bd701bc 100644 --- a/content/apply.js +++ b/content/apply.js @@ -48,6 +48,10 @@ window.addEventListener(chrome.runtime.id, orphanCheck, true); } + // FIXME: does it work with styleViaAPI? + prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value)); + prefs.subscribe(['exposeIframes'], (key, value) => doExposeIframes(value)); + function getMatchUrl() { var matchUrl = location.href; if (!matchUrl.match(/^(http|file|chrome|ftp)/)) { @@ -135,7 +139,6 @@ break; case 'urlChanged': - console.log('url changed', location.href); API.getSectionsByUrl(getMatchUrl(), {enabled: true}) .then(buildSections) .then(replaceAll); diff --git a/edit/edit.js b/edit/edit.js index 13d8daee..9420d1c3 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -238,6 +238,9 @@ preinit(); } })(); +prefs.subscribe(['editor.smartIndent'], (key, value) => + CodeMirror.setOption('smartIndent', value)); + function preinit() { // make querySelectorAll enumeration code readable // FIXME: don't extend native diff --git a/js/dom.js b/js/dom.js index c956d729..7ffb9ce6 100644 --- a/js/dom.js +++ b/js/dom.js @@ -392,3 +392,55 @@ function moveFocus(rootElement, step) { } } } + +// Accepts an array of pref names (values are fetched via prefs.get) +// and establishes a two-way connection between the document elements and the actual prefs +function setupLivePrefs( + IDs = Object.getOwnPropertyNames(prefs.defaults) + .filter(id => $('#' + id)) +) { + for (const id of IDs) { + const element = $('#' + id); + updateElement({id, element, force: true}); + element.addEventListener('change', onChange); + } + prefs.subscribe(IDs, (id, value) => updateElement({id, value})); + + function onChange() { + const value = getInputValue(this); + if (prefs.get(this.id) !== value) { + prefs.set(this.id, value); + } + } + function updateElement({ + id, + value = prefs.get(id), + element = $('#' + id), + force, + }) { + if (!element) { + prefs.unsubscribe(IDs, updateElement); + return; + } + setInputValue(element, value, force); + } + function getInputValue(input) { + if (input.type === 'checkbox') { + return input.checked; + } + if (input.type === 'number') { + return Number(input.value); + } + return input.value; + } + function setInputValue(input, value, force = false) { + if (force || getInputValue(input) !== value) { + if (input.type === 'checkbox') { + input.checked = value; + } else { + input.value = value; + } + input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); + } + } +} diff --git a/js/messaging.js b/js/messaging.js index eb83a738..fc1cb78a 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -91,12 +91,9 @@ if (IS_BG) { window.API_METHODS = {}; } -const FIREFOX_NO_DOM_STORAGE = FIREFOX && !tryCatch(() => localStorage); -if (FIREFOX_NO_DOM_STORAGE) { - // may be disabled via dom.storage.enabled - Object.defineProperty(window, 'localStorage', {value: {}}); - Object.defineProperty(window, 'sessionStorage', {value: {}}); -} +// FIXME: `localStorage` and `sessionStorage` may be disabled via dom.storage.enabled +// Object.defineProperty(window, 'localStorage', {value: {}}); +// Object.defineProperty(window, 'sessionStorage', {value: {}}); function queryTabs(options = {}) { return new Promise(resolve => diff --git a/js/prefs.js b/js/prefs.js index 32de5bd4..f798a96a 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -2,8 +2,7 @@ /* exported prefs */ 'use strict'; -const prefs = new function Prefs() { - const BG = undefined; +const prefs = (() => { const defaults = { 'openEditInWindow': false, // new editor opens in a own browser window 'windowPosition': {}, // detached window position @@ -99,29 +98,33 @@ const prefs = new function Prefs() { }; const values = deepCopy(defaults); - const affectsIcon = [ - 'show-badge', - 'disableAll', - 'badgeDisabled', - 'badgeNormal', - 'iconset', - ]; - const onChange = { any: new Set(), specific: new Map(), }; - // coalesce multiple pref changes in broadcast - let broadcastPrefs = {}; + const initializing = promisify(chrome.storage.sync.get.bind(chrome.storage.sync))('settings') + .then(result => { + if (result.settings) { + setAll(result.settings, true); + } + }); - Object.defineProperties(this, { - defaults: {value: deepCopy(defaults)}, - readOnlyValues: {value: {}}, + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'sync' || !changes.settings || !changes.settings.newValue) { + return; + } + initializing.then(() => setAll(changes.settings.newValue, true)); }); - Object.assign(Prefs.prototype, { + let timer; + // coalesce multiple pref changes in broadcast + // let changes = {}; + + return { + initializing, + defaults, get(key, defaultValue) { if (key in values) { return values[key]; @@ -134,62 +137,11 @@ const prefs = new function Prefs() { } console.warn("No default preference for '%s'", key); }, - getAll() { return deepCopy(values); }, - - set(key, value, {broadcast = true, sync = true, fromBroadcast} = {}) { - const oldValue = values[key]; - switch (typeof defaults[key]) { - case typeof value: - break; - case 'string': - value = String(value); - break; - case 'number': - value |= 0; - break; - case 'boolean': - value = value === true || value === 'true'; - break; - } - values[key] = value; - defineReadonlyProperty(this.readOnlyValues, key, value); - const hasChanged = !equal(value, oldValue); - if (!fromBroadcast || FIREFOX_NO_DOM_STORAGE) { - localStorage[key] = typeof defaults[key] === 'object' - ? JSON.stringify(value) - : value; - } - if (!fromBroadcast && broadcast && hasChanged) { - this.broadcast(key, value, {sync}); - } - if (hasChanged) { - const specific = onChange.specific.get(key); - if (typeof specific === 'function') { - specific(key, value); - } else if (specific instanceof Set) { - for (const listener of specific.values()) { - listener(key, value); - } - } - for (const listener of onChange.any.values()) { - listener(key, value); - } - } - }, - - reset: key => this.set(key, deepCopy(defaults[key])), - - broadcast(key, value, {sync = true} = {}) { - broadcastPrefs[key] = value; - debounce(doBroadcast); - if (sync) { - debounce(doSyncSet); - } - }, - + set, + reset: key => set(key, deepCopy(defaults[key])), subscribe(keys, listener) { // keys: string[] ids // or a falsy value to subscribe to everything @@ -209,7 +161,6 @@ const prefs = new function Prefs() { onChange.any.add(listener); } }, - unsubscribe(keys, listener) { if (keys) { for (const key of keys) { @@ -227,147 +178,75 @@ const prefs = new function Prefs() { onChange.all.remove(listener); } }, - }); + }; - { - const importFromBG = () => - API.getPrefs().then(prefs => { - const props = {}; - for (const id in prefs) { - const value = prefs[id]; - values[id] = value; - props[id] = {value: deepCopy(value)}; - } - Object.defineProperties(this.readOnlyValues, props); - }); - // Unlike chrome.storage or messaging, HTML5 localStorage is synchronous and always ready, - // so we'll mirror the prefs to avoid using the wrong defaults during the startup phase - const importFromLocalStorage = () => { - forgetOutdatedDefaults(localStorage); - for (const key in defaults) { - const defaultValue = defaults[key]; - let value = localStorage[key]; - if (typeof value === 'string') { - switch (typeof defaultValue) { - case 'boolean': - value = value.toLowerCase() === 'true'; - break; - case 'number': - value |= 0; - break; - case 'object': - value = tryJSONparse(value) || defaultValue; - break; + function promisify(fn) { + return (...args) => + new Promise((resolve, reject) => { + fn(...args, (...result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else if (result.length === 0) { + resolve(undefined); + } else if (result.length === 1) { + resolve(result[0]); + } else { + resolve(result); } - } else if (FIREFOX_NO_DOM_STORAGE && BG) { - value = BG.localStorage[key]; - value = value === undefined ? defaultValue : value; - localStorage[key] = value; - } else { - value = defaultValue; - } - if (BG === window) { - // when in bg page, .set() will write to localStorage - this.set(key, value, {broadcast: false, sync: false}); - } else { - values[key] = value; - defineReadonlyProperty(this.readOnlyValues, key, value); - } - } - return Promise.resolve(); - }; - (FIREFOX_NO_DOM_STORAGE && !BG ? importFromBG() : importFromLocalStorage()).then(() => { - if (BG && BG !== window) return; - if (BG === window) { - affectsIcon.forEach(key => this.broadcast(key, values[key], {sync: false})); - chromeSync.getValue('settings').then(settings => importFromSync.call(this, settings)); - } - chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'sync' && 'settings' in changes) { - importFromSync.call(this, changes.settings.newValue); - } + }); }); - }); } - // any access to chrome API takes time due to initialization of bindings - window.addEventListener('load', function _() { - window.removeEventListener('load', _); - chrome.runtime.onMessage.addListener(msg => { - if (msg.prefs) { - for (const id in msg.prefs) { - prefs.set(id, msg.prefs[id], {fromBroadcast: true}); - } - } - }); - }); - - // register hotkeys - if (FIREFOX && (browser.commands || {}).update) { - const hotkeyPrefs = Object.keys(values).filter(k => k.startsWith('hotkey.')); - this.subscribe(hotkeyPrefs, (name, value) => { - try { - name = name.split('.')[1]; - if (value.trim()) { - browser.commands.update({name, shortcut: value}).catch(ignoreChromeError); - } else { - browser.commands.reset(name).catch(ignoreChromeError); - } - } catch (e) {} - }); - } - - return; - - function doBroadcast() { - // if (BG && BG === window && !BG.dbExec.initialized) { - // window.addEventListener('storageReady', function _() { - // window.removeEventListener('storageReady', _); - // doBroadcast(); - // }); - // return; - // } - const affects = { - all: 'disableAll' in broadcastPrefs - || 'exposeIframes' in broadcastPrefs, - }; - if (!affects.all) { - for (const key in broadcastPrefs) { - affects.icon = affects.icon || affectsIcon.includes(key); - affects.popup = affects.popup || key.startsWith('popup'); - affects.editor = affects.editor || key.startsWith('editor'); - affects.manager = affects.manager || key.startsWith('manage'); - } - } - notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects}); - broadcastPrefs = {}; - } - - function doSyncSet() { - chromeSync.setValue('settings', values); - } - - function importFromSync(synced = {}) { - forgetOutdatedDefaults(synced); - for (const key in defaults) { - if (key in synced) { - this.set(key, synced[key], {sync: false}); - } + function setAll(settings, synced) { + for (const [key, value] of Object.entries(settings)) { + set(key, value, synced); } } - function forgetOutdatedDefaults(storage) { - // our linter runs as a worker so we can reduce the delay and forget the old default values - if (Number(storage['editor.lintDelay']) === 500) delete storage['editor.lintDelay']; - if (Number(storage['editor.lintReportDelay']) === 4500) delete storage['editor.lintReportDelay']; + function set(key, value, synced = false) { + const oldValue = values[key]; + switch (typeof defaults[key]) { + case typeof value: + break; + case 'string': + value = String(value); + break; + case 'number': + value |= 0; + break; + case 'boolean': + value = value === true || value === 'true'; + break; + } + if (equal(value, oldValue)) { + return; + } + values[key] = value; + emitChange(key, value); + if (synced || timer) { + return; + } + timer = setTimeout(syncPrefs); } - function defineReadonlyProperty(obj, key, value) { - const copy = deepCopy(value); - if (typeof copy === 'object') { - Object.freeze(copy); + function emitChange(key, value) { + const specific = onChange.specific.get(key); + if (typeof specific === 'function') { + specific(key, value); + } else if (specific instanceof Set) { + for (const listener of specific.values()) { + listener(key, value); + } } - Object.defineProperty(obj, key, {value: copy, configurable: true}); + for (const listener of onChange.any.values()) { + listener(key, value); + } + } + + function syncPrefs() { + // FIXME: we always set the entire object? Ideally, this should only use `changes`. + chrome.storage.sync.set({settings: values}); + timer = null; } function equal(a, b) { @@ -390,7 +269,7 @@ const prefs = new function Prefs() { } function contextDeleteMissing() { - return CHROME && ( + return /Chrome\/\d+/.test(navigator.userAgent) && ( // detect browsers without Delete by looking at the end of UA string /Vivaldi\/[\d.]+$/.test(navigator.userAgent) || // Chrome and co. @@ -399,44 +278,17 @@ const prefs = new function Prefs() { !Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash') ); } -}(); - -// Accepts an array of pref names (values are fetched via prefs.get) -// and establishes a two-way connection between the document elements and the actual prefs -function setupLivePrefs( - IDs = Object.getOwnPropertyNames(prefs.readOnlyValues) - .filter(id => $('#' + id)) -) { - const checkedProps = {}; - for (const id of IDs) { - const element = $('#' + id); - checkedProps[id] = element.type === 'checkbox' ? 'checked' : 'value'; - updateElement({id, element, force: true}); - element.addEventListener('change', onChange); - } - prefs.subscribe(IDs, (id, value) => updateElement({id, value})); - - function onChange() { - const value = this[checkedProps[this.id]]; - if (prefs.get(this.id) !== value) { - prefs.set(this.id, value); + function deepCopy(obj) { + if (!obj || typeof obj !== 'object') { + return obj; } - } - function updateElement({ - id, - value = prefs.get(id), - element = $('#' + id), - force, - }) { - if (!element) { - prefs.unsubscribe(IDs, updateElement); - return; - } - const prop = checkedProps[id]; - if (force || element[prop] !== value) { - element[prop] = value; - element.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); + if (Array.isArray(obj)) { + return obj.map(deepCopy); } + return Object.keys(obj).reduce((output, key) => { + output[key] = deepCopy(obj[key]); + return output; + }, {}); } -} +})(); diff --git a/manage/filters.js b/manage/filters.js index 007ebc4b..b09c3193 100644 --- a/manage/filters.js +++ b/manage/filters.js @@ -114,7 +114,7 @@ onDOMready().then(() => { } if (value !== undefined) { el.lastValue = value; - if (el.id in prefs.readOnlyValues) { + if (el.id in prefs.defaults) { prefs.set(el.id, false); } } diff --git a/manage/manage.js b/manage/manage.js index e3997c25..e13c8865 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -701,8 +701,8 @@ function highlightEditedStyle() { function usePrefsDuringPageLoad() { - for (const id of Object.getOwnPropertyNames(prefs.readOnlyValues)) { - const value = prefs.readOnlyValues[id]; + for (const id of Object.getOwnPropertyNames(prefs.defaults)) { + const value = prefs.get(id); if (value !== true) continue; const el = document.getElementById(id) || id.includes('expanded') && $(`details[data-pref="${id}"]`); diff --git a/manifest.json b/manifest.json index 671946c2..43b53d94 100644 --- a/manifest.json +++ b/manifest.json @@ -62,7 +62,7 @@ "run_at": "document_start", "all_frames": true, "match_about_blank": true, - "js": ["js/promisify.js", "js/msg.js", "content/apply.js"] + "js": ["js/promisify.js", "js/msg.js", "js/prefs.js", "content/apply.js"] }, { "matches": ["http://userstyles.org/*", "https://userstyles.org/*"], @@ -115,5 +115,11 @@ "options_ui": { "page": "options.html", "chrome_style": false + }, + "applications": { + "gecko": { + "id": "{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}", + "strict_min_version": "53" + } } } diff --git a/options/options.js b/options/options.js index 2f9d929f..a657589e 100644 --- a/options/options.js +++ b/options/options.js @@ -59,7 +59,7 @@ document.onclick = e => { case 'reset': $$('input') - .filter(input => input.id in prefs.readOnlyValues) + .filter(input => input.id in prefs.defaults) .forEach(input => prefs.reset(input.id)); break; diff --git a/package.json b/package.json index 1f519203..f0fdf8dc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "semver-bundle": "^0.1.1", "stylelint-bundle": "^8.0.0", "stylus-lang-bundle": "^0.54.5", - "updates": "^4.2.1" + "updates": "^4.2.1", + "web-ext": "^2.9.1" }, "scripts": { "lint": "eslint **/*.js --cache || exit 0", @@ -27,6 +28,7 @@ "update-node": "updates -u && node tools/remove-modules.js && npm install", "update-codemirror": "node tools/update-libraries.js && node tools/update-codemirror-themes.js", "update-versions": "node tools/update-versions", - "zip": "npm run update-versions && node tools/zip.js" + "zip": "npm run update-versions && node tools/zip.js", + "start": "web-ext run" } } diff --git a/popup/popup.js b/popup/popup.js index 54702ea0..159739a4 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -31,6 +31,14 @@ getActiveTab().then(tab => msg.onExtension(onRuntimeMessage); +prefs.subscribe(['popup.stylesFirst'], (key, stylesFirst) => { + const actions = $('body > .actions'); + const before = stylesFirst ? actions : actions.nextSibling; + document.body.insertBefore(installed, before); +}); +prefs.subscribe(['popupWidth'], (key, value) => setPopupWidth(value)); +prefs.subscribe(['popup.borders'], (key, value) => toggleSideBorders(value)); + function onRuntimeMessage(msg) { switch (msg.method) { case 'styleAdded': @@ -42,18 +50,6 @@ function onRuntimeMessage(msg) { case 'styleDeleted': handleDelete(msg.style.id); break; - case 'prefChanged': - if ('popup.stylesFirst' in msg.prefs) { - const stylesFirst = msg.prefs['popup.stylesFirst']; - const actions = $('body > .actions'); - const before = stylesFirst ? actions : actions.nextSibling; - document.body.insertBefore(installed, before); - } else if ('popupWidth' in msg.prefs) { - setPopupWidth(msg.prefs.popupWidth); - } else if ('popup.borders' in msg.prefs) { - toggleSideBorders(msg.prefs['popup.borders']); - } - break; } dispatchEvent(new CustomEvent(msg.method, {detail: msg})); }