f8d13d8dec
* 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.
929 lines
27 KiB
JavaScript
929 lines
27 KiB
JavaScript
/* global cachedStyles: true, prefs: true, contextMenus: false */
|
|
/* global handleUpdate, handleDelete */
|
|
'use strict';
|
|
|
|
function getDatabase(ready, error) {
|
|
const dbOpenRequest = window.indexedDB.open('stylish', 2);
|
|
dbOpenRequest.onsuccess = event => {
|
|
ready(event.target.result);
|
|
};
|
|
dbOpenRequest.onerror = event => {
|
|
console.warn(event.target.errorCode);
|
|
if (error) {
|
|
error(event);
|
|
}
|
|
};
|
|
dbOpenRequest.onupgradeneeded = event => {
|
|
if (event.oldVersion == 0) {
|
|
event.target.result.createObjectStore('styles', {
|
|
keyPath: 'id',
|
|
autoIncrement: true,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
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
|
|
// eslint-disable-next-line no-var
|
|
var cachedStyles, prefs;
|
|
(() => {
|
|
const bg = chrome.extension.getBackgroundPage();
|
|
cachedStyles = bg && bg.cachedStyles || {
|
|
bg,
|
|
list: 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: [],
|
|
},
|
|
};
|
|
prefs = bg && bg.prefs;
|
|
})();
|
|
|
|
|
|
// 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.list) {
|
|
callback(filterStyles(options));
|
|
return;
|
|
}
|
|
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');
|
|
os.getAll().onsuccess = event => {
|
|
cachedStyles.list = event.target.result || [];
|
|
cachedStyles.byId.clear();
|
|
for (const style of cachedStyles.list) {
|
|
cachedStyles.byId.set(style.id, style);
|
|
compileStyleRegExps({style});
|
|
}
|
|
//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;
|
|
for (const {options, callback} of cachedStyles.mutex.onDone) {
|
|
callback(filterStyles(options));
|
|
}
|
|
cachedStyles.mutex.onDone = [];
|
|
};
|
|
}, null);
|
|
}
|
|
|
|
|
|
function getStyleWithNoCode(style) {
|
|
const stripped = Object.assign({}, style, {sections: []});
|
|
for (const 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', added, updated, deletedId});
|
|
}
|
|
if (!cachedStyles.list) {
|
|
return;
|
|
}
|
|
if (updated) {
|
|
const cached = cachedStyles.byId.get(updated.id);
|
|
if (cached) {
|
|
Object.assign(cached, updated);
|
|
//console.debug('cache: updated', updated);
|
|
}
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
if (added) {
|
|
cachedStyles.list.push(added);
|
|
cachedStyles.byId.set(added.id, added);
|
|
//console.debug('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.byId.delete(deletedId);
|
|
//console.debug('cache: deleted', deletedStyle);
|
|
cachedStyles.filters.clear();
|
|
return;
|
|
}
|
|
}
|
|
cachedStyles.list = null;
|
|
//console.debug('cache cleared');
|
|
cachedStyles.filters.clear();
|
|
}
|
|
|
|
|
|
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();
|
|
enabled = fixBoolean(enabled);
|
|
id = id === null ? null : Number(id);
|
|
|
|
if (enabled === null
|
|
&& url === null
|
|
&& id === null
|
|
&& matchUrl === null
|
|
&& asHash != true) {
|
|
//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' + asHash + strictRegexp;
|
|
const cached = cachedStyles.filters.get(cacheKey);
|
|
if (cached) {
|
|
//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();
|
|
|
|
return asHash
|
|
? Object.assign({disableAll}, cached.styles)
|
|
: cached.styles;
|
|
}
|
|
|
|
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 styles = id === null
|
|
? 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 = needSections &&
|
|
getApplicableSections({style, matchUrl, strictRegexp, stopOnFirst: !asHash});
|
|
if (asHash) {
|
|
if (sections.length) {
|
|
filtered[style.id] = sections;
|
|
}
|
|
} else if (matchUrl === null || sections.length) {
|
|
filtered.push(style);
|
|
}
|
|
}
|
|
}
|
|
//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(),
|
|
hits: 1,
|
|
});
|
|
if (cachedStyles.filters.size > 10000) {
|
|
cleanupCachedFilters();
|
|
}
|
|
return asHash
|
|
? Object.assign({disableAll}, filtered)
|
|
: filtered;
|
|
}
|
|
|
|
|
|
function cleanupCachedFilters({force = false} = {}) {
|
|
if (!force) {
|
|
// sliding timer for 1 second
|
|
clearTimeout(cleanupCachedFilters.timeout);
|
|
cleanupCachedFilters.timeout = setTimeout(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));
|
|
cleanupCachedFilters.timeout = 0;
|
|
}
|
|
|
|
|
|
function saveStyle(style) {
|
|
return new Promise(resolve => {
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readwrite');
|
|
const os = tx.objectStore('styles');
|
|
|
|
const id = style.id !== undefined && style.id !== null ? Number(style.id) : null;
|
|
const reason = style.reason;
|
|
const notify = style.notify !== false;
|
|
delete style.method;
|
|
delete style.reason;
|
|
delete style.notify;
|
|
if (!style.name) {
|
|
delete style.name;
|
|
}
|
|
|
|
// Update
|
|
if (id !== null) {
|
|
style.id = id;
|
|
os.get(id).onsuccess = eventGet => {
|
|
const existed = Boolean(eventGet.target.result);
|
|
const oldStyle = Object.assign({}, eventGet.target.result);
|
|
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, existed ? {updated: style} : {added: style});
|
|
compileStyleRegExps({style});
|
|
if (notify) {
|
|
notifyAllTabs({
|
|
method: existed ? 'styleUpdated' : 'styleAdded',
|
|
style, codeIsUpdated, reason,
|
|
});
|
|
}
|
|
if (typeof handleUpdate != 'undefined') {
|
|
handleUpdate(style, {reason});
|
|
}
|
|
resolve(style);
|
|
};
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Create
|
|
delete style.id;
|
|
style = Object.assign({
|
|
// Set optional things if they're undefined
|
|
enabled: true,
|
|
updateUrl: null,
|
|
md5Url: null,
|
|
url: null,
|
|
originalMd5: null,
|
|
}, style);
|
|
addMissingStyleTargets(style);
|
|
os.add(style).onsuccess = event => {
|
|
// Give it the ID that was generated
|
|
style.id = event.target.result;
|
|
invalidateCache(notify, {added: style});
|
|
compileStyleRegExps({style});
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleAdded', style, reason});
|
|
}
|
|
if (typeof handleUpdate != 'undefined') {
|
|
handleUpdate(style, {reason});
|
|
}
|
|
resolve(style);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
function addMissingStyleTargets(style) {
|
|
style.sections = (style.sections || []).map(section =>
|
|
Object.assign({
|
|
urls: [],
|
|
urlPrefixes: [],
|
|
domains: [],
|
|
regexps: [],
|
|
}, section)
|
|
);
|
|
}
|
|
|
|
|
|
function enableStyle(id, enabled) {
|
|
return saveStyle({id, enabled});
|
|
}
|
|
|
|
|
|
function deleteStyle(id, {notify = true} = {}) {
|
|
return new Promise(resolve =>
|
|
getDatabase(db => {
|
|
const tx = db.transaction(['styles'], 'readwrite');
|
|
const os = tx.objectStore('styles');
|
|
os.delete(Number(id)).onsuccess = () => {
|
|
invalidateCache(notify, {deletedId: id});
|
|
if (notify) {
|
|
notifyAllTabs({method: 'styleDeleted', id});
|
|
}
|
|
if (typeof handleDelete != 'undefined') {
|
|
handleDelete(id);
|
|
}
|
|
resolve(id);
|
|
};
|
|
}));
|
|
}
|
|
|
|
|
|
function reportError(...args) {
|
|
for (const arg of args) {
|
|
if ('message' in arg) {
|
|
console.log(arg.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function fixBoolean(b) {
|
|
if (typeof b != 'undefined') {
|
|
return b != 'false';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
function getDomains(url) {
|
|
if (url.indexOf('file:') == 0) {
|
|
return [];
|
|
}
|
|
let d = /.*?:\/*([^/:]+)/.exec(url)[1];
|
|
const domains = [d];
|
|
while (d.indexOf('.') != -1) {
|
|
d = d.substring(d.indexOf('.') + 1);
|
|
domains.push(d);
|
|
}
|
|
return domains;
|
|
}
|
|
|
|
|
|
function getType(o) {
|
|
if (typeof o == 'undefined' || typeof o == 'string') {
|
|
return typeof o;
|
|
}
|
|
// with the persistent cachedStyles the Array reference is usually different
|
|
// so let's check for e.g. type of 'every' which is only present on arrays
|
|
// (in the context of our extension)
|
|
if (o instanceof Array || typeof o.every == 'function') {
|
|
return 'array';
|
|
}
|
|
console.warn('Unsupported type:', o);
|
|
return 'undefined';
|
|
}
|
|
|
|
|
|
function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirst}) {
|
|
//let t0 = 0;
|
|
const sections = [];
|
|
checkingSections:
|
|
for (const section of style.sections) {
|
|
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;
|
|
}
|
|
if (section.urls.length == 0
|
|
&& section.domains.length == 0
|
|
&& section.urlPrefixes.length == 0
|
|
&& section.regexps.length == 0) {
|
|
break andCollect;
|
|
}
|
|
if (section.urls.indexOf(matchUrl) != -1) {
|
|
break andCollect;
|
|
}
|
|
for (const urlPrefix of section.urlPrefixes) {
|
|
if (matchUrl.startsWith(urlPrefix)) {
|
|
break andCollect;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
//t0 >= 0.1 && console.debug('%s emptyCode', t0.toFixed(1)); // eslint-disable-line no-unused-expressions
|
|
return sections;
|
|
}
|
|
|
|
|
|
function isCheckbox(el) {
|
|
return el.localName == 'input' && el.type == 'checkbox';
|
|
}
|
|
|
|
|
|
// js engine can't optimize the entire function if it contains try-catch
|
|
// so we should keep it isolated from normal code in a minimal wrapper
|
|
// Update: might get fixed in V8 TurboFan in the future
|
|
function runTryCatch(func, ...args) {
|
|
try {
|
|
return func(...args);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function tryRegExp(regexp) {
|
|
try {
|
|
return new RegExp(regexp);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
prefs = prefs || new function Prefs() {
|
|
const me = this;
|
|
|
|
const defaults = {
|
|
'openEditInWindow': false, // new editor opens in a own browser window
|
|
'windowPosition': {}, // detached window position
|
|
'show-badge': true, // display text on popup menu icon
|
|
'disableAll': false, // boss key
|
|
|
|
'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs
|
|
'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
|
|
'popup.enabledFirst': true, // display enabled styles before disabled styles
|
|
'popup.stylesFirst': true, // display enabled styles before disabled styles
|
|
|
|
'manage.onlyEnabled': false, // display only enabled styles
|
|
'manage.onlyEdited': false, // display only styles created locally
|
|
|
|
'editor.options': {}, // CodeMirror.defaults.*
|
|
'editor.lineWrapping': true, // word wrap
|
|
'editor.smartIndent': true, // 'smart' indent
|
|
'editor.indentWithTabs': false, // smart indent with tabs
|
|
'editor.tabSize': 4, // tab width, in spaces
|
|
'editor.keyMap': navigator.appVersion.indexOf('Windows') > 0 ? 'sublime' : 'default',
|
|
'editor.theme': 'default', // CSS theme
|
|
'editor.beautify': { // CSS beautifier
|
|
selector_separator_newline: true,
|
|
newline_before_open_brace: false,
|
|
newline_after_open_brace: true,
|
|
newline_between_properties: true,
|
|
newline_before_close_brace: true,
|
|
newline_between_rules: false,
|
|
end_with_newline: false,
|
|
space_around_selector_separator: true,
|
|
},
|
|
'editor.lintDelay': 500, // lint gutter marker update delay, ms
|
|
'editor.lintReportDelay': 4500, // lint report update delay, ms
|
|
'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected
|
|
// selection = only when something is selected
|
|
// '' (empty string) = disabled
|
|
|
|
'badgeDisabled': '#8B0000', // badge background color when disabled
|
|
'badgeNormal': '#006666', // badge background color
|
|
|
|
'popupWidth': 240, // popup width in pixels
|
|
|
|
'updateInterval': 0 // user-style automatic update interval, hour
|
|
};
|
|
const values = deepCopy(defaults);
|
|
|
|
let syncTimeout; // see broadcast() function below
|
|
|
|
Object.defineProperty(this, 'readOnlyValues', {value: {}});
|
|
|
|
Prefs.prototype.get = function(key, defaultValue) {
|
|
if (key in values) {
|
|
return values[key];
|
|
}
|
|
if (defaultValue !== undefined) {
|
|
return defaultValue;
|
|
}
|
|
if (key in defaults) {
|
|
return defaults[key];
|
|
}
|
|
console.warn("No default preference for '%s'", key);
|
|
};
|
|
|
|
Prefs.prototype.getAll = function() {
|
|
return deepCopy(values);
|
|
};
|
|
|
|
Prefs.prototype.set = function(key, value, options) {
|
|
const oldValue = deepCopy(values[key]);
|
|
values[key] = value;
|
|
defineReadonlyProperty(this.readOnlyValues, key, value);
|
|
if ((!options || !options.noBroadcast) && !equal(value, oldValue)) {
|
|
me.broadcast(key, value, options);
|
|
}
|
|
};
|
|
|
|
Prefs.prototype.remove = key => me.set(key, undefined);
|
|
|
|
Prefs.prototype.broadcast = function(key, value, options) {
|
|
const message = {method: 'prefChanged', prefName: key, value: value};
|
|
notifyAllTabs(message);
|
|
chrome.runtime.sendMessage(message);
|
|
if (key == 'disableAll') {
|
|
notifyAllTabs({method: 'styleDisableAll', disableAll: value});
|
|
}
|
|
if (!options || !options.noSync) {
|
|
clearTimeout(syncTimeout);
|
|
syncTimeout = setTimeout(function() {
|
|
getSync().set({'settings': values});
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
Object.keys(defaults).forEach(function(key) {
|
|
me.set(key, defaults[key], {noBroadcast: true});
|
|
});
|
|
|
|
getSync().get('settings', function(result) {
|
|
const synced = result.settings;
|
|
for (const key in defaults) {
|
|
if (synced && (key in synced)) {
|
|
me.set(key, synced[key], {noSync: true});
|
|
} else {
|
|
const value = tryMigrating(key);
|
|
if (value !== undefined) {
|
|
me.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
if (typeof contextMenus !== 'undefined') {
|
|
for (const id in contextMenus) {
|
|
if (typeof values[id] == 'boolean') {
|
|
me.broadcast(id, values[id], {noSync: true});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener(function(changes, area) {
|
|
if (area == 'sync' && 'settings' in changes) {
|
|
const synced = changes.settings.newValue;
|
|
if (synced) {
|
|
for (const key in defaults) {
|
|
if (key in synced) {
|
|
me.set(key, synced[key], {noSync: true});
|
|
}
|
|
}
|
|
} else {
|
|
// user manually deleted our settings, we'll recreate them
|
|
getSync().set({'settings': values});
|
|
}
|
|
}
|
|
});
|
|
|
|
function tryMigrating(key) {
|
|
if (!(key in localStorage)) {
|
|
return undefined;
|
|
}
|
|
const value = localStorage[key];
|
|
delete localStorage[key];
|
|
localStorage['DEPRECATED: ' + key] = value;
|
|
switch (typeof defaults[key]) {
|
|
case 'boolean':
|
|
return value.toLowerCase() === 'true';
|
|
case 'number':
|
|
return Number(value);
|
|
case 'object':
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch (e) {
|
|
console.log("Cannot migrate from localStorage %s = '%s': %o", key, value, e);
|
|
return undefined;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
}();
|
|
|
|
|
|
// 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) {
|
|
const localIDs = {};
|
|
IDs.forEach(function(id) {
|
|
localIDs[id] = true;
|
|
updateElement(id).addEventListener('change', function() {
|
|
prefs.set(this.id, isCheckbox(this) ? this.checked : this.value);
|
|
});
|
|
});
|
|
chrome.runtime.onMessage.addListener(function(request) {
|
|
if (request.prefName in localIDs) {
|
|
updateElement(request.prefName);
|
|
}
|
|
});
|
|
function updateElement(id) {
|
|
const el = document.getElementById(id);
|
|
el[isCheckbox(el) ? 'checked' : 'value'] = prefs.get(id);
|
|
el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
|
|
return el;
|
|
}
|
|
}
|
|
|
|
|
|
function getCodeMirrorThemes(callback) {
|
|
chrome.runtime.getPackageDirectoryEntry(function(rootDir) {
|
|
rootDir.getDirectory('codemirror/theme', {create: false}, function(themeDir) {
|
|
themeDir.createReader().readEntries(function(entries) {
|
|
const themes = [chrome.i18n.getMessage('defaultTheme')];
|
|
entries
|
|
.filter(entry => entry.isFile)
|
|
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
|
.forEach(function(entry) {
|
|
themes.push(entry.name.replace(/\.css$/, ''));
|
|
});
|
|
if (callback) {
|
|
callback(themes);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
function sessionStorageHash(name) {
|
|
return {
|
|
name,
|
|
value: runTryCatch(JSON.parse, sessionStorage[name]) || {},
|
|
set(k, v) {
|
|
this.value[k] = v;
|
|
this.updateStorage();
|
|
},
|
|
unset(k) {
|
|
delete this.value[k];
|
|
this.updateStorage();
|
|
},
|
|
updateStorage() {
|
|
sessionStorage[this.name] = JSON.stringify(this.value);
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function deepCopy(obj) {
|
|
if (!obj || typeof obj != 'object') {
|
|
return obj;
|
|
} else {
|
|
const emptyCopy = Object.create(Object.getPrototypeOf(obj));
|
|
return deepMerge(emptyCopy, obj);
|
|
}
|
|
}
|
|
|
|
|
|
function deepMerge(target, ...args) {
|
|
for (const obj of args) {
|
|
for (const k in obj) {
|
|
const value = obj[k];
|
|
if (!value || typeof value != 'object') {
|
|
target[k] = value;
|
|
} else if (k in target) {
|
|
deepMerge(target[k], value);
|
|
} else if (typeof value.slice == 'function') {
|
|
target[k] = value.slice();
|
|
} else {
|
|
target[k] = deepCopy(value);
|
|
}
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
|
|
function equal(a, b) {
|
|
if (!a || !b || typeof a != 'object' || typeof b != 'object') {
|
|
return a === b;
|
|
}
|
|
if (Object.keys(a).length != Object.keys(b).length) {
|
|
return false;
|
|
}
|
|
for (const k in a) {
|
|
if (a[k] !== b[k]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function defineReadonlyProperty(obj, key, value) {
|
|
const copy = deepCopy(value);
|
|
if (typeof copy == 'object') {
|
|
Object.freeze(copy);
|
|
}
|
|
Object.defineProperty(obj, key, {value: copy, configurable: true});
|
|
}
|
|
|
|
// Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494
|
|
function getSync() {
|
|
if ('sync' in chrome.storage) {
|
|
return chrome.storage.sync;
|
|
}
|
|
const crappyStorage = {};
|
|
return {
|
|
get(key, callback) {
|
|
callback(crappyStorage[key] || {});
|
|
},
|
|
set(source, callback) {
|
|
for (const property in source) {
|
|
if (source.hasOwnProperty(property)) {
|
|
crappyStorage[property] = source[property];
|
|
}
|
|
}
|
|
callback();
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
function styleSectionsEqual(styleA, styleB) {
|
|
if (!styleA.sections || !styleB.sections) {
|
|
return undefined;
|
|
}
|
|
if (styleA.sections.length != styleB.sections.length) {
|
|
return false;
|
|
}
|
|
const propNames = ['code', 'urlPrefixes', 'urls', 'domains', 'regexps'];
|
|
const typeBcaches = [];
|
|
checkingEveryInA:
|
|
for (const sectionA of styleA.sections) {
|
|
const typeAcache = new Map();
|
|
for (const name of propNames) {
|
|
typeAcache.set(name, getType(sectionA[name]));
|
|
}
|
|
lookingForDupeInB:
|
|
for (let i = 0, sectionB; (sectionB = styleB.sections[i]); i++) {
|
|
const typeBcache = typeBcaches[i] = typeBcaches[i] || new Map();
|
|
comparingProps:
|
|
for (const name of propNames) {
|
|
const propA = sectionA[name];
|
|
const typeA = typeAcache.get(name);
|
|
const propB = sectionB[name];
|
|
let typeB = typeBcache.get(name);
|
|
if (!typeB) {
|
|
typeB = getType(propB);
|
|
typeBcache.set(name, typeB);
|
|
}
|
|
if (typeA != typeB) {
|
|
const bothEmptyOrUndefined =
|
|
(typeA == 'undefined' || (typeA == 'array' && propA.length == 0)) &&
|
|
(typeB == 'undefined' || (typeB == 'array' && propB.length == 0));
|
|
if (bothEmptyOrUndefined) {
|
|
continue comparingProps;
|
|
} else {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
if (typeA == 'undefined') {
|
|
continue comparingProps;
|
|
}
|
|
if (typeA == 'array') {
|
|
if (propA.length != propB.length) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
for (const item of propA) {
|
|
if (propB.indexOf(item) < 0) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
continue comparingProps;
|
|
}
|
|
if (typeA == 'string' && propA != propB) {
|
|
continue lookingForDupeInB;
|
|
}
|
|
}
|
|
// dupe found
|
|
continue checkingEveryInA;
|
|
}
|
|
// dupe not found
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function compileStyleRegExps({style, compileAll}) {
|
|
const t0 = performance.now();
|
|
for (const section of style.sections || []) {
|
|
for (const regexp of section.regexps) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|