Isolate storage.js in background context

To prevent cross-page leaks we need to create/copy prefs and cachedStyles inside the background page context.

* storage.js is now used only in the background page

* messaging.js now contains less bg-specific methods and more common methods. Added saveStyleSafe, deleteStyleSafe which automatically invoke onRuntimeMessage of the current page or just handleUpdate/handleDelete when notify:false

* prefs.js with 'prefs' for background and UI pages: separate objects because a UI page may load before the background page and it can read prefs from localStorage/sync/defaults
This commit is contained in:
tophf 2017-04-11 13:51:40 +03:00
parent 7f6d3e241a
commit 5c8d1950a7
17 changed files with 885 additions and 825 deletions

View File

@ -15,17 +15,25 @@ globals:
FIREFOX: false
OPERA: false
URLS: false
BG: false
notifyAllTabs: false
refreshAllTabs: false
updateIcon: false
getActiveTab: false
getActiveTabRealURL: false
getTabRealURL: false
openURL: false
activateTab: false
stringAsRegExp: false
wildcardAsRegExp: false
ignoreChromeError: false
tryCatch: false
tryRegExp: false
tryJSONparse: false
debounce: false
deepCopy: false
onBackgroundReady: false
deleteStyleSafe: false
getStylesSafe: false
saveStyleSafe: false
sessionStorageHash: false
# localization.js
template: false
t: false
@ -37,31 +45,13 @@ globals:
# dom.js
onDOMready: false
scrollElementIntoView: false
enforceInputRange: false
animateElement: false
$: false
$$: false
# storage.js
# prefs.js
prefs: false
cachedStyles: false
sessionStorageHash: false
getStylesSafe: false
invalidateCache: false
saveStyle: false
enableStyle: false
deleteStyle: false
fixBoolean: false
getDomains: false
getType: false
getApplicableSections: false
isCheckbox: false
runTryCatch: false
tryRegExp: false
tryJSONparse: false
debounce: false
setupLivePrefs: false
enforceInputRange: false
getCodeMirrorThemes: false
styleSectionsEqual: false
rules:
accessor-pairs: [2]

View File

