Pārlūkot izejas kodu

feat: generate injection code in bg

tophf 5 gadi atpakaļ
vecāks
revīzija
288cbc53c6

+ 6 - 17
src/background/index.js

@@ -2,12 +2,12 @@ import { sendCmd, sendTabCmd } from '#/common';
 import { TIMEOUT_24HOURS, TIMEOUT_MAX } from '#/common/consts';
 import ua from '#/common/ua';
 import * as sync from './sync';
-import { cache, commands } from './utils';
-import { getData, checkRemove, getScriptsByURL } from './utils/db';
+import { commands } from './utils';
+import { getData, checkRemove } from './utils/db';
 import { setBadge } from './utils/icon';
 import { initialize } from './utils/init';
 import { getOption, hookOptions } from './utils/options';
-import { getPreinjectKey, togglePreinject } from './utils/preinject';
+import { getInjectedScripts } from './utils/preinject';
 import { SCRIPT_TEMPLATE, resetScriptTemplate } from './utils/template-hook';
 import { resetValueOpener, addValueOpener } from './utils/values';
 import './utils/clipboard';
@@ -21,15 +21,10 @@ import './utils/update';
 
 const popupTabs = {}; // { tabId: 1 }
 let isApplied;
-let injectInto;
 
 hookOptions((changes) => {
   if ('autoUpdate' in changes) autoUpdate();
-  if ('defaultInjectInto' in changes) injectInto = changes.defaultInjectInto;
-  if ('isApplied' in changes) {
-    isApplied = changes.isApplied;
-    togglePreinject(isApplied);
-  }
+  if ('isApplied' in changes) isApplied = changes.isApplied;
   if (SCRIPT_TEMPLATE in changes) resetScriptTemplate(changes);
   sendCmd('UpdateOptions', changes);
 });
