diff --git a/.eslintignore b/.eslintignore index 8e747f5b..a710e413 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,2 @@ vendor/ -vendor-overwrites/* -!vendor-overwrites/colorpicker -!vendor-overwrites/csslint +vendor-overwrites/ diff --git a/.eslintrc.yml b/.eslintrc.yml index c99504b4..66788098 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -8,6 +8,9 @@ env: es6: true webextensions: true +globals: + require: readonly # in polyfill.js + rules: accessor-pairs: [2] array-bracket-spacing: [2, never] @@ -42,7 +45,15 @@ rules: id-blacklist: [0] id-length: [0] id-match: [0] - indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}] + indent: [2, 2, { + SwitchCase: 1, + ignoreComments: true, + ignoredNodes: [ + "TemplateLiteral > *", + "ConditionalExpression", + "ForStatement" + ] + }] jsx-quotes: [0] key-spacing: [0] keyword-spacing: [2] @@ -86,7 +97,7 @@ rules: no-empty: [2, {allowEmptyCatch: true}] no-eq-null: [0] no-eval: [2] - no-ex-assign: [2] + no-ex-assign: [0] no-extend-native: [2] no-extra-bind: [2] no-extra-boolean-cast: [2] @@ -136,6 +147,9 @@ rules: no-proto: [2] no-redeclare: [2] no-regex-spaces: [2] + no-restricted-globals: [2, name, event] + # `name` and `event` (in Chrome) are built-in globals + # but we don't use these globals so it's most likely a mistake/typo no-restricted-imports: [0] no-restricted-modules: [2, domain, freelist, smalloc, sys] no-restricted-syntax: [2, WithStatement] @@ -163,7 +177,7 @@ rules: no-unreachable: [2] no-unsafe-finally: [2] no-unsafe-negation: [2] - no-unused-expressions: [1] + no-unused-expressions: [2] no-unused-labels: [0] no-unused-vars: [2, {args: after-used}] no-use-before-define: [2, nofunc] @@ -220,3 +234,7 @@ overrides: webextensions: false parserOptions: ecmaVersion: 2017 + + - files: ["**/*worker*.js"] + env: + worker: true diff --git a/README.md b/README.md index 16d56267..b731931d 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,15 @@ See our [contributing](./.github/CONTRIBUTING.md) page for more details. ## License -Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/): +Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/): Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com) -Current Stylus: +Current Stylus: Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors) -**[GNU GPLv3](./LICENSE)** +**[GNU GPLv3](./LICENSE)** This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/background/background-worker.js b/background/background-worker.js index 7b30969c..cfbbe89a 100644 --- a/background/background-worker.js +++ b/background/background-worker.js @@ -1,176 +1,26 @@ -/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */ +/* global createWorkerApi */// worker-util.js 'use strict'; -importScripts('/js/worker-util.js'); -const {loadScript} = workerUtil; +/** @namespace BackgroundWorker */ +createWorkerApi({ -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); + async compileUsercss(...args) { + require(['/js/usercss-compiler']); /* global compileUsercss */ + return compileUsercss(...args); }, + nullifyInvalidVars(vars) { - loadScript( - '/vendor/usercss-meta/usercss-meta.min.js', - '/vendor-overwrites/colorpicker/colorconverter.js', - '/js/meta-parser.js' - ); + require(['/js/meta-parser']); /* global metaParser */ return metaParser.nullifyInvalidVars(vars); }, + + parseMozFormat(...args) { + require(['/js/moz-parser']); /* global extractSections */ + return extractSections(...args); + }, + + parseUsercssMeta(text) { + require(['/js/meta-parser']); + return metaParser.parse(text); + }, }); - -function compileUsercss(preprocessor, code, vars) { - loadScript( - '/vendor-overwrites/csslint/parserlib.js', - '/vendor-overwrites/colorpicker/colorconverter.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-renderer.min.js'); - return new Promise((resolve, reject) => { - const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); - new self.StylusRenderer(varDef + source) - .render((err, output) => err ? reject(err) : resolve(output)); - }); - }, - }, - less: { - preprocess(source, vars) { - if (!self.less) { - self.less = { - logLevel: 0, - useFileCache: false, - }; - } - loadScript('/vendor/less-bundle/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, rgbName) { - if (!vars.hasOwnProperty(name)) { - if (name.endsWith('-rgb')) { - return getValue(name.slice(0, -4), name); - } - return null; - } - const {type, value} = vars[name]; - switch (type) { - case 'color': { - let color = pool.get(rgbName || name); - if (color == null) { - color = colorConverter.parse(value); - if (color) { - if (color.type === 'hsl') { - color = colorConverter.HSVtoRGB(colorConverter.HSLtoHSV(color)); - } - const {r, g, b} = color; - color = rgbName - ? `${r}, ${g}, ${b}` - : `#${(0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; - } - // the pool stores `false` for bad colors to differentiate from a yet unknown color - pool.set(rgbName || name, color || false); - } - return color || null; - } - case 'dropdown': - case 'select': // prevent infinite recursion - pool.set(name, ''); - return doReplace(value); - } - return 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 dfcad0bf..9b2e33eb 100644 --- a/background/background.js +++ b/background/background.js @@ -1,72 +1,105 @@ -/* global download prefs openURL FIREFOX CHROME - URLS ignoreChromeError chromeLocal semverCompare - styleManager msg navigatorUtil workerUtil contentScripts sync - findExistingTab activateTab isTabReplaceable getActiveTab -*/ - +/* global API msg */// msg.js +/* global addAPI bgReady */// common.js +/* global createWorker */// worker-util.js +/* global prefs */ +/* global styleMan */ +/* global syncMan */ +/* global updateMan */ +/* global usercssMan */ +/* global + FIREFOX + URLS + activateTab + download + findExistingTab + getActiveTab + isTabReplaceable + openURL +*/ // toolbox.js 'use strict'; -// eslint-disable-next-line no-var -var backgroundWorker = workerUtil.createWorker({ - url: '/background/background-worker.js', -}); +//#region API -// eslint-disable-next-line no-var -var browserCommands, contextMenus; +addAPI(/** @namespace API */ { -// ************************************************************************* -// browser commands -browserCommands = { - openManage, - openOptions: () => openManage({options: true}), - styleDisableAll(info) { - prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll')); + styles: styleMan, + sync: syncMan, + updater: updateMan, + usercss: usercssMan, + /** @type {BackgroundWorker} */ + worker: createWorker({url: '/background/background-worker'}), + + download(url, opts) { + return typeof url === 'string' && url.startsWith(URLS.uso) && + this.sender.url.startsWith(URLS.uso) && + download(url, opts || {}); }, - reload: () => chrome.runtime.reload(), -}; - -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, - importManyStyles: styleManager.importMany, - installStyle: styleManager.installStyle, - styleExists: styleManager.styleExists, - toggleStyle: styleManager.toggleStyle, - - addInclusion: styleManager.addInclusion, - removeInclusion: styleManager.removeInclusion, - addExclusion: styleManager.addExclusion, - removeExclusion: styleManager.removeExclusion, + /** @returns {string} */ getTabUrlPrefix() { - const {url} = this.sender.tab; - if (url.startsWith(URLS.ownOrigin)) { - return 'stylus'; + return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; + }, + + /** + * Opens the editor or activates an existing tab + * @param {{ + id?: number + domain?: string + 'url-prefix'?: string + }} params + * @returns {Promise} + */ + async openEditor(params) { + const u = new URL(chrome.runtime.getURL('edit.html')); + u.search = new URLSearchParams(params); + const wnd = prefs.get('openEditInWindow'); + const wndPos = wnd && prefs.get('windowPosition'); + const wndBase = wnd && prefs.get('openEditInWindow.popup') ? {type: 'popup'} : {}; + const ffBug = wnd && FIREFOX; // https://bugzil.la/1271047 + const tab = await openURL({ + url: `${u}`, + currentWindow: null, + newWindow: Object.assign(wndBase, !ffBug && wndPos), + }); + if (ffBug) await browser.windows.update(tab.windowId, wndPos); + return tab; + }, + + /** @returns {Promise} */ + async openManage({options = false, search, searchMode} = {}) { + let url = chrome.runtime.getURL('manage.html'); + if (search) { + url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`; } - return url.match(/^([\w-]+:\/+[^/#]+)/)[1]; + if (options) { + url += '#stylus-options'; + } + let tab = await findExistingTab({ + url, + currentWindow: null, + ignoreHash: true, + ignoreSearch: true, + }); + if (tab) { + await activateTab(tab); + if (url !== (tab.pendingUrl || tab.url)) { + await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error); + } + return tab; + } + tab = await getActiveTab(); + return isTabReplaceable(tab, url) + ? activateTab(tab, {url}) + : browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window }, - download(msg) { - delete msg.method; - return download(msg.url, msg); - }, - parseCss({code}) { - return backgroundWorker.parseMozFormat({code}); - }, - getPrefs: () => prefs.values, - setPref: (key, value) => prefs.set(key, value), - - openEditor, - - /* Same as openURL, the only extra prop in `opts` is `message` - it'll be sent when the tab is ready, - which is needed in the popup, otherwise another extension could force the tab to open in foreground - thus auto-closing the popup (in Chrome at least) and preventing the sendMessage code from running */ + /** + * Same as openURL, the only extra prop in `opts` is `message` - it'll be sent + * when the tab is ready, which is needed in the popup, otherwise another + * extension could force the tab to open in foreground thus auto-closing the + * popup (in Chrome at least) and preventing the sendMessage code from running + * @returns {Promise} + */ async openURL(opts) { const tab = await openURL(opts); if (opts.message) { @@ -87,251 +120,62 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, { } }, - optionsCustomizeHotkeys() { - return browserCommands.openOptions() - .then(() => new Promise(resolve => setTimeout(resolve, 500))) - .then(() => msg.broadcastExtension({method: 'optionsCustomizeHotkeys'})); + prefs: { + getValues: () => prefs.__values, // will be deepCopy'd by apiHandler + set: prefs.set, }, - - syncStart: sync.start, - syncStop: sync.stop, - syncNow: sync.syncNow, - getSyncStatus: sync.getStatus, - syncLogin: sync.login, - - openManage, }); -// ************************************************************************* -// register all listeners -msg.on(onRuntimeMessage); +//#endregion +//#region Events -// tell apply.js to refresh styles for non-committed navigation -navigatorUtil.onUrlChange(({tabId, frameId}, type) => { - if (type !== 'committed') { - msg.sendTab(tabId, {method: 'urlChanged'}, {frameId}) - .catch(msg.ignoreError); - } -}); - -if (FIREFOX) { - // FF misses some about:blank iframes so we inject our content script explicitly - navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, { - url: [ - {urlEquals: 'about:blank'}, - ], - }); -} - -if (chrome.contextMenus) { - chrome.contextMenus.onClicked.addListener((info, tab) => - contextMenus[info.menuItemId].click(info, tab)); -} - -if (chrome.commands) { - // Not available in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1240350 - chrome.commands.onCommand.addListener(command => browserCommands[command]()); -} - -// ************************************************************************* -chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { - if (reason !== 'update') return; - if (semverCompare(previousVersion, '1.5.13') <= 0) { - // Removing unused stuff - // TODO: delete this entire block by the middle of 2021 - try { - localStorage.clear(); - } catch (e) {} - setTimeout(async () => { - const del = Object.keys(await chromeLocal.get()) - .filter(key => key.startsWith('usoSearchCache')); - if (del.length) chromeLocal.remove(del); - }, 15e3); - } -}); - -// ************************************************************************* -// context menus -contextMenus = { - 'show-badge': { - title: 'menuShowBadge', - click: info => prefs.set(info.menuItemId, info.checked), - }, - 'disableAll': { - title: 'disableAllStyles', - click: browserCommands.styleDisableAll, - }, - 'open-manager': { - title: 'openStylesManager', - click: browserCommands.openManage, - }, - 'open-options': { - title: 'openOptions', - click: browserCommands.openOptions, - }, - 'reload': { - presentIf: async () => (await browser.management.getSelf()).installType === 'development', - title: 'reload', - click: browserCommands.reload, - }, - 'editor.contextDelete': { - presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'), - title: 'editDeleteText', - type: 'normal', - contexts: ['editable'], - documentUrlPatterns: [URLS.ownOrigin + 'edit*'], - click: (info, tab) => { - msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension') - .catch(msg.ignoreError); - }, +const browserCommands = { + openManage: () => API.openManage(), + openOptions: () => API.openManage({options: true}), + reload: () => chrome.runtime.reload(), + styleDisableAll(info) { + prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll')); }, }; -async function createContextMenus(ids) { - for (const id of ids) { - let item = contextMenus[id]; - if (item.presentIf && !await item.presentIf()) { - continue; +if (chrome.commands) { + chrome.commands.onCommand.addListener(id => browserCommands[id]()); +} + +chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { + if (reason === 'update') { + const [a, b, c] = (previousVersion || '').split('.'); + if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13 + require(['/background/remove-unused-storage']); } - item = Object.assign({id}, item); - delete item.presentIf; - item.title = chrome.i18n.getMessage(item.title); - if (!item.type && typeof prefs.defaults[id] === 'boolean') { - item.type = 'checkbox'; - item.checked = prefs.get(id); - } - if (!item.contexts) { - item.contexts = ['browser_action']; - } - delete item.click; - chrome.contextMenus.create(item, ignoreChromeError); } -} +}); -if (chrome.contextMenus) { - // "Delete" item in context menu for browsers that don't have it - if (CHROME && - // looking at the end of UA string - /(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) && - // skip forks with Flash as those are likely to have the menu e.g. CentBrowser - !Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) { - prefs.defaults['editor.contextDelete'] = true; +msg.on((msg, sender) => { + if (msg.method === 'invokeAPI') { + let res = msg.path.reduce((res, name) => res && res[name], API); + if (!res) throw new Error(`Unknown API.${msg.path.join('.')}`); + res = res.apply({msg, sender}, msg.args); + return res === undefined ? null : res; } - // circumvent the bug with disabling check marks in Chrome 62-64 - const toggleCheckmark = CHROME >= 62 && CHROME <= 64 ? - (id => chrome.contextMenus.remove(id, () => createContextMenus([id]) + ignoreChromeError())) : - ((id, checked) => chrome.contextMenus.update(id, {checked}, ignoreChromeError)); +}); - const togglePresence = (id, checked) => { - if (checked) { - createContextMenus([id]); - } else { - chrome.contextMenus.remove(id, ignoreChromeError); - } - }; +//#endregion - const keys = Object.keys(contextMenus); - prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), toggleCheckmark); - prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && id in prefs.defaults), togglePresence); - createContextMenus(keys); -} - -// reinject content scripts when the extension is reloaded/updated. Firefox -// would handle this automatically. -if (!FIREFOX) { - setTimeout(contentScripts.injectToAllTabs, 0); -} - -// 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) {} - }); -} - -msg.broadcast({method: 'backgroundReady'}); - -function webNavIframeHelperFF({tabId, frameId}) { - if (!frameId) return; - 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 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 res = fn.apply({msg, sender}, msg.args); - return res === undefined ? null : res; -} - -function openEditor(params) { - /* Open the editor. Activate if it is already opened - - params: { - id?: Number, - domain?: String, - 'url-prefix'?: String - } - */ - const u = new URL(chrome.runtime.getURL('edit.html')); - u.search = new URLSearchParams(params); - return openURL({ - url: `${u}`, - currentWindow: null, - newWindow: prefs.get('openEditInWindow') && Object.assign({}, - prefs.get('openEditInWindow.popup') && {type: 'popup'}, - prefs.get('windowPosition')), - }); -} - -async function openManage({options = false, search, searchMode} = {}) { - let url = chrome.runtime.getURL('manage.html'); - if (search) { - url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`; - } - if (options) { - url += '#stylus-options'; - } - let tab = await findExistingTab({ - url, - currentWindow: null, - ignoreHash: true, - ignoreSearch: true, - }); - if (tab) { - await activateTab(tab); - if (url !== (tab.pendingUrl || tab.url)) { - await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error); - } - return tab; - } - tab = await getActiveTab(); - return isTabReplaceable(tab, url) - ? activateTab(tab, {url}) - : browser.tabs.create({url}); -} +Promise.all([ + bgReady.styles, + /* These are loaded conditionally. + Each item uses `require` individually so IDE can jump to the source and track usage. */ + FIREFOX && + require(['/background/style-via-api']), + FIREFOX && ((browser.commands || {}).update) && + require(['/background/browser-cmd-hotkeys']), + !FIREFOX && + require(['/background/content-scripts']), + chrome.contextMenus && + require(['/background/context-menus']), +]).then(() => { + bgReady._resolveAll(); + msg.isBgReady = true; + msg.broadcast({method: 'backgroundReady'}); +}); diff --git a/background/browser-cmd-hotkeys.js b/background/browser-cmd-hotkeys.js new file mode 100644 index 00000000..a01f454d --- /dev/null +++ b/background/browser-cmd-hotkeys.js @@ -0,0 +1,22 @@ +/* global prefs */ +'use strict'; + +/* + Registers hotkeys in FF + */ + +(() => { + const hotkeyPrefs = prefs.knownKeys.filter(k => k.startsWith('hotkey.')); + prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true}); + + async function updateHotkey(name, value) { + try { + name = name.split('.')[1]; + if (value.trim()) { + await browser.commands.update({name, shortcut: value}); + } else { + await browser.commands.reset(name); + } + } catch (e) {} + } +})(); diff --git a/background/common.js b/background/common.js new file mode 100644 index 00000000..a08174fa --- /dev/null +++ b/background/common.js @@ -0,0 +1,31 @@ +/* global API */// msg.js +'use strict'; + +/** + * Common stuff that's loaded first so it's immediately available to all background scripts + */ + +/* exported + addAPI + bgReady + compareRevision +*/ + +const bgReady = {}; +bgReady.styles = new Promise(r => (bgReady._resolveStyles = r)); +bgReady.all = new Promise(r => (bgReady._resolveAll = r)); + +function addAPI(methods) { + for (const [key, val] of Object.entries(methods)) { + const old = API[key]; + if (old && Object.prototype.toString.call(old) === '[object Object]') { + Object.assign(old, val); + } else { + API[key] = val; + } + } +} + +function compareRevision(rev1, rev2) { + return rev1 - rev2; +} diff --git a/background/content-scripts.js b/background/content-scripts.js index 23293097..06b4a282 100644 --- a/background/content-scripts.js +++ b/background/content-scripts.js @@ -1,15 +1,21 @@ -/* global msg ignoreChromeError URLS */ -/* exported contentScripts */ +/* global bgReady */// common.js +/* global msg */ +/* global URLS ignoreChromeError */// toolbox.js 'use strict'; -const contentScripts = (() => { +/* + Reinject content scripts when the extension is reloaded/updated. + Not used in Firefox as it reinjects automatically. + */ + +bgReady.all.then(() => { const NTP = 'chrome://newtab/'; const ALL_URLS = ''; const SCRIPTS = chrome.runtime.getManifest().content_scripts; // expand * as .*? const wildcardAsRegExp = (s, flags) => new RegExp( - s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&') - .replace(/\*/g, '.*?'), flags); + s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&') + .replace(/\*/g, '.*?'), flags); for (const cs of SCRIPTS) { cs.matches = cs.matches.map(m => ( m === ALL_URLS ? m : wildcardAsRegExp(m) @@ -18,21 +24,7 @@ const contentScripts = (() => { const busyTabs = new Set(); let busyTabsTimer; - // expose version on greasyfork/sleazyfork 1) info page and 2) code page - const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$'; - chrome.webNavigation.onCommitted.addListener(({tabId}) => { - chrome.tabs.executeScript(tabId, { - file: '/content/install-hook-greasyfork.js', - runAt: 'document_start', - }); - }, { - url: [ - {hostEquals: 'greasyfork.org', urlMatches}, - {hostEquals: 'sleazyfork.org', urlMatches}, - ], - }); - - return {injectToTab, injectToAllTabs}; + setTimeout(injectToAllTabs); function injectToTab({url, tabId, frameId = null}) { for (const script of SCRIPTS) { @@ -122,4 +114,4 @@ const contentScripts = (() => { function onBusyTabRemoved(tabId) { trackBusyTab(tabId, false); } -})(); +}); diff --git a/background/context-menus.js b/background/context-menus.js new file mode 100644 index 00000000..53101dea --- /dev/null +++ b/background/context-menus.js @@ -0,0 +1,101 @@ +/* global browserCommands */// background.js +/* global msg */ +/* global prefs */ +/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js +'use strict'; + +(() => { + const contextMenus = { + 'show-badge': { + title: 'menuShowBadge', + click: info => prefs.set(info.menuItemId, info.checked), + }, + 'disableAll': { + title: 'disableAllStyles', + click: browserCommands.styleDisableAll, + }, + 'open-manager': { + title: 'openStylesManager', + click: browserCommands.openManage, + }, + 'open-options': { + title: 'openOptions', + click: browserCommands.openOptions, + }, + 'reload': { + presentIf: async () => (await browser.management.getSelf()).installType === 'development', + title: 'reload', + click: browserCommands.reload, + }, + 'editor.contextDelete': { + presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'), + title: 'editDeleteText', + type: 'normal', + contexts: ['editable'], + documentUrlPatterns: [URLS.ownOrigin + 'edit*'], + click: (info, tab) => { + msg.sendTab(tab.id, {method: 'editDeleteText'}, undefined, 'extension') + .catch(msg.ignoreError); + }, + }, + }; + + // "Delete" item in context menu for browsers that don't have it + if (CHROME && + // looking at the end of UA string + /(Vivaldi|Safari)\/[\d.]+$/.test(navigator.userAgent) && + // skip forks with Flash as those are likely to have the menu e.g. CentBrowser + !Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')) { + prefs.__defaults['editor.contextDelete'] = true; + } + + const keys = Object.keys(contextMenus); + prefs.subscribe(keys.filter(id => typeof prefs.defaults[id] === 'boolean'), + CHROME >= 62 && CHROME <= 64 ? toggleCheckmarkBugged : toggleCheckmark); + prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)), + togglePresence); + + createContextMenus(keys); + + chrome.contextMenus.onClicked.addListener((info, tab) => + contextMenus[info.menuItemId].click(info, tab)); + + async function createContextMenus(ids) { + for (const id of ids) { + let item = contextMenus[id]; + if (item.presentIf && !await item.presentIf()) { + continue; + } + item = Object.assign({id}, item); + delete item.presentIf; + item.title = chrome.i18n.getMessage(item.title); + if (!item.type && typeof prefs.defaults[id] === 'boolean') { + item.type = 'checkbox'; + item.checked = prefs.get(id); + } + if (!item.contexts) { + item.contexts = ['browser_action']; + } + delete item.click; + chrome.contextMenus.create(item, ignoreChromeError); + } + } + + function toggleCheckmark(id, checked) { + chrome.contextMenus.update(id, {checked}, ignoreChromeError); + } + + /** Circumvents the bug with disabling check marks in Chrome 62-64 */ + async function toggleCheckmarkBugged(id) { + await browser.contextMenus.remove(id).catch(ignoreChromeError); + createContextMenus([id]); + } + + function togglePresence(id, checked) { + if (checked) { + createContextMenus([id]); + } else { + chrome.contextMenus.remove(id, ignoreChromeError); + } + } +})(); diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index f3a67796..fe5ace24 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -1,67 +1,66 @@ -/* global chromeLocal */ -/* exported createChromeStorageDB */ +/* global chromeLocal */// storage-util.js 'use strict'; +/* exported createChromeStorageDB */ function createChromeStorageDB() { let INC; const PREFIX = 'style-'; const METHODS = { + + delete(id) { + return chromeLocal.remove(PREFIX + id); + }, + // FIXME: we don't use this method at all. Should we remove this? - get: id => chromeLocal.getValue(PREFIX + id), - put: obj => - // FIXME: should we clone the object? - Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++}))) - .then(() => chromeLocal.setValue(PREFIX + obj.id, obj)) - .then(() => obj.id), - putMany: items => prepareInc() - .then(() => - chromeLocal.set(items.reduce((data, item) => { - if (!item.id) item.id = INC++; - data[PREFIX + item.id] = item; - return data; - }, {}))) - .then(() => items.map(i => i.id)), - delete: id => chromeLocal.remove(PREFIX + id), - getAll: () => chromeLocal.get() - .then(result => { - const output = []; - for (const key in result) { - if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) { - output.push(result[key]); - } + get(id) { + return chromeLocal.getValue(PREFIX + id); + }, + + async getAll() { + const all = await chromeLocal.get(); + if (!INC) prepareInc(all); + return Object.entries(all) + .map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val) + .filter(Boolean); + }, + + async put(item) { + if (!item.id) { + if (!INC) await prepareInc(); + item.id = INC++; + } + await chromeLocal.setValue(PREFIX + item.id, item); + return item.id; + }, + + async putMany(items) { + const data = {}; + for (const item of items) { + if (!item.id) { + if (!INC) await prepareInc(); + item.id = INC++; } - return output; - }), + data[PREFIX + item.id] = item; + } + await chromeLocal.set(data); + return items.map(_ => _.id); + }, }; - return {exec}; - - function exec(method, ...args) { - if (METHODS[method]) { - return METHODS[method](...args) - .then(result => { - if (method === 'putMany' && result.map) { - return result.map(r => ({target: {result: r}})); - } - return {target: {result}}; - }); - } - return Promise.reject(new Error(`unknown DB method ${method}`)); - } - - function prepareInc() { - if (INC) return Promise.resolve(); - return chromeLocal.get().then(result => { - INC = 1; - for (const key in result) { - if (key.startsWith(PREFIX)) { - const id = Number(key.slice(PREFIX.length)); - if (id >= INC) { - INC = id + 1; - } + async function prepareInc(data) { + INC = 1; + for (const key in data || await chromeLocal.get()) { + if (key.startsWith(PREFIX)) { + const id = Number(key.slice(PREFIX.length)); + if (id >= INC) { + INC = id + 1; } } - }); + } } + + return function dbExecChromeStorage(method, ...args) { + return METHODS[method](...args); + }; } diff --git a/background/db.js b/background/db.js index 84075e18..0427a6a8 100644 --- a/background/db.js +++ b/background/db.js @@ -1,14 +1,15 @@ -/* global chromeLocal workerUtil createChromeStorageDB */ -/* 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/ -*/ +/* global chromeLocal */// storage-util.js +/* global cloneError */// worker-util.js 'use strict'; +/* + 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/ +*/ + +/* exported db */ const db = (() => { const DATABASE = 'stylish'; const STORE = 'styles'; @@ -33,32 +34,25 @@ const db = (() => { case false: break; default: await testDB(); } - return useIndexedDB(); + chromeLocal.setValue(FALLBACK, false); + return dbExecIndexedDB; } async function testDB() { - let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1); - // throws if result is null - e = e.target.result[0]; const id = `${performance.now()}.${Math.random()}.${Date.now()}`; await dbExecIndexedDB('put', {id}); - e = await dbExecIndexedDB('get', id); - // throws if result or id is null - await dbExecIndexedDB('delete', e.target.result.id); + const e = await dbExecIndexedDB('get', id); + await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null } - function useChromeStorage(err) { + async function useChromeStorage(err) { chromeLocal.setValue(FALLBACK, true); if (err) { - chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err)); + chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err)); console.warn('Failed to access indexedDB. Switched to storage API.', err); } - return createChromeStorageDB().exec; - } - - function useIndexedDB() { - chromeLocal.setValue(FALLBACK, false); - return dbExecIndexedDB; + await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ + return createChromeStorageDB(); } async function dbExecIndexedDB(method, ...args) { @@ -70,8 +64,9 @@ const db = (() => { function storeRequest(store, method, ...args) { return new Promise((resolve, reject) => { + /** @type {IDBRequest} */ const request = store[method](...args); - request.onsuccess = resolve; + request.onsuccess = () => resolve(request.result); request.onerror = reject; }); } diff --git a/background/icon-manager.js b/background/icon-manager.js index c69faa1b..5d51e8e7 100644 --- a/background/icon-manager.js +++ b/background/icon-manager.js @@ -1,48 +1,36 @@ -/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API_METHODS */ -/* exported iconManager */ +/* global API */// msg.js +/* global addAPI bgReady */// common.js +/* global prefs */ +/* global tabMan */ +/* global CHROME FIREFOX VIVALDI debounce ignoreChromeError */// toolbox.js 'use strict'; -const iconManager = (() => { +(() => { const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38]; const staleBadges = new Set(); + const imageDataCache = new Map(); + // https://github.com/openstyles/stylus/issues/335 + let hasCanvas = loadImage(`/images/icon/${ICON_SIZES[0]}.png`) + .then(({data}) => (hasCanvas = data.some(b => b !== 255))); - prefs.subscribe([ - 'disableAll', - 'badgeDisabled', - 'badgeNormal', - ], () => debounce(refreshIconBadgeColor)); - - prefs.subscribe([ - 'show-badge', - ], () => debounce(refreshAllIconsBadgeText)); - - prefs.subscribe([ - 'disableAll', - 'iconset', - ], () => debounce(refreshAllIcons)); - - prefs.initializing.then(() => { - refreshIconBadgeColor(); - refreshAllIconsBadgeText(); - refreshAllIcons(); - }); - - Object.assign(API_METHODS, { - /** @param {(number|string)[]} styleIds - * @param {boolean} [lazyBadge=false] preventing flicker during page load */ + addAPI(/** @namespace API */ { + /** + * @param {(number|string)[]} styleIds + * @param {boolean} [lazyBadge=false] preventing flicker during page load + */ updateIconBadge(styleIds, {lazyBadge} = {}) { // FIXME: in some cases, we only have to redraw the badge. is it worth a optimization? const {frameId, tab: {id: tabId}} = this.sender; const value = styleIds.length ? styleIds.map(Number) : undefined; - tabManager.set(tabId, 'styleIds', frameId, value); + tabMan.set(tabId, 'styleIds', frameId, value); debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0); staleBadges.add(tabId); if (!frameId) refreshIcon(tabId, true); }, }); - navigatorUtil.onCommitted(({tabId, frameId}) => { - if (!frameId) tabManager.set(tabId, 'styleIds', undefined); + chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => { + if (!frameId) tabMan.set(tabId, 'styleIds', undefined); }); chrome.runtime.onConnect.addListener(port => { @@ -51,15 +39,30 @@ const iconManager = (() => { } }); + bgReady.all.then(() => { + prefs.subscribe([ + 'disableAll', + 'badgeDisabled', + 'badgeNormal', + ], () => debounce(refreshIconBadgeColor), {runNow: true}); + prefs.subscribe([ + 'show-badge', + ], () => debounce(refreshAllIconsBadgeText), {runNow: true}); + prefs.subscribe([ + 'disableAll', + 'iconset', + ], () => debounce(refreshAllIcons), {runNow: true}); + }); + function onPortDisconnected({sender}) { - if (tabManager.get(sender.tab.id, 'styleIds')) { - API_METHODS.updateIconBadge.call({sender}, [], {lazyBadge: true}); + if (tabMan.get(sender.tab.id, 'styleIds')) { + API.updateIconBadge.call({sender}, [], {lazyBadge: true}); } } function refreshIconBadgeText(tabId) { const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : ''; - iconUtil.setBadgeText({tabId, text}); + setBadgeText({tabId, text}); } function getIconName(hasStyles = false) { @@ -69,15 +72,15 @@ const iconManager = (() => { } function refreshIcon(tabId, force = false) { - const oldIcon = tabManager.get(tabId, 'icon'); - const newIcon = getIconName(tabManager.get(tabId, 'styleIds', 0)); + const oldIcon = tabMan.get(tabId, 'icon'); + const newIcon = getIconName(tabMan.get(tabId, 'styleIds', 0)); // (changing the icon only for the main page, frameId = 0) if (!force && oldIcon === newIcon) { return; } - tabManager.set(tabId, 'icon', newIcon); - iconUtil.setIcon({ + tabMan.set(tabId, 'icon', newIcon); + setIcon({ path: getIconPath(newIcon), tabId, }); @@ -96,33 +99,55 @@ const iconManager = (() => { /** @return {number | ''} */ function getStyleCount(tabId) { const allIds = new Set(); - const data = tabManager.get(tabId, 'styleIds') || {}; + const data = tabMan.get(tabId, 'styleIds') || {}; Object.values(data).forEach(frameIds => frameIds.forEach(id => allIds.add(id))); return allIds.size || ''; } + // Caches imageData for icon paths + async function loadImage(url) { + const {OffscreenCanvas} = self.createImageBitmap && self || {}; + const img = OffscreenCanvas + ? await createImageBitmap(await (await fetch(url)).blob()) + : await new Promise((resolve, reject) => + Object.assign(new Image(), { + src: url, + onload: e => resolve(e.target), + onerror: reject, + })); + const {width: w, height: h} = img; + const canvas = OffscreenCanvas + ? new OffscreenCanvas(w, h) + : Object.assign(document.createElement('canvas'), {width: w, height: h}); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, w, h); + const result = ctx.getImageData(0, 0, w, h); + imageDataCache.set(url, result); + return result; + } + function refreshGlobalIcon() { - iconUtil.setIcon({ + setIcon({ path: getIconPath(getIconName()), }); } function refreshIconBadgeColor() { const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'); - iconUtil.setBadgeBackgroundColor({ + setBadgeBackgroundColor({ color, }); } function refreshAllIcons() { - for (const tabId of tabManager.list()) { + for (const tabId of tabMan.list()) { refreshIcon(tabId); } refreshGlobalIcon(); } function refreshAllIconsBadgeText() { - for (const tabId of tabManager.list()) { + for (const tabId of tabMan.list()) { refreshIconBadgeText(tabId); } } @@ -133,4 +158,40 @@ const iconManager = (() => { } staleBadges.clear(); } + + function safeCall(method, data) { + const {browserAction = {}} = chrome; + const fn = browserAction[method]; + if (fn) { + try { + // Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320 + fn.call(browserAction, data, ignoreChromeError); + } catch (e) { + // FIXME: skip pre-rendered tabs? + fn.call(browserAction, data); + } + } + } + + /** @param {chrome.browserAction.TabIconDetails} data */ + async function setIcon(data) { + if (hasCanvas === true || await hasCanvas) { + data.imageData = {}; + for (const [key, url] of Object.entries(data.path)) { + data.imageData[key] = imageDataCache.get(url) || await loadImage(url); + } + delete data.path; + } + safeCall('setIcon', data); + } + + /** @param {chrome.browserAction.BadgeTextDetails} data */ + function setBadgeText(data) { + safeCall('setBadgeText', data); + } + + /** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */ + function setBadgeBackgroundColor(data) { + safeCall('setBadgeBackgroundColor', data); + } })(); diff --git a/background/icon-util.js b/background/icon-util.js deleted file mode 100644 index 4bfffe31..00000000 --- a/background/icon-util.js +++ /dev/null @@ -1,91 +0,0 @@ -/* 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/navigation-manager.js b/background/navigation-manager.js new file mode 100644 index 00000000..dff9c6fe --- /dev/null +++ b/background/navigation-manager.js @@ -0,0 +1,82 @@ +/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js +/* global bgReady */// common.js +/* global msg */ +'use strict'; + +/* exported navMan */ +const navMan = (() => { + const listeners = new Set(); + + chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed')); + chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history')); + chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash')); + + return { + /** @param {function(data: Object, type: ('committed'|'history'|'hash'))} fn */ + onUrlChange(fn) { + listeners.add(fn); + }, + }; + + /** @this {string} type */ + async function onNavigation(data) { + if (CHROME && + URLS.chromeProtectsNTP && + data.url.startsWith('https://www.google.') && + data.url.includes('/_/chrome/newtab?')) { + // Modern Chrome switched to WebUI NTP so this is obsolete, but there may be exceptions + // TODO: investigate, and maybe use a separate listener for CHROME <= ver + const tab = await browser.tabs.get(data.tabId); + const url = tab.pendingUrl || tab.url; + if (url === 'chrome://newtab/') { + data.url = url; + } + } + listeners.forEach(fn => fn(data, this)); + } + + /** @this {string} type */ + function onFakeNavigation(data) { + onNavigation.call(this, data); + msg.sendTab(data.tabId, {method: 'urlChanged'}, {frameId: data.frameId}) + .catch(msg.ignoreError); + } +})(); + +bgReady.all.then(() => { + /* + * Expose style version on greasyfork/sleazyfork 1) info page and 2) code page + * Not using manifest.json as adding a content script disables the extension on update. + */ + const urlMatches = '/scripts/\\d+[^/]*(/code)?([?#].*)?$'; + chrome.webNavigation.onCommitted.addListener(({tabId}) => { + chrome.tabs.executeScript(tabId, { + file: '/content/install-hook-greasyfork.js', + runAt: 'document_start', + }); + }, { + url: [ + {hostEquals: 'greasyfork.org', urlMatches}, + {hostEquals: 'sleazyfork.org', urlMatches}, + ], + }); + /* + * FF misses some about:blank iframes so we inject our content script explicitly + */ + if (FIREFOX) { + chrome.webNavigation.onDOMContentLoaded.addListener(async ({tabId, frameId}) => { + if (frameId && + !await msg.sendTab(tabId, {method: 'ping'}, {frameId}).catch(ignoreChromeError)) { + for (const file of chrome.runtime.getManifest().content_scripts[0].js) { + chrome.tabs.executeScript(tabId, { + frameId, + file, + matchAboutBlank: true, + }, ignoreChromeError); + } + } + }, { + url: [{urlEquals: 'about:blank'}], + }); + } +}); diff --git a/background/navigator-util.js b/background/navigator-util.js deleted file mode 100644 index ad73bf16..00000000 --- a/background/navigator-util.js +++ /dev/null @@ -1,75 +0,0 @@ -/* global CHROME URLS */ -/* exported navigatorUtil */ -'use strict'; - -const navigatorUtil = (() => { - const handler = { - urlChange: null, - }; - 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 browser.tabs.get(data.tabId) - .then(tab => { - const url = tab.pendingUrl || tab.url; - if (url === 'chrome://newtab/') { - data.url = 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/openusercss-api.js b/background/openusercss-api.js index dfd890ff..a1be3a08 100644 --- a/background/openusercss-api.js +++ b/background/openusercss-api.js @@ -1,5 +1,8 @@ +/* global addAPI */// common.js 'use strict'; +/* CURRENTLY UNUSED */ + (() => { // begin:nanographql - Tiny graphQL client library // Author: yoshuawuyts (https://github.com/yoshuawuyts) @@ -25,10 +28,9 @@ // end:nanographql const api = 'https://api.openusercss.org'; - const doQuery = ({id}, queryString) => { + const doQuery = async ({id}, queryString) => { const query = gql(queryString); - - return fetch(api, { + return (await fetch(api, { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json', @@ -36,11 +38,10 @@ body: query({ id, }), - }) - .then(res => res.json()); + })).json(); }; - window.API_METHODS = Object.assign(window.API_METHODS || {}, { + addAPI(/** @namespace- API */ { // TODO: remove "-" when this is implemented /** * This function can be used to retrieve a theme object from the * GraphQL API, set above diff --git a/background/remove-unused-storage.js b/background/remove-unused-storage.js new file mode 100644 index 00000000..1c1a9154 --- /dev/null +++ b/background/remove-unused-storage.js @@ -0,0 +1,15 @@ +/* global chromeLocal */// storage-util.js +'use strict'; + +// Removing unused stuff from storage on extension update +// TODO: delete this by the middle of 2021 + +try { + localStorage.clear(); +} catch (e) {} + +setTimeout(async () => { + const del = Object.keys(await chromeLocal.get()) + .filter(key => key.startsWith('usoSearchCache')); + if (del.length) chromeLocal.remove(del); +}, 15e3); diff --git a/background/style-manager.js b/background/style-manager.js index 20713d15..167328fc 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -1,7 +1,10 @@ -/* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */ -/* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty styleSectionGlobal - getStyleWithNoCode msg prefs sync URLS */ -/* exported styleManager */ +/* global API msg */// msg.js +/* global URLS stringAsRegExp tryRegExp */// toolbox.js +/* global bgReady compareRevision */// common.js +/* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js +/* global db */ +/* global prefs */ +/* global tabMan */ 'use strict'; /* @@ -10,339 +13,287 @@ 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. +to cleanup the temporary code. See livePreview in /edit. */ -/** @type {styleManager} */ -const styleManager = (() => { - const preparing = prepare(); +const styleMan = (() => { - /* styleId => { - data: styleData, - preview: styleData, - appliesTo: Set - } */ - const styles = new Map(); + //#region Declarations + + /** @typedef {{ + style: StyleObj + preview?: StyleObj + appliesTo: Set + }} StyleMapData */ + /** @type {Map} */ + const dataMap = new Map(); const uuidIndex = new Map(); - - /* url => { - maybeMatch: Set, - sections: Object { - id: styleId, - code: Array - }> - } */ + /** @typedef {Object} StyleSectionsToApply */ + /** @type {Map, sections: StyleSectionsToApply}>} */ const cachedStyleForUrl = createCache({ - onDeleted: (url, cache) => { + onDeleted(url, cache) { for (const section of Object.values(cache.sections)) { - const style = styles.get(section.id); - if (style) { - style.appliesTo.delete(url); - } + const data = id2data(section.id); + if (data) data.appliesTo.delete(url); } }, }); - const BAD_MATCHER = {test: () => false}; const compileRe = createCompiler(text => `^(${text})$`); const compileSloppyRe = createCompiler(text => `^${text}$`); const compileExclusion = createCompiler(buildExclusion); + const MISSING_PROPS = { + name: style => `ID: ${style.id}`, + _id: () => uuidv4(), + _rev: () => Date.now(), + }; + const DELETE_IF_NULL = ['id', 'customName']; + /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ + let ready = init(); - const DUMMY_URL = { - hash: '', - host: '', - hostname: '', - href: '', - origin: '', - password: '', - pathname: '', - port: '', - protocol: '', - search: '', - searchParams: new URLSearchParams(), - username: '', + chrome.runtime.onConnect.addListener(handleLivePreview); + + //#endregion + //#region Exports + + return { + + /** @returns {Promise} style id */ + async delete(id, reason) { + if (ready.then) await ready; + const data = id2data(id); + await db.exec('delete', id); + if (reason !== 'sync') { + API.sync.delete(data.style._id, Date.now()); + } + for (const url of data.appliesTo) { + const cache = cachedStyleForUrl.get(url); + if (cache) delete cache.sections[id]; + } + dataMap.delete(id); + uuidIndex.delete(data.style._id); + await msg.broadcast({ + method: 'styleDeleted', + style: {id}, + }); + return id; + }, + + /** @returns {Promise} style id */ + async deleteByUUID(_id, rev) { + if (ready.then) await ready; + const id = uuidIndex.get(_id); + const oldDoc = id && id2style(id); + if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { + // FIXME: does it make sense to set reason to 'sync' in deleteByUUID? + return styleMan.delete(id, 'sync'); + } + }, + + /** @returns {Promise} */ + async editSave(style) { + if (ready.then) await ready; + style = mergeWithMapped(style); + style.updateDate = Date.now(); + return handleSave(await saveStyle(style), 'editSave'); + }, + + /** @returns {Promise} */ + async find(filter) { + if (ready.then) await ready; + const filterEntries = Object.entries(filter); + for (const {style} of dataMap.values()) { + if (filterEntries.every(([key, val]) => style[key] === val)) { + return style; + } + } + return null; + }, + + /** @returns {Promise} */ + async getAll() { + if (ready.then) await ready; + return Array.from(dataMap.values(), data2style); + }, + + /** @returns {Promise} */ + async getByUUID(uuid) { + if (ready.then) await ready; + return id2style(uuidIndex.get(uuid)); + }, + + /** @returns {Promise} */ + async getSectionsByUrl(url, id, isInitialApply) { + if (ready.then) await ready; + if (isInitialApply && prefs.get('disableAll')) { + return {disableAll: true}; + } + /* Chrome hides text frament from location.href of the page e.g. #:~:text=foo + so we'll use the real URL reported by webNavigation API */ + const {tab, frameId} = this && this.sender || {}; + url = tab && tabMan.get(tab.id, 'url', frameId) || url; + let cache = cachedStyleForUrl.get(url); + if (!cache) { + cache = { + sections: {}, + maybeMatch: new Set(), + }; + buildCache(cache, url, dataMap.values()); + cachedStyleForUrl.set(url, cache); + } else if (cache.maybeMatch.size) { + buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean)); + } + return id + ? cache.sections[id] ? {[id]: cache.sections[id]} : {} + : cache.sections; + }, + + /** @returns {Promise} */ + async get(id) { + if (ready.then) await ready; + return id2style(id); + }, + + /** @returns {Promise} */ + async getByUrl(url, id = null) { + if (ready.then) await ready; + // 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 styles = id + ? [id2style(id)].filter(Boolean) + : Array.from(dataMap.values(), data2style); + const query = createMatchQuery(url); + for (const style of styles) { + let excluded = false; + let sloppy = false; + let sectionMatched = false; + const match = urlMatchStyle(query, style); + // TODO: enable this when the function starts returning false + // if (match === false) { + // continue; + // } + if (match === 'excluded') { + excluded = true; + } + for (const section of style.sections) { + if (styleSectionGlobal(section) && styleCodeEmpty(section.code)) { + continue; + } + const match = urlMatchSection(query, section); + if (match) { + if (match === 'sloppy') { + sloppy = true; + } + sectionMatched = true; + break; + } + } + if (sectionMatched) { + result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy}); + } + } + return result; + }, + + /** @returns {Promise} */ + async importMany(items) { + if (ready.then) await ready; + items.forEach(beforeSave); + const events = await db.exec('putMany', items); + return Promise.all(items.map((item, i) => { + afterSave(item, events[i]); + return handleSave(item, 'import'); + })); + }, + + /** @returns {Promise} */ + async import(data) { + if (ready.then) await ready; + return handleSave(await saveStyle(data), 'import'); + }, + + /** @returns {Promise} */ + async install(style, reason = null) { + if (ready.then) await ready; + reason = reason || dataMap.has(style.id) ? 'update' : 'install'; + style = mergeWithMapped(style); + const url = !style.url && style.updateUrl && ( + URLS.extractUsoArchiveInstallUrl(style.updateUrl) || + URLS.extractGreasyForkInstallUrl(style.updateUrl) + ); + if (url) style.url = style.installationUrl = url; + style.originalDigest = await calcStyleDigest(style); + // FIXME: update updateDate? what about usercss config? + return handleSave(await saveStyle(style), reason); + }, + + /** @returns {Promise} */ + async putByUUID(doc) { + if (ready.then) await ready; + const id = uuidIndex.get(doc._id); + if (id) { + doc.id = id; + } else { + delete doc.id; + } + const oldDoc = id && id2style(id); + let diff = -1; + if (oldDoc) { + diff = compareRevision(oldDoc._rev, doc._rev); + if (diff > 0) { + API.sync.put(oldDoc._id, oldDoc._rev); + return; + } + } + if (diff < 0) { + doc.id = await db.exec('put', doc); + uuidIndex.set(doc._id, doc.id); + return handleSave(doc, 'sync'); + } + }, + + /** @returns {Promise} style id */ + async toggle(id, enabled) { + if (ready.then) await ready; + const style = Object.assign({}, id2style(id), {enabled}); + handleSave(await saveStyle(style), 'toggle', false); + return id; + }, + + // using bind() to skip step-into when debugging + + /** @returns {Promise} */ + addExclusion: addIncludeExclude.bind(null, 'exclusions'), + /** @returns {Promise} */ + addInclusion: addIncludeExclude.bind(null, 'inclusions'), + /** @returns {Promise} */ + removeExclusion: removeIncludeExclude.bind(null, 'exclusions'), + /** @returns {Promise} */ + removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), }; - const DELETE_IF_NULL = ['id', 'customName']; + //#endregion + //#region Implementation - handleLivePreviewConnections(); - - return Object.assign(/** @namespace styleManager */{ - compareRevision, - }, ensurePrepared(/** @namespace styleManager */{ - get, - getByUUID, - getSectionsByUrl, - putByUUID, - installStyle, - deleteStyle, - deleteByUUID, - editSave, - findStyle, - importStyle, - importMany, - toggleStyle, - getAllStyles, // used by import-export - getStylesByUrl, // used by popup - styleExists, - addExclusion, - removeExclusion, - addInclusion, - removeInclusion, - })); - - 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'); - } - }); - }); + /** @returns {StyleMapData} */ + function id2data(id) { + return dataMap.get(id); } - function escapeRegExp(text) { - // https://github.com/lodash/lodash/blob/0843bd46ef805dd03c0c8d804630804f3ba0ca3c/lodash.js#L152 - return text.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); + /** @returns {?StyleObj} */ + function id2style(id) { + return (dataMap.get(id) || {}).style; } - function get(id, noCode = false) { - const data = styles.get(id).data; - return noCode ? getStyleWithNoCode(data) : data; - } - - function getByUUID(uuid) { - const id = uuidIndex.get(uuid); - if (id) { - return get(id); - } - } - - function getAllStyles() { - return [...styles.values()].map(s => s.data); - } - - function compareRevision(rev1, rev2) { - return rev1 - rev2; - } - - function putByUUID(doc) { - const id = uuidIndex.get(doc._id); - if (id) { - doc.id = id; - } else { - delete doc.id; - } - const oldDoc = id && styles.has(id) && styles.get(id).data; - let diff = -1; - if (oldDoc) { - diff = compareRevision(oldDoc._rev, doc._rev); - if (diff > 0) { - sync.put(oldDoc._id, oldDoc._rev); - return; - } - } - if (diff < 0) { - return db.exec('put', doc) - .then(event => { - doc.id = event.target.result; - uuidIndex.set(doc._id, doc.id); - return handleSave(doc, 'sync'); - }); - } - } - - function toggleStyle(id, enabled) { - const style = styles.get(id); - const data = Object.assign({}, style.data, {enabled}); - return saveStyle(data) - .then(newData => handleSave(newData, 'toggle', false)) - .then(() => id); - } - - // used by install-hook-userstyles.js - function findStyle(filter, noCode = false) { - for (const style of styles.values()) { - if (filterMatch(filter, style.data)) { - return noCode ? getStyleWithNoCode(style.data) : style.data; - } - } - return null; - } - - function styleExists(filter) { - return [...styles.values()].some(s => filterMatch(filter, s.data)); - } - - function filterMatch(filter, target) { - for (const key of Object.keys(filter)) { - if (filter[key] !== target[key]) { - return false; - } - } - return true; - } - - function importStyle(data) { - // FIXME: is it a good idea to save the data directly? - return saveStyle(data) - .then(newData => handleSave(newData, 'import')); - } - - function importMany(items) { - items.forEach(beforeSave); - return db.exec('putMany', items) - .then(events => { - for (let i = 0; i < items.length; i++) { - afterSave(items[i], events[i].target.result); - } - return Promise.all(items.map(i => handleSave(i, 'import'))); - }); - } - - function installStyle(data, reason = null) { - const style = styles.get(data.id); - if (!style) { - data = Object.assign(createNewStyle(), data); - } else { - data = Object.assign({}, style.data, data); - } - if (!reason) { - reason = style ? 'update' : 'install'; - } - let url = !data.url && data.updateUrl; - if (url) { - const usoId = URLS.extractUsoArchiveId(url); - url = usoId && `${URLS.usoArchive}?style=${usoId}` || - URLS.extractGreasyForkId(url) && url.match(/^.*?\/\d+/)[0]; - if (url) data.url = data.installationUrl = url; - } - // FIXME: update updateDate? what about usercss config? - return calcStyleDigest(data) - .then(digest => { - data.originalDigest = digest; - return saveStyle(data); - }) - .then(newData => handleSave(newData, reason)); - } - - function editSave(data) { - const style = styles.get(data.id); - if (style) { - data = Object.assign({}, style.data, data); - } else { - data = Object.assign(createNewStyle(), data); - } - data.updateDate = Date.now(); - return saveStyle(data) - .then(newData => handleSave(newData, 'editSave')); - } - - function addIncludeExclude(id, rule, type) { - const data = Object.assign({}, styles.get(id).data); - if (!data[type]) { - data[type] = []; - } - if (data[type].includes(rule)) { - throw new Error('The rule already exists'); - } - data[type] = data[type].concat([rule]); - return saveStyle(data) - .then(newData => handleSave(newData, 'styleSettings')); - } - - function removeIncludeExclude(id, rule, type) { - const data = Object.assign({}, styles.get(id).data); - if (!data[type]) { - return; - } - if (!data[type].includes(rule)) { - return; - } - data[type] = data[type].filter(r => r !== rule); - return saveStyle(data) - .then(newData => handleSave(newData, 'styleSettings')); - } - - function addExclusion(id, rule) { - return addIncludeExclude(id, rule, 'exclusions'); - } - - function removeExclusion(id, rule) { - return removeIncludeExclude(id, rule, 'exclusions'); - } - - function addInclusion(id, rule) { - return addIncludeExclude(id, rule, 'inclusions'); - } - - function removeInclusion(id, rule) { - return removeIncludeExclude(id, rule, 'inclusions'); - } - - function deleteStyle(id, reason) { - const style = styles.get(id); - const rev = Date.now(); - return db.exec('delete', id) - .then(() => { - if (reason !== 'sync') { - sync.delete(style.data._id, rev); - } - for (const url of style.appliesTo) { - const cache = cachedStyleForUrl.get(url); - if (cache) { - delete cache.sections[id]; - } - } - styles.delete(id); - uuidIndex.delete(style.data._id); - return msg.broadcast({ - method: 'styleDeleted', - style: {id}, - }); - }) - .then(() => id); - } - - function deleteByUUID(_id, rev) { - const id = uuidIndex.get(_id); - const oldDoc = id && styles.has(id) && styles.get(id).data; - if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { - // FIXME: does it make sense to set reason to 'sync' in deleteByUUID? - return deleteStyle(id, 'sync'); - } - } - - function ensurePrepared(methods) { - const prepared = {}; - for (const [name, fn] of Object.entries(methods)) { - prepared[name] = (...args) => - preparing.then(() => fn(...args)); - } - return prepared; + /** @returns {?StyleObj} */ + function data2style(data) { + return data && data.style; } + /** @returns {StyleObj} */ function createNewStyle() { - return { + return /** @namespace StyleObj */ { enabled: true, updateUrl: null, md5Url: null, @@ -352,43 +303,101 @@ const styleManager = (() => { }; } - function broadcastStyleUpdated(data, reason, method = 'styleUpdated', codeIsUpdated = true) { - const style = styles.get(data.id); + /** @returns {void} */ + function storeInMap(style) { + dataMap.set(style.id, { + style, + appliesTo: new Set(), + }); + } + + /** @returns {StyleObj} */ + function mergeWithMapped(style) { + return Object.assign({}, + id2style(style.id) || createNewStyle(), + style); + } + + function handleLivePreview(port) { + if (port.name !== 'livePreview') { + return; + } + let id; + port.onMessage.addListener(style => { + if (!id) id = style.id; + const data = id2data(id); + data.preview = style; + broadcastStyleUpdated(style, 'editPreview'); + }); + port.onDisconnect.addListener(() => { + port = null; + if (id) { + const data = id2data(id); + if (data) { + data.preview = null; + broadcastStyleUpdated(data.style, 'editPreviewEnd'); + } + } + }); + } + + async function addIncludeExclude(type, id, rule) { + if (ready.then) await ready; + const style = Object.assign({}, id2style(id)); + const list = style[type] || (style[type] = []); + if (list.includes(rule)) { + throw new Error('The rule already exists'); + } + style[type] = list.concat([rule]); + return handleSave(await saveStyle(style), 'styleSettings'); + } + + async function removeIncludeExclude(type, id, rule) { + if (ready.then) await ready; + const style = Object.assign({}, id2style(id)); + const list = style[type]; + if (!list || !list.includes(rule)) { + return; + } + style[type] = list.filter(r => r !== rule); + return handleSave(await saveStyle(style), 'styleSettings'); + } + + function broadcastStyleUpdated(style, reason, method = 'styleUpdated', codeIsUpdated = true) { + const {id} = style; + const data = id2data(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); + if (!data.appliesTo.has(url)) { + cache.maybeMatch.add(id); continue; } - const code = getAppliedCode(createMatchQuery(url), data); - if (!code) { - excluded.add(url); - delete cache.sections[data.id]; - } else { + const code = getAppliedCode(createMatchQuery(url), style); + if (code) { updated.add(url); - cache.sections[data.id] = { - id: data.id, - code, - }; + cache.sections[id] = {id, code}; + } else { + excluded.add(url); + delete cache.sections[id]; } } - style.appliesTo = updated; + data.appliesTo = updated; return msg.broadcast({ method, - style: { - id: data.id, - md5Url: data.md5Url, - enabled: data.enabled, - }, reason, codeIsUpdated, + style: { + id, + md5Url: style.md5Url, + enabled: style.enabled, + }, }); } function beforeSave(style) { if (!style.name) { - throw new Error('style name is empty'); + throw new Error('Style name is empty'); } for (const key of DELETE_IF_NULL) { if (style[key] == null) { @@ -407,114 +416,29 @@ const styleManager = (() => { style.id = newId; } uuidIndex.set(style._id, style.id); - sync.put(style._id, style._rev); + API.sync.put(style._id, style._rev); } - function saveStyle(style) { + async function saveStyle(style) { beforeSave(style); - return db.exec('put', style) - .then(event => { - afterSave(style, event.target.result); - return style; - }); + const newId = await db.exec('put', style); + afterSave(style, newId); + 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'; + function handleSave(style, reason, codeIsUpdated) { + const data = id2data(style.id); + const method = data ? 'styleUpdated' : 'styleAdded'; + if (!data) { + storeInMap(style); } else { - style.data = data; - method = 'styleUpdated'; + data.style = style; } - broadcastStyleUpdated(data, reason, method, codeIsUpdated); - return data; + broadcastStyleUpdated(style, reason, method, codeIsUpdated); + return style; } // 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] : []; - const query = createMatchQuery(url); - for (const data of datas) { - let excluded = false; - let sloppy = false; - let sectionMatched = false; - const match = urlMatchStyle(query, 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 (styleSectionGlobal(section) && styleCodeEmpty(section.code)) { - continue; - } - const match = urlMatchSection(query, section); - if (match) { - if (match === 'sloppy') { - sloppy = true; - } - sectionMatched = true; - break; - } - } - if (sectionMatched) { - result.push({data, excluded, sloppy}); - } - } - return result; - } - - function getSectionsByUrl(url, id, isInitialApply) { - 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)) - ); - } - const res = id - ? cache.sections[id] ? {[id]: cache.sections[id]} : {} - : cache.sections; - // Avoiding flicker of needlessly applied styles by providing both styles & pref in one API call - return isInitialApply && prefs.get('disableAll') - ? Object.assign({disableAll: true}, res) - : res; - - function buildCache(styleList) { - const query = createMatchQuery(url); - for (const {appliesTo, data, preview} of styleList) { - const code = getAppliedCode(query, preview || data); - if (code) { - cache.sections[data.id] = { - id: data.id, - code, - }; - appliesTo.add(url); - } - } - } - } - function getAppliedCode(query, data) { if (urlMatchStyle(query, data) !== true) { return; @@ -528,60 +452,47 @@ const styleManager = (() => { return code.length && code; } - function prepare() { - const ADD_MISSING_PROPS = { - name: style => `ID: ${style.id}`, - _id: () => uuidv4(), - _rev: () => Date.now(), - }; - - return db.exec('getAll') - .then(event => event.target.result || []) - .then(styleList => { - // setup missing _id, _rev - const updated = []; - for (const style of styleList) { - if (addMissingProperties(style)) { - updated.push(style); - } - } - if (updated.length) { - return db.exec('putMany', updated) - .then(() => styleList); - } - return styleList; - }) - .then(styleList => { - for (const style of styleList) { - fixUsoMd5Issue(style); - styles.set(style.id, { - appliesTo: new Set(), - data: style, - }); - uuidIndex.set(style._id, style.id); - } - }); - - function addMissingProperties(style) { - let touched = false; - for (const key in ADD_MISSING_PROPS) { - if (!style[key]) { - style[key] = ADD_MISSING_PROPS[key](style); - touched = true; - } - } - // upgrade the old way of customizing local names - const {originalName} = style; - if (originalName) { - touched = true; - if (originalName !== style.name) { - style.customName = style.name; - style.name = originalName; - } - delete style.originalName; - } - return touched; + async function init() { + const styles = await db.exec('getAll') || []; + const updated = styles.filter(style => + addMissingProps(style) + + addCustomName(style)); + if (updated.length) { + await db.exec('putMany', updated); } + for (const style of styles) { + fixUsoMd5Issue(style); + storeInMap(style); + uuidIndex.set(style._id, style.id); + } + ready = true; + bgReady._resolveStyles(); + } + + function addMissingProps(style) { + let res = 0; + for (const key in MISSING_PROPS) { + if (!style[key]) { + style[key] = MISSING_PROPS[key](style); + res = 1; + } + } + return res; + } + + /** Upgrades the old way of customizing local names */ + function addCustomName(style) { + let res = 0; + const {originalName} = style; + if (originalName) { + res = 1; + if (originalName !== style.name) { + style.customName = style.name; + style.name = originalName; + } + delete style.originalName; + } + return res; } function urlMatchStyle(query, style) { @@ -652,7 +563,8 @@ const styleManager = (() => { } function compileGlob(text) { - return escapeRegExp(text).replace(/\\\\\\\*|\\\*/g, m => m.length > 2 ? m : '.*'); + return stringAsRegExp(text, '', true) + .replace(/\\\\\\\*|\\\*/g, m => m.length > 2 ? m : '.*'); } function buildExclusion(text) { @@ -706,11 +618,36 @@ const styleManager = (() => { }; } + function buildCache(cache, url, styleList) { + const query = createMatchQuery(url); + for (const {style, appliesTo, preview} of styleList) { + const code = getAppliedCode(query, preview || style); + if (code) { + const id = style.id; + cache.sections[id] = {id, code}; + appliesTo.add(url); + } + } + } + function createURL(url) { try { return new URL(url); } catch (err) { - return DUMMY_URL; + return { + hash: '', + host: '', + hostname: '', + href: '', + origin: '', + password: '', + pathname: '', + port: '', + protocol: '', + search: '', + searchParams: new URLSearchParams(), + username: '', + }; } } @@ -726,4 +663,67 @@ const styleManager = (() => { function hex4dashed(num, i) { return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); } + + //#endregion })(); + +/** Creates a FIFO limit-size map. */ +function createCache({size = 1000, onDeleted} = {}) { + const map = new Map(); + const buffer = Array(size); + let index = 0; + let lastIndex = 0; + return { + get(id) { + const item = map.get(id); + return item && item.data; + }, + set(id, data) { + if (map.size === size) { + // full + map.delete(buffer[lastIndex].id); + if (onDeleted) { + onDeleted(buffer[lastIndex].id, buffer[lastIndex].data); + } + lastIndex = (lastIndex + 1) % size; + } + const item = {id, data, index}; + map.set(id, item); + buffer[index] = item; + index = (index + 1) % size; + }, + delete(id) { + const item = map.get(id); + if (!item) { + return false; + } + map.delete(item.id); + const lastItem = buffer[lastIndex]; + lastItem.index = item.index; + buffer[item.index] = lastItem; + lastIndex = (lastIndex + 1) % size; + if (onDeleted) { + onDeleted(item.id, item.data); + } + return true; + }, + clear() { + map.clear(); + index = lastIndex = 0; + }, + has: id => map.has(id), + *entries() { + for (const [id, item] of map) { + yield [id, item.data]; + } + }, + *values() { + for (const item of map.values()) { + yield item.data; + } + }, + get size() { + return map.size; + }, + }; +} diff --git a/background/search-db.js b/background/style-search-db.js similarity index 54% rename from background/search-db.js rename to background/style-search-db.js index fcea0a15..ca8e6e06 100644 --- a/background/search-db.js +++ b/background/style-search-db.js @@ -1,11 +1,6 @@ -/* global - API_METHODS - debounce - stringAsRegExp - styleManager - tryRegExp - usercss -*/ +/* global API */// msg.js +/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js +/* global addAPI */// common.js 'use strict'; (() => { @@ -15,12 +10,12 @@ const extractMeta = style => style.usercssData - ? (style.sourceCode.match(usercss.RX_META) || [''])[0] + ? (style.sourceCode.match(URLS.rxMETA) || [''])[0] : null; const stripMeta = style => style.usercssData - ? style.sourceCode.replace(usercss.RX_META, '') + ? style.sourceCode.replace(URLS.rxMETA, '') : null; const MODES = Object.assign(Object.create(null), { @@ -31,8 +26,8 @@ meta: (style, test, part) => METAKEYS.some(key => test(style[key])) || - test(part === 'all' ? style.sourceCode : extractMeta(style)) || - searchSections(style, test, 'funcs'), + test(part === 'all' ? style.sourceCode : extractMeta(style)) || + searchSections(style, test, 'funcs'), name: (style, test) => test(style.customName) || @@ -43,33 +38,37 @@ !style.usercssData && MODES.code(style, test), }); - /** - * @param params - * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed") - * @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all] - * @param {number[]} [params.ids] - if not specified, all styles are searched - * @returns {number[]} - array of matched styles ids - */ - API_METHODS.searchDB = async ({query, mode = 'all', ids}) => { - let res = []; - if (mode === 'url' && query) { - res = (await styleManager.getStylesByUrl(query)).map(r => r.data.id); - } else if (mode in MODES) { - const modeHandler = MODES[mode]; - const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query); - const rx = m && tryRegExp(m[1], m[2]); - const test = rx ? rx.test.bind(rx) : makeTester(query); - res = (await styleManager.getAllStyles()) - .filter(style => - (!ids || ids.includes(style.id)) && - (!query || modeHandler(style, test))) - .map(style => style.id); - if (cache.size) debounce(clearCache, 60e3); - } - return res; - }; + addAPI(/** @namespace API */ { + styles: { + /** + * @param params + * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed") + * @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all] + * @param {number[]} [params.ids] - if not specified, all styles are searched + * @returns {number[]} - array of matched styles ids + */ + async searchDB({query, mode = 'all', ids}) { + let res = []; + if (mode === 'url' && query) { + res = (await API.styles.getByUrl(query)).map(r => r.style.id); + } else if (mode in MODES) { + const modeHandler = MODES[mode]; + const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query); + const rx = m && tryRegExp(m[1], m[2]); + const test = rx ? rx.test.bind(rx) : createTester(query); + res = (await API.styles.getAll()) + .filter(style => + (!ids || ids.includes(style.id)) && + (!query || modeHandler(style, test))) + .map(style => style.id); + if (cache.size) debounce(clearCache, 60e3); + } + return res; + }, + }, + }); - function makeTester(query) { + function createTester(query) { const flags = `u${lower(query) === query ? 'i' : ''}`; const words = query .split(/(".*?")|\s+/) diff --git a/background/style-via-api.js b/background/style-via-api.js index 6793c65c..ed08dac4 100644 --- a/background/style-via-api.js +++ b/background/style-via-api.js @@ -1,7 +1,14 @@ -/* global API_METHODS styleManager CHROME prefs */ +/* global API */// msg.js +/* global addAPI */// common.js +/* global isEmptyObj */// toolbox.js +/* global prefs */ 'use strict'; -API_METHODS.styleViaAPI = !CHROME && (() => { +/** + * Uses chrome.tabs.insertCSS + */ + +(() => { const ACTIONS = { styleApply, styleDeleted, @@ -11,25 +18,25 @@ API_METHODS.styleViaAPI = !CHROME && (() => { prefChanged, updateCount, }; - const NOP = Promise.resolve(new Error('NOP')); + const NOP = new Error('NOP'); const onError = () => {}; - /* : Object : Object url: String, non-enumerable : Array of strings section code */ const cache = new Map(); - let observingTabs = false; - return function (request) { - const action = ACTIONS[request.method]; - return !action ? NOP : - action(request, this.sender) - .catch(onError) - .then(maybeToggleObserver); - }; + addAPI(/** @namespace API */ { + async styleViaAPI(request) { + try { + const fn = ACTIONS[request.method]; + return fn ? fn(request, this.sender) : NOP; + } catch (e) {} + maybeToggleObserver(); + }, + }); function updateCount(request, sender) { const {tab, frameId} = sender; @@ -37,7 +44,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { throw new Error('we do not count styles for frames'); } const {frameStyles} = getCachedData(tab.id, frameId); - API_METHODS.updateIconBadge.call({sender}, Object.keys(frameStyles)); + API.updateIconBadge.call({sender}, Object.keys(frameStyles)); } function styleApply({id = null, ignoreUrlCheck = false}, {tab, frameId, url}) { @@ -48,7 +55,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { if (id === null && !ignoreUrlCheck && frameStyles.url === url) { return NOP; } - return styleManager.getSectionsByUrl(url, id).then(sections => { + return API.styles.getSectionsByUrl(url, id).then(sections => { const tasks = []; for (const section of Object.values(sections)) { const styleId = section.id; @@ -125,7 +132,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { } const {tab, frameId} = sender; const {tabFrames, frameStyles} = getCachedData(tab.id, frameId); - if (isEmpty(frameStyles)) { + if (isEmptyObj(frameStyles)) { return NOP; } removeFrameIfEmpty(tab.id, frameId, tabFrames, {}); @@ -162,7 +169,7 @@ API_METHODS.styleViaAPI = !CHROME && (() => { const tabFrames = cache.get(tabId); if (tabFrames && frameId in tabFrames) { delete tabFrames[frameId]; - if (isEmpty(tabFrames)) { + if (isEmptyObj(tabFrames)) { onTabRemoved(tabId); } } @@ -178,9 +185,9 @@ API_METHODS.styleViaAPI = !CHROME && (() => { } function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) { - if (isEmpty(frameStyles)) { + if (isEmptyObj(frameStyles)) { delete tabFrames[frameId]; - if (isEmpty(tabFrames)) { + if (isEmptyObj(tabFrames)) { cache.delete(tabId); } return true; @@ -223,11 +230,4 @@ API_METHODS.styleViaAPI = !CHROME && (() => { return browser.tabs.removeCSS(tabId, {frameId, code, matchAboutBlank: true}) .catch(onError); } - - function isEmpty(obj) { - for (const k in obj) { - return false; - } - return true; - } })(); diff --git a/background/style-via-webrequest.js b/background/style-via-webrequest.js index 86ead33c..0586a463 100644 --- a/background/style-via-webrequest.js +++ b/background/style-via-webrequest.js @@ -1,101 +1,103 @@ -/* global API CHROME prefs */ +/* global API */// msg.js +/* global CHROME */// toolbox.js +/* global prefs */ 'use strict'; -// eslint-disable-next-line no-unused-expressions -CHROME && (async () => { +(() => { const idCSP = 'patchCsp'; const idOFF = 'disableAll'; const idXHR = 'styleViaXhr'; const rxHOST = /^('none'|(https?:\/\/)?[^']+?[^:'])$/; // strips CSP sources covered by * const blobUrlPrefix = 'blob:' + chrome.runtime.getURL('/'); + /** @type {Object} */ const stylesToPass = {}; - const enabled = {}; + const state = {}; + const injectedCode = CHROME && `${data => { + if (self.INJECTED !== 1) { // storing data only if apply.js hasn't run yet + window[Symbol.for('styles')] = data; + } + }}`; - await prefs.initializing; - prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true}); + toggle(); + prefs.subscribe([idXHR, idOFF, idCSP], toggle); function toggle() { - const csp = prefs.get(idCSP) && !prefs.get(idOFF); - const xhr = prefs.get(idXHR) && !prefs.get(idOFF) && Boolean(chrome.declarativeContent); - if (xhr === enabled.xhr && csp === enabled.csp) { + const off = prefs.get(idOFF); + const csp = prefs.get(idCSP) && !off; + const xhr = prefs.get(idXHR) && !off; + if (xhr === state.xhr && csp === state.csp && off === state.off) { return; } - // Need to unregister first so that the optional EXTRA_HEADERS is properly registered + const reqFilter = { + urls: ['*://*/*'], + types: ['main_frame', 'sub_frame'], + }; + chrome.webNavigation.onCommitted.removeListener(injectData); chrome.webRequest.onBeforeRequest.removeListener(prepareStyles); chrome.webRequest.onHeadersReceived.removeListener(modifyHeaders); if (xhr || csp) { - const reqFilter = { - urls: [''], - types: ['main_frame', 'sub_frame'], - }; - chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); + // We unregistered it above so that the optional EXTRA_HEADERS is properly re-registered chrome.webRequest.onHeadersReceived.addListener(modifyHeaders, reqFilter, [ 'blocking', 'responseHeaders', xhr && chrome.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS, ].filter(Boolean)); } - if (enabled.xhr !== xhr) { - enabled.xhr = xhr; - toggleEarlyInjection(); + if (CHROME ? !off : xhr || csp) { + chrome.webRequest.onBeforeRequest.addListener(prepareStyles, reqFilter); } - enabled.csp = csp; - } - - /** Runs content scripts earlier than document_start */ - function toggleEarlyInjection() { - const api = chrome.declarativeContent; - if (!api) return; - api.onPageChanged.removeRules([idXHR], async () => { - if (enabled.xhr) { - api.onPageChanged.addRules([{ - id: idXHR, - conditions: [ - new api.PageStateMatcher({ - pageUrl: {urlContains: '://'}, - }), - ], - actions: [ - new api.RequestContentScript({ - js: chrome.runtime.getManifest().content_scripts[0].js, - allFrames: true, - }), - ], - }]); - } - }); + if (CHROME && !off) { + chrome.webNavigation.onCommitted.addListener(injectData, {url: [{urlPrefix: 'http'}]}); + } + state.csp = csp; + state.off = off; + state.xhr = xhr; } /** @param {chrome.webRequest.WebRequestBodyDetails} req */ - function prepareStyles(req) { - API.getSectionsByUrl(req.url).then(sections => { - if (Object.keys(sections).length) { - stylesToPass[req.requestId] = !enabled.xhr ? true : - URL.createObjectURL(new Blob([JSON.stringify(sections)])).slice(blobUrlPrefix.length); - setTimeout(cleanUp, 600e3, req.requestId); - } - }); + async function prepareStyles(req) { + const sections = await API.styles.getSectionsByUrl(req.url); + stylesToPass[req2key(req)] = /** @namespace StylesToPass */ { + blobId: '', + str: JSON.stringify(sections), + timer: setTimeout(cleanUp, 600e3, req), + }; + } + + function injectData(req) { + const data = stylesToPass[req2key(req)]; + if (data && !data.injected) { + data.injected = true; + chrome.tabs.executeScript(req.tabId, { + frameId: req.frameId, + runAt: 'document_start', + code: `(${injectedCode})(${data.str})`, + }); + if (!state.xhr) cleanUp(req); + } } /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ function modifyHeaders(req) { const {responseHeaders} = req; - const id = stylesToPass[req.requestId]; - if (!id) { + const data = stylesToPass[req2key(req)]; + if (!data || data.str === '{}') { + cleanUp(req); return; } - if (enabled.xhr) { + if (state.xhr) { + data.blobId = URL.createObjectURL(new Blob([data.str])).slice(blobUrlPrefix.length); responseHeaders.push({ name: 'Set-Cookie', - value: `${chrome.runtime.id}=${id}`, + value: `${chrome.runtime.id}=${data.blobId}`, }); } - const csp = enabled.csp && + const csp = state.csp && responseHeaders.find(h => h.name.toLowerCase() === 'content-security-policy'); if (csp) { patchCsp(csp); } - if (enabled.xhr || csp) { + if (state.xhr || csp) { return {responseHeaders}; } } @@ -111,7 +113,7 @@ CHROME && (async () => { patchCspSrc(src, 'img-src', 'data:', '*'); patchCspSrc(src, 'font-src', 'data:', '*'); // Allow our DOM styles - patchCspSrc(src, 'style-src', '\'unsafe-inline\''); + patchCspSrc(src, 'style-src', "'unsafe-inline'"); // Allow our XHR cookies in CSP sandbox (known case: raw github urls) if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { src.sandbox.push('allow-same-origin'); @@ -132,9 +134,19 @@ CHROME && (async () => { } } - function cleanUp(key) { - const blobId = stylesToPass[key]; - delete stylesToPass[key]; - if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); + function cleanUp(req) { + const key = req2key(req); + const data = stylesToPass[key]; + if (data) { + delete stylesToPass[key]; + clearTimeout(data.timer); + if (data.blobId) { + URL.revokeObjectURL(blobUrlPrefix + data.blobId); + } + } + } + + function req2key(req) { + return req.tabId + ':' + req.frameId; } })(); diff --git a/background/sync-manager.js b/background/sync-manager.js new file mode 100644 index 00000000..0d5938f5 --- /dev/null +++ b/background/sync-manager.js @@ -0,0 +1,225 @@ +/* global API msg */// msg.js +/* global chromeLocal */// storage-util.js +/* global compareRevision */// common.js +/* global prefs */ +/* global tokenMan */ +'use strict'; + +const syncMan = (() => { + //#region Init + + const SYNC_DELAY = 1; // minutes + const SYNC_INTERVAL = 30; // minutes + const STATES = Object.freeze({ + connected: 'connected', + connecting: 'connecting', + disconnected: 'disconnected', + disconnecting: 'disconnecting', + }); + const STORAGE_KEY = 'sync/state/'; + const status = /** @namespace SyncManager.Status */ { + STATES, + state: STATES.disconnected, + syncing: false, + progress: null, + currentDriveName: null, + errorMessage: null, + login: false, + }; + let ctrl; + let currentDrive; + /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ + let ready = prefs.ready.then(() => { + ready = true; + prefs.subscribe('sync.enabled', + (_, val) => val === 'none' + ? syncMan.stop() + : syncMan.start(val, true), + {runNow: true}); + }); + + chrome.alarms.onAlarm.addListener(info => { + if (info.name === 'syncNow') { + syncMan.syncNow(); + } + }); + + //#endregion + //#region Exports + + return { + + async delete(...args) { + if (ready.then) await ready; + if (!currentDrive) return; + schedule(); + return ctrl.delete(...args); + }, + + /** @returns {Promise} */ + async getStatus() { + return status; + }, + + async login(name = prefs.get('sync.enabled')) { + if (ready.then) await ready; + try { + await tokenMan.getToken(name, true); + } catch (err) { + if (/Authorization page could not be loaded/i.test(err.message)) { + // FIXME: Chrome always fails at the first login so we try again + await tokenMan.getToken(name); + } + throw err; + } + status.login = true; + emitStatusChange(); + }, + + async put(...args) { + if (ready.then) await ready; + if (!currentDrive) return; + schedule(); + return ctrl.put(...args); + }, + + async start(name, fromPref = false) { + if (ready.then) await ready; + if (!ctrl) await initController(); + if (currentDrive) return; + currentDrive = getDrive(name); + ctrl.use(currentDrive); + status.state = STATES.connecting; + status.currentDriveName = currentDrive.name; + status.login = true; + emitStatusChange(); + try { + if (!fromPref) { + await syncMan.login(name).catch(handle401Error); + } + await syncMan.syncNow(); + status.errorMessage = null; + } catch (err) { + status.errorMessage = err.message; + // FIXME: should we move this logic to options.js? + if (!fromPref) { + console.error(err); + return syncMan.stop(); + } + } + prefs.set('sync.enabled', name); + status.state = STATES.connected; + schedule(SYNC_INTERVAL); + emitStatusChange(); + }, + + async stop() { + if (ready.then) await ready; + if (!currentDrive) return; + chrome.alarms.clear('syncNow'); + status.state = STATES.disconnecting; + emitStatusChange(); + try { + await ctrl.stop(); + await tokenMan.revokeToken(currentDrive.name); + await chromeLocal.remove(STORAGE_KEY + currentDrive.name); + } catch (e) {} + currentDrive = null; + prefs.set('sync.enabled', 'none'); + status.state = STATES.disconnected; + status.currentDriveName = null; + status.login = false; + emitStatusChange(); + }, + + async syncNow() { + if (ready.then) await ready; + if (!currentDrive) throw new Error('cannot sync when disconnected'); + try { + await (ctrl.isInit() ? ctrl.syncNow() : ctrl.start()).catch(handle401Error); + status.errorMessage = null; + } catch (err) { + status.errorMessage = err.message; + } + emitStatusChange(); + }, + }; + + //#endregion + //#region Utils + + async function initController() { + await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */ + ctrl = dbToCloud.dbToCloud({ + onGet(id) { + return API.styles.getByUUID(id); + }, + onPut(doc) { + return API.styles.putByUUID(doc); + }, + onDelete(id, rev) { + return API.styles.deleteByUUID(id, rev); + }, + async onFirstSync() { + for (const i of await API.styles.getAll()) { + ctrl.put(i._id, i._rev); + } + }, + onProgress(e) { + if (e.phase === 'start') { + status.syncing = true; + } else if (e.phase === 'end') { + status.syncing = false; + status.progress = null; + } else { + status.progress = e; + } + emitStatusChange(); + }, + compareRevision, + getState(drive) { + return chromeLocal.getValue(STORAGE_KEY + drive.name); + }, + setState(drive, state) { + return chromeLocal.setValue(STORAGE_KEY + drive.name, state); + }, + }); + } + + async function handle401Error(err) { + let emit; + if (err.code === 401) { + await tokenMan.revokeToken(currentDrive.name).catch(console.error); + emit = true; + } else if (/User interaction required|Requires user interaction/i.test(err.message)) { + emit = true; + } + if (emit) { + status.login = false; + emitStatusChange(); + } + return Promise.reject(err); + } + + function emitStatusChange() { + msg.broadcastExtension({method: 'syncStatusUpdate', status}); + } + + function getDrive(name) { + if (name === 'dropbox' || name === 'google' || name === 'onedrive') { + return dbToCloud.drive[name]({ + getAccessToken: () => tokenMan.getToken(name), + }); + } + throw new Error(`unknown cloud name: ${name}`); + } + + function schedule(delay = SYNC_DELAY) { + chrome.alarms.create('syncNow', { + delayInMinutes: delay, + periodInMinutes: SYNC_INTERVAL, + }); + } + + //#endregion +})(); diff --git a/background/sync.js b/background/sync.js deleted file mode 100644 index 6581e732..00000000 --- a/background/sync.js +++ /dev/null @@ -1,236 +0,0 @@ -/* global dbToCloud styleManager chromeLocal prefs tokenManager msg */ -/* exported sync */ - -'use strict'; - -const sync = (() => { - const SYNC_DELAY = 1; // minutes - const SYNC_INTERVAL = 30; // minutes - - const status = { - state: 'disconnected', - syncing: false, - progress: null, - currentDriveName: null, - errorMessage: null, - login: false, - }; - let currentDrive; - const ctrl = dbToCloud.dbToCloud({ - onGet(id) { - return styleManager.getByUUID(id); - }, - onPut(doc) { - return styleManager.putByUUID(doc); - }, - onDelete(id, rev) { - return styleManager.deleteByUUID(id, rev); - }, - onFirstSync() { - return styleManager.getAllStyles() - .then(styles => { - styles.forEach(i => ctrl.put(i._id, i._rev)); - }); - }, - onProgress, - compareRevision(a, b) { - return styleManager.compareRevision(a, b); - }, - getState(drive) { - const key = `sync/state/${drive.name}`; - return chromeLocal.getValue(key); - }, - setState(drive, state) { - const key = `sync/state/${drive.name}`; - return chromeLocal.setValue(key, state); - }, - }); - - const initializing = prefs.initializing.then(() => { - prefs.subscribe(['sync.enabled'], onPrefChange); - onPrefChange(null, prefs.get('sync.enabled')); - }); - - chrome.alarms.onAlarm.addListener(info => { - if (info.name === 'syncNow') { - syncNow().catch(console.error); - } - }); - - return Object.assign({ - getStatus: () => status, - }, ensurePrepared({ - start, - stop, - put: (...args) => { - if (!currentDrive) return; - schedule(); - return ctrl.put(...args); - }, - delete: (...args) => { - if (!currentDrive) return; - schedule(); - return ctrl.delete(...args); - }, - syncNow, - login, - })); - - function ensurePrepared(obj) { - return Object.entries(obj).reduce((o, [key, fn]) => { - o[key] = (...args) => - initializing.then(() => fn(...args)); - return o; - }, {}); - } - - function onProgress(e) { - if (e.phase === 'start') { - status.syncing = true; - } else if (e.phase === 'end') { - status.syncing = false; - status.progress = null; - } else { - status.progress = e; - } - emitStatusChange(); - } - - function schedule(delay = SYNC_DELAY) { - chrome.alarms.create('syncNow', { - delayInMinutes: delay, - periodInMinutes: SYNC_INTERVAL, - }); - } - - function onPrefChange(key, value) { - if (value === 'none') { - stop().catch(console.error); - } else { - start(value, true).catch(console.error); - } - } - - function withFinally(p, cleanup) { - return p.then( - result => { - cleanup(undefined, result); - return result; - }, - err => { - cleanup(err); - throw err; - } - ); - } - - function syncNow() { - if (!currentDrive) { - return Promise.reject(new Error('cannot sync when disconnected')); - } - return withFinally( - (ctrl.isInit() ? ctrl.syncNow() : ctrl.start()) - .catch(handle401Error), - err => { - status.errorMessage = err ? err.message : null; - emitStatusChange(); - } - ); - } - - function handle401Error(err) { - if (err.code === 401) { - return tokenManager.revokeToken(currentDrive.name) - .catch(console.error) - .then(() => { - status.login = false; - emitStatusChange(); - throw err; - }); - } - if (/User interaction required|Requires user interaction/i.test(err.message)) { - status.login = false; - emitStatusChange(); - } - throw err; - } - - function emitStatusChange() { - msg.broadcastExtension({method: 'syncStatusUpdate', status}); - } - - function login(name = prefs.get('sync.enabled')) { - return tokenManager.getToken(name, true) - .catch(err => { - if (/Authorization page could not be loaded/i.test(err.message)) { - // FIXME: Chrome always fails at the first login so we try again - return tokenManager.getToken(name); - } - throw err; - }) - .then(() => { - status.login = true; - emitStatusChange(); - }); - } - - function start(name, fromPref = false) { - if (currentDrive) { - return Promise.resolve(); - } - currentDrive = getDrive(name); - ctrl.use(currentDrive); - status.state = 'connecting'; - status.currentDriveName = currentDrive.name; - status.login = true; - emitStatusChange(); - return withFinally( - (fromPref ? Promise.resolve() : login(name)) - .catch(handle401Error) - .then(() => syncNow()), - err => { - status.errorMessage = err ? err.message : null; - // FIXME: should we move this logic to options.js? - if (err && !fromPref) { - console.error(err); - return stop(); - } - prefs.set('sync.enabled', name); - schedule(SYNC_INTERVAL); - status.state = 'connected'; - emitStatusChange(); - } - ); - } - - function getDrive(name) { - if (name === 'dropbox' || name === 'google' || name === 'onedrive') { - return dbToCloud.drive[name]({ - getAccessToken: () => tokenManager.getToken(name), - }); - } - throw new Error(`unknown cloud name: ${name}`); - } - - function stop() { - if (!currentDrive) { - return Promise.resolve(); - } - chrome.alarms.clear('syncNow'); - status.state = 'disconnecting'; - emitStatusChange(); - return withFinally( - ctrl.stop() - .then(() => tokenManager.revokeToken(currentDrive.name)) - .then(() => chromeLocal.remove(`sync/state/${currentDrive.name}`)), - () => { - currentDrive = null; - prefs.set('sync.enabled', 'none'); - status.state = 'disconnected'; - status.currentDriveName = null; - status.login = false; - emitStatusChange(); - } - ); - } -})(); diff --git a/background/tab-manager.js b/background/tab-manager.js index 49061fcf..ca5b183d 100644 --- a/background/tab-manager.js +++ b/background/tab-manager.js @@ -1,32 +1,37 @@ -/* global navigatorUtil */ -/* exported tabManager */ +/* global bgReady */// common.js +/* global navMan */ 'use strict'; -const tabManager = (() => { - const listeners = []; +const tabMan = (() => { + const listeners = new Set(); const cache = new Map(); chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId)); chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed)); - navigatorUtil.onUrlChange(({tabId, frameId, url}) => { - if (frameId) return; - const oldUrl = tabManager.get(tabId, 'url'); - tabManager.set(tabId, 'url', url); - for (const fn of listeners) { - try { - fn({tabId, url, oldUrl}); - } catch (err) { - console.error(err); + + bgReady.all.then(() => { + navMan.onUrlChange(({tabId, frameId, url}) => { + const oldUrl = !frameId && tabMan.get(tabId, 'url', frameId); + tabMan.set(tabId, 'url', frameId, url); + if (frameId) return; + for (const fn of listeners) { + try { + fn({tabId, url, oldUrl}); + } catch (err) { + console.error(err); + } } - } + }); }); return { onUpdate(fn) { - listeners.push(fn); + listeners.add(fn); }, + get(tabId, ...keys) { return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId)); }, + /** * number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta * (tabId, 'foo', 123) will set tabId's meta to {foo: 123}, @@ -47,8 +52,10 @@ const tabManager = (() => { meta[lastKey] = value; } }, + list() { return cache.keys(); }, }; + })(); diff --git a/background/token-manager.js b/background/token-manager.js index a5738e0f..4c2e7f0c 100644 --- a/background/token-manager.js +++ b/background/token-manager.js @@ -1,8 +1,9 @@ -/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */ -/* exported tokenManager */ +/* global FIREFOX */// toolbox.js +/* global chromeLocal */// storage-util.js 'use strict'; -const tokenManager = (() => { +/* exported tokenMan */ +const tokenMan = (() => { const AUTH = { dropbox: { flow: 'token', @@ -50,64 +51,58 @@ const tokenManager = (() => { }; const NETWORK_LATENCY = 30; // seconds - return {getToken, revokeToken, getClientId, buildKeys}; + return { - function getClientId(name) { - return AUTH[name].clientId; - } + buildKeys(name) { + const k = { + TOKEN: `secure/token/${name}/token`, + EXPIRE: `secure/token/${name}/expire`, + REFRESH: `secure/token/${name}/refresh`, + }; + k.LIST = Object.values(k); + return k; + }, - function buildKeys(name) { - const k = { - TOKEN: `secure/token/${name}/token`, - EXPIRE: `secure/token/${name}/expire`, - REFRESH: `secure/token/${name}/refresh`, - }; - k.LIST = Object.values(k); - return k; - } + getClientId(name) { + return AUTH[name].clientId; + }, - function getToken(name, interactive) { - const k = buildKeys(name); - return chromeLocal.get(k.LIST) - .then(obj => { - if (!obj[k.TOKEN]) { - return authUser(name, k, interactive); - } + async getToken(name, interactive) { + const k = tokenMan.buildKeys(name); + const obj = await chromeLocal.get(k.LIST); + if (obj[k.TOKEN]) { if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) { return obj[k.TOKEN]; } if (obj[k.REFRESH]) { - return refreshToken(name, k, obj) - .catch(err => { - if (err.code === 401) { - return authUser(name, k, interactive); - } - throw err; - }); + try { + return await refreshToken(name, k, obj); + } catch (err) { + if (err.code !== 401) throw err; + } } - return authUser(name, k, interactive); - }); - } - - async function revokeToken(name) { - const provider = AUTH[name]; - const k = buildKeys(name); - if (provider.revoke) { - try { - const token = await chromeLocal.getValue(k.TOKEN); - if (token) { - await provider.revoke(token); - } - } catch (e) { - console.error(e); } - } - await chromeLocal.remove(k.LIST); - } + return authUser(name, k, interactive); + }, - function refreshToken(name, k, obj) { + async revokeToken(name) { + const provider = AUTH[name]; + const k = tokenMan.buildKeys(name); + if (provider.revoke) { + try { + const token = await chromeLocal.getValue(k.TOKEN); + if (token) await provider.revoke(token); + } catch (e) { + console.error(e); + } + } + await chromeLocal.remove(k.LIST); + }, + }; + + async function refreshToken(name, k, obj) { if (!obj[k.REFRESH]) { - return Promise.reject(new Error('no refresh token')); + throw new Error('No refresh token'); } const provider = AUTH[name]; const body = { @@ -119,17 +114,17 @@ const tokenManager = (() => { if (provider.clientSecret) { body.client_secret = provider.clientSecret; } - return postQuery(provider.tokenURL, body) - .then(result => { - if (!result.refresh_token) { - // reuse old refresh token - result.refresh_token = obj[k.REFRESH]; - } - return handleTokenResult(result, k); - }); + const result = await postQuery(provider.tokenURL, body); + if (!result.refresh_token) { + // reuse old refresh token + result.refresh_token = obj[k.REFRESH]; + } + return handleTokenResult(result, k); } - function authUser(name, k, interactive = false) { + async function authUser(name, k, interactive = false) { + await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']); + /* global webextLaunchWebAuthFlow */ const provider = AUTH[name]; const state = Math.random().toFixed(8).slice(2); const query = { @@ -145,52 +140,54 @@ const tokenManager = (() => { Object.assign(query, provider.authQuery); } const url = `${provider.authURL}?${new URLSearchParams(query)}`; - return webextLaunchWebAuthFlow({ + const finalUrl = await webextLaunchWebAuthFlow({ url, interactive, redirect_uri: query.redirect_uri, - }) - .then(url => { - const params = new URLSearchParams( - provider.flow === 'token' ? - new URL(url).hash.slice(1) : - new URL(url).search.slice(1) - ); - if (params.get('state') !== state) { - throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`); - } - if (provider.flow === 'token') { - const obj = {}; - for (const [key, value] of params.entries()) { - obj[key] = value; - } - return obj; - } - const code = params.get('code'); - const body = { - code, - grant_type: 'authorization_code', - client_id: provider.clientId, - redirect_uri: query.redirect_uri, - }; - if (provider.clientSecret) { - body.client_secret = provider.clientSecret; - } - return postQuery(provider.tokenURL, body); - }) - .then(result => handleTokenResult(result, k)); + }); + const params = new URLSearchParams( + provider.flow === 'token' ? + new URL(finalUrl).hash.slice(1) : + new URL(finalUrl).search.slice(1) + ); + if (params.get('state') !== state) { + throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`); + } + let result; + if (provider.flow === 'token') { + const obj = {}; + for (const [key, value] of params) { + obj[key] = value; + } + result = obj; + } else { + const code = params.get('code'); + const body = { + code, + grant_type: 'authorization_code', + client_id: provider.clientId, + redirect_uri: query.redirect_uri, + }; + if (provider.clientSecret) { + body.client_secret = provider.clientSecret; + } + result = await postQuery(provider.tokenURL, body); + } + return handleTokenResult(result, k); } - function handleTokenResult(result, k) { - return chromeLocal.set({ + async function handleTokenResult(result, k) { + await chromeLocal.set({ [k.TOKEN]: result.access_token, - [k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined, + [k.EXPIRE]: result.expires_in + ? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000 + : undefined, [k.REFRESH]: result.refresh_token, - }) - .then(() => result.access_token); + }); + return result.access_token; } - function postQuery(url, body) { + async function postQuery(url, body) { const options = { method: 'POST', headers: { @@ -198,17 +195,13 @@ const tokenManager = (() => { }, body: body ? new URLSearchParams(body) : null, }; - return fetch(url, options) - .then(r => { - if (r.ok) { - return r.json(); - } - return r.text() - .then(body => { - const err = new Error(`failed to fetch (${r.status}): ${body}`); - err.code = r.status; - throw err; - }); - }); + const r = await fetch(url, options); + if (r.ok) { + return r.json(); + } + const text = await r.text(); + const err = new Error(`Failed to fetch (${r.status}): ${text}`); + err.code = r.status; + throw err; } })(); diff --git a/background/update-manager.js b/background/update-manager.js new file mode 100644 index 00000000..88e3b48c --- /dev/null +++ b/background/update-manager.js @@ -0,0 +1,251 @@ +/* global API */// msg.js +/* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js +/* global chromeLocal */// storage-util.js +/* global debounce download ignoreChromeError */// toolbox.js +/* global prefs */ +'use strict'; + +/* exported updateMan */ +const updateMan = (() => { + const STATES = /** @namespace UpdaterStates */ { + UPDATED: 'updated', + SKIPPED: 'skipped', + UNREACHABLE: 'server unreachable', + // details for SKIPPED status + EDITED: 'locally edited', + MAYBE_EDITED: 'may be locally edited', + SAME_MD5: 'up-to-date: MD5 is unchanged', + SAME_CODE: 'up-to-date: code sections are unchanged', + SAME_VERSION: 'up-to-date: version is unchanged', + ERROR_MD5: 'error: MD5 is invalid', + ERROR_JSON: 'error: JSON is invalid', + ERROR_VERSION: 'error: version is older than installed style', + }; + + const ALARM_NAME = 'scheduledUpdate'; + const MIN_INTERVAL_MS = 60e3; + const RETRY_ERRORS = [ + 503, // service unavailable + 429, // too many requests + ]; + let lastUpdateTime; + let checkingAll = false; + let logQueue = []; + let logLastWriteTime = 0; + + chromeLocal.getValue('lastUpdateTime').then(val => { + lastUpdateTime = val || Date.now(); + prefs.subscribe('updateInterval', schedule, {runNow: true}); + chrome.alarms.onAlarm.addListener(onAlarm); + }); + + return { + checkAllStyles, + checkStyle, + getStates: () => STATES, + }; + + async function checkAllStyles({ + save = true, + ignoreDigest, + observe, + } = {}) { + resetInterval(); + checkingAll = true; + const port = observe && chrome.runtime.connect({name: 'updater'}); + const styles = (await API.styles.getAll()) + .filter(style => style.updateUrl); + if (port) port.postMessage({count: styles.length}); + log(''); + log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`); + await Promise.all( + styles.map(style => + checkStyle({style, port, save, ignoreDigest}))); + if (port) port.postMessage({done: true}); + if (port) port.disconnect(); + log(''); + checkingAll = false; + } + + /** + * @param {{ + id?: number + style?: StyleObj + port?: chrome.runtime.Port + save?: boolean = true + ignoreDigest?: boolean + }} opts + * @returns {{ + style: StyleObj + updated?: boolean + error?: any + STATES: UpdaterStates + }} + + Original style digests are calculated in these cases: + * style is installed or updated from server + * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged + + Update check proceeds in these cases: + * style has the original digest and it's equal to the current digest + * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it + * [ignoreDigest: none/false] style doesn't yet have the original digest + so we compare the code to the server code and if it's the same we save the digest, + otherwise we skip the style and report MAYBE_EDITED status + + 'ignoreDigest' option is set on the second manual individual update check on the manage page. + */ + async function checkStyle(opts) { + const { + id, + style = await API.styles.get(id), + ignoreDigest, + port, + save, + } = opts; + const ucd = style.usercssData; + let res, state; + try { + await checkIfEdited(); + res = { + style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), + updated: true, + }; + state = STATES.UPDATED; + } catch (err) { + const error = err === 0 && STATES.UNREACHABLE || + err && err.message || + err; + res = {error, style, STATES}; + state = `${STATES.SKIPPED} (${error})`; + } + log(`${state} #${style.id} ${style.customName || style.name}`); + if (port) port.postMessage(res); + return res; + + async function checkIfEdited() { + if (!ignoreDigest && + style.originalDigest && + style.originalDigest !== await calcStyleDigest(style)) { + return Promise.reject(STATES.EDITED); + } + } + + async function updateUSO() { + const md5 = await tryDownload(style.md5Url); + if (!md5 || md5.length !== 32) { + return Promise.reject(STATES.ERROR_MD5); + } + if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { + return Promise.reject(STATES.SAME_MD5); + } + const json = await tryDownload(style.updateUrl, {responseType: 'json'}); + if (!styleJSONseemsValid(json)) { + return Promise.reject(STATES.ERROR_JSON); + } + // USO may not provide a correctly updated originalMd5 (#555) + json.originalMd5 = md5; + return json; + } + + async function updateUsercss() { + // TODO: when sourceCode is > 100kB use http range request(s) for version check + const text = await tryDownload(style.updateUrl); + const json = await API.usercss.buildMeta({sourceCode: text}); + await require(['/vendor/semver-bundle/semver']); /* global semverCompare */ + const delta = semverCompare(json.usercssData.version, ucd.version); + if (!delta && !ignoreDigest) { + // re-install is invalid in a soft upgrade + const sameCode = text === style.sourceCode; + return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); + } + if (delta < 0) { + // downgrade is always invalid + return Promise.reject(STATES.ERROR_VERSION); + } + return API.usercss.buildCode(json); + } + + async function maybeSave(json) { + json.id = style.id; + json.updateDate = Date.now(); + // keep current state + delete json.customName; + delete json.enabled; + const newStyle = Object.assign({}, style, json); + // update digest even if save === false as there might be just a space added etc. + if (!ucd && styleSectionsEqual(json, style)) { + style.originalDigest = (await API.styles.install(newStyle)).originalDigest; + return Promise.reject(STATES.SAME_CODE); + } + if (!style.originalDigest && !ignoreDigest) { + return Promise.reject(STATES.MAYBE_EDITED); + } + return !save ? newStyle : + (ucd ? API.usercss.install : API.styles.install)(newStyle); + } + + async function tryDownload(url, params) { + let {retryDelay = 1000} = opts; + while (true) { + try { + return await download(url, params); + } catch (code) { + if (!RETRY_ERRORS.includes(code) || + retryDelay > MIN_INTERVAL_MS) { + return Promise.reject(code); + } + } + retryDelay *= 1.25; + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + + function schedule() { + const interval = prefs.get('updateInterval') * 60 * 60 * 1000; + if (interval > 0) { + const elapsed = Math.max(0, Date.now() - lastUpdateTime); + chrome.alarms.create(ALARM_NAME, { + when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed), + }); + } else { + chrome.alarms.clear(ALARM_NAME, ignoreChromeError); + } + } + + function onAlarm({name}) { + if (name === ALARM_NAME) checkAllStyles(); + } + + function resetInterval() { + chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now()); + schedule(); + } + + function log(text) { + logQueue.push({text, time: new Date().toLocaleString()}); + debounce(flushQueue, text && checkingAll ? 1000 : 0); + } + + async function flushQueue(lines) { + if (!lines) { + flushQueue(await chromeLocal.getValue('updateLog') || []); + return; + } + const time = Date.now() - logLastWriteTime > 11e3 ? + logQueue[0].time + ' ' : + ''; + if (logQueue[0] && !logQueue[0].text) { + logQueue.shift(); + if (lines[lines.length - 1]) lines.push(''); + } + lines.splice(0, lines.length - 1000); + lines.push(time + (logQueue[0] && logQueue[0].text || '')); + lines.push(...logQueue.slice(1).map(item => item.text)); + + chromeLocal.setValue('updateLog', lines); + logLastWriteTime = Date.now(); + logQueue = []; + } +})(); diff --git a/background/update.js b/background/update.js deleted file mode 100644 index f4c248ae..00000000 --- a/background/update.js +++ /dev/null @@ -1,290 +0,0 @@ -/* global - API_METHODS - calcStyleDigest - chromeLocal - debounce - download - getStyleWithNoCode - ignoreChromeError - prefs - semverCompare - styleJSONseemsValid - styleManager - styleSectionsEqual - tryJSONparse - usercss -*/ -'use strict'; - -(() => { - - const STATES = { - UPDATED: 'updated', - SKIPPED: 'skipped', - - // details for SKIPPED status - EDITED: 'locally edited', - MAYBE_EDITED: 'may be locally edited', - SAME_MD5: 'up-to-date: MD5 is unchanged', - SAME_CODE: 'up-to-date: code sections are unchanged', - SAME_VERSION: 'up-to-date: version is unchanged', - ERROR_MD5: 'error: MD5 is invalid', - ERROR_JSON: 'error: JSON is invalid', - ERROR_VERSION: 'error: version is older than installed style', - }; - - const ALARM_NAME = 'scheduledUpdate'; - const MIN_INTERVAL_MS = 60e3; - - let lastUpdateTime; - let checkingAll = false; - let logQueue = []; - let logLastWriteTime = 0; - - const retrying = new Set(); - - API_METHODS.updateCheckAll = checkAllStyles; - API_METHODS.updateCheck = checkStyle; - API_METHODS.getUpdaterStates = () => STATES; - - chromeLocal.getValue('lastUpdateTime').then(val => { - lastUpdateTime = val || Date.now(); - prefs.subscribe('updateInterval', schedule, {now: true}); - chrome.alarms.onAlarm.addListener(onAlarm); - }); - - return {checkAllStyles, checkStyle, STATES}; - - function checkAllStyles({ - save = true, - ignoreDigest, - observe, - } = {}) { - resetInterval(); - checkingAll = true; - retrying.clear(); - const port = observe && chrome.runtime.connect({name: 'updater'}); - return styleManager.getAllStyles().then(styles => { - styles = styles.filter(style => style.updateUrl); - if (port) port.postMessage({count: styles.length}); - log(''); - log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`); - return Promise.all( - styles.map(style => - checkStyle({style, port, save, ignoreDigest}))); - }).then(() => { - if (port) port.postMessage({done: true}); - if (port) port.disconnect(); - log(''); - checkingAll = false; - retrying.clear(); - }); - } - - function checkStyle({ - id, - style, - port, - save = true, - ignoreDigest, - }) { - /* - Original style digests are calculated in these cases: - * style is installed or updated from server - * style is checked for an update and its code is equal to the server code - - Update check proceeds in these cases: - * style has the original digest and it's equal to the current digest - * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it - * [ignoreDigest: none/false] style doesn't yet have the original digest - so we compare the code to the server code and if it's the same we save the digest, - otherwise we skip the style and report MAYBE_EDITED status - - 'ignoreDigest' option is set on the second manual individual update check on the manage page. - */ - return fetchStyle() - .then(() => { - if (!ignoreDigest) { - return calcStyleDigest(style) - .then(checkIfEdited); - } - }) - .then(() => { - if (style.usercssData) { - return maybeUpdateUsercss(); - } - return maybeUpdateUSO(); - }) - .then(maybeSave) - .then(reportSuccess) - .catch(reportFailure); - - function fetchStyle() { - if (style) { - return Promise.resolve(); - } - return styleManager.get(id) - .then(style_ => { - style = style_; - }); - } - - function reportSuccess(saved) { - log(STATES.UPDATED + ` #${style.id} ${style.customName || style.name}`); - const info = {updated: true, style: saved}; - if (port) port.postMessage(info); - return info; - } - - function reportFailure(error) { - if (( - error === 503 || // Service Unavailable - error === 429 // Too Many Requests - ) && !retrying.has(id)) { - retrying.add(id); - return new Promise(resolve => { - setTimeout(() => { - resolve(checkStyle({id, style, port, save, ignoreDigest})); - }, 1000); - }); - } - error = error === 0 ? 'server unreachable' : error; - // UserCSS metadata error returns an object; e.g. "Invalid @var color..." - if (typeof error === 'object' && error.message) { - error = error.message; - } - log(STATES.SKIPPED + ` (${error}) #${style.id} ${style.customName || style.name}`); - const info = {error, STATES, style: getStyleWithNoCode(style)}; - if (port) port.postMessage(info); - return info; - } - - function checkIfEdited(digest) { - if (style.originalDigest && style.originalDigest !== digest) { - return Promise.reject(STATES.EDITED); - } - } - - function maybeUpdateUSO() { - return download(style.md5Url).then(md5 => { - if (!md5 || md5.length !== 32) { - return Promise.reject(STATES.ERROR_MD5); - } - if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { - return Promise.reject(STATES.SAME_MD5); - } - // USO can't handle POST requests for style json - return download(style.updateUrl, {body: null}) - .then(text => { - const style = tryJSONparse(text); - if (style) { - // USO may not provide a correctly updated originalMd5 (#555) - style.originalMd5 = md5; - } - return style; - }); - }); - } - - function maybeUpdateUsercss() { - // TODO: when sourceCode is > 100kB use http range request(s) for version check - 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 = {}) { - // usercss is already validated while building - if (!json.usercssData && !styleJSONseemsValid(json)) { - return Promise.reject(STATES.ERROR_JSON); - } - - json.id = style.id; - json.updateDate = Date.now(); - - // keep current state - delete json.enabled; - - const newStyle = Object.assign({}, style, json); - if (!style.usercssData && styleSectionsEqual(json, style)) { - // update digest even if save === false as there might be just a space added etc. - return styleManager.installStyle(newStyle) - .then(saved => { - style.originalDigest = saved.originalDigest; - return Promise.reject(STATES.SAME_CODE); - }); - } - - if (!style.originalDigest && !ignoreDigest) { - return Promise.reject(STATES.MAYBE_EDITED); - } - - return save ? - API_METHODS[json.usercssData ? 'installUsercss' : 'installStyle'](newStyle) : - newStyle; - } - } - - function schedule() { - const interval = prefs.get('updateInterval') * 60 * 60 * 1000; - if (interval > 0) { - const elapsed = Math.max(0, Date.now() - lastUpdateTime); - chrome.alarms.create(ALARM_NAME, { - when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed), - }); - } else { - chrome.alarms.clear(ALARM_NAME, ignoreChromeError); - } - } - - function onAlarm({name}) { - if (name === ALARM_NAME) checkAllStyles(); - } - - function resetInterval() { - chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now()); - schedule(); - } - - function log(text) { - logQueue.push({text, time: new Date().toLocaleString()}); - debounce(flushQueue, text && checkingAll ? 1000 : 0); - } - - async function flushQueue(lines) { - if (!lines) { - flushQueue(await chromeLocal.getValue('updateLog') || []); - return; - } - const time = Date.now() - logLastWriteTime > 11e3 ? - logQueue[0].time + ' ' : - ''; - if (logQueue[0] && !logQueue[0].text) { - logQueue.shift(); - if (lines[lines.length - 1]) lines.push(''); - } - lines.splice(0, lines.length - 1000); - lines.push(time + (logQueue[0] && logQueue[0].text || '')); - lines.push(...logQueue.slice(1).map(item => item.text)); - - chromeLocal.setValue('updateLog', lines); - logLastWriteTime = Date.now(); - logQueue = []; - } -})(); diff --git a/background/usercss-helper.js b/background/usercss-helper.js deleted file mode 100644 index acb1e1af..00000000 --- a/background/usercss-helper.js +++ /dev/null @@ -1,132 +0,0 @@ -/* global API_METHODS usercss styleManager deepCopy */ -/* exported usercssHelper */ -'use strict'; - -const usercssHelper = (() => { - API_METHODS.installUsercss = installUsercss; - API_METHODS.editSaveUsercss = editSaveUsercss; - API_METHODS.configUsercssVars = configUsercssVars; - - API_METHODS.buildUsercss = build; - API_METHODS.buildUsercssMeta = buildMeta; - API_METHODS.findUsercss = find; - - function buildMeta(style) { - if (style.usercssData) { - return Promise.resolve(style); - } - - // allow sourceCode to be normalized - const {sourceCode} = style; - delete style.sourceCode; - - return usercss.buildMeta(sourceCode) - .then(newStyle => Object.assign(newStyle, style)); - } - - function assignVars(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, find the duplication, and build sections with variables - * @param _ - * @param {String} _.sourceCode - * @param {Boolean=} _.checkDup - * @param {Boolean=} _.metaOnly - * @param {Object} _.vars - * @param {Boolean=} _.assignVars - * @returns {Promise<{style, dup:Boolean?}>} - */ - function build({ - styleId, - sourceCode, - checkDup, - metaOnly, - vars, - assignVars = false, - }) { - return usercss.buildMeta(sourceCode) - .then(style => { - const findDup = checkDup || assignVars ? - find(styleId ? {id: styleId} : style) : Promise.resolve(); - return Promise.all([ - metaOnly ? style : doBuild(style, findDup), - findDup, - ]); - }) - .then(([style, dup]) => ({style, dup})); - - function doBuild(style, findDup) { - if (vars || assignVars) { - const getOld = vars ? Promise.resolve({usercssData: {vars}}) : findDup; - return getOld - .then(oldStyle => usercss.assignVars(style, oldStyle)) - .then(() => usercss.buildCode(style)); - } - return usercss.buildCode(style); - } - } - - // 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); - } - - /** - * @param {Style|{name:string, namespace:string}} styleOrData - * @returns {Style} - */ - function find(styleOrData) { - 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; - } - } - }); - } -})(); diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js index 5bf61fdb..b33052a1 100644 --- a/background/usercss-install-helper.js +++ b/background/usercss-install-helper.js @@ -1,37 +1,22 @@ -/* global - API_METHODS - download - openURL - tabManager - URLS -*/ +/* global URLS download openURL */// toolbox.js +/* global addAPI bgReady */// common.js +/* global tabMan */// msg.js 'use strict'; -(() => { +bgReady.all.then(() => { const installCodeCache = {}; - const clearInstallCode = url => delete installCodeCache[url]; - /** Sites may be using custom types like text/stylus so this coarse filter only excludes html */ - const isContentTypeText = type => /^text\/(?!html)/i.test(type); - // in Firefox we have to use a content script to read file:// - const fileLoader = !chrome.app && ( - async tabId => - (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]); - - const urlLoader = - async (tabId, url) => ( - url.startsWith('file:') || - tabManager.get(tabId, isContentTypeText.name) || - isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) - ) && download(url); - - API_METHODS.getUsercssInstallCode = url => { - // when the installer tab is reloaded after the cache is expired, this will throw intentionally - const {code, timer} = installCodeCache[url]; - clearInstallCode(url); - clearTimeout(timer); - return code; - }; + addAPI(/** @namespace API */ { + usercss: { + getInstallCode(url) { + // when the installer tab is reloaded after the cache is expired, this will throw intentionally + const {code, timer} = installCodeCache[url]; + clearInstallCode(url); + clearTimeout(timer); + return code; + }, + }, + }); // `glob`: pathname match pattern for webRequest // `rx`: pathname regex to verify the URL really looks like a raw usercss @@ -48,17 +33,7 @@ }, }; - // Faster installation on known distribution sites to avoid flicker of css text - chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => { - const u = new URL(url); - const m = maybeDistro[u.hostname]; - if (!m || m.rx.test(u.pathname)) { - openInstallerPage(tabId, url, {}); - // Silently suppress navigation. - // Don't redirect to the install URL as it'll flash the text! - return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url - } - }, { + chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, { urls: [ URLS.usoArchiveRaw + 'usercss/*.user.css', '*://greasyfork.org/scripts/*/code/*.user.css', @@ -70,27 +45,63 @@ types: ['main_frame'], }, ['blocking']); - // Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow - chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => { - const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type'); - tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); - }, { + chrome.webRequest.onHeadersReceived.addListener(rememberContentType, { urls: makeUsercssGlobs('*', '/*'), types: ['main_frame'], }, ['responseHeaders']); - tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => { + tabMan.onUpdate(maybeInstall); + + function clearInstallCode(url) { + return delete installCodeCache[url]; + } + + /** Sites may be using custom types like text/stylus so this coarse filter only excludes html */ + function isContentTypeText(type) { + return /^text\/(?!html)/i.test(type); + } + + // in Firefox we have to use a content script to read file:// + async function loadFromFile(tabId) { + return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]; + } + + async function loadFromUrl(tabId, url) { + return ( + url.startsWith('file:') || + tabMan.get(tabId, isContentTypeText.name) || + isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) + ) && download(url); + } + + function makeUsercssGlobs(host, path) { + return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(','); + } + + async function maybeInstall({tabId, url, oldUrl = ''}) { if (url.includes('.user.') && /^(https?|file|ftps?):/.test(url) && /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) && !oldUrl.startsWith(URLS.installUsercss)) { - const inTab = url.startsWith('file:') && Boolean(fileLoader); - const code = await (inTab ? fileLoader : urlLoader)(tabId, url); - if (/==userstyle==/i.test(code) && !/^\s* h.name.toLowerCase() === 'content-type'); + tabMan.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); } -})(); +}); diff --git a/background/usercss-manager.js b/background/usercss-manager.js new file mode 100644 index 00000000..88993c1f --- /dev/null +++ b/background/usercss-manager.js @@ -0,0 +1,152 @@ +/* global API */// msg.js +/* global URLS deepCopy download */// toolbox.js +'use strict'; + +const usercssMan = { + + GLOBAL_META: Object.entries({ + author: null, + description: null, + homepageURL: 'url', + updateURL: 'updateUrl', + name: null, + }), + + async assignVars(style, oldStyle) { + const meta = style.usercssData; + const vars = meta.vars; + const oldVars = oldStyle.usercssData.vars; + if (vars && oldVars) { + // The type of var might be changed during the update. Set value to null if the value is invalid. + for (const [key, v] of Object.entries(vars)) { + const old = oldVars[key] && oldVars[key].value; + if (old) v.value = old; + } + meta.vars = await API.worker.nullifyInvalidVars(vars); + } + }, + + async build({ + styleId, + sourceCode, + vars, + checkDup, + metaOnly, + assignVars, + initialUrl, + }) { + // downloading here while install-usercss page is loading to avoid the wait + if (initialUrl) sourceCode = await download(initialUrl); + const style = await usercssMan.buildMeta({sourceCode}); + const dup = (checkDup || assignVars) && + await usercssMan.find(styleId ? {id: styleId} : style); + if (!metaOnly) { + if (vars || assignVars) { + await usercssMan.assignVars(style, vars ? {usercssData: {vars}} : dup); + } + await usercssMan.buildCode(style); + } + return {style, dup}; + }, + + async buildCode(style) { + const {sourceCode: code, usercssData: {vars, preprocessor}} = style; + const match = code.match(URLS.rxMETA); + const i = match.index; + const j = i + match[0].length; + const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j); + const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars); + const recoverable = errors.every(e => e.recoverable); + if (!sections.length || !recoverable) { + throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.'; + } + style.sections = sections; + return style; + }, + + async buildMeta(style) { + if (style.usercssData) { + return style; + } + // remember normalized sourceCode + let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n'); + style = Object.assign({ + enabled: true, + sections: [], + }, style); + const match = code.match(URLS.rxMETA); + if (!match) { + return Promise.reject(new Error('Could not find metadata.')); + } + try { + code = blankOut(code, 0, match.index) + match[0]; + const {metadata} = await API.worker.parseUsercssMeta(code); + style.usercssData = metadata; + // https://github.com/openstyles/stylus/issues/560#issuecomment-440561196 + for (const [key, globalKey] of usercssMan.GLOBAL_META) { + const val = metadata[key]; + if (val !== undefined) { + style[globalKey || key] = val; + } + } + return style; + } catch (err) { + if (err.code) { + const args = err.code === 'missingMandatory' || err.code === 'missingChar' + ? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ') + : err.args; + const msg = chrome.i18n.getMessage(`meta_${(err.code)}`, args); + if (msg) err.message = msg; + } + return Promise.reject(err); + } + }, + + async configVars(id, vars) { + const style = deepCopy(await API.styles.get(id)); + style.usercssData.vars = vars; + await usercssMan.buildCode(style); + return (await API.styles.install(style, 'config')) + .usercssData.vars; + }, + + async editSave(style) { + return API.styles.editSave(await usercssMan.parse(style)); + }, + + async find(styleOrData) { + if (styleOrData.id) { + return API.styles.get(styleOrData.id); + } + const {name, namespace} = styleOrData.usercssData || styleOrData; + for (const dup of await API.styles.getAll()) { + const data = dup.usercssData; + if (data && + data.name === name && + data.namespace === namespace) { + return dup; + } + } + }, + + async install(style) { + return API.styles.install(await usercssMan.parse(style)); + }, + + async parse(style) { + style = await usercssMan.buildMeta(style); + // preserve style.vars during update + const dup = await usercssMan.find(style); + if (dup) { + style.id = dup.id; + await usercssMan.assignVars(style, dup); + } + return usercssMan.buildCode(style); + }, +}; + +/** Replaces everything with spaces to keep the original length, + * but preserves the line breaks to keep the original line/col relation */ +function blankOut(str, start = 0, end = str.length) { + return str.slice(start, end).replace(/[^\r\n]/g, ' '); +} diff --git a/content/apply.js b/content/apply.js index e2bad4e2..786b4948 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,34 +1,22 @@ -/* global msg API prefs createStyleInjector */ +/* global API msg */// msg.js +/* global StyleInjector */ +/* global prefs */ 'use strict'; -// Chrome reruns content script when documentElement is replaced. -// Note, we're checking against a literal `1`, not just `if (truthy)`, -// because is exposed per HTML spec as a global variable and `window.INJECTED`. +(() => { + if (window.INJECTED === 1) return; -// eslint-disable-next-line no-unused-expressions -self.INJECTED !== 1 && (() => { - self.INJECTED = 1; - - let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html'; - const IS_FRAME = window !== parent; - const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; - const styleInjector = createStyleInjector({ + let hasStyles = false; + let isTab = !chrome.tabs || location.pathname !== '/popup.html'; + const isFrame = window !== parent; + const isFrameAboutBlank = isFrame && location.href === 'about:blank'; + const isUnstylable = !chrome.app && document instanceof XMLDocument; + const styleInjector = StyleInjector({ compare: (a, b) => a.id - b.id, onUpdate: onInjectorUpdate, }); - const initializing = init(); - /** @type chrome.runtime.Port */ - let port; - let lazyBadge = IS_FRAME; - let parentDomain; - - // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason - if (!IS_TAB) { - chrome.tabs.getCurrent(tab => { - IS_TAB = Boolean(tab); - if (tab && styleInjector.list.length) updateCount(); - }); - } + // dynamic about: and javascript: iframes don't have a URL yet so we'll use their parent + const matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href) || location.href; // save it now because chrome.runtime will be unavailable in the orphaned script const orphanEventId = chrome.runtime.id; @@ -36,6 +24,22 @@ self.INJECTED !== 1 && (() => { // firefox doesn't orphanize content scripts so the old elements stay if (!chrome.app) styleInjector.clearOrphans(); + /** @type chrome.runtime.Port */ + let port; + let lazyBadge = isFrame; + let parentDomain; + + // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let + const ready = init(); + + // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason + if (!isTab) { + chrome.tabs.getCurrent(tab => { + isTab = Boolean(tab); + if (tab && styleInjector.list.length) updateCount(); + }); + } + msg.onTab(applyOnMessage); if (!chrome.tabs) { @@ -47,103 +51,97 @@ self.INJECTED !== 1 && (() => { if (!isOrphaned) { updateCount(); const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe']; - onOff(['disableAll'], updateDisableAll); - if (IS_FRAME) { + onOff('disableAll', updateDisableAll); + if (isFrame) { updateExposeIframes(); - onOff(['exposeIframes'], updateExposeIframes); + onOff('exposeIframes', updateExposeIframes); } } } async function init() { - if (STYLE_VIA_API) { + if (isUnstylable) { await API.styleViaAPI({method: 'styleApply'}); } else { - const styles = chrome.app && !chrome.tabs && getStylesViaXhr() || - await API.getSectionsByUrl(getMatchUrl(), null, true); - if (styles.disableAll) { - delete styles.disableAll; - styleInjector.toggle(false); + const SYM_ID = 'styles'; + const SYM = Symbol.for(SYM_ID); + const styles = + window[SYM] || + (isFrameAboutBlank + ? tryCatch(() => parent[parent.Symbol.for(SYM_ID)]) + : chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr)) || + await API.styles.getSectionsByUrl(matchUrl, null, true); + hasStyles = !styles.disableAll; + if (hasStyles) { + window[SYM] = styles; + await styleInjector.apply(styles); + } else { + delete window[SYM]; + prefs.subscribe('disableAll', updateDisableAll); } - await styleInjector.apply(styles); } } + /** Must be executed inside try/catch */ function getStylesViaXhr() { - try { - const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0]; - const url = 'blob:' + chrome.runtime.getURL(blobId); - document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie - const xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); // synchronous - xhr.send(); - URL.revokeObjectURL(url); - return JSON.parse(xhr.response); - } catch (e) {} - } - - function getMatchUrl() { - let matchUrl = location.href; - if (!chrome.tabs && !matchUrl.match(/^(http|file|chrome|ftp)/)) { - // dynamic about: and javascript: iframes don't have an URL yet - // so we'll try the parent frame which is guaranteed to have a real URL - try { - if (IS_FRAME) { - matchUrl = parent.location.href; - } - } catch (e) {} - } - return matchUrl; + const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0]; + const url = 'blob:' + chrome.runtime.getURL(blobId); + document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); // synchronous + xhr.send(); + URL.revokeObjectURL(url); + return JSON.parse(xhr.response); } function applyOnMessage(request) { - if (STYLE_VIA_API) { - if (request.method === 'urlChanged') { + const {method} = request; + if (isUnstylable) { + if (method === 'urlChanged') { request.method = 'styleReplaceAll'; } - if (/^(style|updateCount)/.test(request.method)) { + if (/^(style|updateCount)/.test(method)) { API.styleViaAPI(request); return; } } - switch (request.method) { + const {style} = request; + switch (method) { case 'ping': return true; case 'styleDeleted': - styleInjector.remove(request.style.id); + styleInjector.remove(style.id); break; case 'styleUpdated': - if (request.style.enabled) { - API.getSectionsByUrl(getMatchUrl(), request.style.id) - .then(sections => { - if (!sections[request.style.id]) { - styleInjector.remove(request.style.id); - } else { - styleInjector.apply(sections); - } - }); + if (style.enabled) { + API.styles.getSectionsByUrl(matchUrl, style.id).then(sections => + sections[style.id] + ? styleInjector.apply(sections) + : styleInjector.remove(style.id)); } else { - styleInjector.remove(request.style.id); + styleInjector.remove(style.id); } break; case 'styleAdded': - if (request.style.enabled) { - API.getSectionsByUrl(getMatchUrl(), request.style.id) + if (style.enabled) { + API.styles.getSectionsByUrl(matchUrl, style.id) .then(styleInjector.apply); } break; case 'urlChanged': - API.getSectionsByUrl(getMatchUrl()) - .then(styleInjector.replace); + API.styles.getSectionsByUrl(matchUrl).then(sections => { + hasStyles = true; + styleInjector.replace(sections); + }); break; case 'backgroundReady': - initializing.catch(err => + ready.catch(err => msg.isIgnorableError(err) ? init() : console.error(err)); @@ -156,8 +154,10 @@ self.INJECTED !== 1 && (() => { } function updateDisableAll(key, disableAll) { - if (STYLE_VIA_API) { + if (isUnstylable) { API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}}); + } else if (!hasStyles && !disableAll) { + init(); } else { styleInjector.toggle(!disableAll); } @@ -179,8 +179,8 @@ self.INJECTED !== 1 && (() => { } function updateCount() { - if (!IS_TAB) return; - if (IS_FRAME) { + if (!isTab) return; + if (isFrame) { if (!port && styleInjector.list.length) { port = chrome.runtime.connect({name: 'iframe'}); } else if (port && !styleInjector.list.length) { @@ -188,23 +188,25 @@ self.INJECTED !== 1 && (() => { } if (lazyBadge && performance.now() > 1000) lazyBadge = false; } - (STYLE_VIA_API ? + (isUnstylable ? API.styleViaAPI({method: 'updateCount'}) : API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge}) ).catch(msg.ignoreError); } - function orphanCheck() { + function tryCatch(func, ...args) { try { - if (chrome.i18n.getUILanguage()) return; + return func(...args); } catch (e) {} + } + + function orphanCheck() { + if (tryCatch(() => chrome.i18n.getUILanguage())) return; // In Chrome content script is orphaned on an extension update/reload // so we need to detach event listeners window.removeEventListener(orphanEventId, orphanCheck, true); isOrphaned = true; styleInjector.clear(); - try { - msg.off(applyOnMessage); - } catch (e) {} + tryCatch(msg.off, applyOnMessage); } })(); diff --git a/content/install-hook-greasyfork.js b/content/install-hook-greasyfork.js index 9e125530..7750d4fe 100644 --- a/content/install-hook-greasyfork.js +++ b/content/install-hook-greasyfork.js @@ -1,4 +1,4 @@ -/* global API */ +/* global API */// msg.js 'use strict'; // onCommitted may fire twice @@ -13,7 +13,7 @@ if (window.INJECTED_GREASYFORK !== 1) { e.data.name && e.data.type === 'style-version-query') { removeEventListener('message', onMessage); - const style = await API.findUsercss(e.data) || {}; + const style = await API.usercss.find(e.data) || {}; const {version} = style.usercssData || {}; postMessage({type: 'style-version', version}, '*'); } diff --git a/content/install-hook-openusercss.js b/content/install-hook-openusercss.js index f85fb4da..8fcab797 100644 --- a/content/install-hook-openusercss.js +++ b/content/install-hook-openusercss.js @@ -1,4 +1,4 @@ -/* global API */ +/* global API */// msg.js 'use strict'; (() => { @@ -34,7 +34,7 @@ && event.data.type === 'ouc-is-installed' && allowedOrigins.includes(event.origin) ) { - API.findUsercss({ + API.usercss.find({ name: event.data.name, namespace: event.data.namespace, }).then(style => { @@ -55,7 +55,7 @@ window.addEventListener('message', installedHandler); }; - const doHandshake = () => { + const doHandshake = event => { // This is a representation of features that Stylus is capable of const implementedFeatures = [ 'install-usercss', @@ -106,7 +106,7 @@ && event.data.type === 'ouc-handshake-question' && allowedOrigins.includes(event.origin) ) { - doHandshake(); + doHandshake(event); } }; @@ -129,7 +129,7 @@ && event.data.type === 'ouc-install-usercss' && allowedOrigins.includes(event.origin) ) { - API.installUsercss({ + API.usercss.install({ name: event.data.title, sourceCode: event.data.code, }).then(style => { diff --git a/content/install-hook-usercss.js b/content/install-hook-usercss.js index 6e8be595..42352377 100644 --- a/content/install-hook-usercss.js +++ b/content/install-hook-usercss.js @@ -1,19 +1,21 @@ 'use strict'; // preventing reregistration if reinjected by tabs.executeScript for whatever reason, just in case -if (typeof self.oldCode !== 'string') { - self.oldCode = (document.querySelector('body > pre') || document.body).textContent; +if (typeof window.oldCode !== 'string') { + window.oldCode = (document.querySelector('body > pre') || document.body).textContent; chrome.runtime.onConnect.addListener(port => { if (port.name !== 'downloadSelf') return; - port.onMessage.addListener(({id, force}) => { - fetch(location.href, {mode: 'same-origin'}) - .then(r => r.text()) - .then(code => ({id, code: force || code !== self.oldCode ? code : null})) - .catch(error => ({id, error: error.message || `${error}`})) - .then(msg => { - port.postMessage(msg); - if (msg.code != null) self.oldCode = msg.code; - }); + port.onMessage.addListener(async ({id, force}) => { + const msg = {id}; + try { + const code = await (await fetch(location.href, {mode: 'same-origin'})).text(); + if (code !== window.oldCode || force) { + msg.code = window.oldCode = code; + } + } catch (error) { + msg.error = error.message || `${error}`; + } + port.postMessage(msg); }); // FF keeps content scripts connected on navigation https://github.com/openstyles/stylus/issues/864 addEventListener('pagehide', () => port.disconnect(), {once: true}); @@ -21,4 +23,4 @@ if (typeof self.oldCode !== 'string') { } // passing the result to tabs.executeScript -self.oldCode; // eslint-disable-line no-unused-expressions +window.oldCode; // eslint-disable-line no-unused-expressions diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index 1a5f4842..db67b909 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -1,4 +1,4 @@ -/* global cloneInto msg API */ +/* global API msg */// msg.js 'use strict'; // eslint-disable-next-line no-unused-expressions @@ -14,17 +14,10 @@ msg.on(onMessage); - onDOMready().then(() => { - window.postMessage({ - direction: 'from-content-script', - message: 'StylishInstalled', - }, '*'); - }); - let currentMd5; const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`; Promise.all([ - API.findStyle({md5Url}), + API.styles.find({md5Url}), getResource(md5Url), onDOMready(), ]).then(checkUpdatability); @@ -119,7 +112,7 @@ if (typeof cloneInto !== 'undefined') { // Firefox requires explicit cloning, however USO can't process our messages anyway // because USO tries to use a global "event" variable deprecated in Firefox - detail = cloneInto({detail}, document); + detail = cloneInto({detail}, document); /* global cloneInto */ } else { detail = {detail}; } @@ -154,9 +147,9 @@ function doInstall() { let oldStyle; - return API.findStyle({ + return API.styles.find({ md5Url: getMeta('stylish-md5-url') || location.href, - }, true) + }) .then(_oldStyle => { oldStyle = _oldStyle; return oldStyle ? @@ -172,7 +165,7 @@ }); } - function saveStyleCode(message, name, addProps = {}) { + async function saveStyleCode(message, name, addProps = {}) { const isNew = message === 'styleInstall'; const needsConfirmation = isNew || !saveStyleCode.confirmed; if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) { @@ -180,22 +173,19 @@ } saveStyleCode.confirmed = true; enableUpdateButton(false); - return getStyleJson().then(json => { - if (!json) { - prompt(chrome.i18n.getMessage('styleInstallFailed', ''), - 'https://github.com/openstyles/stylus/issues/195'); - return; - } - // Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5 - return API.installStyle(Object.assign(json, addProps, {originalMd5: currentMd5})) - .then(style => { - if (!isNew && style.updateUrl.includes('?')) { - enableUpdateButton(true); - } else { - sendEvent({type: 'styleInstalledChrome'}); - } - }); - }); + const json = await getStyleJson(); + if (!json) { + prompt(chrome.i18n.getMessage('styleInstallFailed', ''), + 'https://github.com/openstyles/stylus/issues/195'); + return; + } + // Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5 + const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5})); + if (!isNew && style.updateUrl.includes('?')) { + enableUpdateButton(true); + } else { + sendEvent({type: 'styleInstalledChrome'}); + } function enableUpdateButton(state) { const important = s => s.replace(/;/g, '!important;'); @@ -218,50 +208,32 @@ return e ? e.getAttribute('href') : null; } - function getResource(url, options) { - if (url.startsWith('#')) { - return Promise.resolve(document.getElementById(url.slice(1)).textContent); + async function getResource(url, opts) { + try { + return url.startsWith('#') + ? document.getElementById(url.slice(1)).textContent + : await API.download(url, opts); + } catch (error) { + alert('Error\n' + error.message); + return Promise.reject(error); } - return API.download(Object.assign({ - url, - timeout: 60e3, - // USO can't handle POST requests for style json - body: null, - }, options)) - .catch(error => { - alert('Error' + (error ? '\n' + error : '')); - throw error; - }); } // USO providing md5Url as "https://update.update.userstyles.org/#####.md5" // instead of "https://update.userstyles.org/#####.md5" - function tryFixMd5(style) { - if (style && style.md5Url && style.md5Url.includes('update.update')) { - style.md5Url = style.md5Url.replace('update.update', 'update'); - } - return style; - } - - function getStyleJson() { - return getResource(getStyleURL(), {responseType: 'json'}) - .then(style => { - if (!style || !Array.isArray(style.sections) || style.sections.length) { - return style; - } - const codeElement = document.getElementById('stylish-code'); - if (codeElement && !codeElement.textContent.trim()) { - return style; - } - return getResource(getMeta('stylish-update-url')) - .then(code => API.parseCss({code})) - .then(result => { - style.sections = result.sections; - return style; - }); - }) - .then(tryFixMd5) - .catch(() => null); + async function getStyleJson() { + try { + const style = await getResource(getStyleURL(), {responseType: 'json'}); + const codeElement = document.getElementById('stylish-code'); + if (!style || !Array.isArray(style.sections) || style.sections.length || + codeElement && !codeElement.textContent.trim()) { + return style; + } + const code = await getResource(getMeta('stylish-update-url')); + style.sections = (await API.worker.parseMozFormat({code})).sections; + if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update'); + return style; + } catch (e) {} } /** @@ -295,7 +267,7 @@ function onDOMready() { return document.readyState !== 'loading' ? Promise.resolve() - : new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true})); + : new Promise(resolve => window.addEventListener('load', resolve, {once: true})); } function openSettings(countdown = 10e3) { @@ -334,6 +306,7 @@ function inPageContext(eventId) { document.currentScript.remove(); + window.isInstalled = true; const origMethods = { json: Response.prototype.json, byId: document.getElementById, diff --git a/content/style-injector.js b/content/style-injector.js index 8630bf05..675ec135 100644 --- a/content/style-injector.js +++ b/content/style-injector.js @@ -1,6 +1,7 @@ 'use strict'; -self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ +/** @type {function(opts):StyleInjector} */ +window.StyleInjector = window.INJECTED === 1 ? window.StyleInjector : ({ compare, onUpdate = () => {}, }) => { @@ -8,8 +9,6 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ const PATCH_ID = 'transition-patch'; // styles are out of order if any of these elements is injected between them const ORDERED_TAGS = new Set(['head', 'body', 'frameset', 'style', 'link']); - // detect Chrome 65 via a feature it added since browser version can be spoofed - const isChromePre65 = chrome.app && typeof Worklet !== 'function'; const docRewriteObserver = RewriteObserver(_sort); const docRootObserver = RootObserver(_sortIfNeeded); const list = []; @@ -19,22 +18,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ // will store the original method refs because the page can override them let creationDoc, createElement, createElementNS; - return { + return /** @namespace StyleInjector */ { list, - apply(styleMap) { + async apply(styleMap) { const styles = _styleMapToArray(styleMap); - return ( - !styles.length ? - Promise.resolve([]) : - docRootObserver.evade(() => { - if (!isTransitionPatched && isEnabled) { - _applyTransitionPatch(styles); - } - return styles.map(_addUpdate); - }) - ).then(_emitUpdate); + const value = !styles.length + ? [] + : await docRootObserver.evade(() => { + if (!isTransitionPatched && isEnabled) { + _applyTransitionPatch(styles); + } + return styles.map(_addUpdate); + }); + _emitUpdate(); + return value; }, clear() { @@ -157,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ docRootObserver[onOff](); } - function _emitUpdate(value) { + function _emitUpdate() { _toggleObservers(list.length); onUpdate(); - return value; } /* @@ -232,17 +230,8 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ function _update({id, code}) { const style = table.get(id); - if (style.code === code) return; - style.code = code; - // workaround for Chrome devtools bug fixed in v65 - if (isChromePre65) { - const oldEl = style.el; - style.el = _createStyle(id, code); - if (isEnabled) { - oldEl.parentNode.insertBefore(style.el, oldEl.nextSibling); - oldEl.remove(); - } - } else { + if (style.code !== code) { + style.code = code; style.el.textContent = code; } } diff --git a/edit.html b/edit.html index e5bc1fdb..809f1044 100644 --- a/edit.html +++ b/edit.html @@ -4,8 +4,6 @@ - - - - + - - + + + + + diff --git a/options/options.js b/options/options.js index 180558c5..b68e6deb 100644 --- a/options/options.js +++ b/options/options.js @@ -1,7 +1,25 @@ -/* global messageBox msg setupLivePrefs enforceInputRange - $ $$ $create $createLink - FIREFOX OPERA CHROME URLS openURL prefs t API ignoreChromeError - CHROME_HAS_BORDER_BUG capitalize */ +/* global API msg */// msg.js +/* global prefs */ +/* global t */// localization.js +/* global + $ + $$ + $create + $createLink + getEventKeyName + messageBoxProxy + setupLivePrefs +*/// dom.js +/* global + CHROME + CHROME_POPUP_BORDER_BUG + FIREFOX + OPERA + URLS + capitalize + ignoreChromeError + openURL +*/// toolbox.js 'use strict'; setupLivePrefs(); @@ -9,7 +27,7 @@ setupRadioButtons(); $$('input[min], input[max]').forEach(enforceInputRange); setTimeout(splitLongTooltips); -if (CHROME_HAS_BORDER_BUG) { +if (CHROME_POPUP_BORDER_BUG) { const borderOption = $('.chrome-no-popup-border'); if (borderOption) { borderOption.classList.remove('chrome-no-popup-border'); @@ -31,24 +49,6 @@ if (!FIREFOX && !OPERA && CHROME < 66) { if (FIREFOX && 'update' in (chrome.commands || {})) { $('[data-cmd="open-keyboard"]').classList.remove('chromium-only'); - msg.onExtension(msg => { - if (msg.method === 'optionsCustomizeHotkeys') { - customizeHotkeys(); - } - }); -} - -if (CHROME && !chrome.declarativeContent) { - // Show the option as disabled until the permission is actually granted - const el = $('#styleViaXhr'); - prefs.initializing.then(() => { - el.checked = false; - }); - el.addEventListener('click', () => { - if (el.checked) { - chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); - } - }); } // actions @@ -84,13 +84,13 @@ document.onclick = e => { case 'reset': $$('input') - .filter(input => input.id in prefs.defaults) + .filter(input => prefs.knownKeys.includes(input.id)) .forEach(input => prefs.reset(input.id)); break; case 'note': { e.preventDefault(); - messageBox({ + messageBoxProxy.show({ className: 'note', contents: target.dataset.title, buttons: [t('confirmClose')], @@ -101,84 +101,75 @@ document.onclick = e => { // sync to cloud (() => { - const cloud = document.querySelector('.sync-options .cloud-name'); - const connectButton = document.querySelector('.sync-options .connect'); - const disconnectButton = document.querySelector('.sync-options .disconnect'); - const syncButton = document.querySelector('.sync-options .sync-now'); - const statusText = document.querySelector('.sync-options .sync-status'); - const loginButton = document.querySelector('.sync-options .sync-login'); - + const elCloud = $('.sync-options .cloud-name'); + const elStart = $('.sync-options .connect'); + const elStop = $('.sync-options .disconnect'); + const elSyncNow = $('.sync-options .sync-now'); + const elStatus = $('.sync-options .sync-status'); + const elLogin = $('.sync-options .sync-login'); + /** @type {Sync.Status} */ let status = {}; - msg.onExtension(e => { if (e.method === 'syncStatusUpdate') { - status = e.status; - updateButtons(); + setStatus(e.status); } }); + API.sync.getStatus() + .then(setStatus); - API.getSyncStatus() - .then(_status => { - status = _status; - updateButtons(); + elCloud.on('change', updateButtons); + for (const [btn, fn] of [ + [elStart, () => API.sync.start(elCloud.value)], + [elStop, API.sync.stop], + [elSyncNow, API.sync.syncNow], + [elLogin, API.sync.login], + ]) { + btn.on('click', e => { + if (getEventKeyName(e) === 'MouseL') { + fn(); + } }); - - function validClick(e) { - return e.button === 0 && !e.ctrl && !e.alt && !e.shift; } - cloud.addEventListener('change', updateButtons); + function setStatus(newStatus) { + status = newStatus; + updateButtons(); + } function updateButtons() { + const {state, STATES} = status; + const isConnected = state === STATES.connected; + const isDisconnected = state === STATES.disconnected; if (status.currentDriveName) { - cloud.value = status.currentDriveName; + elCloud.value = status.currentDriveName; } - cloud.disabled = status.state !== 'disconnected'; - connectButton.disabled = status.state !== 'disconnected' || cloud.value === 'none'; - disconnectButton.disabled = status.state !== 'connected' || status.syncing; - syncButton.disabled = status.state !== 'connected' || status.syncing; - statusText.textContent = getStatusText(); - loginButton.style.display = status.state === 'connected' && !status.login ? '' : 'none'; + for (const [el, enable] of [ + [elCloud, isDisconnected], + [elStart, isDisconnected && elCloud.value !== 'none'], + [elStop, isConnected && !status.syncing], + [elSyncNow, isConnected && !status.syncing], + ]) { + el.disabled = !enable; + } + elStatus.textContent = getStatusText(); + elLogin.hidden = !isConnected || status.login; } function getStatusText() { + let res; if (status.syncing) { - if (status.progress) { - const {phase, loaded, total} = status.progress; - return chrome.i18n.getMessage(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total]) || - `${phase} ${loaded} / ${total}`; - } - return chrome.i18n.getMessage('optionsSyncStatusSyncing') || 'syncing'; + const {phase, loaded, total} = status.progress || {}; + res = phase + ? t(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total], false) || + `${phase} ${loaded} / ${total}` + : t('optionsSyncStatusSyncing'); + } else { + const {state, errorMessage, STATES} = status; + res = (state === STATES.connected || state === STATES.disconnected) && errorMessage || + t(`optionsSyncStatus${capitalize(state)}`, null, false) || state; } - if ((status.state === 'connected' || status.state === 'disconnected') && status.errorMessage) { - return status.errorMessage; - } - return chrome.i18n.getMessage(`optionsSyncStatus${capitalize(status.state)}`) || status.state; + return res; } - - connectButton.addEventListener('click', e => { - if (validClick(e)) { - API.syncStart(cloud.value).catch(console.error); - } - }); - - disconnectButton.addEventListener('click', e => { - if (validClick(e)) { - API.syncStop().catch(console.error); - } - }); - - syncButton.addEventListener('click', e => { - if (validClick(e)) { - API.syncNow().catch(console.error); - } - }); - - loginButton.addEventListener('click', e => { - if (validClick(e)) { - API.syncLogin().catch(console.error); - } - }); })(); function checkUpdates() { @@ -193,7 +184,7 @@ function checkUpdates() { chrome.runtime.onConnect.removeListener(onConnect); }); - API.updateCheckAll({observe: true}); + API.updater.checkAllStyles({observe: true}); function observer(info) { if ('count' in info) { @@ -223,7 +214,7 @@ function setupRadioButtons() { // group all radio-inputs by name="prefName" attribute for (const el of $$('input[type="radio"][name]')) { (sets[el.name] = sets[el.name] || []).push(el); - el.addEventListener('change', onChange); + el.on('change', onChange); } // select the input corresponding to the actual pref value for (const name in sets) { @@ -259,7 +250,7 @@ function customizeHotkeys() { ['styleDisableAll', 'disableAllStyles'], ]); - messageBox({ + messageBoxProxy.show({ title: t('shortcutsNote'), contents: [ $create('table', @@ -310,8 +301,24 @@ function customizeHotkeys() { } } +function enforceInputRange(element) { + const min = Number(element.min); + const max = Number(element.max); + const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true})); + const onChange = ({type}) => { + if (type === 'input' && element.checkValidity()) { + doNotify(); + } else if (type === 'change' && !element.checkValidity()) { + element.value = Math.max(min, Math.min(max, Number(element.value))); + doNotify(); + } + }; + element.on('change', onChange); + element.on('input', onChange); +} + window.onkeydown = event => { - if (event.key === 'Escape') { + if (getEventKeyName(event) === 'Escape') { top.dispatchEvent(new CustomEvent('closeOptions')); } }; diff --git a/popup.html b/popup.html index 5a894c16..e28e8624 100644 --- a/popup.html +++ b/popup.html @@ -180,33 +180,19 @@ - - + + + + - + - - - - - - - - - - - - - - - - @@ -293,6 +279,10 @@
+ + + + diff --git a/popup/events.js b/popup/events.js new file mode 100644 index 00000000..eb06e0ad --- /dev/null +++ b/popup/events.js @@ -0,0 +1,198 @@ +/* global $ $$ $remove animateElement getEventKeyName moveFocus */// dom.js +/* global API */// msg.js +/* global getActiveTab tryJSONparse */// toolbox.js +/* global resortEntries tabURL */// popup.js +/* global t */// localization.js +'use strict'; + +const MODAL_SHOWN = 'data-display'; // attribute name + +const Events = { + + async configure(event) { + const {styleId, styleIsUsercss} = getClickedStyleElement(event); + if (styleIsUsercss) { + const [style] = await Promise.all([ + API.styles.get(styleId), + require(['/popup/hotkeys']), /* global hotkeys */ + require(['/js/dlg/config-dialog']), /* global configDialog */ + ]); + hotkeys.setState(false); + await configDialog(style); + hotkeys.setState(true); + } else { + Events.openURLandHide.call(this, event); + } + }, + + copyContent(event) { + event.preventDefault(); + const target = document.activeElement; + const message = $('.copy-message'); + navigator.clipboard.writeText(target.textContent); + target.classList.add('copied'); + message.classList.add('show-message'); + setTimeout(() => { + target.classList.remove('copied'); + message.classList.remove('show-message'); + }, 1000); + }, + + delete(event) { + const entry = getClickedStyleElement(event); + const box = $('#confirm'); + box.dataset.id = entry.styleId; + $('b', box).textContent = $('.style-name', entry).textContent; + Events.showModal(box, '[data-cmd=cancel]'); + }, + + getExcludeRule(type) { + const u = new URL(tabURL); + return type === 'domain' + ? u.origin + '/*' + : escapeGlob(u.origin + u.pathname); // current page + }, + + async hideModal(box, {animate} = {}) { + window.off('keydown', box._onkeydown); + box._onkeydown = null; + if (animate) { + box.style.animationName = ''; + await animateElement(box, 'lights-on'); + } + box.removeAttribute(MODAL_SHOWN); + }, + + indicator(event) { + const entry = getClickedStyleElement(event); + const info = t.template.regexpProblemExplanation.cloneNode(true); + $remove('#' + info.id); + $$('a', info).forEach(el => (el.onclick = Events.openURLandHide)); + $$('button', info).forEach(el => (el.onclick = closeExplanation)); + entry.appendChild(info); + }, + + isStyleExcluded({exclusions}, type) { + if (!exclusions) { + return false; + } + const rule = Events.getExcludeRule(type); + return exclusions.includes(rule); + }, + + maybeEdit(event) { + if (!( + event.button === 0 && (event.ctrlKey || event.metaKey) || + event.button === 1 || + event.button === 2)) { + return; + } + // open an editor on middleclick + const el = event.target; + if (el.matches('.entry, .style-edit-link') || el.closest('.style-name')) { + this.onmouseup = () => $('.style-edit-link', this).click(); + this.oncontextmenu = event => event.preventDefault(); + event.preventDefault(); + return; + } + // prevent the popup being opened in a background tab + // when an irrelevant link was accidentally clicked + if (el.closest('a')) { + event.preventDefault(); + return; + } + }, + + name(event) { + $('input', this).dispatchEvent(new MouseEvent('click')); + event.preventDefault(); + }, + + async openEditor(event, options) { + event.preventDefault(); + await API.openEditor(options); + window.close(); + }, + + async openManager(event) { + event.preventDefault(); + const isSearch = tabURL && (event.shiftKey || event.button === 2); + await API.openManage(isSearch ? {search: tabURL, searchMode: 'url'} : {}); + window.close(); + }, + + async openURLandHide(event) { + event.preventDefault(); + await API.openURL({ + url: this.href || this.dataset.href, + index: (await getActiveTab()).index + 1, + message: tryJSONparse(this.dataset.sendMessage), + }); + window.close(); + }, + + showModal(box, cancelButtonSelector) { + const oldBox = $(`[${MODAL_SHOWN}]`); + if (oldBox) box.style.animationName = 'none'; + // '' would be fine but 'true' is backward-compatible with the existing userstyles + box.setAttribute(MODAL_SHOWN, 'true'); + box._onkeydown = e => { + const key = getEventKeyName(e); + switch (key) { + case 'Tab': + case 'Shift-Tab': + e.preventDefault(); + moveFocus(box, e.shiftKey ? -1 : 1); + break; + case 'Escape': { + e.preventDefault(); + window.onkeydown = null; + $(cancelButtonSelector, box).click(); + break; + } + } + }; + window.on('keydown', box._onkeydown); + moveFocus(box, 0); + Events.hideModal(oldBox); + }, + + async toggleState(event) { + // when fired on checkbox, prevent the parent label from seeing the event, see #501 + event.stopPropagation(); + await API.styles.toggle((getClickedStyleElement(event) || {}).styleId, this.checked); + resortEntries(); + }, + + toggleExclude(event, type) { + const entry = getClickedStyleElement(event); + if (event.target.checked) { + API.styles.addExclusion(entry.styleMeta.id, Events.getExcludeRule(type)); + } else { + API.styles.removeExclusion(entry.styleMeta.id, Events.getExcludeRule(type)); + } + }, + + toggleMenu(event) { + const entry = getClickedStyleElement(event); + const menu = $('.menu', entry); + if (menu.hasAttribute(MODAL_SHOWN)) { + Events.hideModal(menu, {animate: true}); + } else { + $('.menu-title', entry).textContent = $('.style-name', entry).textContent; + Events.showModal(menu, '.menu-close'); + } + }, +}; + +function closeExplanation() { + $('#regexp-explanation').remove(); +} + +function escapeGlob(text) { + return text.replace(/\*/g, '\\*'); +} + +function getClickedStyleElement(event) { + return event.target.closest('.entry'); +} diff --git a/popup/hotkeys.js b/popup/hotkeys.js index 236b9c59..90364b33 100644 --- a/popup/hotkeys.js +++ b/popup/hotkeys.js @@ -1,35 +1,26 @@ -/* global $ $$ API debounce $create t */ +/* global $ $$ $create */// dom.js +/* global API */// msg.js +/* global debounce */// toolbox.js +/* global t */// localization.js 'use strict'; -/* exported hotkeys */ const hotkeys = (() => { const entries = document.getElementsByClassName('entry'); - let togglablesShown; - let togglables; - let enabled = false; - let ready = false; + let togglablesShown = true; + let togglables = getTogglables(); + let enabled; - window.addEventListener('showStyles:done', () => { - togglablesShown = true; - togglables = getTogglables(); - ready = true; - setState(true); - initHotkeyInfo(); - }, {once: true}); + window.on('resize', adjustInfoPosition); + initHotkeyInfo(); - window.addEventListener('resize', adjustInfoPosition); - - return {setState}; - - function setState(newState = !enabled) { - if (!ready) { - throw new Error('hotkeys no ready'); - } - if (newState !== enabled) { - window[`${newState ? 'add' : 'remove'}EventListener`]('keydown', onKeyDown); - enabled = newState; - } - } + return { + setState(newState = !enabled) { + if (!newState !== !enabled) { + window[newState ? 'on' : 'off']('keydown', onKeyDown); + enabled = newState; + } + }, + }; function onKeyDown(event) { if (event.ctrlKey || event.altKey || event.metaKey || !enabled || @@ -89,7 +80,7 @@ const hotkeys = (() => { if (!match && $('input', entry).checked !== enable || entry.classList.contains(match)) { results.push(entry.id); task = task - .then(() => API.toggleStyle(entry.styleId, enable)) + .then(() => API.styles.toggle(entry.styleId, enable)) .then(() => { entry.classList.toggle('enabled', enable); entry.classList.toggle('disabled', !enable); @@ -116,11 +107,11 @@ const hotkeys = (() => { delete container.dataset.active; document.body.style.height = ''; container.title = title; - window.addEventListener('resize', adjustInfoPosition); + window.on('resize', adjustInfoPosition); } function open() { - window.removeEventListener('resize', adjustInfoPosition); + window.off('resize', adjustInfoPosition); debounce.unregister(adjustInfoPosition); title = container.title; container.title = ''; @@ -173,3 +164,5 @@ const hotkeys = (() => { } } })(); + +hotkeys.setState(true); diff --git a/popup/popup-preinit.js b/popup/popup-preinit.js deleted file mode 100644 index fc1d942a..00000000 --- a/popup/popup-preinit.js +++ /dev/null @@ -1,87 +0,0 @@ -/* global - API - URLS -*/ -'use strict'; - -const ABOUT_BLANK = 'about:blank'; -/* exported tabURL */ -/** @type string */ -let tabURL; - -/* exported initializing */ -const initializing = (async () => { - let [tab] = await browser.tabs.query({currentWindow: true, active: true}); - if (!chrome.app && tab.status === 'loading' && tab.url === 'about:blank') { - tab = await waitForTabUrlFF(tab); - } - const frames = sortTabFrames(await browser.webNavigation.getAllFrames({tabId: tab.id})); - let url = tab.pendingUrl || tab.url || ''; // new Chrome uses pendingUrl while connecting - if (url === 'chrome://newtab/' && !URLS.chromeProtectsNTP) { - url = frames[0].url || ''; - } - if (!URLS.supported(url)) { - url = ''; - frames.length = 1; - } - tabURL = frames[0].url = url; - const uniqFrames = frames.filter(f => f.url && !f.isDupe); - const styles = await Promise.all(uniqFrames.map(getFrameStyles)); - return {frames, styles}; - - async function getFrameStyles({url}) { - return { - url, - styles: await getStyleDataMerged(url), - }; - } - - /** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ - function sortTabFrames(frames) { - const unknown = new Map(frames.map(f => [f.frameId, f])); - const known = new Map([[0, unknown.get(0) || {frameId: 0, url: ''}]]); - unknown.delete(0); - let lastSize = 0; - while (unknown.size !== lastSize) { - for (const [frameId, f] of unknown) { - if (known.has(f.parentFrameId)) { - unknown.delete(frameId); - if (!f.errorOccurred) known.set(frameId, f); - if (f.url === ABOUT_BLANK) f.url = known.get(f.parentFrameId).url; - } - } - lastSize = unknown.size; // guard against an infinite loop due to a weird frame structure - } - const sortedFrames = [...known.values(), ...unknown.values()]; - const urls = new Set([ABOUT_BLANK]); - for (const f of sortedFrames) { - if (!f.url) f.url = ''; - f.isDupe = urls.has(f.url); - urls.add(f.url); - } - return sortedFrames; - } - - function waitForTabUrlFF(tab) { - return new Promise(resolve => { - browser.tabs.onUpdated.addListener(...[ - function onUpdated(tabId, info, updatedTab) { - if (info.url && tabId === tab.id) { - browser.tabs.onUpdated.removeListener(onUpdated); - resolve(updatedTab); - } - }, - ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], - // TODO: remove both spreads and tabId check when strict_min_version >= 61 - ]); - }); - } -})(); - -/* Merges the extra props from API into style data. - * When `id` is specified returns a single object otherwise an array */ -async function getStyleDataMerged(url, id) { - const styles = (await API.getStylesByUrl(url, id)) - .map(r => Object.assign(r.data, r)); - return id ? styles[0] : styles; -} diff --git a/popup/popup.css b/popup/popup.css index 40369b72..7936ee02 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -112,6 +112,10 @@ body > div:not(#installed):not(#message-box):not(.colorpicker-popup) { cursor: pointer; margin-right: .5em; } +#find-styles-inline-group label { + position: relative; + padding-left: 16px; +} .checker { display: inline; diff --git a/popup/popup.js b/popup/popup.js index f93d4ecd..96326f00 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,53 +1,28 @@ +/* global $ $$ $create setupLivePrefs */// dom.js +/* global ABOUT_BLANK getStyleDataMerged preinit */// preinit.js +/* global API msg */// msg.js +/* global Events */ +/* global prefs */ +/* global t */// localization.js /* global - $ - $$ - $create - animateElement - ABOUT_BLANK - API CHROME - CHROME_HAS_BORDER_BUG - configDialog + CHROME_POPUP_BORDER_BUG FIREFOX - getActiveTab - getEventKeyName - getStyleDataMerged - hotkeys - initializing - moveFocus - msg - onDOMready - prefs - setupLivePrefs - t - tabURL - tryJSONparse URLS -*/ - + getActiveTab + isEmptyObj +*/// toolbox.js 'use strict'; +let tabURL; + /** @type Element */ -let installed; -const handleEvent = {}; - +const installed = $('#installed'); const ENTRY_ID_PREFIX_RAW = 'style-'; -const MODAL_SHOWN = 'data-display'; // attribute name +const $entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`); -$.entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`); - -if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143 - document.head.appendChild($create('style', 'html { overflow: overlay }')); -} - -toggleSideBorders(); - -Promise.all([ - initializing, - onDOMready(), -]).then(([ - {frames, styles}, -]) => { +preinit.then(({frames, styles, url}) => { + tabURL = url; toggleUiSliders(); initPopup(frames); if (styles[0]) { @@ -60,15 +35,16 @@ Promise.all([ msg.onExtension(onRuntimeMessage); -prefs.subscribe(['popup.stylesFirst'], (key, stylesFirst) => { +prefs.subscribe('popup.stylesFirst', (key, stylesFirst) => { const actions = $('body > .actions'); const before = stylesFirst ? actions : actions.nextSibling; document.body.insertBefore(installed, before); }); -prefs.subscribe(['popupWidth'], (key, value) => setPopupWidth(value)); - -if (CHROME_HAS_BORDER_BUG) { - prefs.subscribe(['popup.borders'], (key, value) => toggleSideBorders(value)); +if (CHROME_POPUP_BORDER_BUG) { + prefs.subscribe('popup.borders', toggleSideBorders, {runNow: true}); +} +if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143 + document.head.appendChild($create('style', 'html { overflow: overlay }')); } function onRuntimeMessage(msg) { @@ -87,17 +63,15 @@ function onRuntimeMessage(msg) { ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg}))); } - -function setPopupWidth(width = prefs.get('popupWidth')) { +function setPopupWidth(_key, width) { document.body.style.width = Math.max(200, Math.min(800, width)) + 'px'; } - -function toggleSideBorders(state = prefs.get('popup.borders')) { +function toggleSideBorders(_key, state) { // runs before is parsed const style = document.documentElement.style; - if (CHROME_HAS_BORDER_BUG && state) { + if (state) { style.cssText += 'border-left: 2px solid white !important;' + 'border-right: 2px solid white !important;'; @@ -116,9 +90,7 @@ function toggleUiSliders() { /** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ async function initPopup(frames) { - installed = $('#installed'); - - setPopupWidth(); + prefs.subscribe('popupWidth', setPopupWidth, {runNow: true}); // action buttons $('#disableAll').onchange = function () { @@ -126,9 +98,18 @@ async function initPopup(frames) { }; setupLivePrefs(); + Object.assign($('#find-styles-link'), { + href: URLS.usoArchive, + async onclick(e) { + e.preventDefault(); + await require(['/popup/search']); + Events.searchOnClick(this, e); + }, + }); + Object.assign($('#popup-manage-button'), { - onclick: handleEvent.openManager, - oncontextmenu: handleEvent.openManager, + onclick: Events.openManager, + oncontextmenu: Events.openManager, }); $('#popup-options-button').onclick = () => { @@ -136,17 +117,17 @@ async function initPopup(frames) { window.close(); }; - $('#popup-wiki-button').onclick = handleEvent.openURLandHide; + $('#popup-wiki-button').onclick = Events.openURLandHide; $('#confirm').onclick = function (e) { const {id} = this.dataset; switch (e.target.dataset.cmd) { case 'ok': - hideModal(this, {animate: true}); - API.deleteStyle(Number(id)); + Events.hideModal(this, {animate: true}); + API.styles.delete(Number(id)); break; case 'cancel': - showModal($('.menu', $.entry(id)), '.menu-close'); + Events.showModal($('.menu', $entry(id)), '.menu-close'); break; } }; @@ -207,7 +188,7 @@ function initUnreachable(isStore) { const renderToken = s => s[0] === '<' ? $create('a', { textContent: s.slice(1, -1), - onclick: handleEvent.copyContent, + onclick: Events.copyContent, href: '#', className: 'copy', tabIndex: 0, @@ -243,7 +224,7 @@ function createWriterElement(frame) { : frameId ? isAboutBlank ? url : 'URL' : t('writeStyleForURL').replace(/ /g, '\u00a0'), // this URL - onclick: e => handleEvent.openEditor(e, {'url-prefix': url}), + onclick: e => Events.openEditor(e, {'url-prefix': url}), }); if (prefs.get('popup.breadcrumbs')) { urlLink.onmouseenter = @@ -266,7 +247,7 @@ function createWriterElement(frame) { href: 'edit.html?domain=' + encodeURIComponent(domain), textContent: numParts > 2 ? domain.split('.')[0] : domain, title: `domain("${domain}")`, - onclick: e => handleEvent.openEditor(e, {domain}), + onclick: e => Events.openEditor(e, {domain}), }); domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : ''); targets.appendChild(domainLink); @@ -326,7 +307,7 @@ function showStyles(frameResults) { } else { installed.appendChild(t.template.noStyles); } - window.dispatchEvent(new Event('showStyles:done')); + require(['/popup/hotkeys']); } function resortEntries(entries) { @@ -337,33 +318,33 @@ function resortEntries(entries) { } function createStyleElement(style) { - let entry = $.entry(style); + let entry = $entry(style); if (!entry) { entry = t.template.style.cloneNode(true); Object.assign(entry, { id: ENTRY_ID_PREFIX_RAW + style.id, styleId: style.id, styleIsUsercss: Boolean(style.usercssData), - onmousedown: handleEvent.maybeEdit, + onmousedown: Events.maybeEdit, styleMeta: style, }); Object.assign($('input', entry), { - onclick: handleEvent.toggle, + onclick: Events.toggleState, }); const editLink = $('.style-edit-link', entry); Object.assign(editLink, { href: editLink.getAttribute('href') + style.id, - onclick: e => handleEvent.openEditor(e, {id: style.id}), + onclick: e => Events.openEditor(e, {id: style.id}), }); const styleName = $('.style-name', entry); Object.assign(styleName, { htmlFor: ENTRY_ID_PREFIX_RAW + style.id, - onclick: handleEvent.name, + onclick: Events.name, }); styleName.appendChild(document.createTextNode(' ')); const config = $('.configure', entry); - config.onclick = handleEvent.configure; + config.onclick = Events.configure; if (!style.usercssData) { if (style.updateUrl && style.updateUrl.includes('?') && style.url) { config.href = style.url; @@ -374,22 +355,22 @@ function createStyleElement(style) { } else { config.classList.add('hidden'); } - } else if (Object.keys(style.usercssData.vars || {}).length === 0) { + } else if (isEmptyObj(style.usercssData.vars)) { config.classList.add('hidden'); } - $('.delete', entry).onclick = handleEvent.delete; + $('.delete', entry).onclick = Events.delete; const indicator = t.template.regexpProblemIndicator.cloneNode(true); indicator.appendChild(document.createTextNode('!')); - indicator.onclick = handleEvent.indicator; + indicator.onclick = Events.indicator; $('.main-controls', entry).appendChild(indicator); - $('.menu-button', entry).onclick = handleEvent.toggleMenu; - $('.menu-close', entry).onclick = handleEvent.toggleMenu; + $('.menu-button', entry).onclick = Events.toggleMenu; + $('.menu-close', entry).onclick = Events.toggleMenu; - $('.exclude-by-domain-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'domain'); - $('.exclude-by-url-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'url'); + $('.exclude-by-domain-checkbox', entry).onchange = e => Events.toggleExclude(e, 'domain'); + $('.exclude-by-url-checkbox', entry).onchange = e => Events.toggleExclude(e, 'url'); } style = Object.assign(entry.styleMeta, style); @@ -410,187 +391,26 @@ function createStyleElement(style) { entry.classList.toggle('not-applied', style.excluded || style.sloppy); entry.classList.toggle('regexp-partial', style.sloppy); - $('.exclude-by-domain-checkbox', entry).checked = styleExcluded(style, 'domain'); - $('.exclude-by-url-checkbox', entry).checked = styleExcluded(style, 'url'); + $('.exclude-by-domain-checkbox', entry).checked = Events.isStyleExcluded(style, 'domain'); + $('.exclude-by-url-checkbox', entry).checked = Events.isStyleExcluded(style, 'url'); - $('.exclude-by-domain', entry).title = getExcludeRule('domain'); - $('.exclude-by-url', entry).title = getExcludeRule('url'); + $('.exclude-by-domain', entry).title = Events.getExcludeRule('domain'); + $('.exclude-by-url', entry).title = Events.getExcludeRule('url'); const {frameUrl} = style; if (frameUrl) { const sel = 'span.frame-url'; const frameEl = $(sel, entry) || styleName.insertBefore($create(sel), styleName.lastChild); frameEl.title = frameUrl; - frameEl.onmousedown = handleEvent.maybeEdit; + frameEl.onmousedown = Events.maybeEdit; } entry.classList.toggle('frame', Boolean(frameUrl)); return entry; } -function styleExcluded({exclusions}, type) { - if (!exclusions) { - return false; - } - const rule = getExcludeRule(type); - return exclusions.includes(rule); -} - -function getExcludeRule(type) { - const u = new URL(tabURL); - if (type === 'domain') { - return u.origin + '/*'; - } - // current page - return escapeGlob(u.origin + u.pathname); -} - -function escapeGlob(text) { - return text.replace(/\*/g, '\\*'); -} - -Object.assign(handleEvent, { - - getClickedStyleId(event) { - return (handleEvent.getClickedStyleElement(event) || {}).styleId; - }, - - getClickedStyleElement(event) { - return event.target.closest('.entry'); - }, - - name(event) { - $('input', this).dispatchEvent(new MouseEvent('click')); - event.preventDefault(); - }, - - toggle(event) { - // when fired on checkbox, prevent the parent label from seeing the event, see #501 - event.stopPropagation(); - API - .toggleStyle(handleEvent.getClickedStyleId(event), this.checked) - .then(() => resortEntries()); - }, - - toggleExclude(event, type) { - const entry = handleEvent.getClickedStyleElement(event); - if (event.target.checked) { - API.addExclusion(entry.styleMeta.id, getExcludeRule(type)); - } else { - API.removeExclusion(entry.styleMeta.id, getExcludeRule(type)); - } - }, - - toggleMenu(event) { - const entry = handleEvent.getClickedStyleElement(event); - const menu = $('.menu', entry); - if (menu.hasAttribute(MODAL_SHOWN)) { - hideModal(menu, {animate: true}); - } else { - $('.menu-title', entry).textContent = $('.style-name', entry).textContent; - showModal(menu, '.menu-close'); - } - }, - - delete(event) { - const entry = handleEvent.getClickedStyleElement(event); - const box = $('#confirm'); - box.dataset.id = entry.styleId; - $('b', box).textContent = $('.style-name', entry).textContent; - showModal(box, '[data-cmd=cancel]'); - }, - - configure(event) { - const {styleId, styleIsUsercss} = handleEvent.getClickedStyleElement(event); - if (styleIsUsercss) { - API.getStyle(styleId, true).then(style => { - hotkeys.setState(false); - configDialog(style).then(() => { - hotkeys.setState(true); - }); - }); - } else { - handleEvent.openURLandHide.call(this, event); - } - }, - - indicator(event) { - const entry = handleEvent.getClickedStyleElement(event); - const info = t.template.regexpProblemExplanation.cloneNode(true); - $.remove('#' + info.id); - $$('a', info).forEach(el => (el.onclick = handleEvent.openURLandHide)); - $$('button', info).forEach(el => (el.onclick = handleEvent.closeExplanation)); - entry.appendChild(info); - }, - - closeExplanation() { - $('#regexp-explanation').remove(); - }, - - openEditor(event, options) { - event.preventDefault(); - API.openEditor(options); - window.close(); - }, - - maybeEdit(event) { - if (!( - event.button === 0 && (event.ctrlKey || event.metaKey) || - event.button === 1 || - event.button === 2)) { - return; - } - // open an editor on middleclick - const el = event.target; - if (el.matches('.entry, .style-edit-link') || el.closest('.style-name')) { - this.onmouseup = () => $('.style-edit-link', this).click(); - this.oncontextmenu = event => event.preventDefault(); - event.preventDefault(); - return; - } - // prevent the popup being opened in a background tab - // when an irrelevant link was accidentally clicked - if (el.closest('a')) { - event.preventDefault(); - return; - } - }, - - openURLandHide(event) { - event.preventDefault(); - getActiveTab() - .then(activeTab => API.openURL({ - url: this.href || this.dataset.href, - index: activeTab.index + 1, - message: tryJSONparse(this.dataset.sendMessage), - })) - .then(window.close); - }, - - openManager(event) { - event.preventDefault(); - const isSearch = tabURL && (event.shiftKey || event.button === 2); - API.openManage(isSearch ? {search: tabURL, searchMode: 'url'} : {}); - window.close(); - }, - - copyContent(event) { - event.preventDefault(); - const target = document.activeElement; - const message = $('.copy-message'); - navigator.clipboard.writeText(target.textContent); - target.classList.add('copied'); - message.classList.add('show-message'); - setTimeout(() => { - target.classList.remove('copied'); - message.classList.remove('show-message'); - }, 1000); - }, -}); - - async function handleUpdate({style, reason}) { - if (reason !== 'toggle' || !$.entry(style)) { + if (reason !== 'toggle' || !$entry(style)) { style = await getStyleDataMerged(tabURL, style.id); if (!style) return; } @@ -602,9 +422,8 @@ async function handleUpdate({style, reason}) { resortEntries(); } - function handleDelete(id) { - const el = $.entry(id); + const el = $entry(id); if (el) { el.remove(); if (!$('.entry')) installed.appendChild(t.template.noStyles); @@ -620,39 +439,3 @@ function blockPopup(isBlocked = true) { t.template.noStyles.remove(); } } - -function showModal(box, cancelButtonSelector) { - const oldBox = $(`[${MODAL_SHOWN}]`); - if (oldBox) box.style.animationName = 'none'; - // '' would be fine but 'true' is backward-compatible with the existing userstyles - box.setAttribute(MODAL_SHOWN, 'true'); - box._onkeydown = e => { - const key = getEventKeyName(e); - switch (key) { - case 'Tab': - case 'Shift-Tab': - e.preventDefault(); - moveFocus(box, e.shiftKey ? -1 : 1); - break; - case 'Escape': { - e.preventDefault(); - window.onkeydown = null; - $(cancelButtonSelector, box).click(); - break; - } - } - }; - window.on('keydown', box._onkeydown); - moveFocus(box, 0); - hideModal(oldBox); -} - -async function hideModal(box, {animate} = {}) { - window.off('keydown', box._onkeydown); - box._onkeydown = null; - if (animate) { - box.style.animationName = ''; - await animateElement(box, 'lights-on'); - } - box.removeAttribute(MODAL_SHOWN); -} diff --git a/popup/preinit.js b/popup/preinit.js new file mode 100644 index 00000000..b70dd546 --- /dev/null +++ b/popup/preinit.js @@ -0,0 +1,77 @@ +/* global API */// msg.js +/* global URLS */// toolbox.js +'use strict'; + +const ABOUT_BLANK = 'about:blank'; +/* exported preinit */ +const preinit = (async () => { + let [tab] = await browser.tabs.query({currentWindow: true, active: true}); + if (!chrome.app && tab.status === 'loading' && tab.url === ABOUT_BLANK) { + tab = await waitForTabUrlFF(tab); + } + const frames = sortTabFrames(await browser.webNavigation.getAllFrames({tabId: tab.id})); + let url = tab.pendingUrl || tab.url || ''; // new Chrome uses pendingUrl while connecting + if (url === 'chrome://newtab/' && !URLS.chromeProtectsNTP) { + url = frames[0].url || ''; + } + if (!URLS.supported(url)) { + url = ''; + frames.length = 1; + } + frames[0].url = url; + const uniqFrames = frames.filter(f => f.url && !f.isDupe); + const styles = await Promise.all(uniqFrames.map(async ({url}) => ({ + url, + styles: await getStyleDataMerged(url), + }))); + return {frames, styles, url}; +})(); + +/* Merges the extra props from API into style data. + * When `id` is specified returns a single object otherwise an array */ +async function getStyleDataMerged(url, id) { + const styles = (await API.styles.getByUrl(url, id)) + .map(r => Object.assign(r.style, r)); + return id ? styles[0] : styles; +} + +/** @param {browser.webNavigation._GetAllFramesReturnDetails[]} frames */ +function sortTabFrames(frames) { + const unknown = new Map(frames.map(f => [f.frameId, f])); + const known = new Map([[0, unknown.get(0) || {frameId: 0, url: ''}]]); + unknown.delete(0); + let lastSize = 0; + while (unknown.size !== lastSize) { + for (const [frameId, f] of unknown) { + if (known.has(f.parentFrameId)) { + unknown.delete(frameId); + if (!f.errorOccurred) known.set(frameId, f); + if (f.url === ABOUT_BLANK) f.url = known.get(f.parentFrameId).url; + } + } + lastSize = unknown.size; // guard against an infinite loop due to a weird frame structure + } + const sortedFrames = [...known.values(), ...unknown.values()]; + const urls = new Set([ABOUT_BLANK]); + for (const f of sortedFrames) { + if (!f.url) f.url = ''; + f.isDupe = urls.has(f.url); + urls.add(f.url); + } + return sortedFrames; +} + +function waitForTabUrlFF(tab) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(...[ + function onUpdated(tabId, info, updatedTab) { + if (info.url && tabId === tab.id) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve(updatedTab); + } + }, + ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], + // TODO: remove both spreads and tabId check when strict_min_version >= 61 + ]); + }); +} diff --git a/popup/search-results.css b/popup/search.css old mode 100755 new mode 100644 similarity index 98% rename from popup/search-results.css rename to popup/search.css index c20847ec..127738b4 --- a/popup/search-results.css +++ b/popup/search.css @@ -1,3 +1,7 @@ +/* IMPORTANT! + This file is loaded dynamically when the inline search is invoked. + So don't put main popup's stuff here. */ + body.search-results-shown { overflow-y: auto; overflow-x: hidden; @@ -255,11 +259,6 @@ body.search-results-shown { text-shadow: 0 1px 4px rgba(0, 0, 0, .5); } -#find-styles-inline-group label { - position: relative; - padding-left: 16px; -} - #search-params { display: flex; position: relative; diff --git a/popup/search-results.js b/popup/search.js old mode 100755 new mode 100644 similarity index 91% rename from popup/search-results.js rename to popup/search.js index ad243d4f..1f649627 --- a/popup/search-results.js +++ b/popup/search.js @@ -1,22 +1,15 @@ -/* global - $ - $$ - $create - API - debounce - download - FIREFOX - handleEvent - prefs - t - tabURL - tryCatch - URLS -*/ +/* global $ $$ $create $remove */// dom.js +/* global $entry tabURL */// popup.js +/* global API */// msg.js +/* global Events */ +/* global FIREFOX URLS debounce download tryCatch */// toolbox.js +/* global prefs */ +/* global t */// localization.js 'use strict'; -window.addEventListener('showStyles:done', () => { - if (!tabURL) return; +(() => { + require(['/popup/search.css']); + const RESULT_ID_PREFIX = 'search-result-'; const INDEX_URL = URLS.usoArchiveRaw + 'search-index.json'; const STYLUS_CATEGORY = 'chrome-extension'; @@ -61,27 +54,27 @@ window.addEventListener('showStyles:done', () => { const show = sel => $class(sel).remove('hidden'); const hide = sel => $class(sel).add('hidden'); - Object.assign($('#find-styles-link'), { - href: URLS.usoArchive, - onclick(event) { + Object.assign(Events, { + /** + * @param {HTMLAnchorElement} a + * @param {Event} event + */ + searchOnClick(a, event) { if (!prefs.get('popup.findStylesInline') || dom.container) { // use a less specific category if the inline search wasn't used yet if (!category) calcCategory({retry: 1}); - this.search = new URLSearchParams({category, search: $('#search-query').value}); - handleEvent.openURLandHide.call(this, event); + a.search = new URLSearchParams({category, search: $('#search-query').value}); + Events.openURLandHide.call(a, event); return; } - event.preventDefault(); - this.textContent = this.title; - this.title = ''; + a.textContent = a.title; + a.title = ''; init(); calcCategory(); ready = start(); }, }); - return; - function init() { setTimeout(() => document.body.classList.add('search-results-shown')); hide('#find-styles-inline-group'); @@ -127,7 +120,7 @@ window.addEventListener('showStyles:done', () => { if (FIREFOX) { let lastShift; - addEventListener('resize', () => { + window.on('resize', () => { const scrollbarWidth = window.innerWidth - document.scrollingElement.clientWidth; const shift = document.body.getBoundingClientRect().left; if (!scrollbarWidth || shift === lastShift) return; @@ -137,7 +130,7 @@ window.addEventListener('showStyles:done', () => { }, {passive: true}); } - addEventListener('styleDeleted', ({detail: {style: {id}}}) => { + window.on('styleDeleted', ({detail: {style: {id}}}) => { restoreScrollPosition(); const result = results.find(r => r.installedStyleId === id); if (result) { @@ -146,10 +139,10 @@ window.addEventListener('showStyles:done', () => { } }); - addEventListener('styleAdded', async ({detail: {style}}) => { + window.on('styleAdded', async ({detail: {style}}) => { restoreScrollPosition(); const usoId = calcUsoId(style) || - calcUsoId(await API.getStyle(style.id, true)); + calcUsoId(await API.styles.get(style.id)); if (usoId && results.find(r => r.i === usoId)) { renderActionButtons(usoId, style.id); } @@ -194,7 +187,7 @@ window.addEventListener('showStyles:done', () => { results = await search({retry}); } if (results.length) { - const installedStyles = await API.getAllStyles(); + const installedStyles = await API.styles.getAll(); const allUsoIds = new Set(installedStyles.map(calcUsoId)); results = results.filter(r => !allUsoIds.has(r.i)); } @@ -285,7 +278,7 @@ window.addEventListener('showStyles:done', () => { entry.id = RESULT_ID_PREFIX + id; // title Object.assign($('.search-result-title', entry), { - onclick: handleEvent.openURLandHide, + onclick: Events.openURLandHide, href: URLS.usoArchive + `?category=${category}&style=${id}`, }); $('.search-result-title span', entry).textContent = @@ -304,7 +297,7 @@ window.addEventListener('showStyles:done', () => { textContent: author, title: author, href: URLS.usoArchive + '?author=' + encodeURIComponent(author).replace(/%20/g, '+'), - onclick: handleEvent.openURLandHide, + onclick: Events.openURLandHide, }); // rating $('[data-type="rating"]', entry).dataset.class = @@ -366,7 +359,7 @@ window.addEventListener('showStyles:done', () => { $('.search-result-status', entry).textContent = ''; hide('.search-result-customize', entry); } - const notMatching = installedId > 0 && !$.entry(installedId); + const notMatching = installedId > 0 && !$entry(installedId); if (notMatching !== entry.classList.contains('not-matching')) { entry.classList.toggle('not-matching'); if (notMatching) { @@ -397,7 +390,7 @@ window.addEventListener('showStyles:done', () => { // config button if (vars) { const btn = $('.search-result-customize', entry); - btn.onclick = () => $('.configure', $.entry(style)).click(); + btn.onclick = () => $('.configure', $entry(style)).click(); show(btn); } } @@ -414,17 +407,17 @@ window.addEventListener('showStyles:done', () => { entry.style.setProperty('pointer-events', 'none', 'important'); // FIXME: move this to background page and create an API like installUSOStyle result.pingbackTimer = setTimeout(download, PINGBACK_DELAY, - `${URLS.uso}/styles/install/${id}?source=stylish-ch`); + `${URLS.uso}styles/install/${id}?source=stylish-ch`); const updateUrl = `${URLS.usoArchiveRaw}usercss/${id}.user.css`; try { const sourceCode = await download(updateUrl); - const style = await API.installUsercss({sourceCode, updateUrl}); + const style = await API.usercss.install({sourceCode, updateUrl}); renderFullInfo(entry, style); } catch (reason) { error(`Error while downloading usoID:${id}\nReason: ${reason}`); } - $.remove('.lds-spinner', entry); + $remove('.lds-spinner', entry); installButton.disabled = false; entry.style.pointerEvents = ''; } @@ -432,7 +425,7 @@ window.addEventListener('showStyles:done', () => { function uninstall() { const entry = this.closest('.search-result'); saveScrollPosition(entry); - API.deleteStyle(entry._result.installedStyleId); + API.styles.delete(entry._result.installedStyleId); } function saveScrollPosition(entry) { @@ -479,7 +472,7 @@ window.addEventListener('showStyles:done', () => { index = (await download(INDEX_URL, {responseType: 'json'})) .filter(res => res.f === 'uso'); clearTimeout(timer); - $.remove(':scope > .lds-spinner', dom.list); + $remove(':scope > .lds-spinner', dom.list); return index; } @@ -533,4 +526,4 @@ window.addEventListener('showStyles:done', () => { if (!res._year) res._year = new Date(res.u * 1000).getFullYear(); return res; } -}, {once: true}); +})(); diff --git a/tools/build-vendor.js b/tools/build-vendor.js index 449ed144..067c85ef 100644 --- a/tools/build-vendor.js +++ b/tools/build-vendor.js @@ -161,10 +161,3 @@ function generateList(list) { return `* ${src}`; }).join('\n'); } - -// Rename CodeMirror$1 -> CodeMirror for development purposes -// FIXME: is this a workaround for old version of codemirror? -// function renameCodeMirrorVariable(filePath) { - // const file = fs.readFileSync(filePath, 'utf8'); - // fs.writeFileSync(filePath, file.replace(/CodeMirror\$1/g, 'CodeMirror')); -// } diff --git a/tools/zip.js b/tools/zip.js index 5d975536..f6bfc0fd 100644 --- a/tools/zip.js +++ b/tools/zip.js @@ -3,13 +3,11 @@ const fs = require('fs'); const archiver = require('archiver'); -const manifest = require('../manifest.json'); -function createZip({isFirefox} = {}) { - const fileName = `stylus${isFirefox ? '-firefox' : ''}.zip`; +function createZip() { + const fileName = 'stylus.zip'; const ignore = [ '.*', // dot files/folders (glob, not regexp) - 'vendor/codemirror/lib/**', // get unmodified copy from node_modules 'node_modules/**', 'tools/**', 'package.json', @@ -39,19 +37,7 @@ function createZip({isFirefox} = {}) { }); archive.pipe(file); - if (isFirefox) { - const name = 'manifest.json'; - const keyOpt = 'optional_permissions'; - ignore.unshift(name); - manifest[keyOpt] = manifest[keyOpt].filter(p => p !== 'declarativeContent'); - if (!manifest[keyOpt].length) { - delete manifest[keyOpt]; - } - archive.append(JSON.stringify(manifest, null, ' '), {name, stats: fs.lstatSync(name)}); - } archive.glob('**', {ignore}); - // Don't use modified codemirror.js (see "update-libraries.js") - archive.directory('node_modules/codemirror/lib', 'vendor/codemirror/lib'); archive.finalize(); }); } @@ -59,7 +45,6 @@ function createZip({isFirefox} = {}) { (async () => { try { await createZip(); - await createZip({isFirefox: true}); console.log('\x1b[32m%s\x1b[0m', 'Stylus zip complete'); } catch (err) { console.error(err); diff --git a/vendor/jsonlint/jsonlint.js b/vendor/jsonlint/jsonlint.js index 598c18c2..9613c49c 100644 --- a/vendor/jsonlint/jsonlint.js +++ b/vendor/jsonlint/jsonlint.js @@ -17,7 +17,7 @@ case 1: // replace escaped characters with actual character .replace(/\\v/g,'\v') .replace(/\\f/g,'\f') .replace(/\\b/g,'\b'); - + break; case 2:this.$ = Number(yytext); break; @@ -341,7 +341,7 @@ next:function () { if (this._input === "") { return this.EOF; } else { - this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(), + this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(), {text: "", token: null, line: this.yylineno}); } }, @@ -429,4 +429,4 @@ exports.main = function commonjsMain(args) { if (typeof module !== 'undefined' && require.main === module) { exports.main(typeof process !== 'undefined' ? process.argv.slice(1) : require("system").args); } -} \ No newline at end of file +}