1
0
Эх сурвалжийг харах

fix: safe-guard window.vmXXX initializers + speedup (#1647)

* expose initializer only while injecting to avoid interception
* skip further processing when no scripts match
* speed up, reduce mem usage and injected.js size
* extract shared & injection-related consts
* fix id and make it more random
tophf 2 жил өмнө
parent
commit
d5cc735a3e

+ 5 - 0
.eslintrc.js

@@ -11,9 +11,12 @@ const FILES_WEB = [`src/injected/web/**/*.js`];
 const FILES_SHARED = [
   'src/common/browser.js',
   'src/common/consts.js',
+  'src/common/safe-globals-shared.js',
 ];
 
+const GLOBALS_SHARED = getGlobals('*');
 const GLOBALS_COMMON = {
+  ...GLOBALS_SHARED,
   ...getGlobals('common'),
   re: false, // transform-modern-regexp with useRe option
 };
@@ -24,10 +27,12 @@ const GLOBALS_INJECTED = {
 };
 const GLOBALS_CONTENT = {
   INIT_FUNC_NAME: false,
+  ...GLOBALS_SHARED,
   ...getGlobals('injected/content'),
   ...GLOBALS_INJECTED,
 };
 const GLOBALS_WEB = {
+  ...GLOBALS_SHARED,
   ...getGlobals('injected/web'),
   ...GLOBALS_INJECTED,
   IS_FIREFOX: false, // passed as a parameter to VMInitInjection in webpack.conf.js

+ 13 - 6
scripts/webpack-util.js

@@ -2,12 +2,19 @@ const fs = require('fs');
 const babelCore = require('@babel/core');
 const WrapperWebpackPlugin = require('wrapper-webpack-plugin');
 
-// {entryName: path}
 const entryGlobals = {
-  'common': ['common'],
-  'injected/content': ['injected', 'injected/content'],
-  'injected/web': ['injected', 'injected/web'],
+  'common': [],
+  'injected/content': [],
+  'injected/web': [],
 };
+const entryPathToFilename = path => path === '*'
+  ? `./src/common/safe-globals-shared.js`
+  : `./src/${path}/safe-globals.js`;
+Object.entries(entryGlobals).forEach(([name, val]) => {
+  const parts = name.split('/');
+  if (parts[1]) parts[1] = name;
+  val.push('*', ...parts);
+});
 
 /**
  * Adds a watcher for files in entryGlobals to properly recompile the project on changes.
@@ -17,7 +24,7 @@ function addWrapperWithGlobals(name, config, defsObj, callback) {
     test: new RegExp(`/${name}/.*?\\.js$`.replace(/\//g, /[/\\]/.source)),
     use: [{
       loader: './scripts/fake-dep-loader.js',
-      options: { files: entryGlobals[name] },
+      options: { files: entryGlobals[name].map(entryPathToFilename) },
     }],
   });
   const defsRe = new RegExp(`\\b(${
@@ -53,7 +60,7 @@ function getUniqIdB64() {
 
 function readGlobalsFile(path, babelOpts = {}) {
   const { ast, code = !ast } = babelOpts;
-  const filename = `./src/${path}/safe-globals.js`;
+  const filename = entryPathToFilename(path);
   const src = fs.readFileSync(filename, { encoding: 'utf8' })
   .replace(/\bexport\s+(function\s+(\w+))/g, 'const $2 = $1')
   .replace(/\bexport\s+(?=(const|let)\s)/g, '');

+ 10 - 14
src/background/utils/db.js

@@ -3,7 +3,7 @@ import {
   getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
   getScriptPrettyUrl, makePause,
 } from '@/common';
-import { ICON_PREFIX, INFERRED, INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
+import { ICON_PREFIX, INFERRED, TIMEOUT_WEEK } from '@/common/consts';
 import { deepSize, forEachEntry, forEachKey, forEachValue } from '@/common/object';
 import pluginEvents from '../plugin/events';
 import { getDefaultCustom, getNameURI, inferScriptProps, newScript, parseMeta } from './script';
@@ -241,6 +241,7 @@ const notifiedBadScripts = new Set();
 
 /**
  * @desc Get scripts to be injected to page with specific URL.
+ * @return {VMInjection.Env}
  */
 export function getScriptsByURL(url, isTop, errors) {
   testerBatch(errors || true);
@@ -252,24 +253,18 @@ export function getScriptsByURL(url, isTop, errors) {
       && testScript(url, script)
     ));
   testerBatch();
-  return getScriptEnv(allScripts);
-}
-
-/**
- * @param {VMScript[]} scripts
- * @return {Promise<VMInjection.Env>}
- */
-async function getScriptEnv(scripts) {
+  if (!allScripts[0]) return;
   const allIds = {};
   const [envStart, envDelayed] = [0, 1].map(() => ({
     depsMap: {},
+    runAt: {},
     [ENV_SCRIPTS]: [],
   }));
   for (const [areaName, listName] of STORAGE_ROUTES_ENTRIES) {
     envStart[areaName] = {}; envDelayed[areaName] = {};
     envStart[listName] = []; envDelayed[listName] = [];
   }
-  scripts.forEach((script) => {
+  allScripts.forEach((script) => {
     const { id } = script.props;
     if (!(allIds[id] = +!!script.config.enabled)) {
       return;
@@ -281,6 +276,7 @@ async function getScriptEnv(scripts) {
     const env = runAt === 'start' || runAt === 'body' ? envStart : envDelayed;
     const { depsMap } = env;
     env.ids.push(id);
+    env.runAt[id] = runAt;
     if (meta.grant.some(GMVALUES_RE.test, GMVALUES_RE)) {
       env[ENV_VALUE_IDS].push(id);
     }
@@ -304,15 +300,15 @@ async function getScriptEnv(scripts) {
         }
       }
     }
-    env[ENV_SCRIPTS].push({ ...script, runAt }); // must be a copy because we modify it in preinject
+    env[ENV_SCRIPTS].push(script);
   });
   if (envStart.ids.length) {
-    Object.assign(envStart, await readEnvironmentData(envStart));
+    envStart.promise = readEnvironmentData(envStart);
   }
   if (envDelayed.ids.length) {
-    envDelayed.promise = makePause().then(() => readEnvironmentData(envDelayed));
+    envDelayed.promise = makePause().then(readEnvironmentData.bind(null, envDelayed));
   }
-  return Object.assign(envStart, { allIds, envDelayed });
+  return Object.assign(envStart, { allIds, [INJECT_MORE]: envDelayed });
 }
 
 async function readEnvironmentData(env) {

+ 224 - 219
src/background/utils/preinject.js

@@ -1,10 +1,7 @@
 import { getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd } from '@/common';
-import {
-  INJECT_AUTO, INJECT_CONTENT, INJECT_INTO, INJECT_MAPPING, INJECT_PAGE,
-  FEEDBACK, FORCE_CONTENT, HOMEPAGE_URL, METABLOCK_RE, MORE, NEWLINE_END_RE,
-} from '@/common/consts';
+import { HOMEPAGE_URL, METABLOCK_RE, META_STR, NEWLINE_END_RE } from '@/common/consts';
 import initCache from '@/common/cache';
-import { forEachEntry, forEachValue, objectPick, objectSet } from '@/common/object';
+import { forEachEntry, forEachValue, mapEntry, 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 { postInitialize } from './init';
@@ -12,32 +9,40 @@ import { addPublicCommands } from './message';
 import { getOption, hookOptions } from './options';
 import { popupTabs } from './popup-tracker';
 import { clearRequestsByTabId } from './requests';
-import { S_CACHE_PRE, S_CODE_PRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE_PRE } from './storage';
+import {
+  S_CACHE, S_CACHE_PRE, S_CODE_PRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE, S_VALUE_PRE,
+} from './storage';
 import { clearStorageCache, onStorageChanged } from './storage-cache';
 import { addValueOpener, clearValueOpener } from './values';
 
+let isApplied;
+let injectInto;
+let xhrInject;
+
 const API_CONFIG = {
   urls: ['*://*/*'], // `*` scheme matches only http and https
   types: ['main_frame', 'sub_frame'],
 };
+const __CODE = Symbol('code'); // will be stripped when messaging
 const INJECT = 'inject';
+/** These are reused by cache entries to reduce memory usage */
+const BAG_NOOP = { [INJECT]: {} };
+const BAG_NOOP_EXPOSE = { [INJECT]: { expose: true } };
 const CSAPI_REG = 'csar';
 const contentScriptsAPI = browser.contentScripts;
 const envStartKey = {};
-/** In normal circumstances the data will be removed in ~1sec on use,
- * however connecting may take a long time or the tab may be paused in devtools. */
-const TIME_KEEP_DATA = 5 * 60e3;
 const cache = initCache({
-  lifetime: TIME_KEEP_DATA,
+  lifetime: 5 * 60e3,
   async onDispose(val, key) {
     if (!val) return;
     if (val.then) val = await val;
-    val[CSAPI_REG]?.then(reg => reg.unregister());
     if ((val = val[INJECT] || val)[ENV_SCRIPTS]) {
-      val[ENV_SCRIPTS].forEach(script => cache.del(script.dataKey));
-      cache.del(val[MORE] || envStartKey[key]);
+      cache.del(val[INJECT_MORE] || envStartKey[key]);
       delete envStartKey[key];
     }
+    if ((val = val[CSAPI_REG])) {
+      (await val).unregister();
+    }
   },
 });
 // KEY_XXX for hooked options
@@ -53,36 +58,86 @@ const META_KEYS_TO_ENSURE = [
   'runAt',
   'version',
 ];
+const META_KEYS_TO_ENSURE_FROM = [
+  [HOMEPAGE_URL, 'homepage'],
+];
+const META_KEYS_TO_PLURALIZE_RE = /^(?:(m|excludeM)atch|(ex|in)clude)$/;
+const pluralizeMetaKey = (s, consonant) => s + (consonant ? 'es' : 's');
+const pluralizeMeta = key => key.replace(META_KEYS_TO_PLURALIZE_RE, pluralizeMetaKey);
+const UNWRAP = 'unwrap';
+const KNOWN_INJECT_INTO = {
+  [INJECT_AUTO]: 1,
+  [INJECT_CONTENT]: 1,
+  [INJECT_PAGE]: 1,
+};
+const propsToClear = {
+  [S_CACHE_PRE]: ENV_CACHE_KEYS,
+  [S_CODE_PRE]: true,
+  [S_REQUIRE_PRE]: ENV_REQ_KEYS,
+  [S_SCRIPT_PRE]: true,
+  [S_VALUE_PRE]: ENV_VALUE_IDS,
+};
 const expose = {};
-let isApplied;
-let injectInto;
-let xhrInject;
+const resolveDataCodeStr = `(${data => {
+  if (self.vmResolve) self.vmResolve(data); // `window` is a const which is inaccessible here
+  else self.vmData = data; // Ran earlier than the main content script so just drop the payload
+}})`;
+const getKey = (url, isTop) => (
+  isTop ? url : `-${url}`
+);
+const normalizeRealm = val => (
+  KNOWN_INJECT_INTO[val] ? val : injectInto || INJECT_AUTO
+);
+const normalizeScriptRealm = (custom, meta) => (
+  normalizeRealm(custom[INJECT_INTO] || meta[INJECT_INTO])
+);
+const isContentRealm = (val, force) => (
+  val === INJECT_CONTENT || val === INJECT_AUTO && force
+);
 
 addPublicCommands({
   /** @return {Promise<VMInjection>} */
-  async GetInjected({ url, forceContent, done }, src) {
+  async GetInjected({ url, [INJECT_CONTENT_FORCE]: forceContent, done }, src) {
     const { frameId, tab } = src;
     const tabId = tab.id;
     if (!url) url = src.url || tab.url;
     clearFrameData(tabId, frameId);
-    const key = getKey(url, !frameId);
-    const cacheVal = cache.get(key) || prepare(key, url, tabId, frameId, forceContent);
-    const bag = cacheVal[INJECT] ? cacheVal : await cacheVal;
+    const isTop = !frameId;
+    const bagKey = getKey(url, isTop);
+    const bagP = cache.get(bagKey) || prepare(bagKey, url, isTop);
+    const bag = bagP[INJECT] ? bagP : await bagP;
     /** @type {VMInjection} */
     const inject = bag[INJECT];
-    const feedback = bag[FEEDBACK];
-    if (feedback?.length) {
-      // Injecting known content scripts without waiting for InjectionFeedback message.
-      // Running in a separate task because it may take a long time to serialize data.
-      setTimeout(injectionFeedback, 0, { [FEEDBACK]: feedback }, src);
+    const scripts = inject[ENV_SCRIPTS];
+    const toContent = triageRealms(scripts, bag[INJECT_CONTENT_FORCE] || forceContent, bag);
+    if (toContent[0]) {
+      // Processing known feedback without waiting for InjectionFeedback message.
+      // Running in a separate task as executeScript may take a long time to serialize code.
+      setTimeout(injectContentRealm, 0, toContent, tabId, frameId);
     }
     if (popupTabs[tabId]) {
       setTimeout(sendTabCmd, 0, tabId, 'PopupShown', popupTabs[tabId], { frameId });
     }
-    addValueOpener(tabId, frameId, inject[ENV_SCRIPTS]);
+    addValueOpener(tabId, frameId, scripts);
     return !done && inject;
   },
-  InjectionFeedback: injectionFeedback,
+  async InjectionFeedback({
+    [INJECT_CONTENT_FORCE]: forceContent,
+    [INJECT_CONTENT]: items,
+    [INJECT_MORE]: moreKey,
+  }, { frameId, tab: { id: tabId } }) {
+    injectContentRealm(items, tabId, frameId);
+    if (!moreKey) return;
+    const more = await cache.get(moreKey); // TODO: rebuild if expired
+    if (!more) throw 'Injection data expired, please reload the tab!';
+    const scripts = prepareScripts(more);
+    injectContentRealm(triageRealms(scripts, forceContent), tabId, frameId);
+    addValueOpener(tabId, frameId, scripts);
+    return {
+      [ENV_SCRIPTS]: scripts,
+      [S_CACHE]: more[S_CACHE],
+    };
+  },
 });
 
 hookOptions(onOptionChanged);
@@ -92,43 +147,8 @@ postInitialize.push(() => {
   }
 });
 
-async function injectionFeedback({
-  [MORE]: more,
-  [FEEDBACK]: feedback,
-  [FORCE_CONTENT]: forceContent,
-}, src) {
-  feedback.forEach(processFeedback, src);
-  if (!more) return;
-  const env = await cache.get(more);
-  if (!env) throw 'Injection data expired, please reload the tab!';
-  env[FORCE_CONTENT] = forceContent;
-  env[ENV_SCRIPTS].map(prepareScript, env).filter(Boolean).forEach(processFeedback, src);
-  addValueOpener(src.tab.id, src.frameId, env[ENV_SCRIPTS]);
-  return objectPick(env, ['cache', ENV_SCRIPTS]);
-}
-
-/** @this {chrome.runtime.MessageSender} */
-async function processFeedback([key, runAt, unwrappedId]) {
-  const code = cache.get(key);
-  // see TIME_KEEP_DATA comment
-  if (runAt && code) {
-    const { frameId, tab: { id: tabId } } = this;
-    runAt = `document_${runAt === 'body' ? 'start' : runAt}`;
-    browser.tabs.executeScript(tabId, { code: code.join(''), frameId, runAt });
-    if (unwrappedId) sendTabCmd(tabId, 'Run', unwrappedId, { frameId });
-  }
-}
-
-const propsToClear = {
-  [S_CACHE_PRE]: ENV_CACHE_KEYS,
-  [S_CODE_PRE]: true,
-  [S_REQUIRE_PRE]: ENV_REQ_KEYS,
-  [S_SCRIPT_PRE]: true,
-  [S_VALUE_PRE]: ENV_VALUE_IDS,
-};
-
 onStorageChanged(({ keys }) => {
-  cache.forEach(removeStaleCacheEntry, keys.map((key, i) => [
+  cache.some(removeStaleCacheEntry, keys.map((key, i) => [
     key.slice(0, i = key.indexOf(':') + 1),
     key.slice(i),
   ]));
@@ -142,18 +162,18 @@ async function removeStaleCacheEntry(val, key) {
     const prop = propsToClear[prefix];
     if (prop === true) {
       cache.destroy(); // TODO: try to patch the cache in-place?
-    } else if (val[prop]?.includes(+id || id)) {
-      cache.del(key);
+      return true; // stops further processing as the cache is clear now
+    }
+    if (val[prop]?.includes(+id || id)) {
+      if (prefix === S_REQUIRE_PRE) {
+        val.depsMap[id].forEach(id => cache.del(S_SCRIPT_PRE + id));
+      } else {
+        cache.del(key); // TODO: try to patch the cache in-place?
+      }
     }
   }
 }
 
-function normalizeRealm(value) {
-  return hasOwnProperty(INJECT_MAPPING, value)
-    ? value
-    : injectInto || INJECT_AUTO;
-}
-
 function onOptionChanged(changes) {
   changes::forEachEntry(([key, value]) => {
     switch (key) {
@@ -181,10 +201,6 @@ function onOptionChanged(changes) {
   });
 }
 
-function getKey(url, isTop) {
-  return isTop ? url : `-${url}`;
-}
-
 function togglePreinject(enable) {
   isApplied = enable;
   // Using onSendHeaders because onHeadersReceived in Firefox fires *after* content scripts.
@@ -216,14 +232,14 @@ function toggleXhrInject(enable) {
   }
 }
 
-function onSendHeaders({ url, tabId, frameId }) {
+function onSendHeaders({ url, frameId }) {
   const isTop = !frameId;
   const key = getKey(url, isTop);
   if (!cache.has(key)) {
     // GetInjected message will be sent soon by the content script
     // and it may easily happen while getScriptsByURL is still waiting for browser.storage
     // so we'll let GetInjected await this pending data by storing Promise in the cache
-    cache.put(key, prepare(key, url, tabId, frameId), TIME_KEEP_DATA);
+    cache.put(key, prepare(key, url, isTop));
   }
 }
 
@@ -241,7 +257,7 @@ function onHeadersReceived(info) {
  */
 function prepareXhrBlob({ url, [kResponseHeaders]: responseHeaders }, bag) {
   if (IS_FIREFOX && url.startsWith('https:') && detectStrictCsp(responseHeaders)) {
-    forceContentInjection(bag);
+    bag[INJECT_CONTENT_FORCE] = true;
   }
   const blobUrl = URL.createObjectURL(new Blob([
     JSON.stringify(bag[INJECT]),
@@ -250,93 +266,105 @@ function prepareXhrBlob({ url, [kResponseHeaders]: responseHeaders }, bag) {
     name: 'Set-Cookie',
     value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
   });
-  setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
+  setTimeout(URL.revokeObjectURL, 60e3, blobUrl);
   return { [kResponseHeaders]: responseHeaders };
 }
 
-function prepare(key, url, tabId, frameId, forceContent) {
-  /** @type {VMInjection.Bag} */
-  const res = {
-    [INJECT]: {
-      expose: !frameId
-        && url.startsWith('https://')
-        && expose[url.split('/', 3)[2]],
-    },
-  };
-  return isApplied
-    ? prepareScripts(res, key, url, tabId, frameId, forceContent)
-    : res;
-}
-
-/**
- * @param {VMInjection.Bag} res
- * @param cacheKey
- * @param url
- * @param tabId
- * @param frameId
- * @param forceContent
- * @return {Promise<any>}
- */
-async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
+async function prepare(cacheKey, url, isTop) {
+  const shouldExpose = isTop && url.startsWith('https://') && expose[url.split('/', 3)[2]];
+  const bagNoOp = shouldExpose ? BAG_NOOP_EXPOSE : BAG_NOOP;
+  if (!isApplied) {
+    return bagNoOp;
+  }
   const errors = [];
-  const bag = await getScriptsByURL(url, !frameId, errors);
-  const { envDelayed, allIds, [ENV_SCRIPTS]: scripts } = bag;
-  bag[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
-  propsToClear::forEachValue(val => {
-    if (val !== true) res[val] = bag[val];
-  });
+  // TODO: teach `getScriptEnv` to skip prepared scripts in cache
+  const env = getScriptsByURL(url, isTop, errors);
+  if (!env) {
+    return cache.put(cacheKey, bagNoOp);
+  }
+  Object.assign(env, await env.promise);
   cache.batch(true);
-  const feedback = scripts.map(prepareScript, bag).filter(Boolean);
+  const inject = shouldExpose ? { expose: true } : {};
+  const bag = { [INJECT]: inject };
+  const { allIds, [INJECT_MORE]: envDelayed } = env;
   const more = envDelayed.promise;
   const moreKey = more && getUniqId('more');
-  /** @type {VMInjection} */
-  const inject = res[INJECT];
   Object.assign(inject, {
-    [ENV_SCRIPTS]: scripts,
+    [S_CACHE]: env[S_CACHE],
+    [ENV_SCRIPTS]: prepareScripts(env),
     [INJECT_INTO]: injectInto,
-    [INJECT_PAGE]: !forceContent && (
-      scripts.some(isPageRealm, bag)
-      || envDelayed[ENV_SCRIPTS].some(isPageRealm, bag)
-    ),
-    [MORE]: moreKey,
-    cache: bag.cache,
+    [INJECT_MORE]: moreKey,
     ids: allIds,
-    info: {
-      ua,
-    },
+    info: { ua },
     errors: errors.filter(err => allIds[err.split('#').pop()]).join('\n'),
   });
-  res[FEEDBACK] = feedback;
-  res[CSAPI_REG] = contentScriptsAPI && !xhrInject
-    && registerScriptDataFF(inject, url, !!frameId);
+  propsToClear::forEachValue(val => {
+    if (val !== true) bag[val] = env[val];
+  });
+  bag[INJECT_MORE] = envDelayed;
+  bag[CSAPI_REG] = contentScriptsAPI && !xhrInject
+    && registerScriptDataFF(inject, url, !isTop);
   if (more) {
     cache.put(moreKey, more);
     envStartKey[moreKey] = cacheKey;
   }
-  cache.put(cacheKey, res); // synchronous onHeadersReceived needs plain object not a Promise
+  cache.put(cacheKey, bag); // synchronous onHeadersReceived needs plain object not a Promise
   cache.batch(false);
-  return res;
+  return bag;
+}
+
+function prepareScripts(env) {
+  const scripts = env[ENV_SCRIPTS];
+  for (let i = 0, script, key, id; i < scripts.length; i++) {
+    script = scripts[i];
+    if (!(id = script.id)) {
+      id = script.props.id;
+      key = S_SCRIPT_PRE + id;
+      script = cache.get(key) || cache.put(key, prepareScript(script, env));
+      scripts[i] = script;
+    }
+    script.val = env[S_VALUE][id] || null;
+  }
+  return scripts;
 }
 
-/** @this {VMInjection.Env} */
-function prepareScript(script) {
+/**
+ * @param {VMScript} script
+ * @param {VMInjection.Env} env
+ * @return {VMInjection.Script}
+ */
+function prepareScript(script, env) {
   const { custom, meta, props } = script;
   const { id } = props;
-  const { [FORCE_CONTENT]: forceContent, require, value } = this;
-  const code = this.code[id];
-  const dataKey = getUniqId('VMin');
+  const { require, runAt } = env;
+  const code = env.code[id];
+  const dataKey = getUniqId();
+  const winKey = getUniqId();
+  const key = { data: dataKey, win: winKey };
   const displayName = getScriptName(script);
-  const isContent = isContentRealm(script, forceContent);
   const pathMap = custom.pathMap || {};
-  const wrap = !meta.unwrap;
+  const wrap = !meta[UNWRAP];
   const { grant } = meta;
   const numGrants = grant.length;
   const grantNone = !numGrants || numGrants === 1 && grant[0] === 'none';
   // Storing slices separately to reuse JS-internalized strings for code in our storage cache
   const injectedCode = [];
+  const metaCopy = meta::mapEntry(null, pluralizeMeta);
+  const metaStrMatch = METABLOCK_RE.exec(code);
   let hasReqs;
+  let codeIndex;
+  let tmp;
+  for (const key of META_KEYS_TO_ENSURE) {
+    if (metaCopy[key] == null) metaCopy[key] = '';
+  }
+  for (const [key, from] of META_KEYS_TO_ENSURE_FROM) {
+    if (!metaCopy[key] && (tmp = metaCopy[from])) {
+      metaCopy[key] = tmp;
+    }
+  }
   if (wrap) {
-    injectedCode.push(`window.${dataKey}=function(`
+    // TODO: push winKey/dataKey as separate chunks so we can change them for each injection?
+    injectedCode.push(`window.${winKey}=function ${dataKey}(`
       // using a shadowed name to avoid scope pollution
       + (grantNone ? GRANT_NONE_VARS : 'GM')
       + (IS_FIREFOX ? `,${dataKey}){try{` : '){')
@@ -355,6 +383,7 @@ function prepareScript(script) {
   if (hasReqs && wrap) {
     injectedCode.push('(()=>{');
   }
+  codeIndex = injectedCode.length;
   injectedCode.push(code);
   // adding a new line in case the code ends with a line comment
   injectedCode.push((!NEWLINE_END_RE.test(code) ? '\n' : '')
@@ -363,75 +392,74 @@ function prepareScript(script) {
     // 0 at the end to suppress errors about non-cloneable result of executeScript in FF
     + (IS_FIREFOX ? ';0' : '')
     + `\n//# sourceURL=${getScriptPrettyUrl(script, displayName)}`);
-  cache.put(dataKey, injectedCode, TIME_KEEP_DATA);
-  /** @type {VMInjection.Script} */
-  Object.assign(script, {
-    dataKey,
+  return {
+    code: '',
     displayName,
-    // code will be `true` if the desired realm is PAGE which is not injectable
-    code: isContent ? '' : forceContent || injectedCode,
-    values: value[id] || null,
-    ...prepareGmInfo(script, meta, code),
-  });
-  return isContent && [
-    dataKey,
-    script.runAt,
-    !wrap && id, // unwrapped scripts need an explicit `Run` message
-  ];
+    gmi: {
+      scriptWillUpdate: !!script.config.shouldUpdate,
+      uuid: props.uuid,
+    },
+    id,
+    key,
+    meta: metaCopy,
+    pathMap,
+    runAt: runAt[id],
+    [__CODE]: injectedCode,
+    [INJECT_INTO]: normalizeScriptRealm(custom, meta),
+    [META_STR]: [
+      '',
+      codeIndex,
+      tmp = (metaStrMatch.index + metaStrMatch[1].length),
+      tmp + metaStrMatch[2].length,
+    ],
+  };
 }
 
-function prepareGmInfo(script, meta, code) {
-  const metaCopy = {};
-  meta::forEachEntry(([key, val]) => {
-    switch (key) {
-    case 'match': // -> matches
-    case 'excludeMatch': // -> excludeMatches
-      key += 'e';
-      // fallthrough
-    case 'exclude': // -> excludes
-    case 'include': // -> includes
-      key += 's';
-      break;
-    default:
+function triageRealms(scripts, forceContent, bag) {
+  let code;
+  let wantsPage;
+  const envDelayed = bag?.[INJECT_MORE];
+  const toContent = [];
+  for (const scr of scripts) {
+    const metaStr = scr[META_STR];
+    if (isContentRealm(scr[INJECT_INTO], forceContent)) {
+      if (!metaStr[0]) {
+        const [, i, from, to] = metaStr;
+        metaStr[0] = scr[__CODE][i].slice(from, to);
+      }
+      code = '';
+      toContent.push([scr.id, scr.key.data]);
+    } else {
+      metaStr[0] = '';
+      code = forceContent ? ID_BAD_REALM : scr[__CODE];
+      if (!forceContent) wantsPage = true;
     }
-    metaCopy[key] = val;
-  });
-  META_KEYS_TO_ENSURE.forEach((key) => {
-    if (!metaCopy[key]) metaCopy[key] = '';
-  });
-  let val;
-  if (!metaCopy[HOMEPAGE_URL] && (val = metaCopy.homepage)) {
-    metaCopy[HOMEPAGE_URL] = val;
+    scr.code = code;
   }
-  return {
-    // overwriting existing props is ok because `script` is a copy, see getScriptEnv
-    meta: metaCopy,
-    // `injectInto`, `resources`, `script` will be added in makeGmApiWrapper
-    gmInfo: {
-      platform: ua,
-      scriptHandler: VIOLENTMONKEY,
-      scriptMetaStr: code.match(METABLOCK_RE)[1] || '',
-      scriptWillUpdate: !!script.config.shouldUpdate,
-      uuid: script.props.uuid,
-      version: process.env.VM_VER,
-    },
-  };
+  if (bag) {
+    bag[INJECT][INJECT_PAGE] = wantsPage
+      || envDelayed?.[ENV_SCRIPTS].some(isPageRealmScript, forceContent || null);
+  }
+  return toContent;
 }
 
-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 */
-  const { vmResolve } = this;
-  if (vmResolve) {
-    vmResolve(data);
-  } else {
-    // running earlier than the main content script for whatever reason
-    this.vmData = data;
+function injectContentRealm(toContent, tabId, frameId) {
+  for (const [id, dataKey] of toContent) {
+    const scr = cache.get(S_SCRIPT_PRE + id); // TODO: recreate if expired?
+    if (!scr || scr.key.data !== dataKey) continue;
+    browser.tabs.executeScript(tabId, {
+      code: scr[__CODE].join(''),
+      runAt: `document_${scr.runAt}`.replace('body', 'start'),
+      frameId,
+    }).then(scr.meta[UNWRAP] && (() => sendTabCmd(tabId, 'Run', id, { frameId })));
   }
-}})`;
+}
 
 // TODO: rework the whole thing to register scripts individually with real `matches`
 function registerScriptDataFF(inject, url, allFrames) {
+  for (const scr of inject[ENV_SCRIPTS]) {
+    scr.code = scr[__CODE];
+  }
   return contentScriptsAPI.register({
     allFrames,
     js: [{
@@ -455,32 +483,9 @@ function detectStrictCsp(responseHeaders) {
   ));
 }
 
-/** @param {VMInjection.Bag} bag */
-function forceContentInjection(bag) {
-  const inject = bag[INJECT];
-  inject[FORCE_CONTENT] = true;
-  inject[ENV_SCRIPTS].forEach(scr => {
-    // When script wants `page`, the result below will be `true` so the script has a "bad realm" id
-    const failed = !isContentRealm(scr, true);
-    scr.code = failed || '';
-    bag[FEEDBACK].push([
-      scr.dataKey,
-      !failed && scr.runAt,
-      scr.meta.unwrap && scr.props.id,
-    ]);
-  });
-}
-
-function isContentRealm(scr, forceContent) {
-  const realm = scr[INJECT_INTO] || (
-    scr[INJECT_INTO] = normalizeRealm(scr.custom[INJECT_INTO] || scr.meta[INJECT_INTO])
-  );
-  return realm === INJECT_CONTENT || forceContent && realm === INJECT_AUTO;
-}
-
-/** @this {VMInjection.Env} */
-function isPageRealm(scr) {
-  return !isContentRealm(scr, this[FORCE_CONTENT]);
+/** @this {?} truthy = forceContent */
+function isPageRealmScript(scr) {
+  return !isContentRealm(scr[INJECT_INTO] || normalizeScriptRealm(scr.custom, scr.meta), this);
 }
 
 function onTabRemoved(id /* , info */) {

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

@@ -69,7 +69,7 @@ const metaOptionalTypes = {
 export function parseMeta(code) {
   // initialize meta
   const meta = metaTypes::mapEntry(value => value.default());
-  const metaBody = code.match(METABLOCK_RE)[1] || '';
+  const metaBody = code.match(METABLOCK_RE)[2] || '';
   metaBody.replace(/(?:^|\n)\s*\/\/\x20(@\S+)(.*)/g, (_match, rawKey, rawValue) => {
     const [keyName, locale] = rawKey.slice(1).split(':');
     const camelKey = keyName.replace(/[-_](\w)/g, (m, g) => g.toUpperCase());

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

@@ -76,7 +76,7 @@ export function clearValueOpener(tabId, frameId) {
  */
 export function addValueOpener(tabId, frameId, injectedScripts) {
   injectedScripts?.forEach(script => {
-    const { values, props: { id } } = script;
+    const { id, values } = script;
     if (values) objectSet(openers, [id, tabId, frameId], values);
     else delete openers[id];
   });

+ 5 - 3
src/common/cache.js

@@ -17,7 +17,7 @@ export default function initCache({
   const getNow = () => batchStarted && batchStartTime || (batchStartTime = performance.now());
   const OVERRUN = 1000; // in ms, to reduce frequency of calling setTimeout
   const exports = {
-    batch, get, forEach, pop, put, del, has, hit, destroy,
+    batch, get, some, pop, put, del, has, hit, destroy,
   };
   if (process.env.DEV) Object.defineProperty(exports, 'data', { get: () => cache });
   return exports;
@@ -36,11 +36,13 @@ export default function initCache({
    * @param {(val:?, key:string) => void} fn
    * @param {Object} [thisObj]
    */
-  function forEach(fn, thisObj) {
+  function some(fn, thisObj) {
     for (const key in cache) {
       const item = cache[key];
-      if (item) fn.call(thisObj, item.value, key);
       // Might be already deleted by fn
+      if (item && fn.call(thisObj, item.value, key)) {
+        return true;
+      }
     }
   }
   function pop(key, def) {

+ 2 - 20
src/common/consts.js

@@ -4,34 +4,16 @@ export const INFERRED = 'inferred';
 export const HOMEPAGE_URL = 'homepageURL';
 export const SUPPORT_URL = 'supportURL';
 
-export const INJECT_AUTO = 'auto';
-export const INJECT_PAGE = 'page';
-export const INJECT_CONTENT = 'content';
-export const INJECT_INTO = 'injectInto';
-export const INJECT_MAPPING = {
-  __proto__: null,
-  // `auto` tries to provide `window` from the real page as `unsafeWindow`
-  [INJECT_AUTO]: [INJECT_PAGE, INJECT_CONTENT],
-  // inject into page context
-  [INJECT_PAGE]: [INJECT_PAGE],
-  // inject into content context only
-  [INJECT_CONTENT]: [INJECT_CONTENT],
-};
-export const ID_BAD_REALM = -1;
-export const ID_INJECTING = 2;
-
 // Allow metadata lines to start with WHITESPACE? '//' SPACE
 // Allow anything to follow the predefined text of the metaStart/End
 // The SPACE must be on the same line and specifically \x20 as \s would also match \r\n\t
 // Note: when there's no valid metablock, an empty string is matched for convenience
 export const USERSCRIPT_META_INTRO = '// ==UserScript==';
-export const METABLOCK_RE = /(?:^|\n)\s*\/\/\x20==UserScript==([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$/;
+export const METABLOCK_RE = /((?:^|\n)\s*\/\/\x20==UserScript==)([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$/;
+export const META_STR = 'metaStr';
 export const NEWLINE_END_RE = /\n((?!\n)\s)*$/;
 export const INJECTABLE_TAB_URL_RE = /^(https?|file|ftps?):/;
 export const WATCH_STORAGE = 'watchStorage';
-export const FEEDBACK = 'feedback';
-export const FORCE_CONTENT = 'forceContent';
-export const MORE = 'more';
 // `browser` is a local variable since we remove the global `chrome` and `browser` in injected*
 // to prevent exposing them to userscripts with `@inject-into content`
 export const browser = process.env.IS_INJECTED !== 'injected-web' && global.browser;

+ 0 - 2
src/common/options-defaults.js

@@ -1,5 +1,3 @@
-import { INJECT_AUTO } from './consts';
-
 export default {
   isApplied: true,
   autoUpdate: 1, // days, 0 = disable

+ 28 - 0
src/common/safe-globals-shared.js

@@ -0,0 +1,28 @@
+/* eslint-disable no-unused-vars */
+
+/**
+ * This file is used first by the entire `src` including `injected`.
+ * `global` is used instead of WebPack's polyfill which we disable in webpack.conf.js.
+ * Not exporting NodeJS built-in globals as this file is imported in the test scripts.
+ */
+
+const global = (function _() {
+  return this || globalThis; // eslint-disable-line no-undef
+}());
+/** These two are unforgeable so we extract them primarily to improve minification.
+ * The document's value can change only in about:blank but we don't inject there. */
+const { document, window } = global;
+export const VIOLENTMONKEY = 'Violentmonkey';
+export const INJECT_AUTO = 'auto';
+export const INJECT_PAGE = 'page';
+export const INJECT_CONTENT = 'content';
+export const INJECT_CONTENT_FORCE = 'forceContent';
+export const INJECT_INTO = 'injectInto';
+export const INJECT_MORE = 'more';
+export const ID_BAD_REALM = -1;
+export const ID_INJECTING = 2;
+export const kResponseHeaders = 'responseHeaders';
+export const kResponseText = 'responseText';
+export const kResponseType = 'responseType';
+export const isFunction = val => typeof val === 'function';
+export const isObject = val => val != null && typeof val === 'object';

+ 0 - 12
src/common/safe-globals.js

@@ -2,22 +2,16 @@
 
 /**
  * This file is used by entire `src` except `injected`.
- * `global` is used instead of WebPack's polyfill which we disable in webpack.conf.js.
  * `safeCall` is used by our modified babel-plugin-safe-bind.js.
  * Standard globals are extracted for better minification and marginally improved lookup speed.
  * Not exporting NodeJS built-in globals as this file is imported in the test scripts.
  */
 
-const global = (function _() {
-  return this || globalThis; // eslint-disable-line no-undef
-}());
 const {
   Boolean,
   Error,
   Object,
   Promise,
-  document,
-  window,
   performance,
 } = global;
 export const SafePromise = Promise; // alias used by browser.js
@@ -25,9 +19,3 @@ export const SafeError = Error; // alias used by browser.js
 export const { apply: safeApply, has: hasOwnProperty } = Reflect;
 export const safeCall = Object.call.bind(Object.call);
 export const IS_FIREFOX = !global.chrome.app;
-export const isFunction = val => typeof val === 'function';
-export const isObject = val => val != null && typeof val === 'object';
-export const VIOLENTMONKEY = 'Violentmonkey';
-export const kResponseHeaders = 'responseHeaders';
-export const kResponseText = 'responseText';
-export const kResponseType = 'responseType';

+ 3 - 5
src/common/util.js

@@ -72,11 +72,9 @@ export function throttle(func, time) {
 export function noop() {}
 
 export function getUniqId(prefix = 'VM') {
-  const now = performance.now();
-  // `rnd + 1` to make sure the number is large enough and the string is long enough
-  return prefix
-    + Math.floor((now - Math.floor(now) + 1) * 1e12).toString(36)
-    + Math.floor((Math.random() + 1) * 1e12).toString(36);
+  for (let rnd = ''; (rnd += Math.random().toString(36).slice(2));) {
+    if (rnd.length > 9) return prefix + rnd;
+  }
 }
 
 /**

+ 1 - 1
src/injected/content/bridge.js

@@ -1,4 +1,4 @@
-import { INJECT_PAGE, browser } from '../util';
+import { browser } from '../util';
 import { sendCmd } from './util';
 
 const handlers = createNullObj();

+ 0 - 1
src/injected/content/cmd-run.js

@@ -1,6 +1,5 @@
 import bridge from './bridge';
 import { sendCmd } from './util';
-import { INJECT_PAGE } from '../util';
 
 const { ids } = bridge;
 const runningIds = [];

+ 0 - 1
src/injected/content/gm-api-content.js

@@ -1,6 +1,5 @@
 import bridge, { addBackgroundHandlers, addHandlers } from './bridge';
 import { decodeResource, elemByTag, makeElem, nextTask, sendCmd } from './util';
-import { INJECT_INTO } from '../util';
 
 const menus = createNullObj();
 let setPopupThrottle;

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

@@ -6,15 +6,13 @@ import './notifications';
 import './requests';
 import './tabs';
 import { sendCmd } from './util';
-import { isEmpty, FORCE_CONTENT, INJECT_CONTENT, INJECT_INTO } from '../util';
+import { isEmpty } from '../util';
 import { Run } from './cmd-run';
 
 const { ids } = bridge;
 
 // Make sure to call obj::method() in code that may run after INJECT_CONTENT userscripts
 async function init() {
-  const contentId = safeGetUniqId();
-  const webId = safeGetUniqId();
   const isXml = document instanceof XMLDocument;
   const xhrData = getXhrInjection();
   const dataPromise = sendCmd('GetInjected', {
@@ -22,7 +20,7 @@ async function init() {
      * in Chrome sender.url is ok, but location.href is wrong for text selection URLs #:~:text= */
     url: IS_FIREFOX && location.href,
     // XML document's appearance breaks when script elements are added
-    [FORCE_CONTENT]: isXml,
+    [INJECT_CONTENT_FORCE]: isXml,
     done: !!(xhrData || global.vmData),
   }, {
     retry: true,
@@ -35,13 +33,13 @@ async function init() {
   );
   assign(ids, data.ids);
   bridge[INJECT_INTO] = data[INJECT_INTO];
-  if (data.expose && !isXml && injectPageSandbox(contentId, webId)) {
+  if (data.expose && !isXml && injectPageSandbox()) {
     addHandlers({ GetScriptVer: true }, true);
     bridge.post('Expose');
   }
   if (data.scripts) {
     onScripts.forEach(fn => fn(data));
-    await injectScripts(contentId, webId, data, isXml);
+    await injectScripts(data, isXml);
   }
   onScripts.length = 0;
   sendSetPopup();

+ 100 - 140
src/injected/content/inject.js

@@ -1,10 +1,6 @@
 import bridge, { addHandlers } from './bridge';
 import { elemByTag, makeElem, nextTask, onElement, sendCmd } from './util';
-import {
-  bindEvents, fireBridgeEvent,
-  ID_BAD_REALM, ID_INJECTING, INJECT_CONTENT, INJECT_INTO, INJECT_MAPPING, INJECT_PAGE,
-  MORE, FEEDBACK, FORCE_CONTENT,
-} from '../util';
+import { bindEvents, fireBridgeEvent, META_STR } from '../util';
 import { Run } from './cmd-run';
 
 /* In FF, content scripts running in a same-origin frame cannot directly call parent's functions
@@ -13,11 +9,11 @@ import { Run } from './cmd-run';
  * INIT_FUNC_NAME ids even though we change it now with each release. */
 const VAULT_WRITER = `${VM_UUID}${INIT_FUNC_NAME}VW`;
 const VAULT_WRITER_ACK = `${VAULT_WRITER}+`;
-const tardyQueue = [];
+const bridgeIds = bridge.ids;
+let tardyQueue;
+let bridgeInfo;
 let contLists;
-let pgLists;
-/** @type {Object<string,VMRealmData>} */
-let realms;
+let pageLists;
 /** @type {?boolean} */
 let pageInjectable;
 let frameEventWnd;
@@ -52,13 +48,15 @@ addHandlers({
   /**
    * FF bug workaround to enable processing of sourceURL in injected page scripts
    */
-  InjectList: IS_FIREFOX && injectList,
+  InjectList: IS_FIREFOX && injectPageList,
 });
 
-export function injectPageSandbox(contentId, webId) {
+export function injectPageSandbox() {
   pageInjectable = false;
   const vaultId = safeGetUniqId();
   const handshakeId = safeGetUniqId();
+  const contentId = safeGetUniqId();
+  const webId = safeGetUniqId();
   if (useOpener(opener) || useOpener(!IS_TOP && parent)) {
     startHandshake();
   } else {
@@ -117,121 +115,88 @@ export function injectPageSandbox(contentId, webId) {
 }
 
 /**
- * @param {string} contentId
- * @param {string} webId
  * @param {VMInjection} data
  * @param {boolean} isXml
  */
-export async function injectScripts(contentId, webId, data, isXml) {
-  const { errors, info, [MORE]: more } = data;
+export async function injectScripts(data, isXml) {
+  const { errors, info, [INJECT_MORE]: more } = data;
+  const CACHE = 'cache';
   if (errors) {
     logging.warn(errors);
   }
   if (IS_FIREFOX) {
     IS_FIREFOX = parseFloat(info.ua.browserVersion); // eslint-disable-line no-global-assign
   }
-  realms = {
-    __proto__: null,
-    [INJECT_CONTENT]: {
-      lists: contLists = { start: [], body: [], end: [], idle: [] },
-      is: 0,
-      info,
-    },
-    [INJECT_PAGE]: {
-      lists: pgLists = { start: [], body: [], end: [], idle: [] },
-      is: 0,
-      info,
-    },
-  };
-  assign(bridge.cache, data.cache);
-  if (isXml || data[FORCE_CONTENT]) {
+  bridgeInfo = createNullObj();
+  bridgeInfo[INJECT_PAGE] = info;
+  bridgeInfo[INJECT_CONTENT] = info;
+  assign(bridge[CACHE], data[CACHE]);
+  if (isXml || data[INJECT_CONTENT_FORCE]) {
     pageInjectable = false;
+  } else if (data[INJECT_PAGE] && pageInjectable == null) {
+    injectPageSandbox();
   }
-  if (data[INJECT_PAGE] && pageInjectable == null) {
-    injectPageSandbox(contentId, webId);
-  }
-  const feedback = data.scripts.map((script) => {
-    const { id } = script.props;
-    const realm = INJECT_MAPPING[script[INJECT_INTO]].find(key => (
-      key === INJECT_CONTENT || pageInjectable
-    ));
-    const { runAt } = script;
-    // If the script wants this specific realm, which is unavailable, we won't inject it at all
-    if (realm) {
-      const { pathMap } = script.custom;
-      const realmData = realms[realm];
-      realmData.lists[runAt].push(script); // 'start' or 'body' per getScriptsByURL()
-      realmData.is = true;
-      if (pathMap) bridge.pathMaps[id] = pathMap;
-    } else {
-      bridge.ids[id] = ID_BAD_REALM;
-    }
-    return [
-      script.dataKey,
-      realm === INJECT_CONTENT && runAt,
-      script.meta.unwrap && id,
-    ];
-  });
-  const moreData = sendCmd('InjectionFeedback', {
-    [FEEDBACK]: feedback,
-    [FORCE_CONTENT]: !pageInjectable,
-    [MORE]: more,
-  });
-  const hasInvoker = realms[INJECT_CONTENT].is;
+  const toContent = data.scripts
+    .filter(scr => triageScript(scr) === INJECT_CONTENT)
+    .map(scr => [scr.id, scr.key.data]);
+  const moreData = (more || toContent.length)
+    && sendCmd('InjectionFeedback', {
+      [INJECT_CONTENT_FORCE]: !pageInjectable,
+      [INJECT_CONTENT]: toContent,
+      [INJECT_MORE]: more,
+    });
+  const hasInvoker = contLists;
   if (hasInvoker) {
-    setupContentInvoker(contentId, webId);
+    setupContentInvoker();
   }
   // Using a callback to avoid a microtask tick when the root element exists or appears.
-  await onElement('*', async () => {
-    injectAll('start');
-    const onBody = (pgLists.body.length || contLists.body.length)
-      && onElement('body', injectAll, 'body');
-    // document-end, -idle
-    if (more) {
-      data = await moreData;
-      if (data) await injectDelayedScripts(!hasInvoker && contentId, webId, data);
-    }
-    if (onBody) {
-      await onBody;
+  await onElement('*', injectAll, 'start');
+  if (pageLists?.body || contLists?.body) {
+    await onElement('body', injectAll, 'body');
+  }
+  if (more && (data = await moreData)) {
+    assign(bridge[CACHE], data[CACHE]);
+    if (document::getReadyState() === 'loading') {
+      await new SafePromise(resolve => {
+        /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
+         * by listening on `window` which follows `document` when the event bubbles up. */
+        on('DOMContentLoaded', resolve, { once: true });
+      });
+      await 0; // let the site's listeners on `window` run first
     }
-    realms = null;
-    pgLists = null;
-    contLists = null;
-  });
-  VMInitInjection = null; // release for GC
-}
-
-async function injectDelayedScripts(contentId, webId, { cache, scripts }) {
-  assign(bridge.cache, cache);
-  let needsInvoker;
-  scripts::forEach(script => {
-    const { code, runAt, custom: { pathMap } } = script;
-    const { id } = script.props;
-    if (pathMap) {
-      bridge.pathMaps[id] = pathMap;
+    for (const scr of data.scripts) {
+      triageScript(scr);
     }
-    if (!code) {
-      needsInvoker = true;
-      safePush(contLists[runAt], script);
-    } else if (pageInjectable) {
-      safePush(pgLists[runAt], script);
-    } else {
-      bridge.ids[id] = ID_BAD_REALM;
+    if (contLists && !hasInvoker) {
+      setupContentInvoker();
     }
-  });
-  if (document::getReadyState() === 'loading') {
-    await new SafePromise(resolve => {
-      /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
-       * by listening on `window` which follows `document` when the event bubbles up. */
-      window::on('DOMContentLoaded', resolve, { once: true });
-    });
-    await 0; // let the site's listeners on `window` run first
+    await injectAll('end');
+    await injectAll('idle');
   }
-  if (needsInvoker && contentId) {
-    setupContentInvoker(contentId, webId);
+  // release for GC
+  bridgeInfo = contLists = pageLists = VMInitInjection = null;
+}
+
+function triageScript(script) {
+  let realm = script[INJECT_INTO];
+  realm = (realm === INJECT_AUTO && !pageInjectable) || realm === INJECT_CONTENT
+    ? INJECT_CONTENT
+    : pageInjectable && INJECT_PAGE;
+  if (realm) {
+    const lists = realm === INJECT_CONTENT
+      ? contLists || (contLists = createNullObj())
+      : pageLists || (pageLists = createNullObj());
+    const { gmi, [META_STR]: metaStr, pathMap, runAt } = script;
+    const list = lists[runAt] || (lists[runAt] = []);
+    safePush(list, script);
+    setOwnProp(gmi, 'scriptMetaStr', metaStr[0]
+      || script.code[metaStr[1]]::slice(metaStr[2], metaStr[3]));
+    delete script[META_STR];
+    if (pathMap) bridge.pathMaps[script.id] = pathMap;
+  } else {
+    bridgeIds[script.id] = ID_BAD_REALM;
   }
-  injectAll('end');
-  injectAll('idle');
+  return realm;
 }
 
 function inject(item, iframeCb) {
@@ -297,43 +262,41 @@ function inject(item, iframeCb) {
 }
 
 function injectAll(runAt) {
-  if (process.env.DEBUG) throwIfProtoPresent(realms);
-  for (const realm in realms) { /* proto is null */// eslint-disable-line guard-for-in
-    const realmData = realms[realm];
-    const items = realmData.lists[runAt];
-    const { info } = realmData;
-    if (items.length) {
-      bridge.post('ScriptData', { info, items, runAt }, realm);
-      if (realm === INJECT_PAGE && !IS_FIREFOX) {
-        injectList(runAt);
-      }
-      safePush(tardyQueue, items);
-      nextTask()::then(tardyQueueCheck);
+  let res;
+  for (let inPage = 1; inPage >= 0; inPage--) {
+    const realm = inPage ? INJECT_PAGE : INJECT_CONTENT;
+    const lists = inPage ? pageLists : contLists;
+    const items = lists?.[runAt];
+    if (items) {
+      bridge.post('ScriptData', { items, info: bridgeInfo[realm] }, realm);
+      delete bridgeInfo[realm];
+      if (!tardyQueue) tardyQueue = createNullObj();
+      for (const { id } of items) tardyQueue[id] = 1;
+      if (!inPage) nextTask()::then(tardyQueueCheck);
+      else if (!IS_FIREFOX) res = injectPageList(runAt);
     }
   }
-  if (runAt !== 'start' && contLists[runAt].length) {
-    bridge.post('RunAt', runAt, INJECT_CONTENT);
-  }
+  return res;
 }
 
-async function injectList(runAt) {
-  const list = pgLists[runAt];
-  // Not using for-of because we don't know if @@iterator is safe.
-  for (let i = 0, item; (item = list[i]); i += 1) {
-    if (item.code) {
+async function injectPageList(runAt) {
+  const scripts = pageLists[runAt];
+  for (const scr of scripts) {
+    if (scr.code) {
       if (runAt === 'idle') await nextTask();
       if (runAt === 'end') await 0;
-      inject(item);
-      item.code = '';
-      if (item.meta?.unwrap) {
-        Run(item.props.id);
-      }
+      // Exposing window.vmXXX setter just before running the script to avoid interception
+      if (!scr.meta.unwrap) bridge.post('Plant', scr.key);
+      inject(scr);
+      scr.code = '';
+      if (scr.meta.unwrap) Run(scr.id);
     }
   }
+  tardyQueueCheck();
 }
 
-function setupContentInvoker(contentId, webId) {
-  const invokeContent = VMInitInjection(IS_FIREFOX)(webId, contentId, bridge.onHandle);
+function setupContentInvoker() {
+  const invokeContent = VMInitInjection(IS_FIREFOX)(bridge.onHandle);
   const postViaBridge = bridge.post;
   bridge.post = (cmd, params, realm, node) => {
     const fn = realm === INJECT_CONTENT
@@ -348,13 +311,10 @@ function setupContentInvoker(contentId, webId) {
  * as "still starting", so the popup can show them accordingly.
  */
 function tardyQueueCheck() {
-  for (const items of tardyQueue) {
-    for (const script of items) {
-      const id = script.props.id;
-      if (bridge.ids[id] === 1) bridge.ids[id] = ID_INJECTING;
-    }
+  for (const id in tardyQueue) {
+    if (bridgeIds[id] === 1) bridgeIds[id] = ID_INJECTING;
   }
-  tardyQueue.length = 0;
+  tardyQueue = null;
 }
 
 function tellBridgeToWriteVault(vaultId, wnd) {

+ 0 - 13
src/injected/safe-globals.js

@@ -2,34 +2,21 @@
 
 /**
  * This file runs before safe-globals of `injected-content` and `injected-web` entries.
- * `global` is used instead of WebPack's polyfill which we disable in webpack.conf.js.
  * `export` is stripped in the final output and is only used for our NodeJS test scripts.
  * WARNING! Don't use exported functions from @/common anywhere in injected!
  */
 
-const global = (function _() {
-  return this || globalThis; // eslint-disable-line no-undef
-}());
-/** These two are unforgeable so we extract them primarily to improve minification.
- * The document's value can change only in about:blank but we don't inject there. */
-const { document, window } = global;
 export const { location } = global;
 export const PROTO = 'prototype';
 export const IS_TOP = top === window;
 export const CALLBACK_ID = '__CBID';
-export const VIOLENTMONKEY = 'Violentmonkey';
 export const kFileName = 'fileName';
-export const kResponseHeaders = 'responseHeaders';
-export const kResponseText = 'responseText';
-export const kResponseType = 'responseType';
 
 export const throwIfProtoPresent = process.env.DEBUG && (obj => {
   if (!obj || obj.__proto__) { // eslint-disable-line no-proto
     throw 'proto is not null';
   }
 });
-export const isFunction = val => typeof val === 'function';
-export const isObject = val => val != null && typeof val === 'object';
 export const isString = val => typeof val === 'string';
 
 export const getOwnProp = (obj, key, defVal) => {

+ 18 - 9
src/injected/web/gm-api-wrapper.js

@@ -2,7 +2,6 @@ import bridge from './bridge';
 import { GM_API } from './gm-api';
 import { makeGlobalWrapper } from './gm-global-wrapper';
 import { makeComponentUtils } from './util';
-import { INJECT_INTO } from '../util';
 
 /** Name in Greasemonkey4 -> name in GM */
 const GM4_ALIAS = {
@@ -29,9 +28,8 @@ const sendTabFocus = () => bridge.post('TabFocus');
 export function makeGmApiWrapper(script) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
-  const { meta } = script;
-  const grant = meta.grant;
-  const { id } = script.props;
+  const { id, meta } = script;
+  const { grant } = meta;
   const resources = nullObjFrom(meta.resources);
   /** @type {GMContext} */
   const context = {
@@ -41,7 +39,7 @@ export function makeGmApiWrapper(script) {
     resources,
     resCache: createNullObj(),
   };
-  const gmInfo = makeGmInfo(script.gmInfo, meta, resources);
+  const gmInfo = makeGmInfo(script.gmi, meta, resources);
   const gm4 = {
     __proto__: null,
     info: gmInfo,
@@ -95,8 +93,19 @@ function makeGmInfo(gmInfo, meta, resources) {
     resourcesArr[i] = { name, url: resources[name] };
   });
   // No __proto__:null because these are standard objects for userscripts
-  setOwnProp(meta, 'resources', resourcesArr);
-  setOwnProp(gmInfo, INJECT_INTO, bridge.mode);
-  setOwnProp(gmInfo, 'script', meta);
-  return gmInfo;
+  meta.resources = resourcesArr;
+  return safeAssign(gmInfo, {
+    [INJECT_INTO]: bridge.mode,
+    platform: safeAssign({}, bridge.ua),
+    script: meta,
+    scriptHandler: VIOLENTMONKEY,
+    version: process.env.VM_VER,
+  });
+}
+
+function safeAssign(dst, src) {
+  for (const key of objectKeys(src)) {
+    setOwnProp(dst, key, src[key]);
+  }
+  return dst;
 }

+ 55 - 71
src/injected/web/index.js

@@ -5,60 +5,38 @@ import './gm-values';
 import './notifications';
 import './requests';
 import './tabs';
-import { bindEvents, INJECT_PAGE, INJECT_CONTENT } from '../util';
+import { bindEvents } from '../util';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
-/** document-body scripts in content mode are inserted via executeScript at document-start
- * in inert state, then wait for content bridge to call RunAt('body'). */
-let runAtBodyQueue;
+const toRun = createNullObj();
 
-export default function initialize(
-  webId,
-  contentId,
-  invokeHost,
-) {
-  let invokeGuest;
+export default function initialize(invokeHost) {
   if (PAGE_MODE_HANDSHAKE) {
     window::on(PAGE_MODE_HANDSHAKE + '*', e => {
       e = e::getDetail();
-      webId = e[0];
-      contentId = e[1];
+      bindEvents(e[0], e[1], bridge);
     }, { __proto__: null, once: true, capture: true });
     window::fire(new SafeCustomEvent(PAGE_MODE_HANDSHAKE));
-  }
-  if (invokeHost) {
-    bridge.mode = INJECT_CONTENT;
-    bridge.post = (cmd, data, realm, node) => {
-      invokeHost({ cmd, data, node }, INJECT_CONTENT);
-    };
-    invokeGuest = (cmd, data, realm, node) => {
-      if (process.env.DEBUG) console.info('[bridge.guest.content] received', { cmd, data, node });
-      bridge.onHandle({ cmd, data, node });
-    };
-    global.chrome = undefined;
-    global.browser = undefined;
-    addHandlers({
-      RunAt() {
-        // executeScript code may run after <body> appeared
-        if (runAtBodyQueue) {
-          for (const fn of runAtBodyQueue) fn();
-        }
-        // allowing the belated code to run immediately
-        runAtBodyQueue = false;
-      },
-    });
-  } else {
     bridge.mode = INJECT_PAGE;
-    bindEvents(webId, contentId, bridge);
     addHandlers({
       /** @this {Node} contentWindow */
       WriteVault(id) {
         this[id] = VAULT;
       },
     });
+  } else {
+    bridge.mode = INJECT_CONTENT;
+    bridge.post = (cmd, data, realm, node) => {
+      invokeHost({ cmd, data, node }, INJECT_CONTENT);
+    };
+    global.chrome = undefined;
+    global.browser = undefined;
+    return (cmd, data, realm, node) => {
+      if (process.env.DEBUG) console.info('[bridge.guest.content] received', { cmd, data, node });
+      bridge.onHandle({ cmd, data, node });
+    };
   }
-  return invokeGuest;
 }
 
 addHandlers({
@@ -73,17 +51,43 @@ addHandlers({
     delete bridge.callbacks[id];
     if (fn) this::fn(data);
   },
-  ScriptData({ info, items, runAt }) {
+  async Plant({ data: dataKey, win: winKey }) {
+    setOwnProp(window, winKey, onCodeSet, true, 'set');
+    /* Cleaning up for a script that didn't compile at all due to a syntax error.
+     * Note that winKey can be intercepted via MutationEvent in this case. */
+    await 0;
+    delete toRun[dataKey];
+    delete window[winKey];
+  },
+  /**
+   * @param {VMInjection.Info} info
+   * @param {VMInjection.Script[]} items
+   */
+  ScriptData({ info, items }) {
     if (info) {
       assign(bridge, info);
     }
-    if (items) {
-      items::forEach(createScriptData);
-      // FF bug workaround to enable processing of sourceURL in injected page scripts
-      if (IS_FIREFOX && PAGE_MODE_HANDSHAKE) {
-        bridge.post('InjectList', runAt);
+    const toRunNow = [];
+    for (const script of items) {
+      const { key } = script;
+      toRun[key.data] = script;
+      store.values[script.id] = nullObjFrom(script.val);
+      if (!PAGE_MODE_HANDSHAKE) {
+        const winKey = key.win;
+        const data = window[winKey];
+        if (data) { // executeScript ran before GetInjected response
+          safePush(toRunNow, data);
+          delete window[winKey];
+        } else {
+          safeDefineProperty(window, winKey, {
+            configurable: true,
+            set: onCodeSet,
+          });
+        }
       }
     }
+    if (!PAGE_MODE_HANDSHAKE) toRunNow::forEach(onCodeSet);
+    else if (IS_FIREFOX) bridge.post('InjectList', items[0].runAt);
   },
   Expose() {
     external[VIOLENTMONKEY] = {
@@ -95,38 +99,18 @@ addHandlers({
   },
 });
 
-function createScriptData(item) {
-  const { dataKey } = item;
-  store.values[item.props.id] = nullObjFrom(item.values);
-  if (window[dataKey]) { // executeScript ran before GetInjected response
-    onCodeSet(item, window[dataKey]);
-  } else if (!item.meta.unwrap) {
-    safeDefineProperty(window, dataKey, {
-      configurable: true,
-      set: fn => onCodeSet(item, fn),
-    });
-  }
-}
-
-async function onCodeSet(item, fn) {
-  const { dataKey } = item;
-  // deleting now to prevent interception via DOMNodeRemoved on el::remove()
-  delete window[dataKey];
+function onCodeSet(fn) {
+  const item = toRun[fn.name];
+  const el = document::getCurrentScript();
+  const { gm, wrapper = global } = makeGmApiWrapper(item);
+  // Deleting now to prevent interception via DOMNodeRemoved on el::remove()
+  delete window[item.key.win];
   if (process.env.DEBUG) {
     log('info', [bridge.mode], item.displayName);
   }
-  const run = () => {
-    bridge.post('Run', item.props.id);
-    const { gm, wrapper } = makeGmApiWrapper(item);
-    (wrapper || global)::fn(gm, logging.error);
-  };
-  const el = document::getCurrentScript();
   if (el) {
     el::remove();
   }
-  if (!PAGE_MODE_HANDSHAKE && runAtBodyQueue !== false && item.runAt === 'body') {
-    safePush(runAtBodyQueue || (runAtBodyQueue = []), run);
-  } else {
-    run();
-  }
+  bridge.post('Run', item.id);
+  wrapper::fn(gm, logging.error);
 }

+ 0 - 1
src/options/views/tab-settings/index.vue

@@ -134,7 +134,6 @@
 import { reactive } from 'vue';
 import Tooltip from 'vueleton/lib/tooltip';
 import { debounce, i18n } from '@/common';
-import { INJECT_AUTO, INJECT_PAGE, INJECT_CONTENT } from '@/common/consts';
 import SettingCheck from '@/common/ui/setting-check';
 import { forEachEntry, mapEntry } from '@/common/object';
 import options from '@/common/options';

+ 0 - 3
src/popup/index.js

@@ -1,8 +1,5 @@
 import '@/common/browser';
 import { sendCmdDirectly } from '@/common';
-import {
-  ID_BAD_REALM, ID_INJECTING, INJECT_CONTENT, INJECT_INTO, INJECT_PAGE,
-} from '@/common/consts';
 import handlers from '@/common/handlers';
 import { loadScriptIcon } from '@/common/load-script-icon';
 import { forEachValue, mapEntry } from '@/common/object';

+ 0 - 1
src/popup/views/app.vue

@@ -192,7 +192,6 @@
 <script>
 import { reactive } from 'vue';
 import Tooltip from 'vueleton/lib/tooltip';
-import { INJECT_AUTO } from '@/common/consts';
 import options from '@/common/options';
 import {
   getScriptHome, getScriptName, getScriptSupportUrl, getScriptUpdateUrl,

+ 29 - 19
src/types.d.ts

@@ -202,36 +202,41 @@ declare interface VMInjectionDisabled {
  * 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[];
-  /** cache key for envDelayed, which also tells content bridge to expect envDelayed */
-  more: string;
+  forceContent?: boolean;
   /** content bridge adds the actually running ids and sends via SetPopup */
   ids: number[];
   info: VMInjection.Info;
+  injectInto: VMScriptInjectInto;
+  /** cache key for envDelayed, which also tells content bridge to expect envDelayed */
+  more: string;
+  /** `page` mode will be necessary */
+  page: boolean;
+  scripts: VMInjection.Script[];
 }
 
 /**
  * Injection paraphernalia in the background script
  */
 declare namespace VMInjection {
+  type RunAt = 'start' | 'body' | 'end' | 'idle';
   interface Env {
+    /** Only present in envStart */
+    allIds?: { [id: string]: NumBool };
     cache: StringMap;
     cacheKeys: string[];
     code: StringMap;
     /** Dependencies by key to script ids */
     depsMap: { [url: string]: number[] };
-    /** Only present in envStart */
-    allIds?: { [id: string]: NumBool };
-    /** Only present in envStart */
-    envDelayed?: Env;
+    forceContent?: boolean;
     ids: number[];
+    /** Only present in envStart */
+    more?: Env;
     promise: Promise<Env>;
     reqKeys: string[];
     require: StringMap;
+    runAt: { [id: string]: RunAt };
     scripts: VMScript[];
     sizing?: boolean;
     value: { [scriptId: string]: StringMap };
@@ -241,9 +246,10 @@ declare namespace VMInjection {
    * Contains the injected data and non-injected auxiliaries
    */
   interface Bag {
-    inject: VMInjection;
-    feedback: (string|number)[] | false;
     csar: Promise<browser.contentScripts.RegisteredContentScript>;
+    forceContent?: boolean;
+    inject: VMInjection;
+    more: Env;
   }
   interface Info {
     ua: VMScriptGMInfoPlatform;
@@ -251,17 +257,21 @@ declare namespace VMInjection {
   /**
    * Script prepared for injection
    */
-  interface Script extends VMScript {
-    dataKey: string;
+  interface Script {
     displayName: string;
-    code: string;
-    // `injectInto` and `script` are added in makeGmApiWrapper
-    gmInfo: VMScriptGMInfoObject;
+    /** -1 ID_BAD_REALM if the desired realm is PAGE which is not injectable */
+    code: string | -1;
+    /** Omitted props are added in makeGmApiWrapper */
+    gmi: Omit<VMScriptGMInfoObject, 'injectInto' | 'resources' | 'script' | 'scriptMetaStr'>;
+    id: number;
     injectInto: VMScriptInjectInto;
-    // `resources` is still an object, converted later in makeGmApiWrapper
+    key: { data: string, win: string };
+    /** `resources` is still an object, converted later in makeGmApiWrapper */
     meta: VMScript.Meta | VMScriptGMInfoScriptMeta;
-    runAt?: 'start' | 'body' | 'end' | 'idle';
-    values?: StringMap;
+    metaStr: (string|number)[];
+    pathMap: StringMap;
+    runAt?: RunAt;
+    val?: StringMap;
   }
 }
 

+ 1 - 0
test/mock/polyfill.js

@@ -34,6 +34,7 @@ Object.defineProperties(global, domProps);
 delete MessagePort.prototype.onmessage; // to avoid hanging
 global.PAGE_MODE_HANDSHAKE = 123;
 global.VAULT_ID = false;
+Object.assign(global, require('@/common/safe-globals-shared'));
 Object.assign(global, require('@/common/safe-globals'));
 Object.assign(global, require('@/injected/safe-globals'));
 Object.assign(global, require('@/injected/content/safe-globals'));