فهرست منبع

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

tophf 3 سال پیش
والد
کامیت
8f13b02c8c

+ 1 - 1
scripts/webpack.conf.js

@@ -81,7 +81,7 @@ const defsObj = {
   'process.env.HANDSHAKE_ID': HANDSHAKE_ID,
   'process.env.HANDSHAKE_ACK': '1',
   'process.env.CODEMIRROR_THEMES': JSON.stringify(getCodeMirrorThemes()),
-  'process.env.DEV': JSON.stringify(process.env.NODE_ENV === 'development'),
+  'process.env.DEV': JSON.stringify(!isProd),
 };
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts

+ 1 - 29
src/background/index.js

@@ -8,14 +8,11 @@ import { commands } from './utils';
 import { getData, getSizes, checkRemove } from './utils/db';
 import { initialize } from './utils/init';
 import { getOption, hookOptions } from './utils/options';
-import { popupTabs } from './utils/popup-tracker';
-import { getInjectedScripts } from './utils/preinject';
-import { resetValueOpener, addValueOpener } from './utils/values';
-import { clearRequestsByTabId } from './utils/requests';
 import './utils/clipboard';
 import './utils/hotkeys';
 import './utils/icon';
 import './utils/notifications';
+import './utils/preinject';
 import './utils/script';
 import './utils/tabs';
 import './utils/tab-redirector';
