Browse Source

refactor: async + more ES20xx in background/*

tophf 6 years ago
parent
commit
8275184868

+ 73 - 209
src/background/index.js

@@ -1,30 +1,21 @@
-import { noop, getUniqId, ensureArray } from '#/common';
-import { objectGet } from '#/common/object';
+import { sendCmd, noop } from '#/common';
+import { TIMEOUT_24HOURS, TIMEOUT_MAX } from '#/common/consts';
 import ua from '#/common/ua';
 import * as sync from './sync';
-import {
-  cache,
-  getRequestId, httpRequest, abortRequest, confirmInstall,
-  newScript, parseMeta,
-  setClipboard, checkUpdate,
-  getOption, setOption, hookOptions, getAllOptions,
-  initialize, sendMessageOrIgnore,
-} from './utils';
-import { tabOpen, tabClose } from './utils/tabs';
-import createNotification from './utils/notifications';
-import {
-  getScripts, markRemoved, removeScript, getData, checkRemove, getScriptsByURL,
-  updateScriptInfo, getExportData, getScriptCode,
-  getScriptByIds, moveScript, vacuum, parseScript, getScript,
-  sortScripts, getValueStoresByIds,
-} from './utils/db';
-import { resetBlacklist, testBlacklist } from './utils/tester';
-import {
-  setValueStore, updateValueStore, resetValueOpener, addValueOpener,
-} from './utils/values';
-import { setBadge } from './utils/icon';
+import { cache, commands, initialize } from './utils';
+import { getData, checkRemove, getScriptsByURL } from './utils/db';
+import { getOption, hookOptions } from './utils/options';
+import { resetValueOpener, addValueOpener } from './utils/values';
 import { SCRIPT_TEMPLATE, resetScriptTemplate } from './utils/template-hook';
+import './utils/clipboard';
 import './utils/commands';
+import './utils/icon';
+import './utils/notifications';
+import './utils/requests';
+import './utils/script';
+import './utils/tabs';
+import './utils/tester';
+import './utils/update';
 
 const VM_VER = browser.runtime.getManifest().version;
 let isApplied;
@@ -38,69 +29,15 @@ hookOptions((changes) => {
     togglePreinject(isApplied);
   }
   if (SCRIPT_TEMPLATE in changes) resetScriptTemplate(changes);
-  sendMessageOrIgnore({
-    cmd: 'UpdateOptions',
-    data: changes,
-  });
+  sendCmd('UpdateOptions', changes);
 });
 
-function checkUpdateAll() {
-  setOption('lastUpdate', Date.now());
-  getScripts()
-  .then((scripts) => {
-    const toUpdate = scripts.filter(item => objectGet(item, 'config.shouldUpdate'));
-    return Promise.all(toUpdate.map(checkUpdate));
-  })
-  .then((updatedList) => {
-    if (updatedList.some(Boolean)) sync.sync();
-  });
-}
-
-// setTimeout truncates the delay to a 32-bit signed integer so the max delay is ~24 days
-const TIMEOUT_MAX = 0x7FFF_FFFF;
-const TIMEOUT_24HOURS = 24 * 60 * 60 * 1000;
-
-function autoUpdate() {
-  const interval = (+getOption('autoUpdate') || 0) * TIMEOUT_24HOURS;
-  if (!interval) return;
-  let elapsed = Date.now() - getOption('lastUpdate');
-  if (elapsed >= interval) {
-    checkUpdateAll();
-    elapsed = 0;
-  }
-  clearTimeout(autoUpdate.timer);
-  autoUpdate.timer = setTimeout(autoUpdate, Math.min(TIMEOUT_MAX, interval - elapsed));
-}
-
-function autoCheckRemove() {
-  checkRemove();
-  setTimeout(autoCheckRemove, TIMEOUT_24HOURS);
-}
-
-const commands = {
-  NewScript(id) {
-    return id && cache.get(`new-${id}`) || newScript();
-  },
-  CacheNewScript(data) {
-    const id = getUniqId();
-    cache.put(`new-${id}`, newScript(data));
-    return id;
-  },
-  MarkRemoved({ id, removed }) {
-    return markRemoved(id, removed)
-    .then(() => { sync.sync(); });
-  },
-  RemoveScript(id) {
-    return removeScript(id)
-    .then(() => { sync.sync(); });
-  },
-  GetData() {
-    return getData()
-    .then((data) => {
-      data.sync = sync.getStates();
-      data.version = VM_VER;
-      return data;
-    });
+Object.assign(commands, {
+  async GetData() {
+    const data = await getData();
+    data.sync = sync.getStates();
+    data.version = VM_VER;
+    return data;
   },
   async GetInjected(url, src) {
     const { id: tabId } = src.tab || {};
@@ -119,113 +56,56 @@ const commands = {
     }
     return data;
   },
-  UpdateScriptInfo({ id, config }) {
-    return updateScriptInfo(id, {
-      config,
-      props: {
-        lastModified: Date.now(),
-      },
-    })
-    .then(() => { sync.sync(); });
-  },
-  GetValueStore(id) {
-    return getValueStoresByIds([id]).then(res => res[id] || {});
-  },
-  SetValueStore({ where, valueStore }) {
-    // Value store will be replaced soon.
-    return setValueStore(where, valueStore);
-  },
-  UpdateValue({ id, update }) {
-    // Value will be updated to store later.
-    return updateValueStore(id, update);
-  },
-  ExportZip({ values }) {
-    return getExportData(values);
-  },
-  GetScriptCode(id) {
-    return getScriptCode(id);
-  },
-  GetMetas(ids) {
-    return getScriptByIds(ids);
-  },
-  Move({ id, offset }) {
-    return moveScript(id, offset)
-    .then(() => {
-      sync.sync();
-    });
-  },
-  Vacuum: vacuum,
-  ParseScript(data) {
-    return parseScript(data).then((res) => {
-      sync.sync();
-      return res.data;
-    });
-  },
-  CheckUpdate(id) {
-    getScript({ id }).then(checkUpdate)
-    .then((updated) => {
-      if (updated) sync.sync();
-    });
-  },
-  CheckUpdateAll: checkUpdateAll,
-  ParseMeta(code) {
-    return parseMeta(code);
-  },
-  GetRequestId: getRequestId,
-  HttpRequest(details, src) {
-    httpRequest(details, src, (res) => (
-      browser.tabs.sendMessage(src.tab.id, {
-        cmd: 'HttpRequested',
-        data: res,
-      }, {
-        frameId: src.frameId,
-      })
-      .catch(noop)
-    ));
-  },
-  AbortRequest: abortRequest,
-  SetBadge: setBadge,
-  SyncAuthorize: sync.authorize,
-  SyncRevoke: sync.revoke,
-  SyncStart: sync.sync,
-  SyncSetConfig: sync.setConfig,
-  CacheLoad(data) {
-    return cache.get(data) || null;
-  },
-  CacheHit(data) {
-    cache.hit(data.key, data.lifetime);
-  },
-  Notification: createNotification,
-  SetClipboard: setClipboard,
-  TabOpen: tabOpen,
-  TabClose: tabClose,
-  GetAllOptions: getAllOptions,
-  GetOptions(data) {
-    return data.reduce((res, key) => {
-      res[key] = getOption(key);
-      return res;
-    }, {});
-  },
-  SetOptions(data) {
-    ensureArray(data).forEach(item => setOption(item.key, item.value));
-  },
-  ConfirmInstall: confirmInstall,
-  CheckScript({ name, namespace }) {
-    return getScript({ meta: { name, namespace } })
-    .then(script => (script && !script.config.removed ? script.meta.version : null));
-  },
-  CheckPosition() {
-    return sortScripts();
-  },
-  InjectScript(code, src) {
-    return browser.tabs.executeScript(src.tab.id, {
-      code,
-      runAt: 'document_start',
+});
+
+// commands to sync unconditionally regardless of the returned value from the handler
+const commandsToSync = [
+  'MarkRemoved',
+  'Move',
+  'ParseScript',
+  'RemoveScript',
+  'UpdateScriptInfo',
+];
+// commands to sync only if the handler returns a truthy value
+const commandsToSyncIfTruthy = [
+  'CheckRemove',
+  'CheckUpdate',
+  'CheckUpdateAll',
+];
+
+function handleCommandMessage(req, src) {
+  const { cmd } = req;
+  const func = commands[cmd] || noop;
+  let res = func(req.data, src);
+  if (typeof res !== 'undefined') {
+    // If res is not instance of native Promise, browser APIs will not wait for it.
+    res = Promise.resolve(res)
+    .then(data => {
+      if (commandsToSync.includes(cmd)
+      || data && commandsToSyncIfTruthy.includes(cmd)) {
+        sync.sync();
+      }
+      return { data };
+    }, (error) => {
+      if (process.env.DEBUG) console.error(error);
+      return { error };
     });
-  },
-  TestBlacklist: testBlacklist,
-  CheckRemove: checkRemove,
-};
+  }
+  // `undefined` is ignored so we're sending `null` instead
+  return res || null;
+}
+
+function autoUpdate() {
+  const interval = (+getOption('autoUpdate') || 0) * TIMEOUT_24HOURS;
+  if (!interval) return;
+  let elapsed = Date.now() - getOption('lastUpdate');
+  if (elapsed >= interval) {
+    handleCommandMessage({ cmd: 'CheckUpdateAll' });
+    elapsed = 0;
+  }
+  clearTimeout(autoUpdate.timer);
+  autoUpdate.timer = setTimeout(autoUpdate, Math.min(TIMEOUT_MAX, interval - elapsed));
+}
 
 function togglePreinject(enable) {
   if (enable) {
@@ -250,29 +130,13 @@ function preinject({ url }) {
 
 initialize()
 .then(() => {
-  browser.runtime.onMessage.addListener((req, src) => {
-    const func = commands[req.cmd];
-    let res;
-    if (func) {
-      res = func(req.data, src);
-      if (typeof res !== 'undefined') {
-        // If res is not instance of native Promise, browser APIs will not wait for it.
-        res = Promise.resolve(res)
-        .then(data => ({ data }), (error) => {
-          if (process.env.DEBUG) console.error(error);
-          return { error };
-        });
-      }
-    }
-    // undefined will be ignored
-    return res || null;
-  });
+  browser.runtime.onMessage.addListener(handleCommandMessage);
   injectInto = getOption('defaultInjectInto');
   isApplied = getOption('isApplied');
   togglePreinject(isApplied);
   setTimeout(autoUpdate, 2e4);
   sync.initialize();
-  resetBlacklist();
-  autoCheckRemove();
+  checkRemove();
+  setInterval(checkRemove, TIMEOUT_24HOURS);
   global.dispatchEvent(new Event('backgroundInitialized'));
 });

+ 1 - 3
src/background/plugin/index.js

@@ -8,9 +8,7 @@ import {
 export const script = {
   update(data) {
     // Update an existing script by ID
-    // data: {
-    //   id, code, message, isNew, config, custom, props, update,
-    // }
+    // { id, code, message, isNew, config, custom, props, update }
     return parseScript(data);
   },
   list() {

+ 2 - 1
src/background/sync/base.js

@@ -1,6 +1,7 @@
 import {
   debounce, normalizeKeys, request, noop, makePause, ensureArray,
 } from '#/common';
+import { TIMEOUT_HOUR } from '#/common/consts';
 import {
   objectGet, objectSet, objectPick, objectPurify,
 } from '#/common/object';
@@ -16,7 +17,7 @@ import { script as pluginScript } from '../plugin';
 const serviceNames = [];
 const serviceClasses = [];
 const services = {};
-const autoSync = debounce(sync, 60 * 60 * 1000);
+const autoSync = debounce(sync, TIMEOUT_HOUR);
 let working = Promise.resolve();
 let syncConfig;
 

+ 8 - 0
src/background/sync/index.js

@@ -11,6 +11,14 @@ import './dropbox';
 import './onedrive';
 import './googledrive';
 import './webdav';
+import { commands } from '../utils/message';
+
+Object.assign(commands, {
+  SyncAuthorize: authorize,
+  SyncRevoke: revoke,
+  SyncStart: sync,
+  SyncSetConfig: setConfig,
+});
 
 browser.tabs.onUpdated.addListener((tabId, changes) => {
   if (changes.url && checkAuthUrl(changes.url)) browser.tabs.remove(tabId);

+ 13 - 1
src/background/utils/cache.js

@@ -1,5 +1,17 @@
 import initCache from '#/common/cache';
+import { commands } from './message';
 
-export default initCache({
+const cache = initCache({
   lifetime: 5 * 60 * 1000,
 });
+
+Object.assign(commands, {
+  CacheLoad(data) {
+    return cache.get(data) || null;
+  },
+  CacheHit(data) {
+    cache.hit(data.key, data.lifetime);
+  },
+});
+
+export default cache;

+ 17 - 13
src/background/utils/clipboard.js

@@ -1,19 +1,23 @@
+import { commands } from './message';
+
 const textarea = document.createElement('textarea');
+let clipboardData;
+
+Object.assign(commands, {
+  SetClipboard(data) {
+    clipboardData = data;
+    textarea.focus();
+    const ret = document.execCommand('copy', false, null);
+    if (!ret && process.env.DEBUG) {
+      console.warn('Copy failed!');
+    }
+  },
+});
+
 document.body.appendChild(textarea);
 
-let clipboardData;
-function onCopy(e) {
+document.addEventListener('copy', e => {
   e.preventDefault();
   const { type, data } = clipboardData;
   e.clipboardData.setData(type || 'text/plain', data);
-}
-document.addEventListener('copy', onCopy, false);
-
-export default function setClipboard(data) {
-  clipboardData = data;
-  textarea.focus();
-  const ret = document.execCommand('copy', false, null);
-  if (!ret && process.env.DEBUG) {
-    console.warn('Copy failed!');
-  }
-}
+});

+ 2 - 2
src/background/utils/commands.js

@@ -1,5 +1,5 @@
 import { getActiveTab } from '#/common';
-import { tabOpen } from './tabs';
+import { commands } from './message';
 
 const ROUTES = {
   newScript: '#scripts/_new',
@@ -11,6 +11,6 @@ global.addEventListener('backgroundInitialized', () => {
     const tab = await getActiveTab();
     const optionsUrl = browser.runtime.getURL(browser.runtime.getManifest().options_ui.page);
     const url = `${optionsUrl}${ROUTES[cmd] || ''}`;
-    tabOpen({ url, insert: true }, { tab });
+    commands.TabOpen({ url, insert: true }, { tab });
   });
 }, { once: true });

+ 353 - 285
src/background/utils/db.js

@@ -1,8 +1,7 @@
 import {
-  i18n, getFullUrl, isRemote, getRnd4,
+  i18n, getFullUrl, isRemote, getRnd4, sendCmd,
 } from '#/common';
-import { objectGet, objectSet } from '#/common/object';
-import { CMD_SCRIPT_ADD, CMD_SCRIPT_UPDATE } from '#/common/consts';
+import { CMD_SCRIPT_ADD, CMD_SCRIPT_UPDATE, TIMEOUT_WEEK } from '#/common/consts';
 import storage from '#/common/storage';
 import pluginEvents from '../plugin/events';
 import {
@@ -10,9 +9,9 @@ import {
 } from './script';
 import { testScript, testBlacklist } from './tester';
 import { register } from './init';
+import { commands } from './message';
 import patchDB from './patch-db';
 import { setOption } from './options';
-import { sendMessageOrIgnore } from './message';
 
 const store = {};
 
@@ -20,152 +19,173 @@ storage.script.onDump = (item) => {
   store.scriptMap[item.props.id] = item;
 };
 
-register(initialize());
-
-function initialize() {
-  return browser.storage.local.get('version')
-  .then(({ version: lastVersion }) => {
-    const { version } = browser.runtime.getManifest();
-    return (lastVersion ? Promise.resolve() : patchDB())
-    .then(() => {
-      if (version !== lastVersion) return browser.storage.local.set({ version });
+Object.assign(commands, {
+  CheckPosition: sortScripts,
+  CheckRemove: checkRemove,
+  CheckScript({ name, namespace }) {
+    const script = getScript({ meta: { name, namespace } });
+    return script && !script.config.removed
+      ? script.meta.version
+      : null;
+  },
+  ExportZip({ values }) {
+    return getExportData(values);
+  },
+  GetScriptCode: getScriptCode,
+  GetMetas: getScriptByIds,
+  MarkRemoved({ id, removed }) {
+    return markRemoved(id, removed);
+  },
+  Move({ id, offset }) {
+    return moveScript(id, offset);
+  },
+  RemoveScript(id) {
+    return removeScript(id);
+  },
+  ParseMeta: parseMeta,
+  ParseScript: parseScript,
+  UpdateScriptInfo({ id, config }) {
+    return updateScriptInfo(id, {
+      config,
+      props: { lastModified: Date.now() },
     });
-  })
-  .then(() => browser.storage.local.get())
-  .then((data) => {
-    const scripts = [];
-    const storeInfo = {
-      id: 0,
-      position: 0,
-    };
-    const idMap = {};
-    const uriMap = {};
-    Object.keys(data).forEach((key) => {
-      const script = data[key];
-      if (key.startsWith('scr:')) {
-        // {
-        //   meta,
-        //   custom,
-        //   props: { id, position, uri },
-        //   config: { enabled, shouldUpdate },
-        // }
-        const id = getInt(key.slice(4));
-        if (!id || idMap[id]) {
-          // ID conflicts!
-          // Should not happen, discard duplicates.
-          return;
-        }
-        idMap[id] = script;
-        const uri = getNameURI(script);
-        if (uriMap[uri]) {
-          // Namespace conflicts!
-          // Should not happen, discard duplicates.
-          return;
-        }
-        uriMap[uri] = script;
-        script.props = {
-          ...script.props,
-          id,
-          uri,
-        };
-        script.custom = {
-          ...getDefaultCustom(),
-          ...script.custom,
-        };
-        storeInfo.id = Math.max(storeInfo.id, id);
-        storeInfo.position = Math.max(storeInfo.position, getInt(objectGet(script, 'props.position')));
-        scripts.push(script);
+  },
+  Vacuum: vacuum,
+});
+
+register(async () => {
+  const { version: lastVersion } = await browser.storage.local.get('version');
+  const { version } = browser.runtime.getManifest();
+  if (!lastVersion) await patchDB();
+  if (version !== lastVersion) browser.storage.local.set({ version });
+  const data = await browser.storage.local.get();
+  const scripts = [];
+  const storeInfo = {
+    id: 0,
+    position: 0,
+  };
+  const idMap = {};
+  const uriMap = {};
+  Object.entries(data).forEach(([key, script]) => {
+    if (key.startsWith('scr:')) {
+      // {
+      //   meta,
+      //   custom,
+      //   props: { id, position, uri },
+      //   config: { enabled, shouldUpdate },
+      // }
+      const id = getInt(key.slice(4));
+      if (!id || idMap[id]) {
+        // ID conflicts!
+        // Should not happen, discard duplicates.
+        return;
       }
-    });
-    Object.assign(store, {
-      scripts,
-      storeInfo,
-      scriptMap: scripts.reduce((map, item) => {
-        map[item.props.id] = item;
-        return map;
-      }, {}),
-    });
-    if (process.env.DEBUG) {
-      console.log('store:', store); // eslint-disable-line no-console
+      idMap[id] = script;
+      const uri = getNameURI(script);
+      if (uriMap[uri]) {
+        // Namespace conflicts!
+        // Should not happen, discard duplicates.
+        return;
+      }
+      uriMap[uri] = script;
+      script.props = {
+        ...script.props,
+        id,
+        uri,
+      };
+      script.custom = {
+        ...getDefaultCustom(),
+        ...script.custom,
+      };
+      storeInfo.id = Math.max(storeInfo.id, id);
+      storeInfo.position = Math.max(storeInfo.position, getInt(script.props.position));
+      scripts.push(script);
     }
-    return sortScripts();
   });
-}
+  Object.assign(store, {
+    scripts,
+    storeInfo,
+    scriptMap: scripts.reduce((map, item) => {
+      map[item.props.id] = item;
+      return map;
+    }, {}),
+  });
+  if (process.env.DEBUG) {
+    console.log('store:', store); // eslint-disable-line no-console
+  }
+  return sortScripts();
+});
 
+/** @return {number} */
 function getInt(val) {
   return +val || 0;
 }
 
