diff --git a/.eslintrc b/.eslintrc index 37c2ae81..20603487 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,64 +8,6 @@ env: es6: true webextensions: true -globals: - # messaging.js - KEEP_CHANNEL_OPEN: false - CHROME: false - FIREFOX: false - VIVALDI: false - OPERA: false - URLS: false - BG: false - API: false - notifyAllTabs: false - sendMessage: false - queryTabs: false - getTab: false - getOwnTab: false - getActiveTab: false - getActiveTabRealURL: false - getTabRealURL: false - openURL: false - activateTab: false - stringAsRegExp: false - ignoreChromeError: false - tryCatch: false - tryRegExp: false - tryJSONparse: false - debounce: false - deepCopy: false - sessionStorageHash: false - download: false - invokeOrPostpone: false - # localization.js - template: false - t: false - o: false - tE: false - tHTML: false - tNodeList: false - tDocLoader: false - tWordBreak: false - formatDate: false - # dom.js - onDOMready: false - onDOMscriptReady: false - scrollElementIntoView: false - enforceInputRange: false - animateElement: false - $: false - $$: false - $create: false - $createLink: false - # prefs.js - prefs: false - setupLivePrefs: false - # storage-util.js - chromeLocal: false - chromeSync: false - LZString: false - rules: accessor-pairs: [2] array-bracket-spacing: [2, never] @@ -214,7 +156,6 @@ rules: no-trailing-spaces: [2] no-undef-init: [2] no-undef: [2] - no-undefined: [0] no-underscore-dangle: [0] no-unexpected-multiline: [2] no-unmodified-loop-condition: [0] @@ -224,7 +165,7 @@ rules: no-unsafe-negation: [2] no-unused-expressions: [1] no-unused-labels: [0] - no-unused-vars: [1, {args: after-used, vars: local, argsIgnorePattern: ^_}] + no-unused-vars: [2, {args: after-used}] no-use-before-define: [2, nofunc] no-useless-call: [2] no-useless-computed-key: [2] diff --git a/_locales/en/messages.json b/_locales/en/messages.json index eae2663d..48166e36 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -689,6 +689,194 @@ "message": "Show active style count", "description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text." }, + "meta_invalidCheckboxDefault": { + "message": "Invalid @var checkbox: value must be 0 or 1", + "description": "Error displayed when the value of @var checkbox is invalid" + }, + "meta_invalidColor": { + "message": "Invalid @var color: $color$ is not a color", + "description": "Error displayed when the value of @var color is invalid", + "placeholders": { + "color": { + "content": "$1" + } + } + }, + "meta_invalidRange": { + "message": "Invalid @var $type$: value must be a number or an array", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMultipleUnits": { + "message": "Invalid @var $type$: multiple units are defined", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeTooManyValues": { + "message": "Invalid @var $type$: the array contains too many items", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeValue": { + "message": "Invalid @var $type$: items in the array must be number, string, or null", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeDefault": { + "message": "Invalid @var $type$: default value is null", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMin": { + "message": "Invalid @var $type$: default value is lower than the minimum", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMax": { + "message": "Invalid @var $type$: default value is larger than the maximum", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeStep": { + "message": "Invalid @var $type$: default value is not a mutiple of the step", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidSelectEmptyOptions": { + "message": "Invalid @var select: options list is empty", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidSelectMultipleDefaults": { + "message": "Invalid @var select: multiple default options are defined", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidSelectValueMismatch": { + "message": "Invalid @var select: value doesn't exist in the option list", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidURLProtocol": { + "message": "Invalid URL protocol. Only http and https are allowed: $protocol$", + "description": "Error displayed when the protocol of the URL is invalid", + "placeholders": { + "protocol": { + "content": "$1" + } + } + }, + "meta_invalidVersion": { + "message": "Invalid version number. The value doesn't match SemVer pattern: $version$", + "description": "Error displayed when @version is invalid", + "placeholders": { + "version": { + "content": "$1" + } + } + }, + "meta_invalidNumber": { + "message": "Expect a number", + "description": "Error displayed when the value is expected to be a number" + }, + "meta_invalidString": { + "message": "Expect a quoted string", + "description": "Error displayed when the value is expected to be a quoted string" + }, + "meta_invalidWord": { + "message": "Expect a word", + "description": "Error displayed when the value is expected to be a word" + }, + "meta_missingChar": { + "message": "Expect characters: $chars$", + "description": "Error displayed when the value is expected to be some characters", + "placeholders": { + "chars": { + "content": "$1" + } + } + }, + "meta_missingEOT": { + "message": "Expect EOT data", + "description": "Error displayed when the value is expected to be an EOT list" + }, + "meta_missingMandatory": { + "message": "Missing mandatory metadata: $keys$", + "description": "Error displayed when mandatory keys are missing", + "placeholders": { + "keys": { + "content": "$1" + } + } + }, + "meta_unknownJSONLiteral": { + "message": "Invalid JSON: $literal$ is not a valid JSON literal", + "description": "Error displayed when JSON value is invalid", + "placeholders": { + "literal": { + "content": "$1" + } + } + }, + "meta_unknownMeta": { + "message": "Unknown metadata: $key$", + "description": "Error displayed when unknown metadata is parsed", + "placeholders": { + "key": { + "content": "$1" + } + } + }, + "meta_unknownVarType": { + "message": "Unknown @$varkey$ type: $vartype$", + "description": "Error displayed when unknown variable type is parsed", + "placeholders": { + "varkey": { + "content": "$1" + }, + "vartype": { + "content": "$2" + } + } + }, + "meta_unknownPreprocessor": { + "message": "Unknown @preprocessor: $preprocessor$", + "description": "Error displayed when unknown @preprocessor is parsed", + "placeholders": { + "preprocessor": { + "content": "$1" + } + } + }, "noStylesForSite": { "message": "No styles installed for this site.", "description": "Text displayed when no styles are installed for the current site" @@ -922,10 +1110,6 @@ "message": "Code", "description": "Label for the code for a section" }, - "sectionHelp": { - "message": "Sections let you define different pieces of code to apply to different sets of URLs in the same style. For example, a single style could change the homepage of a site one way, while changing the rest of a site another way.", - "description": "Help text for sections" - }, "sectionRemove": { "message": "Remove section", "description": "Label for the button to remove a section" @@ -1038,50 +1222,6 @@ }, "description": "Confirmation when re-installing a style" }, - "styleMetaErrorCheckbox": { - "message": "Invalid @var checkbox: value must be 0 or 1", - "description": "Error displayed when the value of @var checkbox is invalid" - }, - "styleMetaErrorColor": { - "message": "$color$ is not a valid color", - "placeholders": { - "color": { - "content": "$1" - } - }, - "description": "Error displayed when the value of @var color is invalid" - }, - "styleMetaErrorRangeOrNumber": { - "message": "Invalid @var $type$: value must be an array containing at least one number at index zero", - "description": "Error displayed when the value of @var number or @var range is invalid", - "placeholders": { - "type": { - "content": "$1" - } - } - }, - "styleMetaErrorPreprocessor": { - "message": "Unsupported @preprocessor: $preprocessor$", - "placeholders": { - "preprocessor": { - "content": "$1" - } - }, - "description": "Error displayed when the value of @preprocessor is not supported" - }, - "styleMetaErrorSelectValueMismatch": { - "message": "Invalid @select: value doesn't exist in the list", - "description": "Error displayed when the value of @select is invalid" - }, - "styleMissingMeta": { - "message": "Missing metadata @$key$", - "placeholders": { - "key": { - "content": "$1" - } - }, - "description": "Error displayed when a mandatory metadata is missing" - }, "styleMissingName": { "message": "Enter a name", "description": "Error displayed when user saves without providing a name" @@ -1136,10 +1276,6 @@ "message": "Save", "description": "Label for save button for style editing" }, - "styleSectionsTitle": { - "message": "Sections", - "description": "Title for the style sections section" - }, "styleToMozillaFormatHelp": { "message": "The Mozilla format of the code can be submitted to userstyles.org and used with the classic Stylish for Firefox", "description": "Help info for the Mozilla format header section that converts the code to/from Mozilla format" diff --git a/background/background-worker.js b/background/background-worker.js new file mode 100644 index 00000000..630c33b0 --- /dev/null +++ b/background/background-worker.js @@ -0,0 +1,167 @@ +/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */ +'use strict'; + +importScripts('/js/worker-util.js'); +const {loadScript, createAPI} = workerUtil; + +createAPI({ + parseMozFormat(arg) { + loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js'); + return parseMozFormat(arg); + }, + compileUsercss, + parseUsercssMeta(text, indexOffset = 0) { + loadScript( + '/vendor/usercss-meta/usercss-meta.min.js', + '/vendor-overwrites/colorpicker/colorconverter.js', + '/js/meta-parser.js' + ); + return metaParser.parse(text, indexOffset); + }, + nullifyInvalidVars(vars) { + loadScript( + '/vendor/usercss-meta/usercss-meta.min.js', + '/vendor-overwrites/colorpicker/colorconverter.js', + '/js/meta-parser.js' + ); + return metaParser.nullifyInvalidVars(vars); + } +}); + +function compileUsercss(preprocessor, code, vars) { + loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js'); + const builder = getUsercssCompiler(preprocessor); + vars = simpleVars(vars); + return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code) + .then(code => parseMozFormat({code})) + .then(({sections, errors}) => { + if (builder.postprocess) { + builder.postprocess(sections, vars); + } + return {sections, errors}; + }); + + function simpleVars(vars) { + if (!vars) { + return {}; + } + // simplify vars by merging `va.default` to `va.value`, so BUILDER don't + // need to test each va's default value. + return Object.keys(vars).reduce((output, key) => { + const va = vars[key]; + output[key] = Object.assign({}, va, { + value: va.value === null || va.value === undefined ? + getVarValue(va, 'default') : getVarValue(va, 'value') + }); + return output; + }, {}); + } + + function getVarValue(va, prop) { + if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') { + // TODO: handle customized image + return va.options.find(o => o.name === va[prop]).value; + } + if ((va.type === 'number' || va.type === 'range') && va.units) { + return va[prop] + va.units; + } + return va[prop]; + } +} + +function getUsercssCompiler(preprocessor) { + const BUILDER = { + default: { + postprocess(sections, vars) { + loadScript('/js/sections-util.js'); + let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join(''); + if (!varDef) return; + varDef = ':root {\n' + varDef + '}\n'; + for (const section of sections) { + if (!styleCodeEmpty(section.code)) { + section.code = varDef + section.code; + } + } + } + }, + stylus: { + preprocess(source, vars) { + loadScript('/vendor/stylus-lang-bundle/stylus.min.js'); + return new Promise((resolve, reject) => { + const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); + if (!Error.captureStackTrace) Error.captureStackTrace = () => {}; + self.stylus(varDef + source).render((err, output) => { + if (err) { + reject(err); + } else { + resolve(output); + } + }); + }); + } + }, + less: { + preprocess(source, vars) { + if (!self.less) { + self.less = { + logLevel: 0, + useFileCache: false, + }; + } + loadScript('/vendor/less/less.min.js'); + const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join(''); + return self.less.render(varDefs + source) + .then(({css}) => css); + } + }, + uso: { + preprocess(source, vars) { + loadScript('/vendor-overwrites/colorpicker/colorconverter.js'); + const pool = new Map(); + return Promise.resolve(doReplace(source)); + + function getValue(name, rgb) { + if (!vars.hasOwnProperty(name)) { + if (name.endsWith('-rgb')) { + return getValue(name.slice(0, -4), true); + } + return null; + } + if (rgb) { + if (vars[name].type === 'color') { + const color = colorConverter.parse(vars[name].value); + if (!color) return null; + const {r, g, b} = color; + return `${r}, ${g}, ${b}`; + } + return null; + } + if (vars[name].type === 'dropdown' || vars[name].type === 'select') { + // prevent infinite recursion + pool.set(name, ''); + return doReplace(vars[name].value); + } + return vars[name].value; + } + + function doReplace(text) { + return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => { + if (!pool.has(name)) { + const value = getValue(name); + pool.set(name, value === null ? match : value); + } + return pool.get(name); + }); + } + } + } + }; + + if (preprocessor) { + if (!BUILDER[preprocessor]) { + throw new Error('unknwon preprocessor'); + } + return BUILDER[preprocessor]; + } + return BUILDER.default; +} diff --git a/background/background.js b/background/background.js index bbcd2954..b6c090dd 100644 --- a/background/background.js +++ b/background/background.js @@ -1,54 +1,54 @@ -/* -global dbExec getStyles saveStyle deleteStyle -global handleCssTransitionBug detectSloppyRegexps -global openEditor -global styleViaAPI -global loadScript -global usercss -*/ +/* global download prefs openURL FIREFOX CHROME VIVALDI + openEditor debounce URLS ignoreChromeError queryTabs getTab + styleManager msg navigatorUtil iconUtil workerUtil */ 'use strict'; +// eslint-disable-next-line no-var +var backgroundWorker = workerUtil.createWorker({ + url: '/background/background-worker.js' +}); + window.API_METHODS = Object.assign(window.API_METHODS || {}, { + deleteStyle: styleManager.deleteStyle, + editSave: styleManager.editSave, + findStyle: styleManager.findStyle, + getAllStyles: styleManager.getAllStyles, // used by importer + getSectionsByUrl: styleManager.getSectionsByUrl, + getStyle: styleManager.get, + getStylesByUrl: styleManager.getStylesByUrl, + importStyle: styleManager.importStyle, + installStyle: styleManager.installStyle, + styleExists: styleManager.styleExists, + toggleStyle: styleManager.toggleStyle, - getStyles, - saveStyle, - deleteStyle, - - getStyleFromDB: id => - dbExec('get', id).then(event => event.target.result), + getTabUrlPrefix() { + return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; + }, download(msg) { delete msg.method; return download(msg.url, msg); }, parseCss({code}) { - return usercss.invokeWorker({action: 'parse', code}); + return backgroundWorker.parseMozFormat({code}); }, getPrefs: prefs.getAll, - healthCheck: () => dbExec().then(() => true), - detectSloppyRegexps, openEditor, - updateIcon, + + updateIconBadge(count) { + return updateIconBadge(this.sender.tab.id, count); + }, // exposed for stuff that requires followup sendMessage() like popup::openSettings // that would fail otherwise if another extension forced the tab to open // in the foreground thus auto-closing the popup (in Chrome) openURL, - closeTab: (msg, sender, respond) => { - chrome.tabs.remove(msg.tabId || sender.tab.id, () => { - if (chrome.runtime.lastError && msg.tabId !== sender.tab.id) { - respond(new Error(chrome.runtime.lastError.message)); - } - }); - return KEEP_CHANNEL_OPEN; - }, - optionsCustomizeHotkeys() { return browser.runtime.openOptionsPage() .then(() => new Promise(resolve => setTimeout(resolve, 100))) - .then(() => sendMessage({method: 'optionsCustomizeHotkeys'})); + .then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'})); }, }); @@ -57,67 +57,31 @@ var browserCommands, contextMenus; // ************************************************************************* // register all listeners -chrome.runtime.onMessage.addListener(onRuntimeMessage); +msg.on(onRuntimeMessage); + +navigatorUtil.onUrlChange(({tabId, frameId}, type) => { + if (type === 'committed') { + // styles would be updated when content script is injected. + return; + } + msg.sendTab(tabId, {method: 'urlChanged'}, {frameId}) + .catch(msg.ignoreError); +}); if (FIREFOX) { - // see notes in apply.js for getStylesFallback - const MSG_GET_STYLES = 'getStyles:'; - const MSG_GET_STYLES_LEN = MSG_GET_STYLES.length; - chrome.runtime.onConnect.addListener(port => { - if (!port.name.startsWith(MSG_GET_STYLES)) return; - const tabId = port.sender.tab.id; - const frameId = port.sender.frameId; - const options = tryJSONparse(port.name.slice(MSG_GET_STYLES_LEN)); - port.disconnect(); - getStyles(options).then(styles => { - if (!styles.length) return; - chrome.tabs.executeScript(tabId, { - code: ` - applyOnMessage({ - method: 'styleApply', - styles: ${JSON.stringify(styles)}, - }) - `, - runAt: 'document_start', - frameId, - }); - }); + // FF applies page CSP even to content scripts, https://bugzil.la/1267027 + navigatorUtil.onCommitted(webNavUsercssInstallerFF, { + url: [ + {hostSuffix: '.githubusercontent.com', urlSuffix: '.user.css'}, + {hostSuffix: '.githubusercontent.com', urlSuffix: '.user.styl'}, + ] + }); + // FF misses some about:blank iframes so we inject our content script explicitly + navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, { + url: [ + {urlEquals: 'about:blank'}, + ] }); -} - -{ - const listener = - URLS.chromeProtectsNTP - ? webNavigationListenerChrome - : webNavigationListener; - - chrome.webNavigation.onBeforeNavigate.addListener(data => - listener(null, data)); - - chrome.webNavigation.onCommitted.addListener(data => - listener('styleApply', data)); - - chrome.webNavigation.onHistoryStateUpdated.addListener(data => - listener('styleReplaceAll', data)); - - chrome.webNavigation.onReferenceFragmentUpdated.addListener(data => - listener('styleReplaceAll', data)); - - if (FIREFOX) { - // FF applies page CSP even to content scripts, https://bugzil.la/1267027 - chrome.webNavigation.onCommitted.addListener(webNavUsercssInstallerFF, { - url: [ - {hostSuffix: '.githubusercontent.com', urlSuffix: '.user.css'}, - {hostSuffix: '.githubusercontent.com', urlSuffix: '.user.styl'}, - ] - }); - // FF misses some about:blank iframes so we inject our content script explicitly - chrome.webNavigation.onDOMContentLoaded.addListener(webNavIframeHelperFF, { - url: [ - {urlEquals: 'about:blank'}, - ] - }); - } } if (chrome.contextMenus) { @@ -130,22 +94,45 @@ if (chrome.commands) { chrome.commands.onCommand.addListener(command => browserCommands[command]()); } -if (!chrome.browserAction || - !['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) { - window.updateIcon = () => {}; -} - const tabIcons = new Map(); chrome.tabs.onRemoved.addListener(tabId => tabIcons.delete(tabId)); chrome.tabs.onReplaced.addListener((added, removed) => tabIcons.delete(removed)); -// ************************************************************************* -// set the default icon displayed after a tab is created until webNavigation kicks in -prefs.subscribe(['iconset'], () => - updateIcon({ - tab: {id: undefined}, - styles: {}, - })); +prefs.subscribe([ + 'disableAll', + 'badgeDisabled', + 'badgeNormal', +], () => debounce(refreshIconBadgeColor)); + +prefs.subscribe([ + 'show-badge' +], () => debounce(refreshIconBadgeText)); + +prefs.subscribe([ + 'disableAll', + 'iconset', +], () => debounce(refreshAllIcons)); + +prefs.initializing.then(() => { + refreshIconBadgeColor(); + refreshAllIconsBadgeText(); + refreshAllIcons(); +}); + +navigatorUtil.onUrlChange(({tabId, frameId, transitionQualifiers}, type) => { + if (type === 'committed' && !frameId) { + // it seems that the tab icon would be reset by navigation. We + // invalidate the cache here so it would be refreshed by `apply.js`. + tabIcons.delete(tabId); + + // however, if the tab was swapped in by forward/backward buttons, + // `apply.js` doesn't notify the background to update the icon, + // so we have to refresh it manually. + if (transitionQualifiers.includes('forward_back')) { + msg.sendTab(tabId, {method: 'updateCount'}).catch(msg.ignoreError); + } + } +}); // ************************************************************************* chrome.runtime.onInstalled.addListener(({reason}) => { @@ -191,7 +178,7 @@ contextMenus = { contexts: ['editable'], documentUrlPatterns: [URLS.ownOrigin + 'edit*'], click: (info, tab) => { - sendMessage({tabId: tab.id, method: 'editDeleteText'}); + msg.sendTab(tab.id, {method: 'editDeleteText'}); }, } }; @@ -205,11 +192,10 @@ if (chrome.contextMenus) { } item = Object.assign({id}, item); delete item.presentIf; - const prefValue = prefs.readOnlyValues[id]; item.title = chrome.i18n.getMessage(item.title); - if (!item.type && typeof prefValue === 'boolean') { + if (!item.type && typeof prefs.defaults[id] === 'boolean') { item.type = 'checkbox'; - item.checked = prefValue; + item.checked = prefs.get(id); } if (!item.contexts) { item.contexts = ['browser_action']; @@ -233,24 +219,35 @@ if (chrome.contextMenus) { }; const keys = Object.keys(contextMenus); - prefs.subscribe(keys.filter(id => typeof prefs.readOnlyValues[id] === 'boolean'), toggleCheckmark); + prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark); prefs.subscribe(keys.filter(id => contextMenus[id].presentIf), togglePresence); createContextMenus(keys); } -// ************************************************************************* -// [re]inject content scripts -window.addEventListener('storageReady', function _() { - window.removeEventListener('storageReady', _); +// reinject content scripts when the extension is reloaded/updated. Firefox +// would handle this automatically. +if (!FIREFOX) { + reinjectContentScripts(); +} - updateIcon({ - tab: {id: undefined}, - styles: {}, +// register hotkeys +if (FIREFOX && browser.commands && browser.commands.update) { + const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.')); + prefs.subscribe(hotkeyPrefs, (name, value) => { + try { + name = name.split('.')[1]; + if (value.trim()) { + browser.commands.update({name, shortcut: value}); + } else { + browser.commands.reset(name); + } + } catch (e) {} }); +} - // Firefox injects content script automatically - if (FIREFOX) return; +msg.broadcastTab({method: 'backgroundReady'}); +function reinjectContentScripts() { const NTP = 'chrome://newtab/'; const ALL_URLS = ''; const contentScripts = chrome.runtime.getManifest().content_scripts; @@ -266,20 +263,23 @@ window.addEventListener('storageReady', function _() { const injectCS = (cs, tabId) => { ignoreChromeError(); - chrome.tabs.executeScript(tabId, { - file: cs.js[0], - runAt: cs.run_at, - allFrames: cs.all_frames, - matchAboutBlank: cs.match_about_blank, - }, ignoreChromeError); + for (const file of cs.js) { + chrome.tabs.executeScript(tabId, { + file, + runAt: cs.run_at, + allFrames: cs.all_frames, + matchAboutBlank: cs.match_about_blank, + }, ignoreChromeError); + } }; const pingCS = (cs, {id, url}) => { - const maybeInject = pong => !pong && injectCS(cs, id); cs.matches.some(match => { if ((match === ALL_URLS || url.match(match)) && (!url.startsWith('chrome') || url === NTP)) { - sendMessage({method: 'ping', tabId: id}, maybeInject); + msg.sendTab(id, {method: 'ping'}) + .catch(() => false) + .then(pong => !pong && injectCS(cs, id)); return true; } }); @@ -293,85 +293,19 @@ window.addEventListener('storageReady', function _() { setTimeout(pingCS, 0, cs, tab)); } })); -}); - -// ************************************************************************* -{ - const getStylesForFrame = (msg, sender) => { - const stylesTask = getStyles(msg); - if (!sender || !sender.frameId) return stylesTask; - return Promise.all([ - stylesTask, - getTab(sender.tab.id), - ]).then(([styles, tab]) => { - if (tab) styles.exposeIframes = tab.url.replace(/(\/\/[^/]*).*/, '$1'); - return styles; - }); - }; - const updateAPI = (_, enabled) => { - window.API_METHODS.getStylesForFrame = enabled ? getStylesForFrame : getStyles; - }; - prefs.subscribe(['exposeIframes'], updateAPI); - updateAPI(null, prefs.readOnlyValues.exposeIframes); } -// ************************************************************************* - -function webNavigationListener(method, {url, tabId, frameId}) { - Promise.all([ - getStyles({matchUrl: url, asHash: true}), - frameId && prefs.readOnlyValues.exposeIframes && getTab(tabId), - ]).then(([styles, tab]) => { - if (method && URLS.supported(url) && tabId >= 0) { - if (method === 'styleApply') { - handleCssTransitionBug({tabId, frameId, url, styles}); - } - if (tab) styles.exposeIframes = tab.url.replace(/(\/\/[^/]*).*/, '$1'); - sendMessage({ - tabId, - frameId, - method, - // ping own page so it retrieves the styles directly - styles: url.startsWith(URLS.ownOrigin) ? 'DIY' : styles, - }); - } - // main page frame id is 0 - if (frameId === 0) { - tabIcons.delete(tabId); - updateIcon({tab: {id: tabId, url}, styles}); - } - }); -} - - -function webNavigationListenerChrome(method, data) { - // Chrome 61.0.3161+ doesn't run content scripts on NTP - if ( - !data.url.startsWith('https://www.google.') || - !data.url.includes('/_/chrome/newtab?') - ) { - webNavigationListener(method, data); - return; - } - getTab(data.tabId).then(tab => { - if (tab.url === 'chrome://newtab/') { - data.url = tab.url; - } - webNavigationListener(method, data); - }); -} - - function webNavUsercssInstallerFF(data) { const {tabId} = data; Promise.all([ - sendMessage({tabId, method: 'ping'}), + msg.sendTab(tabId, {method: 'ping'}) + .catch(() => false), // we need tab index to open the installer next to the original one // and also to skip the double-invocation in FF which assigns tab url later getTab(tabId), ]).then(([pong, tab]) => { if (pong !== true && tab.url !== 'about:blank') { - window.API_METHODS.installUsercss({direct: true}, {tab}); + window.API_METHODS.openUsercssInstallPage({direct: true}, {tab}); } }); } @@ -379,135 +313,107 @@ function webNavUsercssInstallerFF(data) { function webNavIframeHelperFF({tabId, frameId}) { if (!frameId) return; - sendMessage({method: 'ping', tabId, frameId}, pong => { - ignoreChromeError(); - if (pong) return; - chrome.tabs.executeScript(tabId, { - frameId, - file: '/content/apply.js', - matchAboutBlank: true, - }, ignoreChromeError); + msg.sendTab(tabId, {method: 'ping'}, {frameId}) + .catch(() => false) + .then(pong => { + if (pong) return; + // insert apply.js to iframe + const files = chrome.runtime.getManifest().content_scripts[0].js; + for (const file of files) { + chrome.tabs.executeScript(tabId, { + frameId, + file, + matchAboutBlank: true, + }, ignoreChromeError); + } + }); +} + +function updateIconBadge(tabId, count) { + let tabIcon = tabIcons.get(tabId); + if (!tabIcon) tabIcons.set(tabId, (tabIcon = {})); + if (tabIcon.count === count) { + return; + } + const oldCount = tabIcon.count; + tabIcon.count = count; + refreshIconBadgeText(tabId, tabIcon); + if (Boolean(oldCount) !== Boolean(count)) { + refreshIcon(tabId, tabIcon); + } +} + +function refreshIconBadgeText(tabId, icon) { + iconUtil.setBadgeText({ + text: prefs.get('show-badge') && icon.count ? String(icon.count) : '', + tabId }); } +function refreshIcon(tabId, icon) { + const disableAll = prefs.get('disableAll'); + const iconset = prefs.get('iconset') === 1 ? 'light/' : ''; + const postfix = disableAll ? 'x' : !icon.count ? 'w' : ''; + const iconType = iconset + postfix; -function updateIcon({tab, styles}) { - if (tab.id < 0) { + if (icon.iconType === iconType) { return; } - if (URLS.chromeProtectsNTP && tab.url === 'chrome://newtab/') { - styles = {}; + icon.iconType = iconset + postfix; + const sizes = FIREFOX || CHROME >= 2883 && !VIVALDI ? [16, 32] : [19, 38]; + iconUtil.setIcon({ + path: sizes.reduce( + (obj, size) => { + obj[size] = `/images/icon/${iconset}${size}${postfix}.png`; + return obj; + }, + {} + ), + tabId + }); +} + +function refreshIconBadgeColor() { + const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'); + iconUtil.setBadgeBackgroundColor({ + color + }); +} + +function refreshAllIcons() { + for (const [tabId, icon] of tabIcons) { + refreshIcon(tabId, icon); } - if (styles) { - stylesReceived(styles); - return; - } - getTabRealURL(tab) - .then(url => getStyles({matchUrl: url, asHash: true})) - .then(stylesReceived); + refreshIcon(null, {}); // default icon +} - function stylesReceived(styles) { - const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll'); - const postfix = disableAll ? 'x' : !styles.length ? 'w' : ''; - const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal'); - const text = prefs.get('show-badge') && styles.length ? String(styles.length) : ''; - const iconset = ['', 'light/'][prefs.get('iconset')] || ''; - - let tabIcon = tabIcons.get(tab.id); - if (!tabIcon) tabIcons.set(tab.id, (tabIcon = {})); - - if (tabIcon.iconType !== iconset + postfix) { - tabIcon.iconType = iconset + postfix; - const sizes = FIREFOX || CHROME >= 2883 && !VIVALDI ? [16, 32] : [19, 38]; - const usePath = tabIcons.get('usePath'); - Promise.all(sizes.map(size => { - const src = `/images/icon/${iconset}${size}${postfix}.png`; - return usePath ? src : tabIcons.get(src) || loadIcon(src); - })).then(data => { - const imageKey = typeof data[0] === 'string' ? 'path' : 'imageData'; - const imageData = {}; - sizes.forEach((size, i) => (imageData[size] = data[i])); - chrome.browserAction.setIcon({ - tabId: tab.id, - [imageKey]: imageData, - }, ignoreChromeError); - }); - } - if (tab.id === undefined) return; - - let defaultIcon = tabIcons.get(undefined); - if (!defaultIcon) tabIcons.set(undefined, (defaultIcon = {})); - if (defaultIcon.color !== color) { - defaultIcon.color = color; - chrome.browserAction.setBadgeBackgroundColor({color}); - } - - if (tabIcon.text === text) return; - tabIcon.text = text; - try { - // Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320 - chrome.browserAction.setBadgeText({text, tabId: tab.id}, ignoreChromeError); - } catch (e) { - setTimeout(() => { - getTab(tab.id).then(realTab => { - // skip pre-rendered tabs - if (realTab.index >= 0) { - chrome.browserAction.setBadgeText({text, tabId: tab.id}); - } - }); - }); - } - } - - function loadIcon(src, resolve) { - if (!resolve) return new Promise(resolve => loadIcon(src, resolve)); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const img = new Image(); - img.src = src; - img.onload = () => { - const w = canvas.width = img.width; - const h = canvas.height = img.height; - ctx.clearRect(0, 0, w, h); - ctx.drawImage(img, 0, 0, w, h); - const data = ctx.getImageData(0, 0, w, h); - // Firefox breaks Canvas when privacy.resistFingerprinting=true, https://bugzil.la/1412961 - let usePath = tabIcons.get('usePath'); - if (usePath === undefined) { - usePath = data.data.every(b => b === 255); - tabIcons.set('usePath', usePath); - } - if (usePath) { - resolve(src); - return; - } - tabIcons.set(src, data); - resolve(data); - }; +function refreshAllIconsBadgeText() { + for (const [tabId, icon] of tabIcons) { + refreshIconBadgeText(tabId, icon); } } +function onRuntimeMessage(msg, sender) { + if (msg.method !== 'invokeAPI') { + return; + } + const fn = window.API_METHODS[msg.name]; + if (!fn) { + throw new Error(`unknown API: ${msg.name}`); + } + const context = {msg, sender}; + return fn.apply(context, msg.args); +} -function onRuntimeMessage(msg, sender, sendResponse) { - const fn = window.API_METHODS[msg.method]; - if (!fn) return; - - // wrap 'Error' object instance as {__ERROR__: message}, - // which will be unwrapped by sendMessage, - // and prevent exceptions on sending to a closed tab - const respond = data => - tryCatch(sendResponse, - data instanceof Error ? {__ERROR__: data.message} : data); - - const result = fn(msg, sender, respond); - if (result instanceof Promise) { - result - .catch(e => ({__ERROR__: e instanceof Error ? e.message : e})) - .then(respond); - return KEEP_CHANNEL_OPEN; - } else if (result === KEEP_CHANNEL_OPEN) { - return KEEP_CHANNEL_OPEN; - } else if (result !== undefined) { - respond(result); +// FIXME: popup.js also open editor but it doesn't use this API. +function openEditor({id}) { + let url = '/edit.html'; + if (id) { + url += `?id=${id}`; + } + if (chrome.windows && prefs.get('openEditInWindow')) { + chrome.windows.create(Object.assign({url}, prefs.get('windowPosition'))); + } else { + openURL({url}); } } diff --git a/background/db.js b/background/db.js new file mode 100644 index 00000000..57057553 --- /dev/null +++ b/background/db.js @@ -0,0 +1,154 @@ +/* global tryCatch chromeLocal ignoreChromeError */ +/* exported db */ +/* +Initialize a database. There are some problems using IndexedDB in Firefox: +https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/ + +Some of them are fixed in FF59: +https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/ +*/ +'use strict'; + +const db = (() => { + let exec; + const preparing = prepare(); + return { + exec: (...args) => + preparing.then(() => exec(...args)) + }; + + function prepare() { + // we use chrome.storage.local fallback if IndexedDB doesn't save data, + // which, once detected on the first run, is remembered in chrome.storage.local + // for reliablility and in localStorage for fast synchronous access + // (FF may block localStorage depending on its privacy options) + + // test localStorage + const fallbackSet = localStorage.dbInChromeStorage; + if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) { + useChromeStorage(); + return Promise.resolve(); + } + if (fallbackSet === 'false') { + useIndexedDB(); + return Promise.resolve(); + } + // test storage.local + return chromeLocal.get('dbInChromeStorage') + .then(data => + data && data.dbInChromeStorage && Promise.reject()) + .then(() => + tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) || + Promise.reject()) + .then(({target}) => ( + (target.result || [])[0] ? + Promise.reject('ok') : + dbExecIndexedDB('put', {id: -1}))) + .then(() => + dbExecIndexedDB('get', -1)) + .then(({target}) => ( + (target.result || {}).id === -1 ? + dbExecIndexedDB('delete', -1) : + Promise.reject())) + .then(() => + Promise.reject('ok')) + .catch(result => { + if (result === 'ok') { + useIndexedDB(); + } else { + useChromeStorage(); + } + }); + } + + function useChromeStorage() { + exec = dbExecChromeStorage; + chromeLocal.set({dbInChromeStorage: true}, ignoreChromeError); + localStorage.dbInChromeStorage = 'true'; + } + + function useIndexedDB() { + exec = dbExecIndexedDB; + chromeLocal.set({dbInChromeStorage: false}, ignoreChromeError); + localStorage.dbInChromeStorage = 'false'; + } + + function dbExecIndexedDB(method, ...args) { + return new Promise((resolve, reject) => { + Object.assign(indexedDB.open('stylish', 2), { + onsuccess(event) { + const database = event.target.result; + if (!method) { + resolve(database); + } else { + const transaction = database.transaction(['styles'], 'readwrite'); + const store = transaction.objectStore('styles'); + try { + Object.assign(store[method](...args), { + onsuccess: event => resolve(event, store, transaction, database), + onerror: reject, + }); + } catch (err) { + reject(err); + } + } + }, + onerror(event) { + console.warn(event.target.error || event.target.errorCode); + reject(event); + }, + onupgradeneeded(event) { + if (event.oldVersion === 0) { + event.target.result.createObjectStore('styles', { + keyPath: 'id', + autoIncrement: true, + }); + } + }, + }); + }); + } + + function dbExecChromeStorage(method, data) { + const STYLE_KEY_PREFIX = 'style-'; + switch (method) { + case 'get': + return chromeLocal.getValue(STYLE_KEY_PREFIX + data) + .then(result => ({target: {result}})); + + case 'put': + if (!data.id) { + return getAllStyles().then(styles => { + data.id = 1; + for (const style of styles) { + data.id = Math.max(data.id, style.id + 1); + } + return dbExecChromeStorage('put', data); + }); + } + return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data) + .then(() => (chrome.runtime.lastError ? Promise.reject() : data.id)); + + case 'delete': + return chromeLocal.remove(STYLE_KEY_PREFIX + data); + + case 'getAll': + return getAllStyles() + .then(styles => ({target: {result: styles}})); + } + return Promise.reject(); + + function getAllStyles() { + return chromeLocal.get(null).then(storage => { + const styles = []; + for (const key in storage) { + if (key.startsWith(STYLE_KEY_PREFIX) && + Number(key.substr(STYLE_KEY_PREFIX.length))) { + styles.push(storage[key]); + } + } + return styles; + }); + } + } +})(); diff --git a/background/icon-util.js b/background/icon-util.js new file mode 100644 index 00000000..ef7b2822 --- /dev/null +++ b/background/icon-util.js @@ -0,0 +1,91 @@ +/* global ignoreChromeError */ +/* exported iconUtil */ +'use strict'; + +const iconUtil = (() => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + // https://github.com/openstyles/stylus/issues/335 + let noCanvas; + const imageDataCache = new Map(); + // test if canvas is usable + const canvasReady = loadImage('/images/icon/16.png') + .then(imageData => { + noCanvas = imageData.data.every(b => b === 255); + }); + + return extendNative({ + /* + Cache imageData for paths + */ + setIcon, + setBadgeText + }); + + function loadImage(url) { + let result = imageDataCache.get(url); + if (!result) { + result = new Promise((resolve, reject) => { + const img = new Image(); + img.src = url; + img.onload = () => { + const w = canvas.width = img.width; + const h = canvas.height = img.height; + ctx.clearRect(0, 0, w, h); + ctx.drawImage(img, 0, 0, w, h); + resolve(ctx.getImageData(0, 0, w, h)); + }; + img.onerror = reject; + }); + imageDataCache.set(url, result); + } + return result; + } + + function setIcon(data) { + canvasReady.then(() => { + if (noCanvas) { + chrome.browserAction.setIcon(data, ignoreChromeError); + return; + } + const pending = []; + data.imageData = {}; + for (const [key, url] of Object.entries(data.path)) { + pending.push(loadImage(url) + .then(imageData => { + data.imageData[key] = imageData; + })); + } + Promise.all(pending).then(() => { + delete data.path; + chrome.browserAction.setIcon(data, ignoreChromeError); + }); + }); + } + + function setBadgeText(data) { + try { + // Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320 + chrome.browserAction.setBadgeText(data, ignoreChromeError); + } catch (e) { + // FIXME: skip pre-rendered tabs? + chrome.browserAction.setBadgeText(data); + } + } + + function extendNative(target) { + return new Proxy(target, { + get: (target, prop) => { + // FIXME: do we really need this? + if (!chrome.browserAction || + !['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) { + return () => {}; + } + if (target[prop]) { + return target[prop]; + } + return chrome.browserAction[prop].bind(chrome.browserAction); + } + }); + } +})(); diff --git a/background/navigator-util.js b/background/navigator-util.js new file mode 100644 index 00000000..ab08dffa --- /dev/null +++ b/background/navigator-util.js @@ -0,0 +1,75 @@ +/* global promisify CHROME URLS */ +/* exported navigatorUtil */ +'use strict'; + +const navigatorUtil = (() => { + const handler = { + urlChange: null + }; + const tabGet = promisify(chrome.tabs.get.bind(chrome.tabs)); + return extendNative({onUrlChange}); + + function onUrlChange(fn) { + initUrlChange(); + handler.urlChange.push(fn); + } + + function initUrlChange() { + if (handler.urlChange) { + return; + } + handler.urlChange = []; + + chrome.webNavigation.onCommitted.addListener(data => + fixNTPUrl(data) + .then(() => executeCallbacks(handler.urlChange, data, 'committed')) + .catch(console.error) + ); + + chrome.webNavigation.onHistoryStateUpdated.addListener(data => + fixNTPUrl(data) + .then(() => executeCallbacks(handler.urlChange, data, 'historyStateUpdated')) + .catch(console.error) + ); + + chrome.webNavigation.onReferenceFragmentUpdated.addListener(data => + fixNTPUrl(data) + .then(() => executeCallbacks(handler.urlChange, data, 'referenceFragmentUpdated')) + .catch(console.error) + ); + } + + function fixNTPUrl(data) { + if ( + !CHROME || + !URLS.chromeProtectsNTP || + !data.url.startsWith('https://www.google.') || + !data.url.includes('/_/chrome/newtab?') + ) { + return Promise.resolve(); + } + return tabGet(data.tabId) + .then(tab => { + if (tab.url === 'chrome://newtab/') { + data.url = tab.url; + } + }); + } + + function executeCallbacks(callbacks, data, type) { + for (const cb of callbacks) { + cb(data, type); + } + } + + function extendNative(target) { + return new Proxy(target, { + get: (target, prop) => { + if (target[prop]) { + return target[prop]; + } + return chrome.webNavigation[prop].addListener.bind(chrome.webNavigation[prop]); + } + }); + } +})(); diff --git a/background/parserlib-loader.js b/background/parserlib-loader.js deleted file mode 100644 index 8275e8fd..00000000 --- a/background/parserlib-loader.js +++ /dev/null @@ -1,9 +0,0 @@ -/* global importScripts parserlib CSSLint parseMozFormat */ -'use strict'; - -importScripts('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js'); -parserlib.css.Tokens[parserlib.css.Tokens.COMMENT].hide = false; - -self.onmessage = ({data}) => { - self.postMessage(parseMozFormat(data)); -}; diff --git a/background/refresh-all-tabs.js b/background/refresh-all-tabs.js deleted file mode 100644 index c7fcf3bb..00000000 --- a/background/refresh-all-tabs.js +++ /dev/null @@ -1,226 +0,0 @@ -/* -global API_METHODS cachedStyles -global getStyles filterStyles invalidateCache normalizeStyleSections -global updateIcon -*/ -'use strict'; - -(() => { - const previewFromTabs = new Map(); - - /** - * When style id and state is provided, only that style is propagated. - * Otherwise all styles are replaced and the toolbar icon is updated. - * @param {Object} [msg] - * @param {{id:Number, enabled?:Boolean, sections?: (Array|String)}} [msg.style] - - * style to propagate - * @param {Boolean} [msg.codeIsUpdated] - * @returns {Promise} - */ - API_METHODS.refreshAllTabs = (msg = {}) => - Promise.all([ - queryTabs(), - maybeParseUsercss(msg), - getStyles(), - ]).then(([tabs, style]) => - new Promise(resolve => { - if (style) msg.style.sections = normalizeStyleSections(style); - run(tabs, msg, resolve); - })); - - - function run(tabs, msg, resolve) { - const {style, codeIsUpdated, refreshOwnTabs} = msg; - - // the style was updated/saved so we need to remove the old copy of the original style - if (msg.method === 'styleUpdated' && msg.reason !== 'editPreview') { - for (const [tabId, original] of previewFromTabs.entries()) { - if (style.id === original.id) { - previewFromTabs.delete(tabId); - } - } - if (!previewFromTabs.size) { - unregisterTabListeners(); - } - } - - if (!style) { - msg = {method: 'styleReplaceAll'}; - - // live preview puts the code in cachedStyles, saves the original in previewFromTabs, - // and if preview is being disabled, but the style is already deleted, we bail out - } else if (msg.reason === 'editPreview' && !updateCache(msg)) { - return; - - // simple style update: - // * if disabled, apply.js will remove the element - // * if toggled and code is unchanged, apply.js will toggle the element - } else if (!style.enabled || codeIsUpdated === false) { - msg = { - method: 'styleUpdated', - reason: msg.reason, - style: { - id: style.id, - enabled: style.enabled, - }, - codeIsUpdated, - }; - - // live preview normal operation, the new code is already in cachedStyles - } else { - msg.method = 'styleApply'; - msg.style = {id: msg.style.id}; - } - - if (!tabs || !tabs.length) { - resolve(); - return; - } - - const last = tabs[tabs.length - 1]; - for (const tab of tabs) { - if (FIREFOX && !tab.width) continue; - if (refreshOwnTabs === false && tab.url.startsWith(URLS.ownOrigin)) continue; - chrome.webNavigation.getAllFrames({tabId: tab.id}, frames => - refreshFrame(tab, frames, msg, tab === last && resolve)); - } - } - - function refreshFrame(tab, frames, msg, resolve) { - ignoreChromeError(); - if (!frames || !frames.length) { - frames = [{ - frameId: 0, - url: tab.url, - }]; - } - msg.tabId = tab.id; - const styleId = msg.style && msg.style.id; - - for (const frame of frames) { - - const styles = filterStyles({ - matchUrl: getFrameUrl(frame, frames), - asHash: true, - id: styleId, - }); - - msg = Object.assign({}, msg); - msg.frameId = frame.frameId; - - if (msg.method !== 'styleUpdated') { - msg.styles = styles; - } - - if (msg.method === 'styleApply' && !styles.length) { - // remove the style from a previously matching frame - invokeOrPostpone(tab.active, sendMessage, { - method: 'styleUpdated', - reason: 'editPreview', - style: { - id: styleId, - enabled: false, - }, - tabId: tab.id, - frameId: frame.frameId, - }, ignoreChromeError); - } else { - invokeOrPostpone(tab.active, sendMessage, msg, ignoreChromeError); - } - - if (!frame.frameId) { - setTimeout(updateIcon, 0, { - tab, - styles: msg.method === 'styleReplaceAll' ? styles : undefined, - }); - } - } - - if (resolve) resolve(); - } - - - function getFrameUrl(frame, frames) { - while (frame.url === 'about:blank' && frame.frameId > 0) { - const parent = frames.find(f => f.frameId === frame.parentFrameId); - if (!parent) break; - frame.url = parent.url; - frame = parent; - } - return (frame || frames[0]).url; - } - - - function maybeParseUsercss({style}) { - if (style && typeof style.sections === 'string') { - return API_METHODS.parseUsercss({sourceCode: style.sections}); - } - } - - - function updateCache(msg) { - const {style, tabId, restoring} = msg; - const spoofed = !restoring && previewFromTabs.get(tabId); - const original = cachedStyles.byId.get(style.id); - - if (style.sections && !restoring) { - if (!previewFromTabs.size) { - registerTabListeners(); - } - if (!spoofed) { - previewFromTabs.set(tabId, Object.assign({}, original)); - } - - } else { - previewFromTabs.delete(tabId); - if (!previewFromTabs.size) { - unregisterTabListeners(); - } - if (!original) { - return; - } - if (!restoring) { - msg.style = spoofed || original; - } - } - invalidateCache({updated: msg.style}); - return true; - } - - - function registerTabListeners() { - chrome.tabs.onRemoved.addListener(onTabRemoved); - chrome.tabs.onReplaced.addListener(onTabReplaced); - chrome.webNavigation.onCommitted.addListener(onTabNavigated); - } - - - function unregisterTabListeners() { - chrome.tabs.onRemoved.removeListener(onTabRemoved); - chrome.tabs.onReplaced.removeListener(onTabReplaced); - chrome.webNavigation.onCommitted.removeListener(onTabNavigated); - } - - - function onTabRemoved(tabId) { - const style = previewFromTabs.get(tabId); - if (style) { - API_METHODS.refreshAllTabs({ - style, - tabId, - reason: 'editPreview', - restoring: true, - }); - } - } - - - function onTabReplaced(addedTabId, removedTabId) { - onTabRemoved(removedTabId); - } - - - function onTabNavigated({tabId}) { - onTabRemoved(tabId); - } -})(); diff --git a/background/search-db.js b/background/search-db.js index 9d5bece6..75318304 100644 --- a/background/search-db.js +++ b/background/search-db.js @@ -1,4 +1,4 @@ -/* global API_METHODS filterStyles cachedStyles */ +/* global API_METHODS styleManager tryRegExp debounce */ 'use strict'; (() => { @@ -25,7 +25,8 @@ if (/^url:/i.test(query)) { matchUrl = query.slice(query.indexOf(':') + 1).trim(); if (matchUrl) { - return filterStyles({matchUrl}).map(style => style.id); + return styleManager.getStylesByUrl(matchUrl) + .then(results => results.map(r => r.data.id)); } } if (query.startsWith('/') && /^\/(.+?)\/([gimsuy]*)$/.test(query)) { @@ -43,26 +44,29 @@ icase = words.some(w => w === lower(w)); } - const results = []; - for (const item of ids || cachedStyles.list) { - const id = isNaN(item) ? item.id : item; - if (!query || words && !words.length) { - results.push(id); - continue; + return styleManager.getAllStyles().then(styles => { + if (ids) { + const idSet = new Set(ids); + styles = styles.filter(s => idSet.has(s.id)); } - const style = isNaN(item) ? item : cachedStyles.byId.get(item); - if (!style) continue; - for (const part in PARTS) { - const text = style[part]; - if (text && PARTS[part](text, rx, words, icase)) { + const results = []; + for (const style of styles) { + const id = style.id; + if (!query || words && !words.length) { results.push(id); - break; + continue; + } + for (const part in PARTS) { + const text = style[part]; + if (text && PARTS[part](text, rx, words, icase)) { + results.push(id); + break; + } } } - } - - if (cache.size) debounce(clearCache, 60e3); - return results; + if (cache.size) debounce(clearCache, 60e3); + return results; + }); }; function searchText(text, rx, words, icase) { diff --git a/background/storage-dummy.js b/background/storage-dummy.js deleted file mode 100644 index 5a2de9b2..00000000 --- a/background/storage-dummy.js +++ /dev/null @@ -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); - } -})(); diff --git a/background/storage.js b/background/storage.js deleted file mode 100644 index 55c47590..00000000 --- a/background/storage.js +++ /dev/null @@ -1,847 +0,0 @@ -/* global getStyleWithNoCode styleSectionsEqual */ -'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 - 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 - }, -}; - -// eslint-disable-next-line no-var -var dbExec = dbExecIndexedDB; -dbExec.initialized = false; - -// we use chrome.storage.local fallback if IndexedDB doesn't save data, -// which, once detected on the first run, is remembered in chrome.storage.local -// for reliablility and in localStorage for fast synchronous access -// (FF may block localStorage depending on its privacy options) -do { - const done = () => { - cachedStyles.mutex.inProgress = false; - getStyles().then(() => { - dbExec.initialized = true; - window.dispatchEvent(new Event('storageReady')); - }); - }; - const fallback = () => { - dbExec = dbExecChromeStorage; - chromeLocal.set({dbInChromeStorage: true}); - localStorage.dbInChromeStorage = 'true'; - ignoreChromeError(); - done(); - }; - const fallbackSet = localStorage.dbInChromeStorage; - if (fallbackSet === 'true' || !tryCatch(() => indexedDB)) { - fallback(); - break; - } else if (fallbackSet === 'false') { - done(); - break; - } - chromeLocal.get('dbInChromeStorage') - .then(data => - data && data.dbInChromeStorage && Promise.reject()) - .then(() => - tryCatch(dbExecIndexedDB, 'getAllKeys', IDBKeyRange.lowerBound(1), 1) || - Promise.reject()) - .then(({target}) => ( - (target.result || [])[0] ? - Promise.reject('ok') : - dbExecIndexedDB('put', {id: -1}))) - .then(() => - dbExecIndexedDB('get', -1)) - .then(({target}) => ( - (target.result || {}).id === -1 ? - dbExecIndexedDB('delete', -1) : - Promise.reject())) - .then(() => - Promise.reject('ok')) - .catch(result => { - if (result === 'ok') { - chromeLocal.set({dbInChromeStorage: false}); - localStorage.dbInChromeStorage = 'false'; - done(); - } else { - fallback(); - } - }); -} while (0); - - -function dbExecIndexedDB(method, ...args) { - return new Promise((resolve, reject) => { - Object.assign(indexedDB.open('stylish', 2), { - onsuccess(event) { - const database = event.target.result; - if (!method) { - resolve(database); - } else { - const transaction = database.transaction(['styles'], 'readwrite'); - const store = transaction.objectStore('styles'); - Object.assign(store[method](...args), { - onsuccess: event => resolve(event, store, transaction, database), - onerror: reject, - }); - } - }, - onerror(event) { - console.warn(event.target.error || event.target.errorCode); - reject(event); - }, - onupgradeneeded(event) { - if (event.oldVersion === 0) { - event.target.result.createObjectStore('styles', { - keyPath: 'id', - autoIncrement: true, - }); - } - }, - }); - }); -} - - -function dbExecChromeStorage(method, data) { - const STYLE_KEY_PREFIX = 'style-'; - switch (method) { - case 'get': - return chromeLocal.getValue(STYLE_KEY_PREFIX + data) - .then(result => ({target: {result}})); - - case 'put': - if (!data.id) { - return getStyles().then(() => { - data.id = 1; - for (const style of cachedStyles.list) { - data.id = Math.max(data.id, style.id + 1); - } - return dbExecChromeStorage('put', data); - }); - } - return chromeLocal.setValue(STYLE_KEY_PREFIX + data.id, data) - .then(() => (chrome.runtime.lastError ? Promise.reject() : data.id)); - - case 'delete': - return chromeLocal.remove(STYLE_KEY_PREFIX + data); - - case 'getAll': - return chromeLocal.get(null).then(storage => { - const styles = []; - for (const key in storage) { - if (key.startsWith(STYLE_KEY_PREFIX) && - Number(key.substr(STYLE_KEY_PREFIX.length))) { - styles.push(storage[key]); - } - } - return {target: {result: styles}}; - }); - } - return Promise.reject(); -} - - -function getStyles(options) { - if (cachedStyles.list) { - return Promise.resolve(filterStyles(options)); - } - if (cachedStyles.mutex.inProgress) { - return new Promise(resolve => { - cachedStyles.mutex.onDone.push({options, resolve}); - }); - } - cachedStyles.mutex.inProgress = true; - - return dbExec('getAll').then(event => { - cachedStyles.list = event.target.result || []; - cachedStyles.list.forEach(fixUsoMd5Issue); - cachedStyles.byId.clear(); - for (const style of cachedStyles.list) { - cachedStyles.byId.set(style.id, style); - if (!style.name) { - style.name = 'ID: ' + style.id; - } - } - - cachedStyles.mutex.inProgress = false; - for (const {options, resolve} of cachedStyles.mutex.onDone) { - resolve(filterStyles(options)); - } - cachedStyles.mutex.onDone = []; - return filterStyles(options); - }); -} - - -function filterStyles({ - enabled = null, - id = null, - matchUrl = null, - md5Url = null, - asHash = null, - omitCode, - strictRegexp = true, // used by the popup to detect bad regexps -} = {}) { - if (id) id = Number(id); - if (asHash) enabled = true; - - if ( - enabled === null && - id === null && - matchUrl === null && - md5Url === null && - asHash !== true - ) { - return cachedStyles.list; - } - - if (matchUrl && !URLS.supported(matchUrl)) { - return asHash ? {length: 0} : []; - } - - const blankHash = asHash && { - length: 0, - disableAll: prefs.get('disableAll'), - exposeIframes: prefs.get('exposeIframes'), - }; - - // 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, - }); - } - 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; -} - - -// The md5Url provided by USO includes a duplicate "update" subdomain (see #523), -// This fixes any already installed styles containing this error -function fixUsoMd5Issue(style) { - if (style && style.md5Url && style.md5Url.includes('update.update.userstyles')) { - style.md5Url = style.md5Url.replace('update.update.userstyles', 'update.userstyles'); - } -} - -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, -}) { - 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)]; - if (!styles[0]) { - // may happen when users [accidentally] reopen an old URL - // of edit.html with a non-existent style id parameter - return asHash ? blankHash : []; - } - 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, - }); - if (asHash) { - if (sections.length) { - 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 saveStyle(style) { - const id = 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; - } - let existed; - let codeIsUpdated; - - fixUsoMd5Issue(style); - - return maybeCalcDigest() - .then(maybeImportFix) - .then(decide); - - function maybeCalcDigest() { - if (['install', 'update', 'update-digest'].includes(reason)) { - return calcStyleDigest(style).then(digest => { - style.originalDigest = digest; - }); - } - return Promise.resolve(); - } - - function maybeImportFix() { - if (reason === 'import') { - style.originalDigest = style.originalDigest || style.styleDigest; // TODO: remove in the future - delete style.styleDigest; // TODO: remove in the future - if (typeof style.originalDigest !== 'string' || style.originalDigest.length !== 40) { - delete style.originalDigest; - } - } - } - - function decide() { - if (id !== null) { - // Update or create - style.id = id; - return dbExec('get', id).then((event, store) => { - const oldStyle = event.target.result; - existed = Boolean(oldStyle); - if (reason === 'update-digest' && oldStyle.originalDigest === style.originalDigest) { - return style; - } - codeIsUpdated = !existed || 'sections' in style && !styleSectionsEqual(style, oldStyle); - style = Object.assign({installDate: Date.now()}, oldStyle, style); - return write(style, store); - }); - } else { - // Create - delete style.id; - style = Object.assign({ - // Set optional things if they're undefined - enabled: true, - updateUrl: null, - md5Url: null, - url: null, - originalMd5: null, - installDate: Date.now(), - }, style); - return write(style); - } - } - - function write(style, store) { - style.sections = normalizeStyleSections(style); - if (store) { - return new Promise(resolve => { - store.put(style).onsuccess = event => resolve(done(event)); - }); - } else { - return dbExec('put', style).then(done); - } - } - - function done(event) { - if (reason === 'update-digest') { - return style; - } - style.id = style.id || event.target.result; - invalidateCache(existed ? {updated: style} : {added: style}); - if (notify) { - notifyAllTabs({ - method: existed ? 'styleUpdated' : 'styleAdded', - style, codeIsUpdated, reason, - }); - } - return style; - } -} - - -function deleteStyle({id, notify = true}) { - id = Number(id); - return dbExec('delete', id).then(() => { - invalidateCache({deletedId: id}); - if (notify) { - notifyAllTabs({method: 'styleDeleted', id}); - } - return id; - }); -} - - -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], - // 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") -}) { - if (!skipUrlCheck && !URLS.supported(matchUrl)) { - 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, - }); - 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}) { - // retain known properties in an arbitrarily predefined order - return (sections || []).map(section => ({ - code: section.code || '', - urls: section.urls || [], - urlPrefixes: section.urlPrefixes || [], - domains: section.domains || [], - regexps: section.regexps || [], - })); -} - - -function calcStyleDigest(style) { - const jsonString = style.usercssData ? - style.sourceCode : JSON.stringify(normalizeStyleSections(style)); - const text = new TextEncoder('utf-8').encode(jsonString); - return crypto.subtle.digest('SHA-1', text).then(hex); - - function hex(buffer) { - const parts = []; - const PAD8 = '00000000'; - const view = new DataView(buffer); - for (let i = 0; i < view.byteLength; i += 4) { - parts.push((PAD8 + view.getUint32(i).toString(16)).slice(-8)); - } - return parts.join(''); - } -} - - -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. - Stylish-for-Chrome implemented it incorrectly since the very beginning. - We'll detect styles that abuse the bug by finding the sections that - would have been applied by Stylish but not by us as we follow the spec. - Additionally we'll check for invalid regexps. -*/ -function detectSloppyRegexps({matchUrl, ids}) { - const results = []; - 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}); - const wannabe = getApplicableSections({style, matchUrl, strictRegexp: false}); - results.push({ - id, - applied, - skipped: wannabe.length - applied.length, - hasInvalidRegexps: wannabe.some(({regexps}) => regexps.some(rx => !rxCache.has(rx))), - }); - } - return results; -} diff --git a/background/style-manager.js b/background/style-manager.js new file mode 100644 index 00000000..d4595f88 --- /dev/null +++ b/background/style-manager.js @@ -0,0 +1,492 @@ +/* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */ +/* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty + getStyleWithNoCode msg */ +/* exported styleManager */ +'use strict'; + +/* +This style manager is a layer between content script and the DB. When a style +is added/updated, it broadcast a message to content script and the content +script would try to fetch the new code. + +The live preview feature relies on `runtime.connect` and `port.onDisconnect` +to cleanup the temporary code. See /edit/live-preview.js. +*/ +const styleManager = (() => { + const preparing = prepare(); + + /* styleId => { + data: styleData, + preview: styleData, + appliesTo: Set + } */ + const styles = new Map(); + + /* url => { + maybeMatch: Set, + sections: Object { + id: styleId, + code: Array + }> + } */ + const cachedStyleForUrl = createCache({ + onDeleted: (url, cache) => { + for (const section of Object.values(cache.sections)) { + const style = styles.get(section.id); + if (style) { + style.appliesTo.delete(url); + } + } + } + }); + + const BAD_MATCHER = {test: () => false}; + const compileRe = createCompiler(text => `^(${text})$`); + const compileSloppyRe = createCompiler(text => `^${text}$`); + const compileExclusion = createCompiler(buildGlob); + + handleLivePreviewConnections(); + + return ensurePrepared({ + get, + getSectionsByUrl, + installStyle, + deleteStyle, + editSave, + findStyle, + importStyle, + toggleStyle, + setStyleExclusions, + getAllStyles, // used by import-export + getStylesByUrl, // used by popup + styleExists, + }); + + function handleLivePreviewConnections() { + chrome.runtime.onConnect.addListener(port => { + if (port.name !== 'livePreview') { + return; + } + let id; + port.onMessage.addListener(data => { + if (!id) { + id = data.id; + } + const style = styles.get(id); + style.preview = data; + broadcastStyleUpdated(style.preview, 'editPreview'); + }); + port.onDisconnect.addListener(() => { + port = null; + if (id) { + const style = styles.get(id); + if (!style) { + // maybe deleted + return; + } + style.preview = null; + broadcastStyleUpdated(style.data, 'editPreviewEnd'); + } + }); + }); + } + + function get(id, noCode = false) { + const data = styles.get(id).data; + return noCode ? getStyleWithNoCode(data) : data; + } + + function getAllStyles(noCode = false) { + const datas = [...styles.values()].map(s => s.data); + return noCode ? datas.map(getStyleWithNoCode) : datas; + } + + function toggleStyle(id, enabled) { + const style = styles.get(id); + const data = Object.assign({}, style.data, {enabled}); + return saveStyle(data) + .then(newData => handleSave(newData, 'toggle', false)) + .then(() => id); + } + + // used by install-hook-userstyles.js + function findStyle(filter, noCode = false) { + for (const style of styles.values()) { + if (filterMatch(filter, style.data)) { + return noCode ? getStyleWithNoCode(style.data) : style.data; + } + } + return null; + } + + function styleExists(filter) { + return [...styles.values()].some(s => filterMatch(filter, s.data)); + } + + function filterMatch(filter, target) { + for (const key of Object.keys(filter)) { + if (filter[key] !== target[key]) { + return false; + } + } + return true; + } + + function importStyle(data) { + // FIXME: is it a good idea to save the data directly? + return saveStyle(data) + .then(newData => handleSave(newData, 'import')); + } + + function installStyle(data, reason = null) { + const style = styles.get(data.id); + if (!style) { + data = Object.assign(createNewStyle(), data); + } else { + data = Object.assign({}, style.data, data); + } + if (!reason) { + reason = style ? 'update' : 'install'; + } + // FIXME: update updateDate? what about usercss config? + return calcStyleDigest(data) + .then(digest => { + data.originalDigest = digest; + return saveStyle(data); + }) + .then(newData => handleSave(newData, reason)); + } + + function editSave(data) { + const style = styles.get(data.id); + if (style) { + data = Object.assign({}, style.data, data); + } else { + data = Object.assign(createNewStyle(), data); + } + return saveStyle(data) + .then(newData => handleSave(newData, 'editSave')); + } + + function setStyleExclusions(id, exclusions) { + const data = Object.assign({}, styles.get(id).data, {exclusions}); + return saveStyle(data) + .then(newData => handleSave(newData, 'exclusions')); + } + + function deleteStyle(id) { + const style = styles.get(id); + return db.exec('delete', id) + .then(() => { + for (const url of style.appliesTo) { + const cache = cachedStyleForUrl.get(url); + if (cache) { + delete cache.sections[id]; + } + } + styles.delete(id); + return msg.broadcast({ + method: 'styleDeleted', + style: {id} + }); + }) + .then(() => id); + } + + function ensurePrepared(methods) { + const prepared = {}; + for (const [name, fn] of Object.entries(methods)) { + prepared[name] = (...args) => + preparing.then(() => fn(...args)); + } + return prepared; + } + + function createNewStyle() { + return { + enabled: true, + updateUrl: null, + md5Url: null, + url: null, + originalMd5: null, + installDate: Date.now() + }; + } + + function broadcastStyleUpdated(data, reason, method = 'styleUpdated', codeIsUpdated = true) { + const style = styles.get(data.id); + const excluded = new Set(); + const updated = new Set(); + for (const [url, cache] of cachedStyleForUrl.entries()) { + if (!style.appliesTo.has(url)) { + cache.maybeMatch.add(data.id); + continue; + } + const code = getAppliedCode(url, data); + if (!code) { + excluded.add(url); + delete cache.sections[data.id]; + } else { + updated.add(url); + cache.sections[data.id] = { + id: data.id, + code + }; + } + } + style.appliesTo = updated; + return msg.broadcast({ + method, + style: { + id: data.id, + enabled: data.enabled + }, + reason, + codeIsUpdated + }); + } + + function saveStyle(style) { + if (!style.name) { + throw new Error('style name is empty'); + } + if (style.id == null) { + delete style.id; + } + fixUsoMd5Issue(style); + return db.exec('put', style) + .then(event => { + if (style.id == null) { + style.id = event.target.result; + } + return style; + }); + } + + function handleSave(data, reason, codeIsUpdated) { + const style = styles.get(data.id); + let method; + if (!style) { + styles.set(data.id, { + appliesTo: new Set(), + data + }); + method = 'styleAdded'; + } else { + style.data = data; + method = 'styleUpdated'; + } + return broadcastStyleUpdated(data, reason, method, codeIsUpdated) + .then(() => data); + } + + // get styles matching a URL, including sloppy regexps and excluded items. + function getStylesByUrl(url, id = null) { + // FIXME: do we want to cache this? Who would like to open popup rapidly + // or search the DB with the same URL? + const result = []; + const datas = !id ? [...styles.values()].map(s => s.data) : + styles.has(id) ? [styles.get(id).data] : []; + for (const data of datas) { + let excluded = false; + let sloppy = false; + let sectionMatched = false; + const match = urlMatchStyle(url, data); + // TODO: enable this when the function starts returning false + // if (match === false) { + // continue; + // } + if (match === 'excluded') { + excluded = true; + } + for (const section of data.sections) { + if (styleCodeEmpty(section.code)) { + continue; + } + const match = urlMatchSection(url, section); + if (match) { + if (match === 'sloppy') { + sloppy = true; + } + sectionMatched = true; + break; + } + } + if (sectionMatched) { + result.push({ + data: getStyleWithNoCode(data), + excluded, + sloppy + }); + } + } + return result; + } + + function getSectionsByUrl(url, id) { + let cache = cachedStyleForUrl.get(url); + if (!cache) { + cache = { + sections: {}, + maybeMatch: new Set() + }; + buildCache(styles.values()); + cachedStyleForUrl.set(url, cache); + } else if (cache.maybeMatch.size) { + buildCache( + [...cache.maybeMatch] + .filter(i => styles.has(i)) + .map(i => styles.get(i)) + ); + } + if (id) { + if (cache.sections[id]) { + return {[id]: cache.sections[id]}; + } + return {}; + } + return cache.sections; + + function buildCache(styleList) { + for (const {appliesTo, data, preview} of styleList) { + const code = getAppliedCode(url, preview || data); + if (code) { + cache.sections[data.id] = { + id: data.id, + code + }; + appliesTo.add(url); + } + } + } + } + + function getAppliedCode(url, data) { + if (urlMatchStyle(url, data) !== true) { + return; + } + const code = []; + for (const section of data.sections) { + if (urlMatchSection(url, section) === true && !styleCodeEmpty(section.code)) { + code.push(section.code); + } + } + return code.length && code; + } + + function prepare() { + return db.exec('getAll').then(event => { + const styleList = event.target.result; + if (!styleList) { + return; + } + for (const style of styleList) { + fixUsoMd5Issue(style); + styles.set(style.id, { + appliesTo: new Set(), + data: style + }); + if (!style.name) { + style.name = 'ID: ' + style.id; + } + } + }); + } + + function urlMatchStyle(url, style) { + if (style.exclusions && style.exclusions.some(e => compileExclusion(e).test(url))) { + return 'excluded'; + } + if (!style.enabled) { + return 'disabled'; + } + return true; + } + + function urlMatchSection(url, section) { + const domain = getDomain(url); + if (section.domains && section.domains.some(d => d === domain || domain.endsWith(`.${d}`))) { + return true; + } + if (section.urlPrefixes && section.urlPrefixes.some(p => url.startsWith(p))) { + return true; + } + // 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; + } + if (section.regexps && section.regexps.some(r => compileRe(r).test(url))) { + return true; + } + /* + According to CSS4 @document specification the entire URL must match. + Stylish-for-Chrome implemented it incorrectly since the very beginning. + We'll detect styles that abuse the bug by finding the sections that + would have been applied by Stylish but not by us as we follow the spec. + */ + if (section.regexps && section.regexps.some(r => compileSloppyRe(r).test(url))) { + return 'sloppy'; + } + // TODO: check for invalid regexps? + if ( + (!section.regexps || !section.regexps.length) && + (!section.urlPrefixes || !section.urlPrefixes.length) && + (!section.urls || !section.urls.length) && + (!section.domains || !section.domains.length) + ) { + return true; + } + return false; + } + + function createCompiler(compile) { + // FIXME: FIFO cache doesn't work well here, if we want to match many + // regexps more than the cache size, we will never hit the cache because + // the first cache is deleted. So we use a simple map but it leaks memory. + const cache = new Map(); + return text => { + let re = cache.get(text); + if (!re) { + re = tryRegExp(compile(text)); + if (!re) { + re = BAD_MATCHER; + } + cache.set(text, re); + } + return re; + }; + } + + function buildGlob(text) { + const prefix = text[0] === '^' ? '' : '\\b'; + const suffix = text[text.length - 1] === '$' ? '' : '\\b'; + return `${prefix}${escape(text)}${suffix}`; + + function escape(text) { + // FIXME: using .* everywhere is slow + return text.replace(/[.*]/g, m => m === '.' ? '\\.' : '.*'); + } + } + + function getDomain(url) { + return url.match(/^[\w-]+:\/+(?:[\w:-]+@)?([^:/#]+)/)[1]; + } + + function getUrlNoHash(url) { + return url.split('#')[0]; + } + + // The md5Url provided by USO includes a duplicate "update" subdomain (see #523), + // This fixes any already installed styles containing this error + function fixUsoMd5Issue(style) { + if (style && style.md5Url && style.md5Url.includes('update.update.userstyles')) { + style.md5Url = style.md5Url.replace('update.update.userstyles', 'update.userstyles'); + } + } +})(); diff --git a/background/style-via-api.js b/background/style-via-api.js index 53b98345..79f5c289 100644 --- a/background/style-via-api.js +++ b/background/style-via-api.js @@ -1,4 +1,4 @@ -/* global getStyles API_METHODS */ +/* global API_METHODS styleManager CHROME prefs updateIconBadge */ 'use strict'; API_METHODS.styleViaAPI = !CHROME && (() => { @@ -9,6 +9,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { styleAdded, styleReplaceAll, prefChanged, + updateCount, }; const NOP = Promise.resolve(new Error('NOP')); const onError = () => {}; @@ -22,15 +23,23 @@ API_METHODS.styleViaAPI = !CHROME && (() => { let observingTabs = false; - return (request, sender) => { - const action = ACTIONS[request.action]; + return function (request) { + const action = ACTIONS[request.method]; return !action ? NOP : - action(request, sender) + action(request, this.sender) .catch(onError) .then(maybeToggleObserver); }; - function styleApply({id = null, ignoreUrlCheck}, {tab, frameId, url}) { + function updateCount(request, {tab, frameId}) { + if (frameId) { + throw new Error('we do not count styles for frames'); + } + const {frameStyles} = getCachedData(tab.id, frameId); + updateIconBadge(tab.id, Object.keys(frameStyles).length); + } + + function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) { if (prefs.get('disableAll')) { return NOP; } @@ -38,24 +47,15 @@ API_METHODS.styleViaAPI = !CHROME && (() => { if (id === null && !ignoreUrlCheck && frameStyles.url === url) { return NOP; } - return getStyles({id, matchUrl: url, asHash: true}).then(styles => { + return styleManager.getSectionsByUrl(url, id).then(sections => { const tasks = []; - for (const styleId in styles) { - if (isNaN(parseInt(styleId))) { - continue; - } - // shallow-extract code from the sections array in order to reuse references - // in other places whereas the combined string gets garbage-collected - const styleSections = styles[styleId].map(section => section.code); - const code = styleSections.join('\n'); - if (!code) { - delete frameStyles[styleId]; - continue; - } + for (const section of Object.values(sections)) { + const styleId = section.id; + const code = section.code.join('\n'); if (code === (frameStyles[styleId] || []).join('\n')) { continue; } - frameStyles[styleId] = styleSections; + frameStyles[styleId] = section.code; tasks.push( browser.tabs.insertCSS(tab.id, { code, @@ -70,16 +70,18 @@ API_METHODS.styleViaAPI = !CHROME && (() => { cache.set(tab.id, tabFrames); } return Promise.all(tasks); - }); + }) + .then(() => updateCount(null, {tab, frameId})); } - function styleDeleted({id}, {tab, frameId}) { + function styleDeleted({style: {id}}, {tab, frameId}) { const {tabFrames, frameStyles, styleSections} = getCachedData(tab.id, frameId, id); const code = styleSections.join('\n'); if (code && !duplicateCodeExists({frameStyles, id, code})) { delete frameStyles[id]; removeFrameIfEmpty(tab.id, frameId, tabFrames, frameStyles); - return removeCSS(tab.id, frameId, code); + return removeCSS(tab.id, frameId, code) + .then(() => updateCount(null, {tab, frameId})); } else { return NOP; } @@ -87,7 +89,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { function styleUpdated({style}, sender) { if (!style.enabled) { - return styleDeleted(style, sender); + return styleDeleted({style}, sender); } const {tab, frameId} = sender; const {frameStyles, styleSections} = getCachedData(tab.id, frameId, style.id); diff --git a/background/update.js b/background/update.js index 8426035e..7f5b60ce 100644 --- a/background/update.js +++ b/background/update.js @@ -1,9 +1,7 @@ -/* -global getStyles saveStyle styleSectionsEqual -global calcStyleDigest cachedStyles getStyleWithNoCode -global usercss semverCompare -global API_METHODS -*/ +/* global styleSectionsEqual prefs download tryJSONparse ignoreChromeError + calcStyleDigest getStyleWithNoCode debounce chromeLocal + usercss semverCompare + API_METHODS styleManager */ 'use strict'; (() => { @@ -51,7 +49,7 @@ global API_METHODS checkingAll = true; retrying.clear(); const port = observe && chrome.runtime.connect({name: 'updater'}); - return getStyles({}).then(styles => { + return styleManager.getAllStyles().then(styles => { styles = styles.filter(style => style.updateUrl); if (port) port.postMessage({count: styles.length}); log(''); @@ -70,7 +68,7 @@ global API_METHODS function checkStyle({ id, - style = cachedStyles.byId.get(id), + style, port, save = true, ignoreDigest, @@ -89,14 +87,33 @@ global API_METHODS 'ignoreDigest' option is set on the second manual individual update check on the manage page. */ - return Promise.resolve(style) - .then([calcStyleDigest][!ignoreDigest ? 0 : 'skip']) - .then([checkIfEdited][!ignoreDigest ? 0 : 'skip']) - .then([maybeUpdateUSO, maybeUpdateUsercss][style.usercssData ? 1 : 0]) + return fetchStyle() + .then(() => { + if (!ignoreDigest) { + return calcStyleDigest(style) + .then(checkIfEdited); + } + }) + .then(() => { + if (style.usercssData) { + return maybeUpdateUsercss(); + } + return maybeUpdateUSO(); + }) .then(maybeSave) .then(reportSuccess) .catch(reportFailure); + function fetchStyle() { + if (style) { + return Promise.resolve(); + } + return styleManager.get(id) + .then(style_ => { + style = style_; + }); + } + function reportSuccess(saved) { log(STATES.UPDATED + ` #${style.id} ${style.name}`); const info = {updated: true, style: saved}; @@ -145,24 +162,25 @@ global API_METHODS function maybeUpdateUsercss() { // TODO: when sourceCode is > 100kB use http range request(s) for version check - return download(style.updateUrl).then(text => { - const json = usercss.buildMeta(text); - const {usercssData: {version}} = style; - const {usercssData: {version: newVersion}} = json; - switch (Math.sign(semverCompare(version, newVersion))) { - case 0: - // re-install is invalid in a soft upgrade - if (!ignoreDigest) { - const sameCode = text === style.sourceCode; - return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); - } - break; - case 1: - // downgrade is always invalid - return Promise.reject(STATES.ERROR_VERSION); - } - return usercss.buildCode(json); - }); + return download(style.updateUrl).then(text => + usercss.buildMeta(text).then(json => { + const {usercssData: {version}} = style; + const {usercssData: {version: newVersion}} = json; + switch (Math.sign(semverCompare(version, newVersion))) { + case 0: + // re-install is invalid in a soft upgrade + if (!ignoreDigest) { + const sameCode = text === style.sourceCode; + return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); + } + break; + case 1: + // downgrade is always invalid + return Promise.reject(STATES.ERROR_VERSION); + } + return usercss.buildCode(json); + }) + ); } function maybeSave(json = {}) { @@ -173,7 +191,6 @@ global API_METHODS json.id = style.id; json.updateDate = Date.now(); - json.reason = 'update'; // keep current state delete json.enabled; @@ -185,10 +202,10 @@ global API_METHODS json.originalName = json.name; } + const newStyle = Object.assign({}, style, json); if (styleSectionsEqual(json, style, {checkSource: true})) { // update digest even if save === false as there might be just a space added etc. - json.reason = 'update-digest'; - return saveStyle(json) + return styleManager.installStyle(newStyle) .then(saved => { style.originalDigest = saved.originalDigest; return Promise.reject(STATES.SAME_CODE); @@ -200,8 +217,8 @@ global API_METHODS } return save ? - API_METHODS[json.usercssData ? 'saveUsercss' : 'saveStyle'](json) : - json; + API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) : + newStyle; } function styleJSONseemsValid(json) { diff --git a/background/usercss-helper.js b/background/usercss-helper.js index c2f2fe84..f1c6419c 100644 --- a/background/usercss-helper.js +++ b/background/usercss-helper.js @@ -1,13 +1,15 @@ -/* global API_METHODS usercss saveStyle getStyles chromeLocal cachedStyles */ +/* global API_METHODS usercss chromeLocal styleManager FIREFOX deepCopy openURL + download */ 'use strict'; (() => { + API_METHODS.installUsercss = installUsercss; + API_METHODS.editSaveUsercss = editSaveUsercss; + API_METHODS.configUsercssVars = configUsercssVars; - API_METHODS.saveUsercss = style => save(style, false); - API_METHODS.saveUsercssUnsafe = style => save(style, true); API_METHODS.buildUsercss = build; - API_METHODS.installUsercss = install; - API_METHODS.parseUsercss = parse; + API_METHODS.openUsercssInstallPage = install; + API_METHODS.findUsercss = find; const TEMP_CODE_PREFIX = 'tempUsercssCode'; @@ -40,69 +42,96 @@ if (style.usercssData) { return Promise.resolve(style); } - try { - const {sourceCode} = style; - // allow sourceCode to be normalized - delete style.sourceCode; - return Promise.resolve(Object.assign(usercss.buildMeta(sourceCode), style)); - } catch (e) { - return Promise.reject(e); - } + + // allow sourceCode to be normalized + const {sourceCode} = style; + delete style.sourceCode; + + return usercss.buildMeta(sourceCode) + .then(newStyle => Object.assign(newStyle, style)); } function assignVars(style) { - if (style.reason === 'config' && style.id) { - return style; - } - const dup = find(style); - if (dup) { - style.id = dup.id; - if (style.reason !== 'config') { - // preserve style.vars during update - usercss.assignVars(style, dup); - } - } - return style; + return find(style) + .then(dup => { + if (dup) { + style.id = dup.id; + // preserve style.vars during update + return usercss.assignVars(style, dup) + .then(() => style); + } + return style; + }); } /** - * Parse the source and find the duplication + * Parse the source, find the duplication, and build sections with variables * @param _ * @param {String} _.sourceCode * @param {Boolean=} _.checkDup * @param {Boolean=} _.metaOnly + * @param {Object} _.vars + * @param {Boolean=} _.assignVars * @returns {Promise<{style, dup:Boolean?}>} */ function build({ sourceCode, checkDup, metaOnly, + vars, + assignVars = false, }) { - const task = buildMeta({sourceCode}); - return (metaOnly ? task : task.then(usercss.buildCode)) - .then(style => ({ - style, - dup: checkDup && find(style), - })); - } + return usercss.buildMeta(sourceCode) + .then(style => { + const findDup = checkDup || assignVars ? find(style) : null; + return Promise.all([ + metaOnly ? style : doBuild(style, findDup), + findDup + ]); + }) + .then(([style, dup]) => ({style, dup})); - // Parse the source, apply customizations, report fatal/syntax errors - function parse(style, allowErrors = false) { - // restore if stripped by getStyleWithNoCode - if (typeof style.sourceCode !== 'string') { - style.sourceCode = cachedStyles.byId.get(style.id).sourceCode; + function doBuild(style, findDup) { + if (vars || assignVars) { + const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup; + return getOld + .then(oldStyle => usercss.assignVars(style, oldStyle)) + .then(() => usercss.buildCode(style)); + } + return usercss.buildCode(style); } - return buildMeta(style) - .then(assignVars) - .then(style => usercss.buildCode(style, allowErrors)); } - function save(style, allowErrors = false) { - return parse(style, allowErrors) - .then(result => - allowErrors ? - saveStyle(result.style).then(style => ({style, errors: result.errors})) : - saveStyle(result)); + // Build the style within aditional properties then inherit variable values + // from the old style. + function parse(style) { + return buildMeta(style) + .then(buildMeta) + .then(assignVars) + .then(usercss.buildCode); + } + + // FIXME: simplify this to `installUsercss(sourceCode)`? + function installUsercss(style) { + return parse(style) + .then(styleManager.installStyle); + } + + // FIXME: simplify this to `editSaveUsercss({sourceCode, exclusions})`? + function editSaveUsercss(style) { + return parse(style) + .then(styleManager.editSave); + } + + function configUsercssVars(id, vars) { + return styleManager.get(id) + .then(style => { + const newStyle = deepCopy(style); + newStyle.usercssData.vars = vars; + return usercss.buildCode(newStyle); + }) + .then(style => styleManager.installStyle(style, 'config')) + .then(style => style.usercssData.vars); } /** @@ -110,19 +139,23 @@ * @returns {Style} */ function find(styleOrData) { - if (styleOrData.id) return cachedStyles.byId.get(styleOrData.id); - const {name, namespace} = styleOrData.usercssData || styleOrData; - for (const dup of cachedStyles.list) { - const data = dup.usercssData; - if (!data) continue; - if (data.name === name && - data.namespace === namespace) { - return dup; - } + if (styleOrData.id) { + return styleManager.get(styleOrData.id); } + const {name, namespace} = styleOrData.usercssData || styleOrData; + return styleManager.getAllStyles().then(styleList => { + for (const dup of styleList) { + const data = dup.usercssData; + if (!data) continue; + if (data.name === name && + data.namespace === namespace) { + return dup; + } + } + }); } - function install({url, direct, downloaded, tab}, sender) { + function install({url, direct, downloaded, tab}, sender = this.sender) { tab = tab !== undefined ? tab : sender.tab; url = url || tab.url; if (direct && !downloaded) { diff --git a/content/apply.js b/content/apply.js index d18b0a22..b118d363 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,44 +1,128 @@ /* eslint no-var: 0 */ +/* global msg API prefs */ +/* exported APPLY */ 'use strict'; -(() => { - if (typeof window.applyOnMessage === 'function') { - // some weird bug in new Chrome: the content script gets injected multiple times - return; - } +// some weird bug in new Chrome: the content script gets injected multiple times +// define a constant so it throws when redefined +const APPLY = (() => { const CHROME = chrome.app ? parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]) : NaN; + const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; var ID_PREFIX = 'stylus-'; - var ROOT = document.documentElement; + var ROOT; var isOwnPage = location.protocol.endsWith('-extension:'); var disableAll = false; - var exposeIframes = false; var styleElements = new Map(); var disabledElements = new Map(); - var retiredStyleTimers = new Map(); var docRewriteObserver; var docRootObserver; + const setStyleContent = createSetStyleContent(); + const initializing = init(); - // FF59+ bug workaround - // See https://github.com/openstyles/stylus/issues/461 - // Since it's easy to spoof the browser version in pre-Quantum FF we're checking - // for getPreventDefault which got removed in FF59 https://bugzil.la/691151 - const FF_BUG461 = !CHROME && !isOwnPage && !Event.prototype.getPreventDefault; - const pageContextQueue = []; - - requestStyles(); - chrome.runtime.onMessage.addListener(applyOnMessage); - window.applyOnMessage = applyOnMessage; + msg.onTab(applyOnMessage); if (!isOwnPage) { - window.dispatchEvent(new CustomEvent(chrome.runtime.id)); + window.dispatchEvent(new CustomEvent(chrome.runtime.id, { + detail: pageObject({method: 'orphan'}) + })); window.addEventListener(chrome.runtime.id, orphanCheck, true); } - function requestStyles(options, callback = applyStyles) { - if (!chrome.app && document instanceof XMLDocument) { - chrome.runtime.sendMessage({method: 'styleViaAPI', action: 'styleApply'}); - return; + let parentDomain; + + prefs.subscribe(['disableAll'], (key, value) => doDisableAll(value)); + if (window !== parent) { + prefs.subscribe(['exposeIframes'], updateExposeIframes); + } + + function init() { + if (STYLE_VIA_API) { + return API.styleViaAPI({method: 'styleApply'}); } + return API.getSectionsByUrl(getMatchUrl()) + .then(result => { + ROOT = document.documentElement; + applyStyles(result, () => { + // CSS transition bug workaround: since we insert styles asynchronously, + // the browsers, especially Firefox, may apply all transitions on page load + if ([...styleElements.values()].some(n => n.textContent.includes('transition'))) { + applyTransitionPatch(); + } + }); + }); + } + + function pageObject(target) { + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts + const obj = new window.Object(); + Object.assign(obj, target); + return obj; + } + + function createSetStyleContent() { + // FF59+ bug workaround + // See https://github.com/openstyles/stylus/issues/461 + // Since it's easy to spoof the browser version in pre-Quantum FF we're checking + // for getPreventDefault which got removed in FF59 https://bugzil.la/691151 + const EVENT_NAME = chrome.runtime.id; + const usePageScript = CHROME || isOwnPage || Event.prototype.getPreventDefault ? + Promise.resolve(false) : injectPageScript(); + return (el, content) => + usePageScript.then(ok => { + if (!ok) { + const disabled = el.disabled; + el.textContent = content; + el.disabled = disabled; + } else { + const detail = pageObject({ + method: 'setStyleContent', + id: el.id, + content + }); + window.dispatchEvent(new CustomEvent(EVENT_NAME, {detail})); + } + }); + + function injectPageScript() { + const scriptContent = EVENT_NAME => { + document.currentScript.remove(); + window.addEventListener(EVENT_NAME, function handler(e) { + const {method, id, content} = e.detail; + if (method === 'setStyleContent') { + const el = document.getElementById(id); + if (!el) { + return; + } + const disabled = el.disabled; + el.textContent = content; + el.disabled = disabled; + } else if (method === 'orphan') { + window.removeEventListener(EVENT_NAME, handler); + } + }, true); + }; + const code = `(${scriptContent})(${JSON.stringify(EVENT_NAME)})`; + const src = `data:application/javascript;base64,${btoa(code)}`; + const script = document.createElement('script'); + const {resolve, promise} = deferred(); + script.src = src; + script.onload = () => resolve(true); + script.onerror = () => resolve(false); + document.documentElement.appendChild(script); + return promise; + } + } + + function deferred() { + const o = {}; + o.promise = new Promise((resolve, reject) => { + o.resolve = resolve; + o.reject = reject; + }); + return o; + } + + function getMatchUrl() { var matchUrl = location.href; if (!matchUrl.match(/^(http|file|chrome|ftp)/)) { // dynamic about: and javascript: iframes don't have an URL yet @@ -49,78 +133,38 @@ } } catch (e) {} } - const request = Object.assign({ - method: 'getStylesForFrame', - asHash: true, - matchUrl, - }, options); - // On own pages we request the styles directly to minimize delay and flicker - if (typeof API === 'function') { - API.getStyles(request).then(callback); - } else if (!CHROME && getStylesFallback(request)) { - // NOP - } else { - chrome.runtime.sendMessage(request, callback); - } + return matchUrl; } - /** - * TODO: remove when FF fixes the bug. - * Firefox borks sendMessage in same-origin iframes that have 'src' with a real path on the site. - * We implement a workaround for the initial styleApply case only. - * Everything else (like toggling of styles) is still buggy. - * @param {Object} msg - * @param {Function} callback - * @returns {Boolean|undefined} - */ - function getStylesFallback(msg) { - if (window !== parent && - location.href !== 'about:blank') { - try { - if (parent.location.origin === location.origin && - parent.location.href !== location.href) { - chrome.runtime.connect({name: 'getStyles:' + JSON.stringify(msg)}); - return true; - } - } catch (e) {} + function applyOnMessage(request) { + if (request.method === 'ping') { + return true; } - } - - function applyOnMessage(request, sender, sendResponse) { - if (request.styles === 'DIY') { - // Do-It-Yourself tells our built-in pages to fetch the styles directly - // which is faster because IPC messaging JSON-ifies everything internally - requestStyles({}, styles => { - request.styles = styles; - applyOnMessage(request); - }); - return; - } - - if (!chrome.app && document instanceof XMLDocument && request.method !== 'ping') { - request.action = request.method; - request.method = 'styleViaAPI'; - request.styles = null; - if (request.style) { - request.style.sections = null; + if (STYLE_VIA_API) { + if (request.method === 'urlChanged') { + request.method = 'styleReplaceAll'; } - chrome.runtime.sendMessage(request); + API.styleViaAPI(request); return; } switch (request.method) { case 'styleDeleted': - removeStyle(request); + removeStyle(request.style); break; case 'styleUpdated': if (request.codeIsUpdated === false) { applyStyleState(request.style); - break; - } - if (request.style.enabled) { - removeStyle({id: request.style.id, retire: true}); - requestStyles({id: request.style.id}); + } else if (request.style.enabled) { + API.getSectionsByUrl(getMatchUrl(), request.style.id) + .then(sections => { + if (!sections[request.style.id]) { + removeStyle(request.style); + } else { + applyStyles(sections); + } + }); } else { removeStyle(request.style); } @@ -128,29 +172,28 @@ case 'styleAdded': if (request.style.enabled) { - requestStyles({id: request.style.id}); + API.getSectionsByUrl(getMatchUrl(), request.style.id) + .then(applyStyles); } break; - case 'styleApply': - applyStyles(request.styles); + case 'urlChanged': + API.getSectionsByUrl(getMatchUrl()) + .then(replaceAll); break; - case 'styleReplaceAll': - replaceAll(request.styles); + case 'backgroundReady': + initializing + .catch(err => { + if (msg.RX_NO_RECEIVER.test(err.message)) { + return init(); + } + }) + .catch(console.error); break; - case 'prefChanged': - if ('disableAll' in request.prefs) { - doDisableAll(request.prefs.disableAll); - } - if ('exposeIframes' in request.prefs) { - doExposeIframes(request.prefs.exposeIframes); - } - break; - - case 'ping': - sendResponse(true); + case 'updateCount': + updateCount(); break; } } @@ -160,27 +203,63 @@ return; } disableAll = disable; - Array.prototype.forEach.call(document.styleSheets, stylesheet => { - if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`) - && stylesheet.disabled !== disable) { - stylesheet.disabled = disable; - } - }); + if (STYLE_VIA_API) { + API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}}); + } else { + Array.prototype.forEach.call(document.styleSheets, stylesheet => { + if (stylesheet.ownerNode.matches(`style.stylus[id^="${ID_PREFIX}"]`) + && stylesheet.disabled !== disable) { + stylesheet.disabled = disable; + } + }); + } } - function doExposeIframes(state = exposeIframes) { - if (state === exposeIframes || - state === true && typeof exposeIframes === 'string' || - window === parent) { + function fetchParentDomain() { + if (parentDomain) { + return Promise.resolve(); + } + return API.getTabUrlPrefix() + .then(newDomain => { + parentDomain = newDomain; + }); + } + + function updateExposeIframes() { + if (!prefs.get('exposeIframes') || window === parent || !styleElements.size) { + document.documentElement.removeAttribute('stylus-iframe'); + } else { + fetchParentDomain().then(() => { + document.documentElement.setAttribute('stylus-iframe', parentDomain); + }); + } + } + + function updateCount() { + if (window !== parent) { + // we don't care about iframes return; } - exposeIframes = state; - const attr = document.documentElement.getAttribute('stylus-iframe'); - if (state && state !== attr) { - document.documentElement.setAttribute('stylus-iframe', state); - } else if (!state && attr !== undefined) { - document.documentElement.removeAttribute('stylus-iframe'); + if (/^\w+?-extension:\/\/.+(popup|options)\.html$/.test(location.href)) { + // popup and the option page are not tabs + return; } + if (STYLE_VIA_API) { + API.styleViaAPI({method: 'updateCount'}).catch(msg.ignoreError); + return; + } + let count = 0; + for (const id of styleElements.keys()) { + if (!disabledElements.has(id)) { + count++; + } + } + // we have to send the tabId so we can't use `sendBg` that is used by `API` + msg.send({ + method: 'invokeAPI', + name: 'updateIconBadge', + args: [count] + }).catch(msg.ignoreError); } function applyStyleState({id, enabled}) { @@ -193,7 +272,8 @@ addStyleElement(inCache); disabledElements.delete(id); } else { - requestStyles({id}); + return API.getSectionsByUrl(getMatchUrl(), id) + .then(applyStyles); } } else { if (inDoc) { @@ -201,32 +281,25 @@ docRootObserver.evade(() => inDoc.remove()); } } + updateCount(); } - function removeStyle({id, retire = false}) { + function removeStyle({id}) { const el = document.getElementById(ID_PREFIX + id); if (el) { - if (retire) { - // to avoid page flicker when the style is updated - // instead of removing it immediately we rename its ID and queue it - // to be deleted in applyStyles after a new version is fetched and applied - const deadID = id + '-ghost'; - el.id = ID_PREFIX + deadID; - // in case something went wrong and new style was never applied - retiredStyleTimers.set(deadID, setTimeout(removeStyle, 1000, {id: deadID})); - } else { - docRootObserver.evade(() => el.remove()); - } + docRootObserver.evade(() => el.remove()); } - styleElements.delete(ID_PREFIX + id); disabledElements.delete(id); - retiredStyleTimers.delete(id); + if (styleElements.delete(id)) { + updateCount(); + } } - function applyStyles(styles) { - if (!styles) { - // Chrome is starting up - requestStyles(); + function applyStyles(sections, done) { + if (!Object.keys(sections).length) { + if (done) { + done(); + } return; } @@ -234,72 +307,40 @@ new MutationObserver((mutations, observer) => { if (document.documentElement) { observer.disconnect(); - applyStyles(styles); + applyStyles(sections, done); } }).observe(document, {childList: true}); return; } - if ('disableAll' in styles) { - doDisableAll(styles.disableAll); + if (docRootObserver) { + docRootObserver.stop(); + } else { + initDocRootObserver(); } - if ('exposeIframes' in styles) { - doExposeIframes(styles.exposeIframes); - } - - const gotNewStyles = styles.length || styles.needTransitionPatch; - if (gotNewStyles) { - if (docRootObserver) { - docRootObserver.stop(); - } else { - initDocRootObserver(); - } - } - - if (styles.needTransitionPatch) { - applyTransitionPatch(); - } - - if (gotNewStyles) { - for (const id in styles) { - const sections = styles[id]; - if (!Array.isArray(sections)) continue; - applySections(id, sections.map(({code}) => code).join('\n')); - } - docRootObserver.firstStart(); - } - - if (FF_BUG461 && (gotNewStyles || styles.needTransitionPatch)) { - setContentsInPageContext(); + const pending = []; + for (const section of Object.values(sections)) { + pending.push(applySections(section.id, section.code.join(''))); } + docRootObserver.firstStart(); if (!isOwnPage && !docRewriteObserver && styleElements.size) { initDocRewriteObserver(); } - if (retiredStyleTimers.size) { - setTimeout(() => { - for (const [id, timer] of retiredStyleTimers.entries()) { - removeStyle({id}); - clearTimeout(timer); - } - }); + updateExposeIframes(); + updateCount(); + if (done) { + Promise.all(pending).then(done); } } - function applySections(styleId, code) { - const id = ID_PREFIX + styleId; - let el = styleElements.get(id) || document.getElementById(id); - if (el && el.textContent !== code) { - if (CHROME < 3321) { - // workaround for Chrome devtools bug fixed in v65 - el.remove(); - el = null; - } else if (FF_BUG461) { - pageContextQueue.push({id: el.id, el, code}); - } else { - el.textContent = code; - } + function applySections(id, code) { + let el = styleElements.get(id) || document.getElementById(ID_PREFIX + id); + if (el && CHROME < 3321) { + // workaround for Chrome devtools bug fixed in v65 + el.remove(); + el = null; } if (!el) { if (document.documentElement instanceof SVGSVGElement) { @@ -312,48 +353,19 @@ // HTML document style; also works on HTML-embedded SVG el = document.createElement('style'); } - el.id = id; + el.id = ID_PREFIX + id; el.type = 'text/css'; // SVG className is not a string, but an instance of SVGAnimatedString el.classList.add('stylus'); - if (FF_BUG461) { - pageContextQueue.push({id: el.id, el, code}); - } else { - el.textContent = code; - } addStyleElement(el); } + let settingStyle; + if (el.textContent !== code) { + settingStyle = setStyleContent(el, code); + } styleElements.set(id, el); - disabledElements.delete(Number(styleId)); - return el; - } - - function setContentsInPageContext() { - try { - (document.head || ROOT).appendChild(document.createElement('script')).text = `(${queue => { - document.currentScript.remove(); - for (const {id, code} of queue) { - const el = document.getElementById(id) || - document.querySelector('style.stylus[id="' + id + '"]'); - if (!el) continue; - const {disabled} = el.sheet; - el.textContent = code; - el.sheet.disabled = disabled; - } - }})(${JSON.stringify(pageContextQueue)})`; - } catch (e) {} - let failedSome; - for (const {el, code} of pageContextQueue) { - if (el.textContent !== code) { - el.textContent = code; - failedSome = true; - } - } - if (failedSome) { - console.debug('Could not set code of some styles in page context, ' + - 'see https://github.com/openstyles/stylus/issues/461'); - } - pageContextQueue.length = 0; + disabledElements.delete(id); + return Promise.resolve(settingStyle); } function addStyleElement(newElement) { @@ -371,34 +383,32 @@ if (next === newElement.nextElementSibling) { return; } - docRootObserver.evade(() => { + const insert = () => { ROOT.insertBefore(newElement, next || null); if (disableAll) { newElement.disabled = true; } - }); + }; + if (docRootObserver) { + docRootObserver.evade(insert); + } else { + insert(); + } } function replaceAll(newStyles) { - if ('disableAll' in newStyles && - disableAll === newStyles.disableAll && - styleElements.size === countStylesInHash(newStyles) && - [...styleElements.values()].every(el => - el.disabled === disableAll && - el.parentNode === ROOT && - el.textContent === (newStyles[getStyleId(el)] || []).map(({code}) => code).join('\n'))) { - return; - } const oldStyles = Array.prototype.slice.call( document.querySelectorAll(`style.stylus[id^="${ID_PREFIX}"]`)); oldStyles.forEach(el => (el.id += '-ghost')); styleElements.clear(); disabledElements.clear(); - [...retiredStyleTimers.values()].forEach(clearTimeout); - retiredStyleTimers.clear(); applyStyles(newStyles); - docRootObserver.evade(() => - oldStyles.forEach(el => el.remove())); + const removeOld = () => oldStyles.forEach(el => el.remove()); + if (docRewriteObserver) { + docRootObserver.evade(removeOld); + } else { + removeOld(); + } } function applyTransitionPatch() { @@ -408,29 +418,27 @@ const docId = document.documentElement.id ? '#' + document.documentElement.id : ''; document.documentElement.classList.add(className); applySections(0, ` - ${docId}.${className}:root * { - transition: none !important; - } - `); - setTimeout(() => { - removeStyle({id: 0}); - document.documentElement.classList.remove(className); - }); + ${docId}.${CSS.escape(className)}:root * { + transition: none !important; + } + `) + .then(() => { + // repaint + // eslint-disable-next-line no-unused-expressions + document.documentElement.offsetWidth; + removeStyle({id: 0}); + document.documentElement.classList.remove(className); + }); } function getStyleId(el) { return parseInt(el.id.substr(ID_PREFIX.length)); } - function countStylesInHash(styleHash) { - let num = 0; - for (const k in styleHash) { - num += !isNaN(parseInt(k)) ? 1 : 0; + function orphanCheck(e) { + if (e && e.detail.method !== 'orphan') { + return; } - return num; - } - - function orphanCheck() { if (chrome.i18n && chrome.i18n.getUILanguage()) { return true; } @@ -439,7 +447,7 @@ [docRewriteObserver, docRootObserver].forEach(ob => ob && ob.disconnect()); window.removeEventListener(chrome.runtime.id, orphanCheck, true); try { - chrome.runtime.onMessage.removeListener(applyOnMessage); + msg.off(applyOnMessage); } catch (e) {} } diff --git a/content/install-hook-openusercss.js b/content/install-hook-openusercss.js index e9699073..31defada 100644 --- a/content/install-hook-openusercss.js +++ b/content/install-hook-openusercss.js @@ -1,3 +1,4 @@ +/* global API */ 'use strict'; (() => { @@ -33,11 +34,10 @@ && event.data.type === 'ouc-is-installed' && allowedOrigins.includes(event.origin) ) { - chrome.runtime.sendMessage({ - method: 'findUsercss', + API.findUsercss({ name: event.data.name, namespace: event.data.namespace - }, style => { + }).then(style => { const data = {event}; const callbackObject = { installed: Boolean(style), @@ -129,12 +129,10 @@ && event.data.type === 'ouc-install-usercss' && allowedOrigins.includes(event.origin) ) { - chrome.runtime.sendMessage({ - method: 'saveUsercss', - reason: 'install', + API.installUsercss({ name: event.data.title, sourceCode: event.data.code, - }, style => { + }).then(style => { sendInstallCallback({ enabled: style.enabled, key: event.data.key diff --git a/content/install-hook-usercss.js b/content/install-hook-usercss.js index 4a4d8f9d..622b3143 100644 --- a/content/install-hook-usercss.js +++ b/content/install-hook-usercss.js @@ -1,3 +1,4 @@ +/* global API */ 'use strict'; (() => { @@ -16,8 +17,8 @@ let sourceCode, port, timer; chrome.runtime.onConnect.addListener(onConnected); - chrome.runtime.sendMessage({method: 'installUsercss', url}, r => - r && r.__ERROR__ && alert(r.__ERROR__)); + API.openUsercssInstallPage({url}) + .catch(err => alert(err)); function onConnected(newPort) { port = newPort; diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index 7ebdbd22..797d84ee 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -1,4 +1,4 @@ -/* global cloneInto */ +/* global cloneInto msg API */ 'use strict'; (() => { @@ -8,7 +8,7 @@ document.addEventListener('stylishInstallChrome', onClick); document.addEventListener('stylishUpdateChrome', onClick); - chrome.runtime.onMessage.addListener(onMessage); + msg.on(onMessage); onDOMready().then(() => { window.postMessage({ @@ -30,10 +30,9 @@ gotBody = true; // TODO: remove the following statement when USO pagination title is fixed document.title = document.title.replace(/^(\d+)&\w+=/, '#$1: '); - chrome.runtime.sendMessage({ - method: 'getStyles', + API.findStyle({ md5Url: getMeta('stylish-md5-url') || location.href - }, checkUpdatability); + }).then(checkUpdatability); } if (document.getElementById('install_button')) { onDOMready().then(() => { @@ -44,16 +43,14 @@ } } - function onMessage(msg, sender, sendResponse) { + function onMessage(msg) { switch (msg.method) { case 'ping': // orphaned content script check - sendResponse(true); - break; + return true; case 'openSettings': openSettings(); - sendResponse(true); - break; + return true; } } @@ -69,7 +66,7 @@ return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : ''); } - function checkUpdatability([installedStyle]) { + function checkUpdatability(installedStyle) { // TODO: remove the following statement when USO is fixed document.dispatchEvent(new CustomEvent('stylusFixBuggyUSOsettings', { detail: installedStyle && installedStyle.updateUrl, @@ -148,10 +145,9 @@ function onUpdate() { return new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ - method: 'getStyles', - md5Url: getMeta('stylish-md5-url') || location.href, - }, ([style]) => { + API.findStyle({ + md5Url: getMeta('stylish-md5-url') || location.href + }, true).then(style => { saveStyleCode('styleUpdate', style.name, {id: style.id}) .then(resolve, reject); }); @@ -160,36 +156,27 @@ function saveStyleCode(message, name, addProps) { - return new Promise((resolve, reject) => { - const isNew = message === 'styleInstall'; - const needsConfirmation = isNew || !saveStyleCode.confirmed; - if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { - reject(); + const isNew = message === 'styleInstall'; + const needsConfirmation = isNew || !saveStyleCode.confirmed; + if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { + return Promise.reject(); + } + saveStyleCode.confirmed = true; + enableUpdateButton(false); + return getStyleJson().then(json => { + if (!json) { + prompt(chrome.i18n.getMessage('styleInstallFailed', ''), + 'https://github.com/openstyles/stylus/issues/195'); return; } - saveStyleCode.confirmed = true; - enableUpdateButton(false); - getStyleJson().then(json => { - if (!json) { - prompt(chrome.i18n.getMessage('styleInstallFailed', ''), - 'https://github.com/openstyles/stylus/issues/195'); - return; - } - chrome.runtime.sendMessage( - Object.assign(json, addProps, { - method: 'saveStyle', - reason: isNew ? 'install' : 'update', - }), - style => { - if (!isNew && style.updateUrl.includes('?')) { - enableUpdateButton(true); - } else { - sendEvent({type: 'styleInstalledChrome'}); - } + return API.installStyle(Object.assign(json, addProps)) + .then(style => { + if (!isNew && style.updateUrl.includes('?')) { + enableUpdateButton(true); + } else { + sendEvent({type: 'styleInstalledChrome'}); } - ); - resolve(); - }); + }); }); function enableUpdateButton(state) { @@ -216,26 +203,19 @@ function getResource(url, options) { - return new Promise(resolve => { - if (url.startsWith('#')) { - resolve(document.getElementById(url.slice(1)).textContent); - } else { - chrome.runtime.sendMessage(Object.assign({ - url, - method: 'download', - timeout: 60e3, - // USO can't handle POST requests for style json - body: null, - }, options), result => { - const error = result && result.__ERROR__; - if (error) { - alert('Error' + (error ? '\n' + error : '')); - } else { - resolve(result); - } - }); - } - }); + if (url.startsWith('#')) { + return Promise.resolve(document.getElementById(url.slice(1)).textContent); + } + return API.download(Object.assign({ + url, + timeout: 60e3, + // USO can't handle POST requests for style json + body: null, + }, options)) + .catch(error => { + alert('Error' + (error ? '\n' + error : '')); + throw error; + }); } // USO providing md5Url as "https://update.update.userstyles.org/#####.md5" @@ -257,12 +237,12 @@ if (codeElement && !codeElement.textContent.trim()) { return style; } - return getResource(getMeta('stylish-update-url')).then(code => new Promise(resolve => { - chrome.runtime.sendMessage({method: 'parseCss', code}, ({sections}) => { - style.sections = sections; - resolve(style); + return getResource(getMeta('stylish-update-url')) + .then(code => API.parseCss({code})) + .then(result => { + style.sections = result.sections; + return style; }); - })); }) .then(tryFixMd5) .catch(() => null); @@ -349,7 +329,7 @@ document.removeEventListener('stylishInstallChrome', onClick); document.removeEventListener('stylishUpdateChrome', onClick); try { - chrome.runtime.onMessage.removeListener(onMessage); + msg.off(onMessage); } catch (e) {} } })(); diff --git a/edit.html b/edit.html index 8463a984..7b7a10dd 100644 --- a/edit.html +++ b/edit.html @@ -18,26 +18,6 @@ } - - - - - - - - - - - - - - - - - - - - @@ -46,6 +26,8 @@ + + @@ -80,6 +62,18 @@ + + + + + + + + + + + + @@ -88,6 +82,25 @@ + + + + + + + + + + + + + + + + + + + @@ -96,8 +109,6 @@ - -