| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- import { debounce, normalizeKeys, request, noop } from 'src/common';
- import {
- getEventEmitter, vmdb,
- getOption, setOption, hookOptions,
- } from '../utils';
- const { getScriptsByIndex, parseScript, removeScript, checkPosition } = vmdb;
- const serviceNames = [];
- const services = {};
- const autoSync = debounce(sync, 60 * 60 * 1000);
- let working = Promise.resolve();
- const syncConfig = initConfig();
- export function getFilename(uri) {
- return `vm-${encodeURIComponent(uri)}`;
- }
- export function isScriptFile(name) {
- return /^vm-/.test(name);
- }
- export function getURI(name) {
- return decodeURIComponent(name.slice(3));
- }
- function initConfig() {
- function get(key, def) {
- const keys = normalizeKeys(key);
- keys.unshift('sync');
- return getOption(keys, def);
- }
- function set(key, value) {
- const keys = normalizeKeys(key);
- keys.unshift('sync');
- setOption(keys, value);
- }
- function init() {
- let config = getOption('sync');
- if (!config || !config.services) {
- config = {
- services: {},
- };
- // XXX Migrate from old data
- ['dropbox', 'onedrive']
- .forEach(key => {
- config.services[key] = getOption(key);
- });
- set([], config);
- }
- }
- init();
- return { get, set };
- }
- function serviceConfig(name) {
- function getKeys(key) {
- const keys = normalizeKeys(key);
- keys.unshift('services', name);
- return keys;
- }
- function get(key, def) {
- return syncConfig.get(getKeys(key), def);
- }
- function set(key, val) {
- if (typeof key === 'object') {
- const data = key;
- Object.keys(data).forEach(k => {
- syncConfig.set(getKeys(k), data[k]);
- });
- } else {
- syncConfig.set(getKeys(key), val);
- }
- }
- function clear() {
- syncConfig.set(getKeys(), {});
- }
- return { get, set, clear };
- }
- function serviceState(validStates, initialState, onChange) {
- let state = initialState || validStates[0];
- function get() {
- return state;
- }
- function set(newState) {
- if (validStates.includes(newState)) {
- state = newState;
- if (onChange) onChange();
- } else {
- console.warn('Invalid state:', newState);
- }
- return get();
- }
- function is(states) {
- const stateArray = Array.isArray(states) ? states : [states];
- return stateArray.includes(state);
- }
- return { get, set, is };
- }
- export function getStates() {
- return serviceNames.map(name => {
- const service = services[name];
- return {
- name: service.name,
- displayName: service.displayName,
- authState: service.authState.get(),
- syncState: service.syncState.get(),
- lastSync: service.config.get('meta', {}).lastSync,
- progress: service.progress,
- };
- });
- }
- function serviceFactory(base) {
- const Service = function constructor() {
- if (!(this instanceof Service)) return new Service();
- this.initialize();
- };
- Service.prototype = base;
- Service.extend = extendService;
- return Service;
- }
- function extendService(options) {
- return serviceFactory(Object.assign(Object.create(this.prototype), options));
- }
- const onStateChange = debounce(() => {
- browser.runtime.sendMessage({
- cmd: 'UpdateSync',
- data: getStates(),
- });
- });
- export const BaseService = serviceFactory({
- name: 'base',
- displayName: 'BaseService',
- delayTime: 1000,
- urlPrefix: '',
- metaFile: 'Violentmonkey',
- initialize() {
- this.progress = {
- finished: 0,
- total: 0,
- };
- this.config = serviceConfig(this.name);
- this.authState = serviceState([
- 'idle',
- 'initializing',
- 'authorizing', // in case some services require asynchronous requests to get access_tokens
- 'authorized',
- 'unauthorized',
- 'error',
- ], null, onStateChange);
- this.syncState = serviceState([
- 'idle',
- 'ready',
- 'syncing',
- 'error',
- ], null, onStateChange);
- // this.initToken();
- this.lastFetch = Promise.resolve();
- this.startSync = this.syncFactory();
- const events = getEventEmitter();
- ['on', 'off', 'fire']
- .forEach(key => {
- this[key] = (...args) => { events[key](...args); };
- });
- },
- log(...args) {
- console.log(...args); // eslint-disable-line no-console
- },
- syncFactory() {
- let promise;
- let debouncedResolve;
- const shouldSync = () => this.authState.is('authorized') && getCurrent() === this.name;
- const getReady = () => {
- if (!shouldSync()) return Promise.resolve();
- this.log('Ready to sync:', this.displayName);
- this.syncState.set('ready');
- working = working.then(() => new Promise(resolve => {
- debouncedResolve = debounce(resolve, 10 * 1000);
- debouncedResolve();
- }))
- .then(() => {
- if (shouldSync()) return this.sync();
- this.syncState.set('idle');
- })
- .catch(err => { console.error(err); })
- .then(() => {
- promise = null;
- debouncedResolve = null;
- });
- promise = working;
- };
- function startSync() {
- if (!promise) getReady();
- if (debouncedResolve) debouncedResolve();
- return promise;
- }
- return startSync;
- },
- prepareHeaders() {
- this.headers = {};
- },
- prepare() {
- this.authState.set('initializing');
- return (this.initToken() ? Promise.resolve(this.user()) : Promise.reject({
- type: 'unauthorized',
- }))
- .then(() => {
- this.authState.set('authorized');
- }, err => {
- if (err && err.type === 'unauthorized') {
- // _this.config.clear();
- this.authState.set('unauthorized');
- } else {
- console.error(err);
- this.authState.set('error');
- }
- this.syncState.set('idle');
- throw err;
- });
- },
- checkSync() {
- return this.prepare()
- .then(() => this.startSync());
- },
- user: noop,
- getMeta() {
- return this.get(this.metaFile)
- .then(data => JSON.parse(data));
- },
- initToken() {
- this.prepareHeaders();
- const token = this.config.get('token');
- this.headers.Authorization = token ? `Bearer ${token}` : null;
- return !!token;
- },
- loadData(options) {
- const { progress } = this;
- let { delay } = options;
- if (delay == null) {
- delay = this.delayTime;
- }
- let lastFetch = Promise.resolve();
- if (delay) {
- lastFetch = this.lastFetch
- .then(ts => new Promise(resolve => {
- const delta = delay - (Date.now() - ts);
- if (delta > 0) {
- setTimeout(resolve, delta);
- } else {
- resolve();
- }
- }))
- .then(() => Date.now());
- this.lastFetch = lastFetch;
- }
- progress.total += 1;
- onStateChange();
- return lastFetch.then(() => {
- let { prefix } = options;
- if (prefix == null) prefix = this.urlPrefix;
- const headers = Object.assign({}, this.headers, options.headers);
- let { url } = options;
- if (url.startsWith('/')) url = prefix + url;
- return request(url, {
- headers,
- method: options.method,
- body: options.body,
- responseType: options.responseType,
- });
- })
- .then(({ data }) => ({ data }), error => ({ error }))
- .then(({ data, error }) => {
- progress.finished += 1;
- onStateChange();
- if (error) return Promise.reject(error);
- return data;
- });
- },
- sync() {
- this.progress = {
- finished: 0,
- total: 0,
- };
- this.syncState.set('syncing');
- // Avoid simultaneous requests
- return this.getMeta()
- .then(remoteMeta => Promise.all([
- remoteMeta,
- this.list(),
- getScriptsByIndex('position'),
- ]))
- .then(([remoteMeta, remoteData, localData]) => {
- const remoteMetaInfo = remoteMeta.info || {};
- const remoteTimestamp = remoteMeta.timestamp || 0;
- let remoteChanged = !remoteTimestamp
- || Object.keys(remoteMetaInfo).length !== remoteData.length;
- const now = Date.now();
- const remoteItemMap = {};
- const localMeta = this.config.get('meta', {});
- const firstSync = !localMeta.timestamp;
- const outdated = firstSync || remoteTimestamp > localMeta.timestamp;
- this.log('First sync:', firstSync);
- this.log('Outdated:', outdated, '(', 'local:', localMeta.timestamp, 'remote:', remoteTimestamp, ')');
- const getRemote = [];
- const putRemote = [];
- const delRemote = [];
- const delLocal = [];
- remoteMeta.info = remoteData.reduce((info, item) => {
- remoteItemMap[item.uri] = item;
- let itemInfo = remoteMetaInfo[item.uri];
- if (!itemInfo) {
- itemInfo = {};
- remoteChanged = true;
- }
- info[item.uri] = itemInfo;
- if (!itemInfo.modified) {
- itemInfo.modified = now;
- remoteChanged = true;
- }
- return info;
- }, {});
- localData.forEach(item => {
- const remoteInfo = remoteMeta.info[item.uri];
- if (remoteInfo) {
- if (firstSync || !item.custom.modified || remoteInfo.modified > item.custom.modified) {
- const remoteItem = remoteItemMap[item.uri];
- getRemote.push(remoteItem);
- } else if (remoteInfo.modified < item.custom.modified) {
- putRemote.push(item);
- } else if (remoteInfo.position !== item.position) {
- remoteInfo.position = item.position;
- remoteChanged = true;
- }
- delete remoteItemMap[item.uri];
- } else if (firstSync || !outdated || item.custom.modified > remoteTimestamp) {
- putRemote.push(item);
- } else {
- delLocal.push(item);
- }
- });
- Object.keys(remoteItemMap).forEach(uri => {
- const item = remoteItemMap[uri];
- if (outdated) {
- getRemote.push(item);
- } else {
- delRemote.push(item);
- }
- });
- const promiseQueue = [
- ...getRemote.map(item => {
- this.log('Download script:', item.uri);
- return this.get(getFilename(item.uri))
- .then(raw => {
- const data = { more: {} };
- try {
- const obj = JSON.parse(raw);
- if (obj.version === 1) {
- data.code = obj.code;
- if (obj.more) data.more = obj.more;
- }
- } catch (e) {
- data.code = raw;
- }
- const remoteInfo = remoteMeta.info[item.uri];
- const { modified, position } = remoteInfo;
- data.modified = modified;
- if (position) data.more.position = position;
- if (!getOption('syncScriptStatus') && data.more) {
- delete data.more.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,
- },
- });
- 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);
- delete remoteMeta.info[item.uri];
- remoteChanged = true;
- return this.remove(getFilename(item.uri));
- }),
- ...delLocal.map(item => {
- this.log('Remove local script:', item.uri);
- return removeScript(item.id);
- }),
- ];
- promiseQueue.push(Promise.all(promiseQueue).then(() => checkPosition()).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;
- });
- }));
- promiseQueue.push(Promise.all(promiseQueue).then(() => {
- const promises = [];
- if (remoteChanged) {
- remoteMeta.timestamp = Date.now();
- promises.push(this.put(this.metaFile, JSON.stringify(remoteMeta)));
- }
- localMeta.timestamp = remoteMeta.timestamp;
- localMeta.lastSync = Date.now();
- this.config.set('meta', localMeta);
- return Promise.all(promises);
- }));
- // ignore errors to ensure all promises are fulfilled
- return Promise.all(promiseQueue.map(promise => promise.then(noop, err => err || true)))
- .then(errors => errors.filter(Boolean))
- .then(errors => { if (errors.length) throw errors; });
- })
- .then(() => {
- this.syncState.set('idle');
- }, err => {
- this.syncState.set('error');
- this.log('Failed syncing:', this.name);
- this.log(err);
- });
- },
- });
- export function register(factory) {
- const service = typeof factory === 'function' ? factory() : factory;
- serviceNames.push(service.name);
- services[service.name] = service;
- return service;
- }
- function getCurrent() {
- return syncConfig.get('current');
- }
- function getService(name) {
- return services[name || getCurrent()];
- }
- export function initialize() {
- const service = getService();
- if (service) service.checkSync();
- }
- function syncOne(service) {
- if (service.syncState.is(['ready', 'syncing'])) return;
- if (service.authState.is(['idle', 'error'])) return service.checkSync();
- if (service.authState.is('authorized')) return service.startSync();
- }
- export function sync() {
- const service = getService();
- return service && Promise.resolve(syncOne(service)).then(autoSync);
- }
- export function checkAuthUrl(url) {
- return serviceNames.some(name => {
- const service = services[name];
- return service.checkAuth && service.checkAuth(url);
- });
- }
- export function authorize() {
- const service = getService();
- if (service) service.authorize();
- }
- export function revoke() {
- const service = getService();
- if (service) service.revoke();
- }
- hookOptions(data => {
- if ('sync.current' in data) initialize();
- });
|