瀏覽代碼

feat: store data with chrome.storage

close #152, #169
Gerald 8 年之前
父節點
當前提交
4c739cfc35

+ 0 - 1
package.json

@@ -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
   },
 });
 

+ 28 - 26
src/background/app.js

@@ -1,6 +1,6 @@
 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,
@@ -32,8 +32,11 @@ function broadcast(data) {
 
 function checkUpdateAll() {
   setOption('lastUpdate', Date.now());
-  vmdb.getScriptsByIndex('update', 1)
-  .then(scripts => Promise.all(scripts.map(checkUpdate)))
+  vmdb.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();
   });
@@ -83,11 +86,14 @@ const commands = {
       vmdb.getScriptsByURL(url).then(res => Object.assign(data, res))
     ) : data;
   },
-  UpdateScriptInfo(data) {
-    return vmdb.updateScriptInfo(data.id, data, {
-      modified: Date.now(),
+  UpdateScriptInfo({ id, config }) {
+    return vmdb.updateScriptInfo(id, {
+      config,
+      custom: {
+        modified: Date.now(),
+      },
     })
-    .then(script => {
+    .then(([script]) => {
       sync.sync();
       browser.runtime.sendMessage({
         cmd: 'UpdateScript',
@@ -95,29 +101,26 @@ const commands = {
       });
     });
   },
-  SetValue(data) {
-    return vmdb.setValue(data.uri, data.values)
+  SetValue({ id, values }) {
+    return vmdb.setValues(id, values)
     .then(() => {
       broadcast({
         cmd: 'UpdateValues',
-        data: {
-          uri: data.uri,
-          values: data.values,
-        },
+        data: { id, values },
       });
     });
   },
-  ExportZip(data) {
-    return vmdb.getExportData(data.ids, data.values);
+  ExportZip({ ids, values }) {
+    return vmdb.getExportData(ids, values);
   },
-  GetScript(id) {
-    return vmdb.getScriptData(id);
+  GetScriptCode(id) {
+    return vmdb.getScriptCode(id);
   },
   GetMetas(ids) {
-    return vmdb.getScriptInfos(ids);
+    return vmdb.getScriptByIds(ids);
   },
-  Move(data) {
-    return vmdb.moveScript(data.id, data.offset)
+  Move({ id, offset }) {
+    return vmdb.moveScript(id, offset)
     .then(() => { sync.sync(); });
   },
   Vacuum: vmdb.vacuum,
@@ -129,7 +132,7 @@ const commands = {
     });
   },
   CheckUpdate(id) {
-    vmdb.getScript(id).then(checkUpdate)
+    vmdb.getScript({ id }).then(checkUpdate)
     .then(updated => {
       if (updated) sync.sync();
     });
@@ -190,12 +193,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 vmdb.getScript({ meta: { name, namespace } })
     .then(script => (script ? script.meta.version : null));
   },
+  CheckPosition() {
+    return vmdb.normalizePosition();
+  },
 };
 
 initialize()
@@ -218,9 +223,6 @@ initialize()
   });
   setTimeout(autoUpdate, 2e4);
   sync.initialize();
-
-  // XXX fix position regression in v2.6.3
-  vmdb.checkPosition();
 });
 
 // Common functions

+ 546 - 549
src/background/utils/db.js

@@ -1,632 +1,629 @@
-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,
-      });
-      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' });
+  }
+  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));
     };
-  });
-}
-
-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];
-      }
+    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;
+      });
+      onCallback();
     });
   }
-  return script;
-}
-
-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);
+  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);
+      }
     };
-  })
-  .then(transformScript);
-}
-
-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);
+    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);
+      }
     };
-  })
-  .then(transformScript);
-}
-
-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 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 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);
+    req.onerror = reject;
+  }
+  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);
+      }
     };
-  })))
-  .then(data => data.reduce((result, value, i) => {
-    if (value) result[uris[i]] = value.values;
-    return result;
-  }, {}));
-}
-
-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: [],
+    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);
+      }
     };
-    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;
-    });
+    req.onerror = reject;
   }
