فهرست منبع

feat: clear caches when disabling

+ move GetInjected to preinject.js
+ reuse prop names for reliable usage tracking
+ reduce awaiting
tophf 3 سال پیش
والد
کامیت
5028a38993
5فایلهای تغییر یافته به همراه119 افزوده شده و 89 حذف شده
  1. 1 29
      src/background/index.js
  2. 105 57
      src/background/utils/preinject.js
  3. 2 1
      src/background/utils/requests.js
  4. 4 0
      src/background/utils/storage-cache.js
  5. 7 2
      src/background/utils/values.js

+ 1 - 29
src/background/index.js

@@ -8,14 +8,11 @@ import { commands } from './utils';
 import { getData, getSizes, checkRemove } from './utils/db';
 import { getData, getSizes, checkRemove } from './utils/db';
 import { initialize } from './utils/init';
 import { initialize } from './utils/init';
 import { getOption, hookOptions } from './utils/options';
 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/clipboard';
 import './utils/hotkeys';
 import './utils/hotkeys';
 import './utils/icon';
 import './utils/icon';
 import './utils/notifications';
 import './utils/notifications';
+import './utils/preinject';
 import './utils/script';
 import './utils/script';
 import './utils/tabs';
 import './utils/tabs';
 import './utils/tab-redirector';
 import './utils/tab-redirector';