+/** @return {void} */
 function updateLastModified() {
   setOption('lastModified', Date.now());
 }
 
-export function normalizePosition() {
-  const updates = [];
-  const positionKey = 'props.position';
-  store.scripts.forEach((item, index) => {
+/** @return {Promise<number>} */
+export async function normalizePosition() {
+  const updates = store.scripts.filter(({ props }, index) => {
     const position = index + 1;
-    if (objectGet(item, positionKey) !== position) {
-      objectSet(item, positionKey, position);
-      updates.push(item);
-    }
+    const res = props.position !== position;
+    if (res) props.position = position;
+    return res;
   });
   store.storeInfo.position = store.scripts.length;
-  const { length } = updates;
-  if (!length) return Promise.resolve();
-  return storage.script.dump(updates)
-  .then(() => {
+  if (updates.length) {
+    await storage.script.dump(updates);
     updateLastModified();
-    return length;
-  });
+  }
+  return updates.length;
 }
 
-export function sortScripts() {
-  store.scripts.sort((a, b) => {
-    const [pos1, pos2] = [a, b].map(item => getInt(objectGet(item, 'props.position')));
-    return pos1 - pos2;
-  });
-  return normalizePosition()
-  .then((changed) => {
-    sendMessageOrIgnore({ cmd: 'ScriptsUpdated' });
-    return changed;
-  });
+/** @return {Promise<number>} */
+export async function sortScripts() {
+  store.scripts.sort((a, b) => getInt(a.props.position) - getInt(b.props.position));
+  const changed = await normalizePosition();
+  sendCmd('ScriptsUpdated', null);
+  return changed;
 }
 
-// TODO: depromisify getScript and all dependent code
-export function getScriptByIdSync(id) {
+/** @return {VMScript} */
+export function getScriptById(id) {
   return store.scriptMap[id];
 }
 
-export function getScript(where) {
+/** @return {VMScript} */
+export function getScript({ id, uri, meta }) {
   let script;
-  if (where.id) {
-    script = store.scriptMap[where.id];
+  if (id) {
+    script = getScriptById(id);
   } else {
-    const uri = where.uri || getNameURI({ meta: where.meta, id: '@@should-have-name' });
-    const predicate = item => uri === objectGet(item, 'props.uri');
-    script = store.scripts.find(predicate);
+    if (!uri) uri = getNameURI({ meta, id: '@@should-have-name' });
+    script = store.scripts.find(({ props }) => uri === props.uri);
   }
-  return Promise.resolve(script);
+  return script;
 }
 
+/** @return {VMScript[]} */
 export function getScripts() {
-  return Promise.resolve(store.scripts)
-  .then(scripts => scripts.filter(script => !script.config.removed));
+  return store.scripts.filter(script => !script.config.removed);
 }
 
+/** @return {VMScript[]} */
 export function getScriptByIds(ids) {
-  return Promise.all(ids.map(id => getScript({ id })))
-  .then(scripts => scripts.filter(Boolean));
+  return ids.map(getScriptById).filter(Boolean);
 }
 
+/** @return {Promise<string>} */
 export function getScriptCode(id) {
   return storage.code.getOne(id);
 }
 
 /**
  * @desc Load values for batch updates.
- * @param {Array} ids
+ * @param {number[]} ids
+ * @return {Promise}
  */
 export function getValueStoresByIds(ids) {
   return storage.value.getMulti(ids);
@@ -174,21 +194,18 @@ export function getValueStoresByIds(ids) {
 /**
  * @desc Dump values for batch updates.
  * @param {Object} valueDict { id1: value1, id2: value2, ... }
+ * @return {Promise}
  */
-export function dumpValueStores(valueDict) {
-  if (process.env.DEBUG) {
-    console.info('Update value stores', valueDict);
-  }
-  return storage.value.dump(valueDict).then(() => valueDict);
+export async function dumpValueStores(valueDict) {
+  if (process.env.DEBUG) console.info('Update value stores', valueDict);
+  await storage.value.dump(valueDict);
+  return valueDict;
 }
 
-export function dumpValueStore(where, valueStore) {
-  return (where.id
-    ? Promise.resolve(where.id)
-    : getScript(where).then(script => objectGet(script, 'props.id')))
-  .then((id) => {
-    if (id) return dumpValueStores({ [id]: valueStore });
-  });
+/** @return {Promise<Object|undefined>} */
+export async function dumpValueStore(where, valueStore) {
+  const id = where.id || getScript(where)?.props.id;
+  return id && dumpValueStores({ [id]: valueStore });
 }
 
 const gmValues = [
@@ -200,8 +217,9 @@ const gmValues = [
 
 /**
  * @desc Get scripts to be injected to page with specific URL.
+ * @return {Promise}
  */
-export function getScriptsByURL(url) {
+export async function getScriptsByURL(url) {
   const scripts = testBlacklist(url)
     ? []
     : store.scripts.filter(script => !script.config.removed && testScript(url, script));
@@ -223,46 +241,49 @@ export function getScriptsByURL(url) {
   .filter(script => script.config.enabled);
   const scriptsWithValue = enabledScripts
   .filter(script => script.meta.grant?.some(gm => gmValues.includes(gm)));
-  return Promise.all([
+  const [require, cache, values, code] = await Promise.all([
     storage.require.getMulti(Object.keys(reqKeys)),
     storage.cache.getMulti(Object.keys(cacheKeys)),
     storage.value.getMulti(scriptsWithValue.map(script => script.props.id), {}),
     storage.code.getMulti(enabledScripts.map(script => script.props.id)),
-  ])
-  .then(([require, cache, values, code]) => ({
+  ]);
+  return {
     scripts,
     require,
     cache,
     values,
     code,
-  }));
+  };
+}
+
+/** @return {string[]} */
+function getIconUrls() {
+  return store.scripts.reduce((res, script) => {
+    const { icon } = script.meta;
+    if (isRemote(icon)) {
+      res.push(script.custom.pathMap?.[icon] || icon);
+    }
+    return res;
+  }, []);
 }
 
 /**
  * @desc Get data for dashboard.
+ * @return {Promise}
  */
-export function getData() {
-  const cacheKeys = {};
-  const { scripts } = store;
-  scripts.forEach((script) => {
-    const icon = objectGet(script, 'meta.icon');
-    if (isRemote(icon)) {
-      const pathMap = objectGet(script, 'custom.pathMap') || {};
-      const fullUrl = pathMap[icon] || icon;
-      cacheKeys[fullUrl] = 1;
-    }
-  });
-  return storage.cache.getMulti(Object.keys(cacheKeys))
-  .then(cache => ({ scripts, cache }));
+export async function getData() {
+  return {
+    scripts: store.scripts,
+    cache: await storage.cache.getMulti(getIconUrls()),
+  };
 }
 
+/** @return {number} */
 export function checkRemove({ force } = {}) {
   const now = Date.now();
-  const toRemove = store.scripts.filter((script) => {
-    if (!script.config.removed) return false;
-    const lastModified = +script.props.lastModified || 0;
-    return force || now - lastModified > 7 * 24 * 60 * 60 * 1000;
-  });
+  const toRemove = store.scripts.filter(script => script.config.removed && (
+    force || now - getInt(script.props.lastModified) > TIMEOUT_WEEK
+  ));
   if (toRemove.length) {
     store.scripts = store.scripts.filter(script => !script.config.removed);
     const ids = toRemove.map(script => script.props.id);
@@ -270,24 +291,24 @@ export function checkRemove({ force } = {}) {
     storage.code.removeMulti(ids);
     storage.value.removeMulti(ids);
   }
-  return Promise.resolve(toRemove.length);
+  return toRemove.length;
 }
 
-export function removeScript(id) {
-  const i = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
+/** @return {Promise} */
+export async function removeScript(id) {
+  const i = store.scripts.indexOf(getScriptById(id));
   if (i >= 0) {
     store.scripts.splice(i, 1);
-    storage.script.remove(id);
-    storage.code.remove(id);
-    storage.value.remove(id);
+    await Promise.all([
+      storage.script.remove(id),
+      storage.code.remove(id),
+      storage.value.remove(id),
+    ]);
   }
-  sendMessageOrIgnore({
-    cmd: 'RemoveScript',
-    data: id,
-  });
-  return Promise.resolve();
+  return sendCmd('RemoveScript', id);
 }
 
+/** @return {Promise} */
 export function markRemoved(id, removed) {
   return updateScriptInfo(id, {
     config: {
@@ -299,8 +320,9 @@ export function markRemoved(id, removed) {
   });
 }
 
+/** @return {Promise<number>} */
 export function moveScript(id, offset) {
-  const index = store.scripts.findIndex(item => id === objectGet(item, 'props.id'));
+  const index = store.scripts.indexOf(getScriptById(id));
   const step = offset > 0 ? 1 : -1;
   const indexStart = index;
   const indexEnd = index + offset;
@@ -320,12 +342,18 @@ export function moveScript(id, offset) {
   return normalizePosition();
 }
 
+/** @return {string} */
 function getUUID(id) {
   const idSec = (id + 0x10bde6a2).toString(16).slice(-8);
   return `${idSec}-${getRnd4()}-${getRnd4()}-${getRnd4()}-${getRnd4()}${getRnd4()}${getRnd4()}`;
 }
 
-function saveScript(script, code) {
+/**
+ * @param {VMScript} script
+ * @param {string} code
+ * @return {Promise<Array>} [VMScript, codeString]
+ */
+async function saveScript(script, code) {
   const config = script.config || {};
   config.enabled = getInt(config.enabled);
   config.shouldUpdate = getInt(config.shouldUpdate);
@@ -340,10 +368,7 @@ function saveScript(script, code) {
   props.uri = getNameURI(script);
   props.uuid = props.uuid || getUUID(props.id);
   // Do not allow script with same name and namespace
-  if (store.scripts.some((item) => {
-    const itemProps = item.props || {};
-    return props.id !== itemProps.id && props.uri === itemProps.uri;
-  })) {
+  if (store.scripts.some(({ props: { id, uri } = {} }) => props.id !== id && props.uri === uri)) {
     throw i18n('msgNamespaceConflict');
   }
   if (oldScript) {
@@ -368,96 +393,83 @@ function saveScript(script, code) {
   ]);
 }
 
-export function updateScriptInfo(id, data) {
+/** @return {Promise} */
+export async function updateScriptInfo(id, data) {
   const script = store.scriptMap[id];
-  if (!script) return Promise.reject();
+  if (!script) throw null;
   script.props = Object.assign({}, script.props, data.props);
   script.config = Object.assign({}, script.config, data.config);
   // script.custom = Object.assign({}, script.custom, data.custom);
-  return storage.script.dump(script)
-  .then(() => sendMessageOrIgnore({
-    cmd: CMD_SCRIPT_UPDATE,
-    data: {
-      where: { id },
-      update: script,
-    },
-  }));
+  await storage.script.dump(script);
+  return sendCmd(CMD_SCRIPT_UPDATE, { where: { id }, update: script });
 }
 
-export function getExportData(withValues) {
-  return getScripts()
-  .then((scripts) => {
-    const ids = scripts.map(({ props: { id } }) => id);
-    return storage.code.getMulti(ids)
-    .then((codeMap) => {
-      const data = {};
-      data.items = scripts.map(script => ({ script, code: codeMap[script.props.id] }));
-      if (withValues) {
-        return storage.value.getMulti(ids)
-        .then((values) => {
-          data.values = values;
-          return data;
-        });
-      }
-      return data;
-    });
-  });
+/** @return {Promise} */
+export async function getExportData(withValues) {
+  const scripts = getScripts();
+  const ids = scripts.map(({ props: { id } }) => id);
+  const codeMap = await storage.code.getMulti(ids);
+  return {
+    items: scripts.map(script => ({ script, code: codeMap[script.props.id] })),
+    ...withValues && {
+      values: await storage.value.getMulti(ids),
+    },
+  };
 }
 
-export function parseScript(data) {
-  const {
-    id, code, message, isNew, config, custom, props, update,
-  } = data;
-  const meta = parseMeta(code);
-  if (!meta.name) return Promise.reject(i18n('msgInvalidScript'));
+/** @return {Promise} */
+export async function parseScript(src) {
+  const meta = parseMeta(src.code);
+  if (!meta.name) throw i18n('msgInvalidScript');
   const result = {
-    cmd: CMD_SCRIPT_UPDATE,
-    data: {
-      update: {
-        message: message == null ? i18n('msgUpdated') : message || '',
-      },
+    update: {
+      message: src.message == null ? i18n('msgUpdated') : src.message || '',
     },
   };
-  return getScript({ id, meta })
-  .then((oldScript) => {
-    let script;
-    if (oldScript) {
-      if (isNew) throw i18n('msgNamespaceConflict');
-      script = Object.assign({}, oldScript);
-    } else {
-      ({ script } = newScript());
-      result.cmd = CMD_SCRIPT_ADD;
-      result.data.isNew = true;
-      result.data.update.message = i18n('msgInstalled');
-    }
-    script.config = Object.assign({}, script.config, config, {
-      removed: 0, // force reset `removed` since this is an installation
-    });
-    script.custom = Object.assign({}, script.custom, custom);
-    script.props = Object.assign({}, script.props, {
-      lastModified: Date.now(),
-      lastUpdated: Date.now(),
-    }, props);
-    script.meta = meta;
-    if (!meta.homepageURL && !script.custom.homepageURL && isRemote(data.from)) {
-      script.custom.homepageURL = data.from;
-    }
-    if (isRemote(data.url)) script.custom.lastInstallURL = data.url;
-    const position = +data.position;
-    if (position) objectSet(script, 'props.position', position);
-    buildPathMap(script, data.url);
-    return saveScript(script, code).then(() => script);
-  })
-  .then((script) => {
-    fetchScriptResources(script, data);
-    Object.assign(result.data.update, script, update);
-    result.data.where = { id: script.props.id };
-    sendMessageOrIgnore(result);
-    pluginEvents.emit('scriptChanged', result.data);
-    return result;
-  });
+  let cmd = CMD_SCRIPT_UPDATE;
+  let script;
+  const oldScript = await getScript({ id: src.id, meta });
+  if (oldScript) {
+    if (src.isNew) throw i18n('msgNamespaceConflict');
+    script = { ...oldScript };
+  } else {
+    ({ script } = newScript());
+    cmd = CMD_SCRIPT_ADD;
+    result.isNew = true;
+    result.update.message = i18n('msgInstalled');
+  }
+  script.config = {
+    ...script.config,
+    ...src.config,
+    removed: 0, // force reset `removed` since this is an installation
+  };
+  script.custom = {
+    ...script.custom,
+    ...src.custom,
+  };
+  script.props = {
+    ...script.props,
+    lastModified: Date.now(),
+    lastUpdated: Date.now(),
+    ...src.props,
+  };
+  script.meta = meta;
+  if (!meta.homepageURL && !script.custom.homepageURL && isRemote(src.from)) {
+    script.custom.homepageURL = src.from;
+  }
+  if (isRemote(src.url)) script.custom.lastInstallURL = src.url;
+  if (src.position) script.props.position = +src.position;
+  buildPathMap(script, src.url);
+  await saveScript(script, src.code);
+  fetchScriptResources(script, src);
+  Object.assign(result.update, script, src.update);
+  result.where = { id: script.props.id };
+  sendCmd(cmd, result);
+  pluginEvents.emit('scriptChanged', result);
+  return result;
 }
 
+/** @return {Object} */
 function buildPathMap(script, base) {
   const { meta } = script;
   const baseUrl = base || script.custom.lastInstallURL;
@@ -476,12 +488,13 @@ function buildPathMap(script, base) {
   return pathMap;
 }
 
+/** @return {void} */
 function fetchScriptResources(script, cache) {
   const { meta, custom: { pathMap } } = script;
   // @require
   meta.require.forEach((key) => {
     const fullUrl = pathMap[key] || key;
-    const cached = objectGet(cache, ['require', fullUrl]);
+    const cached = cache.require?.[fullUrl];
     if (cached) {
       storage.require.set(fullUrl, cached);
     } else {
@@ -491,7 +504,7 @@ function fetchScriptResources(script, cache) {
   // @resource
   Object.values(meta.resources).forEach((url) => {
     const fullUrl = pathMap[url] || url;
-    const cached = objectGet(cache, ['resources', fullUrl]);
+    const cached = cache.resources?.[fullUrl];
     if (cached) {
       storage.cache.set(fullUrl, cached);
     } else {
@@ -519,7 +532,8 @@ function fetchScriptResources(script, cache) {
   }
 }
 
-export function vacuum() {
+/** @return {Promise<void>} */
+export async function vacuum() {
   const valueKeys = {};
   const cacheKeys = {};
   const requireKeys = {};
@@ -530,52 +544,106 @@ export function vacuum() {
     [storage.require, requireKeys],
     [storage.code, codeKeys],
   ];
-  return browser.storage.local.get()
-  .then((data) => {
-    Object.keys(data).forEach((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 touch = (obj, key) => {
-      if (obj[key] < 0) obj[key] = 1;
-      else if (!obj[key]) obj[key] = 2;
-    };
-    store.scripts.forEach((script) => {
-      const { id } = script.props;
-      touch(codeKeys, id);
-      touch(valueKeys, id);
-      if (!script.custom.pathMap) buildPathMap(script);
-      const { pathMap } = script.custom;
-      script.meta.require.forEach((url) => {
-        touch(requireKeys, pathMap[url] || url);
-      });
-      Object.values(script.meta.resources).forEach((url) => {
-        touch(cacheKeys, pathMap[url] || url);
-      });
-      const { icon } = script.meta;
-      if (isRemote(icon)) {
-        const fullUrl = pathMap[icon] || icon;
-        touch(cacheKeys, fullUrl);
+  const data = await browser.storage.local.get();
+  Object.keys(data).forEach((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;
     });
-    mappings.forEach(([substore, map]) => {
-      Object.keys(map).forEach((key) => {
-        const value = map[key];
-        if (value < 0) {
-          // redundant value
-          substore.remove(key);
-        } else if (value === 2 && substore.fetch) {
-          // missing resource
-          substore.fetch(key);
-        }
-      });
+  });
+  const touch = (obj, key) => {
+    if (obj[key] < 0) {
+      obj[key] = 1;
+    } else if (!obj[key]) {
+      obj[key] = 2;
+    }
+  };
+  store.scripts.forEach((script) => {
+    const { id } = script.props;
+    touch(codeKeys, id);
+    touch(valueKeys, id);
+    if (!script.custom.pathMap) buildPathMap(script);
+    const { pathMap } = script.custom;
+    script.meta.require.forEach((url) => {
+      touch(requireKeys, pathMap[url] || url);
+    });
+    Object.values(script.meta.resources).forEach((url) => {
+      touch(cacheKeys, pathMap[url] || url);
+    });
+    const { icon } = script.meta;
+    if (isRemote(icon)) {
+      const fullUrl = pathMap[icon] || icon;
+      touch(cacheKeys, fullUrl);
+    }
+  });
+  mappings.forEach(([substore, map]) => {
+    Object.keys(map).forEach((key) => {
+      const value = map[key];
+      if (value < 0) {
+        // redundant value
+        substore.remove(key);
+      } else if (value === 2 && substore.fetch) {
+        // missing resource
+        substore.fetch(key);
+      }
     });
   });
 }
+
+/** @typedef VMScript
+ * @property {VMScriptConfig} config
+ * @property {VMScriptCustom} custom
+ * @property {VMScriptMeta} meta
+ * @property {VMScriptProps} props
+ */
+/** @typedef VMScriptConfig *
+ * @property {Boolean} enabled - stored as 0 or 1
+ * @property {Boolean} removed - stored as 0 or 1
+ * @property {Boolean} shouldUpdate - stored as 0 or 1
+ */
+/** @typedef VMScriptCustom *
+ * @property {string[]} exclude
+ * @property {string[]} excludeMatch
+ * @property {string[]} include
+ * @property {string[]} match
+ * @property {boolean} origExclude
+ * @property {boolean} origExcludeMatch
+ * @property {boolean} origInclude
+ * @property {boolean} origMatch
+ * @property {Object} pathMap
+ * @property {VMScriptRunAt} runAt
+ */
+/** @typedef VMScriptMeta *
+ * @property {string} description
+ * @property {string} downloadURL
+ * @property {string[]} exclude
+ * @property {string[]} exclude-match
+ * @property {string[]} grant
+ * @property {string} homepageURL
+ * @property {string} icon
+ * @property {string[]} include
+ * @property {'auto' | 'page' | 'content'} inject-into
+ * @property {string[]} match
+ * @property {string} namespace
+ * @property {string} name
+ * @property {boolean} noframes
+ * @property {string[]} require
+ * @property {Object} resource
+ * @property {VMScriptRunAt} run-at
+ * @property {string} supportURL
+ * @property {string} version
+ */
+/** @typedef VMScriptProps *
+ * @property {number} id
+ * @property {number} lastModified
+ * @property {number} lastUpdated
+ * @property {number} position
+ * @property {string} uri
+ * @property {string} uuid
+ */
+/** @typedef {'document-start' | 'document-end' | 'document-idle'} VMScriptRunAt */

+ 6 - 2
src/background/utils/icon.js

@@ -1,10 +1,14 @@
 import { i18n, noop } from '#/common';
 import ua from '#/common/ua';
 import { INJECTABLE_TAB_URL_RE } from '#/common/consts';
-import { forEachTab } from './message';
+import { commands, forEachTab } from './message';
 import { getOption, hookOptions } from './options';
 import { testBlacklist } from './tester';
 
+Object.assign(commands, {
+  SetBadge: setBadge,
+});
+
 // Firefox Android does not support such APIs, use noop
 
 const browserAction = [
@@ -63,7 +67,7 @@ browser.tabs.onUpdated.addListener((tabId, info, tab) => {
   }
 });
 
-export function setBadge(ids, src) {
+function setBadge(ids, src) {
   const { id: tabId } = src.tab || {};
   const data = badges[tabId] || {};
   if (!data.idMap || src.frameId === 0) {

+ 2 - 6
src/background/utils/index.js

@@ -1,10 +1,6 @@
 export { default as cache } from './cache';
-export { default as setClipboard } from './clipboard';
-export { default as checkUpdate } from './update';
 export { default as getEventEmitter } from './events';
-export * from './script';
-export * from './options';
-export * from './requests';
-export * from './search';
 export { initialize } from './init';
 export * from './message';
+export * from './options';
+export * from './search';

+ 2 - 0
src/background/utils/message.js

@@ -1,5 +1,7 @@
 import { defaultImage, i18n, noop } from '#/common';
 
+export const commands = {};
+
 export function notify(options) {
   browser.notifications.create(options.id || 'ViolentMonkey', {
     type: 'basic',

+ 15 - 14
src/background/utils/notifications.js

@@ -1,7 +1,22 @@
 import { i18n, defaultImage, noop } from '#/common';
+import { commands } from './message';
 
 const openers = {};
 
+Object.assign(commands, {
+  async Notification(data, src) {
+    const srcTab = src.tab || {};
+    const notificationId = await browser.notifications.create({
+      type: 'basic',
+      title: data.title || i18n('extName'),
+      message: data.text,
+      iconUrl: data.image || defaultImage,
+    });
+    openers[notificationId] = srcTab.id;
+    return notificationId;
+  },
+});
+
 browser.notifications.onClicked.addListener((id) => {
   const openerId = openers[id];
   if (openerId) {
@@ -24,17 +39,3 @@ browser.notifications.onClosed.addListener((id) => {
     delete openers[id];
   }
 });
-
-export default function createNotification(data, src) {
-  const srcTab = src.tab || {};
-  return browser.notifications.create({
-    type: 'basic',
-    title: data.title || i18n('extName'),
-    message: data.text,
-    iconUrl: data.image || defaultImage,
-  })
-  .then((notificationId) => {
-    openers[notificationId] = srcTab.id;
-    return notificationId;
-  });
-}

+ 17 - 9
src/background/utils/options.js

@@ -1,7 +1,22 @@
-import { initHooks, debounce, normalizeKeys } from '#/common';
-import { objectGet, objectSet } from '#/common/object';
+import {
+  debounce, ensureArray, initHooks, normalizeKeys,
+} from '#/common';
+import { objectGet, objectSet, objectMap } from '#/common/object';
 import defaults from '#/common/options-defaults';
 import { register } from './init';
+import { commands } from './message';
+
+Object.assign(commands, {
+  GetAllOptions() {
+    return commands.GetOptions(defaults);
+  },
+  GetOptions(data) {
+    return objectMap(data, key => getOption(key));
+  },
+  SetOptions(data) {
+    ensureArray(data).forEach(item => setOption(item.key, item.value));
+  },
+});
 
 let changes = {};
 const hooks = initHooks();
@@ -71,11 +86,4 @@ export function setOption(key, value) {
   }
 }
 
-export function getAllOptions() {
-  return Object.keys(defaults).reduce((res, key) => {
-    res[key] = getOption(key);
-    return res;
-  }, {});
-}
-
 export const hookOptions = hooks.hook;

+ 39 - 24
src/background/utils/requests.js

@@ -1,15 +1,49 @@
 import {
-  getUniqId, request, i18n, isEmpty,
+  getUniqId, request, i18n, isEmpty, noop,
 } from '#/common';
 import { objectPick } from '#/common/object';
 import ua from '#/common/ua';
 import cache from './cache';
 import { isUserScript, parseMeta } from './script';
-import { getScriptByIdSync } from './db';
+import { getScriptById } from './db';
+import { commands } from './message';
 
 const VM_VERIFY = 'VM-Verify';
 const requests = {};
 const verify = {};
+
+Object.assign(commands, {
+  ConfirmInstall: confirmInstall,
+  GetRequestId(eventsToNotify = []) {
+    eventsToNotify.push('loadend');
+    const id = getUniqId();
+    requests[id] = {
+      id,
+      eventsToNotify,
+      xhr: new XMLHttpRequest(),
+    };
+    return id;
+  },
+  HttpRequest(details, src) {
+    httpRequest(details, src, (res) => (
+      browser.tabs.sendMessage(src.tab.id, {
+        cmd: 'HttpRequested',
+        data: res,
+      }, {
+        frameId: src.frameId,
+      })
+      .catch(noop)
+    ));
+  },
+  AbortRequest(id) {
+    const req = requests[id];
+    if (req) {
+      req.xhr.abort();
+      clearRequest(req);
+    }
+  },
+});
+
 const specialHeaders = [
   'user-agent',
   // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
@@ -112,17 +146,6 @@ const HeaderInjector = (() => {
   };
 })();
 
-export function getRequestId(eventsToNotify = []) {
-  eventsToNotify.push('loadend');
-  const id = getUniqId();
-  requests[id] = {
-    id,
-    eventsToNotify,
-    xhr: new XMLHttpRequest(),
-  };
-  return id;
-}
-
 function xhrCallbackWrapper(req) {
   let lastPromise = Promise.resolve();
   let blobUrl;
@@ -180,7 +203,7 @@ function isSpecialHeader(lowerHeader) {
  * @param {chrome.runtime.MessageSender} src
  * @param {function} cb
  */
-export async function httpRequest(details, src, cb) {
+async function httpRequest(details, src, cb) {
   const {
     anonymous, data, headers, id, method,
     overrideMimeType, password, responseType,
@@ -236,7 +259,7 @@ export async function httpRequest(details, src, cb) {
     xhr.send(body);
   } catch (e) {
     const { scriptId } = req;
-    console.warn(e, `in script id ${scriptId}, ${getScriptByIdSync(scriptId).meta.name}`);
+    console.warn(e, `in script id ${scriptId}, ${getScriptById(scriptId).meta.name}`);
   }
 }
 
@@ -246,14 +269,6 @@ function clearRequest(req) {
   HeaderInjector.del(req.id);
 }
 
-export function abortRequest(id) {
-  const req = requests[id];
-  if (req) {
-    req.xhr.abort();
-    clearRequest(req);
-  }
-}
-
 function decodeBody(obj) {
   const { cls, value } = obj;
   if (cls === 'formdata') {
@@ -322,7 +337,7 @@ function decodeBody(obj) {
 //   types: ['xmlhttprequest'],
 // });
 
-export async function confirmInstall(info, src = {}) {
+async function confirmInstall(info, src = {}) {
   const { url, from } = info;
   const code = info.code || (await request(url)).data;
   // TODO: display the error in UI

+ 23 - 5
src/background/utils/script.js

@@ -1,6 +1,27 @@
-import { encodeFilename } from '#/common';
+import { getUniqId, encodeFilename } from '#/common';
 import { METABLOCK_RE } from '#/common/consts';
+import { objectMap } from '#/common/object';
+import { commands } from './message';
 import { getOption } from './options';
+import cache from './cache';
+
+Object.assign(commands, {
+  CacheNewScript(data) {
+    const id = getUniqId();
+    cache.put(`new-${id}`, newScript(data));
+    return id;
+  },
+  InjectScript(code, src) {
+    return browser.tabs.executeScript(src.tab.id, {
+      code,
+      runAt: 'document_start',
+    });
+  },
+  NewScript(id) {
+    return id && cache.get(`new-${id}`) || newScript();
+  },
+  ParseMeta: parseMeta,
+});
 
 export function isUserScript(text) {
   if (/^\s*</.test(text)) return false; // HTML
@@ -41,10 +62,7 @@ const metaTypes = {
 };
 export function parseMeta(code) {
   // initialize meta
-  const meta = Object.keys(metaTypes)
-  .reduce((res, key) => Object.assign(res, {
-    [key]: metaTypes[key].default(),
-  }), {});
+  const meta = objectMap(metaTypes, (key, 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(':');

+ 26 - 32
src/background/utils/tabs.js

@@ -1,5 +1,31 @@
 import { noop, getActiveTab } from '#/common';
 import ua from '#/common/ua';
+import { commands } from './message';
+
+const openers = {};
+
+Object.assign(commands, {
+  async TabOpen({ url, active, insert = true }, src) {
+    // src.tab may be absent when invoked from popup (e.g. edit/create buttons)
+    const { id: openerTabId, index, windowId } = src?.tab || await getActiveTab() || {};
+    const tab = await browser.tabs.create({
+      url,
+      active,
+      windowId,
+      ...insert && { index: index + 1 },
+      // XXX openerTabId seems buggy on Chrome, https://crbug.com/967150
+      // It seems to do nothing even set successfully with `browser.tabs.update`.
+      ...ua.openerTabIdSupported && { openerTabId },
+    });
+    const { id } = tab;
+    openers[id] = openerTabId;
+    return { id };
+  },
+  TabClose({ id } = {}, src) {
+    const tabId = id || src?.tab?.id;
+    if (tabId) browser.tabs.remove(tabId);
+  },
+});
 
 // Firefox Android does not support `openerTabId` field, it fails if this field is passed
 ua.ready.then(() => {
@@ -10,8 +36,6 @@ ua.ready.then(() => {
   });
 });
 
-const openers = {};
-
 browser.tabs.onRemoved.addListener((id) => {
   const openerId = openers[id];
   if (openerId) {
@@ -23,33 +47,3 @@ browser.tabs.onRemoved.addListener((id) => {
     delete openers[id];
   }
 });
-
-export async function tabOpen({
-  url,
-  active,
-  insert = true,
-}, src) {
-  // src.tab may be absent when invoked from popup (e.g. edit/create buttons)
-  const {
-    id: openerTabId,
-    index,
-    windowId,
-  } = src.tab || await getActiveTab() || {};
-  const tab = await browser.tabs.create({
-    url,
-    active,
-    windowId,
-    ...insert && { index: index + 1 },
-    // XXX openerTabId seems buggy on Chrome, https://crbug.com/967150
-    // It seems to do nothing even set successfully with `browser.tabs.update`.
-    ...ua.openerTabIdSupported && { openerTabId },
-  });
-  const { id } = tab;
-  openers[id] = openerTabId;
-  return { id };
-}
-
-export function tabClose(data, src) {
-  const tabId = (data && data.id) || (src.tab && src.tab.id);
-  if (tabId) browser.tabs.remove(tabId);
-}

+ 9 - 0
src/background/utils/tester.js

@@ -1,7 +1,16 @@
 import * as tld from '#/common/tld';
 import cache from './cache';
+import { commands } from './message';
 import { getOption, hookOptions } from './options';
 
+Object.assign(commands, {
+  TestBlacklist: testBlacklist,
+});
+
+global.addEventListener('backgroundInitialized', () => {
+  resetBlacklist();
+}, { once: true });
+
 tld.initTLD(true);
 
 const RE_MATCH_PARTS = /(.*?):\/\/([^/]*)\/(.*)/;

+ 15 - 4
src/background/utils/update.js

@@ -1,9 +1,20 @@
 import { i18n, request, compareVersion } from '#/common';
 import { CMD_SCRIPT_UPDATE } from '#/common/consts';
-import { parseScript } from './db';
+import { getScriptById, getScripts, parseScript } from './db';
 import { parseMeta } from './script';
-import { getOption } from './options';
-import { notify, sendMessageOrIgnore } from './message';
+import { getOption, setOption } from './options';
+import { commands, notify, sendMessageOrIgnore } from './message';
+
+Object.assign(commands, {
+  CheckUpdate(id) {
+    return checkUpdate(getScriptById(id));
+  },
+  CheckUpdateAll() {
+    setOption('lastUpdate', Date.now());
+    const toUpdate = getScripts().filter(item => item.config.shouldUpdate);
+    return Promise.all(toUpdate.map(checkUpdate));
+  },
+});
 
 const processes = {};
 const NO_HTTP_CACHE = {
@@ -28,7 +39,7 @@ export default function checkUpdate(script) {
 async function doCheckUpdate(script) {
   const { id } = script.props;
   try {
-    const { data: { update } } = await parseScript({
+    const { update } = await parseScript({
       id,
       code: await downloadUpdate(script),
       update: { checking: false },

+ 32 - 30
src/background/utils/values.js

@@ -1,31 +1,37 @@
 import { noop } from '#/common';
 import { getValueStoresByIds, dumpValueStores, dumpValueStore } from './db';
+import { commands } from './message';
 
 const openers = {}; // scriptId: { openerId: 1, ... }
 const tabScripts = {}; // openerId: { scriptId: 1, ... }
 let cache;
 let timer;
 
-browser.tabs.onRemoved.addListener((id) => {
-  resetValueOpener(id);
+Object.assign(commands, {
+  async GetValueStore(id) {
+    const stores = await getValueStoresByIds([id]);
+    return stores[id] || {};
+  },
+  async SetValueStore({ where, valueStore }) {
+    // Value store will be replaced soon.
+    const store = await dumpValueStore(where, valueStore);
+    return broadcastUpdates(store);
+  },
+  UpdateValue({ id, update }) {
+    // Value will be updated to store later.
+    updateLater();
+    const { key, value } = update;
+    if (!cache) cache = {};
+    let updates = cache[id];
+    if (!updates) {
+      updates = {};
+      cache[id] = updates;
+    }
+    updates[key] = value || null;
+  },
 });
 
-export function updateValueStore(id, update) {
-  updateLater();
-  const { key, value } = update;
-  if (!cache) cache = {};
-  let updates = cache[id];
-  if (!updates) {
-    updates = {};
-    cache[id] = updates;
-  }
-  updates[key] = value || null;
-}
-
-export function setValueStore(where, value) {
-  return dumpValueStore(where, value)
-  .then(broadcastUpdates);
-}
+browser.tabs.onRemoved.addListener(resetValueOpener);
 
 export function resetValueOpener(openerId) {
   const scriptMap = tabScripts[openerId];
@@ -62,12 +68,12 @@ function updateLater() {
   }
 }
 
-function doUpdate() {
+async function doUpdate() {
   const currentCache = cache;
   cache = null;
   const ids = Object.keys(currentCache);
-  getValueStoresByIds(ids)
-  .then((valueStores) => {
+  try {
+    const valueStores = await getValueStoresByIds(ids);
     ids.forEach((id) => {
       const valueStore = valueStores[id] || {};
       valueStores[id] = valueStore;
@@ -78,16 +84,12 @@ function doUpdate() {
         else valueStore[key] = value;
       });
     });
-    return dumpValueStores(valueStores);
-  })
-  .then(broadcastUpdates)
-  .catch((err) => {
+    await broadcastUpdates(await dumpValueStores(valueStores));
+  } catch (err) {
     console.error('Values error:', err);
-  })
-  .then(() => {
-    timer = null;
-    if (cache) updateLater();
-  });
+  }
+  timer = null;
+  if (cache) updateLater();
 }
 
 function broadcastUpdates(updates) {

+ 6 - 0
src/common/consts.js

@@ -28,3 +28,9 @@ export const INJECTABLE_TAB_URL_RE = /^(https?|file|ftps?):/;
 // `browser` is a local variable since we remove the global `chrome` and `browser` in injected*
 // to prevent exposing them to userscripts with `@inject-into content`
 export const { browser } = global;
+
+// setTimeout truncates the delay to a 32-bit signed integer so the max delay is ~24 days
+export const TIMEOUT_MAX = 0x7FFF_FFFF;
+export const TIMEOUT_HOUR = 60 * 60 * 1000;
+export const TIMEOUT_24HOURS = 24 * 60 * 60 * 1000;
+export const TIMEOUT_WEEK = 7 * 24 * 60 * 60 * 1000;

+ 13 - 10
src/common/index.js

@@ -38,18 +38,21 @@ export function sendCmd(cmd, data, options) {
   return sendMessage({ cmd, data }, options);
 }
 
-export function sendMessage(payload, { retry } = {}) {
+// ignoreError is always `true` when sending from the background script because it's a broadcast
+export async function sendMessage(payload, { retry, ignoreError } = {}) {
   if (retry) return sendMessageRetry(payload);
-  const promise = browser.runtime.sendMessage(payload)
-  .then((res) => {
-    const { data, error } = res || {};
-    if (error) return Promise.reject(error);
+  try {
+    let promise = browser.runtime.sendMessage(payload);
+    if (ignoreError || window === browser.extension.getBackgroundPage?.()) {
+      promise = promise.catch(noop);
+    }
+    const { data, error } = await promise || {};
+    if (error) throw error;
     return data;
-  });
-  promise.catch((err) => {
-    if (process.env.DEBUG) console.warn(err);
-  });
-  return promise;
+  } catch (error) {
+    if (process.env.DEBUG) console.warn(error);
+    throw error;
+  }
 }
 
 /**

+ 7 - 0
src/common/object.js

@@ -57,3 +57,10 @@ export function objectPick(obj, keys) {
     return res;
   }, {});
 }
+
+export function objectMap(obj, func) {
+  return Object.entries(obj).reduce((res, [key, value]) => {
+    res[key] = func(key, value);
+    return res;
+  }, {});
+}

+ 2 - 5
src/popup/index.js

@@ -2,6 +2,7 @@ import Vue from 'vue';
 import { i18n, sendCmd, getActiveTab } from '#/common';
 import { INJECTABLE_TAB_URL_RE } from '#/common/consts';
 import handlers from '#/common/handlers';
+import { objectMap } from '#/common/object';
 import * as tld from '#/common/tld';
 import '#/common/ui/style';
 import App from './views/app';
@@ -36,11 +37,7 @@ Object.assign(handlers, {
     allScriptIds.push(...ids);
     if (isTop) {
       mutex.resolve();
-      store.commands = Object.entries(data.menus)
-      .reduce((map, [id, values]) => {
-        map[id] = Object.keys(values).sort();
-        return map;
-      }, {});
+      store.commands = objectMap(data.menus, (key, value) => Object.keys(value).sort());
     }
     if (ids.length) {
       // frameScripts may be appended multiple times if iframes have unique scripts