From 922c66a141de8794ba6f3c1f918e51d279a01201 Mon Sep 17 00:00:00 2001 From: tophf Date: Sat, 26 Dec 2020 15:59:49 +0300 Subject: [PATCH] use executeScript for early injection + split/async'ify * split, regroup, and async'ify files * consistent window scrolling in scrollToEditor and jumpToPos * rework waitForSelector and collapsible
* parserlib: fast section extraction, tweaks and speedups * csslint: "simple-not" rule * csslint: enable and fix "selector-newline" rule * blank paint frame workaround for new Chrome * extract stuff from edit.js and load on demand * simplify regexpTester::isShown * move MozDocMapper to sections-util.js * extract fitSelectBox() * initialize router earlier * use helpPopup.close() * fix autofocus in popups, follow-up to 5bb1b5ef * clone objects in prefs.get() + cosmetics * reuse getAll result for INC --- .eslintignore | 4 +- .eslintrc.yml | 24 +- README.md | 6 +- background/background-worker.js | 187 +--- background/background.js | 131 ++- background/browser-cmd-hotkeys.js | 22 + background/common.js | 31 + background/content-scripts.js | 20 +- background/context-menus.js | 20 +- background/db-chrome-storage.js | 92 +- background/db.js | 26 +- background/icon-manager.js | 143 ++- background/icon-util.js | 91 -- background/navigation-manager.js | 82 ++ background/navigator-util.js | 103 -- background/openusercss-api.js | 16 +- background/remove-unused-storage.js | 15 + background/style-manager.js | 200 ++-- .../{search-db.js => style-search-db.js} | 74 +- background/style-via-api.js | 46 +- background/style-via-webrequest.js | 134 +-- background/{sync.js => sync-manager.js} | 200 ++-- background/tab-manager.js | 37 +- background/token-manager.js | 209 ++-- background/{update.js => update-manager.js} | 45 +- background/usercss-api-helper.js | 81 -- background/usercss-install-helper.js | 121 ++- background/usercss-manager.js | 152 +++ content/apply.js | 157 ++- content/install-hook-greasyfork.js | 2 +- content/install-hook-openusercss.js | 6 +- content/install-hook-userstyles.js | 88 +- content/style-injector.js | 30 +- edit.html | 80 +- edit/autocomplete.js | 234 +++++ edit/base.js | 412 ++++++++ edit/beautify.js | 312 +++--- edit/codemirror-default.css | 3 - edit/codemirror-default.js | 116 +-- edit/codemirror-factory.js | 335 +------ edit/colorpicker-helper.js | 81 -- edit/edit.js | 948 ++++++------------ edit/editor-worker.js | 161 +-- edit/embedded-popup.js | 114 +++ edit/global-search.js | 47 +- edit/linter-config-dialog.js | 197 ---- edit/linter-defaults.js | 222 ---- edit/linter-dialogs.js | 226 +++++ edit/linter-engines.js | 115 --- edit/linter-help-dialog.js | 52 - edit/linter-manager.js | 420 ++++++++ edit/linter-meta.js | 44 - edit/linter-report.js | 161 --- edit/linter.js | 77 -- edit/live-preview.js | 74 -- edit/moz-section-finder.js | 36 +- edit/moz-section-widget.js | 50 +- edit/regexp-tester.js | 160 ++- edit/reroute-hotkeys.js | 49 - edit/sections-editor-section.js | 56 +- edit/sections-editor.js | 107 +- edit/show-keymap-help.js | 35 +- edit/source-editor.js | 240 +++-- edit/util.js | 303 +++--- install-usercss.html | 39 +- install-usercss/install-usercss.css | 4 + install-usercss/install-usercss.js | 770 +++++++------- install-usercss/preinit.js | 90 ++ js/cache.js | 71 -- .../colorpicker => js/color}/LICENSE | 0 .../colorpicker => js/color}/README.md | 0 .../color/color-converter.js | 0 js/color/color-mimicry.js | 89 ++ .../color/color-picker.css | 0 .../color/color-picker.js | 103 +- .../colorview.js => js/color/color-view.js | 5 +- {vendor-overwrites => js}/csslint/LICENSE | 0 {vendor-overwrites => js}/csslint/README.md | 0 {vendor-overwrites => js}/csslint/csslint.js | 81 +- .../csslint/parserlib.js | 341 +++---- {manage => js/dlg}/config-dialog.css | 0 {manage => js/dlg}/config-dialog.js | 106 +- msgbox/msgbox.css => js/dlg/message-box.css | 0 msgbox/msgbox.js => js/dlg/message-box.js | 73 +- js/dom.js | 802 +++++++-------- js/localization.js | 17 +- js/meta-parser.js | 59 +- js/moz-parser.js | 39 +- js/msg.js | 120 ++- js/polyfill.js | 36 +- js/prefs.js | 87 +- js/router.js | 121 +-- js/script-loader.js | 50 - js/sections-util.js | 122 ++- js/storage-util.js | 23 +- js/{messaging.js => toolbox.js} | 153 ++- js/usercss-compiler.js | 128 +++ js/usercss.js | 81 -- js/worker-util.js | 155 +-- manage.html | 38 +- manage/events.js | 283 ++++++ manage/filters.js | 70 +- manage/import-export.js | 127 ++- manage/incremental-search.js | 22 +- manage/manage.js | 733 +------------- manage/object-diff.js | 40 - manage/render.js | 428 ++++++++ manage/{sort.js => sorter.js} | 130 +-- manage/updater-ui.js | 114 +-- manifest.json | 43 +- options.html | 13 +- options/options.js | 90 +- popup.html | 28 +- popup/events.js | 198 ++++ popup/hotkeys.js | 49 +- popup/popup-preinit.js | 87 -- popup/popup.css | 4 + popup/popup.js | 344 ++----- popup/preinit.js | 77 ++ popup/{search-results.css => search.css} | 9 +- popup/{search-results.js => search.js} | 69 +- tools/build-vendor.js | 7 - tools/zip.js | 19 +- vendor/jsonlint/jsonlint.js | 6 +- 124 files changed, 7266 insertions(+), 7589 deletions(-) create mode 100644 background/browser-cmd-hotkeys.js create mode 100644 background/common.js delete mode 100644 background/icon-util.js create mode 100644 background/navigation-manager.js delete mode 100644 background/navigator-util.js create mode 100644 background/remove-unused-storage.js rename background/{search-db.js => style-search-db.js} (54%) rename background/{sync.js => sync-manager.js} (50%) rename background/{update.js => update-manager.js} (91%) delete mode 100644 background/usercss-api-helper.js create mode 100644 background/usercss-manager.js create mode 100644 edit/autocomplete.js create mode 100644 edit/base.js delete mode 100644 edit/colorpicker-helper.js create mode 100644 edit/embedded-popup.js delete mode 100644 edit/linter-config-dialog.js delete mode 100644 edit/linter-defaults.js create mode 100644 edit/linter-dialogs.js delete mode 100644 edit/linter-engines.js delete mode 100644 edit/linter-help-dialog.js create mode 100644 edit/linter-manager.js delete mode 100644 edit/linter-meta.js delete mode 100644 edit/linter-report.js delete mode 100644 edit/linter.js delete mode 100644 edit/live-preview.js delete mode 100644 edit/reroute-hotkeys.js create mode 100644 install-usercss/preinit.js delete mode 100644 js/cache.js rename {vendor-overwrites/colorpicker => js/color}/LICENSE (100%) rename {vendor-overwrites/colorpicker => js/color}/README.md (100%) rename vendor-overwrites/colorpicker/colorconverter.js => js/color/color-converter.js (100%) create mode 100644 js/color/color-mimicry.js rename vendor-overwrites/colorpicker/colorpicker.css => js/color/color-picker.css (100%) rename vendor-overwrites/colorpicker/colorpicker.js => js/color/color-picker.js (90%) rename vendor-overwrites/colorpicker/colorview.js => js/color/color-view.js (99%) rename {vendor-overwrites => js}/csslint/LICENSE (100%) rename {vendor-overwrites => js}/csslint/README.md (100%) rename {vendor-overwrites => js}/csslint/csslint.js (96%) rename {vendor-overwrites => js}/csslint/parserlib.js (95%) rename {manage => js/dlg}/config-dialog.css (100%) rename {manage => js/dlg}/config-dialog.js (85%) rename msgbox/msgbox.css => js/dlg/message-box.css (100%) rename msgbox/msgbox.js => js/dlg/message-box.js (74%) delete mode 100644 js/script-loader.js rename js/{messaging.js => toolbox.js} (83%) create mode 100644 js/usercss-compiler.js delete mode 100644 js/usercss.js create mode 100644 manage/events.js delete mode 100644 manage/object-diff.js create mode 100644 manage/render.js rename manage/{sort.js => sorter.js} (67%) create mode 100644 popup/events.js delete mode 100644 popup/popup-preinit.js create mode 100644 popup/preinit.js rename popup/{search-results.css => search.css} (98%) mode change 100755 => 100644 rename popup/{search-results.js => search.js} (93%) mode change 100755 => 100644 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 5c8136b4..cfbbe89a 100644 --- a/background/background-worker.js +++ b/background/background-worker.js @@ -1,177 +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({ -/** @namespace ApiWorker */ -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 371e8253..9b2e33eb 100644 --- a/background/background.js +++ b/background/background.js @@ -1,42 +1,43 @@ +/* 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 - activateTab - API - chromeLocal - findExistingTab FIREFOX + URLS + activateTab + download + findExistingTab getActiveTab isTabReplaceable - msg openURL - prefs - semverCompare - URLS - workerUtil -*/ +*/ // toolbox.js 'use strict'; //#region API -Object.assign(API, { +addAPI(/** @namespace API */ { - /** @type {ApiWorker} */ - worker: workerUtil.createWorker({ - url: '/background/background-worker.js', - }), + 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 || {}); + }, /** @returns {string} */ getTabUrlPrefix() { - const {url} = this.sender.tab; - if (url.startsWith(URLS.ownOrigin)) { - return 'stylus'; - } - return url.match(/^([\w-]+:\/+[^/#]+)/)[1]; - }, - - /** @returns {Prefs} */ - getPrefs: () => prefs.values, - setPref(key, value) { - prefs.set(key, value); + return this.sender.tab.url.match(/^([\w-]+:\/+[^/#]+)/)[1]; }, /** @@ -118,65 +119,63 @@ Object.assign(API, { })); } }, + + prefs: { + getValues: () => prefs.__values, // will be deepCopy'd by apiHandler + set: prefs.set, + }, }); //#endregion -//#region browserCommands +//#region Events 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')); }, - reload: () => chrome.runtime.reload(), }; + if (chrome.commands) { - chrome.commands.onCommand.addListener(command => browserCommands[command]()); -} -if (FIREFOX && browser.commands && browser.commands.update) { - // register hotkeys in FF - 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) {} - }); + chrome.commands.onCommand.addListener(id => browserCommands[id]()); } -//#endregion -//#region Init +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']); + } + } +}); msg.on((msg, sender) => { if (msg.method === 'invokeAPI') { - const fn = msg.path.reduce((res, name) => res && res[name], API); - if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`); - const res = fn.apply({msg, sender}, msg.args); + 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; } }); -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); - } -}); - -msg.broadcast({method: 'backgroundReady'}); - //#endregion + +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 08b7d144..06b4a282 100644 --- a/background/content-scripts.js +++ b/background/content-scripts.js @@ -1,25 +1,21 @@ -/* global - FIREFOX - ignoreChromeError - msg - URLS -*/ +/* global bgReady */// common.js +/* global msg */ +/* global URLS ignoreChromeError */// toolbox.js 'use strict'; /* Reinject content scripts when the extension is reloaded/updated. - Firefox handles this automatically. + Not used in Firefox as it reinjects automatically. */ -// eslint-disable-next-line no-unused-expressions -!FIREFOX && (() => { +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) @@ -118,4 +114,4 @@ function onBusyTabRemoved(tabId) { trackBusyTab(tabId, false); } -})(); +}); diff --git a/background/context-menus.js b/background/context-menus.js index b5f66d29..53101dea 100644 --- a/background/context-menus.js +++ b/background/context-menus.js @@ -1,16 +1,10 @@ -/* global - browserCommands - CHROME - FIREFOX - ignoreChromeError - msg - prefs - URLS -*/ +/* global browserCommands */// background.js +/* global msg */ +/* global prefs */ +/* global CHROME FIREFOX URLS ignoreChromeError */// toolbox.js 'use strict'; -// eslint-disable-next-line no-unused-expressions -chrome.contextMenus && (() => { +(() => { const contextMenus = { 'show-badge': { title: 'menuShowBadge', @@ -52,13 +46,13 @@ chrome.contextMenus && (() => { /(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; + 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 && id in prefs.defaults), + prefs.subscribe(keys.filter(id => contextMenus[id].presentIf && prefs.knownKeys.includes(id)), togglePresence); createContextMenus(keys); diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index 6327a54c..fe5ace24 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -1,56 +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: (method, ...args) => METHODS[method](...args), - }; - - 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 3b6f8bc3..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'; @@ -44,13 +45,14 @@ const db = (() => { 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; + await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ + return createChromeStorageDB(); } async function dbExecIndexedDB(method, ...args) { diff --git a/background/icon-manager.js b/background/icon-manager.js index e2781fde..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 */ -/* 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, { - /** @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')) { + 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 bdcdbedb..00000000 --- a/background/navigator-util.js +++ /dev/null @@ -1,103 +0,0 @@ -/* global - CHROME - FIREFOX - ignoreChromeError - msg - URLS -*/ -'use strict'; - -(() => { - /** @type {Set} */ - const listeners = new Set(); - /** @type {NavigatorUtil} */ - const navigatorUtil = window.navigatorUtil = new Proxy({ - onUrlChange(fn) { - listeners.add(fn); - }, - }, { - get(target, prop) { - return target[prop] || - (target = chrome.webNavigation[prop]).addListener.bind(target); - }, - }); - - navigatorUtil.onCommitted(onNavigation.bind('committed')); - navigatorUtil.onHistoryStateUpdated(onFakeNavigation.bind('history')); - navigatorUtil.onReferenceFragmentUpdated(onFakeNavigation.bind('hash')); - navigatorUtil.onCommitted(runGreasyforkContentScript, { - // expose style version on greasyfork/sleazyfork 1) info page and 2) code page - url: ['greasyfork', 'sleazyfork'].map(host => ({ - hostEquals: host + '.org', - urlMatches: '/scripts/\\d+[^/]*(/code)?([?#].*)?$', - })), - }); - if (FIREFOX) { - navigatorUtil.onDOMContentLoaded(runMainContentScripts, { - url: [{ - urlEquals: 'about:blank', - }], - }); - } - - /** @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); - } - - /** FF misses some about:blank iframes so we inject our content script explicitly */ - async function runMainContentScripts({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); - } - } - } - - function runGreasyforkContentScript({tabId}) { - chrome.tabs.executeScript(tabId, { - file: '/content/install-hook-greasyfork.js', - runAt: 'document_start', - }); - } -})(); - -/** - * @typedef NavigatorUtil - * @property {NavigatorUtilEvent} onBeforeNavigate - * @property {NavigatorUtilEvent} onCommitted - * @property {NavigatorUtilEvent} onCompleted - * @property {NavigatorUtilEvent} onCreatedNavigationTarget - * @property {NavigatorUtilEvent} onDOMContentLoaded - * @property {NavigatorUtilEvent} onErrorOccurred - * @property {NavigatorUtilEvent} onHistoryStateUpdated - * @property {NavigatorUtilEvent} onReferenceFragmentUpdated - * @property {NavigatorUtilEvent} onTabReplaced -*/ -/** - * @typedef {function(cb: function, filters: WebNavigationEventFilter?)} NavigatorUtilEvent - */ diff --git a/background/openusercss-api.js b/background/openusercss-api.js index 73a3ec3c..a1be3a08 100644 --- a/background/openusercss-api.js +++ b/background/openusercss-api.js @@ -1,6 +1,8 @@ -/* global API */ +/* global addAPI */// common.js 'use strict'; +/* CURRENTLY UNUSED */ + (() => { // begin:nanographql - Tiny graphQL client library // Author: yoshuawuyts (https://github.com/yoshuawuyts) @@ -26,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', @@ -37,11 +38,10 @@ body: query({ id, }), - }) - .then(res => res.json()); + })).json(); }; - API.openusercss = { + 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 @@ -99,5 +99,5 @@ } } `), - }; + }); })(); 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 c9497288..167328fc 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -1,17 +1,10 @@ -/* global - API - calcStyleDigest - createCache - db - msg - prefs - stringAsRegExp - styleCodeEmpty - styleSectionGlobal - tabManager - tryRegExp - URLS -*/ +/* 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'; /* @@ -20,27 +13,25 @@ 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. */ -/* exported styleManager */ -const styleManager = API.styles = (() => { +const styleMan = (() => { //#region Declarations - const ready = init(); - /** - * @typedef StyleMapData - * @property {StyleObj} style - * @property {?StyleObj} [preview] - * @property {Set} appliesTo - urls - */ + + /** @typedef {{ + style: StyleObj + preview?: StyleObj + appliesTo: Set + }} StyleMapData */ /** @type {Map} */ const dataMap = new Map(); const uuidIndex = new Map(); /** @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 data = id2data(section.id); if (data) data.appliesTo.delete(url); @@ -51,40 +42,25 @@ const styleManager = API.styles = (() => { const compileRe = createCompiler(text => `^(${text})$`); const compileSloppyRe = createCompiler(text => `^${text}$`); const compileExclusion = createCompiler(buildExclusion); - const DUMMY_URL = { - hash: '', - host: '', - hostname: '', - href: '', - origin: '', - password: '', - pathname: '', - port: '', - protocol: '', - search: '', - searchParams: new URLSearchParams(), - username: '', - }; const MISSING_PROPS = { name: style => `ID: ${style.id}`, _id: () => uuidv4(), _rev: () => Date.now(), }; const DELETE_IF_NULL = ['id', 'customName']; - //#endregion + /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ + let ready = init(); chrome.runtime.onConnect.addListener(handleLivePreview); - //#region Public surface + //#endregion + //#region Exports - // Sorted alphabetically return { - compareRevision, - /** @returns {Promise} style id */ async delete(id, reason) { - await ready; + if (ready.then) await ready; const data = id2data(id); await db.exec('delete', id); if (reason !== 'sync') { @@ -105,18 +81,18 @@ const styleManager = API.styles = (() => { /** @returns {Promise} style id */ async deleteByUUID(_id, rev) { - await ready; + 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 API.styles.delete(id, 'sync'); + return styleMan.delete(id, 'sync'); } }, /** @returns {Promise} */ async editSave(style) { - await ready; + if (ready.then) await ready; style = mergeWithMapped(style); style.updateDate = Date.now(); return handleSave(await saveStyle(style), 'editSave'); @@ -124,7 +100,7 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ async find(filter) { - await ready; + if (ready.then) await ready; const filterEntries = Object.entries(filter); for (const {style} of dataMap.values()) { if (filterEntries.every(([key, val]) => style[key] === val)) { @@ -136,23 +112,26 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ async getAll() { - await ready; + if (ready.then) await ready; return Array.from(dataMap.values(), data2style); }, /** @returns {Promise} */ async getByUUID(uuid) { - await ready; + if (ready.then) await ready; return id2style(uuidIndex.get(uuid)); }, /** @returns {Promise} */ async getSectionsByUrl(url, id, isInitialApply) { - await ready; + 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.sender; - url = tab && tabManager.get(tab.id, 'url', frameId) || url; + const {tab, frameId} = this && this.sender || {}; + url = tab && tabMan.get(tab.id, 'url', frameId) || url; let cache = cachedStyleForUrl.get(url); if (!cache) { cache = { @@ -164,24 +143,20 @@ const styleManager = API.styles = (() => { } else if (cache.maybeMatch.size) { buildCache(cache, url, Array.from(cache.maybeMatch, id2data).filter(Boolean)); } - const res = id + return 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; }, /** @returns {Promise} */ async get(id) { - await ready; + if (ready.then) await ready; return id2style(id); }, /** @returns {Promise} */ async getByUrl(url, id = null) { - await ready; + 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 = []; @@ -215,7 +190,7 @@ const styleManager = API.styles = (() => { } } if (sectionMatched) { - result.push(/** @namespace StylesByUrlResult */{style, excluded, sloppy}); + result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy}); } } return result; @@ -223,7 +198,7 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ async importMany(items) { - await ready; + if (ready.then) await ready; items.forEach(beforeSave); const events = await db.exec('putMany', items); return Promise.all(items.map((item, i) => { @@ -234,13 +209,13 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ async import(data) { - await ready; + if (ready.then) await ready; return handleSave(await saveStyle(data), 'import'); }, /** @returns {Promise} */ async install(style, reason = null) { - await ready; + if (ready.then) await ready; reason = reason || dataMap.has(style.id) ? 'update' : 'install'; style = mergeWithMapped(style); const url = !style.url && style.updateUrl && ( @@ -255,7 +230,7 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ async putByUUID(doc) { - await ready; + if (ready.then) await ready; const id = uuidIndex.get(doc._id); if (id) { doc.id = id; @@ -280,7 +255,7 @@ const styleManager = API.styles = (() => { /** @returns {Promise} style id */ async toggle(id, enabled) { - await ready; + if (ready.then) await ready; const style = Object.assign({}, id2style(id), {enabled}); handleSave(await saveStyle(style), 'toggle', false); return id; @@ -297,8 +272,8 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), }; - //#endregion + //#endregion //#region Implementation /** @returns {StyleMapData} */ @@ -318,7 +293,7 @@ const styleManager = API.styles = (() => { /** @returns {StyleObj} */ function createNewStyle() { - return /** @namespace StyleObj */{ + return /** @namespace StyleObj */ { enabled: true, updateUrl: null, md5Url: null, @@ -366,12 +341,8 @@ const styleManager = API.styles = (() => { }); } - function compareRevision(rev1, rev2) { - return rev1 - rev2; - } - async function addIncludeExclude(type, id, rule) { - await ready; + if (ready.then) await ready; const style = Object.assign({}, id2style(id)); const list = style[type] || (style[type] = []); if (list.includes(rule)) { @@ -382,7 +353,7 @@ const styleManager = API.styles = (() => { } async function removeIncludeExclude(type, id, rule) { - await ready; + if (ready.then) await ready; const style = Object.assign({}, id2style(id)); const list = style[type]; if (!list || !list.includes(rule)) { @@ -494,6 +465,8 @@ const styleManager = API.styles = (() => { storeInMap(style); uuidIndex.set(style._id, style.id); } + ready = true; + bgReady._resolveStyles(); } function addMissingProps(style) { @@ -661,7 +634,20 @@ const styleManager = API.styles = (() => { 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: '', + }; } } @@ -677,5 +663,67 @@ const styleManager = API.styles = (() => { 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 b23679ed..ca8e6e06 100644 --- a/background/search-db.js +++ b/background/style-search-db.js @@ -1,10 +1,6 @@ -/* global - API - debounce - stringAsRegExp - tryRegExp - usercss -*/ +/* global API */// msg.js +/* global URLS debounce stringAsRegExp tryRegExp */// toolbox.js +/* global addAPI */// common.js 'use strict'; (() => { @@ -14,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), { @@ -30,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) || @@ -42,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.searchDB = async ({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) : makeTester(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; - }; + 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 7da780ae..ed08dac4 100644 --- a/background/style-via-api.js +++ b/background/style-via-api.js @@ -1,7 +1,14 @@ -/* global API CHROME prefs */ +/* global API */// msg.js +/* global addAPI */// common.js +/* global isEmptyObj */// toolbox.js +/* global prefs */ 'use strict'; -API.styleViaAPI = !CHROME && (() => { +/** + * Uses chrome.tabs.insertCSS + */ + +(() => { const ACTIONS = { styleApply, styleDeleted, @@ -11,25 +18,25 @@ API.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; @@ -125,7 +132,7 @@ API.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.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.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.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 45e12d65..0586a463 100644 --- a/background/style-via-webrequest.js +++ b/background/style-via-webrequest.js @@ -1,105 +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 */ async function prepareStyles(req) { const sections = await API.styles.getSectionsByUrl(req.url); - 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); + 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}; } } @@ -115,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'); @@ -136,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.js b/background/sync-manager.js similarity index 50% rename from background/sync.js rename to background/sync-manager.js index be478545..0d5938f5 100644 --- a/background/sync.js +++ b/background/sync-manager.js @@ -1,109 +1,74 @@ -/* global - API - chromeLocal - dbToCloud - msg - prefs - styleManager - tokenManager -*/ -/* exported sync */ - +/* global API msg */// msg.js +/* global chromeLocal */// storage-util.js +/* global compareRevision */// common.js +/* global prefs */ +/* global tokenMan */ 'use strict'; -const sync = API.sync = (() => { +const syncMan = (() => { + //#region Init + const SYNC_DELAY = 1; // minutes const SYNC_INTERVAL = 30; // minutes - - /** @typedef API.sync.Status */ - const status = { - /** @type {'connected'|'connecting'|'disconnected'|'disconnecting'} */ - state: 'disconnected', + 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; - const 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(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 ready = prefs.initializing.then(() => { + /** @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' - ? sync.stop() - : sync.start(val, true), - {now: true}); + ? syncMan.stop() + : syncMan.start(val, true), + {runNow: true}); }); chrome.alarms.onAlarm.addListener(info => { if (info.name === 'syncNow') { - sync.syncNow(); + syncMan.syncNow(); } }); - // Sorted alphabetically + //#endregion + //#region Exports + return { async delete(...args) { - await ready; + if (ready.then) await ready; if (!currentDrive) return; schedule(); return ctrl.delete(...args); }, - /** - * @returns {Promise} - */ + /** @returns {Promise} */ async getStatus() { return status; }, async login(name = prefs.get('sync.enabled')) { - await ready; + if (ready.then) await ready; try { - await tokenManager.getToken(name, true); + 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 tokenManager.getToken(name); + await tokenMan.getToken(name); } throw err; } @@ -112,70 +77,64 @@ const sync = API.sync = (() => { }, async put(...args) { - await ready; + if (ready.then) await ready; if (!currentDrive) return; schedule(); return ctrl.put(...args); }, async start(name, fromPref = false) { - await ready; - if (currentDrive) { - return; - } + if (ready.then) await ready; + if (!ctrl) await initController(); + if (currentDrive) return; currentDrive = getDrive(name); ctrl.use(currentDrive); - status.state = 'connecting'; + status.state = STATES.connecting; status.currentDriveName = currentDrive.name; status.login = true; emitStatusChange(); try { if (!fromPref) { - await sync.login(name).catch(handle401Error); + await syncMan.login(name).catch(handle401Error); } - await sync.syncNow(); + 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 sync.stop(); + return syncMan.stop(); } } prefs.set('sync.enabled', name); - status.state = 'connected'; + status.state = STATES.connected; schedule(SYNC_INTERVAL); emitStatusChange(); }, async stop() { - await ready; - if (!currentDrive) { - return; - } + if (ready.then) await ready; + if (!currentDrive) return; chrome.alarms.clear('syncNow'); - status.state = 'disconnecting'; + status.state = STATES.disconnecting; emitStatusChange(); try { await ctrl.stop(); - await tokenManager.revokeToken(currentDrive.name); - await chromeLocal.remove(`sync/state/${currentDrive.name}`); - } catch (e) { - } + await tokenMan.revokeToken(currentDrive.name); + await chromeLocal.remove(STORAGE_KEY + currentDrive.name); + } catch (e) {} currentDrive = null; prefs.set('sync.enabled', 'none'); - status.state = 'disconnected'; + status.state = STATES.disconnected; status.currentDriveName = null; status.login = false; emitStatusChange(); }, async syncNow() { - await ready; - if (!currentDrive) { - return Promise.reject(new Error('cannot sync when disconnected')); - } + 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; @@ -186,17 +145,51 @@ const sync = API.sync = (() => { }, }; - function schedule(delay = SYNC_DELAY) { - chrome.alarms.create('syncNow', { - delayInMinutes: delay, - periodInMinutes: SYNC_INTERVAL, + //#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 tokenManager.revokeToken(currentDrive.name).catch(console.error); + await tokenMan.revokeToken(currentDrive.name).catch(console.error); emit = true; } else if (/User interaction required|Requires user interaction/i.test(err.message)) { emit = true; @@ -215,9 +208,18 @@ const sync = API.sync = (() => { function getDrive(name) { if (name === 'dropbox' || name === 'google' || name === 'onedrive') { return dbToCloud.drive[name]({ - getAccessToken: () => tokenManager.getToken(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/tab-manager.js b/background/tab-manager.js index 5a341c1f..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}) => { - const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId); - tabManager.set(tabId, 'url', frameId, url); - if (frameId) return; - 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.js b/background/update-manager.js similarity index 91% rename from background/update.js rename to background/update-manager.js index 182a008d..88e3b48c 100644 --- a/background/update.js +++ b/background/update-manager.js @@ -1,20 +1,13 @@ -/* global - API - calcStyleDigest - chromeLocal - debounce - download - ignoreChromeError - prefs - semverCompare - styleJSONseemsValid - styleSectionsEqual - usercss -*/ +/* 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'; -(() => { - const STATES = /** @namespace UpdaterStates */{ +/* exported updateMan */ +const updateMan = (() => { + const STATES = /** @namespace UpdaterStates */ { UPDATED: 'updated', SKIPPED: 'skipped', UNREACHABLE: 'server unreachable', @@ -28,6 +21,7 @@ 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 = [ @@ -39,18 +33,18 @@ let logQueue = []; let logLastWriteTime = 0; - API.updater = { + 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, }; - chromeLocal.getValue('lastUpdateTime').then(val => { - lastUpdateTime = val || Date.now(); - prefs.subscribe('updateInterval', schedule, {now: true}); - chrome.alarms.onAlarm.addListener(onAlarm); - }); - async function checkAllStyles({ save = true, ignoreDigest, @@ -157,7 +151,8 @@ 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 usercss.buildMeta(text); + 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 @@ -168,7 +163,7 @@ // downgrade is always invalid return Promise.reject(STATES.ERROR_VERSION); } - return usercss.buildCode(json); + return API.usercss.buildCode(json); } async function maybeSave(json) { @@ -187,7 +182,7 @@ return Promise.reject(STATES.MAYBE_EDITED); } return !save ? newStyle : - (ucd ? API.usercss : API.styles).install(newStyle); + (ucd ? API.usercss.install : API.styles.install)(newStyle); } async function tryDownload(url, params) { diff --git a/background/usercss-api-helper.js b/background/usercss-api-helper.js deleted file mode 100644 index b6508896..00000000 --- a/background/usercss-api-helper.js +++ /dev/null @@ -1,81 +0,0 @@ -/* global - API - deepCopy - usercss -*/ -'use strict'; - -API.usercss = { - - async build({ - styleId, - sourceCode, - vars, - checkDup, - metaOnly, - assignVars, - }) { - let style = await usercss.buildMeta(sourceCode); - const dup = (checkDup || assignVars) && - await API.usercss.find(styleId ? {id: styleId} : style); - if (!metaOnly) { - if (vars || assignVars) { - await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup); - } - style = await usercss.buildCode(style); - } - return {style, dup}; - }, - - async buildMeta(style) { - if (style.usercssData) { - return style; - } - // allow sourceCode to be normalized - const {sourceCode} = style; - delete style.sourceCode; - return Object.assign(await usercss.buildMeta(sourceCode), style); - }, - - async configVars(id, vars) { - let style = deepCopy(await API.styles.get(id)); - style.usercssData.vars = vars; - style = await usercss.buildCode(style); - style = await API.styles.install(style, 'config'); - return style.usercssData.vars; - }, - - async editSave(style) { - return API.styles.editSave(await API.usercss.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 API.usercss.parse(style)); - }, - - async parse(style) { - style = await API.usercss.buildMeta(style); - // preserve style.vars during update - const dup = await API.usercss.find(style); - if (dup) { - style.id = dup.id; - await usercss.assignVars(style, dup); - } - return usercss.buildCode(style); - }, -}; diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js index 099a0822..b33052a1 100644 --- a/background/usercss-install-helper.js +++ b/background/usercss-install-helper.js @@ -1,37 +1,22 @@ -/* global - API - 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.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; - }; + 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 b4905b38..786b4948 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,21 +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, }); + // 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; @@ -25,16 +26,16 @@ self.INJECTED !== 1 && (() => { /** @type chrome.runtime.Port */ let port; - let lazyBadge = IS_FRAME; + let lazyBadge = isFrame; let parentDomain; // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let - const initializing = init(); + 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 (!IS_TAB) { + if (!isTab) { chrome.tabs.getCurrent(tab => { - IS_TAB = Boolean(tab); + isTab = Boolean(tab); if (tab && styleInjector.list.length) updateCount(); }); } @@ -50,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.styles.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.styles.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.styles.getSectionsByUrl(getMatchUrl(), request.style.id) + if (style.enabled) { + API.styles.getSectionsByUrl(matchUrl, style.id) .then(styleInjector.apply); } break; case 'urlChanged': - API.styles.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)); @@ -159,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); } @@ -182,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) { @@ -191,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 9de4cab3..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 diff --git a/content/install-hook-openusercss.js b/content/install-hook-openusercss.js index e57da66d..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'; (() => { @@ -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); } }; diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index f9330f43..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,13 +14,6 @@ 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([ @@ -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}; } @@ -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.styles.install(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,11 +208,11 @@ return e ? e.getAttribute('href') : null; } - async function getResource(url, type = 'text') { + async function getResource(url, opts) { try { return url.startsWith('#') ? document.getElementById(url.slice(1)).textContent - : await (await fetch(url))[type]; + : await API.download(url, opts); } catch (error) { alert('Error\n' + error.message); return Promise.reject(error); @@ -231,32 +221,19 @@ // 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(), '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.worker.parseMozFormat({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) {} } /** @@ -290,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) { @@ -329,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 7a3fb0f2..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 = () => {}, }) => { @@ -17,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() { @@ -155,10 +156,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ docRootObserver[onOff](); } - function _emitUpdate(value) { + function _emitUpdate() { _toggleObservers(list.length); onUpdate(); - return value; } /* 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 6a9f9127..b68e6deb 100644 --- a/options/options.js +++ b/options/options.js @@ -1,25 +1,25 @@ +/* global API msg */// msg.js +/* global prefs */ +/* global t */// localization.js /* global $ $$ $create $createLink - API - capitalize - CHROME - CHROME_HAS_BORDER_BUG - enforceInputRange - FIREFOX getEventKeyName - ignoreChromeError - messageBox - msg - openURL - OPERA - prefs + messageBoxProxy setupLivePrefs - t +*/// dom.js +/* global + CHROME + CHROME_POPUP_BORDER_BUG + FIREFOX + OPERA URLS -*/ + capitalize + ignoreChromeError + openURL +*/// toolbox.js 'use strict'; setupLivePrefs(); @@ -27,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'); @@ -49,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.on('click', () => { - if (el.checked) { - chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); - } - }); } // actions @@ -102,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')], @@ -125,7 +107,7 @@ document.onclick = e => { const elSyncNow = $('.sync-options .sync-now'); const elStatus = $('.sync-options .sync-status'); const elLogin = $('.sync-options .sync-login'); - /** @type {API.sync.Status} */ + /** @type {Sync.Status} */ let status = {}; msg.onExtension(e => { if (e.method === 'syncStatusUpdate') { @@ -143,7 +125,7 @@ document.onclick = e => { [elLogin, API.sync.login], ]) { btn.on('click', e => { - if (getEventKeyName(e) === 'L') { + if (getEventKeyName(e) === 'MouseL') { fn(); } }); @@ -155,8 +137,9 @@ document.onclick = e => { } function updateButtons() { - const isConnected = status.state === 'connected'; - const isDisconnected = status.state === 'disconnected'; + const {state, STATES} = status; + const isConnected = state === STATES.connected; + const isDisconnected = state === STATES.disconnected; if (status.currentDriveName) { elCloud.value = status.currentDriveName; } @@ -173,18 +156,17 @@ document.onclick = e => { } function getStatusText() { - // chrome.i18n.getMessage is used instead of t() because calculated ids may be absent let res; if (status.syncing) { const {phase, loaded, total} = status.progress || {}; res = phase - ? chrome.i18n.getMessage(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total]) || + ? t(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total], false) || `${phase} ${loaded} / ${total}` : t('optionsSyncStatusSyncing'); } else { - const {state, errorMessage} = status; - res = (state === 'connected' || state === 'disconnected') && errorMessage || - chrome.i18n.getMessage(`optionsSyncStatus${capitalize(state)}`) || state; + const {state, errorMessage, STATES} = status; + res = (state === STATES.connected || state === STATES.disconnected) && errorMessage || + t(`optionsSyncStatus${capitalize(state)}`, null, false) || state; } return res; } @@ -268,7 +250,7 @@ function customizeHotkeys() { ['styleDisableAll', 'disableAllStyles'], ]); - messageBox({ + messageBoxProxy.show({ title: t('shortcutsNote'), contents: [ $create('table', @@ -319,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 511f096c..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 || @@ -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 3e3693e2..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.styles.getByUrl(url, id)) - .map(r => Object.assign(r.style, 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 1c5fe508..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}); + 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,186 +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(); - }, - - async toggle(event) { - // when fired on checkbox, prevent the parent label from seeing the event, see #501 - event.stopPropagation(); - await API.styles.toggle(handleEvent.getClickedStyleId(event), this.checked); - resortEntries(); - }, - - toggleExclude(event, type) { - const entry = handleEvent.getClickedStyleElement(event); - if (event.target.checked) { - API.styles.addExclusion(entry.styleMeta.id, getExcludeRule(type)); - } else { - API.styles.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.styles.get(styleId).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; } @@ -601,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); @@ -619,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 93% rename from popup/search-results.js rename to popup/search.js index dad898cb..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,7 +139,7 @@ window.addEventListener('showStyles:done', () => { } }); - addEventListener('styleAdded', async ({detail: {style}}) => { + window.on('styleAdded', async ({detail: {style}}) => { restoreScrollPosition(); const usoId = calcUsoId(style) || calcUsoId(await API.styles.get(style.id)); @@ -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,7 +407,7 @@ 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 { @@ -424,7 +417,7 @@ window.addEventListener('showStyles:done', () => { } catch (reason) { error(`Error while downloading usoID:${id}\nReason: ${reason}`); } - $.remove('.lds-spinner', entry); + $remove('.lds-spinner', entry); installButton.disabled = false; entry.style.pointerEvents = ''; } @@ -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 +}