@@ -38,23 +35,6 @@ Object.assign(commands, {
   },
   },
   GetSizes: getSizes,
   GetSizes: getSizes,
   /** @return {Promise<Object>} */
   /** @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() {
   async GetTabDomain() {
     const tab = await getActiveTab() || {};
     const tab = await getActiveTab() || {};
     const url = tab.pendingUrl || tab.url || '';
     const url = tab.pendingUrl || tab.url || '';
@@ -111,14 +91,6 @@ function autoUpdate() {
   autoUpdate.timer = setTimeout(autoUpdate, Math.min(TIMEOUT_MAX, interval - elapsed));
   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(() => {
 initialize(() => {
   global.handleCommandMessage = handleCommandMessage;
   global.handleCommandMessage = handleCommandMessage;
   global.deepCopy = deepCopy;
   global.deepCopy = deepCopy;

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

@@ -1,7 +1,7 @@
 import { getScriptName, getUniqId, sendTabCmd, trueJoin } from '@/common';
 import { getScriptName, getUniqId, sendTabCmd, trueJoin } from '@/common';
 import {
 import {
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
-  INJECTABLE_TAB_URL_RE, METABLOCK_RE,
+  METABLOCK_RE,
 } from '@/common/consts';
 } from '@/common/consts';
 import initCache from '@/common/cache';
 import initCache from '@/common/cache';
 import { forEachEntry, objectPick, objectSet } from '@/common/object';
 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 { extensionRoot, postInitialize } from './init';
 import { commands } from './message';
 import { commands } from './message';
 import { getOption, hookOptions } from './options';
 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 = {
 const API_CONFIG = {
   urls: ['*://*/*'], // `*` scheme matches only http and https
   urls: ['*://*/*'], // `*` scheme matches only http and https
   types: ['main_frame', 'sub_frame'],
   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({
 const cache = initCache({
   lifetime: TIME_KEEP_DATA,
   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 FORCE_CONTENT = 'forceContent';
 const INJECT_INTO = 'injectInto';
 const INJECT_INTO = 'injectInto';
 // KEY_XXX for hooked options
 // KEY_XXX for hooked options
@@ -42,6 +50,32 @@ const expose = {};
 let isApplied;
 let isApplied;
 let injectInto;
 let injectInto;
 let xhrInject;
 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);
 hookOptions(onOptionChanged);
 postInitialize.push(() => {
 postInitialize.push(() => {
   for (const key of [KEY_EXPOSE, KEY_DEF_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
   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} */
 /** @this {chrome.runtime.MessageSender} */
 async function processFeedback([key, runAt, unwrappedId]) {
 async function processFeedback([key, runAt, unwrappedId]) {
-  const code = cacheCode.pop(key);
+  const code = cache.pop(key);
   // see TIME_KEEP_DATA comment
   // see TIME_KEEP_DATA comment
   if (runAt && code) {
   if (runAt && code) {
     const { frameId, tab: { id: tabId } } = this;
     const { frameId, tab: { id: tabId } } = this;
@@ -88,8 +124,10 @@ const propsToClear = {
 };
 };
 
 
 onStorageChanged(async ({ keys: dbKeys }) => {
 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) => {
     && dbKeys.some((key) => {
       const prefix = key.slice(0, key.indexOf(':') + 1);
       const prefix = key.slice(0, key.indexOf(':') + 1);
       const prop = propsToClear[prefix];
       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) {
 function getKey(url, isTop) {
   return isTop ? url : `-${url}`;
   return isTop ? url : `-${url}`;
@@ -155,7 +189,13 @@ function togglePreinject(enable) {
   if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
   if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
     browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
     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) {
 function toggleXhrInject(enable) {
@@ -171,14 +211,13 @@ function toggleXhrInject(enable) {
 }
 }
 
 
 function onSendHeaders({ url, tabId, frameId }) {
 function onSendHeaders({ url, tabId, frameId }) {
-  if (!INJECTABLE_TAB_URL_RE.test(url)) return;
   const isTop = !frameId;
   const isTop = !frameId;
   const key = getKey(url, isTop);
   const key = getKey(url, isTop);
   if (!cache.has(key)) {
   if (!cache.has(key)) {
     // GetInjected message will be sent soon by the content script
     // GetInjected message will be sent soon by the content script
     // and it may easily happen while getScriptsByURL is still waiting for browser.storage
     // 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
     // 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 key = getKey(info.url, !info.frameId);
   const data = xhrInject && cache.get(key);
   const data = xhrInject && cache.get(key);
   // Proceeding only if prepareScripts has replaced promise in cache with the actual data
   // 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);
     forceContentInjection(data);
   }
   }
   const blobUrl = URL.createObjectURL(new Blob([
   const blobUrl = URL.createObjectURL(new Blob([
-    JSON.stringify(data.inject),
+    JSON.stringify(data[INJECT]),
   ]));
   ]));
   responseHeaders.push({
   responseHeaders.push({
     name: 'Set-Cookie',
     name: 'Set-Cookie',
     value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
     value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
   });
   });
   setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
   setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
-  data.headers = true;
+  data[HEADERS] = true;
   return { responseHeaders };
   return { responseHeaders };
 }
 }
 
 
@@ -214,7 +253,7 @@ function prepare(key, url, tabId, frameId, forceContent) {
   /** @namespace VMGetInjectedDataContainer */
   /** @namespace VMGetInjectedDataContainer */
   const res = {
   const res = {
     /** @namespace VMGetInjectedData */
     /** @namespace VMGetInjectedData */
-    inject: {
+    [INJECT]: {
       expose: !frameId
       expose: !frameId
         && url.startsWith('https://')
         && url.startsWith('https://')
         && expose[url.split('/', 3)[2]],
         && 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) {
 async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
   const data = getScriptsByURL(url, !frameId);
   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;
   const isLate = forceContent != null;
   data[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
   data[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
   const feedback = scripts.map(prepareScript, data).filter(Boolean);
   const feedback = scripts.map(prepareScript, data).filter(Boolean);
   const more = envDelayed.promise;
   const more = envDelayed.promise;
   const envKey = getUniqId(`${tabId}:${frameId}:`);
   const envKey = getUniqId(`${tabId}:${frameId}:`);
-  const { inject } = res;
+  const inject = res[INJECT];
   /** @namespace VMGetInjectedData */
   /** @namespace VMGetInjectedData */
   Object.assign(inject, {
   Object.assign(inject, {
-    scripts,
+    [ENV_SCRIPTS]: scripts,
     [INJECT_INTO]: injectInto,
     [INJECT_INTO]: injectInto,
     [INJECT_PAGE]: !forceContent && (
     [INJECT_PAGE]: !forceContent && (
       scripts.some(isPageRealm, data)
       scripts.some(isPageRealm, data)
@@ -253,13 +292,9 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
       ua,
       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 (more) cache.put(envKey, more);
   if (!isLate && !cache.get(cacheKey)?.headers) {
   if (!isLate && !cache.get(cacheKey)?.headers) {
     cache.put(cacheKey, res); // synchronous onHeadersReceived needs plain object not a Promise
     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
     // 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}`,
     `\n//# sourceURL=${extensionRoot}${IS_FIREFOX ? '%20' : ''}${name}.user.js#${id}`,
   ]::trueJoin('');
   ]::trueJoin('');
-  cacheCode.put(dataKey, injectedCode, TIME_KEEP_DATA);
+  cache.put(dataKey, injectedCode, TIME_KEEP_DATA);
   /** @namespace VMInjectedScript */
   /** @namespace VMInjectedScript */
   Object.assign(script, {
   Object.assign(script, {
     dataKey,
     dataKey,
@@ -344,7 +379,7 @@ const resolveDataCodeStr = `(${function _(data) {
 
 
 // TODO: rework the whole thing to register scripts individually with real `matches`
 // TODO: rework the whole thing to register scripts individually with real `matches`
 function registerScriptDataFF(inject, url, allFrames) {
 function registerScriptDataFF(inject, url, allFrames) {
-  return browser.contentScripts?.register({
+  return contentScriptsAPI.register({
     allFrames,
     allFrames,
     js: [{
     js: [{
       code: `${resolveDataCodeStr}(${JSON.stringify(inject)})`,
       code: `${resolveDataCodeStr}(${JSON.stringify(inject)})`,
@@ -370,12 +405,12 @@ function detectStrictCsp(responseHeaders) {
 /** @param {VMGetInjectedDataContainer} data */
 /** @param {VMGetInjectedDataContainer} data */
 function forceContentInjection(data) {
 function forceContentInjection(data) {
   /** @type VMGetInjectedData */
   /** @type VMGetInjectedData */
-  const inject = data.inject;
+  const inject = data[INJECT];
   inject[FORCE_CONTENT] = true;
   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`
     // When script wants `page`, the result below will be `true` so the script goes into `failedIds`
     scr.code = !isContentRealm(scr, true) || '';
     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) {
 function isPageRealm(scr) {
   return !isContentRealm(scr, this[FORCE_CONTENT]);
   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) {
 export function clearRequestsByTabId(tabId, frameId) {
   requests::forEachValue(req => {
   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);
       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.
  * so if someone wants to edit the db in devtools they need to restart the background page.
 */
 */
 export const onStorageChanged = hook;
 export const onStorageChanged = hook;
+export const clearStorageCache = () => {
+  cache.destroy();
+  dbKeys.destroy();
+};
 
 
 storage.api = {
 storage.api = {
 
 

+ 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]) => {
   openers::forEachEntry(([id, tabs]) => {
     const frames = tabs[tabId];
     const frames = tabs[tabId];
     if (frames) {
     if (frames) {
@@ -52,7 +55,9 @@ export function resetValueOpener(tabId, frameId) {
         delete tabs[tabId];
         delete tabs[tabId];
       }
       }
     }
     }
-    if (isEmpty(tabs)) delete openers[id];
+    if (tabId == null || isEmpty(tabs)) {
+      delete openers[id];
+    }
   });
   });
 }
 }