Browse Source

feat: `@unwrap` meta key (#1443)

+ fix @ in script names in FF
tophf 3 years ago
parent
commit
e6527dd1cf

+ 24 - 16
src/background/utils/preinject.js

@@ -1,4 +1,4 @@
-import { getScriptName, getUniqId } from '#/common';
+import { getScriptName, getUniqId, sendTabCmd, trueJoin } from '#/common';
 import {
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
   INJECTABLE_TAB_URL_RE, METABLOCK_RE,
@@ -35,6 +35,9 @@ const KEY_EXPOSE = 'expose';
 const KEY_DEF_INJECT_INTO = 'defaultInjectInto';
 const KEY_IS_APPLIED = 'isApplied';
 const KEY_XHR_INJECT = 'xhrInject';
+const BAD_URL_CHAR = IS_FIREFOX
+  ? /[#&',/:;?=+]/g // FF shows `@` fine as ASCII but mangles it as full-width
+  : /[#&',/:;?=+@]/g;
 const expose = {};
 let isApplied;
 let injectInto;
@@ -64,15 +67,14 @@ Object.assign(commands, {
 });
 
 /** @this {chrome.runtime.MessageSender} */
-function processFeedback([key, needsInjection]) {
+async function processFeedback([key, runAt, unwrappedId]) {
   const code = cacheCode.pop(key);
   // see TIME_KEEP_DATA comment
-  if (needsInjection && code) {
-    browser.tabs.executeScript(this.tab.id, {
-      code,
-      frameId: this.frameId,
-      runAt: 'document_start',
-    });
+  if (runAt && code) {
+    const { frameId, tab: { id: tabId } } = this;
+    runAt = `document_${runAt === 'body' ? 'start' : runAt}`;
+    browser.tabs.executeScript(tabId, { code, frameId, runAt });
+    if (unwrappedId) sendTabCmd(tabId, 'Run', unwrappedId, { frameId });
   }
 }
 
@@ -271,7 +273,7 @@ function prepareScript(script) {
   const code = this.code[id];
   const dataKey = getUniqId('VMin');
   const displayName = getScriptName(script);
-  const name = encodeURIComponent(displayName.replace(/[#&',/:;?@=+]/g, replaceWithFullWidthForm));
+  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);
@@ -279,21 +281,23 @@ function prepareScript(script) {
   // 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 wrap = !meta.unwrap;
   const injectedCode = [
     // hiding module interface from @require'd scripts so they don't mistakenly use it
-    `window.${dataKey}=function(${dataKey}){try{with(this)((define,module,exports)=>{`,
+    wrap && `window.${dataKey}=function(${dataKey}){try{with(this)((define,module,exports)=>{`,
     ...reqsSlices,
     // adding a nested IIFE to support 'use strict' in the code when there are @requires
-    hasReqs ? '(()=>{' : '',
+    hasReqs && wrap && '(()=>{',
     code,
     // adding a new line in case the code ends with a line comment
-    code.endsWith('\n') ? '' : '\n',
-    hasReqs ? '})()' : '',
+    !code.endsWith('\n') && '\n',
+    hasReqs && wrap && '})()',
+    wrap && `})()}catch(e){${dataKey}(e)}}`,
     // 0 at the end to suppress errors about non-cloneable result of executeScript in FF
-    `})()}catch(e){${dataKey}(e)}};0`,
+    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}`,
-  ].join('');
+  ]::trueJoin('');
   cacheCode.put(dataKey, injectedCode, TIME_KEEP_DATA);
   /** @namespace VMInjectedScript */
   Object.assign(script, {
@@ -304,7 +308,11 @@ function prepareScript(script) {
     metaStr: code.match(METABLOCK_RE)[1] || '',
     values: value[id] || null,
   });
-  return isContent && [dataKey, true];
+  return isContent && [
+    dataKey,
+    script.runAt,
+    !wrap && id, // unwrapped scripts need an explicit `Run` message
+  ];
 }
 
 function replaceWithFullWidthForm(s) {

+ 6 - 4
src/background/utils/script.js

@@ -32,6 +32,10 @@ const arrayType = {
     return res;
   },
 };
+const booleanType = {
+  default: () => false,
+  transform: () => true,
+};
 const defaultType = {
   default: () => null,
   transform: (res, val) => (res == null ? val : res),
@@ -51,15 +55,13 @@ const metaTypes = {
     },
   },
   grant: arrayType,
-  noframes: {
-    default: () => false,
-    transform: () => true,
-  },
 };
 const metaOptionalTypes = {
   antifeature: arrayType,
   compatible: arrayType,
   connect: arrayType,
+  noframes: booleanType,
+  unwrap: booleanType,
 };
 export function parseMeta(code) {
   // initialize meta

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

@@ -0,0 +1,39 @@
+import bridge from './bridge';
+import { sendCmd } from './util-content';
+import { INJECT_CONTENT } from '../util';
+
+const { runningIds } = bridge;
+const resolvedPromise = promiseResolve();
+let badgePromise;
+let numBadgesSent = 0;
+let bfCacheWired;
+
+export function Run(id, realm) {
+  runningIds::push(id);
+  bridge.ids::push(id);
+  if (realm === INJECT_CONTENT) {
+    bridge.invokableIds::push(id);
+  }
+  if (!badgePromise) {
+    badgePromise = resolvedPromise::then(throttledSetBadge);
+  }
+  if (!bfCacheWired) {
+    bfCacheWired = true;
+    window::on('pageshow', evt => {
+      // isTrusted is `unforgeable` per DOM spec so we don't need to safeguard its getter
+      if (evt.isTrusted && evt.persisted) {
+        sendCmd('SetBadge', runningIds);
+      }
+    });
+  }
+}
+
+function throttledSetBadge() {
+  const num = runningIds.length;
+  if (numBadgesSent < num) {
+    numBadgesSent = num;
+    return sendCmd('SetBadge', runningIds)::then(() => {
+      badgePromise = throttledSetBadge();
+    });
+  }
+}

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

@@ -7,12 +7,9 @@ import './requests';
 import './tabs';
 import { sendCmd } from './util-content';
 import { isEmpty, INJECT_CONTENT } from '../util';
+import { Run } from './cmd-run';
 
-const { invokableIds, runningIds } = bridge;
-const resolvedPromise = promiseResolve();
-let badgePromise;
-let numBadgesSent = 0;
-let bfCacheWired;
+const { invokableIds } = bridge;
 
 // Make sure to call obj::method() in code that may run after INJECT_CONTENT userscripts
 async function init() {
@@ -60,6 +57,7 @@ bridge.addBackgroundHandlers({
     const realm = invokableIds::includes(data.id) && INJECT_CONTENT;
     bridge.post('Command', data, realm);
   },
+  Run: id => Run(id, INJECT_CONTENT),
   UpdatedValues(data) {
     const dataPage = createNullObj();
     const dataContent = createNullObj();
@@ -72,25 +70,7 @@ bridge.addBackgroundHandlers({
 });
 
 bridge.addHandlers({
-  Run(id, realm) {
-    runningIds::push(id);
-    bridge.ids::push(id);
-    if (realm === INJECT_CONTENT) {
-      invokableIds::push(id);
-    }
-    if (!badgePromise) {
-      badgePromise = resolvedPromise::then(throttledSetBadge);
-    }
-    if (!bfCacheWired) {
-      bfCacheWired = true;
-      window::on('pageshow', evt => {
-        // isTrusted is `unforgeable` per DOM spec so we don't need to safeguard its getter
-        if (evt.isTrusted && evt.persisted) {
-          sendCmd('SetBadge', runningIds);
-        }
-      });
-    }
-  },
+  Run,
   SetTimeout: true,
   TabFocus: true,
   UpdateValue: true,
@@ -98,16 +78,6 @@ bridge.addHandlers({
 
 init().catch(IS_FIREFOX && console.error); // Firefox can't show exceptions in content scripts
 
-function throttledSetBadge() {
-  const num = runningIds.length;
-  if (numBadgesSent < num) {
-    numBadgesSent = num;
-    return sendCmd('SetBadge', runningIds)::then(() => {
-      badgePromise = throttledSetBadge();
-    });
-  }
-}
-
 async function getDataFF(viaMessaging) {
   // In Firefox we set data on global `this` which is not equal to `window`
   const data = global.vmData || await SafePromise.race([

+ 11 - 2
src/injected/content/inject.js

@@ -4,6 +4,7 @@ import {
   bindEvents, fireBridgeEvent,
   INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
 } from '../util';
+import { Run } from './cmd-run';
 
 /* In FF, content scripts running in a same-origin frame cannot directly call parent's functions
  * so we'll use the extension's UUID, which is unique per computer in FF, for messages
@@ -156,18 +157,23 @@ export async function injectScripts(contentId, webId, data, isXml) {
     const realm = INJECT_MAPPING[script.injectInto].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[script.runAt].push(script); // 'start' or 'body' per getScriptsByURL()
+      realmData.lists[runAt].push(script); // 'start' or 'body' per getScriptsByURL()
       realmData.is = true;
       if (pathMap) bridge.pathMaps[id] = pathMap;
       bridge.allowScript(script);
     } else {
       bridge.failedIds.push(id);
     }
-    return [script.dataKey, realm === INJECT_CONTENT];
+    return [
+      script.dataKey,
+      realm === INJECT_CONTENT && runAt,
+      script.meta.unwrap && id,
+    ];
   });
   const moreData = sendCmd('InjectionFeedback', {
     feedback,
@@ -313,6 +319,9 @@ async function injectList(runAt) {
       if (runAt === 'end') await 0;
       inject(item);
       item.code = '';
+      if (item.meta?.unwrap) {
+        Run(item.props.id);
+      }
     }
   }
 }

+ 0 - 1
src/injected/web/gm-api-wrapper.js

@@ -120,7 +120,6 @@ function makeGmInfo(script, resources) {
     val[i] = { name, url: resources[name] };
   });
   setOwnProp(metaCopy, 'resources', val);
-  setOwnProp(metaCopy, 'unwrap', false); // deprecated, always `false`
   return {
     // No __proto__:null because it's a standard object for userscripts
     uuid: script.props.uuid,

+ 1 - 1
src/injected/web/index.js

@@ -100,7 +100,7 @@ function createScriptData(item) {
   store.values[item.props.id] = item.values || createNullObj();
   if (window[dataKey]) { // executeScript ran before GetInjected response
     onCodeSet(item, window[dataKey]);
-  } else {
+  } else if (!item.meta.unwrap) {
     safeDefineProperty(window, dataKey, {
       configurable: true,
       set: fn => onCodeSet(item, fn),

+ 0 - 1
test/background/script.test.js

@@ -9,7 +9,6 @@ const baseMeta = {
   require: [],
   grant: [],
   resources: {},
-  noframes: false,
 };
 
 test('parseMeta', (t) => {