浏览代码

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

# Conflicts:
#	src/common/ui/externals.vue
tophf 3 年之前
父节点
当前提交
ebf8ba8dc6

+ 1 - 0
.babelrc.js

@@ -11,5 +11,6 @@ module.exports = {
   ],
   plugins: [
     './scripts/babel-plugin-safe-bind.js',
+    ['@babel/plugin-transform-for-of', { assumeArray: true }],
   ],
 };

+ 1 - 0
scripts/webpack.conf.js

@@ -81,6 +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'),
 };
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts

+ 1 - 1
src/_locales/id/messages.yml

@@ -214,7 +214,7 @@ filterScopeName:
   message: Nama
 filterSize:
   description: Label for option to sort scripts by size.
-  message: ''
+  message: ukuran
 genericError:
   description: Label for generic error.
   message: Galat

+ 11 - 8
src/background/index.js

@@ -42,21 +42,16 @@ Object.assign(commands, {
     const { frameId, tab } = src;
     const tabId = tab.id;
     if (!url) url = src.url || tab.url;
-    if (!frameId) {
-      resetValueOpener(tabId);
-      clearRequestsByTabId(tabId);
-    }
+    clearFrameData(tabId, frameId);
     const res = await getInjectedScripts(url, tabId, frameId, forceContent);
-    const { feedback, inject, valOpIds } = res;
+    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);
     }
-    if (valOpIds) {
-      addValueOpener(tabId, frameId, valOpIds);
-    }
+    addValueOpener(tabId, frameId, inject.scripts);
     return inject;
   },
   /** @return {Promise<Object>} */
@@ -116,6 +111,14 @@ 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;

+ 66 - 103
src/background/utils/db.js

@@ -1,5 +1,5 @@
 import {
-  compareVersion, dataUri2text, i18n, getScriptHome,
+  compareVersion, dataUri2text, i18n, getScriptHome, makeDataUri,
   getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
 } from '@/common';
 import { INJECT_PAGE, INJECT_AUTO, TIMEOUT_WEEK } from '@/common/consts';
@@ -12,10 +12,8 @@ import { preInitialize } from './init';
 import { commands } from './message';
 import patchDB from './patch-db';
 import { setOption } from './options';
-import './storage-fetch';
-import dataCache from './cache';
 
