From f8d13d8decb5230d5873261392211e36245c6b6a Mon Sep 17 00:00:00 2001 From: tophf Date: Fri, 31 Mar 2017 02:18:41 +0300 Subject: [PATCH] Remove code:false mode; show sloppy regexps in popup * Now that our own pages retrieve the styles directly via getStylesSafe the only 0.001% of cases where code:false would be needed (the browser is starting up with some of the tabs showing our built-in pages like editor or manage) is not worth optimizing for. * According to CSS4 @document specification the entire URL must match. Stylish-for-Chrome implemented it incorrectly since the very beginning. We detect styles that abuse the bug by finding the sections that would have been applied by Stylish but not by us as we follow the spec. Additionally we'll check for invalid regexps. --- .eslintrc | 1 + _locales/en/messages.json | 14 ++ backup/fileSaveLoad.js | 2 +- manage.js | 6 +- messaging.js | 1 + popup.css | 304 ++++++++++++++++++++++++++++---------- popup.html | 16 +- popup.js | 149 ++++++++++++++----- storage.js | 212 ++++++++++++++------------ 9 files changed, 487 insertions(+), 218 deletions(-) diff --git a/.eslintrc b/.eslintrc index 85e21a98..d02a8b8c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ globals: # messaging.js OWN_ORIGIN: false KEEP_CHANNEL_OPEN: false + RX_SUPPORTED_URLS: false configureCommands: false notifyAllTabs: false refreshAllTabs: false diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9b462ee4..46eb7ae4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -402,6 +402,20 @@ "message": "Invalid regexps skipped", "description": "RegExp test report: label for the invalid expressions" }, + "styleRegexpPartialExplanation": { + "message": "This style uses partially matching regexps in violation of CSS4 @document specification which requires a full URL match. The affected CSS sections were not applied to the page. This style was probably created in Stylish-for-Chrome which incorrectly checks 'regexp()' rules since the very first version (known bug)." + }, + "styleRegexpInvalidExplanation": { + "message": "Some 'regexp()' rules that could not be compiled at all." + }, + "styleNotAppliedRegexpProblemTooltip": { + "message": "Style was not applied due to its incorrect usage of 'regexp()'", + "description": "Tooltip in the popup for styles that were not applied at all" + }, + "styleRegexpProblemTooltip": { + "message": "Number of sections not applied due to incorrect usage of 'regexp()'", + "description": "Tooltip in the popup for styles that were applied only partially" + }, "styleBeautify": { "message": "Beautify", "description": "Label for the CSS-beautifier button on the edit style page" diff --git a/backup/fileSaveLoad.js b/backup/fileSaveLoad.js index 3bf03510..114d8763 100644 --- a/backup/fileSaveLoad.js +++ b/backup/fileSaveLoad.js @@ -70,7 +70,7 @@ function importFromString(jsonString) { continue; } item.name = item.name.trim(); - const byId = (cachedStyles.byId.get(item.id) || {}).style; + const byId = cachedStyles.byId.get(item.id); const byName = oldStylesByName.get(item.name); const oldStyle = byId && byId.name.trim() == item.name || !byName ? byId : byName; if (oldStyle == byName && byName) { diff --git a/manage.js b/manage.js index a29855d0..d7094034 100644 --- a/manage.js +++ b/manage.js @@ -7,7 +7,7 @@ const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps']; const TARGET_LIMIT = 10; -getStylesSafe({code: false}) +getStylesSafe() .then(showStyles) .then(initGlobalEvents); @@ -235,7 +235,7 @@ class EntryOnClick { static delete(event) { const styleElement = getClickedStyleElement(event); const id = styleElement.styleId; - const name = ((cachedStyles.byId.get(id) || {}).style || {}).name; + const {name} = cachedStyles.byId.get(id) || {}; animateElement(styleElement, {className: 'highlight'}); messageBox({ title: t('deleteStyleConfirm'), @@ -436,7 +436,7 @@ function searchStyles({immediately, container}) { } for (const element of (container || installed).children) { - const {style} = cachedStyles.byId.get(element.styleId) || {}; + const style = cachedStyles.byId.get(element.styleId) || {}; if (style) { const isMatching = !query || isMatchingText(style.name) diff --git a/messaging.js b/messaging.js index fb221764..d2e8ecba 100644 --- a/messaging.js +++ b/messaging.js @@ -4,6 +4,7 @@ // keep message channel open for sendResponse in chrome.runtime.onMessage listener const KEEP_CHANNEL_OPEN = true; const OWN_ORIGIN = chrome.runtime.getURL(''); +const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${OWN_ORIGIN}`); function notifyAllTabs(request) { diff --git a/popup.css b/popup.css index bcd7c7d6..a92052de 100644 --- a/popup.css +++ b/popup.css @@ -1,29 +1,43 @@ body { width: 252px; font-size: 12px; - font-family: Arial,"Helvetica Neue",Helvetica,sans-serif; + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + margin: 0; } + +body > div:not(#installed) { + margin-left: 0.75em; + margin-right: 0.75em; +} + input[type=checkbox] { outline: none; } + #disable-all-wrapper { padding: 0.3em 0 0.6em; } + #no-styles { font-style: italic; } + #popup-shortcuts-button { margin-left: 3px; } + .checker { display: inline; } + .style-name { cursor: default; font-weight: bold; display: block; } -a, a:visited { + +a, +a:visited { color: black; text-decoration-skip: ink; } @@ -33,53 +47,151 @@ a, a:visited { width: 16px; vertical-align: top; } + .left-gutter input { margin-bottom: 1px; margin-top: 0; margin-left: 0; } + .main-controls { display: table-cell; } -.entry { - padding: 0.5em 0; -} -.entry:first-child { - padding-top: 0; -} - #unavailable, #installed { border-bottom: 1px solid black; padding-bottom: 2px; } + body > DIV:last-of-type, body.blocked > DIV { border-bottom: none; } + #installed { padding-top: 2px; max-height: 434px; overflow-y: auto; } + #installed.disabled .style-name { text-decoration: line-through; } + #installed .actions { cursor: default; } + #installed .actions a { cursor: pointer; text-decoration: none; } -#installed .style-edit-link { + +/* entry */ + +.entry { + display: flex; + align-items: center; + padding: 5px 0.75em; +} + +.entry:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +.entry .style-edit-link { margin-right: 2px; } -#installed .style-edit-link, #installed .delete { + +.entry .style-edit-link, +.entry .delete { display: inline-block; padding: 0 1px 0; } + +.entry .main-controls { + display: flex; + flex: 1; + width: calc(100% - 20px); + align-items: center; +} + +.entry .main-controls label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 5px; +} + +.not-applied .checker, +.not-applied .style-name, +.not-applied .actions > * { + opacity: .2; + transition: opacity .5s ease-in-out .25s, color .5s ease-in-out .25s; +} + +.not-applied:hover .checker, +.not-applied:hover .style-name, +.not-applied:hover .actions > * { + opacity: 1; +} + +.not-applied:hover .style-name { + color: darkorange; +} + +.regexp-problem-indicator { + background-color: darkorange; + width: 15px; + height: 15px; + line-height: 16px; + border-radius: 8px; + margin-right: 4px; + margin-left: 4px; + text-align: center; + color: white; + font-weight: bold; + box-sizing: border-box; + cursor: pointer; +} + +.regexp-partial .actions, +.regexp-invalid .actions { + order: 2; +} + +#regexp-explanation { + position: fixed; + background-color: white; + left: 0; + right: 0; + padding: .5rem; + font-size: 90%; + border-top: 2px solid black; + border-bottom: 2px solid black; + box-shadow: 0 0 100px black; + display: flex; + flex-direction: column; +} + +#regexp-explanation > div { + display: none; + list-style-type: none; + padding: 0; + margin: 0; +} + +.regexp-partial #regexp-partial, +.regexp-invalid #regexp-invalid { + display: block; +} + +#regexp-explanation > div:not(:last-child) { + margin-bottom: .5rem; +} + .svg-icon { pointer-events: none; transition: fill .5s; @@ -92,20 +204,27 @@ body > .actions { margin-top: 0.5em; } -.actions > div:not(:last-child):not(#disable-all-wrapper), .actions > .main-controls > div:not(:last-child), #unavailable:not(:last-child), #unavailable + .actions { +.actions > div:not(:last-child):not(#disable-all-wrapper), +.actions > .main-controls > div:not(:last-child), +#unavailable:not(:last-child), +#unavailable + .actions { margin-bottom: 0.75em; } -.actions input, .actions label { + +.actions input, +.actions label { vertical-align: middle; } #unavailable { border: none; - display: none; /* flex */ + display: none; + margin-top: 0.75em; align-items: center; justify-content: center; font-size: 14px; } + body.blocked #installed, body.blocked #find-styles, body.blocked #write-style, @@ -118,41 +237,84 @@ body.blocked #unavailable { } /* Never shown, but can be enabled with a style */ -.enable, .disable { + +.enable, +.disable { display: none; } /* 'New style' links */ -#write-style-for {margin-right: .6ex} -.write-style-link {margin-left: .6ex} -.write-style-link::before, .write-style-link::after {font-size: 12px} -.write-style-link::before {content: "\00ad"} /* "soft" hyphen */ -#match {overflow-wrap: break-word;} + +#write-style-for { + margin-right: .6ex +} + +.write-style-link { + margin-left: .6ex +} + +.write-style-link::before, +.write-style-link::after { + font-size: 12px +} + +.write-style-link::before { + content: "\00ad"; /* "soft" hyphen */ +} + +#match { + overflow-wrap: break-word; + display: inline-block; +} /* "breadcrumbs" 'new style' links */ -.breadcrumbs > .write-style-link {margin-left: 0} -.breadcrumbs:hover a {color: #bbb; text-decoration: none} +.breadcrumbs > .write-style-link { + margin-left: 0 +} + +.breadcrumbs:hover a { + color: #bbb; + text-decoration: none +} + +/* use just the subdomain name instead of the full domain name */ +.breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2)) { + font-size: 0 +} - /* use just the subdomain name instead of the full domain name */ -.breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2)) {font-size: 0} .breadcrumbs > .write-style-link[subdomain]:not(:nth-last-child(2))::before { content: attr(subdomain); } - /* "dot" after each subdomain name */ -.breadcrumbs > .write-style-link[subdomain]::after {content: "."} - /* no "dot" after top-level domain */ -.breadcrumbs > .write-style-link:nth-last-child(2)::after {content: none} - /* "forward slash" before path ("this URL") */ -.breadcrumbs > .write-style-link:last-child::before {content: "\200b/"} +/* "dot" after each subdomain name */ +.breadcrumbs > .write-style-link[subdomain]::after { + content: "." +} + +/* no "dot" after top-level domain */ +.breadcrumbs > .write-style-link:nth-last-child(2)::after { + content: none +} + +/* "forward slash" before path ("this URL") */ +.breadcrumbs > .write-style-link:last-child::before { + content: "\200b/" +} + .breadcrumbs > .write-style-link:last-child:first-child::before, -.breadcrumbs > .write-style-link[subdomain=""] + .write-style-link::before {content: none} +.breadcrumbs > .write-style-link[subdomain=""] + .write-style-link::before { + content: none +} - /* suppress TLD-only link */ -.breadcrumbs > .write-style-link[subdomain=""] {display: none} +/* suppress TLD-only link */ +.breadcrumbs > .write-style-link[subdomain=""] { + display: none +} - /* :hover style */ -.breadcrumbs.url\(\) > .write-style-link, /* :hover or :focus on "this URL" sets class="url()" */ +/* :hover style */ +.breadcrumbs.url\(\) > .write-style-link, + +/* :hover or :focus on "this URL" sets class="url()" */ .breadcrumbs > .write-style-link:hover, .breadcrumbs > .write-style-link:focus, .breadcrumbs > .write-style-link:hover ~ .write-style-link[subdomain], @@ -162,13 +324,15 @@ body.blocked #unavailable { text-decoration-skip: ink; } - /* action buttons */ +/* action buttons */ + #popup-options { display: flex; flex-direction: row; justify-content: space-around; padding: 1.2em 0; } + #popup-options button { margin: 0 2px; width: 33%; @@ -177,72 +341,43 @@ body.blocked #unavailable { text-overflow: ellipsis; } - /* margins */ -body { - margin: 0; -} -body>div:not(#installed) { - margin-left:0.75em; - margin-right:0.75em; -} -#unavailable { - margin-top: 0.75em; -} -#installed .entry { -} - /* entries */ -#installed .entry { - display: flex; - align-items: center; - padding: 5px 0.75em; -} -#installed .entry:nth-child(even) { - background-color: rgba(0, 0, 0, 0.05); -} -#installed .main-controls { - display: flex; - flex: 1; - width: calc(100% - 20px); - align-items: center; -} -#installed .main-controls label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-right: 5px; -} - /* confirm */ +/* confirm */ + #confirm, -#confirm>div>span { +#confirm > div > span { align-items: center; justify-content: center; } + #confirm { z-index: 2147483647; - display: none; /* flex */ + display: none; position: absolute; left: 0; top: 0; width: 100%; height: 100%; - margin: 0!important; + margin: 0 !important; box-sizing: border-box; background-color: rgba(0, 0, 0, 0.4); animation: lights-off .5s cubic-bezier(.03, .67, .08, .94); animation-fill-mode: both; } + #confirm.lights-on { animation: lights-on .25s ease-in-out; animation-fill-mode: both; } -#confirm.lights-on > div{ + +#confirm.lights-on > div { display: none; } + #confirm[data-display=true] { display: flex; } -#confirm>div { + +#confirm > div { width: 80%; height: 100px; max-height: 80%; @@ -252,25 +387,30 @@ body>div:not(#installed) { flex-direction: column; border: solid 2px rgba(0, 0, 0, 0.5); } -#confirm>div>span { + +#confirm > div > span { display: flex; flex: 1; padding: 0 10px; } -#confirm>div>b { + +#confirm > div > b { padding: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#confirm>div>div { + +#confirm > div > div { padding: 10px; text-align: center; } -.non-windows #confirm>div>div { + +.non-windows #confirm > div > div { direction: rtl; text-align: right; } + @keyframes lights-off { from { background-color: transparent; @@ -279,6 +419,7 @@ body>div:not(#installed) { background-color: rgba(0, 0, 0, 0.4); } } + @keyframes lights-on { from { background-color: rgba(0, 0, 0, 0.4); @@ -287,4 +428,3 @@ body>div:not(#installed) { background-color: transparent; } } - diff --git a/popup.html b/popup.html index 5fb3f9b6..0a36c1ad 100644 --- a/popup.html +++ b/popup.html @@ -37,7 +37,19 @@
- + + + + + @@ -81,7 +93,7 @@
-
+
diff --git a/popup.js b/popup.js index 23f6a30f..553d3ab3 100644 --- a/popup.js +++ b/popup.js @@ -1,15 +1,19 @@ +/* global SLOPPY_REGEXP_PREFIX, compileStyleRegExps */ 'use strict'; let installed; +let tabURL; getActiveTabRealURL().then(url => { - const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${OWN_ORIGIN}`); - const isUrlSupported = RX_SUPPORTED_URLS.test(url); + tabURL = RX_SUPPORTED_URLS.test(url) ? url : ''; Promise.all([ - isUrlSupported ? getStylesSafe({matchUrl: url}) : null, - onDOMready().then(() => initPopup(isUrlSupported ? url : '')), - ]) - .then(([styles]) => styles && showStyles(styles)); + tabURL && getStylesSafe({matchUrl: tabURL}), + onDOMready().then(() => { + initPopup(tabURL); + }), + ]).then(([styles]) => { + showStyles(styles); + }); }); @@ -116,30 +120,55 @@ function initPopup(url) { function showStyles(styles) { + if (!styles) { + return; + } if (!styles.length) { installed.innerHTML = template.noStyles.outerHTML; - } else { - const enabledFirst = prefs.get('popup.enabledFirst'); - styles.sort((a, b) => ( - enabledFirst && a.enabled !== b.enabled - ? !(a.enabled < b.enabled) ? -1 : 1 - : a.name.localeCompare(b.name) - )); - const fragment = document.createDocumentFragment(); - for (const style of styles) { - fragment.appendChild(createStyleElement(style)); - } - installed.appendChild(fragment); + return; } + + const enabledFirst = prefs.get('popup.enabledFirst'); + styles.sort((a, b) => ( + enabledFirst && a.enabled !== b.enabled + ? !(a.enabled < b.enabled) ? -1 : 1 + : a.name.localeCompare(b.name) + )); + + let postponeDetect = false; + const t0 = performance.now(); + const container = document.createDocumentFragment(); + for (const style of styles) { + createStyleElement({style, container, postponeDetect}); + postponeDetect = postponeDetect || performance.now() - t0 > 100; + } + installed.appendChild(container); + + getStylesSafe({matchUrl: tabURL, strictRegexp: false}) + .then(unscreenedStyles => { + for (const unscreened of unscreenedStyles) { + if (!styles.includes(unscreened)) { + postponeDetect = postponeDetect || performance.now() - t0 > 100; + createStyleElement({ + style: Object.assign({appliedSections: [], postponeDetect}, unscreened), + }); + } + } + }); } // silence the inapplicable warning for async code /* eslint no-use-before-define: [2, {"functions": false, "classes": false}] */ -function createStyleElement(style) { +function createStyleElement({ + style, + container = installed, + postponeDetect, +}) { const entry = template.style.cloneNode(true); entry.setAttribute('style-id', style.id); Object.assign(entry, { + id: 'style-' + style.id, styleId: style.id, className: entry.className + ' ' + (style.enabled ? 'enabled' : 'disabled'), onmousedown: openEditorOnMiddleclick, @@ -171,7 +200,18 @@ function createStyleElement(style) { $('.disable', entry).onclick = EntryOnClick.toggle; $('.delete', entry).onclick = EntryOnClick.delete; - return entry; + if (postponeDetect) { + setTimeout(detectSloppyRegexps, 0, {entry, style}); + } else { + detectSloppyRegexps({entry, style}); + } + + const oldElement = $('#style-' + style.id); + if (oldElement) { + oldElement.parentNode.replaceChild(entry, oldElement); + } else { + container.appendChild(entry); + } } @@ -194,7 +234,7 @@ class EntryOnClick { const box = $('#confirm'); box.dataset.display = true; box.style.cssText = ''; - $('b', box).textContent = ((cachedStyles.byId.get(id) || {}).style || {}).name; + $('b', box).textContent = (cachedStyles.byId.get(id) || {}).name; $('[data-cmd="ok"]', box).onclick = () => confirm(true); $('[data-cmd="cancel"]', box).onclick = () => confirm(false); window.onkeydown = event => { @@ -219,6 +259,18 @@ class EntryOnClick { } } + static indicator(event) { + const entry = getClickedStyleElement(event); + const info = template.regexpProblemExplanation.cloneNode(true); + $$('#' + info.id).forEach(el => el.remove()); + $$('a', info).forEach(el => (el.onclick = openURLandHide)); + $$('button', info).forEach(el => (el.onclick = EntryOnClick.closeExplanation)); + entry.appendChild(info); + } + + static closeExplanation(event) { + $('#regexp-explanation').remove(); + } } @@ -264,24 +316,51 @@ function openURLandHide(event) { function handleUpdate(style) { - const styleElement = $(`[style-id="${style.id}"]`, installed); - if (styleElement) { - installed.replaceChild(createStyleElement(style), styleElement); - } else { - getActiveTabRealURL().then(url => { - if (getApplicableSections(style, url).length) { - // a new style for the current url is installed - $('#unavailable').style.display = 'none'; - installed.appendChild(createStyleElement(style)); - } - }); + if ($('#style-' + style.id)) { + createStyleElement({style}); + return; + } + // Add an entry when a new style for the current url is installed + if (tabURL && getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) { + $('#unavailable').style.display = 'none'; + createStyleElement({style}); } } function handleDelete(id) { - const styleElement = $(`[style-id="${id}"]`, installed); - if (styleElement) { - installed.removeChild(styleElement); + $$('#style-' + id).forEach(el => el.remove()); +} + + +/* + According to CSS4 @document specification the entire URL must match. + Stylish-for-Chrome implemented it incorrectly since the very beginning. + We'll detect styles that abuse the bug by finding the sections that + would have been applied by Stylish but not by us as we follow the spec. + Additionally we'll check for invalid regexps. +*/ +function detectSloppyRegexps({entry, style}) { + const { + appliedSections = getApplicableSections({style, matchUrl: tabURL}), + wannabeSections = getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}), + } = style; + + compileStyleRegExps({style, compileAll: true}); + entry.hasInvalidRegexps = wannabeSections.some(section => + section.regexps.some(rx => !cachedStyles.regexps.has(rx))); + entry.sectionsSkipped = wannabeSections.length - appliedSections.length; + + if (!appliedSections.length) { + entry.classList.add('not-applied'); + $('.style-name', entry).title = t('styleNotAppliedRegexpProblemTooltip'); + } + if (entry.sectionsSkipped || entry.hasInvalidRegexps) { + entry.classList.toggle('regexp-partial', entry.sectionsSkipped); + entry.classList.toggle('regexp-invalid', entry.hasInvalidRegexps); + const indicator = template.regexpProblemIndicator.cloneNode(true); + indicator.appendChild(document.createTextNode(entry.sectionsSkipped || '!')); + indicator.onclick = EntryOnClick.indicator; + $('.main-controls', entry).appendChild(indicator); } } diff --git a/storage.js b/storage.js index 3f3c3e33..656fcbf0 100644 --- a/storage.js +++ b/storage.js @@ -28,7 +28,7 @@ const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, /(@namespace[\s\r\n]+(?:[^\s\r\n]+[\s\r\n]+)?url\(http:\/\/.*?\);)/, /[\s\r\n]*/].map(rx => rx.source).join(''), 'g'); const RX_CSS_COMMENTS = /\/\*[\s\S]*?\*\//g; - +const SLOPPY_REGEXP_PREFIX = '\0'; // Let manage/popup/edit reuse background page variables // Note, only 'var'-declared variables are visible from another extension page @@ -39,11 +39,11 @@ var cachedStyles, prefs; cachedStyles = bg && bg.cachedStyles || { bg, list: null, - noCode: null, byId: new Map(), filters: new Map(), regexps: new Map(), urlDomains: new Map(), + emptyCode: new Map(), // entire code is comments/whitespace/@namespace mutex: { inProgress: false, onDone: [], @@ -89,15 +89,12 @@ function getStyles(options, callback) { const os = tx.objectStore('styles'); os.getAll().onsuccess = event => { cachedStyles.list = event.target.result || []; - cachedStyles.noCode = []; cachedStyles.byId.clear(); for (const style of cachedStyles.list) { - const noCode = getStyleWithNoCode(style); - cachedStyles.noCode.push(noCode); - cachedStyles.byId.set(style.id, {style, noCode}); - compileStyleRegExps(style); + cachedStyles.byId.set(style.id, style); + compileStyleRegExps({style}); } - //console.log('%s getStyles %s, invoking cached callbacks: %o', (performance.now() - t0).toFixed(1), JSON.stringify(options), cachedStyles.mutex.onDone.map(e => JSON.stringify(e.options))) + //console.debug('%s getStyles %s, invoking cached callbacks: %o', (performance.now() - t0).toFixed(1), JSON.stringify(options), cachedStyles.mutex.onDone.map(e => JSON.stringify(e.options))); // eslint-disable-line max-len callback(filterStyles(options)); cachedStyles.mutex.inProgress = false; @@ -134,19 +131,16 @@ function invalidateCache(andNotify, {added, updated, deletedId} = {}) { 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); + Object.assign(cached, updated); + //console.debug('cache: updated', updated); } cachedStyles.filters.clear(); return; } 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.byId.set(added.id, added); + //console.debug('cache: added', added); cachedStyles.filters.clear(); return; } @@ -155,46 +149,47 @@ function invalidateCache(andNotify, {added, updated, deletedId} = {}) { 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); + //console.debug('cache: deleted', deletedStyle); cachedStyles.filters.clear(); return; } } cachedStyles.list = null; - cachedStyles.noCode = null; - //console.log('cache cleared'); + //console.debug('cache cleared'); cachedStyles.filters.clear(); } -function filterStyles(options = {}) { +function filterStyles({ + enabled, + url = null, + id = null, + matchUrl = null, + asHash = null, + strictRegexp = true, // used by the popup to detect bad regexps +} = {}) { //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; + enabled = fixBoolean(enabled); + id = id === null ? null : Number(id); 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; + //console.debug('%c%s filterStyles SKIPPED LOOP %s', 'color:gray', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len + return cachedStyles.list; } // silence the inapplicable warning for async code // eslint-disable-next-line no-use-before-define const disableAll = asHash && prefs.get('disableAll', false); // 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 cacheKey = ' ' + enabled + url + '\t' + id + matchUrl + '\t' + asHash + strictRegexp; 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)) + //console.debug('%c%s filterStyles REUSED RESPONSE %s', 'color:gray', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len cached.hits++; cached.lastHit = Date.now(); @@ -212,19 +207,22 @@ function filterStyles(options = {}) { } const styles = id === null - ? (code ? cachedStyles.list : cachedStyles.noCode) - : [(cachedStyles.byId.get(id) || {})[code ? 'style' : 'noCode']]; + ? cachedStyles.list + : [cachedStyles.byId.get(id)]; const filtered = asHash ? {} : []; if (!styles) { // may happen when users [accidentally] reopen an old URL // of edit.html with a non-existent style id parameter return filtered; } + const needSections = asHash || matchUrl !== null; + 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); + const sections = needSections && + getApplicableSections({style, matchUrl, strictRegexp, stopOnFirst: !asHash}); if (asHash) { if (sections.length) { filtered[style.id] = sections; @@ -234,7 +232,7 @@ function filterStyles(options = {}) { } } } - //console.log('%s filterStyles %s', (performance.now() - t0).toFixed(1), JSON.stringify(options)) + //console.debug('%s filterStyles %s', (performance.now() - t0).toFixed(1), enabled, id, asHash, strictRegexp, matchUrl); // eslint-disable-line max-len cachedStyles.filters.set(cacheKey, { styles: filtered, lastHit: Date.now(), @@ -307,7 +305,7 @@ function saveStyle(style) { os.put(style).onsuccess = eventPut => { style.id = style.id || eventPut.target.result; invalidateCache(notify, existed ? {updated: style} : {added: style}); - compileStyleRegExps(style); + compileStyleRegExps({style}); if (notify) { notifyAllTabs({ method: existed ? 'styleUpdated' : 'styleAdded', @@ -338,7 +336,7 @@ function saveStyle(style) { // Give it the ID that was generated style.id = event.target.result; invalidateCache(notify, {added: style}); - compileStyleRegExps(style); + compileStyleRegExps({style}); if (notify) { notifyAllTabs({method: 'styleAdded', style, reason}); } @@ -434,68 +432,88 @@ function getType(o) { } -function getApplicableSections(style, url) { +function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) { + //let t0 = 0; const sections = []; checkingSections: for (const section of style.sections) { - // only http, https, file, ftp, and chrome-extension://OWN_EXTENSION_ID allowed - if (!url.startsWith('http') - && !url.startsWith('ftp') - && !url.startsWith('file') - && !url.startsWith(OWN_ORIGIN)) { - continue checkingSections; - } - if (section.urls.length == 0 - && section.domains.length == 0 - && section.urlPrefixes.length == 0 - && section.regexps.length == 0) { - sections.push(section); - continue checkingSections; - } - if (section.urls.indexOf(url) != -1) { - sections.push(section); - continue checkingSections; - } - for (const urlPrefix of section.urlPrefixes) { - if (url.startsWith(urlPrefix)) { - sections.push(section); + andCollect: + do { + // only http, https, file, ftp, and chrome-extension://OWN_EXTENSION_ID allowed + if (!matchUrl.startsWith('http') + && !matchUrl.startsWith('ftp') + && !matchUrl.startsWith('file') + && !matchUrl.startsWith(OWN_ORIGIN)) { continue checkingSections; } - } - const urlDomains = cachedStyles.urlDomains.get(url) || getDomains(url); - for (const domain of urlDomains) { - if (section.domains.indexOf(domain) != -1) { - sections.push(section); - continue checkingSections; + if (section.urls.length == 0 + && section.domains.length == 0 + && section.urlPrefixes.length == 0 + && section.regexps.length == 0) { + break andCollect; } - } - for (const regexp of section.regexps) { - let rx = cachedStyles.regexps.get(regexp); - if (rx == false) { - // bad regexp - continue; + if (section.urls.indexOf(matchUrl) != -1) { + break andCollect; } - if (!rx) { - rx = tryRegExp('^(?:' + regexp + ')$'); - cachedStyles.regexps.set(regexp, rx || false); - if (!rx) { - // bad regexp - continue; + for (const urlPrefix of section.urlPrefixes) { + if (matchUrl.startsWith(urlPrefix)) { + break andCollect; } } - if (rx.test(url)) { - sections.push(section); - continue checkingSections; + if (section.domains.length) { + const urlDomains = cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl); + for (const domain of urlDomains) { + if (section.domains.indexOf(domain) != -1) { + break andCollect; + } + } + } + for (const regexp of section.regexps) { + for (let pass = 1; pass <= (strictRegexp ? 1 : 2); pass++) { + const cacheKey = pass == 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp; + let rx = cachedStyles.regexps.get(cacheKey); + if (rx == false) { + // invalid regexp + break; + } + if (!rx) { + const anchored = pass == 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$'; + rx = tryRegExp(anchored); + cachedStyles.regexps.set(cacheKey, rx || false); + if (!rx) { + // invalid regexp + break; + } + } + if (rx.test(matchUrl)) { + break andCollect; + } + } + } + continue checkingSections; + } while (0); + // Collect the section if not empty or namespace-only. + // We don't check long code as it's slow both for emptyCode declared as Object + // and as Map in case the string is not the same reference used to add the item + //const t0start = performance.now(); + const code = section.code; + let isEmpty = code.length < 1000 && cachedStyles.emptyCode.get(code); + if (isEmpty === undefined) { + isEmpty = !code || !code.trim() + || code.indexOf('@namespace') >= 0 + && code.replace(RX_CSS_COMMENTS, '').replace(RX_NAMESPACE, '').trim() == ''; + cachedStyles.emptyCode.set(code, isEmpty); + } + //t0 += performance.now() - t0start; + if (!isEmpty) { + sections.push(section); + if (stopOnFirst) { + //t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions + return sections; } } } - // ignore @namespace-only results - if (sections.length == 1 - && sections[0].code - && sections[0].code.indexOf('@namespace') >= 0 - && sections[0].code.replace(RX_CSS_COMMENTS, '').replace(RX_NAMESPACE, '').trim() == '') { - return []; - } + //t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions return sections; } @@ -888,18 +906,22 @@ function styleSectionsEqual(styleA, styleB) { } -function compileStyleRegExps(style) { +function compileStyleRegExps({style, compileAll}) { const t0 = performance.now(); for (const section of style.sections || []) { for (const regexp of section.regexps) { - // we want to match the full url, so add ^ and $ if not already present - if (cachedStyles.regexps.has(regexp)) { - continue; - } - const rx = tryRegExp('^(?:' + regexp + ')$'); - cachedStyles.regexps.set(regexp, rx || false); - if (performance.now() - t0 > 100) { - return; + for (let pass = 1; pass <= (compileAll ? 2 : 1); pass++) { + const cacheKey = pass == 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp; + if (cachedStyles.regexps.has(cacheKey)) { + continue; + } + // according to CSS4 @document specification the entire URL must match + const anchored = pass == 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$'; + const rx = tryRegExp(anchored); + cachedStyles.regexps.set(cacheKey, rx || false); + if (!compileAll && performance.now() - t0 > 100) { + return; + } } } }