Browse Source

Merge remote-tracking branch 'LOCAL-main/master' into vue3

# Conflicts:
#	src/background/utils/tester.js
tophf 3 years ago
parent
commit
6d0e34a6a6

+ 2 - 2
.eslintrc.js

@@ -38,8 +38,8 @@ const INJECTED_RULES = {
       selector: 'ObjectExpression > ExperimentalSpreadProperty',
       message: 'Object spread adds a polyfill in injected* even if unused by it',
     }, {
-      selector: 'OptionalCallExpression',
-      message: 'Optional call uses .call(), which may be spoofed/broken in an unsafe environment',
+      selector: 'OptionalCallExpression > MemberExpression',
+      message: 'Optional call on property uses .call(), which may be spoofed/broken in an unsafe environment',
       // TODO: write a Babel plugin to use safeCall for this.
     }, {
       selector: 'ArrayPattern',

+ 2 - 2
src/background/index.js

@@ -1,12 +1,12 @@
 import '@/common/browser';
 import { getActiveTab, makePause, sendCmd } from '@/common';
-import { TIMEOUT_24HOURS, TIMEOUT_MAX } from '@/common/consts';
+import { TIMEOUT_24HOURS, TIMEOUT_MAX, extensionOrigin } from '@/common/consts';
 import { deepCopy } from '@/common/object';
 import * as tld from '@/common/tld';
 import * as sync from './sync';
 import { commands } from './utils';
 import { getData, getSizes, checkRemove } from './utils/db';
-import { extensionOrigin, initialize } from './utils/init';
+import { initialize } from './utils/init';
 import { getOption, hookOptions } from './utils/options';
 import './utils/clipboard';
 import './utils/hotkeys';

+ 1 - 4
src/background/utils/cache.js

@@ -2,10 +2,7 @@ import initCache from '@/common/cache';
 import { commands } from './message';
 
 const cache = initCache({
-  /* Keeping the data for one hour since chrome.storage.local is insanely slow in Chrome,
-     it can takes seconds to read it when injecting tabs with a big script/value, which delays
-     all other scripts in this tab and they will never be able to run at document-start. */
-  lifetime: 60 * 60 * 1000,
+  lifetime: 5 * 60 * 1000,
 });
 
 Object.assign(commands, {

+ 23 - 13
src/background/utils/db.js

@@ -1,12 +1,13 @@
 import {
   compareVersion, dataUri2text, i18n, getScriptHome, isDataUri, makeDataUri,
   getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
+  makePause,
 } from '@/common';
 import { ICON_PREFIX, INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
 import { deepSize, forEachEntry, forEachKey, forEachValue } from '@/common/object';
 import pluginEvents from '../plugin/events';
 import { getNameURI, parseMeta, newScript, getDefaultCustom } from './script';
-import { testScript, testBlacklist } from './tester';
+import { testScript, testBlacklist, testerBatch } from './tester';
 import { preInitialize } from './init';
 import { commands } from './message';
 import patchDB from './patch-db';
@@ -84,7 +85,14 @@ Object.assign(commands, {
     }
     return sendCmd('RemoveScript', id);
   },
-  ParseMeta: parseMeta,
+  ParseMeta(code) {
+    const meta = parseMeta(code);
+    const errors = [];
+    testerBatch(errors);
+    testScript('', { custom: getDefaultCustom(), meta });
+    testerBatch();
+    return { meta, errors };
+  },
   ParseScript: parseScript,
   /** @return {Promise<void>} */
   UpdateScriptInfo({ id, config, custom }) {
@@ -238,7 +246,8 @@ const retriedStorageKeys = {};
 /**
  * @desc Get scripts to be injected to page with specific URL.
  */
-export function getScriptsByURL(url, isTop) {
+export function getScriptsByURL(url, isTop, errors) {
+  testerBatch(errors || true);
   const allScripts = testBlacklist(url)
     ? []
     : store.scripts.filter(script => (
@@ -246,19 +255,18 @@ export function getScriptsByURL(url, isTop) {
       && (isTop || !(script.custom.noframes ?? script.meta.noframes))
       && testScript(url, script)
     ));
+  testerBatch();
   return getScriptEnv(allScripts);
 }
 
 /**
  * @param {VMScript[]} scripts
- * @param {boolean} [sizing]
- * @return {VMInjection.Env}
+ * @return {Promise<VMInjection.Env>}
  */
-function getScriptEnv(scripts, sizing) {
+async function getScriptEnv(scripts) {
   const disabledIds = [];
   const [envStart, envDelayed] = [0, 1].map(() => ({
     depsMap: {},
-    sizing,
     [ENV_SCRIPTS]: [],
   }));
   for (const [areaName, listName] of STORAGE_ROUTES_ENTRIES) {
@@ -267,7 +275,7 @@ function getScriptEnv(scripts, sizing) {
   }
   scripts.forEach((script) => {
     const { id } = script.props;
-    if (!sizing && !script.config.enabled) {
+    if (!script.config.enabled) {
       disabledIds.push(id);
       return;
     }
@@ -275,7 +283,7 @@ function getScriptEnv(scripts, sizing) {
     const { pathMap = buildPathMap(script) } = custom;
     const runAt = `${custom.runAt || meta.runAt || ''}`.match(RUN_AT_RE)?.[1] || 'end';
     /** @type {VMInjection.Env} */
-    const env = sizing || runAt === 'start' || runAt === 'body' ? envStart : envDelayed;
+    const env = runAt === 'start' || runAt === 'body' ? envStart : envDelayed;
     const { depsMap } = env;
     env.ids.push(id);
     if (meta.grant.some(GMVALUES_RE.test, GMVALUES_RE)) {
@@ -301,11 +309,13 @@ function getScriptEnv(scripts, sizing) {
         }
       }
     }
-    env[ENV_SCRIPTS].push(sizing ? script : { ...script, runAt });
+    env[ENV_SCRIPTS].push({ ...script, runAt });
   });
-  envStart.promise = readEnvironmentData(envStart);
+  if (envStart.ids.length) {
+    Object.assign(envStart, await readEnvironmentData(envStart));
+  }
   if (envDelayed.ids.length) {
-    envDelayed.promise = readEnvironmentData(envDelayed);
+    envDelayed.promise = makePause().then(() => readEnvironmentData(envDelayed));
   }
   return Object.assign(envStart, { disabledIds, envDelayed });
 }
@@ -324,7 +334,7 @@ async function readEnvironmentData(env, isRetry) {
       let val = data[storage[area].toKey(id)];
       if (!val && area === S_VALUE) val = {};
       env[area][id] = val;
-      if (val == null && !env.sizing && retriedStorageKeys[area + id] !== 2) {
+      if (val == null && retriedStorageKeys[area + id] !== 2) {
         retriedStorageKeys[area + id] = isRetry ? 2 : 1;
         if (!isRetry) {
           console.warn(`The "${area}" storage is missing "${id}"! Vacuuming...`);

+ 2 - 2
src/background/utils/icon.js

@@ -1,5 +1,5 @@
 import { i18n, makeDataUri, noop } from '@/common';
-import { ICON_PREFIX, INJECTABLE_TAB_URL_RE } from '@/common/consts';
+import { BLACKLIST, ICON_PREFIX, INJECTABLE_TAB_URL_RE } from '@/common/consts';
 import { objectPick } from '@/common/object';
 import { postInitialize } from './init';
 import { commands, forEachTab } from './message';
@@ -78,7 +78,7 @@ hookOptions((changes) => {
   || (v = changes[KEY_BADGE_COLOR_BLOCKED]) && (badgeColorBlocked = v)) {
     jobs.push(updateBadgeColor);
   }
-  if ('blacklist' in changes) {
+  if (BLACKLIST in changes) {
     jobs.push(updateState);
   }
   if (jobs.length) {

+ 0 - 3
src/background/utils/init.js

@@ -1,6 +1,3 @@
-export const extensionRoot = browser.runtime.getURL('/');
-export const extensionOrigin = extensionRoot.slice(0, -1);
-
 export const preInitialize = [];
 export const postInitialize = [];
 

+ 37 - 21
src/background/utils/options.js

@@ -5,6 +5,10 @@ import { preInitialize } from './init';
 import { commands } from './message';
 import storage from './storage';
 
+let changes;
+let initPending;
+let options = {};
+
 Object.assign(commands, {
   /** @return {Object} */
   GetAllOptions() {
@@ -14,9 +18,16 @@ Object.assign(commands, {
   GetOptions(data) {
     return data::mapEntry((_, key) => getOption(key));
   },
-  /** @return {void} */
-  SetOptions(data) {
-    ensureArray(data).forEach(item => setOption(item.key, item.value));
+  /**
+   * @param {{key:string, value?:PlainJSONValue, reply?:boolean}|Array} data
+   * @return {Promise<void>}
+   * @throws {?} hooks can throw after the option was set */
+  async SetOptions(data) {
+    if (initPending) await initPending;
+    for (const { key, value, reply } of ensureArray(data)) {
+      setOption(key, value, reply);
+    }
+    if (changes) callHooks(); // exceptions will be sent to the caller
   },
 });
 
@@ -35,10 +46,8 @@ const DELAY = 100;
 const hooks = initHooks();
 const callHooksLater = debounce(callHooks, DELAY);
 const writeOptionsLater = debounce(writeOptions, DELAY);
-let changes = {};
-let options = {};
-let initPending = storage.base.getOne(STORAGE_KEY)
-.then(data => {
+
+initPending = storage.base.getOne(STORAGE_KEY).then(data => {
   if (data && typeof data === 'object') options = data;
   if (process.env.DEBUG) console.info('options:', options);
   if (!options[VERSION]) {
@@ -55,18 +64,23 @@ let initPending = storage.base.getOne(STORAGE_KEY)
 });
 preInitialize.push(initPending);
 
-function fireChange(keys, value) {
-  // Flattening key path so the subscribers can update nested values without overwriting the parent
-  const key = keys.join('.');
-  // Ensuring the correct order when updates were mixed like this: foo.bar=1; foo={bar:2}; foo.bar=3
-  delete changes[key];
+/**
+ * @param {!string} key - must be "a.b.c" to allow clients easily set inside existing object trees
+ * @param {PlainJSONValue} [value]
+ * @param {boolean} [silent] - in case you callHooks() directly yourself afterwards
+ */
+function addChange(key, value, silent) {
+  if (!changes) changes = {};
+  else delete changes[key]; // Deleting first to place the new value at the end
   changes[key] = value;
-  callHooksLater();
+  if (!silent) callHooksLater();
 }
 
+/** @throws in option handlers */
 function callHooks() {
-  hooks.fire(changes);
-  changes = {};
+  const tmp = changes;
+  changes = null;
+  hooks.fire(tmp);
 }
 
 export function getOption(key, def) {
@@ -76,25 +90,27 @@ export function getOption(key, def) {
   return keys.length > 1 ? objectGet(value, keys.slice(1)) ?? def : value;
 }
 
-export async function setOption(key, value) {
-  if (initPending) await initPending;
+export function setOption(key, value, silent) {
+  // eslint-disable-next-line prefer-rest-params
+  if (initPending) return initPending.then(() => setOption(...arguments));
   const keys = normalizeKeys(key);
   const mainKey = keys[0];
+  key = keys.join('.'); // must be a string for addChange()
   if (!defaults::hasOwnProperty(mainKey)) {
-    if (process.env.DEBUG) console.info('Unknown option:', keys.join('.'), value, options);
+    if (process.env.DEBUG) console.info('Unknown option:', key, value, options);
     return;
   }
   const subKey = keys.length > 1 && keys.slice(1);
   const mainVal = getOption([mainKey]);
   if (deepEqual(value, subKey ? objectGet(mainVal, subKey) : mainVal)) {
-    if (process.env.DEBUG) console.info('Option unchanged:', keys.join('.'), value, options);
+    if (process.env.DEBUG) console.info('Option unchanged:', key, value, options);
     return;
   }
   options[mainKey] = subKey ? objectSet(mainVal, subKey, value) : value;
   omitDefaultValue(mainKey);
   writeOptionsLater();
-  fireChange(keys, value);
-  if (process.env.DEBUG) console.info('Options updated:', keys.join('.'), value, options);
+  addChange(key, value, silent);
+  if (process.env.DEBUG) console.info('Options updated:', key, value, options);
 }
 
 function writeOptions() {

+ 8 - 14
src/background/utils/preinject.js

@@ -1,4 +1,4 @@
-import { getScriptName, getUniqId, sendTabCmd, trueJoin } from '@/common';
+import { getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd, trueJoin } from '@/common';
 import {
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
   METABLOCK_RE,
@@ -7,7 +7,7 @@ import initCache from '@/common/cache';
 import { forEachEntry, objectPick, objectSet } from '@/common/object';
 import ua from '@/common/ua';
 import { getScriptsByURL, ENV_CACHE_KEYS, ENV_REQ_KEYS, ENV_SCRIPTS, ENV_VALUE_IDS } from './db';
-import { extensionRoot, postInitialize } from './init';
+import { postInitialize } from './init';
 import { commands } from './message';
 import { getOption, hookOptions } from './options';
 import { popupTabs } from './popup-tracker';
@@ -44,7 +44,6 @@ const KEY_EXPOSE = 'expose';
 const KEY_DEF_INJECT_INTO = 'defaultInjectInto';
 const KEY_IS_APPLIED = 'isApplied';
 const KEY_XHR_INJECT = 'xhrInject';
-const BAD_URL_CHAR = /[#/?]/g; // will be encoded to avoid splitting the URL in devtools UI
 const GRANT_NONE_VARS = '{GM,GM_info,unsafeWindow,cloneInto,createObjectIn,exportFunction}';
 const expose = {};
 let isApplied;
@@ -271,8 +270,9 @@ function prepare(key, url, tabId, frameId, forceContent) {
  * @return {Promise<any>}
  */
 async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
-  const bag = getScriptsByURL(url, !frameId);
-  const { envDelayed, [ENV_SCRIPTS]: scripts } = Object.assign(bag, await bag.promise);
+  const errors = [];
+  const bag = await getScriptsByURL(url, !frameId, errors);
+  const { envDelayed, disabledIds: ids, [ENV_SCRIPTS]: scripts } = bag;
   const isLate = forceContent != null;
   bag[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
   const feedback = scripts.map(prepareScript, bag).filter(Boolean);
@@ -293,10 +293,11 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
       envKey, // InjectionFeedback cache key for envDelayed
     },
     hasMore: !!more, // tells content bridge to expect envDelayed
-    ids: bag.disabledIds, // content bridge adds the actually running ids and sends via SetPopup
+    ids, // content bridge adds the actually running ids and sends via SetPopup
     info: {
       ua,
     },
+    errors: errors.filter(err => !ids.includes(+err.slice(err.lastIndexOf('#') + 1))).join('\n'),
   });
   res[FEEDBACK] = feedback;
   res[CSAPI_REG] = contentScriptsAPI && !isLate && !xhrInject
@@ -316,7 +317,6 @@ function prepareScript(script) {
   const code = this.code[id];
   const dataKey = getUniqId('VMin');
   const displayName = getScriptName(script);
-  const name = encodeURIComponent(displayName.replace(BAD_URL_CHAR, replaceWithFullWidthForm));
   const isContent = isContentRealm(script, forceContent);
   const pathMap = custom.pathMap || {};
   const reqs = meta.require.map(key => require[pathMap[key] || key]).filter(Boolean);
@@ -346,8 +346,7 @@ function prepareScript(script) {
     wrap && `})()${IS_FIREFOX ? `}catch(e){${dataKey}(e)}` : ''}}`,
     // 0 at the end to suppress errors about non-cloneable result of executeScript in FF
     IS_FIREFOX && ';0',
-    // Firefox lists .user.js among our own content scripts so a space at start will group them
-    `\n//# sourceURL=${extensionRoot}${IS_FIREFOX ? '%20' : ''}${name}.user.js#${id}`,
+    `\n//# sourceURL=${getScriptPrettyUrl(script, displayName)}`,
   ]::trueJoin('');
   cache.put(dataKey, injectedCode, TIME_KEEP_DATA);
   /** @type {VMInjection.Script} */
@@ -366,11 +365,6 @@ function prepareScript(script) {
   ];
 }
 
-function replaceWithFullWidthForm(s) {
-  // fullwidth range starts at 0xFF00, normal range starts at space char code 0x20
-  return String.fromCharCode(s.charCodeAt(0) - 0x20 + 0xFF00);
-}
-
 const resolveDataCodeStr = `(${function _(data) {
   /* `function` is required to compile `this`, and `this` is required because our safe-globals
    * shadows `window` so its name is minified and hence inaccessible here */

+ 1 - 1
src/background/utils/requests-core.js

@@ -1,7 +1,7 @@
 import { buffer2string, getUniqId, isEmpty, noop } from '@/common';
+import { extensionOrigin } from '@/common/consts';
 import { forEachEntry } from '@/common/object';
 import ua from '@/common/ua';
-import { extensionOrigin } from './init';
 
 let encoder;
 

+ 0 - 1
src/background/utils/script.js

@@ -16,7 +16,6 @@ Object.assign(commands, {
   NewScript(id) {
     return id && cache.get(`new-${id}`) || newScript();
   },
-  ParseMeta: parseMeta,
 });
 
 export function isUserScript(text) {

+ 1 - 1
src/background/utils/tab-redirector.js

@@ -1,7 +1,7 @@
 import { request, noop, i18n, getUniqId } from '@/common';
+import { extensionRoot } from '@/common/consts';
 import ua from '@/common/ua';
 import cache from './cache';
-import { extensionRoot } from './init';
 import { commands } from './message';
 import { parseMeta, isUserScript } from './script';
 

+ 1 - 1
src/background/utils/tabs.js

@@ -1,6 +1,6 @@
 import { getActiveTab, noop, sendTabCmd, getFullUrl } from '@/common';
+import { extensionRoot } from '@/common/consts';
 import ua from '@/common/ua';
-import { extensionRoot } from './init';
 import { commands } from './message';
 import { getOption } from './options';
 

+ 183 - 135
src/background/utils/tester.js

@@ -1,84 +1,121 @@
+import { getScriptPrettyUrl } from '@/common';
+import { BLACKLIST, BLACKLIST_ERRORS } from '@/common/consts';
+import initCache from '@/common/cache';
 import * as tld from '@/common/tld';
-import cache from './cache';
 import { postInitialize } from './init';
 import { commands } from './message';
 import { getOption, hookOptions } from './options';
+import storage from './storage';
 
 Object.assign(commands, {
   TestBlacklist: testBlacklist,
 });
 
-postInitialize.push(resetBlacklist);
-
-tld.initTLD(true);
-
+const matchAlways = () => 1;
+/**
+ * Using separate caches to avoid memory consumption for thousands of prefixed long urls
+ * TODO: switch `cache` to hubs internally and add a prefix parameter or accept an Array for key
+ */
+const cacheMat = initCache({ lifetime: 60 * 60e3 });
+const cacheInc = initCache({ lifetime: 60 * 60e3 });
+const cacheResultMat = initCache({ lifetime: 60e3 });
+const cacheResultInc = initCache({ lifetime: 60e3 });
 const RE_MATCH_PARTS = /(.*?):\/\/([^/]*)\/(.*)/;
-let blacklistRules = [];
-hookOptions((changes) => {
-  if ('blacklist' in changes) resetBlacklist(changes.blacklist || '');
-});
 const RE_HTTP_OR_HTTPS = /^https?$/i;
-
-/*
- Simple FIFO queue for the results of testBlacklist, cached separately from the main |cache|
- because the blacklist is updated only once in a while so its entries would be crowding
- the main cache and reducing its performance (objects with lots of keys are slow to access).
-
- We also don't need to auto-expire the entries after a timeout.
- The only limit we're concerned with is the overall memory used.
- The limit is specified in the amount of unicode characters (string length) for simplicity.
- Disregarding deduplication due to interning, the actual memory used is approximately twice as big:
- 2 * keyLength + objectStructureOverhead * objectCount
-*/
 const MAX_BL_CACHE_LENGTH = 100e3;
 let blCache = {};
 let blCacheSize = 0;
+let blacklistRules = [];
+let batchErrors;
 
-function testRules(url, rules, prefix, ruleBuilder) {
-  return rules.some(rule => {
-    const key = `${prefix}:${rule}`;
-    const matcher = cache.get(key) || cache.put(key, ruleBuilder(rule));
-    return matcher.test(url);
-  });
-}
+postInitialize.push(resetBlacklist);
+hookOptions((changes) => {
+  if (BLACKLIST in changes) {
+    const errors = resetBlacklist(changes[BLACKLIST] || []);
+    const res = errors.length ? errors : null;
+    storage.base.setOne(BLACKLIST_ERRORS, res);
+    if (res) throw res; // will be passed to the UI
+  }
+});
+tld.initTLD(true);
 
-/**
- * Test glob rules like `@include` and `@exclude`.
- */
-export function testGlob(url, rules) {
-  return testRules(url, rules, 're', autoReg);
+export function testerBatch(arr) {
+  cacheMat.batch(arr);
+  cacheInc.batch(arr);
+  cacheResultMat.batch(arr);
+  cacheResultInc.batch(arr);
+  batchErrors = Array.isArray(arr) && arr;
 }
 
 /**
- * Test match rules like `@match` and `@exclude_match`.
+ * As this code is *very* hot, we avoid calling functions or creating possibly big arrays
+ * or creating copies of thousands of keys by prefixing them in `cache`, thus we avoid pauses
+ * due to major GC. The speedup is ~3x (from ~40ms to ~14ms) on a 4GHz CPU
+ * with popular scripts that have lots of @match e.g. Handy Image.
  */
-export function testMatch(url, rules) {
-  return testRules(url, rules, 'match', matchTester);
-}
-
 export function testScript(url, script) {
-  cache.batch(true);
+  let matex1; // main @match / @exclude-match
+  let matex2; // custom @match / @exclude-match
+  let inex1; // main @include / @exclude
+  let inex2; // custom @include / @exclude
   const { custom, meta } = script;
-  const mat = mergeLists(custom.origMatch && meta.match, custom.match);
-  const inc = mergeLists(custom.origInclude && meta.include, custom.include);
-  const exc = mergeLists(custom.origExclude && meta.exclude, custom.exclude);
-  const excMat = mergeLists(custom.origExcludeMatch && meta.excludeMatch, custom.excludeMatch);
-  // match all if no @match or @include rule
-  let ok = !mat.length && !inc.length;
-  // @match
-  ok = ok || testMatch(url, mat);
-  // @include
-  ok = ok || testGlob(url, inc);
-  // @exclude-match
-  ok = ok && !testMatch(url, excMat);
-  // @exclude
-  ok = ok && !testGlob(url, exc);
-  cache.batch(false);
+  const len = (matex1 = custom.origMatch && meta.match || '').length
+    + (matex2 = custom.match || '').length
+    + (inex1 = custom.origInclude && meta.include || '').length
+    + (inex2 = custom.include || '').length;
+  const ok = (
+    // Ok if lists are empty or @match + @include apply
+    !len || testRules(url, script, matex1, matex2, inex1, inex2)
+  ) && !(
+    // and no excludes apply
+    ((matex1 = custom.origExcludeMatch && meta.excludeMatch || '').length
+      + (matex2 = custom.excludeMatch || '').length
+      + (inex1 = custom.origExclude && meta.exclude || '').length
+      + (inex2 = custom.exclude || '').length
+    ) && testRules(url, script, matex1, matex2, inex1, inex2)
+  );
   return ok;
 }
 
-function mergeLists(...args) {
-  return args.reduce((res, item) => (item ? res.concat(item) : res), []);
+function testRules(url, script, ...list) {
+  // TODO: combine all non-regex rules in one big smart regexp
+  // e.g. lots of `*://foo/*` can be combined into `^https?://(foo|bar|baz)/`
+  for (let i = 0, m, rules, builder, cache, urlResults, res; i < 4; i += 1) {
+    // [matches, matches, includes, includes], some items may be empty
+    if ((rules = list[i]).length) {
+      if (!cache) { // happens one time for 0 or 1 and another time for 2 or 3
+        if (i < 2) { // matches1, matches2
+          builder = matchTester;
+          cache = cacheMat;
+          urlResults = cacheResultMat;
+        } else { // includes1, includes2
+          builder = autoReg;
+          cache = cacheInc;
+          urlResults = cacheResultInc;
+        }
+        urlResults = urlResults.get(url) || urlResults.put(url, {});
+      }
+      for (const rule of rules) {
+        if ((res = urlResults[rule]) != null) {
+          return res;
+        }
+        if (!(m = cache.get(rule))) {
+          try {
+            m = builder(rule);
+            cache.put(rule, m);
+          } catch (err) {
+            if (batchErrors) {
+              batchErrors.push(url ? `${err} - ${getScriptPrettyUrl(script)}` : `${err}`);
+            }
+          }
+        }
+        if (m && (urlResults[rule] = m.test(url))) {
+          return true;
+        }
+      }
+    }
+    if (i === 1) cache = false; // this will switch cache+builder for includes if they're non-empty
+  }
 }
 
 function str2RE(str) {
@@ -86,34 +123,25 @@ function str2RE(str) {
   return re;
 }
 
-function bindRE(re) {
-  return re.test.bind(re);
-}
-
 function autoReg(str) {
   // regexp mode: case-insensitive per GM documentation
   if (str.length > 1 && str[0] === '/' && str[str.length - 1] === '/') {
-    let re;
-    try { re = new RegExp(str.slice(1, -1), 'i'); } catch (e) { /* ignore */ }
-    return { test: re ? bindRE(re) : () => false };
+    return new RegExp(str.slice(1, -1), 'i');
   }
   // glob mode: case-insensitive to match GM4 & Tampermonkey bugged behavior
   const reStr = str2RE(str.toLowerCase());
-  if (tld.isReady() && str.includes('.tld/')) {
-    const reTldStr = reStr.replace('\\.tld/', '((?:\\.[-\\w]+)+)/');
-    return {
-      test: (tstr) => {
-        const matches = tstr.toLowerCase().match(reTldStr);
-        if (matches) {
-          const suffix = matches[1].slice(1);
-          if (tld.getPublicSuffix(suffix) === suffix) return true;
-        }
-        return false;
-      },
-    };
+  const reTldStr = reStr.replace('\\.tld/', '((?:\\.[-\\w]+)+)/');
+  if (reStr !== reTldStr) {
+    return { test: matchTld.bind([reTldStr]) };
   }
-  const re = new RegExp(`^${reStr}$`, 'i'); // String with wildcards
-  return { test: bindRE(re) };
+  // String with wildcards
+  return RegExp(`^${reStr}$`, 'i');
+}
+
+function matchTld(tstr) {
+  const matches = tstr.toLowerCase().match(this[0]);
+  const suffix = matches?.[1].slice(1);
+  return suffix && tld.getPublicSuffix(suffix) === suffix;
 }
 
 function matchScheme(rule, data) {
@@ -121,10 +149,7 @@ function matchScheme(rule, data) {
   if (rule === data) return 1;
   // * = http | https
   // support http*
-  if ([
-    '*',
-    'http*',
-  ].includes(rule) && RE_HTTP_OR_HTTPS.test(data)) return 1;
+  if ((rule === '*' || rule === 'http*') && RE_HTTP_OR_HTTPS.test(data)) return 1;
   return 0;
 }
 
@@ -133,7 +158,7 @@ const RE_STR_TLD = '((?:\\.[-\\w]+)+)';
 function hostMatcher(rule) {
   // * matches all
   if (rule === '*') {
-    return () => 1;
+    return matchAlways;
   }
   // *.example.com
   // www.google.*
@@ -151,20 +176,22 @@ function hostMatcher(rule) {
     suffix = RE_STR_TLD;
   }
   const re = new RegExp(`^${prefix}${str2RE(base)}${suffix}$`);
-  return (data) => {
-    // exact match, case-insensitive
-    data = data.toLowerCase();
-    if (ruleLC === data) return 1;
-    // full check
-    const matches = data.match(re);
-    if (matches) {
-      const [, tldStr] = matches;
-      if (!tldStr) return 1;
-      const tldSuffix = tldStr.slice(1);
-      return tld.getPublicSuffix(tldSuffix) === tldSuffix;
-    }
-    return 0;
-  };
+  return hostMatcherFunc.bind([ruleLC, re]);
+}
+
+function hostMatcherFunc(data) {
+  // exact match, case-insensitive
+  data = data.toLowerCase();
+  if (this[0] === data) return 1;
+  // full check
+  const matches = data.match(this[1]);
+  if (matches) {
+    const [, tldStr] = matches;
+    if (!tldStr) return 1;
+    const tldSuffix = tldStr.slice(1);
+    return tld.getPublicSuffix(tldSuffix) === tldSuffix;
+  }
+  return 0;
 }
 
 function pathMatcher(rule) {
@@ -176,78 +203,99 @@ function pathMatcher(rule) {
     if (iQuery < 0) strRe = `^${strRe}(?:[?#]|$)`;
     else strRe = `^${strRe}(?:#|$)`;
   }
-  return bindRE(new RegExp(strRe));
+  return RegExp(strRe);
 }
 
 function matchTester(rule) {
   let test;
   if (rule === '<all_urls>') {
-    test = () => true;
+    test = matchAlways;
   } else {
     const ruleParts = rule.match(RE_MATCH_PARTS);
     if (ruleParts) {
-      const matchHost = hostMatcher(ruleParts[2]);
-      const matchPath = pathMatcher(ruleParts[3]);
-      test = (url) => {
-        const parts = url.match(RE_MATCH_PARTS);
-        return !!ruleParts && !!parts
-          && matchScheme(ruleParts[1], parts[1])
-          && matchHost(parts[2])
-          && matchPath(parts[3]);
-      };
+      test = matchTesterFunc.bind([
+        ruleParts[1],
+        hostMatcher(ruleParts[2]),
+        pathMatcher(ruleParts[3]),
+      ]);
     } else {
-      // Ignore invalid match rules
-      test = () => false;
+      throw `Invalid @match ${rule}`;
     }
   }
   return { test };
 }
 
+function matchTesterFunc(url) {
+  const parts = url.match(RE_MATCH_PARTS);
+  return +!!(parts
+    && matchScheme(this[0], parts[1])
+    && this[1](parts[2])
+    && this[2].test(parts[3])
+  );
+}
+
 export function testBlacklist(url) {
   let res = blCache[url];
   if (res === undefined) {
-    const rule = blacklistRules.find(({ test }) => test(url));
-    res = rule?.reject && rule.text || false;
-    updateBlacklistCache(url, res);
+    const rule = blacklistRules.find(m => m.test(url));
+    res = rule?.reject && rule.text;
+    updateBlacklistCache(url, res || false);
   }
   return res;
 }
 
-export function resetBlacklist(list) {
-  cache.batch(true);
-  const rules = list == null ? getOption('blacklist') : list;
+export function resetBlacklist(rules = getOption(BLACKLIST)) {
+  const emplace = (cache, rule, builder) => cache.get(rule) || cache.put(rule, builder(rule));
+  const errors = [];
+  testerBatch(true);
   if (process.env.DEBUG) {
     console.info('Reset blacklist:', rules);
   }
   // XXX compatible with {Array} list in v2.6.1-
   blacklistRules = (Array.isArray(rules) ? rules : (rules || '').split('\n'))
-  .map((text) => {
-    text = text.trim();
-    if (!text || text.startsWith('#')) return null;
-    const mode = text.startsWith('@') && text.split(/\s/, 1)[0];
-    const rule = mode ? text.slice(mode.length + 1).trim() : text;
-    const reject = mode !== '@include' && mode !== '@match'; // @include and @match = whitelist
-    const { test } = mode === '@include' || mode === '@exclude' && autoReg(rule)
-      || !mode && !rule.includes('/') && matchTester(`*://${rule}/*`) // domain
-      || matchTester(rule); // @match and @exclude-match
-    return { reject, test, text };
-  })
-  .filter(Boolean);
+  .reduce((res, text) => {
+    try {
+      text = text.trim();
+      if (!text || text.startsWith('#')) return res;
+      const mode = text.startsWith('@') && text.split(/\s/, 1)[0];
+      const rule = mode ? text.slice(mode.length + 1).trim() : text;
+      const isInc = mode === '@include';
+      const m = (isInc || mode === '@exclude') && emplace(cacheInc, rule, autoReg)
+      || !mode && !rule.includes('/') && emplace(cacheMat, `*://${rule}/*`, matchTester) // domain
+      || emplace(cacheMat, rule, matchTester); // @match and @exclude-match
+      m.reject = !(mode === '@match' || isInc); // @include and @match = whitelist
+      m.text = text;
+      res.push(m);
+    } catch (err) {
+      errors.push(err);
+    }
+    return res;
+  }, []);
   blCache = {};
   blCacheSize = 0;
-  cache.batch(false);
+  testerBatch();
+  return errors;
 }
 
+/**
+ Simple FIFO queue for the results of testBlacklist, cached separately from the main |cache|
+ because the blacklist is updated only once in a while so its entries would be crowding
+ the main cache and reducing its performance (objects with lots of keys are slow to access).
+ We also don't need to auto-expire the entries after a timeout.
+ The only limit we're concerned with is the overall memory used.
+ The limit is specified in the amount of unicode characters (string length) for simplicity.
+ Disregarding deduplication due to interning, the actual memory used is approximately twice as big:
+ 2 * keyLength + objectStructureOverhead * objectCount
+*/
 function updateBlacklistCache(key, value) {
   blCache[key] = value;
   blCacheSize += key.length;
   if (blCacheSize > MAX_BL_CACHE_LENGTH) {
-    Object.keys(blCache)
-    .some((k) => {
-      blCacheSize -= k.length;
-      delete blCache[k];
-      // reduce the cache to 75% so that this function doesn't run too often.
-      return blCacheSize < MAX_BL_CACHE_LENGTH * 3 / 4;
-    });
+    for (const k in blCache) {
+      if (delete blCache[k] && (blCacheSize -= k.length) < MAX_BL_CACHE_LENGTH * 0.75) {
+        // Reduced the cache to 75% so that this function doesn't run too often
+        return;
+      }
+    }
   }
 }

+ 4 - 5
src/common/cache.js

@@ -15,6 +15,7 @@ export default function initCache({
   let batchStartTime;
   // eslint-disable-next-line no-return-assign
   const getNow = () => batchStarted && batchStartTime || (batchStartTime = performance.now());
+  const OVERRUN = 1000; // in ms, to reduce frequency of calling setTimeout
   const exports = {
     batch, get, getValues, pop, put, del, has, hit, destroy,
   };
@@ -80,12 +81,10 @@ export default function initCache({
       clearTimeout(timer);
     }
     minLifetime = lifetime;
-    timer = setTimeout(trim, lifetime);
+    timer = setTimeout(trim, lifetime + OVERRUN);
   }
   function trim() {
-    // next timer won't be able to run earlier than 10ms
-    // so we'll sweep the upcoming expired entries in this run
-    const now = performance.now() + 10;
+    const now = performance.now();
     let closestExpiry = Number.MAX_SAFE_INTEGER;
     // eslint-disable-next-line guard-for-in
     for (const key in cache) {
@@ -98,7 +97,7 @@ export default function initCache({
     }
     minLifetime = closestExpiry - now;
     timer = closestExpiry < Number.MAX_SAFE_INTEGER
-      ? setTimeout(trim, minLifetime)
+      ? setTimeout(trim, minLifetime + OVERRUN)
       : 0;
   }
 }

+ 5 - 2
src/common/consts.js

@@ -32,5 +32,8 @@ export const TIMEOUT_HOUR = 60 * 60 * 1000;
 export const TIMEOUT_24HOURS = 24 * 60 * 60 * 1000;
 export const TIMEOUT_WEEK = 7 * 24 * 60 * 60 * 1000;
 
-export const ICON_PREFIX = !process.env.IS_INJECTED
-  && browser.runtime.getURL('/public/images/icon');
+export const extensionRoot = !process.env.IS_INJECTED && browser.runtime.getURL('/') || '';
+export const extensionOrigin = extensionRoot.slice(0, -1);
+export const ICON_PREFIX = `${extensionRoot}public/images/icon`;
+export const BLACKLIST = 'blacklist';
+export const BLACKLIST_ERRORS = `${BLACKLIST}Errors`;

+ 20 - 1
src/common/index.js

@@ -1,6 +1,6 @@
 // SAFETY WARNING! Exports used by `injected` must make ::safe() calls and use __proto__:null
 
-import { browser, ICON_PREFIX } from '@/common/consts';
+import { browser, extensionRoot, ICON_PREFIX } from '@/common/consts';
 import { deepCopy } from './object';
 import { blob2base64, i18n, isDataUri, noop } from './util';
 
@@ -18,6 +18,10 @@ if (process.env.DEV && process.env.IS_INJECTED !== 'injected-web') {
 }
 
 export const defaultImage = `${ICON_PREFIX}128.png`;
+/** Will be encoded to avoid splitting the URL in devtools UI */
+const BAD_URL_CHAR = /[#/?]/g;
+/** Fullwidth range starts at 0xFF00, normal range starts at space char code 0x20 */
+const replaceWithFullWidthForm = s => String.fromCharCode(s.charCodeAt(0) - 0x20 + 0xFF00);
 
 export function initHooks() {
   const hooks = [];
@@ -158,6 +162,21 @@ export function getScriptName(script) {
     || `#${script.props.id ?? i18n('labelNoName')}`;
 }
 
+/** URL that shows the name of the script and opens in devtools sources or in our editor */
+export function getScriptPrettyUrl(script, displayName) {
+  return `${
+    extensionRoot
+  }${
+    // When called from prepareScript, adding a space to group scripts in one block visually
+    displayName && IS_FIREFOX ? '%20' : ''
+  }${
+    encodeURIComponent((displayName || getScriptName(script))
+    .replace(BAD_URL_CHAR, replaceWithFullWidthForm))
+  }.user.js#${
+    script.props.id
+  }`;
+}
+
 /**
  * @param {VMScript} script
  * @param {boolean} [all] - to return all two urls (1: check, 2: download)

+ 21 - 27
src/common/options.js

@@ -3,38 +3,32 @@ import { initHooks, sendCmdDirectly } from '.';
 import { forEachEntry, objectGet, objectSet } from './object';
 
 let options = {};
-const hooks = initHooks();
+const { hook, fire } = initHooks();
 const ready = sendCmdDirectly('GetAllOptions', null, { retry: true })
 .then((data) => {
   options = data;
-  if (data) hooks.fire(data);
+  if (data) fire(data);
 });
 
-function getOption(key) {
-  return objectGet(options, key) ?? objectGet(defaults, key);
-}
-
-function setOption(key, value) {
-  // the updated options object will be propagated from the background script after a pause
-  // so meanwhile the local code should be able to see the new value using options.get()
-  objectSet(options, key, value);
-  sendCmdDirectly('SetOptions', { key, value });
-}
-
-function updateOptions(data) {
-  // Keys in `data` may be { flattened.like.this: 'foo' }
-  const expandedData = {};
-  data::forEachEntry(([key, value]) => {
-    objectSet(options, key, value);
-    objectSet(expandedData, key, value);
-  });
-  hooks.fire(expandedData);
-}
-
 export default {
   ready,
-  get: getOption,
-  set: setOption,
-  update: updateOptions,
-  hook: hooks.hook,
+  hook,
+  get(key) {
+    return objectGet(options, key) ?? objectGet(defaults, key);
+  },
+  set(key, value) {
+    // the updated options object will be propagated from the background script after a pause
+    // so meanwhile the local code should be able to see the new value using options.get()
+    objectSet(options, key, value);
+    return sendCmdDirectly('SetOptions', { key, value, reply: true });
+  },
+  update(data) {
+    // Keys in `data` may be { flattened.like.this: 'foo' }
+    const expandedData = {};
+    data::forEachEntry(([key, value]) => {
+      objectSet(options, key, value);
+      objectSet(expandedData, key, value);
+    });
+    fire(expandedData);
+  },
 };

+ 5 - 2
src/common/ui/setting-text.vue

@@ -106,7 +106,7 @@ export default {
       if (!this.hasSave && this.canSave) this.onSave();
     },
     onSave() {
-      options.set(this.name, this.parsedData.value);
+      options.set(this.name, this.parsedData.value).catch(this.bgError);
       this.$emit('save');
     },
     onReset() {
@@ -116,7 +116,7 @@ export default {
       el.focus();
       if (!this.hasSave) {
         // No save button = something rather trivial e.g. the export file name
-        options.set(this.name, this.defaultValue);
+        options.set(this.name, this.defaultValue).catch(this.bgError);
       } else {
         // Save button exists = let the user undo the input
         el.select();
@@ -125,6 +125,9 @@ export default {
         }
       }
     },
+    bgError(err) {
+      this.$emit('bg-error', err);
+    },
   },
 };
 </script>

+ 6 - 3
src/confirm/views/app.vue

@@ -35,7 +35,7 @@
             </div>
             <dl v-for="(list, name) in lists" :key="name"
                 :data-type="name" :hidden="!list.length" tabindex="0">
-              <dt v-text="`@${name}`"/>
+              <dt v-text="name ? `@${name}` : i18n('genericError')"/>
               <dd v-text="list" class="ellipsis"/>
             </dl>
           </div>
@@ -238,8 +238,7 @@ export default {
       }
     },
     async parseMeta() {
-      /** @type {VMScript.meta} */
-      const meta = await sendCmdDirectly('ParseMeta', this.code);
+      const { meta, errors } = await sendCmdDirectly('ParseMeta', this.code);
       const name = getLocaleString(meta, 'name');
       document.title = `${name.slice(0, MAX_TITLE_NAME_LEN)}${name.length > MAX_TITLE_NAME_LEN ? '...' : ''} - ${
         basicTitle || (basicTitle = document.title)
@@ -263,6 +262,7 @@ export default {
         .join('\n')
         || ''
       ));
+      this.lists[''] = errors.join('\n');
       this.script = { meta, custom: {}, props: {} };
       this.allDeps = [
         [...new Set(meta.require)],
@@ -470,6 +470,9 @@ $infoIconSize: 18px;
         padding: 2px 6px;
         max-width: 25em;
       }
+      &[data-type=""] {
+        color: red;
+      }
     }
     dt {
       font-weight: bold;

+ 12 - 6
src/injected/content/bridge.js

@@ -90,15 +90,21 @@ const bridge = {
       // TODO: maybe remove this check if our VAULT is reliable
       log('info', null, `Invalid command: "${cmd}" from "${dataKeyNameMap[dataKey] || '?'}"`);
     }
-    const callbackId = data && getOwnProp(data, CALLBACK_ID);
+    let callbackId = data && getOwnProp(data, CALLBACK_ID);
     if (callbackId) {
       data = data.data;
     }
-    let res = handle === true
-      ? sendCmd(cmd, data)
-      : node::handle(data, realm || INJECT_PAGE);
-    if (isPromise(res)) {
-      res = await res;
+    let res;
+    try {
+      res = handle === true
+        ? sendCmd(cmd, data)
+        : node::handle(data, realm || INJECT_PAGE);
+      if (isPromise(res)) {
+        res = await res;
+      }
+    } catch (e) {
+      callbackId = 'Error';
+      res = e;
     }
     if (callbackId) {
       bridge.post('Callback', { id: callbackId, data: res }, realm);

+ 6 - 4
src/injected/content/inject.js

@@ -57,7 +57,6 @@ bridge.addHandlers({
 
 export function injectPageSandbox(contentId, webId) {
   pageInjectable = false;
-  const { cloneInto } = global;
   const vaultId = safeGetUniqId();
   const handshakeId = safeGetUniqId();
   if (useOpener(window.opener) || useOpener(!IS_TOP && window.parent)) {
@@ -113,8 +112,8 @@ export function injectPageSandbox(contentId, webId) {
   function handshaker(evt) {
     pageInjectable = true;
     evt::stopImmediatePropagation();
-    bindEvents(contentId, webId, bridge, cloneInto);
-    fireBridgeEvent(handshakeId + process.env.HANDSHAKE_ACK, [webId, contentId], cloneInto);
+    bindEvents(contentId, webId, bridge);
+    fireBridgeEvent(handshakeId + process.env.HANDSHAKE_ACK, [webId, contentId]);
   }
 }
 
@@ -125,7 +124,10 @@ export function injectPageSandbox(contentId, webId) {
  * @param {boolean} isXml
  */
 export async function injectScripts(contentId, webId, data, isXml) {
-  const { hasMore, info } = data;
+  const { errors, hasMore, info } = data;
+  if (errors) {
+    logging.warn(errors);
+  }
   realms = {
     __proto__: null,
     [INJECT_CONTENT]: {

+ 5 - 3
src/injected/content/requests.js

@@ -33,11 +33,13 @@ bridge.addHandlers({
       'fileName',
     ]);
     msg.url = getFullUrl(msg.url);
-    if (msg.data[1]) {
+    let { data } = msg;
+    if (data[1]) {
       // TODO: support huge data by splitting it to multiple messages
-      msg.data = await encodeBody(msg.data[0], msg.data[1]);
+      data = await encodeBody(data[0], data[1]);
+      msg.data = cloneInto ? cloneInto(data, msg) : data;
     }
-    sendCmd('HttpRequest', msg);
+    return sendCmd('HttpRequest', msg);
   },
   AbortRequest: true,
 });

+ 1 - 0
src/injected/content/safe-globals-content.js

@@ -17,6 +17,7 @@ export const {
   Uint8Array: SafeUint8Array,
   atob: safeAtob,
   addEventListener: on,
+  cloneInto,
   dispatchEvent: fire,
   removeEventListener: off,
 } = global;

+ 3 - 3
src/injected/util/index.js

@@ -11,13 +11,13 @@ export {
 } from '@/common';
 export * from '@/common/consts';
 
-export const fireBridgeEvent = (eventId, msg, cloneInto) => {
+export const fireBridgeEvent = (eventId, msg) => {
   const detail = cloneInto ? cloneInto(msg, document) : msg;
   const evtMain = new SafeCustomEvent(eventId, { __proto__: null, detail });
   window::fire(evtMain);
 };
 
-export const bindEvents = (srcId, destId, bridge, cloneInto) => {
+export const bindEvents = (srcId, destId, bridge) => {
   /* Using a separate event for `node` because CustomEvent can't transfer nodes,
    * whereas MouseEvent (and some others) can't transfer objects without stringification. */
   let incomingNodeEvent;
@@ -43,7 +43,7 @@ export const bindEvents = (srcId, destId, bridge, cloneInto) => {
   bridge.post = (cmd, data, { dataKey } = bridge, node) => {
     // Constructing the event now so we don't send anything if it throws on invalid `node`
     const evtNode = node && new SafeMouseEvent(destId, { __proto__: null, relatedTarget: node });
-    fireBridgeEvent(destId, { cmd, data, dataKey, node: !!evtNode }, cloneInto);
+    fireBridgeEvent(destId, { cmd, data, dataKey, node: !!evtNode });
     if (evtNode) window::fire(evtNode);
   };
 };

+ 6 - 1
src/injected/web/bridge.js

@@ -1,5 +1,10 @@
 const handlers = createNullObj();
-const callbacks = createNullObj();
+const callbacks = {
+  __proto__: null,
+  Error(err) {
+    throw err;
+  },
+};
 /**
  * @property {VMScriptGMInfoPlatform} ua
  */

+ 1 - 1
src/injected/web/safe-globals-web.js

@@ -12,7 +12,7 @@ export const {
    * TODO: try reimplementing Promise in our sandbox wrapper if it can work with user code */
   Promise: UnsafePromise,
 } = global;
-
+export const cloneInto = process.env.HANDSHAKE_ID ? null : global.cloneInto;
 export let
   // window
   SafeCustomEvent,

+ 2 - 6
src/injected/web/util.js

@@ -1,6 +1,3 @@
-import { INJECT_CONTENT } from '../util';
-import bridge from './bridge';
-
 const isConcatSpreadableSym = SafeSymbol.isConcatSpreadable;
 
 export const safeConcat = (...arrays) => {
@@ -98,10 +95,9 @@ export const FastLookup = (hubs = createNullObj()) => {
  * for compatibility with many [old] scripts that use these utils blindly
  */
 export const makeComponentUtils = () => {
-  const CLONE_INTO = 'cloneInto';
   const CREATE_OBJECT_IN = 'createObjectIn';
   const EXPORT_FUNCTION = 'exportFunction';
-  const src = IS_FIREFOX && bridge.mode === INJECT_CONTENT && global;
+  const src = IS_FIREFOX && !process.env.HANDSHAKE_ID && global;
   const defineIn = !src && ((target, as, val) => {
     if (as && (as = getOwnProp(as, 'defineAs'))) {
       setOwnProp(target, as, val);
@@ -109,7 +105,7 @@ export const makeComponentUtils = () => {
     return val;
   });
   return {
-    [CLONE_INTO]: src && src[CLONE_INTO] || (
+    cloneInto: cloneInto || (
       obj => obj
     ),
     [CREATE_OBJECT_IN]: src && src[CREATE_OBJECT_IN] || (

+ 16 - 3
src/options/views/tab-settings/vm-blacklist.vue

@@ -5,12 +5,18 @@
       {{i18n('descBlacklist')}}
       <a href="https://violentmonkey.github.io/posts/smart-rules-for-blacklist/#blacklist-patterns" target="_blank" rel="noopener noreferrer" v-text="i18n('learnBlacklist')"></a>
     </p>
-    <setting-text name="blacklist" @save="onSave"/>
+    <div class="flex flex-wrap">
+      <setting-text name="blacklist" class="flex-1" @save="onSave" @bgError="errors = $event"/>
+      <ol v-if="errors" class="text-red">
+        <li v-for="e in errors" :key="e" v-text="e"/>
+      </ol>
+    </div>
   </section>
 </template>
 
 <script>
-import { sendCmd } from '@/common';
+import { sendCmdDirectly } from '@/common';
+import { BLACKLIST_ERRORS } from '@/common/consts';
 import { showMessage } from '@/common/ui';
 import SettingText from '@/common/ui/setting-text';
 
@@ -18,11 +24,18 @@ export default {
   components: {
     SettingText,
   },
+  data() {
+    return {
+      errors: null,
+    };
+  },
   methods: {
     onSave() {
       showMessage({ text: this.i18n('msgSavedBlacklist') });
-      sendCmd('BlacklistReset');
     },
   },
+  async mounted() {
+    this.errors = await sendCmdDirectly('Storage', ['base', 'getOne', BLACKLIST_ERRORS]);
+  },
 };
 </script>

+ 9 - 2
src/types.d.ts

@@ -173,14 +173,20 @@ declare namespace VMScript {
   }
 }
 /**
- * Injection data sent to the content bridge
+ * Injection data sent to the content bridge when injection is disabled
  */
-declare interface VMInjection {
+declare interface VMInjectionDisabled {
   expose: string | false;
+}
+/**
+ * Injection data sent to the content bridge when injection is enabled
+ */
+declare interface VMInjection extends VMInjectionDisabled {
   scripts: VMInjection.Script[];
   injectInto: VMScriptInjectInto;
   injectPage: boolean;
   cache: StringMap;
+  errors: string[];
   feedId: {
     /** InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected */
     cacheKey: string;
@@ -235,6 +241,7 @@ declare namespace VMInjection {
     dataKey: string;
     displayName: string;
     code: string;
+    injectInto: VMScriptInjectInto;
     metaStr: string;
     runAt?: 'start' | 'body' | 'end' | 'idle';
     values?: StringMap;