-const store = {
+export const store = {
   /** @type VMScript[] */
   scripts: [],
   /** @type Object<string,VMScript[]> */
@@ -25,10 +23,6 @@ const store = {
     position: 0,
   },
 };
-storage.base.setDataCache(dataCache);
-storage.script.onDump = (item) => {
-  store.scriptMap[item.props.id] = item;
-};
 
 Object.assign(commands, {
   CheckPosition: sortScripts,
@@ -75,10 +69,10 @@ Object.assign(commands, {
     const i = store.scripts.indexOf(getScriptById(id));
     if (i >= 0) {
       store.scripts.splice(i, 1);
-      await Promise.all([
-        storage.script.remove(id),
-        storage.code.remove(id),
-        storage.value.remove(id),
+      await storage.base.remove([
+        storage.script.toKey(id),
+        storage.code.toKey(id),
+        storage.value.toKey(id),
       ]);
     }
     return sendCmd('RemoveScript', id);
@@ -98,33 +92,25 @@ Object.assign(commands, {
 });
 
 preInitialize.push(async () => {
-  const { version: lastVersion } = await browser.storage.local.get('version');
+  const lastVersion = await storage.base.getOne('version');
   const version = process.env.VM_VER;
   if (!lastVersion) await patchDB();
-  if (version !== lastVersion) browser.storage.local.set({ version });
-  const data = await browser.storage.local.get();
-  const { scripts, storeInfo, scriptMap: idMap } = store;
+  if (version !== lastVersion) storage.base.set({ version });
+  const data = await storage.base.getMulti();
+  const { scripts, storeInfo, scriptMap } = store;
   const uriMap = {};
   const mods = [];
   const resUrls = new Set();
   /** @this VMScriptCustom.pathMap */
   const rememberUrl = function _(url) { resUrls.add(this[url] || url); };
   data::forEachEntry(([key, script]) => {
-    dataCache.put(key, script);
-    if (key.startsWith(storage.script.prefix)) {
-      // {
-      //   meta,
-      //   custom,
-      //   props: { id, position, uri },
-      //   config: { enabled, shouldUpdate },
-      // }
-      const id = getInt(key.slice(storage.script.prefix.length));
-      if (!id || idMap[id]) {
+    let id = +storage.script.toId(key);
+    if (id) {
+      if (scriptMap[id] && scriptMap[id] !== script) {
         // ID conflicts!
         // Should not happen, discard duplicates.
         return;
       }
-      idMap[id] = script;
       const uri = getNameURI(script);
       if (uriMap[uri]) {
         // Namespace conflicts!
@@ -158,11 +144,11 @@ preInitialize.push(async () => {
       resources::forEachValue(rememberUrl, pathMap);
       pathMap::rememberUrl(meta.icon);
       getScriptUpdateUrl(script, true)?.forEach(rememberUrl, pathMap);
-    } else if (key.startsWith(storage.mod.prefix)) {
-      mods.push(key.slice(storage.mod.prefix.length));
+    } else if ((id = storage.mod.toId(key))) {
+      mods.push(id);
     }
   });
-  storage.mod.removeMulti(mods.filter(url => !resUrls.has(url)));
+  storage.mod.remove(mods.filter(url => !resUrls.has(url)));
   // Switch defaultInjectInto from `page` to `auto` when upgrading VM2.12.7 or older
   if (version !== lastVersion
   && IS_FIREFOX
@@ -173,8 +159,8 @@ preInitialize.push(async () => {
   if (process.env.DEBUG) {
     console.log('store:', store); // eslint-disable-line no-console
   }
+  sortScripts();
   vacuum(data);
-  return sortScripts();
 });
 
 /** @return {number} */
@@ -192,20 +178,23 @@ function updateLastModified() {
   setOption('lastModified', Date.now());
 }
 
-/** @return {Promise<number>} */
+/** @return {Promise<boolean>} */
 export async function normalizePosition() {
-  const updates = store.scripts.filter(({ props }, index) => {
+  const updates = store.scripts.reduce((res, script, index) => {
+    const { props } = script;
     const position = index + 1;
-    const res = props.position !== position;
-    if (res) props.position = position;
+    if (props.position !== position) {
+      props.position = position;
+      (res || (res = {}))[props.id] = script;
+    }
     return res;
-  });
+  }, null);
   store.storeInfo.position = store.scripts.length;
-  if (updates.length) {
-    await storage.script.dump(updates);
+  if (updates) {
+    await storage.script.set(updates);
     updateLastModified();
   }
-  return updates.length;
+  return !!updates;
 }
 
 /** @return {Promise<number>} */
@@ -238,26 +227,6 @@ export function getScripts() {
   return store.scripts.filter(script => !script.config.removed);
 }
 
-/**
- * @desc Load values for batch updates.
- * @param {number[]} ids
- * @return {Promise<Object>}
- */
-export function getValueStoresByIds(ids) {
-  return storage.value.getMulti(ids);
-}
-
-/**
- * @desc Dump values for batch updates.
- * @param {Object} valueDict { id1: value1, id2: value2, ... }
- * @return {Promise<Object>}
- */
-export async function dumpValueStores(valueDict) {
-  if (process.env.DEBUG) console.info('Update value stores', valueDict);
-  await storage.value.dump(valueDict);
-  return valueDict;
-}
-
 export const ENV_CACHE_KEYS = 'cacheKeys';
 export const ENV_REQ_KEYS = 'reqKeys';
 export const ENV_SCRIPTS = 'scripts';
@@ -267,7 +236,7 @@ const RUN_AT_RE = /^document-(start|body|end|idle)$/;
 /**
  * @desc Get scripts to be injected to page with specific URL.
  */
-export async function getScriptsByURL(url, isTop) {
+export function getScriptsByURL(url, isTop) {
   const allScripts = testBlacklist(url)
     ? []
     : store.scripts.filter(script => (
@@ -281,9 +250,9 @@ export async function getScriptsByURL(url, isTop) {
 /**
  * @param {VMScript[]} scripts
  * @param {boolean} [sizing]
- * @return {Promise<VMScriptByUrlData>}
+ * @return {VMScriptByUrlData}
  */
-async function getScriptEnv(scripts, sizing) {
+function getScriptEnv(scripts, sizing) {
   const disabledIds = [];
   /** @namespace VMScriptByUrlData */
   const [envStart, envDelayed] = [0, 1].map(() => ({
@@ -325,18 +294,11 @@ async function getScriptEnv(scripts, sizing) {
     /** @namespace VMInjectedScript */
     env[ENV_SCRIPTS].push(sizing ? script : { ...script, runAt });
   });
-  // Starting to read it before the potentially huge envDelayed
-  const envStartData = await readEnvironmentData(envStart);
+  envStart.promise = readEnvironmentData(envStart);
   if (envDelayed.ids.length) {
     envDelayed.promise = readEnvironmentData(envDelayed);
   }
-  /** @namespace VMScriptByUrlData */
-  return {
-    ...envStart,
-    ...envStartData,
-    disabledIds,
-    envDelayed,
-  };
+  return Object.assign(envStart, { disabledIds, envDelayed });
 }
 
 /**
@@ -356,7 +318,7 @@ async function readEnvironmentData(env, isRetry) {
   STORAGE_ROUTES.forEach(([area, srcIds]) => {
     env[srcIds].forEach(id => {
       if (!/^data:/.test(id)) {
-        keys.push(storage[area].getKey(id));
+        keys.push(storage[area].toKey(id));
       }
     });
   });
@@ -367,7 +329,7 @@ async function readEnvironmentData(env, isRetry) {
     for (const id of env[srcIds]) {
       const val = /^data:/.test(id)
         ? area !== 'require' && id || dataUri2text(id)
-        : data[storage[area].getKey(id)];
+        : data[storage[area].toKey(id)];
       env[area][id] = val;
       if (val == null && area !== 'value' && !env.sizing && retriedStorageKeys[area + id] !== 2) {
         retriedStorageKeys[area + id] = isRetry ? 2 : 1;
@@ -421,14 +383,13 @@ function getIconCache(scripts) {
       if (isRemote(icon)) res.push(custom.pathMap?.[icon] || icon);
       return res;
     }, []),
-    undefined,
-    storage.cache.makeDataUri,
+    makeDataUri,
   );
 }
 
 export async function getSizes(ids) {
   const scripts = ids ? ids.map(getScriptById) : store.scripts;
-  const { cache, code, value, require } = await getScriptEnv(scripts, true);
+  const { cache, code, value, require } = await getScriptEnv(scripts, true).promise;
   return scripts.map(({
     meta,
     custom: { pathMap = {} },
@@ -443,20 +404,25 @@ export async function getSizes(ids) {
   }));
 }
 
-/** @return {number} */
+/** @return {?Promise<void>} only if something was removed, otherwise undefined */
 export function checkRemove({ force } = {}) {
   const now = Date.now();
-  const toRemove = store.scripts.filter(script => script.config.removed && (
-    force || now - getInt(script.props.lastModified) > TIMEOUT_WEEK
-  ));
+  const toKeep = [];
+  const toRemove = [];
+  store.scripts.forEach(script => {
+    const { id, lastModified } = script.props;
+    if (script.config.removed && (force || now - getInt(lastModified) > TIMEOUT_WEEK)) {
+      toRemove.push(storage.code.toKey(id),
+        storage.script.toKey(id),
+        storage.value.toKey(id));
+    } else {
+      toKeep.push(script);
+    }
+  });
   if (toRemove.length) {
-    store.scripts = store.scripts.filter(script => !script.config.removed);
-    const ids = toRemove.map(getPropsId);
-    storage.script.removeMulti(ids);
-    storage.code.removeMulti(ids);
-    storage.value.removeMulti(ids);
+    store.scripts = toKeep;
+    return storage.base.remove(toRemove);
   }
-  return toRemove.length;
 }
 
 /** @return {string} */
@@ -510,10 +476,10 @@ async function saveScript(script, code) {
     script.props = props;
     store.scripts.push(script);
   }
-  return Promise.all([
-    storage.script.dump(script),
-    storage.code.set(props.id, code),
-  ]);
+  return storage.base.set({
+    [storage.script.toKey(props.id)]: script,
+    [storage.code.toKey(props.id)]: code,
+  });
 }
 
 /** @return {Promise<void>} */
@@ -523,7 +489,7 @@ export async function updateScriptInfo(id, data) {
   script.props = { ...script.props, ...data.props };
   script.config = { ...script.config, ...data.config };
   script.custom = { ...script.custom, ...data.custom };
-  await storage.script.dump(script);
+  await storage.script.setOne(id, script);
   return sendCmd('UpdateScript', { where: { id }, update: script });
 }
 
@@ -603,7 +569,7 @@ export async function fetchResources(script, resourceCache, reqOptions) {
     url = pathMap[url] || url;
     const contents = resourceCache?.[type]?.[url];
     return contents != null && !validator
-      ? storage[type].set(url, contents) && null
+      ? storage[type].setOne(url, contents) && null
       : storage[type].fetch(url, reqOptions, validator).catch(err => err);
   };
   const errors = await Promise.all([
@@ -669,16 +635,13 @@ export async function vacuum(data) {
     [storage.require, requireKeys],
     [storage.code, codeKeys],
   ];
-  if (!data) data = await browser.storage.local.get();
+  if (!data) data = await storage.base.getMulti();
   data::forEachKey((key) => {
     mappings.some(([substore, map]) => {
-      const { prefix } = substore;
-      if (key.startsWith(prefix)) {
-        // -1 for untouched, 1 for touched, 2 for missing
-        map[key.slice(prefix.length)] = -1;
-        return true;
-      }
-      return false;
+      const id = substore.toId(key);
+      // -1 for untouched, 1 for touched, 2 for missing
+      if (id) map[id] = -1;
+      return id;
     });
   });
   const touch = (obj, key, scriptId) => {
@@ -710,11 +673,11 @@ export async function vacuum(data) {
     map::forEachEntry(([key, value]) => {
       if (value < 0) {
         // redundant value
-        keysToRemove.push(substore.getKey(key));
+        keysToRemove.push(substore.toKey(key));
         numFixes += 1;
       } else if (value >= 2 && substore.fetch) {
         // missing resource
-        keysToRemove.push(storage.mod.getKey(key));
+        keysToRemove.push(storage.mod.toKey(key));
         toFetch.push(substore.fetch(key).catch(err => `${
           getScriptName(getScriptById(value - 2))
         }: ${
@@ -725,7 +688,7 @@ export async function vacuum(data) {
     });
   });
   if (numFixes) {
-    await storage.base.removeMulti(keysToRemove); // Removing `mod` before fetching
+    await storage.base.remove(keysToRemove); // Removing `mod` before fetching
     result.errors = (await Promise.all(toFetch)).filter(Boolean);
   }
   _vacuuming = null;

+ 1 - 0
src/background/utils/index.js

@@ -1,3 +1,4 @@
+import './storage-fetch';
 export { default as cache } from './cache';
 export { default as getEventEmitter } from './events';
 export * from './message';

+ 6 - 5
src/background/utils/options.js

@@ -1,6 +1,7 @@
 import { debounce, ensureArray, initHooks, normalizeKeys } from '@/common';
 import { deepCopy, deepEqual, mapEntry, objectGet, objectSet } from '@/common/object';
 import defaults from '@/common/options-defaults';
+import storage from '@/common/storage';
 import { preInitialize } from './init';
 import { commands } from './message';
 
@@ -11,7 +12,7 @@ Object.assign(commands, {
   },
   /** @return {Object} */
   GetOptions(data) {
-    return data::mapEntry(([key]) => getOption(key));
+    return data::mapEntry((_, key) => getOption(key));
   },
   /** @return {void} */
   SetOptions(data) {
@@ -36,8 +37,8 @@ const callHooksLater = debounce(callHooks, DELAY);
 const writeOptionsLater = debounce(writeOptions, DELAY);
 let changes = {};
 let options = {};
-let initPending = browser.storage.local.get(STORAGE_KEY)
-.then(({ options: data }) => {
+let initPending = storage.base.getOne(STORAGE_KEY)
+.then(data => {
   if (data && typeof data === 'object') options = data;
   if (process.env.DEBUG) console.info('options:', options);
   if (!options[VERSION]) {
@@ -72,7 +73,7 @@ export function getOption(key, def) {
   const keys = normalizeKeys(key);
   const mainKey = keys[0];
   const value = options[mainKey] ?? deepCopy(defaults[mainKey]) ?? def;
-  return keys.length > 1 ? objectGet(value, keys.slice(1), def) : value;
+  return keys.length > 1 ? objectGet(value, keys.slice(1)) ?? def : value;
 }
 
 export async function setOption(key, value) {
@@ -97,7 +98,7 @@ export async function setOption(key, value) {
 }
 
 function writeOptions() {
-  return browser.storage.local.set({ [STORAGE_KEY]: options });
+  return storage.base.setOne(STORAGE_KEY, options);
 }
 
 function omitDefaultValue(key) {

+ 6 - 6
src/background/utils/patch-db.js

@@ -26,7 +26,7 @@ export default () => new Promise((resolve, reject) => {
     let processing = 3;
     const done = () => {
       processing -= 1;
-      if (!processing) resolve(browser.storage.local.set(updates));
+      if (!processing) resolve(storage.base.set(updates));
     };
     const getAll = (storeName, callback) => {
       const req = tx.objectStore(storeName).getAll();
@@ -37,27 +37,27 @@ export default () => new Promise((resolve, reject) => {
       const uriMap = {};
       allScripts.forEach((script) => {
         const { code, id, uri } = script;
-        updates[`${storage.script.prefix}${id}`] = transformScript(script);
-        updates[`${storage.code.prefix}${id}`] = code;
+        updates[storage.script.toKey(id)] = transformScript(script);
+        updates[storage.code.toKey(id)] = code;
         uriMap[uri] = id;
       });
       getAll('values', (allValues) => {
         allValues.forEach(({ uri, values }) => {
           const id = uriMap[uri];
-          if (id) updates[`${storage.value.prefix}${id}`] = values;
+          if (id) updates[storage.value.toKey(id)] = values;
         });
         done();
       });
     });
     getAll('cache', (allCache) => {
       allCache.forEach(({ uri, data }) => {
-        updates[`${storage.cache.prefix}${uri}`] = data;
+        updates[storage.cache.toKey(uri)] = data;
       });
       done();
     });
     getAll('require', (allRequire) => {
       allRequire.forEach(({ uri, code }) => {
-        updates[`${storage.require.prefix}${uri}`] = code;
+        updates[storage.require.toKey(uri)] = code;
       });
       done();
     });

+ 13 - 10
src/background/utils/preinject.js

@@ -11,13 +11,14 @@ 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';
 
 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_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
 const cacheCode = initCache({ lifetime: TIME_KEEP_DATA });
 const cache = initCache({
@@ -59,6 +60,7 @@ Object.assign(commands, {
       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]);
       }
     }
@@ -85,8 +87,7 @@ const propsToClear = {
   [storage.value.prefix]: ENV_VALUE_IDS,
 };
 
-browser.storage.onChanged.addListener(async changes => {
-  const dbKeys = Object.keys(changes);
+onStorageChanged(async ({ keys: dbKeys }) => {
   const cacheValues = await Promise.all(cache.getValues());
   const dirty = cacheValues.some(data => data.inject
     && dbKeys.some((key) => {
@@ -134,7 +135,7 @@ function onOptionChanged(changes) {
   });
 }
 
-/** @return {Promise<Object>} */
+/** @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);
@@ -185,7 +186,7 @@ function onSendHeaders({ url, tabId, frameId }) {
 function onHeadersReceived(info) {
   const key = getKey(info.url, !info.frameId);
   const data = xhrInject && cache.get(key);
-  cache.hit(key, TIME_AFTER_RECEIVE);
+  // Proceeding only if prepareScripts has replaced promise in cache with the actual data
   return data?.inject && prepareXhrBlob(info, data);
 }
 
@@ -205,6 +206,7 @@ function prepareXhrBlob({ url, responseHeaders }, data) {
     value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
   });
   setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
+  data.headers = true;
   return { responseHeaders };
 }
 
@@ -224,8 +226,8 @@ function prepare(key, url, tabId, frameId, forceContent) {
 }
 
 async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
-  const data = await getScriptsByURL(url, !frameId);
-  const { envDelayed, scripts } = data;
+  const data = getScriptsByURL(url, !frameId);
+  const { envDelayed, 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);
@@ -254,13 +256,14 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
   /** @namespace VMGetInjectedDataContainer */
   Object.assign(res, {
     feedback,
-    valOpIds: [...data[ENV_VALUE_IDS], ...envDelayed[ENV_VALUE_IDS]],
     rcsPromise: !isLate && !xhrInject && IS_FIREFOX
       ? registerScriptDataFF(inject, url, !!frameId)
       : null,
   });
   if (more) cache.put(envKey, more);
-  cache.put(cacheKey, res); // necessary for the synchronous onHeadersReceived
+  if (!isLate && !cache.get(cacheKey)?.headers) {
+    cache.put(cacheKey, res); // synchronous onHeadersReceived needs plain object not a Promise
+  }
   return res;
 }
 
@@ -313,7 +316,7 @@ function prepareScript(script) {
     // code will be `true` if the desired realm is PAGE which is not injectable
     code: isContent ? '' : forceContent || injectedCode,
     metaStr: code.match(METABLOCK_RE)[1] || '',
-    values: value[id] || null,
+    values: id in value ? value[id] || {} : null,
   });
   return isContent && [
     dataKey,

+ 3 - 6
src/background/utils/requests.js

@@ -15,6 +15,7 @@ Object.assign(commands, {
     requests[id] = {
       id,
       tabId,
+      frameId,
       eventsToNotify,
       xhr: new XMLHttpRequest(),
     };
@@ -221,9 +222,9 @@ function clearRequest({ id, coreId }) {
   toggleHeaderInjector(id, false);
 }
 
-export function clearRequestsByTabId(tabId) {
+export function clearRequestsByTabId(tabId, frameId) {
   requests::forEachValue(req => {
-    if (req.tabId === tabId) {
+    if (req.tabId === tabId && (!frameId || req.frameId === frameId)) {
       commands.AbortRequest(req.id);
     }
   });
@@ -245,7 +246,3 @@ function decodeBody([body, type, wasBlob]) {
   }
   return [body, type];
 }
-
-// In Firefox with production code of Violentmonkey, scripts can be injected before `tabs.onUpdated` is fired.
-// Ref: https://github.com/violentmonkey/violentmonkey/issues/1255
-browser.tabs.onRemoved.addListener(clearRequestsByTabId);

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

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

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

@@ -0,0 +1,133 @@
+import { debounce, initHooks, isEmpty } from '@/common';
+import initCache from '@/common/cache';
+import { deepCopy, deepCopyDiff, forEachEntry } from '@/common/object';
+import storage from '@/common/storage';
+import { store } from './db';
+
+/** Throttling browser API for `storage.value`, processing requests sequentially,
+ so that we can supersede an earlier chained request if it's obsolete now,
+ e.g. in a chain like [GET:foo, SET:foo=bar] `bar` will be used in GET. */
+let valuesToFlush = {};
+/** Reading the entire db in init/vacuum/sizing shouldn't be cached for long. */
+const TTL_SKIM = 5e3;
+/** Keeping data for long time since chrome.storage.local is insanely slow in Chrome,
+ * so reading just a few megabytes would inject all scripts after document-end. */
+const TTL_MAIN = 3600e3;
+/** Keeping tiny info for extended period of time as it's inexpensive. */
+const TTL_TINY = 24 * 3600e3;
+const cache = initCache({ lifetime: TTL_MAIN });
+const dbKeys = initCache({ lifetime: TTL_TINY }); // 1: exists, 0: known to be absent
+const { scriptMap } = store;
+const { api } = storage;
+const GET = 'get';
+const SET = 'set';
+const REMOVE = 'remove';
+const flushLater = debounce(flush, 200);
+const { hook, fire } = initHooks();
+
+/**
+ * Not using browser.storage.onChanged to improve performance, as it sends data across processes,
+ * so if someone wants to edit the db in devtools they need to restart the background page.
+*/
+export const onStorageChanged = hook;
+
+storage.api = {
+
+  async [GET](keys) {
+    const res = {};
+    batch(true);
+    keys = keys?.filter(key => {
+      const cached = cache.get(key);
+      const ok = cached !== undefined;
+      if (ok) res[key] = deepCopy(cached);
+      return !ok && dbKeys.get(key) !== 0;
+    });
+    if (!keys || keys.length) {
+      (await api[GET](keys))::forEachEntry(([key, val]) => {
+        res[key] = val;
+        dbKeys.put(key, 1);
+        cache.put(key, deepCopy(val), !keys && TTL_SKIM);
+        updateScriptMap(key, val);
+      });
+      keys?.forEach(key => dbKeys.put(key, +res::hasOwnProperty(key)));
+    }
+    batch(false);
+    return res;
+  },
+
+  async [SET](data) {
+    const toWrite = {};
+    const keys = [];
+    let unflushed;
+    batch(true);
+    data::forEachEntry(([key, val]) => {
+      const copy = deepCopyDiff(val, cache.get(key));
+      if (copy !== undefined) {
+        cache.put(key, copy);
+        dbKeys.put(key, 1);
+        if (storage.value.toId(key)) {
+          unflushed = true;
+          valuesToFlush[key] = copy;
+        } else {
+          keys.push(key);
+          toWrite[key] = val;
+          updateScriptMap(key, val);
+        }
+      }
+    });
+    batch(false);
+    if (keys.length) {
+      await api.set(toWrite);
+      fire({ keys });
+    }
+    if (unflushed) flushLater();
+  },
+
+  async [REMOVE](keys) {
+    let unflushed;
+    keys = keys.filter(key => {
+      let ok = dbKeys.get(key) !== 0;
+      if (ok) {
+        cache.del(key);
+        dbKeys.put(key, 0);
+        if (storage.value.toId(key)) {
+          valuesToFlush[key] = null;
+          unflushed = true;
+          ok = false;
+        } else {
+          updateScriptMap(key);
+        }
+      }
+      return ok;
+    });
+    if (keys.length) {
+      await api[REMOVE](keys);
+      fire({ keys });
+    }
+    if (unflushed) {
+      flushLater();
+    }
+  },
+};
+
+function batch(state) {
+  cache.batch(state);
+  dbKeys.batch(state);
+}
+
+function updateScriptMap(key, val) {
+  const id = +storage.script.toId(key);
+  if (id) {
+    if (val) scriptMap[id] = val;
+    else delete scriptMap[id];
+  }
+}
+
+function flush() {
+  const keys = Object.keys(valuesToFlush);
+  const toRemove = keys.filter(key => !valuesToFlush[key] && delete valuesToFlush[key]);
+  if (!isEmpty(valuesToFlush)) api.set(valuesToFlush);
+  if (toRemove.length) api.remove(toRemove);
+  valuesToFlush = {};
+  fire({ keys });
+}

+ 3 - 3
src/background/utils/storage-fetch.js

@@ -1,4 +1,4 @@
-import { request } from '@/common';
+import { makeRaw, request } from '@/common';
 import storage from '@/common/storage';
 
 /** @type { function(url, options, check): Promise<void> } or throws on error */
@@ -7,7 +7,7 @@ storage.cache.fetch = cacheOrFetch({
     return { ...options, responseType: 'blob' };
   },
   async transform(response, url, options, check) {
-    const [type, body] = await storage.cache.makeRaw(response, true);
+    const [type, body] = await makeRaw(response, true);
     await check?.(url, response.data, type);
     return `${type},${body}`;
   },
@@ -60,7 +60,7 @@ export async function requestNewer(url, opts) {
         return;
       }
       if (get) {
-        if (mod) storage.mod.set(url, mod);
+        if (mod) storage.mod.setOne(url, mod);
         else if (modOld) storage.mod.remove(url);
         return req;
       }

+ 84 - 80
src/background/utils/values.js

@@ -1,108 +1,112 @@
-import { isEmpty, makePause, sendTabCmd } from '@/common';
-import { forEachEntry, forEachKey, objectSet } from '@/common/object';
-import { getScript, getValueStoresByIds, dumpValueStores } from './db';
+import { isEmpty, sendTabCmd } from '@/common';
+import { forEachEntry, objectGet, objectSet } from '@/common/object';
+import storage from '@/common/storage';
+import { getScript } from './db';
 import { commands } from './message';
 
-const openers = {}; // { scriptId: { tabId: { frameId: 1, ... }, ... } }
-let cache = {}; // { scriptId: { key: { last: value, tabId: { frameId: value } } } }
-let cacheUpd;
+const nest = (obj, key) => obj[key] || (obj[key] = {}); // eslint-disable-line no-return-assign
+/** { scriptId: { tabId: { frameId: {key: raw}, ... }, ... } } */
+const openers = {};
+let chain = Promise.resolve();
+let toSend = {};
 
 Object.assign(commands, {
-  /** @param {{ where, store }[]} data
-   * @return {Promise<void>} */
-  async SetValueStores(data) {
-    // Value store will be replaced soon.
-    const stores = data.reduce((res, { where, store }) => {
-      const id = where.id || getScript(where)?.props.id;
-      if (id) res[id] = store;
-      return res;
-    }, {});
-    await Promise.all([
-      dumpValueStores(stores),
-      broadcastValueStores(groupStoresByFrame(stores)),
-    ]);
+  /**
+   * @param {Object} data - key can be an id or a uri
+   * @return {Promise<void>}
+   */
+  SetValueStores(data) {
+    const toWrite = {};
+    data::forEachEntry(([id, store = {}]) => {
+      id = getScript({ id: +id, uri: id })?.props.id;
+      if (id) {
+        toWrite[id] = store;
+        toSend[id] = store;
+      }
+    });
+    commit(toWrite);
+    return chain;
   },
-  /** @return {void} */
-  UpdateValue({ id, key, value = null }, src) {
-    objectSet(cache, [id, key, 'last'], value);
-    objectSet(cache, [id, key, src.tab.id, src.frameId], value);
-    updateLater();
+  /**
+   * @return {?Promise<void>}
+   */
+  UpdateValue({ id, key, raw }, { tab, frameId }) {
+    const values = objectGet(openers, [id, tab.id, frameId]);
+    if (values) { // preventing the weird case of message arriving after the page navigated
+      if (raw) values[key] = raw; else delete values[key];
+      nest(toSend, id)[key] = raw || null;
+      commit({ [id]: values });
+      return chain;
+    }
   },
 });
 
-browser.tabs.onRemoved.addListener(resetValueOpener);
-browser.tabs.onReplaced.addListener((addedId, removedId) => resetValueOpener(removedId));
-
-export function resetValueOpener(tabId) {
-  openers::forEachEntry(([id, openerTabs]) => {
-    if (tabId in openerTabs) {
-      delete openerTabs[tabId];
-      if (isEmpty(openerTabs)) delete openers[id];
+export function resetValueOpener(tabId, frameId) {
+  openers::forEachEntry(([id, tabs]) => {
+    const frames = tabs[tabId];
+    if (frames) {
+      if (frameId) {
+        delete frames[frameId];
+        if (isEmpty(frames)) delete tabs[tabId];
+      } else {
+        delete tabs[tabId];
+      }
     }
+    if (isEmpty(tabs)) delete openers[id];
   });
 }
 
-export function addValueOpener(tabId, frameId, scriptIds) {
-  scriptIds.forEach((id) => {
-    objectSet(openers, [id, tabId, frameId], 1);
+/**
+ * @param {number} tabId
+ * @param {number} frameId
+ * @param {VMInjectedScript[]} injectedScripts
+ */
+export function addValueOpener(tabId, frameId, injectedScripts) {
+  injectedScripts.forEach(script => {
+    const { values, props: { id } } = script;
+    if (values) objectSet(openers, [id, tabId, frameId], values);
+    else delete openers[id];
   });
 }
 
-async function updateLater() {
-  while (!cacheUpd) {
-    await makePause(0);
-    cacheUpd = cache;
-    cache = {};
-    await doUpdate();
-    cacheUpd = null;
-    if (isEmpty(cache)) break;
-  }
+/** Caution: may delete keys in `data` */
+function commit(data) {
+  storage.value.set(data);
+  chain = chain.catch(console.warn).then(broadcast);
 }
 
-async function doUpdate() {
-  const toSend = {};
-  const valueStores = await getValueStoresByIds(Object.keys(cacheUpd));
-  cacheUpd::forEachEntry(([id, scriptData]) => {
-    scriptData::forEachEntry(([key, history]) => {
-      const { last } = history;
-      objectSet(valueStores, [id, key], last || undefined);
-      openers[id]::forEachEntry(([tabId, frames]) => {
-        const tabHistory = history[tabId] || {};
-        frames::forEachKey((frameId) => {
-          if (tabHistory[frameId] !== last) {
-            objectSet(toSend, [tabId, frameId, id, key], last);
-          }
-        });
-      });
-    });
-  });
-  await Promise.all([
-    dumpValueStores(valueStores),
-    broadcastValueStores(toSend, { partial: true }),
-  ]);
-}
-
-async function broadcastValueStores(tabFrameData, { partial } = {}) {
+async function broadcast() {
   const tasks = [];
-  for (const [tabId, frames] of Object.entries(tabFrameData)) {
-    for (const [frameId, frameData] of Object.entries(frames)) {
-      if (partial) frameData.partial = true;
-      tasks.push(sendTabCmd(+tabId, 'UpdatedValues', frameData, { frameId: +frameId }));
+  const toTabs = {};
+  toSend::forEachEntry(groupByTab, toTabs);
+  toSend = {};
+  for (const [tabId, frames] of Object.entries(toTabs)) {
+    for (const [frameId, toFrame] of Object.entries(frames)) {
+      tasks.push(sendToFrame(+tabId, +frameId, toFrame));
       if (tasks.length === 20) await Promise.all(tasks.splice(0)); // throttling
     }
   }
   await Promise.all(tasks);
 }
 
-// Returns per tab/frame data
-function groupStoresByFrame(stores) {
-  const toSend = {};
-  stores::forEachEntry(([id, store]) => {
-    openers[id]::forEachEntry(([tabId, frames]) => {
-      frames::forEachKey(frameId => {
-        objectSet(toSend, [tabId, frameId, id], store);
+/** @this {Object} accumulator */
+function groupByTab([id, valuesToSend]) {
+  const entriesToSend = Object.entries(valuesToSend);
+  openers[id]::forEachEntry(([tabId, frames]) => {
+    const toFrames = nest(this, tabId);
+    frames::forEachEntry(([frameId, last]) => {
+      const toScript = nest(nest(toFrames, frameId), id);
+      entriesToSend.forEach(([key, raw]) => {
+        if (raw !== last[key]) {
+          if (raw) last[key] = raw; else delete last[key];
+          toScript[key] = raw;
+        }
       });
     });
   });
-  return toSend;
+}
+
+function sendToFrame(tabId, frameId, data) {
+  return sendTabCmd(tabId, 'UpdatedValues', data, { frameId }).catch(console.warn);
+  // must use catch() to keep Promise.all going
 }

+ 3 - 1
src/common/cache.js

@@ -16,9 +16,11 @@ export default function initCache({
   // eslint-disable-next-line no-return-assign
   const getNow = () => batchStarted && batchStartTime || (batchStartTime = performance.now());
   /** @namespace VMCache */
-  return {
+  const exports = {
     batch, get, getValues, pop, put, del, has, hit, destroy,
   };
+  if (process.env.DEV) Object.defineProperty(exports, 'data', { get: () => cache });
+  return exports;
   function batch(enable) {
     batchStarted = enable;
     batchStartTime = 0;

+ 27 - 1
src/common/index.js

@@ -2,7 +2,7 @@
 
 import { browser } from '@/common/consts';
 import { deepCopy } from './object';
-import { i18n, noop } from './util';
+import { blob2base64, i18n, noop } from './util';
 
 export { normalizeKeys } from './object';
 export * from './util';
@@ -217,3 +217,29 @@ export function makePause(ms) {
 export function trueJoin(separator) {
   return this.filter(Boolean).join(separator);
 }
+
+/**
+ * @param {string} url
+ * @param {string} raw - raw value in storage.cache
+ * @returns {?string}
+ */
+export function makeDataUri(raw, url) {
+  if (url.startsWith('data:')) return url;
+  if (/^(i,|image\/)/.test(raw)) { // workaround for bugs in old VM, see 2e135cf7
+    const i = raw.lastIndexOf(',');
+    const type = raw.startsWith('image/') ? raw.slice(0, i) : 'image/png';
+    return `data:${type};base64,${raw.slice(i + 1)}`;
+  }
+  return raw;
+}
+
+/**
+ * @param {VMRequestResponse} response
+ * @param {boolean} [noJoin]
+ * @returns {string|string[]}
+ */
+export async function makeRaw(response, noJoin) {
+  const type = (response.headers.get('content-type') || '').split(';')[0] || '';
+  const body = await blob2base64(response.data);
+  return noJoin ? [type, body] : `${type},${body}`;
+}

+ 88 - 46
src/common/object.js

@@ -1,38 +1,40 @@
+/** @type {boolean} */
+let deepDiff;
+
 export function normalizeKeys(key) {
   if (key == null) return [];
   if (Array.isArray(key)) return key;
   return `${key}`.split('.').filter(Boolean);
 }
 
-export function objectGet(obj, rawKey, def) {
-  const keys = normalizeKeys(rawKey);
-  let res = obj;
-  keys.every((key) => {
-    if (res && typeof res === 'object' && (key in res)) {
-      res = res[key];
-      return true;
-    }
-    res = def;
-    return false;
-  });
-  return res;
+export function objectGet(obj, rawKey) {
+  for (const key of normalizeKeys(rawKey)) {
+    if (!obj || typeof obj !== 'object') break;
+    obj = obj[key];
+  }
+  return obj;
 }
 
-export function objectSet(obj, rawKey, val) {
-  const keys = normalizeKeys(rawKey);
-  if (!keys.length) return val;
-  const root = obj || {};
-  let sub = root;
-  const lastKey = keys.pop();
-  keys.forEach((key) => {
-    sub = sub[key] || (sub[key] = {});
-  });
-  if (typeof val === 'undefined') {
-    delete sub[lastKey];
+/**
+ * @param {Object} [obj = {}]
+ * @param {string|string[]} [rawKey]
+ * @param {?} [val] - if `undefined` or omitted the value is deleted
+ * @param {boolean} [retParent]
+ * @return {Object} the original object or the parent of `val` if retParent is set
+ */
+export function objectSet(obj, rawKey, val, retParent) {
+  rawKey = normalizeKeys(rawKey);
+  let res = obj || {};
+  let key;
+  for (let i = 0; (key = rawKey[i], i < rawKey.length - 1); i += 1) {
+    res = res[key] || (res[key] = {});
+  }
+  if (val === undefined) {
+    delete res[key];
   } else {
-    sub[lastKey] = val;
+    res[key] = val;
   }
-  return root;
+  return retParent ? res : obj;
 }
 
 /**
@@ -42,20 +44,30 @@ export function objectSet(obj, rawKey, val) {
  * @returns {{}}
  */
 export function objectPick(obj, keys, transform) {
-  return keys.reduce((res, key) => {
+  const res = {};
+  for (const key of keys) {
     let value = obj?.[key];
     if (transform) value = transform(value, key);
-    if (value != null) res[key] = value;
-    return res;
-  }, {});
+    if (value !== undefined) res[key] = value;
+  }
+  return res;
 }
 
-// invoked as obj::mapEntry(([key, value], i, allEntries) => transformedValue)
-export function mapEntry(func) {
-  return Object.entries(this).reduce((res, entry, i, allEntries) => {
-    res[entry[0]] = func(entry, i, allEntries);
-    return res;
-  }, {});
+/**
+ * @param {function} [fnValue] - (value, newKey, obj) => newValue
+ * @param {function} [fnKey] - (key, val, obj) => newKey (if newKey is falsy the key is skipped)
+ * @param {Object} [thisObj] - passed as `this` to both functions
+ * @return {Object}
+ */
+export function mapEntry(fnValue, fnKey, thisObj) {
+  const res = {};
+  for (let key of Object.keys(this)) {
+    const val = this[key];
+    if (!fnKey || (key = thisObj::fnKey(key, val, this))) {
+      res[key] = fnValue ? thisObj::fnValue(val, key, this) : val;
+    }
+  }
+  return res;
 }
 
 // invoked as obj::forEachEntry(([key, value], i, allEntries) => {})
@@ -73,16 +85,12 @@ export function forEachValue(func, thisObj) {
   if (this) Object.values(this).forEach(func, thisObj);
 }
 
-// Needed for Firefox's browser.storage API which fails on Vue observables
 export function deepCopy(src) {
-  return src && (
-    /* Not using `map` because its result belongs to the `window` of the source,
-     * so it becomes "dead object" in Firefox after GC collects it. */
-    Array.isArray(src) && Array.from(src, deepCopy)
-    // Used in safe context
-    // eslint-disable-next-line no-restricted-syntax
-    || typeof src === 'object' && src::mapEntry(([, val]) => deepCopy(val))
-  ) || src;
+  if (!src || typeof src !== 'object') return src;
+  /* Not using `map` because its result belongs to the `window` of the source,
+   * so it becomes "dead object" in Firefox after GC collects it. */
+  if (Array.isArray(src)) return Array.from(src, deepCopy);
+  return src::mapEntry(deepCopy);
 }
 
 // Simplified deep equality checker
@@ -91,8 +99,7 @@ export function deepEqual(a, b) {
   if (!a || !b || typeof a !== typeof b || typeof a !== 'object') {
     res = a === b;
   } else if (Array.isArray(a)) {
-    res = a.length === b.length
-      && a.every((item, i) => deepEqual(item, b[i]));
+    res = a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
   } else {
     const keysA = Object.keys(a);
     const keysB = Object.keys(b);
@@ -101,3 +108,38 @@ export function deepEqual(a, b) {
   }
   return res;
 }
+
+/** @return {?} `undefined` if equal */
+export function deepCopyDiff(src, sample) {
+  if (src === sample) return;
+  if (!src || typeof src !== 'object') return src;
+  if (!sample || typeof sample !== 'object') return deepCopy(src);
+  if ((deepDiff = false, src = deepCopyDiffObjects(src, sample), deepDiff)) return src;
+}
+
+function deepCopyDiffObjects(src, sample) {
+  const isArr = Array.isArray(src);
+  const arr1 = isArr ? src : Object.keys(src);
+  const arr2 = isArr ? sample : Object.keys(sample);
+  const res = isArr ? [] : {};
+  if (arr1.length !== arr2.length) {
+    deepDiff = true;
+  }
+  for (let i = 0, key, a, b; i < arr1.length; i += 1) {
+    key = isArr ? i : arr1[i];
+    a = src[key];
+    b = isArr || arr2.includes(key) ? sample[key] : !a;
+    if (a && typeof a === 'object') {
+      if (b && typeof b === 'object') {
+        a = deepCopyDiffObjects(a, b);
+      } else {
+        a = deepCopy(a);
+        deepDiff = true;
+      }
+    } else if (a !== b) {
+      deepDiff = true;
+    }
+    res[key] = a;
+  }
+  return res;
+}

+ 65 - 145
src/common/storage.js

@@ -1,157 +1,77 @@
-import { deepCopy, forEachEntry } from '@/common/object';
-import { blob2base64, ensureArray } from './util';
+import { mapEntry } from '@/common/object';
+import { ensureArray } from './util';
 
-/** @type VMCache */
-let dataCache;
-const browserStorageLocal = browser.storage.local;
-const onStorageChanged = changes => {
-  changes::forEachEntry(([key, { newValue }]) => {
-    if (newValue == null) {
-      dataCache.del(key);
-    } else {
-      dataCache.put(key, newValue);
-    }
-  });
-};
+let api = browser.storage.local;
 
 /** @namespace VMStorageBase */
-const base = {
-  prefix: '',
-  setDataCache(val) {
-    dataCache = val;
-    browser.storage.onChanged.addListener(onStorageChanged);
-  },
-  getKey(id) {
-    return `${this.prefix}${id}`;
-  },
-  async getOne(id) {
-    return (await this.getMulti([id]))[id];
-  },
-  /**
-   * @param {string[]} ids
-   * @param {?} [def]
-   * @param {function(id:string, val:?):?} [transform]
-   * @returns {Promise<Object>}
-   */
-  async getMulti(ids, def, transform) {
-    const res = {};
-    const data = {};
-    const missingKeys = [];
-    ids.forEach(id => {
-      const key = this.getKey(id);
-      const cached = dataCache?.get(key);
-      res[id] = key;
-      if (cached != null) {
-        data[key] = deepCopy(cached);
-      } else {
-        missingKeys.push(key);
-      }
-    });
-    if (missingKeys.length) {
-      Object.assign(data, await browserStorageLocal.get(missingKeys));
-    }
-    res::forEachEntry(([id, key]) => {
-      res[id] = transform
-        ? transform(id, data[key])
-        : data[key] ?? deepCopy(def);
-    });
-    return res;
-  },
-  // Must be `async` to ensure a Promise is returned when `if` doesn't match
-  async set(id, value) {
-    if (id) return this.dump({ [id]: value });
-  },
-  // Must be `async` to ensure a Promise is returned when `if` doesn't match
-  async remove(id) {
-    if (id) return this.removeMulti([id]);
-  },
-  // Must be `async` to ensure a Promise is returned when `if` doesn't match
-  async removeMulti(ids) {
-    if (ids.length) {
-      const keys = ids.map(this.getKey, this);
-      if (dataCache) keys.forEach(dataCache.del);
-      return browserStorageLocal.remove(keys);
-    }
-  },
-  async dump(data) {
-    const output = {};
-    data::forEachEntry(([id, value]) => {
-      const key = this.getKey(id);
-      output[key] = value;
-      dataCache?.put(key, deepCopy(value));
-    });
-    await browserStorageLocal.set(output);
-    return data;
-  },
-};
+class Area {
+  constructor(prefix) {
+    this.prefix = prefix;
+  }
 
-export default {
+  /** @return {string} */
+  toKey(id) {
+    return this.prefix + id;
+  }
 
-  base,
+  /** @return {?string} */
+  toId(key) {
+    if (key.startsWith(this.prefix)) return key.slice(this.prefix.length);
+  }
 
-  cache: {
-    ...base,
-    prefix: 'cac:',
-    /**
-     * @param {VMRequestResponse} response
-     * @param {boolean} [noJoin]
-     * @returns {string|string[]}
-     */
-    async makeRaw(response, noJoin) {
-      const type = (response.headers.get('content-type') || '').split(';')[0] || '';
-      const body = await blob2base64(response.data);
-      return noJoin ? [type, body] : `${type},${body}`;
-    },
-    /**
-     * @param {string} url
-     * @param {string} [raw] - raw value in storage.cache
-     * @returns {?string}
-     */
-    makeDataUri(url, raw) {
-      if (url.startsWith('data:')) return url;
-      if (/^(i,|image\/)/.test(raw)) { // workaround for bugs in old VM, see 2e135cf7
-        const i = raw.lastIndexOf(',');
-        const type = raw.startsWith('image/') ? raw.slice(0, i) : 'image/png';
-        return `data:${type};base64,${raw.slice(i + 1)}`;
-      }
-      return raw;
-    },
-  },
+  async getOne(id) {
+    const key = this.toKey(id);
+    const data = await api.get([key]);
+    return data[key];
+  }
 
-  code: {
-    ...base,
-    prefix: 'code:',
-  },
+  /**
+   * @param {?string[]} [ids] - if null/absent, the entire storage is returned
+   * @param {function(val:?,key:string):?} [transform]
+   * @return {Promise<?>} - single value or object of id:value
+   */
+  async getMulti(ids, transform) {
+    const keys = ids?.map(this.toKey, this);
+    const data = await api.get(keys);
+    return transform || this.prefix
+      ? data::mapEntry(transform, this.toId, this)
+      : data;
+  }
 
-  // last-modified HTTP header value per URL
-  mod: {
-    ...base,
-    prefix: 'mod:',
-  },
+  /**
+   * @param {string|number|Array<string|number>} id
+   * @return {Promise<void>}
+   */
+  async remove(id) {
+    const keys = ensureArray(id).filter(Boolean).map(this.toKey, this);
+    if (keys.length) await api.remove(keys);
+  }
 
-  require: {
-    ...base,
-    prefix: 'req:',
-  },
+  async setOne(id, value) {
+    if (id) return this.set({ [id]: value });
+  }
 
-  script: {
-    ...base,
-    prefix: 'scr:',
-    async dump(items) {
-      items = ensureArray(items).filter(Boolean);
-      if (!items.length) return;
-      const data = items.reduce((res, item) => {
-        res[this.getKey(item.props.id)] = item;
-        if (this.onDump) this.onDump(item);
-        return res;
-      }, {});
-      await base.dump(data);
-      return items;
-    },
-  },
+  /**
+   * @param {Object} data
+   * @return {Promise<Object>} same object
+   */
+  async set(data) {
+    await api.set(this.prefix
+      ? data::mapEntry(null, this.toKey, this)
+      : data);
+    return data;
+  }
+}
 
-  value: {
-    ...base,
-    prefix: 'val:',
-  },
+export default {
+  get api() { return api; },
+  set api(val) { api = val; },
+  base: new Area(''),
+  cache: new Area('cac:'),
+  code: new Area('code:'),
+  /** last-modified HTTP header value per URL */
+  mod: new Area('mod:'),
+  require: new Area('req:'),
+  script: new Area('scr:'),
+  value: new Area('val:'),
 };

+ 1 - 1
src/common/ui/code.vue

@@ -562,7 +562,7 @@ export default {
     storage.base.getOne('editorSearch').then(prev => {
       const { search } = this;
       const saveSearchLater = debounce(() => {
-        storage.base.set('editorSearch', objectPick(search, ['query', 'replace', 'options']));
+        storage.base.setOne('editorSearch', objectPick(search, ['query', 'replace', 'options']));
       }, 500);
       const searchAgain = () => {
         saveSearchLater();

+ 2 - 2
src/common/ui/externals.vue

@@ -36,7 +36,7 @@
 
 <script setup>
 import { computed, ref, watchEffect } from 'vue';
-import { formatByteLength, dataUri2text, i18n } from '@/common';
+import { dataUri2text, formatByteLength, i18n, makeDataUri } from '@/common';
 import VmCode from '@/common/ui/code';
 import storage from '@/common/storage';
 
@@ -82,7 +82,7 @@ async function update() {
   } else {
     const key = value.custom.pathMap?.[url] || url;
     raw = await storage[isReq ? 'require' : 'cache'].getOne(key);
-    if (!isReq) raw = storage.cache.makeDataUri(key, raw);
+          if (!isReq) raw = makeDataUri(raw, key);
   }
   if (isReq || !raw) {
     data.value = { code: raw };

+ 2 - 3
src/confirm/views/app.vue

@@ -90,11 +90,10 @@ import Tooltip from 'vueleton/lib/tooltip';
 import Icon from '@/common/ui/icon';
 import {
   getFullUrl, getLocaleString, getScriptHome, isRemote,
-  makePause, request, sendCmdDirectly, trueJoin,
+  makePause, makeRaw, request, sendCmdDirectly, trueJoin,
 } from '@/common';
 import { keyboardService } from '@/common/keyboard';
 import initCache from '@/common/cache';
-import storage from '@/common/storage';
 import VmExternals from '@/common/ui/externals';
 import SettingCheck from '@/common/ui/setting-check';
 import { loadScriptIcon } from '@/common/load-script-icon';
@@ -336,7 +335,7 @@ export default {
         responseType: isBlob ? 'blob' : null,
       });
       const data = isBlob
-        ? await storage.cache.makeRaw(response)
+        ? await makeRaw(response)
         : response.data;
       if (useCache) cache.put(cacheKey, data);
       return data;

+ 4 - 1
src/injected/content/inject.js

@@ -205,8 +205,11 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }) {
   assign(bridge.cache, cache);
   let needsInvoker;
   scripts::forEach(script => {
-    const { code, runAt } = script;
+    const { code, runAt, custom: { pathMap } } = script;
     const { id } = script.props;
+    if (pathMap) {
+      bridge.pathMaps[id] = pathMap;
+    }
     if (!code) {
       needsInvoker = true;
       contLists[runAt]::push(script);

+ 8 - 4
src/injected/safe-globals-injected.js

@@ -81,10 +81,14 @@ export const pickIntoNullObj = (dst, src, keys) => {
  * @returns {Object} `base` if it's already without prototype, a new object otherwise
  */
 export const createNullObj = (base, src, keys) => {
-  // eslint-disable-next-line no-proto
   const res = { __proto__: null };
-  if (base || src && !keys) assign(res, base, src);
-  if (src && keys) pickIntoNullObj(res, src, keys);
+  if (base) {
+    assign(res, base);
+  }
+  if (src) {
+    if (keys) pickIntoNullObj(res, src, keys);
+    else assign(res, src);
+  }
   return res;
 };
 
@@ -100,7 +104,7 @@ export const ensureNestedProp = (obj, bucketId, key, defaultValue) => {
   return val;
 };
 
-export const promiseResolve = () => (async () => {})();
+export const promiseResolve = async val => val;
 
 export const vmOwnFunc = (func, toString) => (
   setOwnProp(func, 'toString', toString || vmOwnFuncToString, false)

+ 6 - 5
src/injected/web/gm-api-wrapper.js

@@ -147,9 +147,10 @@ function makeGmInfo(script, resources) {
 
 function makeGmMethodCaller(gmMethod, context, isAsync) {
   // keeping the native console.log intact
-  return gmMethod === gmApi.GM_log ? gmMethod : vmOwnFunc(
-    isAsync
-      ? (async (...args) => gmMethod::apply(context, args))
-      : gmMethod::bind(context),
-  );
+  if (gmMethod === gmApi.GM_log) return gmMethod;
+  if (isAsync) {
+    /** @namespace VMInjectedScript.Context */
+    context = assign({ __proto__: null, async: true }, context);
+  }
+  return vmOwnFunc(gmMethod::bind(context));
 }

+ 9 - 5
src/injected/web/gm-api.js

@@ -7,6 +7,10 @@ import { onNotificationCreate } from './notifications';
 import { decodeValue, dumpValue, loadValues, changeHooks } from './gm-values';
 import { jsonDump } from './util';
 
+const resolveOrReturn = (context, val) => (
+  context.async ? promiseResolve(val) : val
+);
+
 export function makeGmApi() {
   return {
     __proto__: null,
@@ -16,14 +20,14 @@ export function makeGmApi() {
       const oldRaw = values[key];
       delete values[key];
       // using `undefined` to match the documentation and TM for GM_addValueChangeListener
-      dumpValue(id, key, undefined, null, oldRaw, this);
+      return dumpValue(id, key, undefined, null, oldRaw, this);
     },
     GM_getValue(key, def) {
       const raw = loadValues(this.id)[key];
-      return raw ? decodeValue(raw) : def;
+      return resolveOrReturn(this, raw ? decodeValue(raw) : def);
     },
     GM_listValues() {
-      return objectKeys(loadValues(this.id));
+      return resolveOrReturn(this, objectKeys(loadValues(this.id)));
     },
     GM_setValue(key, val) {
       const { id } = this;
@@ -31,7 +35,7 @@ export function makeGmApi() {
       const values = loadValues(id);
       const oldRaw = values[key];
       values[key] = raw;
-      dumpValue(id, key, val, raw, oldRaw, this);
+      return dumpValue(id, key, val, raw, oldRaw, this);
     },
     /**
      * @callback GMValueChangeListener
@@ -217,6 +221,6 @@ function getResource(context, name, isBlob) {
       }
       ensureNestedProp(resCache, bucketKey, key, res);
     }
-    return res === true ? key : res;
+    return resolveOrReturn(context, res === true ? key : res);
   }
 }

+ 15 - 4
src/injected/web/gm-values.js

@@ -13,15 +13,13 @@ const dataDecoders = {
 
 bridge.addHandlers({
   UpdatedValues(updates) {
-    const { partial } = updates;
     objectKeys(updates)::forEach(id => {
       const oldData = store.values[id];
       if (oldData) {
         const update = updates[id];
         const keyHooks = changeHooks[id];
         if (keyHooks) changedRemotely(keyHooks, oldData, update);
-        else if (partial) applyPartialUpdate(oldData, update);
-        else store.values[id] = update;
+        else applyPartialUpdate(oldData, update);
       }
     });
   },
@@ -31,12 +29,25 @@ export function loadValues(id) {
   return store.values[id];
 }
 
+/**
+ * @param {number} id
+ * @param {string} key
+ * @param {?} val
+ * @param {?string} raw
+ * @param {?string} oldRaw
+ * @param {VMInjectedScript.Context} context
+ * @return {void|Promise<void>}
+ */
 export function dumpValue(id, key, val, raw, oldRaw, context) {
-  bridge.post('UpdateValue', { id, key, value: raw }, context);
+  let res;
   if (raw !== oldRaw) {
+    res = bridge[context.async ? 'send' : 'post']('UpdateValue', { id, key, raw }, context);
     const hooks = changeHooks[id]?.[key];
     if (hooks) notifyChange(hooks, key, val, raw, oldRaw);
+  } else if (context.async) {
+    res = promiseResolve();
   }
+  return res;
 }
 
 export function decodeValue(raw) {

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

@@ -97,7 +97,7 @@ bridge.addHandlers({
 
 function createScriptData(item) {
   const { dataKey } = item;
-  store.values[item.props.id] = item.values || createNullObj();
+  store.values[item.props.id] = createNullObj(item.values);
   if (window[dataKey]) { // executeScript ran before GetInjected response
     onCodeSet(item, window[dataKey]);
   } else if (!item.meta.unwrap) {

+ 9 - 5
src/options/index.js

@@ -84,11 +84,7 @@ export async function loadData() {
   const [
     { cache, scripts, sync },
     sizes,
-  ] = await Promise.all([
-    sendCmdDirectly('GetData', params, { retry: true }),
-    sendCmdDirectly('GetSizes', params, { retry: true }),
-    options.ready,
-  ]);
+  ] = await requestData(params);
   store.cache = cache;
   scripts.forEach(initScript);
   sizes.forEach((sz, i) => initSize(sz, scripts[i]));
@@ -97,6 +93,14 @@ export async function loadData() {
   store.loading = false;
 }
 
+function requestData(ids) {
+  return Promise.all([
+    sendCmdDirectly('GetData', ids, { retry: true }),
+    sendCmdDirectly('GetSizes', ids, { retry: true }),
+    options.ready,
+  ]).catch(ids ? (() => requestData()) : console.error);
+}
+
 function initMain() {
   store.loading = true;
   loadData();

+ 4 - 7
src/options/views/edit/values.vue

@@ -230,7 +230,7 @@ export default {
       rawValue = dumpScriptValue(jsonValue) || '',
     }) {
       const { id } = this.script.props;
-      return sendCmdDirectly('UpdateValue', { id, key, value: rawValue })
+      return sendCmdDirectly('UpdateValue', { id, key, raw: rawValue })
       .then(() => {
         if (rawValue) {
           this.$set(this.values, key, rawValue);
@@ -294,12 +294,9 @@ export default {
       }
       this.current = null;
       if (current.isAll) {
-        await sendCmdDirectly('SetValueStores', [{
-          where: {
-            id: this.script.props.id,
-          },
-          store: current.jsonValue::mapEntry(([, val]) => dumpScriptValue(val) || ''),
-        }]);
+        await sendCmdDirectly('SetValueStores', {
+          [this.script.props.id]: current.jsonValue::mapEntry(val => dumpScriptValue(val) || ''),
+        });
       } else {
         await this.updateValue(current);
       }

+ 1 - 2
src/options/views/tab-settings/vm-import.vue

@@ -83,8 +83,7 @@ async function importBackup(file) {
   await processAll(readScript, '.user.js');
   if (options.get('importScriptData')) {
     await processAll(readScriptStorage, '.storage.json');
-    sendCmdDirectly('SetValueStores',
-      toObjectArray(vm.values, ([uri, store]) => store && ({ where: { uri }, store })));
+    sendCmdDirectly('SetValueStores', vm.values);
   }
   if (options.get('importSettings')) {
     sendCmdDirectly('SetOptions',

+ 1 - 1
src/popup/index.js

@@ -24,7 +24,7 @@ Object.assign(handlers, {
     store.scriptIds.push(...ids);
     if (isTop) {
       mutex.resolve();
-      store.commands = data.menus::mapEntry(([, value]) => Object.keys(value));
+      store.commands = data.menus::mapEntry(Object.keys);
       // executeScript may(?) fail in a discarded or lazy-loaded tab, which is actually injectable
       store.injectable = true;
     }