Kill old storage, storage-dummy

This commit is contained in:
eight 2018-10-11 01:22:13 +08:00
parent ba64b95575
commit dc491e9be3
5 changed files with 15 additions and 613 deletions

View File

@ -1,4 +1,4 @@
/* global API_METHODS styleManager */ /* global API_METHODS styleManager tryRegExp debounce */
'use strict'; 'use strict';
(() => { (() => {

View File

@ -1,78 +0,0 @@
'use strict';
// eslint-disable-next-line no-unused-expressions
(chrome.runtime.id.includes('@temporary') || !('sync' in chrome.storage)) && (() => {
const listeners = new Set();
Object.assign(chrome.storage.onChanged, {
addListener: fn => listeners.add(fn),
hasListener: fn => listeners.has(fn),
removeListener: fn => listeners.delete(fn),
});
for (const name of ['local', 'sync']) {
const dummy = tryJSONparse(localStorage['dummyStorage.' + name]) || {};
chrome.storage[name] = {
get(data, cb) {
let result = {};
if (data === null) {
result = deepCopy(dummy);
} else if (Array.isArray(data)) {
for (const key of data) {
result[key] = dummy[key];
}
} else if (typeof data === 'object') {
const hasOwnProperty = Object.prototype.hasOwnProperty;
for (const key in data) {
if (hasOwnProperty.call(data, key)) {
const value = dummy[key];
result[key] = value === undefined ? data[key] : value;
}
}
} else {
result[data] = dummy[data];
}
if (typeof cb === 'function') cb(result);
},
set(data, cb) {
const hasOwnProperty = Object.prototype.hasOwnProperty;
const changes = {};
for (const key in data) {
if (!hasOwnProperty.call(data, key)) continue;
const newValue = data[key];
changes[key] = {newValue, oldValue: dummy[key]};
dummy[key] = newValue;
}
localStorage['dummyStorage.' + name] = JSON.stringify(dummy);
if (typeof cb === 'function') cb();
notify(changes);
},
remove(keyOrKeys, cb) {
const changes = {};
for (const key of Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]) {
changes[key] = {oldValue: dummy[key]};
delete dummy[key];
}
localStorage['dummyStorage.' + name] = JSON.stringify(dummy);
if (typeof cb === 'function') cb();
notify(changes);
},
};
}
window.API_METHODS = Object.assign(window.API_METHODS || {}, {
dummyStorageGet: ({data, name}) => new Promise(resolve => chrome.storage[name].get(data, resolve)),
dummyStorageSet: ({data, name}) => new Promise(resolve => chrome.storage[name].set(data, resolve)),
dummyStorageRemove: ({data, name}) => new Promise(resolve => chrome.storage[name].remove(data, resolve)),
});
function notify(changes, name) {
for (const fn of listeners.values()) {
fn(changes, name);
}
sendMessage({
dummyStorageChanges: changes,
dummyStorageName: name,
}, ignoreChromeError);
}
})();

View File

@ -1,427 +1,6 @@
/* global getStyleWithNoCode styleSectionsEqual */ /* exported calcStyleDigest detectSloppyRegexps */
'use strict'; 'use strict';
const RX_NAMESPACE = /\s*(@namespace\s+(?:\S+\s+)?url\(http:\/\/.*?\);)\s*/g;
const RX_CHARSET = /\s*@charset\s+(['"]).*?\1\s*;\s*/g;
const RX_CSS_COMMENTS = /\/\*[\s\S]*?(?:\*\/|$)/g;
// eslint-disable-next-line no-var
var SLOPPY_REGEXP_PREFIX = '\0';
// CSS transition bug workaround: since we insert styles asynchronously,
// the browsers, especially Firefox, may apply all transitions on page load
const CSS_TRANSITION_SUPPRESSOR = '* { transition: none !important; }';
const RX_CSS_TRANSITION_DETECTOR = /([\s\n;/{]|-webkit-|-moz-)transition[\s\n]*:[\s\n]*(?!none)/;
// Note, only 'var'-declared variables are visible from another extension page
// eslint-disable-next-line no-var
var cachedStyles = {
list: null, // array of all styles
byId: new Map(), // all styles indexed by id
filters: new Map(), // filterStyles() parameters mapped to the returned results, 10k max
regexps: new Map(), // compiled style regexps
exclusions: new Map(), // compiled exclusion regexps
urlDomains: new Map(), // getDomain() results for 100 last checked urls
needTransitionPatch: new Map(), // FF bug workaround
mutex: {
inProgress: true, // while getStyles() is reading IndexedDB all subsequent calls
// (initially 'true' to prevent rogue getStyles before dbExec.initialized)
onDone: [], // to getStyles() are queued and resolved when the first one finishes
},
};
function filterStyles({
enabled = null,
id = null,
matchUrl = null,
md5Url = null,
asHash = null,
omitCode,
strictRegexp = true, // used by the popup to detect bad regexps
} = {}) {
if (matchUrl && !URLS.supported(matchUrl)) {
return asHash ? {length: 0} : [];
}
// make sure to use the same order in updateFiltersCache()
const cacheKey =
enabled + '\t' +
id + '\t' +
matchUrl + '\t' +
md5Url + '\t' +
asHash + '\t' +
strictRegexp;
const cached = cachedStyles.filters.get(cacheKey);
let styles;
if (cached) {
cached.hits++;
cached.lastHit = Date.now();
styles = asHash
? Object.assign(blankHash, cached.styles)
: cached.styles.slice();
} else {
styles = filterStylesInternal({
enabled,
id,
matchUrl,
md5Url,
asHash,
strictRegexp,
blankHash,
cacheKey,
omitCode,
});
}
if (!omitCode) return styles;
if (!asHash) return styles.map(getStyleWithNoCode);
for (const id in styles) {
const sections = styles[id];
if (Array.isArray(sections)) {
styles[id] = getStyleWithNoCode({sections}).sections;
}
}
return styles;
}
function filterStylesInternal({
// js engines don't like big functions (V8 often deoptimized the original filterStyles)
// it also makes sense to extract the less frequently executed code
enabled,
id,
matchUrl,
md5Url,
asHash,
strictRegexp,
blankHash,
cacheKey,
omitCode,
}) {
if (matchUrl && !cachedStyles.urlDomains.has(matchUrl)) {
cachedStyles.urlDomains.set(matchUrl, getDomains(matchUrl));
for (let i = cachedStyles.urlDomains.size - 100; i > 0; i--) {
const firstKey = cachedStyles.urlDomains.keys().next().value;
cachedStyles.urlDomains.delete(firstKey);
}
}
const filtered = asHash ? {length: 0} : [];
const needSections = asHash || matchUrl !== null;
const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0];
let style;
for (let i = 0; (style = styles[i]); i++) {
if ((enabled === null || style.enabled === enabled)
&& (md5Url === null || style.md5Url === md5Url)
&& (id === null || style.id === id)) {
const sections = needSections &&
getApplicableSections({
style,
matchUrl,
strictRegexp,
stopOnFirst: !asHash,
skipUrlCheck: true,
matchUrlBase,
omitCode,
});
if (asHash) {
if (sections.length && sections[0] !== '') {
sections.unshift({included: !isPageExcluded(matchUrl, style.exclusions)});
filtered[style.id] = sections;
filtered.length++;
}
} else if (matchUrl === null || sections.length) {
filtered.push(style);
}
}
}
cachedStyles.filters.set(cacheKey, {
styles: filtered,
lastHit: Date.now(),
hits: 1,
});
if (cachedStyles.filters.size > 10000) {
cleanupCachedFilters();
}
// a shallow copy is needed because the cache doesn't store options like disableAll
return asHash
? Object.assign(blankHash, filtered)
: filtered;
}
function compileExclusionRegexps(exclusions) {
exclusions.forEach(exclusion => {
if (!cachedStyles.exclusions.get(exclusion)) {
cachedStyles.exclusions.set(exclusion, tryRegExp(exclusion) || false);
}
});
}
function isPageExcluded(matchUrl, exclusions = {}) {
const keys = Object.keys(exclusions);
if (!keys.length) {
return false;
}
compileExclusionRegexps(keys);
return keys.some(exclude => {
const rx = cachedStyles.exclusions.get(exclude);
return rx && rx.test(matchUrl);
});
}
function getApplicableSections({
style,
matchUrl,
strictRegexp = true,
// filterStylesInternal() sets the following to avoid recalc on each style:
stopOnFirst,
skipUrlCheck,
matchUrlBase = matchUrl.includes('#') && matchUrl.split('#', 1)[0],
omitCode,
// as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it in case of url("http://exact.url/without/hash")
}) {
const excluded = isPageExcluded(matchUrl, style.exclusions);
if (!skipUrlCheck && !URLS.supported(matchUrl) || omitCode !== false && excluded) {
return [];
}
// Show excluded style in popup
if (excluded) {
return [{}];
}
const sections = [];
for (const section of style.sections) {
const {urls, domains, urlPrefixes, regexps, code} = section;
const isGlobal = !urls.length && !urlPrefixes.length && !domains.length && !regexps.length;
const isMatching = !isGlobal && (
urls.length
&& (urls.includes(matchUrl) || matchUrlBase && urls.includes(matchUrlBase))
|| urlPrefixes.length
&& arraySomeIsPrefix(urlPrefixes, matchUrl)
|| domains.length
&& arraySomeIn(cachedStyles.urlDomains.get(matchUrl) || getDomains(matchUrl), domains)
|| regexps.length
&& arraySomeMatches(regexps, matchUrl, strictRegexp));
if (isGlobal && !styleCodeEmpty(code) || isMatching) {
sections.push(section);
if (stopOnFirst) {
break;
}
}
}
return sections;
function arraySomeIsPrefix(array, string) {
for (const prefix of array) {
if (string.startsWith(prefix)) {
return true;
}
}
return false;
}
function arraySomeIn(array, haystack) {
for (const el of array) {
if (haystack.indexOf(el) >= 0) {
return true;
}
}
return false;
}
function arraySomeMatches(array, matchUrl, strictRegexp) {
for (const regexp of array) {
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)) {
return true;
}
}
}
return false;
}
}
function styleCodeEmpty(code) {
// Collect the global section if it's not empty, not comment-only, not namespace-only.
const cmtOpen = code && code.indexOf('/*');
if (cmtOpen >= 0) {
const cmtCloseLast = code.lastIndexOf('*/');
if (cmtCloseLast < 0) {
code = code.substr(0, cmtOpen);
} else {
code = code.substr(0, cmtOpen) +
code.substring(cmtOpen, cmtCloseLast + 2).replace(RX_CSS_COMMENTS, '') +
code.substr(cmtCloseLast + 2);
}
}
if (!code || !code.trim()) return true;
if (code.includes('@namespace')) code = code.replace(RX_NAMESPACE, '').trim();
if (code.includes('@charset')) code = code.replace(RX_CHARSET, '').trim();
return !code;
}
function invalidateCache({added, updated, deletedId} = {}) {
if (!cachedStyles.list) return;
const id = added ? added.id : updated ? updated.id : deletedId;
const cached = cachedStyles.byId.get(id);
if (updated) {
if (cached) {
const isSectionGlobal = section =>
!section.urls.length &&
!section.urlPrefixes.length &&
!section.domains.length &&
!section.regexps.length;
const hadOrHasGlobals = cached.sections.some(isSectionGlobal) ||
updated.sections.some(isSectionGlobal);
const reenabled = !cached.enabled && updated.enabled;
const equal = !hadOrHasGlobals &&
!reenabled &&
styleSectionsEqual(updated, cached, {ignoreCode: true});
Object.assign(cached, updated);
if (equal) {
updateFiltersCache(cached);
} else {
cachedStyles.filters.clear();
}
cachedStyles.needTransitionPatch.delete(id);
return;
} else {
added = updated;
}
}
if (added) {
if (!cached) {
cachedStyles.list.push(added);
cachedStyles.byId.set(added.id, added);
cachedStyles.filters.clear();
cachedStyles.needTransitionPatch.delete(id);
}
return;
}
if (deletedId !== undefined) {
if (cached) {
const cachedIndex = cachedStyles.list.indexOf(cached);
cachedStyles.list.splice(cachedIndex, 1);
cachedStyles.byId.delete(deletedId);
for (const {styles} of cachedStyles.filters.values()) {
if (Array.isArray(styles)) {
const index = styles.findIndex(({id}) => id === deletedId);
if (index >= 0) styles.splice(index, 1);
} else if (deletedId in styles) {
delete styles[deletedId];
styles.length--;
}
}
cachedStyles.needTransitionPatch.delete(id);
return;
}
}
cachedStyles.list = null;
cachedStyles.filters.clear();
cachedStyles.needTransitionPatch.clear(id);
}
function updateFiltersCache(style) {
const {id} = style;
for (const [key, {styles}] of cachedStyles.filters.entries()) {
if (Array.isArray(styles)) {
const index = styles.findIndex(style => style.id === id);
if (index >= 0) styles[index] = Object.assign({}, style);
continue;
}
if (id in styles) {
const [, , matchUrl, , , strictRegexp] = key.split('\t');
if (!style.enabled) {
delete styles[id];
styles.length--;
continue;
}
const matchUrlBase = matchUrl && matchUrl.includes('#') && matchUrl.split('#', 1)[0];
const sections = getApplicableSections({
style,
matchUrl,
matchUrlBase,
strictRegexp,
skipUrlCheck: true,
omitCode: false
});
if (sections.length) {
styles[id] = sections;
} else {
delete styles[id];
styles.length--;
}
}
}
}
function cleanupCachedFilters({force = false} = {}) {
if (!force) {
debounce(cleanupCachedFilters, 1000, {force: true});
return;
}
const size = cachedStyles.filters.size;
const oldestHit = cachedStyles.filters.values().next().value.lastHit;
const now = Date.now();
const timeSpan = now - oldestHit;
const recencyWeight = 5 / size;
const hitWeight = 1 / 4; // we make ~4 hits per URL
const lastHitWeight = 10;
// delete the oldest 10%
[...cachedStyles.filters.entries()]
.map(([id, v], index) => ({
id,
weight:
index * recencyWeight +
v.hits * hitWeight +
(v.lastHit - oldestHit) / timeSpan * lastHitWeight,
}))
.sort((a, b) => a.weight - b.weight)
.slice(0, size / 10 + 1)
.forEach(({id}) => cachedStyles.filters.delete(id));
}
function getDomains(url) {
let d = /.*?:\/*([^/:]+)|$/.exec(url)[1];
if (!d || url.startsWith('file:')) {
return [];
}
const domains = [d];
while (d.indexOf('.') !== -1) {
d = d.substring(d.indexOf('.') + 1);
domains.push(d);
}
return domains;
}
function normalizeStyleSections({sections}) { function normalizeStyleSections({sections}) {
// retain known properties in an arbitrarily predefined order // retain known properties in an arbitrarily predefined order
return (sections || []).map(section => ({ return (sections || []).map(section => ({
@ -433,7 +12,6 @@ function normalizeStyleSections({sections}) {
})); }));
} }
function calcStyleDigest(style) { function calcStyleDigest(style) {
const jsonString = style.usercssData ? const jsonString = style.usercssData ?
style.sourceCode : JSON.stringify(normalizeStyleSections(style)); style.sourceCode : JSON.stringify(normalizeStyleSections(style));
@ -451,81 +29,6 @@ function calcStyleDigest(style) {
} }
} }
function handleCssTransitionBug({tabId, frameId, url, styles}) {
for (let id in styles) {
id |= 0;
if (!id) {
continue;
}
let need = cachedStyles.needTransitionPatch.get(id);
if (need === false) {
continue;
}
if (need !== true) {
need = styles[id].some(sectionContainsTransitions);
cachedStyles.needTransitionPatch.set(id, need);
if (!need) {
continue;
}
}
if (FIREFOX && !url.startsWith(URLS.ownOrigin)) {
patchFirefox();
} else {
styles.needTransitionPatch = true;
}
break;
}
function patchFirefox() {
const options = {
frameId,
code: CSS_TRANSITION_SUPPRESSOR,
matchAboutBlank: true,
};
if (FIREFOX >= 53) {
options.cssOrigin = 'user';
}
browser.tabs.insertCSS(tabId, Object.assign(options, {
runAt: 'document_start',
})).then(() => setTimeout(() => {
browser.tabs.removeCSS(tabId, options).catch(ignoreChromeError);
})).catch(ignoreChromeError);
}
function sectionContainsTransitions(section) {
let code = section.code;
const firstTransition = code.indexOf('transition');
if (firstTransition < 0) {
return false;
}
const firstCmt = code.indexOf('/*');
// check the part before the first comment
if (firstCmt < 0 || firstTransition < firstCmt) {
if (quickCheckAround(code, firstTransition)) {
return true;
} else if (firstCmt < 0) {
return false;
}
}
// check the rest
const lastCmt = code.lastIndexOf('*/');
if (lastCmt < firstCmt) {
// the comment is unclosed and we already checked the preceding part
return false;
}
let mid = code.slice(firstCmt, lastCmt + 2);
mid = mid.indexOf('*/') === mid.length - 2 ? '' : mid.replace(RX_CSS_COMMENTS, '');
code = mid + code.slice(lastCmt + 2);
return quickCheckAround(code) || RX_CSS_TRANSITION_DETECTOR.test(code);
}
function quickCheckAround(code, pos = code.indexOf('transition')) {
return RX_CSS_TRANSITION_DETECTOR.test(code.substr(Math.max(0, pos - 10), 50));
}
}
/* /*
According to CSS4 @document specification the entire URL must match. According to CSS4 @document specification the entire URL must match.
Stylish-for-Chrome implemented it incorrectly since the very beginning. Stylish-for-Chrome implemented it incorrectly since the very beginning.
@ -534,39 +37,5 @@ function handleCssTransitionBug({tabId, frameId, url, styles}) {
Additionally we'll check for invalid regexps. Additionally we'll check for invalid regexps.
*/ */
function detectSloppyRegexps({matchUrl, ids}) { function detectSloppyRegexps({matchUrl, ids}) {
const results = []; // TODO
for (const id of ids) {
const style = cachedStyles.byId.get(id);
if (!style) continue;
// make sure all regexps are compiled
const rxCache = cachedStyles.regexps;
let hasRegExp = false;
for (const section of style.sections) {
for (const regexp of section.regexps) {
hasRegExp = true;
for (let pass = 1; pass <= 2; pass++) {
const cacheKey = pass === 1 ? regexp : SLOPPY_REGEXP_PREFIX + regexp;
if (!rxCache.has(cacheKey)) {
// according to CSS4 @document specification the entire URL must match
const anchored = pass === 1 ? '^(?:' + regexp + ')$' : '^' + regexp + '$';
// create in the bg context to avoid leaking of "dead objects"
const rx = tryRegExp(anchored);
rxCache.set(cacheKey, rx || false);
}
}
}
}
if (!hasRegExp) continue;
const applied = getApplicableSections({style, matchUrl, omitCode: false});
const wannabe = getApplicableSections({style, matchUrl, omitCode: false, strictRegexp: false});
if (wannabe.length && wannabe[0] !== '') {
results.push({
id,
applied,
skipped: wannabe.length - applied.length,
hasInvalidRegexps: wannabe.some(({regexps}) => regexps.some(rx => !rxCache.has(rx))),
});
}
}
return results;
} }

View File

@ -344,6 +344,7 @@ const styleManager = (() => {
} }
} }
// TODO: report excluded styles and sloppy regexps?
function getAppliedCode(url, data) { function getAppliedCode(url, data) {
if (!urlMatchStyle(url, data)) { if (!urlMatchStyle(url, data)) {
return; return;
@ -388,6 +389,7 @@ const styleManager = (() => {
} }
function urlMatchStyle(url, style) { function urlMatchStyle(url, style) {
// TODO: show excluded style in popup?
if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url))) { if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url))) {
return false; return false;
} }
@ -402,7 +404,14 @@ const styleManager = (() => {
if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) { if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) {
return true; return true;
} }
if (section.urls && section.urls.includes(getUrlNoHash(url))) { // as per spec the fragment portion is ignored in @-moz-document:
// https://www.w3.org/TR/2012/WD-css3-conditional-20120911/#url-of-doc
// but the spec is outdated and doesn't account for SPA sites
// so we only respect it for `url()` function
if (section.urls && (
section.urls.includes(url) ||
section.urls.includes(getUrlNoHash(url))
)) {
return true; return true;
} }
if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) { if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) {

View File

@ -34,6 +34,8 @@
API.getSectionsByUrl(getMatchUrl(), {enabled: true}) API.getSectionsByUrl(getMatchUrl(), {enabled: true})
.then(result => { .then(result => {
const styles = Object.values(result); const styles = Object.values(result);
// CSS transition bug workaround: since we insert styles asynchronously,
// the browsers, especially Firefox, may apply all transitions on page load
if (styles.some(s => s.code.includes('transition'))) { if (styles.some(s => s.code.includes('transition'))) {
applyTransitionPatch(); applyTransitionPatch();
} }