Browse Source

Merge branch 'feat/storage'

Gerald 8 years ago
parent
commit
2a1b754d32

+ 1 - 0
icons/undo.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M596.677 248.123c204.8 0 372.185 165.415 372.185 372.185S801.477 992.492 596.677 992.492H435.2c-15.754 0-25.6-11.815-25.6-27.569v-63.015c0-15.754 11.815-29.539 27.57-29.539h159.507c139.815 0 252.061-112.246 252.061-252.061S736.492 368.246 596.677 368.246H322.954s-15.754 0-21.662 1.97c-15.754 7.876-11.815 19.692 1.97 33.476l96.492 96.493c11.815 11.815 9.846 29.538-1.97 41.353l-43.322 43.324c-11.816 11.815-25.6 11.815-37.416 1.969l-256-256a24.96 24.96 0 0 1 0-35.446l254.03-254.031c11.816-11.816 31.509-11.816 41.355 0l41.354 41.354c11.815 11.815 11.815 31.507 0 41.354l-96.493 96.492c-11.815 11.815-11.815 25.6 7.877 25.6h13.785l273.723 1.97z"/></svg>

+ 1 - 2
package.json

@@ -5,7 +5,7 @@
     "dev": "gulp watch",
     "prebuild": "npm run lint && gulp clean",
     "build": "NODE_ENV=production gulp build",
-    "build:min": "MINIFY=true NODE_ENV=production gulp build",
+    "build:min": "MINIFY=true npm run build",
     "analyze": "webpack --profile --json --config scripts/webpack.conf.js | webpack-bundle-size-analyzer",
     "analyze:json": "webpack --profile --json --config scripts/webpack.conf.js > stats.json",
     "i18n": "gulp i18n",
