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 1f2a3c1b..62a3d4fd 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -315,6 +315,55 @@ "message": "Enable", "description": "Label for the button to enable a style" }, + "excludedDomain": { + "message": "Domain", + "description": "Label for a domain or subdomain portion of an URL used to exclude a style" + }, + "excludedPrefix": { + "message": "Prefix", + "description": "Label for a full url with a subdirectory to be used as the beginning portion of a URL to match to exclude a style" + }, + "exclusionsAddTitle": { + "message": "Add exclusion", + "description": "Title of popup to add an excluded site or page (URL)" + }, + "exclusionsHeader": { + "message": "Excluded", + "description": "Title of user configurable lists of site urls to exclude per style" + }, + "exclusionsHelp": { + "message": "Exclusion entries are only checked when a style is set to be applied to a page, and if an exclusion is found, the given style (and all internal sections) will not be applied to that page.\n\nThe list of exclusions is set separately from the userstyle so that it will not be effected when updating or editing the style itself. This is useful because you can exclude pages that would be otherwise be effected by a global style.\n\nAdd one or more exclusion entries for each style. An exclusion entry string contains a pattern that will match a web location (URL). This string may contain wildcards (\"*\") to match any portion of a URL, e.g. \"forum.*.com\" will exclude the forum sub-domains of all top level dot-com domains. Regular expressions are allowed, except `.` and `*` are altered, and are saved as a string so character classes must be doubly escaped (e.g. `\\\\w`).\n\nExcluded pages are automatically updated while typing; invalid entries will be removed on page reload!", + "description": "Help text for user set style exclusions" + }, + "exclusionsHelpTitle": { + "message": "Set Style Exclusions", + "description": "Header text for help modal" + }, + "exclusionsvalidateEntry": { + "message": "Enter a unique and valid URL", + "description": "Text for an alert notifying the user that an entered URL is not unique or invalid" + }, + "exclusionsPopupTitle": { + "message": "Exclude the site or page", + "description": "Title of exclusion popup dialog within the extension popup" + }, + "exclusionsPopupTip": { + "message": "Right-click to edit exclusions on this page", + "description": "Title on the checkbox in the popup to let the user know how to edit exclusions on the current page" + }, + "exclusionsPrefix": { + "message": "Excluded on: ", + "description": "Prefix label added to the applies to column in the style manager" + }, + "exclusionsStatus": { + "message": "$number$ pages", + "description": "Label added next to the Excluded Pages header when 'number' is not zero", + "placeholders": { + "number": { + "content": "$1" + } + } + }, "exportLabel": { "message": "Export", "description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)" @@ -689,6 +738,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 +1159,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 +1271,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 +1325,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 80f3f13a..b6c090dd 100644 --- a/background/background.js +++ b/background/background.js @@ -1,51 +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'; -window.API_METHODS = Object.assign(window.API_METHODS || {}, { +// eslint-disable-next-line no-var +var backgroundWorker = workerUtil.createWorker({ + url: '/background/background-worker.js' +}); - getStyles, - saveStyle, - deleteStyle, +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, + + 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'})); }, }); @@ -54,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) { @@ -127,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}) => { @@ -188,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'}); }, } }; @@ -202,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']; @@ -230,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; @@ -263,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; } }); @@ -290,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}); } }); } @@ -376,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 b57cdf62..00000000 --- a/background/storage.js +++ /dev/null @@ -1,836 +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.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; -} - - -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; - - 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..13355b83 --- /dev/null +++ b/background/style-manager.js @@ -0,0 +1,479 @@ +/* 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.value()].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) { + const style = styles.get(data.id); + if (!style) { + data = Object.assign(createNewStyle(), data); + } else { + data = Object.assign({}, style.data, data); + } + // FIXME: update updateDate? what about usercss config? + return calcStyleDigest(data) + .then(digest => { + data.originalDigest = digest; + return saveStyle(data); + }) + .then(newData => handleSave(newData, style ? 'update' : 'install')); + } + + 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; + } + 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) { + 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]; + } +})(); diff --git a/background/style-via-api.js b/background/style-via-api.js index 5a2c2d3c..e5fdf6ac 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..9daad4b5 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,7 +87,7 @@ global API_METHODS 'ignoreDigest' option is set on the second manual individual update check on the manage page. */ - return Promise.resolve(style) + return fetchStyle() .then([calcStyleDigest][!ignoreDigest ? 0 : 'skip']) .then([checkIfEdited][!ignoreDigest ? 0 : 'skip']) .then([maybeUpdateUSO, maybeUpdateUsercss][style.usercssData ? 1 : 0]) @@ -97,6 +95,16 @@ global API_METHODS .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 +153,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 +182,6 @@ global API_METHODS json.id = style.id; json.updateDate = Date.now(); - json.reason = 'update'; // keep current state delete json.enabled; @@ -185,10 +193,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 +208,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..850130ee 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,92 @@ 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 * @returns {Promise<{style, dup:Boolean?}>} */ function build({ sourceCode, checkDup, metaOnly, + vars, }) { - const task = buildMeta({sourceCode}); - return (metaOnly ? task : task.then(usercss.buildCode)) - .then(style => ({ - style, - dup: checkDup && find(style), - })); - } + return usercss.buildMeta(sourceCode) + .then(style => + Promise.all([ + metaOnly ? style : doBuild(style), + checkDup ? find(style) : undefined + ]) + ) + .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) { + if (vars) { + const oldStyle = {usercssData: {vars}}; + return 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 +135,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..db142bc6 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) { + // FIXME: do we have to keep el.sheet.disabled? + el.textContent = content; + } 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.sheet.disabled; + el.textContent = content; + el.sheet.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,39 @@ 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(); + for (const section of Object.values(sections)) { + 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) { + 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,50 +352,20 @@ // 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); } + if (el.textContent !== code) { + setStyleContent(el, code); + } styleElements.set(id, el); - disabledElements.delete(Number(styleId)); + disabledElements.delete(id); 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; - } - function addStyleElement(newElement) { if (!ROOT) { return; @@ -371,34 +381,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 +416,25 @@ 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; + } + `); + // 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 +443,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 d26a88af..72ea6862 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; + }); } @@ -249,12 +229,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; }); - })); }) .catch(() => null); } @@ -340,7 +320,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..6b7ae8b8 100644 --- a/edit.html +++ b/edit.html @@ -18,26 +18,6 @@ } - - - - - - - - - - - - - - - - - - - - @@ -46,6 +26,8 @@ + + @@ -80,6 +62,18 @@ + + + + + + + + + + + + @@ -88,6 +82,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -96,8 +110,6 @@ - -