@@ -44,18 +39,14 @@ Object.assign(commands, {
   /** @return {Promise<Object>} */
   async GetInjected(_, src) {
     const { frameId, tab, url } = src;
-    const isTop = !frameId;
-    if (isTop) resetValueOpener(tab.id);
+    if (!frameId) resetValueOpener(tab.id);
     const res = {
-      isApplied,
-      injectInto,
       ua,
       isFirefox: ua.isFirefox,
       isPopupShown: popupTabs[tab.id],
     };
     if (isApplied) {
-      const key = getPreinjectKey(url, isTop);
-      const data = await (cache.get(key) || getScriptsByURL(url, isTop));
+      const data = await getInjectedScripts(url, tab.id, frameId);
       addValueOpener(tab.id, frameId, data.withValueIds);
       setBadge(data.enabledIds, src);
       Object.assign(res, data);
@@ -117,9 +108,7 @@ function onPopupClosed({ name }) {
 initialize(() => {
   browser.runtime.onMessage.addListener(handleCommandMessage);
   browser.runtime.onConnect.addListener(onPopupOpened);
-  injectInto = getOption('defaultInjectInto');
   isApplied = getOption('isApplied');
-  togglePreinject(isApplied);
   setTimeout(autoUpdate, 2e4);
   sync.initialize();
   checkRemove();

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

@@ -291,13 +291,13 @@ export async function getScriptsByURL(url, isTop) {
   return Object.defineProperties({
     cache,
     code,
-    enabledIds,
     ids,
     require,
     scripts,
     values,
   }, {
     // Hiding from the messaging API
+    enabledIds: { value: enabledIds },
     withValueIds: { value: withValueIds },
   });
 }

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

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

+ 114 - 7
src/background/utils/preinject.js

@@ -1,18 +1,71 @@
+import { getUniqId } from '#/common';
+import { INJECT_CONTENT, INJECTABLE_TAB_URL_RE, METABLOCK_RE } from '#/common/consts';
+import ua from '#/common/ua';
 import cache from './cache';
 import { getScriptsByURL } from './db';
+import { extensionRoot, postInitialize } from './init';
+import { commands } from './message';
+import { getOption, hookOptions } from './options';
 
 const API_CONFIG = {
   urls: ['*://*/*'], // `*` scheme matches only http and https
   types: ['main_frame', 'sub_frame'],
 };
-const TIME_AFTER_SEND = 1000; // longer as establishing connection to sites may take time
-const TIME_AFTER_RECEIVE = 250; // shorter as response body will be coming very soon
+const TIME_AFTER_SEND = 10e3; // longer as establishing connection to sites may take time
+const TIME_AFTER_RECEIVE = 1e3; // shorter as response body will be coming very soon
+const TIME_KEEP_DATA = 60e3; // 100ms should be enough but the tab may hang or get paused in debugger
+let injectInto;
+hookOptions(changes => {
+  injectInto = changes.defaultInjectInto ?? injectInto;
+  if ('isApplied' in changes) togglePreinject(changes.isApplied);
+});
+postInitialize.push(() => {
+  injectInto = getOption('defaultInjectInto');
+  togglePreinject(getOption('isApplied'));
+});
 
-export function getPreinjectKey(url, isTop) {
+Object.assign(commands, {
+  InjectionFeedback(feedback, { tab, frameId }) {
+    feedback.forEach(([key, action]) => {
+      if (action === 'done') {
+        cache.del(key);
+        return;
+      }
+      const [slices, sourceUrl] = cache.pop(key) || [];
+      if (!slices) { // see TIME_KEEP_DATA comment
+        return;
+      }
+      const needsCatch = ua.isFirefox;
+      const needsWait = action === 'wait';
+      const code = [
+        needsWait ? '(async()=>{' : '',
+        needsCatch ? 'try{' : '', // show content scripts errors in FF, https://bugzil.la/1410932
+        ...slices,
+        needsWait ? '(await ' : '(',
+        key,
+        needsCatch ? ')}catch(e){console.error(e)}' : ')',
+        needsWait ? '})()' : '',
+        sourceUrl,
+      ].join('');
+      browser.tabs.executeScript(tab.id, {
+        code,
+        frameId,
+        runAt: 'document_start',
+      });
+    });
+  },
+});
+
+/** @return {Promise<Object>} */
+export function getInjectedScripts(url, tabId, frameId) {
+  return cache.pop(getKey(url, !frameId)) || prepare(url, tabId, !frameId);
+}
+
+function getKey(url, isTop) {
   return `preinject${+isTop}:${url}`;
 }
 
-export function togglePreinject(enable) {
+function togglePreinject(enable) {
   // Using onSendHeaders because onHeadersReceived in Firefox fires *after* content scripts.
   // And even in Chrome a site may be so fast that preinject on onHeadersReceived won't be useful.
   const onOff = `${enable ? 'add' : 'remove'}Listener`;
@@ -22,16 +75,70 @@ export function togglePreinject(enable) {
 }
 
 function preinject({ url, frameId }) {
+  if (!INJECTABLE_TAB_URL_RE.test(url)) return;
   const isTop = !frameId;
-  const key = getPreinjectKey(url, isTop);
+  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, getScriptsByURL(url, isTop), TIME_AFTER_SEND);
+    cache.put(key, prepare(url, isTop), TIME_AFTER_SEND);
   }
 }
 
 function prolong({ url, frameId }) {
-  cache.hit(getPreinjectKey(url, !frameId), TIME_AFTER_RECEIVE);
+  cache.hit(getKey(url, !frameId), TIME_AFTER_RECEIVE);
+}
+
+async function prepare(url, isTop) {
+  const data = await getScriptsByURL(url, isTop);
+  data.scripts = data.scripts.map(prepareScript, data);
+  data.injectInto = injectInto;
+  data.require = undefined;
+  return data;
+}
+
+/** @this data */
+function prepareScript(script) {
+  const { custom, meta, props } = script;
+  const { id } = props;
+  const { require, values } = this;
+  const code = this.code[id];
+  const dataKey = getUniqId('VMin');
+  const name = encodeURIComponent(meta.name.replace(/[#&',/:;?@=]/g, replaceWithFullWidthForm));
+  const isContent = (custom.injectInto || meta.injectInto || injectInto) === INJECT_CONTENT;
+  const pathMap = custom.pathMap || {};
+  const reqs = meta.require?.map(key => require[pathMap[key] || key]).filter(Boolean);
+  // trying to avoid progressive string concatenation of potentially huge code slices
+  // adding `;` on a new line in case some required script ends with a line comment
+  const reqsSlices = reqs ? [].concat(...reqs.map(req => [req, '\n;'])) : [];
+  const hasReqs = reqsSlices.length;
+  const slices = [
+    // hiding module interface from @require'd scripts so they don't mistakenly use it
+    '(function(){with(this)((define,module,exports)=>{',
+    ...reqsSlices,
+    // adding a nested IIFE to support 'use strict' in the code when there are @requires
+    hasReqs ? '(()=>{' : '',
+    // TODO: move code above @require
+    code,
+    // adding a new line in case the code ends with a line comment
+    code.endsWith('\n') ? '' : '\n',
+    hasReqs ? '})()' : '',
+    '})()}).call',
+  ];
+  // Firefox lists .user.js among our own content scripts so a space at start will group them
+  const sourceUrl = `\n//# sourceURL=${extensionRoot}${ua.isFirefox ? ' ' : ''}${name}.user.js#${id}`;
+  cache.put(dataKey, [slices, sourceUrl], TIME_KEEP_DATA);
+  return {
+    ...script,
+    dataKey,
+    code: isContent ? '' : [...slices, '(', dataKey, ')', sourceUrl].join(''),
+    metaStr: code.match(METABLOCK_RE)[1] || '',
+    values: values[id],
+  };
+}
+
+function replaceWithFullWidthForm(s) {
+  // fullwidth range starts at 0xFF00, normal range starts at space char code 0x20
+  return String.fromCharCode(s.charCodeAt(0) - 0x20 + 0xFF00);
 }

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

@@ -6,6 +6,7 @@ import ua from '#/common/ua';
 import cache from './cache';
 import { isUserScript, parseMeta } from './script';
 import { getScriptById } from './db';
+import { extensionRoot } from './init';
 import { commands } from './message';
 
 const VM_VERIFY = 'VM-Verify';
@@ -372,7 +373,6 @@ const blacklist = [
   '//(?:(?:gist.|)github.com|greasyfork.org|openuserjs.org)/',
 ].map(re => new RegExp(re));
 const bypass = {};
-const extensionRoot = browser.runtime.getURL('/');
 
 browser.tabs.onCreated.addListener((tab) => {
   if (/\.user\.js([?#]|$)/.test(tab.pendingUrl || tab.url)) {

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

@@ -12,14 +12,6 @@ Object.assign(commands, {
     cache.put(`new-${id}`, newScript(data));
     return id;
   },
-  /** @return {Promise<Array>} */
-  InjectScript(code, { tab, frameId }) {
-    return browser.tabs.executeScript(tab.id, {
-      code,
-      frameId,
-      runAt: 'document_start',
-    });
-  },
   /** @return {VMScript} */
   NewScript(id) {
     return id && cache.get(`new-${id}`) || newScript();

+ 7 - 1
src/common/cache.js

@@ -17,7 +17,7 @@ export default function initCache({
   // eslint-disable-next-line no-return-assign
   const getNow = () => batchStarted && batchStartTime || (batchStartTime = performance.now());
   return {
-    batch, get, put, del, has, hit, destroy,
+    batch, get, pop, put, del, has, hit, destroy,
   };
   function batch(enable) {
     batchStarted = enable;
@@ -27,6 +27,11 @@ export default function initCache({
     const item = cache[key];
     return item ? item.value : def;
   }
+  function pop(key, def) {
+    const value = get(key, def);
+    del(key);
+    return value;
+  }
   function put(key, value, lifetime = defaultLifetime) {
     if (value) {
       cache[key] = {
@@ -37,6 +42,7 @@ export default function initCache({
     } else {
       delete cache[key];
     }
+    return value;
   }
   function del(key) {
     delete cache[key];

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

@@ -8,8 +8,8 @@ import {
 import { sendCmd } from '#/common';
 
 import {
-  forEach, join, append, createElementNS, defineProperty, describeProperty, NS_HTML, DocProto,
-  charCodeAt, fromCharCode, replace, remove,
+  forEach, push, defineProperty, describeProperty, objectEntries, setTimeout, append,
+  createElementNS, remove, DocProto, NS_HTML,
 } from '../utils/helpers';
 import bridge from './bridge';
 
@@ -19,63 +19,79 @@ const VMInitInjection = window[Symbol.for(process.env.INIT_FUNC_NAME)];
 // (the symbol is undeletable so a userscript can't fool us on reinjection)
 defineProperty(window, Symbol.for(process.env.INIT_FUNC_NAME), { value: 1 });
 
-const { encodeURIComponent, document } = global;
+const { document } = global;
 // Userscripts in content mode may redefine head and documentElement
 const { get: getHead } = describeProperty(DocProto, 'head');
 const { get: getDocElem } = describeProperty(DocProto, 'documentElement');
 const { appendChild } = DocProto; // same as Node.appendChild
 
-bridge.addHandlers({
-  Inject: injectScript,
-  InjectMulti: data => data::forEach(injectScript),
-});
+export function appendToRoot(node) {
+  // DOM spec allows any elements under documentElement
+  // https://dom.spec.whatwg.org/#node-trees
+  const root = document::getHead() || document::getDocElem();
+  return root && root::appendChild(node);
+}
 
 export function injectPageSandbox(contentId, webId) {
-  inject(`(${VMInitInjection}())('${webId}','${contentId}')`,
-    browser.runtime.getURL('sandbox/injected-web.js'));
+  inject(`(${VMInitInjection}())('${webId}','${contentId}')\n//# sourceURL=${
+    browser.runtime.getURL('sandbox/injected-web.js')
+  }`);
 }
 
 export function injectScripts(contentId, webId, data, isXml) {
+  bridge.ids = data.ids;
   // eslint-disable-next-line prefer-rest-params
   if (!document::getDocElem()) return waitForDocElem(() => injectScripts(...arguments));
-  const injectPage = [];
-  const injectContent = [];
-  const scriptLists = {
-    [INJECT_PAGE]: injectPage,
-    [INJECT_CONTENT]: injectContent,
-  };
-  bridge.ids = data.ids;
   let injectable = isXml ? false : null;
-  const injectChecking = {
-    // eslint-disable-next-line no-return-assign
-    [INJECT_PAGE]: () => injectable ?? (injectable = checkInjectable()),
-    [INJECT_CONTENT]: () => true,
+  const bornReady = ['interactive', 'complete'].includes(document.readyState);
+  const INFO = {
+    cache: data.cache,
+    isFirefox: data.isFirefox,
+    ua: data.ua,
   };
-  data.scripts.forEach((script) => {
-    const injectInto = script.custom.injectInto || script.meta.injectInto || data.injectInto;
-    const internalInjectInto = INJECT_MAPPING[injectInto] || INJECT_MAPPING[INJECT_AUTO];
-    const availableInjectInto = internalInjectInto.find(key => injectChecking[key]?.());
-    scriptLists[availableInjectInto]?.push({ script, injectInto: availableInjectInto });
-  });
-  if (injectContent.length) {
-    const invokeGuest = VMInitInjection()(webId, contentId, bridge.onHandle);
-    const postViaBridge = bridge.post;
-    bridge.invokableIds.push(...injectContent.map(({ script }) => script.props.id));
-    bridge.post = (cmd, params, realm) => {
-      (realm === INJECT_CONTENT ? invokeGuest : postViaBridge)(cmd, params);
-    };
-    bridge.post('LoadScripts', {
-      ...data,
-      mode: INJECT_CONTENT,
-      items: injectContent,
-    }, INJECT_CONTENT);
-  }
-  if (injectPage.length) {
-    bridge.post('LoadScripts', {
-      ...data,
-      mode: INJECT_PAGE,
-      items: injectPage,
-    });
+  const realms = {
+    [INJECT_CONTENT]: {
+      injectable: () => true,
+      lists: { start: [], end: [], idle: [] },
+      ids: [],
+      info: INFO,
+    },
+    [INJECT_PAGE]: {
+      // eslint-disable-next-line no-return-assign
+      injectable: () => injectable ?? (injectable = checkInjectable()),
+      lists: { start: [], end: [], idle: [] },
+      ids: [],
+      info: INFO,
+    },
+  };
+  const triage = (script) => {
+    const { custom, meta } = script;
+    const desiredRealm = custom.injectInto || meta.injectInto || data.injectInto;
+    const internalRealm = INJECT_MAPPING[desiredRealm] || INJECT_MAPPING[INJECT_AUTO];
+    const realm = internalRealm.find(key => realms[key]?.injectable());
+    const { ids, lists } = realms[realm];
+    let runAt = bornReady ? 'start'
+      : `${custom.runAt || meta.runAt || ''}`.replace(/^document-/, '');
+    const list = lists[runAt] || lists[runAt = 'end'];
+    const action = realm === INJECT_PAGE && 'done'
+      || runAt !== 'start' && 'wait'
+      || '';
+    script.action = action;
+    ids::push(script.props.id);
+    list::push(script);
+    return [script.dataKey, action];
+  };
+  const feedback = data.scripts.map(triage);
+  setupContentInvoker(realms, contentId, webId);
+  sendCmd('InjectionFeedback', feedback);
+  injectAll(realms, 'start');
+  if (!bornReady && realms[INJECT_PAGE].ids.length) {
+    delete realms[INJECT_CONTENT];
+    document.addEventListener('DOMContentLoaded', async () => {
+      await 0;
+      injectAll(realms, 'end');
+      setTimeout(injectAll, 0, realms, 'idle');
+    }, { once: true });
   }
 }
 
@@ -90,6 +106,45 @@ function checkInjectable() {
   return res;
 }
 
+function inject(code) {
+  const script = document::createElementNS(NS_HTML, 'script');
+  // using a safe call to an existing method so we don't have to extract textContent setter
+  script::append(code);
+  // When using declarativeContent there's no documentElement so we'll append to `document`
+  if (!appendToRoot(script)) document::appendChild(script);
+  script::remove();
+}
+
+function injectAll(realms, runAt) {
+  objectEntries(realms)::forEach(([realm, realmData]) => {
+    const isPage = realm === INJECT_PAGE;
+    let { info } = realmData;
+    realmData.info = undefined;
+    objectEntries(realmData.lists)::forEach(([name, items]) => {
+      if ((!isPage || name === runAt) && items.length) {
+        bridge.post('ScriptData', { info, items }, realm);
+        info = undefined;
+        items::forEach(item => {
+          if (isPage) inject(item.code);
+          item.code = '';
+        });
+      }
+    });
+  });
+}
+
+function setupContentInvoker(realms, contentId, webId) {
+  const invokableIds = realms[INJECT_CONTENT].ids;
+  if (invokableIds.length) {
+    const invoke = {
+      [INJECT_CONTENT]: VMInitInjection()(webId, contentId, bridge.onHandle),
+    };
+    const postViaBridge = bridge.post;
+    bridge.invokableIds = invokableIds;
+    bridge.post = (cmd, params, realm) => (invoke[realm] || postViaBridge)(cmd, params);
+  }
+}
+
 function waitForDocElem(cb) {
   const observer = new MutationObserver(() => {
     if (document::getDocElem()) {
@@ -99,40 +154,3 @@ function waitForDocElem(cb) {
   });
   observer.observe(document, { childList: true });
 }
-
-// fullwidth range starts at 0xFF00
-// normal range starts at space char code 0x20
-const replaceWithFullWidthForm = s => fromCharCode(s::charCodeAt(0) - 0x20 + 0xFF00);
-
-function injectScript([codeSlices, mode, scriptId, scriptName]) {
-  // using fullwidth forms for special chars and those added by the newer RFC3986 spec for URI
-  const name = encodeURIComponent(scriptName::replace(/[#&',/:;?@=]/g, replaceWithFullWidthForm));
-  const sourceUrl = browser.extension.getURL(`${name}.user.js#${scriptId}`);
-  // trying to avoid string concatenation of potentially huge code slices for as long as possible
-  if (mode === INJECT_CONTENT) {
-    // Firefox: the injected script must return 0 at the end
-    codeSlices.push(`;0\n//# sourceURL=${sourceUrl}`);
-    sendCmd('InjectScript', codeSlices::join(''));
-  } else {
-    inject(codeSlices, sourceUrl);
-  }
-}
-
-function inject(code, sourceUrl) {
-  const script = document::createElementNS(NS_HTML, 'script');
-  // avoid string concatenation of |code| as it can be extremely long
-  script::append(
-    ...typeof code === 'string' ? [code] : code,
-    ...sourceUrl ? ['\n//# sourceURL=', sourceUrl] : [],
-  );
-  // When using declarativeContent there's no documentElement so we'll append to `document`
-  if (!appendToRoot(script)) document::appendChild(script);
-  script::remove();
-}
-
-export function appendToRoot(node) {
-  // DOM spec allows any elements under documentElement
-  // https://dom.spec.whatwg.org/#node-trees
-  const root = document::getHead() || document::getDocElem();
-  return root && root::appendChild(node);
-}

+ 2 - 2
src/injected/utils/helpers.js

@@ -11,7 +11,7 @@ export const {
 } = global;
 
 export const {
-  concat, filter, findIndex, forEach, includes, indexOf, join, map, push, shift,
+  concat, filter, findIndex, forEach, includes, indexOf, join, map, push,
   // arraySlice, // to differentiate from String::slice which we use much more often
 } = Array.prototype;
 
@@ -20,7 +20,7 @@ export const {
   assign, defineProperty, getOwnPropertyDescriptor: describeProperty,
 } = Object;
 export const {
-  charCodeAt, match, slice, replace,
+  charCodeAt, slice, replace,
 } = String.prototype;
 export const { toString: objectToString } = Object.prototype;
 export const { fromCharCode } = String;

+ 2 - 2
src/injected/web/gm-api.js

@@ -92,7 +92,7 @@ export function makeGmApi() {
     GM_getResourceText(name) {
       if (name in this.resources) {
         const key = this.resources[name];
-        const raw = this.cache[this.pathMap[key] || key];
+        const raw = store.cache[this.pathMap[key] || key];
         if (!raw) return;
         const i = raw::lastIndexOf(',');
         const lastPart = i < 0 ? raw : raw::slice(i + 1);
@@ -104,7 +104,7 @@ export function makeGmApi() {
         const key = this.resources[name];
         let blobUrl = this.urls[key];
         if (!blobUrl) {
-          const raw = this.cache[this.pathMap[key] || key];
+          const raw = store.cache[this.pathMap[key] || key];
           if (raw) {
             blobUrl = cache2blobUrl(raw);
             this.urls[key] = blobUrl;

+ 13 - 13
src/injected/web/gm-wrapper.js

@@ -1,10 +1,9 @@
 import { hasOwnProperty as has } from '#/common';
-import { INJECT_CONTENT, METABLOCK_RE } from '#/common/consts';
+import { INJECT_CONTENT } from '#/common/consts';
 import bridge from './bridge';
 import {
-  concat, filter, forEach, includes, indexOf, map, match, push, slice,
-  defineProperty, describeProperty, objectKeys, replace,
-  addEventListener, removeEventListener,
+  concat, filter, forEach, includes, indexOf, map, push, slice, defineProperty, describeProperty,
+  objectKeys, replace, addEventListener, removeEventListener,
 } from '../utils/helpers';
 import { makeGmApi, vmOwnFunc } from './gm-api';
 
@@ -29,15 +28,16 @@ export function deletePropsCache() {
   componentUtils = null;
 }
 
-export function wrapGM(script, code, cache, injectInto) {
+export function wrapGM(script) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
   const grant = script.meta.grant || [];
   if (grant.length === 1 && grant[0] === 'none') {
     grant.length = 0;
   }
+  const id = script.props.id;
   const resources = script.meta.resources || {};
-  const gmInfo = makeGmInfo(script, code, resources, injectInto);
+  const gmInfo = makeGmInfo(script, resources);
   const gm = {
     GM: { info: gmInfo },
     GM_info: gmInfo,
@@ -48,10 +48,9 @@ export function wrapGM(script, code, cache, injectInto) {
     }),
   };
   const context = {
-    cache,
+    id,
     script,
     resources,
-    id: script.props.id,
     pathMap: script.custom.pathMap || {},
     urls: {},
   };
@@ -69,14 +68,15 @@ export function wrapGM(script, code, cache, injectInto) {
   return grant.length ? makeGlobalWrapper(gm) : gm;
 }
 
-function makeGmInfo({ config, meta, props }, code, resources, injectInto) {
+function makeGmInfo(script, resources) {
+  const { meta } = script;
   return {
-    uuid: props.uuid,
-    scriptMetaStr: code::match(METABLOCK_RE)[1] || '',
-    scriptWillUpdate: !!config.shouldUpdate,
+    uuid: script.props.uuid,
+    scriptMetaStr: script.metaStr,
+    scriptWillUpdate: !!script.config.shouldUpdate,
     scriptHandler: 'Violentmonkey',
     version: process.env.VM_VER,
-    injectInto,
+    injectInto: bridge.mode,
     platform: { ...bridge.ua },
     script: {
       description: meta.description || '',

+ 41 - 9
src/injected/web/index.js

@@ -1,17 +1,21 @@
 import { INJECT_PAGE, INJECT_CONTENT } from '#/common/consts';
 import { bindEvents } from '../utils';
-import { defineProperty } from '../utils/helpers';
+import {
+  defineProperty, describeProperty, forEach, log, remove, Promise,
+} from '../utils/helpers';
 import bridge from './bridge';
+import { wrapGM } from './gm-wrapper';
 import store from './store';
 import './gm-values';
-import './gm-wrapper';
-import './load-scripts';
 import './notifications';
 import './requests';
 import './tabs';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
+const { document } = global;
+const { get: getCurrentScript } = describeProperty(Document.prototype, 'currentScript');
+
 export default function initialize(
   webId,
   contentId,
@@ -36,12 +40,11 @@ export default function initialize(
       exposeVM();
     }
   }
-  document.addEventListener('DOMContentLoaded', async () => {
-    store.state = 1;
-    // Load scripts after being handled by listeners in web page
-    await 0;
-    bridge.load();
-  }, { once: true });
+  bridge.load = new Promise(resolve => {
+    // waiting for the page handlers to run first
+    bridge.loadResolve = async () => await 1 && resolve(1);
+    document.addEventListener('DOMContentLoaded', bridge.loadResolve, { once: true });
+  });
   return invokeGuest;
 }
 
@@ -52,8 +55,37 @@ bridge.addHandlers({
   Callback({ callbackId, payload }) {
     bridge.callbacks[callbackId]?.(payload);
   },
+  ScriptData({ info, items }) {
+    if (info) {
+      bridge.isFirefox = info.isFirefox;
+      bridge.ua = info.ua;
+      store.cache = info.cache;
+    }
+    if (items) {
+      items::forEach(createScriptData);
+    }
+  },
 });
 
+function createScriptData(item) {
+  store.values[item.props.id] = item.values;
+  defineProperty(window, item.dataKey, {
+    configurable: true,
+    get() {
+      // deleting now to prevent interception via DOMNodeRemoved on el::remove()
+      delete window[item.dataKey];
+      if (process.env.DEBUG) {
+        log('info', [bridge.mode], item.custom.name || item.meta.name || item.props.id);
+      }
+      const el = document::getCurrentScript();
+      if (el) el::remove();
+      return item.action === 'wait'
+        ? (async () => await bridge.load && wrapGM(item))()
+        : wrapGM(item);
+    },
+  });
+}
+
 function exposeVM() {
   const Violentmonkey = {};
   defineProperty(Violentmonkey, 'version', {

+ 0 - 110
src/injected/web/load-scripts.js

@@ -1,110 +0,0 @@
-import { getUniqId } from '#/common';
-import { INJECT_CONTENT } from '#/common/consts';
-import {
-  filter, map, defineProperty, describeProperty, Boolean, Promise, setTimeout, log, noop,
-  remove,
-} from '../utils/helpers';
-import bridge from './bridge';
-import store from './store';
-import { deletePropsCache, wrapGM } from './gm-wrapper';
-
-const { concat } = Array.prototype;
-const { document } = global;
-const { get: getCurrentScript } = describeProperty(Document.prototype, 'currentScript');
-
-bridge.addHandlers({
-  LoadScripts(data) {
-    if (data.mode !== bridge.mode) return;
-    const start = [];
-    const idle = [];
-    const end = [];
-    bridge.isFirefox = data.isFirefox;
-    bridge.ua = data.ua;
-    // reset load and checkLoad
-    bridge.load = () => {
-      bridge.load = noop;
-      run(end);
-      setTimeout(runIdle);
-    };
-    // Firefox doesn't display errors in content scripts https://bugzil.la/1410932
-    const isFirefoxContentMode = bridge.isFirefox && bridge.mode === INJECT_CONTENT;
-    const listMap = {
-      'document-start': start,
-      'document-idle': idle,
-      'document-end': end,
-    };
-    if (data.items) {
-      data.items.forEach((item) => {
-        const { script } = item;
-        const runAt = script.custom.runAt || script.meta.runAt;
-        const list = listMap[runAt] || end;
-        list.push(item);
-        store.values[script.props.id] = data.values[script.props.id];
-      });
-      run(start);
-    }
-    if (!store.state && ['interactive', 'complete'].includes(document.readyState)) {
-      store.state = 1;
-    }
-    if (store.state) bridge.load();
-
-    function buildCode({ script, injectInto }) {
-      const pathMap = script.custom.pathMap || {};
-      const requireKeys = script.meta.require || [];
-      const requires = requireKeys::map(key => data.require[pathMap[key] || key])::filter(Boolean);
-      const requiresSlices = []::concat(...requires::map(req => [req, '\n;']));
-      const scriptId = script.props.id;
-      const code = data.code[scriptId] || '';
-      const thisObj = wrapGM(script, code, data.cache, injectInto);
-      const id = getUniqId('VMin');
-      const codeSlices = [
-        `(function(){${
-          isFirefoxContentMode
-            ? 'try{'
-            : ''
-        // hiding module interface from @require'd scripts so they don't mistakenly use it
-        }with(this)((define,module,exports)=>{`,
-        // 1. trying to avoid string concatenation of potentially huge code slices
-        // 2. adding `;` on a new line in case some required script ends with a line comment
-        ...requiresSlices,
-        // 3. adding a nested IIFE to support 'use strict' in the code when there are @requires
-        ...requiresSlices.length ? ['(()=>{'] : [],
-        code,
-        // adding a new line in case the code ends with a line comment
-        `\n${
-          requiresSlices.length ? '})()' : ''
-        }})()${
-          isFirefoxContentMode
-            ? '}catch(e){console.error(e)}'
-            : ''
-        }}).call(${id})`,
-      ];
-      defineProperty(window, id, {
-        configurable: true,
-        get() {
-          // deleting now to prevent interception via DOMNodeRemoved on el::remove()
-          delete window[id];
-          if (process.env.DEBUG) {
-            log('info', [bridge.mode], script.custom.name || script.meta.name || script.props.id);
-          }
-          const el = document::getCurrentScript();
-          if (el) el::remove();
-          return thisObj;
-        },
-      });
-      return [codeSlices, bridge.mode, scriptId, script.meta.name];
-    }
-    function run(list) {
-      bridge.post('InjectMulti', list::map(buildCode));
-      list.length = 0;
-    }
-    async function runIdle() {
-      for (const script of idle) {
-        bridge.post('Inject', buildCode(script));
-        await new Promise(setTimeout);
-      }
-      deletePropsCache();
-      idle.length = 0;
-    }
-  },
-});

+ 3 - 0
test/mock/polyfill.js

@@ -16,6 +16,9 @@ global.browser = {
       },
     },
   },
+  runtime: {
+    getURL: path => path,
+  },
 };
 
 const domProps = Object.getOwnPropertyDescriptors(new JSDOM('').window);