@@ -67,7 +67,6 @@
   "dependencies": {
     "codemirror": "^5.27.4",
     "core-js": "^2.4.1",
-    "sync-promise-lite": "^0.2.3",
     "vue": "^2.4.1",
     "vue-code": "^1.2.2",
     "vueleton": "^0.1.0"

+ 1 - 2
scripts/webpack.base.conf.js

@@ -10,8 +10,7 @@ const DIST = 'dist';
 const definePlugin = new webpack.DefinePlugin({
   'process.env': {
     NODE_ENV: JSON.stringify(process.env.NODE_ENV),
-    // DEBUG: IS_DEV ? 'true' : 'false', // whether to log message errors
-    DEBUG: 'false',
+    DEBUG: IS_DEV ? 'true' : 'false', // whether to log message errors
   },
 });
 

+ 6 - 0
src/_locales/cs/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/de/messages.yml

@@ -414,3 +414,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/en/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: Support page
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: Undo
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: '[Removed]'

+ 6 - 0
src/_locales/es/messages.yml

@@ -416,3 +416,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/hr/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/id/messages.yml

@@ -414,3 +414,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/ja/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/pl/messages.yml

@@ -417,3 +417,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/ro/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/ru/messages.yml

@@ -419,3 +419,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/sr/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/vi/messages.yml

@@ -412,3 +412,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: ''
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: ''
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: ''

+ 6 - 0
src/_locales/zh/messages.yml

@@ -410,3 +410,9 @@ buttonHome:
 buttonSupport:
   description: Button to open support page.
   message: 支持页面
+buttonUndo:
+  description: Button to undo removement of a script.
+  message: 撤销
+labelRemoved:
+  description: Label shown when a script is removed.
+  message: 【已删除】

+ 50 - 34
src/background/app.js

@@ -1,15 +1,21 @@
 import 'src/common/polyfills';
 import 'src/common/browser';
-import { i18n, defaultImage } from 'src/common';
+import { i18n, defaultImage, object } from 'src/common';
 import * as sync from './sync';
 import {
-  cache, vmdb,
+  cache,
   getRequestId, httpRequest, abortRequest, confirmInstall,
   newScript, parseMeta,
   setClipboard, checkUpdate,
   getOption, setOption, hookOptions, getAllOptions,
   initialize,
 } from './utils';
+import {
+  getScripts, removeScript, getData, checkRemove, getScriptsByURL,
+  updateScriptInfo, setValues, getExportData, getScriptCode,
+  getScriptByIds, moveScript, vacuum, parseScript, getScript,
+  normalizePosition,
+} from './utils/db';
 
 const VM_VER = browser.runtime.getManifest().version;
 
@@ -32,8 +38,11 @@ function broadcast(data) {
 
 function checkUpdateAll() {
   setOption('lastUpdate', Date.now());
-  vmdb.getScriptsByIndex('update', 1)
-  .then(scripts => Promise.all(scripts.map(checkUpdate)))
+  getScripts()
+  .then(scripts => {
+    const toUpdate = scripts.filter(item => object.get(item, 'config.shouldUpdate'));
+    return Promise.all(toUpdate.map(checkUpdate));
+  })
   .then(updatedList => {
     if (updatedList.some(Boolean)) sync.sync();
   });
@@ -58,11 +67,16 @@ const commands = {
     return newScript();
   },
   RemoveScript(id) {
-    return vmdb.removeScript(id)
+    return removeScript(id)
     .then(() => { sync.sync(); });
   },
   GetData() {
-    return vmdb.getData().then(data => {
+    return checkRemove()
+    .then(changed => {
+      if (changed) sync.sync();
+      return getData();
+    })
+    .then(data => {
       data.sync = sync.getStates();
       data.version = VM_VER;
       return data;
@@ -80,56 +94,59 @@ const commands = {
       }
     });
     return data.isApplied ? (
-      vmdb.getScriptsByURL(url).then(res => Object.assign(data, res))
+      getScriptsByURL(url).then(res => Object.assign(data, res))
     ) : data;
   },
-  UpdateScriptInfo(data) {
-    return vmdb.updateScriptInfo(data.id, data, {
-      modified: Date.now(),
+  UpdateScriptInfo({ id, config }) {
+    return updateScriptInfo(id, {
+      config,
+      custom: {
+        modified: Date.now(),
+      },
     })
-    .then(script => {
+    .then(([script]) => {
       sync.sync();
       browser.runtime.sendMessage({
         cmd: 'UpdateScript',
-        data: script,
+        data: {
+          where: { id: script.props.id },
+          update: script,
+        },
       });
     });
   },
-  SetValue(data) {
-    return vmdb.setValue(data.uri, data.values)
-    .then(() => {
+  SetValue({ where, values }) {
+    return setValues(where, values)
+    .then(data => {
       broadcast({
         cmd: 'UpdateValues',
-        data: {
-          uri: data.uri,
-          values: data.values,
-        },
+        data,
       });
     });
   },
-  ExportZip(data) {
-    return vmdb.getExportData(data.ids, data.values);
+  ExportZip({ ids, values }) {
+    return getExportData(ids, values);
   },
-  GetScript(id) {
-    return vmdb.getScriptData(id);
+  GetScriptCode(id) {
+    return getScriptCode(id);
   },
   GetMetas(ids) {
-    return vmdb.getScriptInfos(ids);
+    return getScriptByIds(ids);
   },
-  Move(data) {
-    return vmdb.moveScript(data.id, data.offset)
+  Move({ id, offset }) {
+    return moveScript(id, offset)
     .then(() => { sync.sync(); });
   },
-  Vacuum: vmdb.vacuum,
+  Vacuum: vacuum,
   ParseScript(data) {
-    return vmdb.parseScript(data).then(res => {
+    return parseScript(data).then(res => {
       browser.runtime.sendMessage(res);
       sync.sync();
       return res.data;
     });
   },
   CheckUpdate(id) {
-    vmdb.getScript(id).then(checkUpdate)
+    getScript({ id }).then(checkUpdate)
     .then(updated => {
       if (updated) sync.sync();
     });
@@ -190,12 +207,14 @@ const commands = {
     const items = Array.isArray(data) ? data : [data];
     items.forEach(item => { setOption(item.key, item.value); });
   },
-  CheckPosition: vmdb.checkPosition,
   ConfirmInstall: confirmInstall,
   CheckScript({ name, namespace }) {
-    return vmdb.queryScript(null, { name, namespace })
+    return getScript({ meta: { name, namespace } })
     .then(script => (script ? script.meta.version : null));
   },
+  CheckPosition() {
+    return normalizePosition();
+  },
 };
 
 initialize()
@@ -218,9 +237,6 @@ initialize()
   });
   setTimeout(autoUpdate, 2e4);
   sync.initialize();
-
-  // XXX fix position regression in v2.6.3
-  vmdb.checkPosition();
 });
 
 // Common functions

+ 71 - 46
src/background/sync/base.js

@@ -1,10 +1,6 @@
-import { debounce, normalizeKeys, request, noop } from 'src/common';
-import {
-  getEventEmitter, vmdb,
-  getOption, setOption, hookOptions,
-} from '../utils';
-
-const { getScriptsByIndex, parseScript, removeScript, checkPosition } = vmdb;
+import { debounce, normalizeKeys, request, noop, object } from 'src/common';
+import { getEventEmitter, getOption, setOption, hookOptions } from '../utils';
+import { getScripts, getScriptCode, parseScript, removeScript, normalizePosition } from '../utils/db';
 
 const serviceNames = [];
 const services = {};
@@ -22,6 +18,11 @@ export function getURI(name) {
   return decodeURIComponent(name.slice(3));
 }
 
+function getLocalData() {
+  return getScripts()
+  .then(scripts => scripts.filter(script => !script.config.removed));
+}
+
 function initConfig() {
   function get(key, def) {
     const keys = normalizeKeys(key);
@@ -289,7 +290,7 @@ export const BaseService = serviceFactory({
     .then(remoteMeta => Promise.all([
       remoteMeta,
       this.list(),
-      getScriptsByIndex('position'),
+      getLocalData(),
     ]))
     .then(([remoteMeta, remoteData, localData]) => {
       const remoteMetaInfo = remoteMeta.info || {};
@@ -322,19 +323,20 @@ export const BaseService = serviceFactory({
         return info;
       }, {});
       localData.forEach(item => {
-        const remoteInfo = remoteMeta.info[item.uri];
+        const { props: { uri, position }, custom: { modified } } = item;
+        const remoteInfo = remoteMeta.info[uri];
         if (remoteInfo) {
-          if (firstSync || !item.custom.modified || remoteInfo.modified > item.custom.modified) {
-            const remoteItem = remoteItemMap[item.uri];
+          if (firstSync || !modified || remoteInfo.modified > modified) {
+            const remoteItem = remoteItemMap[uri];
             getRemote.push(remoteItem);
-          } else if (remoteInfo.modified < item.custom.modified) {
+          } else if (remoteInfo.modified < modified) {
             putRemote.push(item);
-          } else if (remoteInfo.position !== item.position) {
-            remoteInfo.position = item.position;
+          } else if (remoteInfo.position !== position) {
+            remoteInfo.position = position;
             remoteChanged = true;
           }
-          delete remoteItemMap[item.uri];
-        } else if (firstSync || !outdated || item.custom.modified > remoteTimestamp) {
+          delete remoteItemMap[uri];
+        } else if (firstSync || !outdated || modified > remoteTimestamp) {
           putRemote.push(item);
         } else {
           delLocal.push(item);
@@ -353,44 +355,65 @@ export const BaseService = serviceFactory({
           this.log('Download script:', item.uri);
           return this.get(getFilename(item.uri))
           .then(raw => {
-            const data = { more: {} };
+            const data = {};
             try {
               const obj = JSON.parse(raw);
-              if (obj.version === 1) {
+              if (obj.version === 2) {
+                data.code = obj.code;
+                data.config = obj.config;
+                data.custom = obj.custom;
+              } else if (obj.version === 1) {
                 data.code = obj.code;
-                if (obj.more) data.more = obj.more;
+                if (obj.more) {
+                  data.custom = obj.more.custom;
+                  data.config = object.purify({
+                    enabled: obj.more.enabled,
+                    shouldUpdate: obj.more.update,
+                  });
+                }
               }
             } catch (e) {
               data.code = raw;
             }
             const remoteInfo = remoteMeta.info[item.uri];
-            const { modified, position } = remoteInfo;
+            const { modified } = remoteInfo;
             data.modified = modified;
-            if (position) data.more.position = position;
-            if (!getOption('syncScriptStatus') && data.more) {
-              delete data.more.enabled;
+            const position = +remoteInfo.position;
+            if (position) data.position = position;
+            if (!getOption('syncScriptStatus') && data.config) {
+              delete data.config.enabled;
             }
             return parseScript(data)
             .then(res => { browser.runtime.sendMessage(res); });
           });
         }),
-        ...putRemote.map(item => {
-          this.log('Upload script:', item.uri);
-          const data = JSON.stringify({
-            version: 1,
-            code: item.code,
-            more: {
-              custom: item.custom,
-              enabled: item.enabled,
-              update: item.update,
-            },
+        ...putRemote.map(script => {
+          this.log('Upload script:', script.props.uri);
+          return getScriptCode(script.props.id)
+          .then(code => {
+            // const data = {
+            //   version: 2,
+            //   code,
+            //   custom: script.custom,
+            //   config: script.config,
+            // };
+            // XXX use version 1 to be compatible with Violentmonkey on other platforms
+            const data = {
+              version: 1,
+              code,
+              more: {
+                custom: script.custom,
+                enabled: script.config.enabled,
+                update: script.config.shouldUpdate,
+              },
+            };
+            remoteMeta.info[script.props.uri] = {
+              modified: script.custom.modified,
+              position: script.props.position,
+            };
+            remoteChanged = true;
+            return this.put(getFilename(script.props.uri), JSON.stringify(data));
           });
-          remoteMeta.info[item.uri] = {
-            modified: item.custom.modified,
-            position: item.position,
-          };
-          remoteChanged = true;
-          return this.put(getFilename(item.uri), data);
         }),
         ...delRemote.map(item => {
           this.log('Remove remote script:', item.uri);
@@ -398,17 +421,19 @@ export const BaseService = serviceFactory({
           remoteChanged = true;
           return this.remove(getFilename(item.uri));
         }),
-        ...delLocal.map(item => {
-          this.log('Remove local script:', item.uri);
-          return removeScript(item.id);
+        ...delLocal.map(script => {
+          this.log('Remove local script:', script.props.uri);
+          return removeScript(script.props.id);
         }),
       ];
-      promiseQueue.push(Promise.all(promiseQueue).then(() => checkPosition()).then(changed => {
+      promiseQueue.push(Promise.all(promiseQueue).then(() => normalizePosition()).then(changed => {
         if (!changed) return;
         remoteChanged = true;
-        return getScriptsByIndex('position', null, null, item => {
-          const remoteInfo = remoteMeta.info[item.uri];
-          if (remoteInfo) remoteInfo.position = item.position;
+        return getScripts().then(scripts => {
+          scripts.forEach(script => {
+            const remoteInfo = remoteMeta.info[script.props.uri];
+            if (remoteInfo) remoteInfo.position = script.props.position;
+          });
         });
       }));
       promiseQueue.push(Promise.all(promiseQueue).then(() => {

+ 580 - 548
src/background/utils/db.js

@@ -1,632 +1,664 @@
-import Promise from 'sync-promise-lite';
-import { i18n, request, buffer2string, getFullUrl } from 'src/common';
-import { getNameURI, getScriptInfo, isRemote, parseMeta, newScript } from './script';
+import { i18n, request, buffer2string, getFullUrl, object } from 'src/common';
+import { getNameURI, isRemote, parseMeta, newScript } from './script';
 import { testScript, testBlacklist } from './tester';
 import { register } from './init';
 
-let db;
-
-const position = {
-  value: 0,
-  set(v) {
-    position.value = +v || 0;
-  },
-  get() {
-    return position.value + 1;
-  },
-  update(v) {
-    if (position.value < +v) position.set(v);
-  },
-};
-
-register(openDatabase().then(initPosition));
-
-function openDatabase() {
-  return new Promise((resolve, reject) => {
+const patch = () => new Promise((resolve, reject) => {
+  console.info('Upgrade database...');
+  init();
+  function init() {
     const req = indexedDB.open('Violentmonkey', 1);
     req.onsuccess = () => {
-      db = req.result;
-      resolve();
+      transform(req.result);
     };
-    req.onerror = e => {
-      const { error } = e.target;
-      console.error(`IndexedDB error: ${error.message}`);
-      reject(error);
+    req.onerror = reject;
+    req.onupgradeneeded = () => {
+      // No available upgradation
+      throw reject();
     };
-    req.onupgradeneeded = e => {
-      const _db = e.currentTarget.result;
-      // scripts: id uri custom meta enabled update code position
-      const os = _db.createObjectStore('scripts', {
-        keyPath: 'id',
-        autoIncrement: true,
+  }
+  function transform(db) {
+    const tx = db.transaction(['scripts', 'require', 'cache', 'values']);
+    const updates = {};
+    let processing = 3;
+    const onCallback = () => {
+      processing -= 1;
+      if (!processing) resolve(browser.storage.local.set(updates));
+    };
+    getAllScripts(tx, items => {
+      const uriMap = {};
+      items.forEach(({ script, code }) => {
+        updates[`scr:${script.props.id}`] = script;
+        updates[`code:${script.props.id}`] = code;
+        uriMap[script.props.uri] = script.props.id;
+      });
+      getAllValues(tx, data => {
+        data.forEach(({ id, values }) => {
+          updates[`val:${id}`] = values;
+        });
+        onCallback();
+      }, uriMap);
+    });
+    getAllCache(tx, cache => {
+      cache.forEach(({ uri, data }) => {
+        updates[`cac:${uri}`] = data;
+      });
+      onCallback();
+    });
+    getAllRequire(tx, data => {
+      data.forEach(({ uri, code }) => {
+        updates[`req:${uri}`] = code;
       });
-      os.createIndex('uri', 'uri', { unique: true });
-      os.createIndex('update', 'update', { unique: false });
-      // position should be unique at last
-      os.createIndex('position', 'position', { unique: false });
-      // require: uri code
-      _db.createObjectStore('require', { keyPath: 'uri' });
-      // cache: uri data
-      _db.createObjectStore('cache', { keyPath: 'uri' });
-      // values: uri values
-      _db.createObjectStore('values', { keyPath: 'uri' });
+      onCallback();
+    });
+  }
+  function getAllScripts(tx, callback) {
+    const os = tx.objectStore('scripts');
+    const list = [];
+    const req = os.openCursor();
+    req.onsuccess = e => {
+      const cursor = e.target.result;
+      if (cursor) {
+        const { value } = cursor;
+        list.push(transformScript(value));
+        cursor.continue();
+      } else {
+        callback(list);
+      }
     };
-  });
-}
-
-function transformScript(script) {
-  // XXX transform custom fields used in v2.6.1-
-  if (script) {
-    const { custom } = script;
-    [
-      ['origInclude', '_include'],
-      ['origMatch', '_match'],
-      ['origExclude', '_exclude'],
-      ['origExcludeMatch', '_excludeMatch'],
-    ].forEach(([key, oldKey]) => {
-      if (typeof custom[key] === 'undefined') {
-        custom[key] = custom[oldKey] !== false;
-        delete custom[oldKey];
+    req.onerror = reject;
+  }
+  function getAllCache(tx, callback) {
+    const os = tx.objectStore('cache');
+    const list = [];
+    const req = os.openCursor();
+    req.onsuccess = e => {
+      const cursor = e.target.result;
+      if (cursor) {
+        const { value: { uri, data } } = cursor;
+        list.push({ uri, data });
+        cursor.continue();
+      } else {
+        callback(list);
       }
-    });
+    };
+    req.onerror = reject;
   }
-  return script;
+  function getAllRequire(tx, callback) {
+    const os = tx.objectStore('require');
+    const list = [];
+    const req = os.openCursor();
+    req.onsuccess = e => {
+      const cursor = e.target.result;
+      if (cursor) {
+        const { value: { uri, code } } = cursor;
+        list.push({ uri, code });
+        cursor.continue();
+      } else {
+        callback(list);
+      }
+    };
+    req.onerror = reject;
+  }
+  function getAllValues(tx, callback, uriMap) {
+    const os = tx.objectStore('values');
+    const list = [];
+    const req = os.openCursor();
+    req.onsuccess = e => {
+      const cursor = e.target.result;
+      if (cursor) {
+        const { value: { uri, values } } = cursor;
+        const id = uriMap[uri];
+        if (id) list.push({ id, values });
+        cursor.continue();
+      } else {
+        callback(list);
+      }
+    };
+    req.onerror = reject;
+  }
+  function transformScript(script) {
+    const item = {
+      script: {
+        meta: parseMeta(script.code),
+        custom: script.custom,
+        props: {
+          id: script.id,
+          uri: script.uri,
+          position: script.position,
+        },
+        config: {
+          enabled: script.enabled,
+          shouldUpdate: script.update,
+        },
+      },
+      code: script.code,
+    };
+    return item;
+  }
+})
+// Ignore error
+.catch(() => {});
+
+function cacheOrFetch(handle) {
+  const requests = {};
+  return function cachedHandle(url, ...args) {
+    let promise = requests[url];
+    if (!promise) {
+      promise = handle.call(this, url, ...args)
+      .catch(() => {
+        console.error(`Error fetching: ${url}`);
+      })
+      .then(() => {
+        delete requests[url];
+      });
+      requests[url] = promise;
+    }
+    return promise;
+  };
+}
+function ensureListArgs(handle) {
+  return function handleList(data) {
+    let items = Array.isArray(data) ? data : [data];
+    items = items.filter(Boolean);
+    if (!items.length) return Promise.resolve();
+    return handle.call(this, items);
+  };
 }
 
-export function getScript(id, cTx) {
-  const tx = cTx || db.transaction('scripts');
-  const os = tx.objectStore('scripts');
-  return new Promise(resolve => {
-    os.get(id).onsuccess = e => {
-      const { result } = e.target;
-      result.id = id;
-      resolve(result);
-    };
+const store = {};
+const storage = {
+  base: {
+    prefix: '',
+    getKey(id) {
+      return `${this.prefix}${id}`;
+    },
+    getOne(id) {
+      const key = this.getKey(id);
+      return browser.storage.local.get(key).then(data => data[key]);
+    },
+    getMulti(ids) {
+      return browser.storage.local.get(ids.map(id => this.getKey(id)))
+      .then(data => {
+        const result = {};
+        ids.forEach(id => { result[id] = data[this.getKey(id)]; });
+        return result;
+      });
+    },
+    dump(id, value) {
+      if (!id) return Promise.resolve();
+      return browser.storage.local.set({
+        [this.getKey(id)]: value,
+      });
+    },
+    remove(id) {
+      if (!id) return Promise.resolve();
+      return browser.storage.local.remove(this.getKey(id));
+    },
+    removeMulti(ids) {
+      return browser.storage.local.remove(ids.map(id => this.getKey(id)));
+    },
+  },
+};
+storage.script = Object.assign({}, storage.base, {
+  prefix: 'scr:',
+  dump: ensureListArgs(function dump(items) {
+    const updates = {};
+    items.forEach(item => {
+      updates[this.getKey(item.props.id)] = item;
+      store.scriptMap[item.props.id] = item;
+    });
+    return browser.storage.local.set(updates)
+    .then(() => items);
+  }),
+});
+storage.code = Object.assign({}, storage.base, {
+  prefix: 'code:',
+});
+storage.value = Object.assign({}, storage.base, {
+  prefix: 'val:',
+});
+storage.require = Object.assign({}, storage.base, {
+  prefix: 'req:',
+  fetch: cacheOrFetch(function fetch(uri) {
+    return request(uri).then(({ data }) => this.dump(uri, data));
+  }),
+});
+storage.cache = Object.assign({}, storage.base, {
+  prefix: 'cac:',
+  fetch: cacheOrFetch(function fetch(uri, check) {
+    return request(uri, { responseType: 'arraybuffer' })
+    .then(({ data: buffer }) => {
+      const data = {
+        buffer,
+        blob: options => new Blob([buffer], options),
+        string: () => buffer2string(buffer),
+        base64: () => window.btoa(data.string()),
+      };
+      return (check ? Promise.resolve(check(data)) : Promise.resolve())
+      .then(() => this.dump(uri, data.base64()));
+    });
+  }),
+});
+
+register(initialize());
+
+function initialize() {
+  return browser.storage.local.get('version')
+  .then(({ version: lastVersion }) => {
+    const { version } = browser.runtime.getManifest();
+    return (lastVersion ? Promise.resolve() : patch())
+    .then(() => {
+      if (version !== lastVersion) return browser.storage.local.set({ version });
+    });
   })
-  .then(transformScript);
+  .then(() => browser.storage.local.get())
+  .then(data => {
+    const scripts = [];
+    const storeInfo = {
+      id: 0,
+      position: 0,
+    };
+    Object.keys(data).forEach(key => {
+      const value = data[key];
+      if (key.startsWith('scr:')) {
+        // {
+        //   meta,
+        //   custom,
+        //   props: { id, position, uri },
+        //   config: { enabled, shouldUpdate },
+        // }
+        scripts.push(value);
+        storeInfo.id = Math.max(storeInfo.id, getInt(object.get(value, 'props.id')));
+        storeInfo.position = Math.max(storeInfo.position, getInt(object.get(value, 'props.position')));
+      }
+    });
+    scripts.sort((a, b) => {
+      const [pos1, pos2] = [a, b].map(item => getInt(object.get(item, 'props.position')));
+      return Math.sign(pos1 - pos2);
+    });
+    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 normalizePosition();
+  });
 }
 
-export function queryScript(id, meta, cTx) {
-  if (id) return getScript(id, cTx);
-  return new Promise(resolve => {
-    const uri = getNameURI({ meta });
-    const tx = cTx || db.transaction('scripts');
-    tx.objectStore('scripts').index('uri').get(uri).onsuccess = e => {
-      resolve(e.target.result);
-    };
-  })
-  .then(transformScript);
+function getInt(val) {
+  return +val || 0;
 }
 
-export function getScriptData(id) {
-  return getScript(id).then(script => {
-    if (!script) return Promise.reject();
-    const data = getScriptInfo(script);
-    data.code = script.code;
-    return data;
+export function normalizePosition() {
+  const updates = [];
+  store.scripts.forEach((item, index) => {
+    const position = index + 1;
+    if (object.get(item, 'props.position') !== position) {
+      object.set(item, 'props.position', position);
+      updates.push(item);
+    }
   });
+  store.storeInfo.position = store.scripts.length;
+  const { length } = updates;
+  return length ? storage.script.dump(updates).then(() => length) : Promise.resolve();
 }
 
-export function getScriptInfos(ids) {
-  const tx = db.transaction('scripts');
-  return Promise.all(ids.map(id => getScript(id, tx)))
-  .then(scripts => scripts.filter(Boolean).map(getScriptInfo));
+export function getScript(where) {
+  let script;
+  if (where.id) {
+    script = store.scriptMap[where.id];
+  } else {
+    const uri = where.uri || getNameURI({ meta: where.meta, id: '@@should-have-name' });
+    const predicate = item => uri === object.get(item, 'props.uri');
+    script = store.scripts.find(predicate);
+  }
+  return Promise.resolve(script);
 }
 
-export function getValues(uris, cTx) {
-  const tx = cTx || db.transaction('values');
-  const os = tx.objectStore('values');
-  return Promise.all(uris.map(uri => new Promise(resolve => {
-    os.get(uri).onsuccess = e => {
-      resolve(e.target.result);
-    };
-  })))
-  .then(data => data.reduce((result, value, i) => {
-    if (value) result[uris[i]] = value.values;
-    return result;
-  }, {}));
+export function getScripts() {
+  return Promise.resolve(store.scripts);
 }
 
-export function getScriptsByURL(url) {
-  const tx = db.transaction(['scripts', 'require', 'values', 'cache']);
-  return loadScripts()
-  .then(data => Promise.all([
-    loadRequires(data.require),
-    getValues(data.uris, tx),
-    getCacheB64(data.cache, tx),
-  ]).then(res => ({
-    scripts: data.scripts,
-    require: res[0],
-    values: res[1],
-    cache: res[2],
-  })));
-
-  function loadScripts() {
-    const data = {
-      uris: [],
-    };
-    const require = {};
-    const cache = {};
-    return (testBlacklist(url) ? Promise.resolve([]) : (
-      getScriptsByIndex('position', null, tx, script => {
-        if (!testScript(url, script)) return;
-        data.uris.push(script.uri);
-        script.meta.require.forEach(key => { require[key] = 1; });
-        Object.keys(script.meta.resources).forEach(key => {
-          cache[script.meta.resources[key]] = 1;
-        });
-        return script;
-      })
-    ))
-    .then(scripts => {
-      data.scripts = scripts.filter(Boolean);
-      data.require = Object.keys(require);
-      data.cache = Object.keys(cache);
-      return data;
-    });
-  }
-  function loadRequires(uris) {
-    const os = tx.objectStore('require');
-    return Promise.all(uris.map(uri => new Promise(resolve => {
-      os.get(uri).onsuccess = e => {
-        resolve(e.target.result);
-      };
-    })))
-    .then(data => data.reduce((result, value, i) => {
-      if (value) result[uris[i]] = value.code;
-      return result;
-    }, {}));
-  }
+export function getScriptByIds(ids) {
+  return Promise.all(ids.map(id => getScript({ id })))
+  .then(scripts => scripts.filter(Boolean));
 }
 
-export function getData() {
-  const tx = db.transaction(['scripts', 'cache']);
-  return loadScripts()
-  .then(data => loadCache(data.cache).then(cache => ({
-    cache,
-    scripts: data.scripts,
-  })));
-
-  function loadScripts() {
-    const data = {};
-    const cache = {};
-    return getScriptsByIndex('position', null, tx, script => {
-      const { icon } = script.meta;
-      if (isRemote(icon)) cache[icon] = 1;
-      return getScriptInfo(script);
-    })
-    .then(scripts => {
-      data.scripts = scripts;
-      data.cache = Object.keys(cache);
-      return data;
-    });
-  }
-  function loadCache(uris) {
-    return getCacheB64(uris, tx)
-    .then(cache => {
-      Object.keys(cache).forEach(key => {
-        cache[key] = `data:image/png;base64,${cache[key]}`;
-      });
-      return cache;
-    });
-  }
+export function getScriptCode(id) {
+  return storage.code.getOne(id);
 }
 
-export function removeScript(id) {
-  const tx = db.transaction('scripts', 'readwrite');
-  return new Promise(resolve => {
-    const os = tx.objectStore('scripts');
-    os.delete(id).onsuccess = () => { resolve(); };
-  })
-  .then(() => {
-    browser.runtime.sendMessage({
-      cmd: 'RemoveScript',
-      data: id,
-    });
+export function setValues(where, values) {
+  return (where.id
+    ? Promise.resolve(where.id)
+    : getScript(where).then(script => object.get(script, 'props.id')))
+  .then(id => {
+    if (id) storage.value.dump(id, values).then(() => ({ id, values }));
   });
 }
 
-export function moveScript(id, offset) {
-  const tx = db.transaction('scripts', 'readwrite');
-  const os = tx.objectStore('scripts');
-  return getScript(id, tx)
-  .then(script => {
-    let pos = script.position;
-    let range;
-    let order;
-    let number = offset;
-    if (offset < 0) {
-      range = IDBKeyRange.upperBound(pos, true);
-      order = 'prev';
-      number = -number;
-    } else {
-      range = IDBKeyRange.lowerBound(pos, true);
-      order = 'next';
+/**
+ * @desc Get scripts to be injected to page with specific URL.
+ */
+export function getScriptsByURL(url) {
+  const scripts = testBlacklist(url)
+    ? []
+    : store.scripts.filter(script => !script.config.removed && testScript(url, script));
+  const reqKeys = {};
+  const cacheKeys = {};
+  scripts.forEach(script => {
+    if (object.get(script, 'config.enabled')) {
+      script.meta.require.forEach(key => {
+        reqKeys[key] = 1;
+      });
+      Object.keys(script.meta.resources).forEach(key => {
+        cacheKeys[script.meta.resources[key]] = 1;
+      });
     }
-    return new Promise(resolve => {
-      os.index('position').openCursor(range, order).onsuccess = e => {
-        const { result } = e.target;
-        if (result) {
-          number -= 1;
-          const { value } = result;
-          value.position = pos;
-          pos = result.key;
-          result.update(value);
-          if (number) result.continue();
-          else {
-            script.position = pos;
-            os.put(script).onsuccess = () => { resolve(); };
-          }
-        }
-      };
-    });
   });
+  const enabledScriptIds = scripts
+  .filter(script => script.config.enabled)
+  .map(script => script.props.id);
+  return Promise.all([
+    storage.require.getMulti(Object.keys(reqKeys)),
+    storage.cache.getMulti(Object.keys(cacheKeys)),
+    storage.value.getMulti(enabledScriptIds),
+    storage.code.getMulti(enabledScriptIds),
+  ])
+  .then(([require, cache, values, code]) => ({
+    scripts,
+    require,
+    cache,
+    values,
+    code,
+  }));
 }
 
-function getCacheB64(urls, cTx) {
-  const tx = cTx || db.transaction('cache');
-  const os = tx.objectStore('cache');
-  return Promise.all(urls.map(url => new Promise(resolve => {
-    os.get(url).onsuccess = e => {
-      resolve(e.target.result);
-    };
-  })))
-  .then(data => data.reduce((map, value, i) => {
-    if (value) map[urls[i]] = value.data;
-    return map;
-  }, {}));
-}
-
-function saveCache(uri, data, cTx) {
-  const tx = cTx || db.transaction('cache', 'readwrite');
-  const os = tx.objectStore('cache');
-  return new Promise(resolve => {
-    os.put({ uri, data }).onsuccess = () => { resolve(); };
+/**
+ * @desc Get data for dashboard.
+ */
+export function getData() {
+  const cacheKeys = {};
+  const { scripts } = store;
+  scripts.forEach(script => {
+    const icon = object.get(script, 'meta.icon');
+    if (isRemote(icon)) cacheKeys[icon] = 1;
   });
+  return storage.cache.getMulti(Object.keys(cacheKeys))
+  .then(cache => {
+    Object.keys(cache).forEach(key => {
+      cache[key] = `data:image/png;base64,${cache[key]}`;
+    });
+    return cache;
+  })
+  .then(cache => ({ scripts, cache }));
 }
 
-function saveRequire(uri, code, cTx) {
-  const tx = cTx || db.transaction('require', 'readwrite');
-  const os = tx.objectStore('require');
-  return new Promise(resolve => {
-    os.put({ uri, code }).onsuccess = () => { resolve(); };
-  });
+export function checkRemove() {
+  const toRemove = store.scripts.filter(script => script.config.removed);
+  if (toRemove.length) {
+    store.scripts = store.scripts.filter(script => !script.config.removed);
+    storage.script.removeMulti(toRemove);
+    storage.code.removeMulti(toRemove);
+  }
+  return Promise.resolve(toRemove.length);
 }
 
-export function saveScript(script, cTx) {
-  script.enabled = script.enabled ? 1 : 0;
-  script.update = script.update ? 1 : 0;
-  if (!script.position) script.position = position.get();
-  position.update(script.position);
-  const tx = cTx || db.transaction('scripts', 'readwrite');
-  const os = tx.objectStore('scripts');
-  return new Promise((resolve, reject) => {
-    const res = os.put(script);
-    res.onsuccess = e => {
-      script.id = e.target.result;
-      resolve(script);
-    };
-    res.onerror = () => {
-      reject(i18n('msgNamespaceConflict'));
-    };
+export function removeScript(id) {
+  const i = store.scripts.findIndex(item => id === object.get(item, 'props.id'));
+  if (i >= 0) {
+    store.scripts.splice(i, 1);
+    storage.script.remove(id);
+    storage.code.remove(id);
+  }
+  return browser.runtime.sendMessage({
+    cmd: 'RemoveScript',
+    data: id,
   });
 }
 
-const cacheRequests = {};
-function fetchCache(url, check) {
-  let promise = cacheRequests[url];
-  if (!promise) {
-    // DataURL cannot be loaded with `responseType=blob`
-    // ref: https://bugs.chromium.org/p/chromium/issues/detail?id=412752
-    promise = request(url, { responseType: 'arraybuffer' })
-    .then(({ data: buffer }) => {
-      const data = {
-        buffer,
-        blob(options) {
-          return new Blob([buffer], options);
-        },
-        string() {
-          return buffer2string(buffer);
-        },
-        base64() {
-          return window.btoa(data.string());
-        },
-      };
-      if (check) return Promise.resolve(check(data)).then(() => data);
-      return data;
-    })
-    .then(({ base64 }) => saveCache(url, base64()))
-    .then(() => { delete cacheRequests[url]; });
-    cacheRequests[url] = promise;
+export function moveScript(id, offset) {
+  const index = store.scripts.findIndex(item => id === object.get(item, 'props.id'));
+  const step = offset > 0 ? 1 : -1;
+  const indexStart = index;
+  const indexEnd = index + offset;
+  const offsetI = Math.min(indexStart, indexEnd);
+  const offsetJ = Math.max(indexStart, indexEnd);
+  const updated = store.scripts.slice(offsetI, offsetJ + 1);
+  if (step > 0) {
+    updated.push(updated.shift());
+  } else {
+    updated.unshift(updated.pop());
   }
-  return promise;
+  store.scripts = [
+    ...store.scripts.slice(0, offsetI),
+    ...updated,
+    ...store.scripts.slice(offsetJ + 1),
+  ];
+  return normalizePosition();
 }
 
-const requireRequests = {};
-function fetchRequire(url) {
-  let promise = requireRequests[url];
-  if (!promise) {
-    promise = request(url)
-    .then(({ data }) => saveRequire(url, data))
-    .catch(() => { console.error(`Error fetching required script: ${url}`); })
-    .then(() => { delete requireRequests[url]; });
-    requireRequests[url] = promise;
+function saveScript(script, code) {
+  const config = script.config || {};
+  config.enabled = getInt(config.enabled);
+  config.shouldUpdate = getInt(config.shouldUpdate);
+  const props = script.props || {};
+  let oldScript;
+  if (!props.id) {
+    store.storeInfo.id += 1;
+    props.id = store.storeInfo.id;
+  } else {
+    oldScript = store.scriptMap[props.id];
   }
-  return promise;
-}
-
-export function setValue(uri, values) {
-  const os = db.transaction('values', 'readwrite').objectStore('values');
-  return new Promise(resolve => {
-    os.put({ uri, values }).onsuccess = () => { resolve(); };
-  });
+  props.uri = getNameURI(script);
+  // 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;
+  })) {
+    throw i18n('msgNamespaceConflict');
+  }
+  if (oldScript) {
+    script.config = Object.assign({}, oldScript.config, config);
+    script.props = Object.assign({}, oldScript.props, props);
+    const index = store.scripts.indexOf(oldScript);
+    store.scripts[index] = script;
+  } else {
+    store.storeInfo.position += 1;
+    props.position = store.storeInfo.position;
+    script.config = config;
+    script.props = props;
+    store.scripts.push(script);
+  }
+  return Promise.all([
+    storage.script.dump(script),
+    storage.code.dump(props.id, code),
+  ]);
 }
 
-export function updateScriptInfo(id, data, custom) {
-  const tx = db.transaction('scripts', 'readwrite');
-  const os = tx.objectStore('scripts');
-  return getScript(id, tx)
-  .then(script => new Promise((resolve, reject) => {
-    if (!script) return reject();
-    Object.keys(data).forEach(key => {
-      if (key in script) script[key] = data[key];
-    });
-    Object.assign(script.custom, custom);
-    os.put(script).onsuccess = () => {
-      resolve(getScriptInfo(script));
-    };
-  }));
+export function updateScriptInfo(id, data) {
+  const script = store.scriptMap[id];
+  if (!script) return Promise.reject();
+  script.config = Object.assign({}, script.config, data.config);
+  script.custom = Object.assign({}, script.custom, data.custom);
+  return storage.script.dump(script);
 }
 
 export function getExportData(ids, withValues) {
-  const tx = db.transaction(['scripts', 'values']);
-  return loadScripts()
-  .then(scripts => {
-    const res = { scripts };
+  const availableIds = ids.filter(id => {
+    const script = store.scriptMap[id];
+    return script && !script.config.removed;
+  });
+  return Promise.all([
+    Promise.all(availableIds.map(id => getScript({ id }))),
+    storage.code.getMulti(availableIds),
+  ])
+  .then(([scripts, codeMap]) => {
+    const data = {};
+    data.items = scripts.map(script => ({ script, code: codeMap[script.props.id] }));
     if (withValues) {
-      return getValues(scripts.map(script => script.uri), tx)
+      return storage.value.getMulti(ids)
       .then(values => {
-        res.values = values;
-        return res;
+        data.values = values;
+        return data;
       });
     }
-    return res;
-  });
-  function loadScripts() {
-    const os = tx.objectStore('scripts');
-    return Promise.all(ids.map(id => new Promise(resolve => {
-      os.get(id).onsuccess = e => {
-        resolve(e.target.result);
-      };
-    })))
-    .then(data => data.filter(Boolean));
-  }
-}
-
-export function vacuum() {
-  const tx = db.transaction(['scripts', 'require', 'cache', 'values'], 'readwrite');
-  checkPosition();
-  return loadScripts()
-  .then(data => Promise.all([
-    vacuumCache('require', data.require),
-    vacuumCache('cache', data.cache),
-    vacuumCache('values', data.values),
-  ]).then(() => ({
-    require: data.require,
-    cache: data.cache,
-  })))
-  .then(data => Promise.all([
-    Object.keys(data.require).map(k => data.require[k] === 1 && fetchRequire(k)),
-    Object.keys(data.cache).map(k => data.cache[k] === 1 && fetchCache(k)),
-  ]));
-
-  function loadScripts() {
-    const data = {
-      require: {},
-      cache: {},
-      values: {},
-    };
-    return getScriptsByIndex('position', null, tx, script => {
-      const base = script.custom.lastInstallURL;
-      script.meta.require.forEach(url => {
-        const fullUrl = getFullUrl(url, base);
-        data.require[fullUrl] = 1;
-      });
-      Object.keys(script.meta.resources).forEach(key => {
-        const url = script.meta.resources[key];
-        const fullUrl = getFullUrl(url, base);
-        data.cache[fullUrl] = 1;
-      });
-      if (isRemote(script.meta.icon)) data.cache[script.meta.icon] = 1;
-      data.values[script.uri] = 1;
-    })
-    .then(() => data);
-  }
-  function vacuumCache(dbName, dict) {
-    const os = tx.objectStore(dbName);
-    const deleteCache = uri => new Promise(resolve => {
-      if (!dict[uri]) {
-        os.delete(uri).onsuccess = () => { resolve(); };
-      } else {
-        dict[uri] += 1;
-        resolve();
-      }
-    });
-    return new Promise(resolve => {
-      os.openCursor().onsuccess = e => {
-        const { result } = e.target;
-        if (result) {
-          const { value } = result;
-          deleteCache(value.uri).then(() => result.continue());
-        } else resolve();
-      };
-    });
-  }
-}
-
-export function getScriptsByIndex(index, options, cTx, mapEach) {
-  const tx = cTx || db.transaction('scripts');
-  return new Promise(resolve => {
-    const os = tx.objectStore('scripts');
-    const list = [];
-    os.index(index).openCursor(options).onsuccess = e => {
-      const { result } = e.target;
-      if (result) {
-        let { value } = result;
-        value = transformScript(value);
-        if (mapEach) value = mapEach(value);
-        list.push(value);
-        result.continue();
-      } else resolve(list);
-    };
+    return data;
   });
 }
 
-function updateProps(target, source) {
-  if (source) {
-    Object.keys(source).forEach(key => {
-      if (key in target) target[key] = source[key];
-    });
-  }
-  return target;
-}
-
 export function parseScript(data) {
-  const meta = parseMeta(data.code);
-  if (!meta.name) return Promise.reject(i18n('msgInvalidScript'));
-  const res = {
+  const { id, code, message, isNew, config, custom } = data;
+  const meta = parseMeta(code);
+  if (!meta.name) throw i18n('msgInvalidScript');
+  const result = {
     cmd: 'UpdateScript',
     data: {
-      message: data.message == null ? i18n('msgUpdated') : data.message || '',
+      update: {
+        message: message == null ? i18n('msgUpdated') : message || '',
+      },
     },
   };
-  const tx = db.transaction(['scripts', 'require'], 'readwrite');
-  function fetchResources(base) {
-    // @require
-    meta.require.forEach(url => {
-      const fullUrl = getFullUrl(url, base);
-      const cache = data.require && data.require[fullUrl];
-      if (cache) saveRequire(fullUrl, cache, tx);
-      else fetchRequire(fullUrl);
-    });
-    // @resource
-    Object.keys(meta.resources).forEach(k => {
-      const url = meta.resources[k];
-      const fullUrl = getFullUrl(url, base);
-      const cache = data.resources && data.resources[fullUrl];
-      if (cache) saveCache(fullUrl, cache);
-      else fetchCache(fullUrl);
-    });
-    // @icon
-    if (isRemote(meta.icon)) {
-      fetchCache(
-        getFullUrl(meta.icon, base),
-        ({ blob: getBlob }) => new Promise((resolve, reject) => {
-          const blob = getBlob({ type: 'image/png' });
-          const url = URL.createObjectURL(blob);
-          const image = new Image();
-          const free = () => URL.revokeObjectURL(url);
-          image.onload = () => {
-            free();
-            resolve();
-          };
-          image.onerror = () => {
-            free();
-            reject();
-          };
-          image.src = url;
-        }),
-      );
-    }
-  }
-  return queryScript(data.id, meta, tx)
-  .then(result => {
+  return getScript({ id, meta })
+  .then(oldScript => {
     let script;
-    if (result) {
-      if (data.isNew) throw i18n('msgNamespaceConflict');
-      script = result;
+    if (oldScript) {
+      if (isNew) throw i18n('msgNamespaceConflict');
+      script = Object.assign({}, oldScript);
     } else {
-      script = newScript();
-      script.position = position.get();
-      res.cmd = 'AddScript';
-      res.data.message = i18n('msgInstalled');
+      ({ script } = newScript());
+      result.cmd = 'AddScript';
+      result.data.update.message = i18n('msgInstalled');
     }
-    updateProps(script, data.more);
-    Object.assign(script.custom, data.custom);
+    script.config = Object.assign({}, script.config, config);
+    script.custom = Object.assign({}, script.custom, custom);
     script.meta = meta;
-    script.code = data.code;
-    script.uri = getNameURI(script);
-    // use referer page as default homepage
     if (!meta.homepageURL && !script.custom.homepageURL && isRemote(data.from)) {
       script.custom.homepageURL = data.from;
     }
     if (isRemote(data.url)) script.custom.lastInstallURL = data.url;
-    fetchResources(script.custom.lastInstallURL);
-    script.custom.modified = data.modified || Date.now();
-    return saveScript(script, tx);
+    object.set(script, 'props.lastModified', data.modified || Date.now());
+    const position = +data.position;
+    if (position) object.set(script, 'props.position', position);
+    return saveScript(script, code).then(() => script);
   })
   .then(script => {
-    Object.assign(res.data, getScriptInfo(script));
-    return res;
+    fetchScriptResources(script, data);
+    Object.assign(result.data.update, script);
+    result.data.where = { id: script.props.id };
+    return result;
   });
 }
 
-function initPosition() {
-  const os = db.transaction('scripts').objectStore('scripts');
-  return new Promise(resolve => {
-    os.index('position').openCursor(null, 'prev').onsuccess = e => {
-      const { result } = e.target;
-      if (result) position.set(result.key);
-      resolve();
-    };
+function fetchScriptResources(script, cache) {
+  const base = object.get(script, 'custom.lastInstallURL');
+  const meta = script.meta;
+  // @require
+  meta.require.forEach(url => {
+    const fullUrl = getFullUrl(url, base);
+    const cached = object.get(cache, ['require', fullUrl]);
+    if (cached) {
+      storage.require.dump(fullUrl, cached);
+    } else {
+      storage.require.fetch(fullUrl);
+    }
   });
+  // @resource
+  Object.keys(meta.resources).forEach(key => {
+    const url = meta.resources[key];
+    const fullUrl = getFullUrl(url, base);
+    const cached = object.get(cache, ['resources', fullUrl]);
+    if (cached) {
+      storage.cache.dump(fullUrl, cached);
+    } else {
+      storage.cache.fetch(fullUrl);
+    }
+  });
+  // @icon
+  if (isRemote(meta.icon)) {
+    const fullUrl = getFullUrl(meta.icon, base);
+    storage.cache.fetch(fullUrl, ({ blob: getBlob }) => new Promise((resolve, reject) => {
+      const blob = getBlob({ type: 'image/png' });
+      const url = URL.createObjectURL(blob);
+      const image = new Image();
+      const free = () => URL.revokeObjectURL(url);
+      image.onload = () => {
+        free();
+        resolve();
+      };
+      image.onerror = () => {
+        free();
+        reject();
+      };
+      image.src = url;
+    }));
+  }
 }
 
-export function checkPosition(start) {
-  let offset = Math.max(1, start || 0);
-  const updates = [];
-  let changed;
-  if (!position.checking) {
-    const tx = db.transaction('scripts', 'readwrite');
-    const os = tx.objectStore('scripts');
-    position.checking = new Promise(resolve => {
-      os.index('position').openCursor(start).onsuccess = e => {
-        const cursor = e.target.result;
-        if (cursor) {
-          const { value } = cursor;
-          if (value.position !== offset) updates.push({ id: value.id, position: offset });
-          position.update(offset);
-          offset += 1;
-          cursor.continue();
-        } else {
-          resolve();
-        }
-      };
-    })
-    .then(() => {
-      changed = updates.length;
-      return update();
-      function update() {
-        const item = updates.shift();
-        if (item) {
-          return new Promise(resolve => {
-            os.get(item.id).onsuccess = e => {
-              const { result } = e.target;
-              result.position = item.position;
-              os.put(result).onsuccess = () => { resolve(); };
-            };
-          })
-          .then(update);
+export function vacuum() {
+  const valueKeys = {};
+  const cacheKeys = {};
+  const requireKeys = {};
+  const codeKeys = {};
+  const mappings = [
+    [storage.value, valueKeys],
+    [storage.cache, cacheKeys],
+    [storage.require, requireKeys],
+    [storage.code, codeKeys],
+  ];
+  browser.storage.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;
         }
-      }
-    })
-    .then(() => {
-      browser.runtime.sendMessage({
-        cmd: 'ScriptsUpdated',
       });
-      position.checking = null;
-    })
-    .then(() => changed);
-  }
-  return position.checking;
+    });
+  });
+  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);
+    const base = script.custom.lastInstallURL;
+    script.meta.require.forEach(url => {
+      const fullUrl = getFullUrl(url, base);
+      touch(requireKeys, fullUrl);
+    });
+    Object.keys(script.meta.resources).forEach(key => {
+      const url = script.meta.resources[key];
+      const fullUrl = getFullUrl(url, base);
+      touch(cacheKeys, fullUrl);
+    });
+    const { icon } = script.meta;
+    if (isRemote(icon)) {
+      const fullUrl = getFullUrl(icon, base);
+      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);
+      }
+    });
+  });
 }

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

@@ -7,7 +7,6 @@ export getEventEmitter from './events';
 export * from './script';
 export * from './options';
 export * from './requests';
-export * as vmdb from './db';
 export * from './search';
 export { initialize } from './init';
 

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

@@ -53,6 +53,14 @@ export function parseMeta(code) {
 }
 
 export function newScript() {
+  const code = `\
+// ==UserScript==
+// @name New Script
+// @namespace Violentmonkey Scripts
+// @match *://*/*
+// @grant none
+// ==/UserScript==
+`;
   const script = {
     custom: {
       origInclude: true,
@@ -60,36 +68,20 @@ export function newScript() {
       origMatch: true,
       origExcludeMatch: true,
     },
-    enabled: 1,
-    update: 1,
-    code: `\
-// ==UserScript==
-// @name New Script
-// @namespace Violentmonkey Scripts
-// @match *://*/*
-// @grant none
-// ==/UserScript==
-`,
-  };
-  script.meta = parseMeta(script.code);
-  return script;
-}
-
-export function getScriptInfo(script) {
-  return {
-    id: script.id,
-    custom: script.custom,
-    meta: script.meta,
-    enabled: script.enabled,
-    update: script.update,
+    config: {
+      enabled: 1,
+      shouldUpdate: 1,
+    },
+    meta: parseMeta(code),
   };
+  return { script, code };
 }
 
 export function getNameURI(script) {
   const ns = script.meta.namespace || '';
   const name = script.meta.name || '';
   let nameURI = `${escape(ns)}:${escape(name)}:`;
-  if (!ns && !name) nameURI += script.id || '';
+  if (!ns && !name) nameURI += script.props.id || '';
   return nameURI;
 }
 

+ 26 - 20
src/background/utils/update.js

@@ -7,11 +7,16 @@ import { notify } from '.';
 const processes = {};
 
 function doCheckUpdate(script) {
+  const update = {
+    checking: true,
+  };
   const res = {
     cmd: 'UpdateScript',
     data: {
-      id: script.id,
-      checking: true,
+      where: {
+        id: script.props.id,
+      },
+      update,
     },
   };
   const downloadURL = (
@@ -23,35 +28,35 @@ function doCheckUpdate(script) {
   const okHandler = ({ data }) => {
     const meta = parseMeta(data);
     if (compareVersion(script.meta.version, meta.version) < 0) return Promise.resolve();
-    res.data.checking = false;
-    res.data.message = i18n('msgNoUpdate');
+    update.checking = false;
+    update.message = i18n('msgNoUpdate');
     browser.runtime.sendMessage(res);
     return Promise.reject();
   };
   const errHandler = () => {
-    res.data.checking = false;
-    res.data.message = i18n('msgErrorFetchingUpdateInfo');
+    update.checking = false;
+    update.message = i18n('msgErrorFetchingUpdateInfo');
     browser.runtime.sendMessage(res);
     return Promise.reject();
   };
-  const update = () => {
+  const doUpdate = () => {
     if (!downloadURL) {
-      res.data.message = `<span class="new">${i18n('msgNewVersion')}</span>`;
+      update.message = `<span class="new">${i18n('msgNewVersion')}</span>`;
       browser.runtime.sendMessage(res);
       return Promise.reject();
     }
-    res.data.message = i18n('msgUpdating');
+    update.message = i18n('msgUpdating');
     browser.runtime.sendMessage(res);
     return request(downloadURL)
     .then(({ data }) => data, () => {
-      res.data.checking = false;
-      res.data.message = i18n('msgErrorFetchingScript');
+      update.checking = false;
+      update.message = i18n('msgErrorFetchingScript');
       browser.runtime.sendMessage(res);
       return Promise.reject();
     });
   };
   if (!updateURL) return Promise.reject();
-  res.data.message = i18n('msgCheckingForUpdate');
+  update.message = i18n('msgCheckingForUpdate');
   browser.runtime.sendMessage(res);
   return request(updateURL, {
     headers: {
@@ -59,27 +64,28 @@ function doCheckUpdate(script) {
     },
   })
   .then(okHandler, errHandler)
-  .then(update);
+  .then(doUpdate);
 }
 
 export default function checkUpdate(script) {
-  let promise = processes[script.id];
+  const { id } = script.props;
+  let promise = processes[id];
   if (!promise) {
     let updated = false;
     promise = doCheckUpdate(script)
     .then(code => parseScript({
+      id,
       code,
-      id: script.id,
     }))
     .then(res => {
-      const { data } = res;
-      data.checking = false;
+      const { data: { update } } = res;
+      update.checking = false;
       browser.runtime.sendMessage(res);
       updated = true;
       if (getOption('notifyUpdates')) {
         notify({
           title: i18n('titleScriptUpdated'),
-          body: i18n('msgScriptUpdated', [data.meta.name || i18n('labelNoName')]),
+          body: i18n('msgScriptUpdated', [update.meta.name || i18n('labelNoName')]),
         });
       }
     })
@@ -87,10 +93,10 @@ export default function checkUpdate(script) {
       if (process.env.DEBUG) console.error(err);
     })
     .then(() => {
-      delete processes[script.id];
+      delete processes[id];
       return updated;
     });
-    processes[script.id] = promise;
+    processes[id] = promise;
   }
   return promise;
 }

+ 14 - 1
src/common/index.js

@@ -37,11 +37,24 @@ export const object = {
       }
       sub = child;
     });
-    if (val == null) {
+    if (typeof val === 'undefined') {
       delete sub[lastKey];
     } else {
       sub[lastKey] = val;
     }
+    return root;
+  },
+  purify(obj) {
+    // Remove keys with undefined values
+    if (Array.isArray(obj)) {
+      obj.forEach(object.purify);
+    } else if (obj && typeof obj === 'object') {
+      Object.keys(obj).forEach(key => {
+        const type = typeof obj[key];
+        if (type === 'undefined') delete obj[key];
+        else object.purify(obj[key]);
+      });
+    }
     return obj;
   },
 };

+ 156 - 7
src/common/ui/code.vue

@@ -1,7 +1,38 @@
 <template>
-  <vue-code class="editor-code"
-    :options="cmOptions" v-model="content" @ready="onReady"
-  />
+  <div class="flex flex-col">
+    <vue-code class="editor-code flex-auto"
+      :options="cmOptions" v-model="content" @ready="onReady"
+    />
+    <div class="frame-block" v-show="search.show">
+      <button class="pull-right" @click="clearSearch">&times;</button>
+      <form class="inline-block mr-1" @submit.prevent="goToLine()">
+        <span v-text="i18n('labelLineNumber')"></span>
+        <input class="w-1" v-model="search.line">
+      </form>
+      <form class="inline-block mr-1" @submit.prevent="findNext()">
+        <span v-text="i18n('labelSearch')"></span>
+        <tooltip title="Ctrl-F">
+          <input ref="search" v-model="search.state.query">
+        </tooltip>
+        <tooltip title="Shift-Ctrl-G">
+          <button type="button" @click="findNext(1)">&lt;</button>
+        </tooltip>
+        <tooltip title="Ctrl-G">
+          <button type="submit">&gt;</button>
+        </tooltip>
+      </form>
+      <form class="inline-block mr-1" @submit.prevent="replace()" v-if="!readonly">
+        <span v-text="i18n('labelReplace')"></span>
+        <input v-model="search.state.replace">
+        <tooltip title="Shift-Ctrl-F">
+          <button type="submit" v-text="i18n('buttonReplace')"></button>
+        </tooltip>
+        <tooltip title="Shift-Ctrl-R">
+          <button type="button" v-text="i18n('buttonReplaceAll')" @click="replace(1)"></button>
+        </tooltip>
+      </form>
+    </div>
+  </div>
 </template>
 
 <script>
@@ -20,6 +51,8 @@ import 'codemirror/addon/search/match-highlighter';
 import 'codemirror/addon/search/searchcursor';
 import 'codemirror/addon/selection/active-line';
 import CodeMirror from 'codemirror';
+import { debounce } from 'src/common';
+import Tooltip from './tooltip';
 
 function getHandler(key) {
   return (cm) => {
@@ -39,7 +72,7 @@ function indentWithTab(cm) {
 }
 
 [
-  'save', 'cancel', 'find', 'findNext', 'findPrev', 'replace', 'replaceAll',
+  'save', 'cancel', 'find', 'findNext', 'findPrev', 'replace', 'replaceAll', 'close',
 ].forEach((key) => {
   CodeMirror.commands[key] = getHandler(key);
 });
@@ -58,6 +91,44 @@ const cmOptions = {
   theme: 'eclipse',
 };
 
+function findNext(cm, state, reversed) {
+  cm.operation(() => {
+    const query = state.query || '';
+    let cursor = cm.getSearchCursor(query, reversed ? state.posFrom : state.posTo);
+    if (!cursor.find(reversed)) {
+      cursor = cm.getSearchCursor(query,
+        reversed ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0));
+      if (!cursor.find(reversed)) return;
+    }
+    cm.setSelection(cursor.from(), cursor.to());
+    state.posFrom = cursor.from();
+    state.posTo = cursor.to();
+  });
+}
+function replaceOne(cm, state) {
+  const start = cm.getCursor('start');
+  const end = cm.getCursor('end');
+  state.posTo = state.posFrom;
+  findNext(cm, state);
+  const start2 = cm.getCursor('start');
+  const end2 = cm.getCursor('end');
+  if (
+    start.line === start2.line && start.ch === start2.ch
+    && end.line === end2.line && end.ch === end2.ch
+  ) {
+    cm.replaceRange(state.replace, start, end);
+    findNext(cm, state);
+  }
+}
+function replaceAll(cm, state) {
+  cm.operation(() => {
+    const query = state.query || '';
+    for (let cursor = cm.getSearchCursor(query); cursor.findNext();) {
+      cursor.replace(state.replace);
+    }
+  });
+}
+
 export default {
   props: {
     readonly: {
@@ -73,11 +144,19 @@ export default {
   },
   components: {
     VueCode,
+    Tooltip,
   },
   data() {
     return {
-      content: this.value,
       cmOptions,
+      content: this.value,
+      search: {
+        show: false,
+        state: {
+          query: null,
+          replace: null,
+        },
+      },
     };
   },
   watch: {
@@ -92,12 +171,32 @@ export default {
       cm.getDoc().clearHistory();
       cm.focus();
     },
+    'search.state.query'() {
+      this.debouncedFind();
+    },
   },
   methods: {
     onReady(cm) {
       this.cm = cm;
       if (this.readonly) cm.setOption('readOnly', true);
-      cm.state.commands = this.commands;
+      cm.state.commands = Object.assign({
+        cancel: () => {
+          if (this.search.show) {
+            this.clearSearch();
+          } else {
+            cm.execCommand('close');
+          }
+        },
+        find: this.find,
+        findNext: this.findNext,
+        findPrev: () => {
+          this.findNext(1);
+        },
+        replace: this.replace,
+        replaceAll: () => {
+          this.replace(1);
+        },
+      }, this.commands);
       cm.setOption('extraKeys', {
         Esc: 'cancel',
         Tab: indentWithTab,
@@ -117,7 +216,7 @@ export default {
         let stop = false;
         if (keyMap) {
           CodeMirror.lookupKey(name, keyMap, (b) => {
-            if (this.commands[b]) {
+            if (cm.state.commands[b]) {
               e.preventDefault();
               e.stopPropagation();
               cm.execCommand(b);
@@ -128,8 +227,58 @@ export default {
         return stop;
       });
     },
+    doFind(reversed) {
+      const { state } = this.search;
+      const { cm } = this;
+      if (state.query) {
+        findNext(cm, state, reversed);
+      }
+      this.search.show = true;
+    },
+    find() {
+      const { state } = this.search;
+      state.posTo = state.posFrom;
+      this.doFind();
+      this.$nextTick(() => {
+        const { search } = this.$refs;
+        search.select();
+        search.focus();
+      });
+    },
+    findNext(reversed) {
+      this.doFind(reversed);
+      this.$nextTick(() => {
+        this.$refs.search.focus();
+      });
+    },
+    clearSearch() {
+      const { cm } = this;
+      cm.operation(() => {
+        const { state } = this.search;
+        state.posFrom = null;
+        state.posTo = null;
+        this.search.show = false;
+      });
+      cm.focus();
+    },
+    replace(all) {
+      const { cm } = this;
+      const { state } = this.search;
+      if (!state.query) {
+        this.find();
+        return;
+      }
+      (all ? replaceAll : replaceOne)(cm, state);
+    },
+    goToLine() {
+      const line = this.search.line - 1;
+      const { cm } = this;
+      if (!isNaN(line)) cm.setCursor(line, 0);
+      cm.focus();
+    },
   },
   mounted() {
+    this.debouncedFind = debounce(this.doFind, 100);
     if (this.global) window.addEventListener('keydown', this.onKeyDown, false);
   },
   beforeDestroy() {

+ 23 - 4
src/options/views/tooltip.vue → src/common/ui/tooltip.vue

@@ -100,20 +100,39 @@ $gap: 10px;
         top: 0;
       }
     }
+    &.tooltip-left,
     &.tooltip-right {
       top: 50%;
-      left: 100%;
-      margin-left: 10px;
       > * {
         transform: translateY(-50%);
       }
       > i {
-        right: 100%;
         border-top: $border-side;
-        border-right: $border-base;
         border-bottom: $border-side;
       }
     }
+    &.tooltip-left {
+      margin-right: 10px;
+      right: 100%;
+      > div {
+        right: 100%;
+      }
+      > i {
+        left: 100%;
+        border-left: $border-base;
+      }
+    }
+    &.tooltip-right {
+      margin-left: 10px;
+      left: 100%;
+      > div {
+        left: 100%;
+      }
+      > i {
+        right: 100%;
+        border-right: $border-base;
+      }
+    }
   }
 }
 </style>

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

@@ -53,7 +53,7 @@ export default {
       message: '',
       code: '',
       commands: {
-        cancel: this.close,
+        close: this.close,
       },
       info: {},
     };
@@ -161,7 +161,6 @@ export default {
       });
     },
     close() {
-      // window.close();
       sendMessage({ cmd: 'TabClose' });
     },
     getFile(url, { isBlob, useCache } = {}) {
@@ -206,8 +205,8 @@ export default {
           resources: this.resources,
         },
       })
-      .then(res => {
-        this.message = `${res.message}[${this.getTimeString()}]`;
+      .then(result => {
+        this.message = `${result.update.message}[${this.getTimeString()}]`;
         if (this.closeAfterInstall) this.close();
         else if (this.isLocal && options.get('trackLocalFile')) this.trackLocalFile();
       });

+ 2 - 2
src/injected/content/index.js

@@ -53,8 +53,8 @@ export default function initialize(contentId, webId) {
   .then(data => {
     if (data.scripts) {
       data.scripts.forEach(script => {
-        ids.push(script.id);
-        if (script.enabled) badge.number += 1;
+        ids.push(script.props.id);
+        if (script.config.enabled) badge.number += 1;
       });
     }
     bridge.post({ cmd: 'LoadScripts', data });

+ 27 - 24
src/injected/web/index.js

@@ -21,30 +21,31 @@ export default function initialize(webId, contentId, props) {
   bridge.checkLoad();
 }
 
-const commands = {};
-const ainject = {};
-const values = {};
+const store = {
+  commands: {},
+  ainject: {},
+  values: {},
+};
 
 const handlers = {
   LoadScripts: onLoadScripts,
   Command(data) {
-    const func = commands[data];
+    const func = store.commands[data];
     if (func) func();
   },
   GotRequestId: onRequestStart,
   HttpRequested: onRequestCallback,
   TabClosed: onTabClosed,
-  UpdateValues(data) {
-    if (values[data.uri]) values[data.uri] = data.values;
+  UpdateValues({ id, values }) {
+    if (values[id]) values[id] = values;
   },
   NotificationClicked: onNotificationClicked,
   NotificationClosed: onNotificationClosed,
-  // advanced inject
   Injected(id) {
-    const item = ainject[id];
+    const item = store.ainject[id];
     const func = window[`VM_${id}`];
     delete window[`VM_${id}`];
-    delete ainject[id];
+    delete store.ainject[id];
     if (item && func) runCode(item[0], func, item[1], item[2]);
   },
   ScriptChecked(data) {
@@ -85,13 +86,13 @@ function onLoadScripts(data) {
   };
   if (data.scripts) {
     forEach(data.scripts, script => {
-      values[script.uri] = data.values[script.uri] || {};
-      if (script && script.enabled) {
+      if (script && script.config.enabled) {
         // XXX: use camelCase since v2.6.3
         const runAt = script.custom.runAt || script.custom['run-at']
           || script.meta.runAt || script.meta['run-at'];
         const list = listMap[runAt] || end;
         list.push(script);
+        store.values[script.props.id] = data.values[script.props.id] || {};
       }
     });
     run(start);
@@ -99,7 +100,10 @@ function onLoadScripts(data) {
   bridge.checkLoad();
   function buildCode(script) {
     const requireKeys = script.meta.require || [];
-    const wrapper = wrapGM(script, data.cache);
+    const code = data.code[script.props.id] || '';
+    const matches = code.match(/\/\/\s+==UserScript==\s+([\s\S]*?)\/\/\s+==\/UserScript==\s/);
+    const metaStr = matches ? matches[1] : '';
+    const wrapper = wrapGM(script, metaStr, data.cache);
     // Must use Object.getOwnPropertyNames to list unenumerable properties
     const wrapperKeys = Object.getOwnPropertyNames(wrapper);
     const wrapperInit = map(wrapperKeys, name => `this["${name}"]=${name}`).join(';');
@@ -113,22 +117,22 @@ function onLoadScripts(data) {
       }
     });
     // wrap code to make 'use strict' work
-    codeSlices.push(`!function(){${script.code}\n}.call(this)`);
+    codeSlices.push(`!function(){${code}\n}.call(this)`);
     codeSlices.push('}.call(this);');
-    const code = codeSlices.join('\n');
-    const name = script.custom.name || script.meta.name || script.id;
+    const codeConcat = codeSlices.join('\n');
+    const name = script.custom.name || script.meta.name || script.props.id;
     const args = map(wrapperKeys, key => wrapper[key]);
     const thisObj = wrapper.window || wrapper;
     const id = getUniqId();
-    ainject[id] = [name, args, thisObj];
-    bridge.post({ cmd: 'Inject', data: [id, wrapperKeys, code] });
+    store.ainject[id] = [name, args, thisObj];
+    bridge.post({ cmd: 'Inject', data: [id, wrapperKeys, codeConcat] });
   }
   function run(list) {
     while (list.length) buildCode(list.shift());
   }
 }
 
-function wrapGM(script, cache) {
+function wrapGM(script, metaStr, cache) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
   const gm = {};
@@ -158,10 +162,9 @@ function wrapGM(script, cache) {
     unsafeWindow: { value: window },
     GM_info: {
       get() {
-        const matches = script.code.match(/\/\/\s+==UserScript==\s+([\s\S]*?)\/\/\s+==\/UserScript==\s/);
         const obj = {
-          scriptMetaStr: matches ? matches[1] : '',
-          scriptWillUpdate: !!script.update,
+          scriptMetaStr: metaStr,
+          scriptWillUpdate: !!script.config.shouldUpdate,
           scriptHandler: 'Violentmonkey',
           version: bridge.version,
           script: {
@@ -279,7 +282,7 @@ function wrapGM(script, cache) {
     },
     GM_registerMenuCommand: {
       value(cap, func, acc) {
-        commands[cap] = func;
+        store.commands[cap] = func;
         bridge.post({ cmd: 'RegisterMenu', data: [cap, acc] });
       },
     },
@@ -315,7 +318,7 @@ function wrapGM(script, cache) {
   });
   return gm;
   function getValues() {
-    return values[script.uri];
+    return store.values[script.props.id];
   }
   function propertyToString() {
     return '[Violentmonkey property]';
@@ -330,7 +333,7 @@ function wrapGM(script, cache) {
     bridge.post({
       cmd: 'SetValue',
       data: {
-        uri: script.uri,
+        where: { id: script.props.id },
         values: getValues(),
       },
     });

+ 2 - 1
src/manifest.json

@@ -47,6 +47,7 @@
     "webRequest",
     "webRequestBlocking",
     "notifications",
-    "storage"
+    "storage",
+    "unlimitedStorage"
   ]
 }

+ 9 - 14
src/options/app.js

@@ -75,24 +75,19 @@ function initMain() {
     UpdateSync(data) {
       store.sync = data;
     },
-    AddScript(data) {
-      data.message = '';
-      initSearch(data);
-      store.scripts.push(data);
+    AddScript({ update }) {
+      update.message = '';
+      initSearch(update);
+      store.scripts.push(update);
     },
     UpdateScript(data) {
       if (!data) return;
-      const script = store.scripts.find(item => item.id === data.id);
-      if (script) {
-        Object.keys(data).forEach((key) => {
-          Vue.set(script, key, data[key]);
-        });
-        initSearch(script);
+      const index = store.scripts.findIndex(item => item.props.id === data.where.id);
+      if (index >= 0) {
+        const updated = Object.assign({}, store.scripts[index], data.update);
+        Vue.set(store.scripts, index, updated);
+        initSearch(updated);
       }
     },
-    RemoveScript(data) {
-      const i = store.scripts.findIndex(script => script.id === data);
-      if (i >= 0) store.scripts.splice(i, 1);
-    },
   });
 }

+ 36 - 170
src/options/views/edit/index.vue

@@ -19,38 +19,9 @@
       />
       <vm-settings
         v-show="nav === 'settings'" class="abs-full"
-        :value="value" :settings="settings"
+        :value="script" :settings="settings"
       />
     </div>
-    <div class="frame-block" v-show="search.show">
-      <button class="pull-right" @click="clearSearch">&times;</button>
-      <form class="inline-block mr-1" @submit.prevent="goToLine()">
-        <span v-text="i18n('labelLineNumber')"></span>
-        <input class="w-1" v-model="search.line">
-      </form>
-      <form class="inline-block mr-1" @submit.prevent="findNext()">
-        <span v-text="i18n('labelSearch')"></span>
-        <tooltip title="Ctrl-F">
-          <input ref="search" v-model="search.state.query">
-        </tooltip>
-        <tooltip title="Shift-Ctrl-G">
-          <button type="button" @click="findNext(1)">&lt;</button>
-        </tooltip>
-        <tooltip title="Ctrl-G">
-          <button type="submit">&gt;</button>
-        </tooltip>
-      </form>
-      <form class="inline-block mr-1" @submit.prevent="replace()">
-        <span v-text="i18n('labelReplace')"></span>
-        <input v-model="search.state.replace">
-        <tooltip title="Shift-Ctrl-F">
-          <button type="submit" v-text="i18n('buttonReplace')"></button>
-        </tooltip>
-        <tooltip title="Shift-Ctrl-R">
-          <button type="button" v-text="i18n('buttonReplaceAll')" @click="replace(1)"></button>
-        </tooltip>
-      </form>
-    </div>
     <div class="frame-block">
       <div class="pull-right">
         <button v-text="i18n('buttonSave')" @click="save" :disabled="!canSave"></button>
@@ -62,12 +33,10 @@
 </template>
 
 <script>
-import CodeMirror from 'codemirror';
-import { i18n, debounce, sendMessage, noop } from 'src/common';
+import { i18n, sendMessage, noop } from 'src/common';
 import VmCode from 'src/common/ui/code';
 import { showMessage } from '../../utils';
 import VmSettings from './settings';
-import Tooltip from '../tooltip';
 
 function fromList(list) {
   return (list || []).join('\n');
@@ -77,83 +46,23 @@ function toList(text) {
   .map(line => line.trim())
   .filter(Boolean);
 }
-function findNext(cm, state, reversed) {
-  cm.operation(() => {
-    const query = state.query || '';
-    let cursor = cm.getSearchCursor(query, reversed ? state.posFrom : state.posTo);
-    if (!cursor.find(reversed)) {
-      cursor = cm.getSearchCursor(query,
-        reversed ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0));
-      if (!cursor.find(reversed)) return;
-    }
-    cm.setSelection(cursor.from(), cursor.to());
-    state.posFrom = cursor.from();
-    state.posTo = cursor.to();
-  });
-}
-function replaceOne(cm, state) {
-  const start = cm.getCursor('start');
-  const end = cm.getCursor('end');
-  state.posTo = state.posFrom;
-  findNext(cm, state);
-  const start2 = cm.getCursor('start');
-  const end2 = cm.getCursor('end');
-  if (
-    start.line === start2.line && start.ch === start2.ch
-    && end.line === end2.line && end.ch === end2.ch
-  ) {
-    cm.replaceRange(state.replace, start, end);
-    findNext(cm, state);
-  }
-}
-function replaceAll(cm, state) {
-  cm.operation(() => {
-    const query = state.query || '';
-    for (let cursor = cm.getSearchCursor(query); cursor.findNext();) {
-      cursor.replace(state.replace);
-    }
-  });
-}
 
 export default {
-  props: ['value'],
+  props: ['initial'],
   components: {
     VmCode,
     VmSettings,
-    Tooltip,
   },
   data() {
-    this.debouncedFind = debounce(this.doFind, 100);
     return {
       nav: 'code',
       canSave: false,
+      script: null,
       code: '',
       settings: {},
-      search: {
-        show: false,
-        state: {
-          query: null,
-          replace: null,
-        },
-      },
       commands: {
         save: this.save,
-        cancel: () => {
-          if (this.search.show) {
-            this.clearSearch();
-          } else {
-            this.close();
-          }
-        },
-        find: this.find,
-        findNext: this.findNext,
-        findPrev: () => {
-          this.findNext(1);
-        },
-        replace: this.replace,
-        replaceAll: () => {
-          this.replace(1);
-        },
+        close: this.close,
       },
     };
   },
@@ -167,22 +76,28 @@ export default {
         this.canSave = true;
       },
     },
-    'search.state.query'() {
-      this.debouncedFind();
-    },
+  },
+  created() {
+    this.script = this.initial;
   },
   mounted() {
-    (this.value.id ? sendMessage({
-      cmd: 'GetScript',
-      data: this.value.id,
-    }) : Promise.resolve(this.value))
-    .then(script => {
+    const { id } = this.script.props;
+    (id ? sendMessage({
+      cmd: 'GetScriptCode',
+      data: id,
+    }) : sendMessage({
+      cmd: 'NewScript',
+    }).then(({ script, code }) => {
+      this.script = script;
+      return code;
+    }))
+    .then(code => {
+      this.code = code;
       const settings = {};
-      settings.more = {
-        update: script.update,
+      const { custom, config } = this.script;
+      settings.config = {
+        shouldUpdate: config.shouldUpdate,
       };
-      this.code = script.code;
-      const { custom } = script;
       settings.custom = [
         'name',
         'homepageURL',
@@ -210,8 +125,8 @@ export default {
   },
   methods: {
     save() {
-      const { settings: { custom, more } } = this;
-      const value = [
+      const { settings: { config, custom: rawCustom } } = this;
+      const custom = [
         'name',
         'runAt',
         'homepageURL',
@@ -222,30 +137,30 @@ export default {
         'origMatch',
         'origExcludeMatch',
       ].reduce((val, key) => {
-        val[key] = custom[key];
+        val[key] = rawCustom[key];
         return val;
       }, {
-        include: toList(custom.include),
-        match: toList(custom.match),
-        exclude: toList(custom.exclude),
-        excludeMatch: toList(custom.excludeMatch),
+        include: toList(rawCustom.include),
+        match: toList(rawCustom.match),
+        exclude: toList(rawCustom.exclude),
+        excludeMatch: toList(rawCustom.excludeMatch),
       });
+      const { id } = this.script.props;
       return sendMessage({
         cmd: 'ParseScript',
         data: {
-          id: this.value.id,
+          id,
+          custom,
+          config,
           code: this.code,
           // User created scripts MUST be marked `isNew` so that
           // the backend is able to check namespace conflicts,
           // otherwise the script with same namespace will be overridden
-          isNew: !this.value.id,
+          isNew: !id,
           message: '',
-          custom: value,
-          more,
         },
       })
-      .then(script => {
-        this.$emit('input', script);
+      .then(() => {
         this.canSave = false;
       }, err => {
         showMessage({ text: err });
@@ -278,55 +193,6 @@ export default {
     initEditor(cm) {
       this.cm = cm;
     },
-    doFind(reversed) {
-      const { state } = this.search;
-      const { cm } = this;
-      if (state.query) {
-        findNext(cm, state, reversed);
-      }
-      this.search.show = true;
-    },
-    find() {
-      const { state } = this.search;
-      state.posTo = state.posFrom;
-      this.doFind();
-      this.$nextTick(() => {
-        const { search } = this.$refs;
-        search.select();
-        search.focus();
-      });
-    },
-    findNext(reversed) {
-      this.doFind(reversed);
-      this.$nextTick(() => {
-        this.$refs.search.focus();
-      });
-    },
-    clearSearch() {
-      const { cm } = this;
-      cm.operation(() => {
-        const { state } = this.search;
-        state.posFrom = null;
-        state.posTo = null;
-        this.search.show = false;
-      });
-      cm.focus();
-    },
-    replace(all) {
-      const { cm } = this;
-      const { state } = this.search;
-      if (!state.query) {
-        this.find();
-        return;
-      }
-      (all ? replaceAll : replaceOne)(cm, state);
-    },
-    goToLine() {
-      const line = this.search.line - 1;
-      const { cm } = this;
-      if (!isNaN(line)) cm.setCursor(line, 0);
-      cm.focus();
-    },
   },
 };
 </script>

+ 4 - 4
src/options/views/edit/settings.vue

@@ -3,7 +3,7 @@
     <h4 v-text="i18n('editLabelSettings')"></h4>
     <div class="form-group">
       <label>
-        <input type="checkbox" v-model="more.update">
+        <input type="checkbox" v-model="config.shouldUpdate">
         <span v-text="i18n('labelAllowUpdate')"></span>
       </label>
     </div>
@@ -88,7 +88,7 @@
 
 <script>
 import { i18n } from 'src/common';
-import Tooltip from '../tooltip';
+import Tooltip from 'src/common/ui/tooltip';
 
 export default {
   props: ['value', 'settings'],
@@ -99,8 +99,8 @@ export default {
     custom() {
       return this.settings.custom || {};
     },
-    more() {
-      return this.settings.more || {};
+    config() {
+      return this.settings.config || {};
     },
     placeholders() {
       const { value } = this;

+ 47 - 17
src/options/views/script-item.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="script" :class="{disabled:!script.enabled}" draggable="true" @dragstart.prevent="onDragStart">
+  <div class="script" :class="{ disabled: !script.config.enabled, removed: script.config.removed }" :draggable="!script.config.removed" @dragstart.prevent="onDragStart">
     <img class="script-icon" :src="safeIcon">
     <div class="script-info flex">
       <div class="script-name ellipsis" v-text="script.custom.name || getLocaleString('name')"></div>
@@ -9,7 +9,15 @@
         <a :href="'mailto:'+author.email" v-if="author.email" v-text="author.name"></a>
         <span v-if="!author.email" v-text="author.name"></span>
       </div>
-      <div class="script-version" v-text="script.meta.version?'v'+script.meta.version:''"></div>
+      <div class="script-version" v-text="script.meta.version ? `v${script.meta.version}` : ''"></div>
+      <div v-if="script.config.removed" v-text="i18n('labelRemoved')"></div>
+      <div v-if="script.config.removed">
+        <tooltip :title="i18n('buttonUndo')" placement="left">
+          <span class="btn-ghost" @click="onRemove(0)">
+            <icon name="undo"></icon>
+          </span>
+        </tooltip>
+      </div>
     </div>
     <p class="script-desc ellipsis" v-text="script.custom.description || getLocaleString('description')"></p>
     <div class="script-buttons flex">
@@ -20,7 +28,7 @@
       </tooltip>
       <tooltip :title="labelEnable" align="start">
         <span class="btn-ghost" @click="onEnable">
-          <icon :name="`toggle-${script.enabled ? 'on' : 'off'}`"></icon>
+          <icon :name="`toggle-${script.config.enabled ? 'on' : 'off'}`"></icon>
         </span>
       </tooltip>
       <tooltip v-if="canUpdate" :title="i18n('buttonUpdate')" align="start">
@@ -41,7 +49,7 @@
       </tooltip>
       <div class="flex-auto" v-text="script.message"></div>
       <tooltip :title="i18n('buttonRemove')" align="end">
-        <span class="btn-ghost" @click="onRemove">
+        <span class="btn-ghost" @click="onRemove(1)">
           <icon name="trash"></icon>
         </span>
       </tooltip>
@@ -52,7 +60,7 @@
 <script>
 import { sendMessage, getLocaleString } from 'src/common';
 import Icon from 'src/common/ui/icon';
-import Tooltip from './tooltip';
+import Tooltip from 'src/common/ui/tooltip';
 import { store } from '../utils';
 
 const DEFAULT_ICON = '/public/images/icon48.png';
@@ -92,7 +100,7 @@ export default {
   computed: {
     canUpdate() {
       const { script } = this;
-      return script.update && (
+      return script.config.shouldUpdate && (
         script.custom.updateURL ||
         script.meta.updateURL ||
         script.custom.downloadURL ||
@@ -114,7 +122,7 @@ export default {
       };
     },
     labelEnable() {
-      return this.script.enabled ? this.i18n('buttonDisable') : this.i18n('buttonEnable');
+      return this.script.config.enabled ? this.i18n('buttonDisable') : this.i18n('buttonEnable');
     },
   },
   mounted() {
@@ -133,27 +141,34 @@ export default {
       return getLocaleString(this.script.meta, key);
     },
     onEdit() {
-      this.$emit('edit', this.script.id);
+      this.$emit('edit', this.script.props.id);
     },
-    onRemove() {
+    onRemove(remove) {
       sendMessage({
-        cmd: 'RemoveScript',
-        data: this.script.id,
+        cmd: 'UpdateScriptInfo',
+        data: {
+          id: this.script.props.id,
+          config: {
+            removed: remove ? 1 : 0,
+          },
+        },
       });
     },
     onEnable() {
       sendMessage({
         cmd: 'UpdateScriptInfo',
         data: {
-          id: this.script.id,
-          enabled: this.script.enabled ? 0 : 1,
+          id: this.script.props.id,
+          config: {
+            enabled: this.script.config.enabled ? 0 : 1,
+          },
         },
       });
     },
     onUpdate() {
       sendMessage({
         cmd: 'CheckUpdate',
-        data: this.script.id,
+        data: this.script.props.id,
       });
     },
     onDragStart(e) {
@@ -294,10 +309,14 @@ export default {
   &:hover {
     border-color: darkgray;
   }
-  &.disabled {
+  &.disabled,
+  &.removed {
     background: #f0f0f0;
     color: #999;
   }
+  &.removed {
+    padding-bottom: 10px;
+  }
   &-buttons {
     align-items: center;
     line-height: 1;
@@ -305,6 +324,9 @@ export default {
     > .flex-auto {
       margin-left: 1rem;
     }
+    .removed & {
+      display: none;
+    }
   }
   &-info {
     margin-left: 3.5rem;
@@ -319,12 +341,17 @@ export default {
   }
   &-icon {
     position: absolute;
-    top: 1rem;
     width: 3rem;
     height: 3rem;
-    .disabled & {
+    top: 1rem;
+    .disabled &,
+    .removed & {
       filter: grayscale(.8);
     }
+    .removed & {
+      width: 2rem;
+      height: 2rem;
+    }
   }
   &-name {
     font-weight: bold;
@@ -343,6 +370,9 @@ export default {
     &::after {
       content: "\200b";
     }
+    .removed & {
+      display: none;
+    }
   }
 }
 .dragging {

+ 9 - 9
src/options/views/tab-installed.vue

@@ -23,13 +23,13 @@
       </div>
     </header>
     <div class="scripts">
-      <item v-for="script in scripts" :key="script.id"
+      <item v-for="script in scripts" :key="script.props.id"
       :script="script" @edit="editScript" @move="moveScript"></item>
     </div>
     <div class="backdrop" :class="{mask: store.loading}" v-show="message">
       <div v-html="message"></div>
     </div>
-    <edit v-if="script" v-model="script" @close="endEditScript"></edit>
+    <edit v-if="script" :initial="script" @close="endEditScript"></edit>
   </div>
 </template>
 
@@ -37,10 +37,10 @@
 import { i18n, sendMessage, noop, debounce } from 'src/common';
 import { Dropdown as VmDropdown } from 'src/common/ui/vueleton';
 import Icon from 'src/common/ui/icon';
+import Tooltip from 'src/common/ui/tooltip';
 import Item from './script-item';
 import Edit from './edit';
 import { store, showMessage } from '../utils';
-import Tooltip from './tooltip';
 
 export default {
   components: {
@@ -86,10 +86,7 @@ export default {
         : scripts;
     },
     newScript() {
-      sendMessage({ cmd: 'NewScript' })
-      .then((script) => {
-        this.script = script;
-      });
+      this.script = {};
     },
     updateAll() {
       sendMessage({ cmd: 'CheckUpdateAll' });
@@ -120,7 +117,7 @@ export default {
       });
     },
     editScript(id) {
-      this.script = this.store.scripts.find(script => script.id === id);
+      this.script = this.store.scripts.find(script => script.props.id === id);
     },
     endEditScript() {
       this.script = null;
@@ -130,7 +127,7 @@ export default {
       sendMessage({
         cmd: 'Move',
         data: {
-          id: this.store.scripts[data.from].id,
+          id: this.store.scripts[data.from].props.id,
           offset: data.to - data.from,
         },
       })
@@ -151,6 +148,9 @@ export default {
         this.store.scripts = seq.concat.apply([], seq);
       });
     },
+    onScriptUpdated(script) {
+      this.script = script;
+    },
   },
   created() {
     this.debouncedUpdate = debounce(this.onUpdate, 200);

+ 16 - 13
src/options/views/tab-settings/vm-export.vue

@@ -45,7 +45,7 @@ export default {
   },
   computed: {
     selectedIds() {
-      return this.items.filter(item => item.active).map(item => item.script.id);
+      return this.items.filter(item => item.active).map(item => item.script.props.id);
     },
   },
   created() {
@@ -136,7 +136,7 @@ function exportData(selectedIds) {
       ids: selectedIds,
     },
   })
-  .then((data) => {
+  .then(data => {
     const names = {};
     const vm = {
       scripts: {},
@@ -144,28 +144,31 @@ function exportData(selectedIds) {
     };
     delete vm.settings.sync;
     if (withValues) vm.values = {};
-    const files = data.scripts.map((script) => {
-      let name = script.custom.name || script.meta.name || 'Noname';
+    const files = data.items.map(({ script, code }) => {
+      let name = script.custom.name || script.meta.name || script.props.id;
       if (names[name]) {
         names[name] += 1;
         name = `${name}_${names[name]}`;
       } else names[name] = 1;
-      vm.scripts[name] = ['custom', 'enabled', 'update', 'position']
-      .reduce((res, key) => {
-        res[key] = script[key];
-        return res;
-      }, {});
+      const info = {
+        custom: script.custom,
+        config: script.config,
+        position: script.props.position,
+      };
       if (withValues) {
-        const values = data.values[script.uri];
-        if (values) vm.values[script.uri] = values;
+        // `values` are related to scripts by `props.id` in Violentmonkey,
+        // but by the global `props.uri` when exported.
+        const values = data.values[script.props.id];
+        if (values) vm.values[script.props.uri] = values;
       }
+      vm.scripts[name] = info;
       return {
         name: `${name}.user.js`,
-        content: script.code,
+        content: code,
       };
     });
     files.push({
-      name: 'ViolentMonkey',
+      name: 'violentmonkey',
       content: JSON.stringify(vm),
     });
     return files;

+ 33 - 32
src/options/views/tab-settings/vm-import.vue

@@ -65,26 +65,7 @@ function getVMConfig(text) {
   } catch (e) {
     console.warn('Error parsing ViolentMonkey configuration.');
   }
-  vm = vm || {};
-  forEachItem(vm.values, (value, key) => {
-    if (value) {
-      sendMessage({
-        cmd: 'SetValue',
-        data: {
-          uri: key,
-          values: value,
-        },
-      });
-    }
-  });
-  if (options.get('importSettings')) {
-    const ignoreKeys = ['sync'];
-    forEachItem(vm.settings, (value, key) => {
-      if (ignoreKeys.includes(key)) return;
-      options.set(key, value);
-    });
-  }
-  return vm;
+  return vm || {};
 }
 
 function getVMFile(entry, vmFile) {
@@ -97,10 +78,12 @@ function getVMFile(entry, vmFile) {
       if (vm.scripts) {
         const more = vm.scripts[entry.filename.slice(0, -8)];
         if (more) {
-          if (more.custom) data.custom = more.custom;
-          data.more = more;
-          delete more.id;
-          delete more.custom;
+          data.custom = more.custom;
+          data.config = more.config || {};
+          data.position = more.position;
+          // Import data from older version
+          if ('enabled' in more) data.config.enabled = more.enabled;
+          if ('update' in more) data.config.shouldUpdate = more.update;
         }
       }
       sendMessage({
@@ -112,7 +95,7 @@ function getVMFile(entry, vmFile) {
 }
 
 function getVMFiles(entries) {
-  const i = entries.findIndex(entry => entry.filename === 'ViolentMonkey');
+  const i = entries.findIndex(entry => entry.filename && entry.filename.toLowerCase() === 'violentmonkey');
   if (i < 0) {
     return { entries };
   }
@@ -141,14 +124,32 @@ function readZip(file) {
 function importData(file) {
   readZip(file)
   .then(getVMFiles)
-  .then((data) => {
+  .then(data => {
     const { vm, entries } = data;
-    return Promise.all(entries.map(entry => getVMFile(entry, vm)));
-  })
-  .then(res => res.filter(Boolean).length)
-  .then(count => {
-    showMessage({ text: i18n('msgImported', [count]) });
-    sendMessage({ cmd: 'CheckPosition' });
+    if (options.get('importSettings')) {
+      const ignoreKeys = ['sync'];
+      forEachItem(vm.settings, (value, key) => {
+        if (ignoreKeys.includes(key)) return;
+        options.set(key, value);
+      });
+    }
+    return Promise.all(entries.map(entry => getVMFile(entry, vm)))
+    .then(res => res.filter(Boolean).length)
+    .then(count => {
+      forEachItem(vm.values, (value, key) => {
+        if (value) {
+          sendMessage({
+            cmd: 'SetValue',
+            data: {
+              where: { uri: key },
+              values: value,
+            },
+          });
+        }
+      });
+      showMessage({ text: i18n('msgImported', [count]) });
+      sendMessage({ cmd: 'CheckPosition' });
+    });
   });
 }
 </script>

+ 7 - 5
src/popup/views/app.vue

@@ -42,8 +42,8 @@
         <span v-text="i18n('menuMatchedScripts')"></span>
       </div>
       <div class="submenu">
-        <div class="menu-item" v-for="item in scripts" @click="onToggleScript(item)" :class="{disabled:!item.data.enabled}">
-          <icon :name="getSymbolCheck(item.data.enabled)" class="icon-right"></icon>
+        <div class="menu-item" v-for="item in scripts" @click="onToggleScript(item)" :class="{disabled:!item.data.config.enabled}">
+          <icon :name="getSymbolCheck(item.data.config.enabled)" class="icon-right"></icon>
           <span v-text="item.name"></span>
         </div>
       </div>
@@ -130,15 +130,17 @@ export default {
       });
     },
     onToggleScript(item) {
+      const { data } = item;
+      const enabled = !data.config.enabled;
       sendMessage({
         cmd: 'UpdateScriptInfo',
         data: {
-          id: item.data.id,
-          enabled: !item.data.enabled,
+          id: data.props.id,
+          config: { enabled },
         },
       })
       .then(() => {
-        item.data.enabled = !item.data.enabled;
+        data.config.enabled = enabled;
         this.checkReload();
       });
     },