-  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;
-    }, {}));
+  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 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]}`;
+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;
       });
-      return cache;
+    },
+    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));
+    },
+  },
+};
+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()));
+    });
+  }),
+});
 
-export function removeScript(id) {
-  const tx = db.transaction('scripts', 'readwrite');
-  return new Promise(resolve => {
-    const os = tx.objectStore('scripts');
-    os.delete(id).onsuccess = () => { resolve(); };
+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(() => {
-    browser.runtime.sendMessage({
-      cmd: 'RemoveScript',
-      data: id,
+  .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 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';
+function getInt(val) {
+  return +val || 0;
+}
+
+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);
     }
-    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(); };
-          }
-        }
-      };
-    });
   });
+  store.storeInfo.position = store.scripts.length;
+  return storage.script.dump(updates);
 }
 
-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;
-  }, {}));
+export function getScript(where) {
+  let script;
+  if (where.id) {
+    script = store.scriptMap[where.id];
+  } else {
+    const 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);
 }
 
-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(); };
-  });
+export function getScripts() {
+  return Promise.resolve(store.scripts);
+}
+
+export function getScriptByIds(ids) {
+  return Promise.all(ids.map(id => getScript({ id })))
+  .then(scripts => scripts.filter(Boolean));
+}
+
+export function getScriptCode(id) {
+  return storage.code.getOne(id);
 }
 
-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 setValues(id, values) {
+  return storage.value.dump(id, values);
+}
+
+/**
+ * @desc Get scripts to be injected to page with specific URL.
+ */
+export function getScriptsByURL(url) {
+  const scripts = testBlacklist(url) ? [] : store.scripts.filter(script => 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;
+      });
+    }
   });
+  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,
+  }));
 }
 
-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'));
-    };
+/**
+ * @desc Get data for dashboard.
+ */
+export function getData() {
+  const scripts = store.scripts;
+  const cacheKeys = {};
+  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 }));
 }
 
-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 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);
   }
-  return promise;
+  return browser.runtime.sendMessage({
+    cmd: 'RemoveScript',
+    data: id,
+  });
 }
 
-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;
+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();
 }
 
-export function setValue(uri, values) {
-  const os = db.transaction('values', 'readwrite').objectStore('values');
-  return new Promise(resolve => {
-    os.put({ uri, values }).onsuccess = () => { resolve(); };
-  });
+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];
+  }
+  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()
+  return Promise.all(ids.map(id => getScript({ id })))
   .then(scripts => {
-    const res = { scripts };
+    const data = { scripts };
     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 || '',
+      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.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());
+    return saveScript(script, code).then(() => script);
   })
   .then(script => {
-    Object.assign(res.data, getScriptInfo(script));
-    return res;
+    fetchScriptResources(script, data);
+    Object.assign(result.data, script);
+    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);
+      }
+    });
+  });
 }

+ 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;
 }
 

+ 9 - 8
src/background/utils/update.js

@@ -10,7 +10,7 @@ function doCheckUpdate(script) {
   const res = {
     cmd: 'UpdateScript',
     data: {
-      id: script.id,
+      id: script.props.id,
       checking: true,
     },
   };
@@ -63,23 +63,24 @@ function doCheckUpdate(script) {
 }
 
 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 +88,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;
 }

+ 1 - 1
src/common/index.js

@@ -37,7 +37,7 @@ export const object = {
       }
       sub = child;
     });
-    if (val == null) {
+    if (typeof val === 'undefined') {
       delete sub[lastKey];
     } else {
       sub[lastKey] = val;

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

@@ -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,
+        id: script.props.id,
         values: getValues(),
       },
     });

+ 2 - 1
src/manifest.json

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

+ 8 - 10
src/options/app.js

@@ -82,17 +82,15 @@ function initMain() {
     },
     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.props.id);
+      if (index >= 0) {
+        Vue.set(store.scripts, index, data);
+        initSearch(data);
       }
     },
-    RemoveScript(data) {
-      const i = store.scripts.findIndex(script => script.id === data);
-      if (i >= 0) store.scripts.splice(i, 1);
-    },
+    // RemoveScript(data) {
+    //   const i = store.scripts.findIndex(script => script.props.id === data);
+    //   if (i >= 0) store.scripts.splice(i, 1);
+    // },
   });
 }

+ 34 - 25
src/options/views/edit/index.vue

@@ -19,7 +19,7 @@
       />
       <vm-settings
         v-show="nav === 'settings'" class="abs-full"
-        :value="value" :settings="settings"
+        :value="script" :settings="settings"
       />
     </div>
     <div class="frame-block">
@@ -33,7 +33,6 @@
 </template>
 
 <script>
-import CodeMirror from 'codemirror';
 import { i18n, sendMessage, noop } from 'src/common';
 import VmCode from 'src/common/ui/code';
 import { showMessage } from '../../utils';
@@ -49,7 +48,7 @@ function toList(text) {
 }
 
 export default {
-  props: ['value'],
+  props: ['initial'],
   components: {
     VmCode,
     VmSettings,
@@ -58,6 +57,7 @@ export default {
     return {
       nav: 'code',
       canSave: false,
+      script: null,
       code: '',
       settings: {},
       commands: {
@@ -77,18 +77,27 @@ export default {
       },
     },
   },
+  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',
@@ -116,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',
@@ -128,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 });

+ 1 - 1
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>

+ 18 - 11
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 || script.config.removed }" draggable="true" @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,7 @@
         <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>
     <p class="script-desc ellipsis" v-text="script.custom.description || getLocaleString('description')"></p>
     <div class="script-buttons flex">
@@ -20,7 +20,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">
@@ -92,7 +92,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 +114,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 +133,34 @@ export default {
       return getLocaleString(this.script.meta, key);
     },
     onEdit() {
-      this.$emit('edit', this.script.id);
+      this.$emit('edit', this.script.props.id);
     },
     onRemove() {
       sendMessage({
-        cmd: 'RemoveScript',
-        data: this.script.id,
+        cmd: 'UpdateScriptInfo',
+        data: {
+          id: this.script.props.id,
+          config: {
+            removed: 1,
+          },
+        },
       });
     },
     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) {

+ 8 - 8
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>
 
@@ -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);

+ 1 - 1
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() {

+ 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();
       });
     },