Prechádzať zdrojové kódy

refactor: use Proxy for `browser`, embed it as module (#1345)

tophf 4 rokov pred
rodič
commit
bc8ce02524

+ 15 - 31
scripts/plaid.conf.js

@@ -6,37 +6,21 @@ const { isProd } = require('@gera2ld/plaid/util');
  * - value.html: options object passed to HtmlWebpackPlugin.
  * - value.html.inlineSource: if true, JS and CSS files will be inlined in HTML.
  */
-const injectTo = item => {
-  if (!(item.attributes.src || '').endsWith('/index.js')) return 'head';
-};
-const htmlFactory = extra => options => ({
-  ...options,
-  title: 'Violentmonkey',
-  ...extra,
-  chunks: ['browser', ...options.chunks],
-  injectTo,
-});
-exports.pages = {
-  'browser': {
-    entry: './src/common/browser',
-  },
-  'background/index': {
-    entry: './src/background',
-    html: htmlFactory(),
-  },
-  'options/index': {
-    entry: './src/options',
-    html: htmlFactory(),
+exports.pages = [
+  'background',
+  'confirm',
+  'options',
+  'popup',
+].reduce((res, name) => Object.assign(res, {
+  [`${name}/index`]: {
+    entry: `./src/${name}`,
+    html: options => ({
+      ...options,
+      title: 'Violentmonkey',
+      injectTo: item => (item.attributes.src || '').endsWith('/index.js') ? 'body' : 'head',
+    }),
   },
-  'confirm/index': {
-    entry: './src/confirm',
-    html: htmlFactory(),
-  },
-  'popup/index': {
-    entry: './src/popup',
-    html: htmlFactory(),
-  },
-};
+}), {});
 
 const splitVendor = prefix => ({
   [prefix]: {
@@ -57,7 +41,7 @@ exports.optimization = {
         name: 'common',
         minChunks: 2,
         enforce: true,
-        chunks: chunk => chunk.name !== 'browser',
+        chunks: 'all',
       },
       ...splitVendor('codemirror'),
       ...splitVendor('tldjs'),

+ 11 - 28
scripts/webpack.conf.js

@@ -44,21 +44,18 @@ const minimizer = isProd && [
   }),
 ];
 
-const modify = (extra, init) => modifyWebpackConfig(
+const modify = (page, entry, init) => modifyWebpackConfig(
   (config) => {
     config.plugins.push(definitions);
+    if (!entry) init = page;
     if (init) init(config);
     return config;
   }, {
     projectConfig: {
       ...mergedConfig,
-      ...extra,
+      ...entry && { pages: { [page]: { entry }} },
       optimization: {
         ...mergedConfig.optimization,
-        ...(extra || {}).pages && {
-          runtimeChunk: false,
-          splitChunks: false,
-        },
         minimizer,
       },
     },
@@ -68,31 +65,17 @@ const modify = (extra, init) => modifyWebpackConfig(
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts
 const skipReinjectionHeader = `if (window['${INIT_FUNC_NAME}'] !== 1)`;
-const skipReinjectionConfig = (config, test) => config.plugins.push(
-  new WrapperWebpackPlugin({
-    header: skipReinjectionHeader,
-    ...test && { test },
-  }));
-
 module.exports = Promise.all([
-  modify(null, config => {
+  modify((config) => {
     config.output.publicPath = '/';
-    skipReinjectionConfig(config, /^browser\.js$/);
   }),
-  modify({
-    pages: {
-      injected: {
-        entry: './src/injected',
-      },
-    },
-  }, skipReinjectionConfig),
-  modify({
-    pages: {
-      'injected-web': {
-        entry: './src/injected/web',
-      },
-    },
-  }, (config) => {
+  modify('injected', './src/injected', (config) => {
+    config.plugins.push(
+      new WrapperWebpackPlugin({
+        header: skipReinjectionHeader,
+      }));
+  }),
+  modify('injected-web', './src/injected/web', (config) => {
     config.output.libraryTarget = 'commonjs2';
     config.plugins.push(
       new WrapperWebpackPlugin({

+ 1 - 0
src/background/index.js

@@ -1,3 +1,4 @@
+import '#/common/browser';
 import { getActiveTab, makePause, sendCmd } from '#/common';
 import { TIMEOUT_24HOURS, TIMEOUT_MAX } from '#/common/consts';
 import { deepCopy, forEachEntry, objectSet } from '#/common/object';

+ 101 - 96
src/common/browser.js

@@ -3,123 +3,127 @@
 // https://github.com/mozilla/webextension-polyfill/pull/153
 // https://html.spec.whatwg.org/multipage/window-object.html#named-access-on-the-window-object
 if (!global.browser?.runtime?.sendMessage) {
-  const { chrome, Promise } = global;
-  const wrapAPIs = (source, meta = {}) => {
-    return Object.entries(source)
-    .reduce((target, [key, value]) => {
-      const metaVal = meta[key];
-      if (metaVal) {
-        if (typeof metaVal === 'function') {
-          value = source::metaVal(value);
-        } else if (typeof metaVal === 'object' && typeof value === 'object') {
-          value = wrapAPIs(value, metaVal);
-        }
-        target[key] = value;
-      }
-      return target;
-    }, {});
+  // region Chrome
+  const { chrome, Error, Promise, Proxy } = global;
+  const { bind } = Proxy;
+  /** onXXX like onMessage */
+  const isApiEvent = key => key[0] === 'o' && key[1] === 'n';
+  /** API types or enums or literal constants */
+  const isFunction = val => typeof val === 'function';
+  const isObject = val => typeof val === 'object';
+  const proxifyValue = (target, key, groupName, src, metaVal) => {
+    const srcVal = src[key];
+    if (srcVal === undefined) return;
+    let res;
+    if (isFunction(metaVal)) {
+      res = metaVal(src, srcVal);
+    } else if (isFunction(srcVal)) {
+      res = metaVal === 0 || isApiEvent(groupName)
+        ? srcVal::bind(src)
+        : wrapAsync(src, srcVal); // eslint-disable-line no-use-before-define
+    } else if (isObject(srcVal) && metaVal !== 0) {
+      res = proxifyGroup(key, srcVal, metaVal); // eslint-disable-line no-use-before-define
+    } else {
+      res = srcVal;
+    }
+    target[key] = res;
+    return res;
   };
-  const wrapAsync = function wrapAsync(func) {
-    return (...args) => {
-      const promise = new Promise((resolve, reject) => {
-        this::func(...args, (res) => {
-          const err = chrome.runtime.lastError;
-          if (err) reject(err);
-          else resolve(res);
-        });
+  const proxifyGroup = (groupName, src, meta) => new Proxy({}, {
+    get: (target, key) => (
+      target[key]
+      ?? proxifyValue(target, key, groupName, src, meta?.[key])
+    ),
+  });
+  const wrapAsync = (thisArg, func, preprocessorFunc) => (
+    (...args) => {
+      let resolve;
+      let reject;
+      /* Using resolve/reject to call API in the scope of this function, not inside Promise,
+         because an API validation exception is thrown synchronously both in Chrome and FF
+         so the caller can use try/catch to detect it like we've been doing in icon.js */
+      const promise = new Promise((_resolve, _reject) => {
+        resolve = _resolve;
+        reject = _reject;
+      });
+      // Make the error messages actually useful by capturing a real stack
+      const stackInfo = new Error();
+      // Using (...results) for API callbacks that return several results (we don't use them though)
+      thisArg::func(...args, (...results) => {
+        let err = chrome.runtime.lastError;
+        if (err) {
+          err = err.message;
+        } else if (preprocessorFunc) {
+          err = preprocessorFunc(resolve, ...results);
+        } else {
+          resolve(results[0]);
+        }
+        // Prefer `reject` over `throw` which stops debugger in 'pause on exceptions' mode
+        if (err) reject(new Error(`${err}\n${stackInfo.stack}`));
       });
       if (process.env.DEBUG) promise.catch(err => console.warn(args, err?.message || err));
       return promise;
-    };
+    }
+  );
+  const sendResponseAsync = async (result, sendResponse) => {
+    try {
+      result = await result;
+      if (process.env.DEBUG) console.info('send', result);
+      sendResponse({ data: result });
+    } catch (err) {
+      if (process.env.DEBUG) console.warn(err);
+      sendResponse({ error: err instanceof Error ? err.stack : err });
+    }
   };
-  const wrapMessageListener = listener => (message, sender, sendResponse) => {
+  const onMessageListener = (listener, message, sender, sendResponse) => {
     if (process.env.DEBUG) console.info('receive', message);
     const result = listener(message, sender);
-    if (typeof result?.then === 'function') {
-      result.then((data) => {
-        if (process.env.DEBUG) console.info('send', data);
-        sendResponse({ data });
-      }, (error) => {
-        if (process.env.DEBUG) console.warn(error);
-        sendResponse({ error: error instanceof Error ? error.stack : error });
-      })
-      .catch(() => {}); // Ignore sendResponse error
+    if (result && isFunction(result.then)) {
+      sendResponseAsync(result, sendResponse);
       return true;
     }
-    if (typeof result !== 'undefined') {
+    if (result !== undefined) {
       // In some browsers (e.g Chrome 56, Vivaldi), the listener in
       // popup pages are not properly cleared after closed.
       // They may send `undefined` before the real response is sent.
       sendResponse({ data: result });
     }
   };
-  const meta = {
-    browserAction: true,
-    commands: true,
-    cookies: {
-      getAll: wrapAsync,
-      getAllCookieStores: wrapAsync,
-      set: wrapAsync,
-    },
-    extension: true,
-    i18n: true,
-    notifications: {
-      onClicked: true,
-      onClosed: true,
-      clear: wrapAsync,
-      create: wrapAsync,
-    },
+  /** @returns {?} error */
+  const unwrapResponse = (resolve, response) => (
+    !response && 'null response'
+    || response.error
+    || resolve(response.data)
+  );
+  const wrapSendMessage = (runtime, sendMessage) => (
+    wrapAsync(runtime, sendMessage, unwrapResponse)
+  );
+  /**
+   * 0 = non-async method or the entire group
+   * function = transformer like (originalObj, originalFunc): function
+   */
+  global.browser = proxifyGroup('', chrome, {
+    extension: 0, // we don't use its async methods
+    i18n: 0, // we don't use its async methods
     runtime: {
-      connect: true,
-      getManifest: true,
-      getPlatformInfo: wrapAsync,
-      getURL: true,
-      openOptionsPage: wrapAsync,
-      onConnect: true,
-      onMessage: onMessage => ({
-        addListener: listener => onMessage.addListener(wrapMessageListener(listener)),
-      }),
-      sendMessage(sendMessage) {
-        const promisifiedSendMessage = wrapAsync(sendMessage);
-        const unwrapResponse = ({ data: response, error } = {}) => {
-          if (error) throw error;
-          return response;
-        };
-        return data => promisifiedSendMessage(data).then(unwrapResponse);
+      connect: 0,
+      getManifest: 0,
+      getURL: 0,
+      onMessage: {
+        addListener: (onMessage, addListener) => (
+          listener => onMessage::addListener(onMessageListener::bind(null, listener))
+        ),
       },
-    },
-    storage: {
-      local: {
-        get: wrapAsync,
-        set: wrapAsync,
-        remove: wrapAsync,
-      },
-      onChanged: true,
+      sendMessage: wrapSendMessage,
     },
     tabs: {
-      onCreated: true,
-      onUpdated: true,
-      onRemoved: true,
-      onReplaced: true,
-      create: wrapAsync,
-      get: wrapAsync,
-      getCurrent: wrapAsync,
-      query: wrapAsync,
-      reload: wrapAsync,
-      remove: wrapAsync,
-      sendMessage: wrapAsync,
-      update: wrapAsync,
-      executeScript: wrapAsync,
-    },
-    webRequest: true,
-    windows: {
-      create: wrapAsync,
-      getCurrent: wrapAsync,
-      update: wrapAsync,
+      connect: 0,
+      sendMessage: wrapSendMessage,
     },
-  };
-  global.browser = wrapAPIs(chrome, meta);
+  });
+  // endregion
 } else if (process.env.DEBUG && !global.chrome.app) {
+  // region Firefox
   let counter = 0;
   const { runtime } = global.browser;
   const { sendMessage, onMessage } = runtime;
@@ -148,4 +152,5 @@ if (!global.browser?.runtime?.sendMessage) {
     .then(data => log('on', [data], id, true), console.warn);
     return result;
   });
+  // endregion
 }

+ 0 - 1
src/common/handlers.js

@@ -1,4 +1,3 @@
-import { browser } from '#/common/consts';
 import options from './options';
 
 const handlers = {

+ 0 - 1
src/common/storage.js

@@ -1,4 +1,3 @@
-import { browser } from './consts';
 import { blob2base64, ensureArray } from './util';
 
 const base = {

+ 0 - 2
src/common/ua.js

@@ -1,5 +1,3 @@
-import { browser } from './consts';
-
 // UA can be overridden by about:config in FF or devtools in Chrome
 // so we'll test for window.chrome.app which is only defined in Chrome
 // and for browser.runtime.getBrowserInfo in Firefox 51+

+ 1 - 0
src/confirm/index.js

@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import '#/common/browser';
 import { i18n } from '#/common';
 import '#/common/handlers';
 import options from '#/common/options';

+ 1 - 0
src/injected/index.js

@@ -1,3 +1,4 @@
+import '#/common/browser';
 import { sendCmd } from '#/common';
 import './content';
 

+ 0 - 1
src/manifest.yml

@@ -26,7 +26,6 @@ options_ui:
   open_in_tab: true
 content_scripts:
   - js:
-      - browser.js
       - injected-web.js
       - injected.js
     matches:

+ 1 - 0
src/options/index.js

@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import '#/common/browser';
 import { sendCmdDirectly, i18n, getLocaleString } from '#/common';
 import handlers from '#/common/handlers';
 import { loadScriptIcon } from '#/common/load-script-icon';

+ 1 - 0
src/popup/index.js

@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import '#/common/browser';
 import { i18n, sendCmdDirectly } from '#/common';
 import { INJECT_PAGE } from '#/common/consts';
 import handlers from '#/common/handlers';