@ -1,4 +1,4 @@
/* global getDatabase, getStyles, reportError */
/* global getDatabase, getStyles, saveStyle, reportError, invalidateCache */
'use strict';
chrome.webNavigation.onBeforeNavigate.addListener(data => {
@ -39,9 +39,9 @@ function webNavigationListener(method, data) {
// messaging
chrome.runtime.onMessage.addListener(onBackgroundMessage);
chrome.runtime.onMessage.addListener(onRuntimeMessage);
function onBackgroundMessage(request, sender, sendResponse) {
function onRuntimeMessage(request, sender, sendResponse) {
switch (request.method) {
case 'getStyles':
@ -61,9 +61,7 @@ function onBackgroundMessage(request, sender, sendResponse) {
return KEEP_CHANNEL_OPEN;
case 'invalidateCache':
if (typeof invalidateCache != 'undefined') {
invalidateCache(false, request);
}
invalidateCache(false, request);
break;
case 'healthCheck':
@ -101,8 +99,8 @@ if ('commands' in chrome) {
}
// context menus
const contextMenus = {
// eslint-disable-next-line no-var
var contextMenus = {
'show-badge': {
title: 'menuShowBadge',
click: info => prefs.set(info.menuItemId, info.checked),
@ -123,7 +121,7 @@ const contextMenus = {
// Vivaldi: Vivaldi/#
if (/Vivaldi\/[\d.]+$/.test(navigator.userAgent)
|| /Safari\/[\d.]+$/.test(navigator.userAgent)
&& ![...navigator.plugins].some(p => p.name == 'Shockwave Flash')) {
&& !Array.from(navigator.plugins).some(p => p.name == 'Shockwave Flash')) {
contextMenus.editDeleteText = {
title: 'editDeleteText',
contexts: ['editable'],
@ -172,8 +170,11 @@ chrome.tabs.onAttached.addListener((tabId, data) => {
});
});
var codeMirrorThemes; // eslint-disable-line no-var
getCodeMirrorThemes(themes => (codeMirrorThemes = themes));
// eslint-disable-next-line no-var
var codeMirrorThemes;
getCodeMirrorThemes().then(themes => {
codeMirrorThemes = themes;
});
// do not use prefs.get('version', null) as it might not yet be available
chrome.storage.local.get('version', prefs => {
@ -198,6 +199,9 @@ chrome.storage.local.get('version', prefs => {
injectContentScripts();
function injectContentScripts() {
// expand * as .*?
const wildcardAsRegExp = (s, flags) =>
new RegExp(s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&').replace(/\*/g, '.*?'), flags);
const contentScripts = chrome.runtime.getManifest().content_scripts;
for (const cs of contentScripts) {
cs.matches = cs.matches.map(m => (
@ -227,3 +231,152 @@ function injectContentScripts() {
}
});
}
function refreshAllTabs() {
return new Promise(resolve => {
// list all tabs including chrome-extension:// which can be ours
chrome.tabs.query({}, tabs => {
const lastTab = tabs[tabs.length - 1];
for (const tab of tabs) {
getStyles({matchUrl: tab.url, enabled: true, asHash: true}, styles => {
const message = {method: 'styleReplaceAll', styles};
chrome.tabs.sendMessage(tab.id, message);
updateIcon(tab, styles);
if (tab == lastTab) {
resolve();
}
});
}
});
});
}
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)
const isNTP = tab.url == 'chrome://newtab/';
if (isNTP && tab.status != 'complete' || tab.id < 0) {
return;
}
if (styles) {
// check for not-yet-existing tabs e.g. omnibox instant search
chrome.tabs.get(tab.id, () => {
if (!chrome.runtime.lastError) {
stylesReceived(styles);
}
});
return;
}
if (isNTP) {
getTabRealURL(tab).then(url =>
getStyles({matchUrl: url, enabled: true, asHash: true}, stylesReceived));
} else {
getStyles({matchUrl: tab.url, enabled: true, asHash: true}, stylesReceived);
}
function stylesReceived(styles) {
let numStyles = styles.length;
if (numStyles === undefined) {
// for 'styles' asHash:true fake the length by counting numeric ids manually
numStyles = 0;
for (const 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' : '';
const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal');
const text = prefs.get('show-badge') && numStyles ? String(numStyles) : '';
chrome.browserAction.setIcon({
tabId: tab.id,
path: {
// Material Design 2016 new size is 16px
16: `images/icon/16${postfix}.png`,
32: `images/icon/32${postfix}.png`,
// Chromium forks or non-chromium browsers may still use the traditional 19px
19: `images/icon/19${postfix}.png`,
38: `images/icon/38${postfix}.png`,
// TODO: add Edge preferred sizes: 20, 25, 30, 40
},
}, () => {
if (!chrome.runtime.lastError) {
// Vivaldi bug workaround: setBadgeText must follow setBadgeBackgroundColor
chrome.browserAction.setBadgeBackgroundColor({color});
chrome.browserAction.setBadgeText({text, tabId: tab.id});
}
});
}
}
function getCodeMirrorThemes() {
if (!chrome.runtime.getPackageDirectoryEntry) {
return Promise.resolve([
'3024-day',
'3024-night',
'abcdef',
'ambiance',
'ambiance-mobile',
'base16-dark',
'base16-light',
'bespin',
'blackboard',
'cobalt',
'colorforth',
'dracula',
'duotone-dark',
'duotone-light',
'eclipse',
'elegant',
'erlang-dark',
'hopscotch',
'icecoder',
'isotope',
'lesser-dark',
'liquibyte',
'material',
'mbo',
'mdn-like',
'midnight',
'monokai',
'neat',
'neo',
'night',
'panda-syntax',
'paraiso-dark',
'paraiso-light',
'pastel-on-dark',
'railscasts',
'rubyblue',
'seti',
'solarized',
'the-matrix',
'tomorrow-night-bright',
'tomorrow-night-eighties',
'ttcn',
'twilight',
'vibrant-ink',
'xq-dark',
'xq-light',
'yeti',
'zenburn',
]);
}
return new Promise(resolve => {
chrome.runtime.getPackageDirectoryEntry(rootDir => {
rootDir.getDirectory('codemirror/theme', {create: false}, themeDir => {
themeDir.createReader().readEntries(entries => {
resolve([
chrome.i18n.getMessage('defaultTheme')
].concat(
entries.filter(entry => entry.isFile)
.sort((a, b) => (a.name < b.name ? -1 : 1))
.map(entry => entry.name.replace(/\.css$/, ''))
));
});
});
});
});
}

View File

@ -1,4 +1,4 @@
/* global messageBox */
/* global messageBox, handleUpdate */
'use strict';
const STYLISH_DUMP_FILE_EXT = '.txt';
@ -47,8 +47,15 @@ function importFromFile({fileTypeFilter, file} = {}) {
function importFromString(jsonString) {
const json = runTryCatch(() => Array.from(JSON.parse(jsonString))) || [];
const oldStyles = json.length && deepCopyStyles();
if (!BG) {
onBackgroundReady().then(() => importFromString(jsonString));
return;
}
const json = BG.tryJSONparse(jsonString) || []; // create object in background context
if (typeof json.slice != 'function') {
json.length = 0;
}
const oldStyles = json.length && BG.deepCopy(BG.cachedStyles.list || []);
const oldStylesByName = json.length && new Map(
oldStyles.map(style => [style.name.trim(), style]));
const stats = {
@ -60,18 +67,19 @@ function importFromString(jsonString) {
invalid: {names: [], legend: 'invalid skipped'},
};
let index = 0;
let lastRepaint = performance.now();
return new Promise(proceed);
function proceed(resolve) {
while (index < json.length) {
const item = json[index++];
if (!item || !item.name || !item.name.trim() || typeof item != 'object'
|| (item.sections && !(item.sections instanceof Array))) {
|| (item.sections && typeof item.sections.slice != 'function')) {
stats.invalid.names.push(`#${index}: ${limitString(item && item.name || '')}`);
continue;
}
item.name = item.name.trim();
const byId = cachedStyles.byId.get(item.id);
const byId = BG.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) {
@ -81,16 +89,22 @@ function importFromString(jsonString) {
const metaEqual = oldStyleKeys &&
oldStyleKeys.length == Object.keys(item).length &&
oldStyleKeys.every(k => k == 'sections' || oldStyle[k] === item[k]);
const codeEqual = oldStyle && styleSectionsEqual(oldStyle, item);
const codeEqual = oldStyle && BG.styleSectionsEqual(oldStyle, item);
if (metaEqual && codeEqual) {
stats.unchanged.names.push(oldStyle.name);
stats.unchanged.ids.push(oldStyle.id);
continue;
}
saveStyle(Object.assign(item, {
// using saveStyle directly since json was parsed in background page context
BG.saveStyle(Object.assign(item, {
reason: 'import',
notify: false,
})).then(style => {
handleUpdate(style, {reason: 'import'});
if (performance.now() - lastRepaint > 1000) {
scrollElementIntoView($('#style-' + style.id));
lastRepaint = performance.now();
}
setTimeout(proceed, 0, resolve);
if (!oldStyle) {
stats.added.names.push(style.name);
@ -120,17 +134,22 @@ function importFromString(jsonString) {
stats.metaOnly.names.length +
stats.codeOnly.names.length +
stats.added.names.length;
Promise.resolve(numChanged && refreshAllTabs()).then(() => {
scrollTo(0, 0);
Promise.resolve(numChanged && BG.refreshAllTabs()).then(() => {
const listNames = kind => {
const {ids, names} = stats[kind];
return ids
? names.map((name, i) => `<div data-id="${ids[i]}">${name}</div>`)
: names.map(name => `<div>${name}</div>`);
};
const report = Object.keys(stats)
.filter(kind => stats[kind].names.length)
.map(kind => `<details data-id="${kind}">
.map(kind =>
`<details data-id="${kind}">
<summary><b>${stats[kind].names.length} ${stats[kind].legend}</b></summary>
<small>` + stats[kind].names.map((name, i) =>
`<div data-id="${stats[kind].ids[i]}">${name}</div>`).join('') + `
</small>
<small>${listNames(kind).join('')}</small>
</details>`)
.join('');
scrollTo(0, 0);
messageBox({
title: 'Finished importing styles',
contents: report || 'Nothing was changed.',
@ -155,7 +174,7 @@ function importFromString(jsonString) {
];
index = 0;
return new Promise(undoNextId)
.then(refreshAllTabs)
.then(BG.refreshAllTabs)
.then(() => messageBox({
title: 'Import has been undone',
contents: newIds.length + ' styles were reverted.',
@ -167,14 +186,14 @@ function importFromString(jsonString) {
return;
}
const id = newIds[index++];
deleteStyle(id, {notify: false}).then(id => {
deleteStyleSafe({id, notify: false}).then(id => {
const oldStyle = oldStylesById.get(id);
if (oldStyle) {
saveStyle(Object.assign(oldStyle, {
reason: 'undoImport',
saveStyleSafe(Object.assign(oldStyle, {
reason: 'import',
notify: false,
}))
.then(() => setTimeout(undoNextId, 0, resolve));
})).then(() =>
setTimeout(undoNextId, 0, resolve));
} else {
setTimeout(undoNextId, 0, resolve);
}
@ -198,25 +217,6 @@ function importFromString(jsonString) {
}
}
function deepCopyStyles() {
const clonedStyles = [];
for (let style of cachedStyles.list || []) {
style = Object.assign({}, style);
style.sections = style.sections.slice();
for (let i = 0, section; (section = style.sections[i]); i++) {
const copy = style.sections[i] = Object.assign({}, section);
for (const propName in copy) {
const prop = copy[propName];
if (prop instanceof Array) {
copy[propName] = prop.slice();
}
}
}
clonedStyles.push(style);
}
return clonedStyles;
}
function limitString(s, limit = 100) {
return s.length <= limit ? s : s.substr(0, limit) + '...';
}

15
dom.js
View File

@ -50,6 +50,21 @@ function animateElement(element, {className, remove = false}) {
}
function enforceInputRange(element) {
const min = Number(element.min);
const max = Number(element.max);
const onChange = () => {
const value = Number(element.value);
if (value < min || value > max) {
element.value = Math.max(min, Math.min(max, value));
}
};
onChange();
element.addEventListener('change', onChange);
element.addEventListener('input', onChange);
}
function $(selector, base = document) {
// we have ids with . like #manage.onlyEdited which look like #id.class
// so since getElementById is superfast we'll try it anyway

View File

@ -645,8 +645,8 @@
</template>
<script src="dom.js"></script>
<script src="storage.js"></script>
<script src="messaging.js"></script>
<script src="prefs.js"></script>
<script src="localization.js"></script>
<script src="apply.js"></script>
<script src="edit.js"></script>

11
edit.js
View File

@ -252,7 +252,8 @@ function initCodeMirror() {
} else {
// Chrome is starting up and shows our edit.html, but the background page isn't loaded yet
themeControl.innerHTML = optionsHtmlFromArray([theme == "default" ? t("defaultTheme") : theme]);
getCodeMirrorThemes(function(themes) {
BG.getCodeMirrorThemes().then(themes => {
BG.codeMirrorThemes = themes;
themeControl.innerHTML = optionsHtmlFromArray(themes);
themeControl.selectedIndex = Math.max(0, themes.indexOf(theme));
});
@ -1333,7 +1334,7 @@ function save() {
}
var name = document.getElementById("name").value;
var enabled = document.getElementById("enabled").checked;
saveStyle({
saveStyleSafe({
id: styleId,
name: name,
enabled: enabled,
@ -1815,7 +1816,9 @@ function getParams() {
return params;
}
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
chrome.runtime.onMessage.addListener(onRuntimeMessage);
function onRuntimeMessage(request) {
switch (request.method) {
case "styleUpdated":
if (styleId && styleId == request.style.id && request.reason != 'editSave') {
@ -1838,7 +1841,7 @@ chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
document.execCommand('delete');
break;
}
});
}
function getComputedHeight(el) {
var compStyle = getComputedStyle(el);

View File

@ -116,9 +116,8 @@
</template>
<script src="dom.js"></script>
<script src="health.js"></script>
<script src="storage.js"></script>
<script src="messaging.js"></script>
<script src="prefs.js"></script>
<script src="apply.js"></script>
<script src="localization.js"></script>
<script src="manage.js"></script>

View File

@ -27,7 +27,9 @@ Promise.all([
});
chrome.runtime.onMessage.addListener(msg => {
chrome.runtime.onMessage.addListener(onRuntimeMessage);
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleUpdated':
case 'styleAdded':
@ -37,7 +39,7 @@ chrome.runtime.onMessage.addListener(msg => {
handleDelete(msg.id);
break;
}
});
}
function initGlobalEvents() {
@ -151,8 +153,6 @@ function createStyleElement({style, name}) {
(style.enabled ? 'enabled' : 'disabled') +
(style.updateUrl ? ' updatable' : ''),
id: 'style-' + style.id,
styleId: style.id,
styleNameLowerCase: name || style.name.toLocaleLowerCase(),
});
parts.nameLink.textContent = style.name;
@ -216,6 +216,8 @@ function createStyleElement({style, name}) {
}
const newEntry = parts.entry.cloneNode(true);
newEntry.styleId = style.id;
newEntry.styleNameLowerCase = name || style.name.toLocaleLowerCase();
const newTargets = $('.targets', newEntry);
if (numTargets) {
newTargets.parentElement.replaceChild(targets, newTargets);
@ -282,7 +284,10 @@ Object.assign(handleEvent, {
},
toggle(event, entry) {
enableStyle(entry.styleId, this.matches('.enable') || this.checked);
saveStyleSafe({
id: entry.styleId,
enabled: this.matches('.enable') || this.checked,
});
},
check(event, entry) {
@ -291,7 +296,7 @@ Object.assign(handleEvent, {
update(event, entry) {
// update everything but name
saveStyle(Object.assign(entry.updatedCode, {
saveStyleSafe(Object.assign(entry.updatedCode, {
id: entry.styleId,
name: null,
reason: 'update',
@ -300,7 +305,7 @@ Object.assign(handleEvent, {
delete(event, entry) {
const id = entry.styleId;
const {name} = cachedStyles.byId.get(id) || {};
const {name} = BG.cachedStyles.byId.get(id) || {};
animateElement(entry, {className: 'highlight'});
messageBox({
title: t('deleteStyleConfirm'),
@ -310,7 +315,7 @@ Object.assign(handleEvent, {
})
.then(({button, enter, esc}) => {
if (button == 0 || enter) {
deleteStyle(id);
deleteStyleSafe({id});
}
});
},
@ -335,7 +340,7 @@ Object.assign(handleEvent, {
});
function handleUpdate(style, {reason, quiet} = {}) {
function handleUpdate(style, {reason} = {}) {
const element = createStyleElement({style});
const oldElement = $('#style-' + style.id, installed);
if (oldElement) {
@ -354,8 +359,8 @@ function handleUpdate(style, {reason, quiet} = {}) {
installed.insertBefore(element, findNextElement(style));
if (reason != 'import') {
animateElement(element, {className: 'highlight'});
scrollElementIntoView(element);
}
scrollElementIntoView(element);
}
@ -465,7 +470,7 @@ function checkUpdate(element) {
class Updater {
constructor(element) {
const style = cachedStyles.byId.get(element.styleId);
const style = BG.cachedStyles.byId.get(element.styleId);
Object.assign(this, {
element,
id: style.id,
@ -504,7 +509,7 @@ class Updater {
handleJson(forceUpdate, json) {
return getStylesSafe({id: this.id}).then(([style]) => {
const needsUpdate = forceUpdate || !styleSectionsEqual(style, json);
const needsUpdate = forceUpdate || !BG.styleSectionsEqual(style, json);
this.display({json: needsUpdate && json});
return needsUpdate;
});
@ -598,7 +603,7 @@ function searchStyles({immediately, container}) {
}
for (const element of (container || installed).children) {
const style = cachedStyles.byId.get(element.styleId) || {};
const style = BG.cachedStyles.byId.get(element.styleId) || {};
if (style) {
const isMatching = !query
|| isMatchingText(style.name)

View File

@ -19,7 +19,7 @@
"*://*/*"
],
"background": {
"scripts": ["messaging.js", "storage.js", "background.js", "update.js"]
"scripts": ["messaging.js", "storage.js", "prefs.js", "background.js", "update.js"]
},
"commands": {
"openManage": {

View File

@ -1,4 +1,4 @@
/* global getStyleWithNoCode, applyOnMessage, onBackgroundMessage, getStyles */
/* global BG: true, onRuntimeMessage, applyOnMessage, handleUpdate, handleDelete */
'use strict';
// keep message channel open for sendResponse in chrome.runtime.onMessage listener
@ -7,134 +7,59 @@ const FIREFOX = /Firefox/.test(navigator.userAgent);
const OPERA = /OPR/.test(navigator.userAgent);
const URLS = {
ownOrigin: chrome.runtime.getURL(''),
optionsUI: new Set([
optionsUI: [
chrome.runtime.getURL('options/index.html'),
'chrome://extensions/?options=' + chrome.runtime.id,
]),
configureCommands: OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
],
configureCommands:
OPERA ? 'opera://settings/configureCommands'
: 'chrome://extensions/configureCommands',
};
const RX_SUPPORTED_URLS = new RegExp(`^(file|https?|ftps?):|^${URLS.ownOrigin}`);
document.documentElement.classList.toggle('firefox', FIREFOX);
document.documentElement.classList.toggle('opera', OPERA);
let BG = chrome.extension.getBackgroundPage();
if (!BG || BG != window) {
document.documentElement.classList.toggle('firefox', FIREFOX);
document.documentElement.classList.toggle('opera', OPERA);
}
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)
function notifyAllTabs(msg) {
const originalMessage = msg;
if (msg.codeIsUpdated === false && msg.style) {
msg = Object.assign({}, msg, {
style: getStyleWithNoCode(msg.style)
});
}
const affectsAll = !request.affects || request.affects.all;
const affectsOwnOrigin = !affectsAll && (request.affects.editor || request.affects.manager);
const affectsAll = !msg.affects || msg.affects.all;
const affectsOwnOrigin = !affectsAll && (msg.affects.editor || msg.affects.manager);
const affectsTabs = affectsAll || affectsOwnOrigin;
const affectsIcon = affectsAll || request.affects.icon;
const affectsPopup = affectsAll || request.affects.popup;
const affectsIcon = affectsAll || msg.affects.icon;
const affectsPopup = affectsAll || msg.affects.popup;
if (affectsTabs || affectsIcon) {
// list all tabs including chrome-extension:// which can be ours
chrome.tabs.query(affectsOwnOrigin ? {url: URLS.ownOrigin + '*'} : {}, tabs => {
for (const tab of tabs) {
if (affectsTabs || URLS.optionsUI.has(tab.url)) {
chrome.tabs.sendMessage(tab.id, request);
if (affectsTabs || URLS.optionsUI.includes(tab.url)) {
chrome.tabs.sendMessage(tab.id, msg);
}
if (affectsIcon) {
updateIcon(tab);
if (affectsIcon && BG) {
BG.updateIcon(tab);
}
}
});
}
// notify self: the message no longer is sent to the origin in new Chrome
if (window.applyOnMessage) {
applyOnMessage(request);
} else if (window.onBackgroundMessage) {
onBackgroundMessage(request);
if (typeof onRuntimeMessage != 'undefined') {
onRuntimeMessage(originalMessage);
}
// notify apply.js on own pages
if (typeof applyOnMessage != 'undefined') {
applyOnMessage(originalMessage);
}
// notify background page and all open popups
if (affectsPopup || request.prefs) {
chrome.runtime.sendMessage(request);
}
}
function refreshAllTabs() {
return new Promise(resolve => {
// list all tabs including chrome-extension:// which can be ours
chrome.tabs.query({}, tabs => {
const lastTab = tabs[tabs.length - 1];
for (const tab of tabs) {
getStyles({matchUrl: tab.url, enabled: true, asHash: true}, styles => {
const message = {method: 'styleReplaceAll', styles};
if (tab.url == location.href && typeof applyOnMessage !== 'undefined') {
applyOnMessage(message);
} else {
chrome.tabs.sendMessage(tab.id, message);
}
updateIcon(tab, styles);
if (tab == lastTab) {
resolve();
}
});
}
});
});
}
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)
const isNTP = tab.url == 'chrome://newtab/';
if (isNTP && tab.status != 'complete' || tab.id < 0) {
return;
}
if (styles) {
// check for not-yet-existing tabs e.g. omnibox instant search
chrome.tabs.get(tab.id, () => {
if (!chrome.runtime.lastError) {
stylesReceived(styles);
}
});
return;
}
if (isNTP) {
getTabRealURL(tab).then(url =>
getStyles({matchUrl: url, enabled: true, asHash: true}, stylesReceived));
} else {
getStyles({matchUrl: tab.url, enabled: true, asHash: true}, stylesReceived);
}
function stylesReceived(styles) {
let numStyles = styles.length;
if (numStyles === undefined) {
// for 'styles' asHash:true fake the length by counting numeric ids manually
numStyles = 0;
for (const 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' : '';
const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal');
const text = prefs.get('show-badge') && numStyles ? String(numStyles) : '';
chrome.browserAction.setIcon({
tabId: tab.id,
path: {
// Material Design 2016 new size is 16px
16: `images/icon/16${postfix}.png`,
32: `images/icon/32${postfix}.png`,
// Chromium forks or non-chromium browsers may still use the traditional 19px
19: `images/icon/19${postfix}.png`,
38: `images/icon/38${postfix}.png`,
// TODO: add Edge preferred sizes: 20, 25, 30, 40
},
}, () => {
if (!chrome.runtime.lastError) {
// Vivaldi bug workaround: setBadgeText must follow setBadgeBackgroundColor
chrome.browserAction.setBadgeBackgroundColor({color});
chrome.browserAction.setBadgeText({text, tabId: tab.id});
}
});
if (affectsPopup || msg.prefs) {
chrome.runtime.sendMessage(msg);
}
}
@ -211,12 +136,153 @@ function stringAsRegExp(s, flags) {
}
// expands * as .*?
function wildcardAsRegExp(s, flags) {
return new RegExp(s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&').replace(/\*/g, '.*?'), flags);
}
function ignoreChromeError() {
chrome.runtime.lastError; // eslint-disable-line no-unused-expressions
}
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;
}
// 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 tryCatch(func, ...args) {
try {
return func(...args);
} catch (e) {}
}
function tryRegExp(regexp) {
try {
return new RegExp(regexp);
} catch (e) {}
}
function tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
}
function debounce(fn, delay, ...args) {
const timers = debounce.timers = debounce.timers || new Map();
debounce.run = debounce.run || ((fn, ...args) => {
timers.delete(fn);
fn(...args);
});
clearTimeout(timers.get(fn));
timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}
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 (typeof value.slice == 'function') {
const arrayCopy = target[k] = target[k] || [];
for (const element of value) {
arrayCopy.push(deepCopy(element));
}
} else if (k in target) {
deepMerge(target[k], value);
} else {
target[k] = deepCopy(value);
}
}
}
return target;
}
function sessionStorageHash(name) {
return {
name,
value: tryCatch(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 onBackgroundReady() {
return BG ? Promise.resolve() : new Promise(ping);
function ping(resolve) {
chrome.runtime.sendMessage({method: 'healthCheck'}, health => {
if (health !== undefined) {
BG = chrome.extension.getBackgroundPage();
resolve();
} else {
ping(resolve);
}
});
}
}
// 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 (BG) {
BG.getStyles(options, resolve);
} else {
onBackgroundReady().then(() =>
BG.getStyles(options, resolve));
}
});
}
function saveStyleSafe(style) {
return onBackgroundReady()
.then(() => BG.saveStyle(BG.deepCopy(style)))
.then(savedStyle => {
if (style.notify === false) {
handleUpdate(savedStyle, style);
}
return savedStyle;
});
}
function deleteStyleSafe({id, notify = true} = {}) {
return onBackgroundReady()
.then(() => BG.deleteStyle({id, notify}))
.then(() => {
if (!notify) {
handleDelete(id);
}
return id;
});
}

View File

@ -5,9 +5,9 @@
<link rel="stylesheet" href="index.css">
<script src="/dom.js"></script>
<script src="/localization.js"></script>
<script src="/apply.js"></script>
<script src="/storage.js"></script>
<script src="/messaging.js"></script>
<script src="/prefs.js"></script>
<script src="/apply.js"></script>
</head>
<body>

View File

@ -1,7 +1,5 @@
/* global update */
'use strict';
setupLivePrefs([
'show-badge',
'popup.stylesFirst',
@ -33,7 +31,7 @@ document.onclick = e => {
}
function check() {
chrome.extension.getBackgroundPage().update.perform((cmd, value) => {
BG.update.perform((cmd, value) => {
switch (cmd) {
case 'count':
total = value;

View File

@ -57,9 +57,8 @@
<script src="dom.js"></script>
<script src="localization.js"></script>
<script src="health.js"></script>
<script src="storage.js"></script>
<script src="messaging.js"></script>
<script src="prefs.js"></script>
<script src="apply.js"></script>
<script src="popup.js"></script>
</head>

View File

@ -1,4 +1,3 @@
/* global SLOPPY_REGEXP_PREFIX, compileStyleRegExps */
'use strict';
let installed;
@ -17,8 +16,9 @@ getActiveTabRealURL().then(url => {
});
});
chrome.runtime.onMessage.addListener(onRuntimeMessage);
chrome.runtime.onMessage.addListener(msg => {
function onRuntimeMessage(msg) {
switch (msg.method) {
case 'styleAdded':
case 'styleUpdated':
@ -38,7 +38,7 @@ chrome.runtime.onMessage.addListener(msg => {
}
break;
}
});
}
function setPopupWidth(width = prefs.get('popupWidth')) {
@ -117,7 +117,7 @@ function initPopup(url) {
matchTargets.appendChild(urlLink);
// For domain
const domains = getDomains(url);
const domains = BG.getDomains(url);
for (const domain of domains) {
// Don't include TLD
if (domains.length > 1 && !domain.includes('.')) {
@ -252,7 +252,7 @@ Object.assign(handleEvent, {
},
toggle(event) {
saveStyle({
saveStyleSafe({
id: handleEvent.getClickedStyleId(event),
enabled: this.type == 'checkbox' ? this.checked : this.matches('.enable'),
});
@ -263,7 +263,7 @@ Object.assign(handleEvent, {
const box = $('#confirm');
box.dataset.display = true;
box.style.cssText = '';
$('b', box).textContent = (cachedStyles.byId.get(id) || {}).name;
$('b', box).textContent = (BG.cachedStyles.byId.get(id) || {}).name;
$('[data-cmd="ok"]', box).onclick = () => confirm(true);
$('[data-cmd="cancel"]', box).onclick = () => confirm(false);
window.onkeydown = event => {
@ -278,7 +278,7 @@ Object.assign(handleEvent, {
animateElement(box, {className: 'lights-on'})
.then(() => (box.dataset.display = false));
if (ok) {
deleteStyle(id).then(() => {
deleteStyleSafe({id}).then(() => {
// update view with 'No styles installed for this site' message
if (!installed.children.length) {
showStyles([]);
@ -297,7 +297,7 @@ Object.assign(handleEvent, {
entry.appendChild(info);
},
closeExplanation(event) {
closeExplanation() {
$('#regexp-explanation').remove();
},
@ -347,7 +347,7 @@ function handleUpdate(style) {
return;
}
// Add an entry when a new style for the current url is installed
if (tabURL && getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) {
if (tabURL && BG.getApplicableSections({style, matchUrl: tabURL, stopOnFirst: true}).length) {
$('#unavailable').style.display = 'none';
createStyleElement({style});
}
@ -368,13 +368,15 @@ function handleDelete(id) {
*/
function detectSloppyRegexps({entry, style}) {
const {
appliedSections = getApplicableSections({style, matchUrl: tabURL}),
wannabeSections = getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}),
appliedSections =
BG.getApplicableSections({style, matchUrl: tabURL}),
wannabeSections =
BG.getApplicableSections({style, matchUrl: tabURL, strictRegexp: false}),
} = style;
compileStyleRegExps({style, compileAll: true});
BG.compileStyleRegExps({style, compileAll: true});
entry.hasInvalidRegexps = wannabeSections.some(section =>
section.regexps.some(rx => !cachedStyles.regexps.has(rx)));
section.regexps.some(rx => !BG.cachedStyles.regexps.has(rx)));
entry.sectionsSkipped = wannabeSections.length - appliedSections.length;
if (!appliedSections.length) {

265
prefs.js Normal file
View File

@ -0,0 +1,265 @@
/* global prefs: true, contextMenus */
'use strict';
// eslint-disable-next-line no-var
var prefs = new function Prefs() {
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
'manage.newUI': true, // use the new compact layout
'manage.newUI.favicons': true, // show favicons for the sites in applies-to
'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none
'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': 246, // popup width in pixels
'updateInterval': 0 // user-style automatic update interval, hour
};
const values = deepCopy(defaults);
const affectsIcon = [
'show-badge',
'disableAll',
'badgeDisabled',
'badgeNormal',
];
// coalesce multiple pref changes in broadcast
let broadcastPrefs = {};
Object.defineProperty(this, 'readOnlyValues', {value: {}});
Object.assign(Prefs.prototype, {
get(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);
},
getAll() {
return deepCopy(values);
},
set(key, value, {noBroadcast, noSync} = {}) {
const oldValue = deepCopy(values[key]);
values[key] = value;
defineReadonlyProperty(this.readOnlyValues, key, value);
if (!noBroadcast && !equal(value, oldValue)) {
this.broadcast(key, value, {noSync});
}
localStorage[key] = typeof defaults[key] == 'object'
? JSON.stringify(value)
: value;
},
remove: key => this.set(key, undefined),
reset: key => this.set(key, deepCopy(defaults[key])),
broadcast(key, value, {noSync} = {}) {
broadcastPrefs[key] = value;
debounce(doBroadcast);
if (!noSync) {
debounce(doSyncSet);
}
},
});
// Unlike sync, HTML5 localStorage is ready at browser startup
// so we'll mirror the prefs to avoid using the wrong defaults
// during the startup phase
for (const key in defaults) {
const defaultValue = defaults[key];
let value = localStorage[key];
if (typeof value == 'string') {
switch (typeof defaultValue) {
case 'boolean':
value = value.toLowerCase() === 'true';
break;
case 'number':
value |= 0;
break;
case 'object':
value = tryJSONparse(value) || defaultValue;
break;
}
} else {
value = defaultValue;
}
this.set(key, value, {noBroadcast: true});
}
getSync().get('settings', ({settings: synced} = {}) => {
if (synced) {
for (const key in defaults) {
if (key == 'popupWidth' && synced[key] != values.popupWidth) {
// this is a fix for the period when popupWidth wasn't synced
// TODO: remove it in a couple of months
continue;
}
if (key in synced) {
this.set(key, synced[key], {noSync: true});
}
}
}
if (typeof contextMenus !== 'undefined') {
for (const id in contextMenus) {
if (typeof values[id] == 'boolean') {
this.broadcast(id, values[id], {noSync: true});
}
}
}
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area == 'sync' && 'settings' in changes) {
const synced = changes.settings.newValue;
if (synced) {
for (const key in defaults) {
if (key in synced) {
this.set(key, synced[key], {noSync: true});
}
}
} else {
// user manually deleted our settings, we'll recreate them
getSync().set({'settings': values});
}
}
});
function doBroadcast() {
const affects = {all: 'disableAll' in broadcastPrefs};
if (!affects.all) {
for (const key in broadcastPrefs) {
affects.icon = affects.icon || affectsIcon.includes(key);
affects.popup = affects.popup || key.startsWith('popup');
affects.editor = affects.editor || key.startsWith('editor');
affects.manager = affects.manager || key.startsWith('manage');
}
}
notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects});
broadcastPrefs = {};
}
function doSyncSet() {
getSync().set({'settings': values});
}
// 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 defineReadonlyProperty(obj, key, value) {
const copy = deepCopy(value);
if (typeof copy == 'object') {
Object.freeze(copy);
}
Object.defineProperty(obj, key, {value: copy, configurable: true});
}
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;
}
}();
// 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(msg => {
if (msg.prefs) {
for (const prefName in msg.prefs) {
if (prefName in localIDs) {
updateElement(prefName, msg.prefs[prefName]);
}
}
}
});
function updateElement(id, value) {
const el = document.getElementById(id);
el[isCheckbox(el) ? 'checked' : 'value'] = value || prefs.get(id);
el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
return el;
}
function isCheckbox(el) {
return el.localName == 'input' && el.type == 'checkbox';
}
}

View File

@ -1,7 +1,28 @@
/* global cachedStyles: true, prefs: true, contextMenus: false */
/* global handleUpdate, handleDelete */
/* global cachedStyles: true */
'use strict';
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';
// Note, only 'var'-declared variables are visible from another extension page
// eslint-disable-next-line no-var
var cachedStyles = {
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: [],
},
};
function getDatabase(ready, error) {
const dbOpenRequest = window.indexedDB.open('stylish', 2);
dbOpenRequest.onsuccess = event => {
@ -24,54 +45,6 @@ function getDatabase(ready, error) {
}
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));
@ -107,60 +80,6 @@ function getStyles(options, callback) {
}
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,
@ -174,10 +93,10 @@ function filterStyles({
id = id === null ? null : Number(id);
if (enabled === null
&& url === null
&& id === null
&& matchUrl === null
&& asHash != true) {
&& 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;
}
@ -247,36 +166,6 @@ function filterStyles({
}
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 => {
@ -312,9 +201,6 @@ function saveStyle(style) {
style, codeIsUpdated, reason,
});
}
if (typeof handleUpdate != 'undefined') {
handleUpdate(style, {reason});
}
resolve(style);
};
};
@ -340,9 +226,6 @@ function saveStyle(style) {
if (notify) {
notifyAllTabs({method: 'styleAdded', style, reason});
}
if (typeof handleUpdate != 'undefined') {
handleUpdate(style, {reason});
}
resolve(style);
};
});
@ -350,24 +233,7 @@ function saveStyle(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} = {}) {
function deleteStyle({id, notify = true}) {
return new Promise(resolve =>
getDatabase(db => {
const tx = db.transaction(['styles'], 'readwrite');
@ -377,61 +243,12 @@ function deleteStyle(id, {notify = true} = {}) {
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 = [];
@ -518,392 +335,6 @@ function getApplicableSections({style, matchUrl, strictRegexp = true, stopOnFirs
}
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) {}
}
function tryJSONparse(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {}
}
function debounce(fn, delay, ...args) {
const timers = debounce.timers = debounce.timers || new Map();
debounce.run = debounce.run || ((fn, ...args) => {
timers.delete(fn);
fn(...args);
});
clearTimeout(timers.get(fn));
timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
}
prefs = prefs || new function Prefs() {
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
'manage.newUI': true, // use the new compact layout
'manage.newUI.favicons': true, // show favicons for the sites in applies-to
'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none
'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': 246, // popup width in pixels
'updateInterval': 0 // user-style automatic update interval, hour
};
const values = deepCopy(defaults);
const affectsIcon = [
'show-badge',
'disableAll',
'badgeDisabled',
'badgeNormal',
];
// coalesce multiple pref changes in broadcast
let broadcastPrefs = {};
function doBroadcast() {
const affects = {all: 'disableAll' in broadcastPrefs};
if (!affects.all) {
for (const key in broadcastPrefs) {
affects.icon = affects.icon || affectsIcon.includes(key);
affects.popup = affects.popup || key.startsWith('popup');
affects.editor = affects.editor || key.startsWith('editor');
affects.manager = affects.manager || key.startsWith('manage');
}
}
notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects});
broadcastPrefs = {};
}
function doSyncSet() {
getSync().set({'settings': values});
}
Object.defineProperty(this, 'readOnlyValues', {value: {}});
Object.assign(Prefs.prototype, {
get(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);
},
getAll() {
return deepCopy(values);
},
set(key, value, {noBroadcast, noSync} = {}) {
const oldValue = deepCopy(values[key]);
values[key] = value;
defineReadonlyProperty(this.readOnlyValues, key, value);
if (!noBroadcast && !equal(value, oldValue)) {
this.broadcast(key, value, {noSync});
}
localStorage[key] = typeof defaults[key] == 'object'
? JSON.stringify(value)
: value;
},
remove: key => this.set(key, undefined),
reset: key => this.set(key, deepCopy(defaults[key])),
broadcast(key, value, {noSync} = {}) {
broadcastPrefs[key] = value;
debounce(doBroadcast);
if (!noSync) {
debounce(doSyncSet);
}
},
});
// Unlike sync, HTML5 localStorage is ready at browser startup
// so we'll mirror the prefs to avoid using the wrong defaults
// during the startup phase
for (const key in defaults) {
const defaultValue = defaults[key];
let value = localStorage[key];
if (typeof value == 'string') {
switch (typeof defaultValue) {
case 'boolean':
value = value.toLowerCase() === 'true';
break;
case 'number':
value |= 0;
break;
case 'object':
value = tryJSONparse(value) || defaultValue;
break;
}
} else {
value = defaultValue;
}
this.set(key, value, {noBroadcast: true});
}
getSync().get('settings', ({settings: synced}) => {
if (synced) {
for (const key in defaults) {
if (key == 'popupWidth' && synced[key] != values.popupWidth) {
// this is a fix for the period when popupWidth wasn't synced
// TODO: remove it in a couple of months before the summer 2017
continue;
}
if (key in synced) {
this.set(key, synced[key], {noSync: true});
}
}
}
if (typeof contextMenus !== 'undefined') {
for (const id in contextMenus) {
if (typeof values[id] == 'boolean') {
this.broadcast(id, values[id], {noSync: true});
}
}
}
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area == 'sync' && 'settings' in changes) {
const synced = changes.settings.newValue;
if (synced) {
for (const key in defaults) {
if (key in synced) {
this.set(key, synced[key], {noSync: true});
}
}
} else {
// user manually deleted our settings, we'll recreate them
getSync().set({'settings': values});
}
}
});
}();
// 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(msg => {
if (msg.prefs) {
for (const prefName in msg.prefs) {
if (prefName in localIDs) {
updateElement(prefName, msg.prefs[prefName]);
}
}
}
});
function updateElement(id, value) {
const el = document.getElementById(id);
el[isCheckbox(el) ? 'checked' : 'value'] = value || prefs.get(id);
el.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
return el;
}
}
function enforceInputRange(element) {
const min = Number(element.min);
const max = Number(element.max);
const onChange = () => {
const value = Number(element.value);
if (value < min || value > max) {
element.value = Math.max(min, Math.min(max, value));
}
};
onChange();
element.addEventListener('change', onChange);
element.addEventListener('input', onChange);
}
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;
@ -990,3 +421,136 @@ function compileStyleRegExps({style, compileAll}) {
}
}
}
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 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 addMissingStyleTargets(style) {
style.sections = (style.sections || []).map(section =>
Object.assign({
urls: [],
urlPrefixes: [],
domains: [],
regexps: [],
}, section)
);
}
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';
}

View File

@ -1,4 +1,5 @@
/* globals getStyles */
/* eslint brace-style: 1, arrow-parens: 1, space-before-function-paren: 1, arrow-body-style: 1 */
/* globals getStyles, saveStyle */
'use strict';
// TODO: refactor to make usable in manage::Updater