@@ -38,23 +35,6 @@ Object.assign(commands, {
   },
   GetSizes: getSizes,
   /** @return {Promise<Object>} */
-  async GetInjected({ url, forceContent }, src) {
-    const { frameId, tab } = src;
-    const tabId = tab.id;
-    if (!url) url = src.url || tab.url;
-    clearFrameData(tabId, frameId);
-    const res = await getInjectedScripts(url, tabId, frameId, forceContent);
-    const { feedback, inject } = res;
-    inject.isPopupShown = popupTabs[tabId];
-    // Injecting known content scripts without waiting for InjectionFeedback message.
-    // Running in a separate task because it may take a long time to serialize data.
-    if (feedback?.length) {
-      setTimeout(commands.InjectionFeedback, 0, { feedback }, src);
-    }
-    addValueOpener(tabId, frameId, inject.scripts);
-    return inject;
-  },
-  /** @return {Promise<Object>} */
   async GetTabDomain() {
     const tab = await getActiveTab() || {};
     const url = tab.pendingUrl || tab.url || '';
@@ -111,14 +91,6 @@ function autoUpdate() {
   autoUpdate.timer = setTimeout(autoUpdate, Math.min(TIMEOUT_MAX, interval - elapsed));
 }
 
-function clearFrameData(tabId, frameId) {
-  clearRequestsByTabId(tabId, frameId);
-  resetValueOpener(tabId, frameId);
-}
-
-browser.tabs.onRemoved.addListener((id /* , info */) => clearFrameData(id));
-browser.tabs.onReplaced.addListener((addedId, removedId) => clearFrameData(removedId));
-
 initialize(() => {
   global.handleCommandMessage = handleCommandMessage;
   global.deepCopy = deepCopy;

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

@@ -2,7 +2,7 @@ import {
   compareVersion, dataUri2text, i18n, getScriptHome, isDataUri, makeDataUri,
   getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
 } from '@/common';
-import { INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
+import { ICON_PREFIX, INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
 import { forEachEntry, forEachKey, forEachValue } from '@/common/object';
 import storage from '@/common/storage';
 import pluginEvents from '../plugin/events';
@@ -137,9 +137,12 @@ preInitialize.push(async () => {
       scripts.push(script);
       // listing all known resource urls in order to remove unused mod keys
       const {
-        custom: { pathMap = {} } = {},
+        custom,
         meta = script.meta = {},
       } = script;
+      const {
+        pathMap = custom.pathMap = {},
+      } = custom;
       const {
         require = meta.require = [],
         resources = meta.resources = {},
@@ -388,14 +391,21 @@ export async function getData(ids) {
  * @param {VMScript[]} scripts
  * @return {Promise<{}>}
  */
-function getIconCache(scripts) {
-  return storage.cache.getMulti(
-    scripts.reduce((res, { custom, meta: { icon } }) => {
-      if (isRemote(icon)) res.push(custom.pathMap?.[icon] || icon);
-      return res;
-    }, []),
-    makeDataUri,
-  );
+async function getIconCache(scripts) {
+  const urls = [];
+  for (const { custom, meta: { icon } } of scripts) {
+    if (isRemote(icon)) {
+      urls.push(custom.pathMap[icon] || icon);
+    }
+  }
+  // Getting a data uri for own icon to load it instantly in Chrome when there are many images
+  const ownPath = `${ICON_PREFIX}38.png`;
+  const [res, ownUri] = await Promise.all([
+    storage.cache.getMulti(urls, makeDataUri),
+    commands.GetImageData(ownPath),
+  ]);
+  res[ownPath] = ownUri;
+  return res;
 }
 
 export async function getSizes(ids) {

+ 20 - 24
src/background/utils/icon.js

@@ -1,24 +1,20 @@
 import { i18n, noop } from '@/common';
-import { INJECTABLE_TAB_URL_RE } from '@/common/consts';
+import { ICON_PREFIX, INJECTABLE_TAB_URL_RE } from '@/common/consts';
 import { objectPick } from '@/common/object';
-import cache from './cache';
 import { postInitialize } from './init';
 import { commands, forEachTab } from './message';
 import { getOption, hookOptions } from './options';
 import { testBlacklist } from './tester';
 
-// storing in `cache` only for the duration of page load in case there are 2+ identical urls
-const CACHE_DURATION = 1000;
-
 Object.assign(commands, {
-  async GetImageData(url) {
-    const key = `GetImageData:${url}`;
-    return cache.get(key)
-      || cache.put(key, loadImageData(url, { base64: true }).catch(noop), CACHE_DURATION);
-  },
+  GetImageData: async path => (await getOwnIcon(path)).uri,
   SetBadge: setBadge,
 });
 
+/** Caching own icon to improve dashboard loading speed, as well as browserAction API
+ * (e.g. Chrome wastes 40ms in our extension's process to read 4 icons for every tab). */
+const iconCache = {};
+
 // Firefox Android does not support such APIs, use noop
 
 const browserAction = (() => {
@@ -61,10 +57,6 @@ let titleBlacklisted;
 /** @type string */
 let titleNoninjectable;
 
-// We'll cache the icon data in Chrome as it doesn't cache the data and takes up to 40ms
-// in our background page context to set the 4 icon sizes for each new tab opened
-const iconCache = !IS_FIREFOX && {};
-
 hookOptions((changes) => {
   let v;
   const jobs = [];
@@ -176,21 +168,22 @@ async function setIcon(tab = {}, data = {}) {
   const mod = data.blocked && 'b' || !isApplied && 'w' || '';
   const iconData = {};
   for (const n of [16, 19, 32, 38]) {
-    const path = `/public/images/icon${n}${mod}.png`;
-    let icon = iconCache ? iconCache[path] : path;
-    if (!icon) {
-      icon = await loadImageData(path);
-      iconCache[path] = icon;
-    }
-    iconData[n] = icon;
+    const path = `${ICON_PREFIX}${n}${mod}.png`;
+    const icon = getOwnIcon(path);
+    iconData[n] = (icon.then ? await icon : icon).img;
   }
   browserAction.setIcon({
     tabId: tab.id,
-    [iconCache ? 'imageData' : 'path']: iconData,
+    imageData: iconData,
   });
 }
 
-function loadImageData(path, { base64 } = {}) {
+function getOwnIcon(path) {
+  const icon = iconCache[path] || (iconCache[path] = loadImageData(path));
+  return icon;
+}
+
+function loadImageData(path) {
   return new Promise((resolve, reject) => {
     const img = new Image();
     img.src = path;
@@ -205,7 +198,10 @@ function loadImageData(path, { base64 } = {}) {
       canvas.width = width;
       canvas.height = height;
       ctx.drawImage(img, 0, 0, width, height);
-      resolve(base64 ? canvas.toDataURL() : ctx.getImageData(0, 0, width, height));
+      resolve(iconCache[path] = {
+        uri: canvas.toDataURL(),
+        img: ctx.getImageData(0, 0, width, height),
+      });
     };
     img.onerror = reject;
   });

+ 105 - 57
src/background/utils/preinject.js

@@ -1,7 +1,7 @@
 import { getScriptName, getUniqId, sendTabCmd, trueJoin } from '@/common';
 import {
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
-  INJECTABLE_TAB_URL_RE, METABLOCK_RE,
+  METABLOCK_RE,
 } from '@/common/consts';
 import initCache from '@/common/cache';
 import { forEachEntry, objectPick, objectSet } from '@/common/object';
@@ -11,24 +11,32 @@ import { getScriptsByURL, ENV_CACHE_KEYS, ENV_REQ_KEYS, ENV_SCRIPTS, ENV_VALUE_I
 import { extensionRoot, postInitialize } from './init';
 import { commands } from './message';
 import { getOption, hookOptions } from './options';
-import { onStorageChanged } from './storage-cache';
-import { addValueOpener } from './values';
+import { popupTabs } from './popup-tracker';
+import { clearRequestsByTabId } from './requests';
+import { clearStorageCache, onStorageChanged } from './storage-cache';
+import { addValueOpener, clearValueOpener } from './values';
 
 const API_CONFIG = {
   urls: ['*://*/*'], // `*` scheme matches only http and https
   types: ['main_frame', 'sub_frame'],
 };
-const TIME_AFTER_SEND = 10e3; // longer as establishing connection to sites may take time
-const TIME_KEEP_DATA = 60e3; // 100ms should be enough but the tab may hang or get paused in debugger
-const cacheCode = initCache({ lifetime: TIME_KEEP_DATA });
+const CSAPI_REG = 'csar';
+const contentScriptsAPI = browser.contentScripts;
+/** 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,
-  onDispose: async promise => {
-    const data = await promise;
-    const rcs = await data?.rcsPromise;
-    rcs?.unregister();
-  },
+  onDispose: contentScriptsAPI && (async val => {
+    if (val && typeof val === 'object') {
+      const reg = (CSAPI_REG in val ? val : await val)[CSAPI_REG];
+      if (reg) (await reg).unregister();
+    }
+  }),
 });
+const FEEDBACK = 'feedback';
+const HEADERS = 'headers';
+const INJECT = 'inject';
 const FORCE_CONTENT = 'forceContent';
 const INJECT_INTO = 'injectInto';
 // KEY_XXX for hooked options
@@ -42,6 +50,32 @@ const expose = {};
 let isApplied;
 let injectInto;
 let xhrInject;
+
+Object.assign(commands, {
+  /** @return {Promise<VMGetInjectedData>} */
+  async GetInjected({ url, forceContent }, 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.pop(key) || prepare(key, url, tabId, frameId, forceContent);
+    /** @type VMGetInjectedDataContainer */
+    const data = cacheVal[INJECT] ? cacheVal : await cacheVal;
+    const inject = data[INJECT];
+    const feedback = data[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);
+    }
+    addValueOpener(tabId, frameId, inject[ENV_SCRIPTS]);
+    inject.isPopupShown = popupTabs[tabId];
+    return inject;
+  },
+  InjectionFeedback: injectionFeedback,
+});
+
 hookOptions(onOptionChanged);
 postInitialize.push(() => {
   for (const key of [KEY_EXPOSE, KEY_DEF_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
@@ -49,27 +83,29 @@ postInitialize.push(() => {
   }
 });
 
-Object.assign(commands, {
-  async InjectionFeedback({ feedId, feedback, [FORCE_CONTENT]: forceContent }, src) {
-    feedback.forEach(processFeedback, src);
-    if (feedId) {
-      // cache cleanup when getDataFF outruns GetInjected
-      cache.del(feedId.cacheKey);
-      // envDelayed
-      const env = await cache.pop(feedId.envKey);
-      if (env) {
-        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]);
-      }
+async function injectionFeedback({
+  feedId,
+  [FEEDBACK]: feedback,
+  [FORCE_CONTENT]: forceContent,
+}, src) {
+  feedback.forEach(processFeedback, src);
+  if (feedId) {
+    // cache cleanup when getDataFF outruns GetInjected
+    cache.del(feedId.cacheKey);
+    // envDelayed
+    const env = await cache.pop(feedId.envKey);
+    if (env) {
+      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 = cacheCode.pop(key);
+  const code = cache.pop(key);
   // see TIME_KEEP_DATA comment
   if (runAt && code) {
     const { frameId, tab: { id: tabId } } = this;
@@ -88,8 +124,10 @@ const propsToClear = {
 };
 
 onStorageChanged(async ({ keys: dbKeys }) => {
-  const cacheValues = await Promise.all(cache.getValues());
-  const dirty = cacheValues.some(data => data.inject
+  const raw = cache.getValues();
+  const resolved = !raw.some(val => val?.then);
+  const cacheValues = resolved ? raw : await Promise.all(raw);
+  const dirty = cacheValues.some(data => data[INJECT]
     && dbKeys.some((key) => {
       const prefix = key.slice(0, key.indexOf(':') + 1);
       const prop = propsToClear[prefix];
@@ -135,11 +173,7 @@ function onOptionChanged(changes) {
   });
 }
 
-/** @return {Promise<VMGetInjectedDataContainer>} */
-export function getInjectedScripts(url, tabId, frameId, forceContent) {
-  const key = getKey(url, !frameId);
-  return cache.pop(key) || prepare(key, url, tabId, frameId, forceContent);
-}
+/** @typedef {Promise<VMGetInjectedDataContainer>|VMGetInjectedDataContainer} VMGetInjected */
 
 function getKey(url, isTop) {
   return isTop ? url : `-${url}`;
@@ -155,7 +189,13 @@ function togglePreinject(enable) {
   if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
     browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
   }
-  cache.destroy();
+  browser.tabs.onRemoved[onOff](onTabRemoved);
+  browser.tabs.onReplaced[onOff](onTabReplaced);
+  if (!enable) {
+    cache.destroy();
+    clearFrameData();
+    clearStorageCache();
+  }
 }
 
 function toggleXhrInject(enable) {
@@ -171,14 +211,13 @@ function toggleXhrInject(enable) {
 }
 
 function onSendHeaders({ url, tabId, frameId }) {
-  if (!INJECTABLE_TAB_URL_RE.test(url)) return;
   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_AFTER_SEND);
+    cache.put(key, prepare(key, url, tabId, frameId), TIME_KEEP_DATA);
   }
 }
 
@@ -187,7 +226,7 @@ function onHeadersReceived(info) {
   const key = getKey(info.url, !info.frameId);
   const data = xhrInject && cache.get(key);
   // Proceeding only if prepareScripts has replaced promise in cache with the actual data
-  return data?.inject && prepareXhrBlob(info, data);
+  return data?.[INJECT] && prepareXhrBlob(info, data);
 }
 
 /**
@@ -199,14 +238,14 @@ function prepareXhrBlob({ url, responseHeaders }, data) {
     forceContentInjection(data);
   }
   const blobUrl = URL.createObjectURL(new Blob([
-    JSON.stringify(data.inject),
+    JSON.stringify(data[INJECT]),
   ]));
   responseHeaders.push({
     name: 'Set-Cookie',
     value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
   });
   setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
-  data.headers = true;
+  data[HEADERS] = true;
   return { responseHeaders };
 }
 
@@ -214,7 +253,7 @@ function prepare(key, url, tabId, frameId, forceContent) {
   /** @namespace VMGetInjectedDataContainer */
   const res = {
     /** @namespace VMGetInjectedData */
-    inject: {
+    [INJECT]: {
       expose: !frameId
         && url.startsWith('https://')
         && expose[url.split('/', 3)[2]],
@@ -227,16 +266,16 @@ function prepare(key, url, tabId, frameId, forceContent) {
 
 async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
   const data = getScriptsByURL(url, !frameId);
-  const { envDelayed, scripts } = Object.assign(data, await data.promise);
+  const { envDelayed, [ENV_SCRIPTS]: scripts } = Object.assign(data, await data.promise);
   const isLate = forceContent != null;
   data[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
   const feedback = scripts.map(prepareScript, data).filter(Boolean);
   const more = envDelayed.promise;
   const envKey = getUniqId(`${tabId}:${frameId}:`);
-  const { inject } = res;
+  const inject = res[INJECT];
   /** @namespace VMGetInjectedData */
   Object.assign(inject, {
-    scripts,
+    [ENV_SCRIPTS]: scripts,
     [INJECT_INTO]: injectInto,
     [INJECT_PAGE]: !forceContent && (
       scripts.some(isPageRealm, data)
@@ -253,13 +292,9 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
       ua,
     },
   });
-  /** @namespace VMGetInjectedDataContainer */
-  Object.assign(res, {
-    feedback,
-    rcsPromise: !isLate && !xhrInject && IS_FIREFOX
-      ? registerScriptDataFF(inject, url, !!frameId)
-      : null,
-  });
+  res[FEEDBACK] = feedback;
+  res[CSAPI_REG] = contentScriptsAPI && !isLate && !xhrInject
+    && registerScriptDataFF(inject, url, !!frameId);
   if (more) cache.put(envKey, more);
   if (!isLate && !cache.get(cacheKey)?.headers) {
     cache.put(cacheKey, res); // synchronous onHeadersReceived needs plain object not a Promise
@@ -308,7 +343,7 @@ function prepareScript(script) {
     // 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}`,
   ]::trueJoin('');
-  cacheCode.put(dataKey, injectedCode, TIME_KEEP_DATA);
+  cache.put(dataKey, injectedCode, TIME_KEEP_DATA);
   /** @namespace VMInjectedScript */
   Object.assign(script, {
     dataKey,
@@ -344,7 +379,7 @@ const resolveDataCodeStr = `(${function _(data) {
 
 // TODO: rework the whole thing to register scripts individually with real `matches`
 function registerScriptDataFF(inject, url, allFrames) {
-  return browser.contentScripts?.register({
+  return contentScriptsAPI.register({
     allFrames,
     js: [{
       code: `${resolveDataCodeStr}(${JSON.stringify(inject)})`,
@@ -370,12 +405,12 @@ function detectStrictCsp(responseHeaders) {
 /** @param {VMGetInjectedDataContainer} data */
 function forceContentInjection(data) {
   /** @type VMGetInjectedData */
-  const inject = data.inject;
+  const inject = data[INJECT];
   inject[FORCE_CONTENT] = true;
-  inject.scripts.forEach(scr => {
+  inject[ENV_SCRIPTS].forEach(scr => {
     // When script wants `page`, the result below will be `true` so the script goes into `failedIds`
     scr.code = !isContentRealm(scr, true) || '';
-    data.feedback.push([scr.dataKey, true]);
+    data[FEEDBACK].push([scr.dataKey, true]);
   });
 }
 
@@ -390,3 +425,16 @@ function isContentRealm(scr, forceContent) {
 function isPageRealm(scr) {
   return !isContentRealm(scr, this[FORCE_CONTENT]);
 }
+
+function onTabRemoved(id /* , info */) {
+  clearFrameData(id);
+}
+
+function onTabReplaced(addedId, removedId) {
+  clearFrameData(removedId);
+}
+
+function clearFrameData(tabId, frameId) {
+  clearRequestsByTabId(tabId, frameId);
+  clearValueOpener(tabId, frameId);
+}

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

@@ -224,7 +224,8 @@ function clearRequest({ id, coreId }) {
 
 export function clearRequestsByTabId(tabId, frameId) {
   requests::forEachValue(req => {
-    if (req.tabId === tabId && (!frameId || req.frameId === frameId)) {
+    if ((tabId == null || req.tabId === tabId)
+    && (!frameId || req.frameId === frameId)) {
       commands.AbortRequest(req.id);
     }
   });

+ 4 - 0
src/background/utils/storage-cache.js

@@ -30,6 +30,10 @@ const { hook, fire } = initHooks();
  * so if someone wants to edit the db in devtools they need to restart the background page.
 */
 export const onStorageChanged = hook;
+export const clearStorageCache = () => {
+  cache.destroy();
+  dbKeys.destroy();
+};
 
 storage.api = {
 

+ 5 - 2
src/background/utils/storage-fetch.js

@@ -55,9 +55,12 @@ export async function requestNewer(url, opts) {
     if (modOld || get) {
       const req = await request(url, !get ? { ...opts, method: 'HEAD' } : opts);
       const { headers } = req;
-      const mod = headers.get('etag')
+      // headers does not exist when requesting a local file
+      const mod = headers && (
+        headers.get('etag')
         || +new Date(headers.get('last-modified'))
-        || +new Date(headers.get('date'));
+        || +new Date(headers.get('date'))
+      );
       if (mod && mod === modOld) {
         return;
       }

+ 7 - 2
src/background/utils/values.js

@@ -41,7 +41,10 @@ Object.assign(commands, {
   },
 });
 
-export function resetValueOpener(tabId, frameId) {
+export function clearValueOpener(tabId, frameId) {
+  if (tabId == null) {
+    toSend = {};
+  }
   openers::forEachEntry(([id, tabs]) => {
     const frames = tabs[tabId];
     if (frames) {
@@ -52,7 +55,9 @@ export function resetValueOpener(tabId, frameId) {
         delete tabs[tabId];
       }
     }
-    if (isEmpty(tabs)) delete openers[id];
+    if (tabId == null || isEmpty(tabs)) {
+      delete openers[id];
+    }
   });
 }
 

+ 1 - 1
src/common/consts.js

@@ -19,7 +19,7 @@ export const INJECT_MAPPING = {
 // 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 METABLOCK_RE = /(?:^|\n)\s*\/\/\x20==UserScript==([\s\S]*?\n)\s*\/\/\x20==\/UserScript==|$/;
-
+export const ICON_PREFIX = '/public/images/icon';
 export const INJECTABLE_TAB_URL_RE = /^(https?|file|ftps?):/;
 
 // `browser` is a local variable since we remove the global `chrome` and `browser` in injected*

+ 12 - 2
src/common/index.js

@@ -1,13 +1,23 @@
 // SAFETY WARNING! Exports used by `injected` must make ::safe() calls and use __proto__:null
 
-import { browser } from '@/common/consts';
+import { browser, ICON_PREFIX } from '@/common/consts';
 import { deepCopy } from './object';
 import { blob2base64, i18n, isDataUri, noop } from './util';
 
 export { normalizeKeys } from './object';
 export * from './util';
 
-export const defaultImage = '/public/images/icon128.png';
+if (process.env.DEV && process.env.IS_INJECTED !== 'injected-web') {
+  const get = () => {
+    throw 'Do not use `for-of` with Map/Set. Use forEach or for-of with a [...copy]'
+    + '\n(not supported due to our config of @babel/plugin-transform-for-of).';
+  };
+  for (const obj of [Map, Set, WeakMap, WeakSet]) {
+    Object.defineProperty(obj.prototype, 'length', { get, configurable: true });
+  }
+}
+
+export const defaultImage = `${ICON_PREFIX}128.png`;
 
 export function initHooks() {
   const hooks = [];

+ 9 - 3
src/common/load-script-icon.js

@@ -1,18 +1,24 @@
+import { ICON_PREFIX } from '@/common/consts';
 import { isDataUri, sendCmdDirectly } from '@/common/index';
 
+// TODO: convert this into a component tag e.g. <safe-icon>
 const KEY = 'safeIcon';
 
 /**
  * Sets script's safeIcon property after the image is successfully loaded
  * @param {VMScript} script
  * @param {Object} [cache]
+ * @param {number} [defSize] - show default icon of this size, -1 = auto, falsy = no
  */
-export async function loadScriptIcon(script, cache = {}) {
+export async function loadScriptIcon(script, cache = {}, defSize) {
   const { icon } = script.meta;
-  const url = script.custom?.pathMap?.[icon] || icon;
+  const url = script.custom?.pathMap?.[icon] || icon
+    || defSize && `${ICON_PREFIX}${defSize > 0 || (script.config.removed ? 32 : 38)}.png`;
   if (!url || url !== script[KEY]) {
     // creates an observable property so Vue will see the change after `await`
-    script[KEY] = null;
+    if (!(KEY in script)) {
+      script[KEY] = null;
+    }
     if (url) {
       script[KEY] = cache[url]
         || isDataUri(url) && url

+ 3 - 0
src/common/storage.js

@@ -56,6 +56,9 @@ class StorageArea {
    * @return {Promise<Object>} same object
    */
   async set(data) {
+    if (process.env.DEV && (!data || typeof data !== 'object')) {
+      throw 'StorageArea.set: data is not an object';
+    }
     await api.set(this.prefix
       ? data::mapEntry(null, this.toKey, this)
       : data);

+ 9 - 10
src/options/index.js

@@ -43,11 +43,7 @@ async function initScript(script) {
   const name = script.custom.name || localeName;
   const lowerName = name.toLowerCase();
   script.$cache = { search, name, lowerName, size: '', sizes: '', sizeNum: 0 };
-  if (!await loadScriptIcon(script, store.cache)) {
-    script.safeIcon = `/public/images/icon${
-      store.HiDPI ? 128 : script.config.removed && 32 || 38
-    }.png`;
-  }
+  loadScriptIcon(script, store.cache, store.HiDPI || -1);
 }
 
 /**
@@ -86,13 +82,16 @@ export function loadData() {
 
 async function requestData(ids) {
   const getDataP = sendCmdDirectly('GetData', ids, { retry: true });
-  const getSizesP = sendCmdDirectly('GetSizes', ids, { retry: true });
   const [data] = await Promise.all([getDataP, options.ready]);
-  const { scripts } = data;
-  scripts.forEach(initScript);
-  getSizesP.then(sizes => sizes.forEach((sz, i) => initSize(sz, scripts[i])));
-  Object.assign(store, data);
+  const { scripts, ...auxData } = data;
+  Object.assign(store, auxData); // initScripts needs `cache` in store
+  scripts.forEach(initScript); // modifying scripts without triggering reactivity
+  store.scripts = scripts; // now we can render
   store.loading = false;
+  setTimeout(async () => { // sizing runs in the same thread, so we'll start it after render
+    (await sendCmdDirectly('GetSizes', ids, { retry: true }))
+    .forEach((sz, i) => initSize(sz, scripts[i]));
+  });
 }
 
 function initMain() {