diff --git a/.eslintrc b/.eslintrc index f71971cd..479e9a94 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ env: globals: CodeMirror: false runTryCatch: true + getStylesSafe: true getStyles: true updateIcon: true saveStyle: true diff --git a/apply.js b/apply.js index e882ed9f..3d95fc4e 100644 --- a/apply.js +++ b/apply.js @@ -9,20 +9,22 @@ var retiredStyleIds = []; initObserver(); requestStyles(); -function requestStyles() { +function requestStyles(options = {}) { // If this is a Stylish page (Edit Style or Manage Styles), // we'll request the styles directly to minimize delay and flicker, // unless Chrome still starts up and the background page isn't fully loaded. // (Note: in this case the function may be invoked again from applyStyles.) - var request = {method: "getStyles", matchUrl: location.href, enabled: true, asHash: true}; - if (location.href.indexOf(chrome.extension.getURL("")) == 0) { - var bg = chrome.extension.getBackgroundPage(); - if (bg && bg.getStyles) { - // apply styles immediately, then proceed with a normal request that will update the icon - bg.getStyles(request, applyStyles); - } + var request = Object.assign({ + method: "getStyles", + matchUrl: location.href, + enabled: true, + asHash: true, + }, options); + if (typeof getStylesSafe !== 'undefined') { + getStylesSafe(request).then(applyStyles); + } else { + chrome.runtime.sendMessage(request, applyStyles); } - chrome.runtime.sendMessage(request, applyStyles); } chrome.runtime.onMessage.addListener(applyOnMessage); @@ -34,6 +36,10 @@ function applyOnMessage(request, sender, sendResponse) { removeStyle(request.id, document); break; case "styleUpdated": + if (request.codeIsUpdated === false) { + applyStyleState(request.style.id, request.style.enabled, document); + break; + } if (request.style.enabled) { retireStyle(request.style.id); // fallthrough to "styleAdded" @@ -92,6 +98,20 @@ function disableAll(disable) { } } +function applyStyleState(id, enabled, doc) { + var e = doc.getElementById("stylus-" + id); + if (!e) { + if (enabled) { + requestStyles({id}); + } + } else { + e.sheet.disabled = !enabled; + getDynamicIFrames(doc).forEach(function(iframe) { + applyStyleState(id, iframe.contentDocument); + }); + } +} + function removeStyle(id, doc) { var e = doc.getElementById("stylus-" + id); delete g_styleElements["stylus-" + id]; diff --git a/background.js b/background.js index bea7653b..b7915c76 100644 --- a/background.js +++ b/background.js @@ -1,36 +1,21 @@ /* globals wildcardAsRegExp, KEEP_CHANNEL_OPEN */ -var frameIdMessageable; -runTryCatch(function() { - chrome.tabs.sendMessage(0, {}, {frameId: 0}, function() { - var clearError = chrome.runtime.lastError; - frameIdMessageable = true; - }); -}); - // This happens right away, sometimes so fast that the content script isn't even ready. That's // why the content script also asks for this stuff. -chrome.webNavigation.onCommitted.addListener(webNavigationListener.bind(this, "styleApply")); -// Not supported in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1239349 -if ("onHistoryStateUpdated" in chrome.webNavigation) { - chrome.webNavigation.onHistoryStateUpdated.addListener(webNavigationListener.bind(this, "styleReplaceAll")); -} +chrome.webNavigation.onCommitted.addListener(webNavigationListener.bind(this, 'styleApply')); +chrome.webNavigation.onHistoryStateUpdated.addListener(webNavigationListener.bind(this, 'styleReplaceAll')); chrome.webNavigation.onBeforeNavigate.addListener(webNavigationListener.bind(this, null)); + function webNavigationListener(method, data) { - // Until Chrome 41, we can't target a frame with a message - // (https://developer.chrome.com/extensions/tabs#method-sendMessage) - // so a style affecting a page with an iframe will affect the main page as well. - // Skip doing this for frames in pre-41 to prevent page flicker. - if (data.frameId != 0 && !frameIdMessageable) { - return; - } - getStyles({matchUrl: data.url, enabled: true, asHash: true}, function(styleHash) { - if (method) { - chrome.tabs.sendMessage(data.tabId, {method: method, styles: styleHash}, - frameIdMessageable ? {frameId: data.frameId} : undefined); + getStyles({matchUrl: data.url, enabled: true, asHash: true}, styles => { + // we can't inject chrome:// and chrome-extension:// pages except our own + // that request the styles on their own, so we'll only update the icon + if (method && !data.url.startsWith('chrome')) { + chrome.tabs.sendMessage(data.tabId, {method, styles}, {frameId: data.frameId}); } + // main page frame id is 0 if (data.frameId == 0) { - updateIcon({id: data.tabId, url: data.url}, styleHash); + updateIcon({id: data.tabId, url: data.url}, styles); } }); } @@ -70,7 +55,7 @@ chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { return KEEP_CHANNEL_OPEN; case "invalidateCache": if (typeof invalidateCache != "undefined") { - invalidateCache(false); + invalidateCache(false, request); } break; case "healthCheck": diff --git a/edit.js b/edit.js index 8443af7e..faffc91a 100644 --- a/edit.js +++ b/edit.js @@ -1087,18 +1087,10 @@ function init() { } // This is an edit tE("heading", "editStyleHeading", null, false); - requestStyle(); - function requestStyle() { - chrome.runtime.sendMessage({method: "getStyles", id: params.id}, function callback(styles) { - if (!styles) { // Chrome is starting up and shows edit.html - requestStyle(); - return; - } - var style = styles[0]; - styleId = style.id; - initWithStyle(style); - }); - } + getStylesSafe({id: params.id}).then(styles => { + styleId = styles[0].id; + initWithStyle(styles[0]); + }); } function initWithStyle(style) { @@ -1107,7 +1099,7 @@ function initWithStyle(style) { document.getElementById("url").href = style.url; // if this was done in response to an update, we need to clear existing sections getSections().forEach(function(div) { div.remove(); }); - var queue = style.sections.length ? style.sections : [{code: ""}]; + var queue = style.sections.length ? style.sections.slice() : [{code: ""}]; var queueStart = new Date().getTime(); // after 100ms the sections will be added asynchronously while (new Date().getTime() - queueStart <= 100 && queue.length) { diff --git a/manage.js b/manage.js index 955f9e41..3b779272 100644 --- a/manage.js +++ b/manage.js @@ -6,13 +6,9 @@ var appliesToExtraTemplate = document.createElement("span"); appliesToExtraTemplate.className = "applies-to-extra"; appliesToExtraTemplate.innerHTML = " " + t('appliesDisplayTruncatedSuffix'); -chrome.runtime.sendMessage({method: "getStyles"}, showStyles); +getStylesSafe({code: false}).then(showStyles); function showStyles(styles) { - if (!styles) { // Chrome is starting up - chrome.runtime.sendMessage({method: "getStyles"}, showStyles); - return; - } if (!installed) { // "getStyles" message callback is invoked before document is loaded, // postpone the action until DOMContentLoaded is fired diff --git a/messaging.js b/messaging.js index 379eace0..c9eb1f5f 100644 --- a/messaging.js +++ b/messaging.js @@ -4,12 +4,15 @@ const OWN_ORIGIN = chrome.runtime.getURL(''); function notifyAllTabs(request) { // list all tabs including chrome-extension:// which can be ours + if (request.codeIsUpdated === false && request.style) { + request = Object.assign({}, request, { + style: getStyleWithNoCode(request.style) + }); + } chrome.tabs.query({}, tabs => { for (let tab of tabs) { - if (request.codeIsUpdated !== false || tab.url.startsWith(OWN_ORIGIN)) { - chrome.tabs.sendMessage(tab.id, request); - updateIcon(tab); - } + chrome.tabs.sendMessage(tab.id, request); + updateIcon(tab); } }); // notify all open popups @@ -47,57 +50,59 @@ function refreshAllTabs() { function updateIcon(tab, styles) { // while NTP is still loading only process the request for its main frame with a real url // (but when it's loaded we should process style toggle requests from popups, for example) - if (tab.url == "chrome://newtab/" && tab.status != "complete") { + if (tab.url == 'chrome://newtab/' && tab.status != 'complete') { return; } if (styles) { // check for not-yet-existing tabs e.g. omnibox instant search - chrome.tabs.get(tab.id, function() { + chrome.tabs.get(tab.id, () => { if (!chrome.runtime.lastError) { - // for 'styles' asHash:true fake the length by counting numeric ids manually - if (styles.length === undefined) { - styles.length = 0; - for (var id in styles) { - styles.length += id.match(/^\d+$/) ? 1 : 0; - } - } stylesReceived(styles); } }); return; } - getTabRealURL(tab, function(url) { - // if we have access to this, call directly. a page sending a message to itself doesn't seem to work right. - if (typeof getStyles != "undefined") { - getStyles({matchUrl: url, enabled: true}, stylesReceived); + getTabRealURL(tab, url => { + // if we have access to this, call directly + // (Chrome no longer sends messages to the page itself) + const options = {method: 'getStyles', matchUrl: url, enabled: true, asHash: true}; + if (typeof getStyles != 'undefined') { + getStyles(options, stylesReceived); } else { - chrome.runtime.sendMessage({method: "getStyles", matchUrl: url, enabled: true}, stylesReceived); + chrome.runtime.sendMessage(options, stylesReceived); } }); function stylesReceived(styles) { - var disableAll = "disableAll" in styles ? styles.disableAll : prefs.get("disableAll"); - var postfix = disableAll ? "x" : styles.length == 0 ? "w" : ""; + let numStyles = styles.length; + if (numStyles === undefined) { + // for 'styles' asHash:true fake the length by counting numeric ids manually + numStyles = 0; + for (let id of Object.keys(styles)) { + numStyles += id.match(/^\d+$/) ? 1 : 0; + } + } + const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll'); + const postfix = disableAll ? 'x' : numStyles == 0 ? 'w' : ''; chrome.browserAction.setIcon({ path: { // Material Design 2016 new size is 16px - 16: "16" + postfix + ".png", 32: "32" + postfix + ".png", + 16: '16' + postfix + '.png', 32: '32' + postfix + '.png', // Chromium forks or non-chromium browsers may still use the traditional 19px - 19: "19" + postfix + ".png", 38: "38" + postfix + ".png", + 19: '19' + postfix + '.png', 38: '38' + postfix + '.png', }, tabId: tab.id - }, function() { + }, () => { // if the tab was just closed an error may occur, // e.g. 'windowPosition' pref updated in edit.js::window.onbeforeunload if (!chrome.runtime.lastError) { - var t = prefs.get("show-badge") && styles.length ? ("" + styles.length) : ""; - chrome.browserAction.setBadgeText({text: t, tabId: tab.id}); + const text = prefs.get('show-badge') && numStyles ? String(numStyles) : ''; + chrome.browserAction.setBadgeText({text, tabId: tab.id}); chrome.browserAction.setBadgeBackgroundColor({ color: prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal') }); } }); - //console.log("Tab " + tab.id + " (" + tab.url + ") badge text set to '" + t + "'."); } } diff --git a/popup.js b/popup.js index cfdda45f..85665ce4 100644 --- a/popup.js +++ b/popup.js @@ -19,7 +19,8 @@ function updatePopUp(url) { return; } - chrome.runtime.sendMessage({method: "getStyles", matchUrl: url}, showStyles); + getStylesSafe({matchUrl: url}).then(showStyles); + document.querySelector("#find-styles a").href = "https://userstyles.org/styles/browse/all/" + encodeURIComponent("file" === urlWillWork[1] ? "file:" : url); // Write new style links diff --git a/storage.js b/storage.js index 47c56c9a..8a40ec21 100644 --- a/storage.js +++ b/storage.js @@ -17,103 +17,226 @@ function getDatabase(ready, error) { } }; -var cachedStyles = null; + +// Let manage/popup/edit reuse background page variables +// Note, only "var"-declared variables are visible from another extension page +var cachedStyles = ((bg) => bg && bg.cache || { + bg, + list: null, + noCode: null, + byId: new Map(), + filters: new Map(), + mutex: { + inProgress: false, + onDone: [], + }, +})(chrome.extension.getBackgroundPage()); + + +// in case Chrome haven't yet loaded the bg page and displays our page like edit/manage +function getStylesSafe(options) { + return new Promise(resolve => { + if (cachedStyles.bg) { + getStyles(options, resolve); + return; + } + chrome.runtime.sendMessage(Object.assign({method: 'getStyles'}, options), styles => { + if (!styles) { + resolve(getStylesSafe(options)); + } else { + cachedStyles = chrome.extension.getBackgroundPage().cachedStyles; + resolve(styles); + } + }); + }); +} + + function getStyles(options, callback) { - if (cachedStyles != null) { - callback(filterStyles(cachedStyles, options)); + if (cachedStyles.list) { + callback(filterStyles(options)); return; } - getDatabase(function(db) { - var tx = db.transaction(["styles"], "readonly"); - var os = tx.objectStore("styles"); - var all = []; - os.openCursor().onsuccess = function(event) { - var cursor = event.target.result; + if (cachedStyles.mutex.inProgress) { + cachedStyles.mutex.onDone.push({options, callback}); + return; + } + cachedStyles.mutex.inProgress = true; + + const t0 = performance.now() + getDatabase(db => { + const tx = db.transaction(['styles'], 'readonly'); + const os = tx.objectStore('styles'); + const all = []; + os.openCursor().onsuccess = event => { + const cursor = event.target.result; if (cursor) { - var s = cursor.value; + const s = cursor.value; s.id = cursor.key; all.push(cursor.value); cursor.continue(); } else { - cachedStyles = all; + cachedStyles.list = all; + cachedStyles.noCode = []; + for (let style of all) { + const noCode = getStyleWithNoCode(style); + cachedStyles.noCode.push(noCode); + cachedStyles.byId.set(style.id, {style, noCode}); + } + //console.log('%s getStyles %s, invoking cached callbacks: %o', (performance.now() - t0).toFixed(1), JSON.stringify(options), cache.mutex.onDone.map(e => JSON.stringify(e.options))) try{ - callback(filterStyles(all, options)); + callback(filterStyles(options)); } catch(e){ // no error in console, it works } + + cachedStyles.mutex.inProgress = false; + for (let {options, callback} of cachedStyles.mutex.onDone) { + callback(filterStyles(options)); + } + cachedStyles.mutex.onDone = []; } }; - }, null); + }, null); } -function invalidateCache(andNotify) { - cachedStyles = null; + +function getStyleWithNoCode(style) { + const stripped = Object.assign({}, style, {sections: []}); + for (let section of style.sections) { + stripped.sections.push(Object.assign({}, section, {code: null})); + } + return stripped; +} + + +function invalidateCache(andNotify, {added, updated, deletedId} = {}) { + // prevent double-add on echoed invalidation + const cached = added && cachedStyles.byId.get(added.id); + if (cached) { + return; + } if (andNotify) { - chrome.runtime.sendMessage({method: "invalidateCache"}); + chrome.runtime.sendMessage({method: 'invalidateCache', added, updated, deletedId}); } -} - -function filterStyles(styles, options) { - var enabled = fixBoolean(options.enabled); - var url = "url" in options ? options.url : null; - var id = "id" in options ? Number(options.id) : null; - var matchUrl = "matchUrl" in options ? options.matchUrl : null; - - if (enabled != null) { - styles = styles.filter(function(style) { - return style.enabled == enabled; - }); + if (!cachedStyles.list) { + return; } - if (url != null) { - styles = styles.filter(function(style) { - return style.url == url; - }); - } - if (id != null) { - styles = styles.filter(function(style) { - return style.id == id; - }); - } - if (matchUrl != null) { - // Return as a hash from style to applicable sections? Can only be used with matchUrl. - var asHash = "asHash" in options ? options.asHash : false; - if (asHash) { - var h = {disableAll: prefs.get("disableAll", false)}; - styles.forEach(function(style) { - var applicableSections = getApplicableSections(style, matchUrl); - if (applicableSections.length > 0) { - h[style.id] = applicableSections; - } - }); - return h; + if (updated) { + const cached = cachedStyles.byId.get(updated.id); + if (cached) { + Object.assign(cached.style, updated); + Object.assign(cached.noCode, getStyleWithNoCode(updated)); + //console.log('cache: updated', updated); } - styles = styles.filter(function(style) { - var applicableSections = getApplicableSections(style, matchUrl); - return applicableSections.length > 0; - }); + cachedStyles.filters.clear(); + return; } - return styles; + if (added) { + const noCode = getStyleWithNoCode(added); + cachedStyles.list.push(added); + cachedStyles.noCode.push(noCode); + cachedStyles.byId.set(added.id, {style: added, noCode}); + //console.log('cache: added', added); + cachedStyles.filters.clear(); + return; + } + if (deletedId != undefined) { + const deletedStyle = (cachedStyles.byId.get(deletedId) || {}).style; + if (deletedStyle) { + const cachedIndex = cachedStyles.list.indexOf(deletedStyle); + cachedStyles.list.splice(cachedIndex, 1); + cachedStyles.noCode.splice(cachedIndex, 1); + cachedStyles.byId.delete(deletedId); + //console.log('cache: deleted', deletedStyle); + cachedStyles.filters.clear(); + return; + } + } + cachedStyles.list = null; + cachedStyles.noCode = null; + //console.log('cache cleared'); + cachedStyles.filters.clear(); } + +function filterStyles(options = {}) { + const t0 = performance.now() + const enabled = fixBoolean(options.enabled); + const url = 'url' in options ? options.url : null; + const id = 'id' in options ? Number(options.id) : null; + const matchUrl = 'matchUrl' in options ? options.matchUrl : null; + const code = 'code' in options ? options.code : true; + const asHash = 'asHash' in options ? options.asHash : false; + + if (enabled == null + && url == null + && id == null + && matchUrl == null + && asHash != true) { + //console.log('%c%s filterStyles SKIPPED LOOP %s', 'color:gray', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + return code ? cachedStyles.list : cachedStyles.noCode; + } + + // add \t after url to prevent collisions (not sure it can actually happen though) + const cacheKey = '' + enabled + url + '\t' + id + matchUrl + '\t' + code + asHash; + const cached = cachedStyles.filters.get(cacheKey); + if (cached) { + //console.log('%c%s filterStyles REUSED RESPONSE %s', 'color:gray', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + return asHash + ? Object.assign({disableAll: prefs.get('disableAll', false)}, cached) + : cached; + } + + const styles = id == null + ? (code ? cachedStyles.list : cachedStyles.noCode) + : [code ? cachedStyles.byId.get(id).style : cachedStyles.byId.get(id).noCode]; + const filtered = asHash ? {} : []; + + for (let i = 0, style; (style = styles[i]); i++) { + if ((enabled == null || style.enabled == enabled) + && (url == null || style.url == url) + && (id == null || style.id == id)) { + const sections = (asHash || matchUrl != null) && getApplicableSections(style, matchUrl); + if (asHash) { + if (sections.length) { + filtered[style.id] = sections; + } + } else if (matchUrl == null || sections.length) { + filtered.push(style); + } + } + } + //console.log('%s filterStyles %s', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + cachedStyles.filters.set(cacheKey, filtered); + return asHash + ? Object.assign({disableAll: prefs.get('disableAll', false)}, filtered) + : filtered; +} + + function saveStyle(style, {notify = true} = {}) { return new Promise(resolve => { getDatabase(db => { const tx = db.transaction(['styles'], 'readwrite'); const os = tx.objectStore('styles'); + delete style.method; + // Update if (style.id) { style.id = Number(style.id); os.get(style.id).onsuccess = eventGet => { const oldStyle = Object.assign({}, eventGet.target.result); - const codeIsUpdated = !styleSectionsEqual(style, oldStyle); + const codeIsUpdated = 'sections' in style && !styleSectionsEqual(style, oldStyle); style = Object.assign(oldStyle, style); + addMissingStyleTargets(style); os.put(style).onsuccess = eventPut => { style.id = style.id || eventPut.target.result; + invalidateCache(notify, {updated: style}); if (notify) { notifyAllTabs({method: 'styleUpdated', style, codeIsUpdated}); } - invalidateCache(notify); resolve(style); }; }; @@ -121,6 +244,7 @@ function saveStyle(style, {notify = true} = {}) { } // Create + delete style.id; style = Object.assign({ // Set optional things if they're undefined enabled: true, @@ -128,23 +252,12 @@ function saveStyle(style, {notify = true} = {}) { md5Url: null, url: null, originalMd5: null, - }, style, { - // Set other optional things to empty array if they're undefined - sections: style.sections.map(section => - Object.assign({ - urls: [], - urlPrefixes: [], - domains: [], - regexps: [], - }, section) - ), - }) - // Make sure it's not null - that makes indexeddb sad - delete style.id; + }, style); + addMissingStyleTargets(style); os.add(style).onsuccess = event => { - invalidateCache(true); // Give it the ID that was generated style.id = event.target.result; + invalidateCache(true, {added: style}); notifyAllTabs({method: 'styleAdded', style}); resolve(style); }; @@ -152,13 +265,25 @@ function saveStyle(style, {notify = true} = {}) { }); } -function enableStyle(id, enabled) { - saveStyle({id: id, enabled: enabled}).then(style => { - handleUpdate(style); - notifyAllTabs({method: "styleUpdated", style}); - }); + +function addMissingStyleTargets(style) { + style.sections = (style.sections || []).map(section => + Object.assign({ + urls: [], + urlPrefixes: [], + domains: [], + regexps: [], + }, section) + ); } + +function enableStyle(id, enabled) { + saveStyle({id, enabled}) + .then(handleUpdate); +} + + function deleteStyle(id, callback = function (){}) { getDatabase(function(db) { var tx = db.transaction(["styles"], "readwrite"); @@ -166,13 +291,14 @@ function deleteStyle(id, callback = function (){}) { var request = os.delete(Number(id)); request.onsuccess = function(event) { handleDelete(id); - invalidateCache(true); - notifyAllTabs({method: "styleDeleted", id: id}); + invalidateCache(true, {deletedId: id}); + notifyAllTabs({method: "styleDeleted", id}); callback(); }; }); } + function reportError() { for (i in arguments) { if ("message" in arguments[i]) { @@ -182,6 +308,7 @@ function reportError() { } } + function fixBoolean(b) { if (typeof b != "undefined") { return b != "false"; @@ -189,6 +316,7 @@ function fixBoolean(b) { return null; } + function getDomains(url) { if (url.indexOf("file:") == 0) { return []; @@ -202,6 +330,7 @@ function getDomains(url) { return domains; } + function getType(o) { if (typeof o == "undefined" || typeof o == "string") { return typeof o; @@ -212,7 +341,8 @@ function getType(o) { throw "Not supported - " + o; } -var namespacePattern = /^\s*(@namespace[^;]+;\s*)+$/; +const namespacePattern = /^\s*(@namespace[^;]+;\s*)+$/; + function getApplicableSections(style, url) { var sections = style.sections.filter(function(section) { return sectionAppliesToUrl(section, url); @@ -224,6 +354,7 @@ function getApplicableSections(style, url) { return sections; } + function sectionAppliesToUrl(section, url) { // only http, https, file, and chrome-extension allowed if (url.indexOf("http") != 0 && url.indexOf("file") != 0 && url.indexOf("chrome-extension") != 0 && url.indexOf("ftp") != 0) { @@ -275,6 +406,7 @@ function sectionAppliesToUrl(section, url) { return false; } + function isCheckbox(el) { return el.nodeName.toLowerCase() == "input" && "checkbox" == el.type.toLowerCase(); } @@ -462,6 +594,7 @@ var prefs = chrome.extension.getBackgroundPage().prefs || new function Prefs() { } }; + function getCodeMirrorThemes(callback) { chrome.runtime.getPackageDirectoryEntry(function(rootDir) { rootDir.getDirectory("codemirror/theme", {create: false}, function(themeDir) { @@ -481,6 +614,7 @@ function getCodeMirrorThemes(callback) { }); } + function sessionStorageHash(name) { var hash = { value: {}, @@ -495,6 +629,7 @@ function sessionStorageHash(name) { return hash; } + function deepCopy(obj) { if (!obj || typeof obj != "object") { return obj; @@ -504,6 +639,7 @@ function deepCopy(obj) { } } + function deepMerge(target, obj1 /* plus any number of object arguments */) { for (var i = 1; i < arguments.length; i++) { var obj = arguments[i]; @@ -522,6 +658,7 @@ function deepMerge(target, obj1 /* plus any number of object arguments */) { return target; } + function equal(a, b) { if (!a || !b || typeof a != "object" || typeof b != "object") { return a === b; @@ -537,6 +674,7 @@ function equal(a, b) { return true; } + function defineReadonlyProperty(obj, key, value) { var copy = deepCopy(value); // In ES6, freezing a literal is OK (it returns the same value), but in previous versions it's an exception. @@ -546,7 +684,7 @@ function defineReadonlyProperty(obj, key, value) { Object.defineProperty(obj, key, {value: copy, configurable: true}) } -// Polyfill, can be removed when Firefox gets this - https://bugzilla.mozilla.org/show_bug.cgi?id=1220494 +// Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494 function getSync() { if ("sync" in chrome.storage) { return chrome.storage.sync; @@ -567,6 +705,7 @@ function getSync() { } } + function styleSectionsEqual(styleA, styleB) { if (!styleA.sections || !styleB.sections) { return undefined;