base.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import { debounce, normalizeKeys, request, noop } from 'src/common';
  2. import {
  3. getEventEmitter, vmdb,
  4. getOption, setOption, hookOptions,
  5. } from '../utils';
  6. const { getScriptsByIndex, parseScript, removeScript, checkPosition } = vmdb;
  7. const serviceNames = [];
  8. const services = {};
  9. const autoSync = debounce(sync, 60 * 60 * 1000);
  10. let working = Promise.resolve();
  11. const syncConfig = initConfig();
  12. export function getFilename(uri) {
  13. return `vm-${encodeURIComponent(uri)}`;
  14. }
  15. export function isScriptFile(name) {
  16. return /^vm-/.test(name);
  17. }
  18. export function getURI(name) {
  19. return decodeURIComponent(name.slice(3));
  20. }
  21. function initConfig() {
  22. function get(key, def) {
  23. const keys = normalizeKeys(key);
  24. keys.unshift('sync');
  25. return getOption(keys, def);
  26. }
  27. function set(key, value) {
  28. const keys = normalizeKeys(key);
  29. keys.unshift('sync');
  30. setOption(keys, value);
  31. }
  32. function init() {
  33. let config = getOption('sync');
  34. if (!config || !config.services) {
  35. config = {
  36. services: {},
  37. };
  38. // XXX Migrate from old data
  39. ['dropbox', 'onedrive']
  40. .forEach(key => {
  41. config.services[key] = getOption(key);
  42. });
  43. set([], config);
  44. }
  45. }
  46. init();
  47. return { get, set };
  48. }
  49. function serviceConfig(name) {
  50. function getKeys(key) {
  51. const keys = normalizeKeys(key);
  52. keys.unshift('services', name);
  53. return keys;
  54. }
  55. function get(key, def) {
  56. return syncConfig.get(getKeys(key), def);
  57. }
  58. function set(key, val) {
  59. if (typeof key === 'object') {
  60. const data = key;
  61. Object.keys(data).forEach(k => {
  62. syncConfig.set(getKeys(k), data[k]);
  63. });
  64. } else {
  65. syncConfig.set(getKeys(key), val);
  66. }
  67. }
  68. function clear() {
  69. syncConfig.set(getKeys(), {});
  70. }
  71. return { get, set, clear };
  72. }
  73. function serviceState(validStates, initialState, onChange) {
  74. let state = initialState || validStates[0];
  75. function get() {
  76. return state;
  77. }
  78. function set(newState) {
  79. if (validStates.includes(newState)) {
  80. state = newState;
  81. if (onChange) onChange();
  82. } else {
  83. console.warn('Invalid state:', newState);
  84. }
  85. return get();
  86. }
  87. function is(states) {
  88. const stateArray = Array.isArray(states) ? states : [states];
  89. return stateArray.includes(state);
  90. }
  91. return { get, set, is };
  92. }
  93. export function getStates() {
  94. return serviceNames.map(name => {
  95. const service = services[name];
  96. return {
  97. name: service.name,
  98. displayName: service.displayName,
  99. authState: service.authState.get(),
  100. syncState: service.syncState.get(),
  101. lastSync: service.config.get('meta', {}).lastSync,
  102. progress: service.progress,
  103. };
  104. });
  105. }
  106. function serviceFactory(base) {
  107. const Service = function constructor() {
  108. if (!(this instanceof Service)) return new Service();
  109. this.initialize();
  110. };
  111. Service.prototype = base;
  112. Service.extend = extendService;
  113. return Service;
  114. }
  115. function extendService(options) {
  116. return serviceFactory(Object.assign(Object.create(this.prototype), options));
  117. }
  118. const onStateChange = debounce(() => {
  119. browser.runtime.sendMessage({
  120. cmd: 'UpdateSync',
  121. data: getStates(),
  122. });
  123. });
  124. export const BaseService = serviceFactory({
  125. name: 'base',
  126. displayName: 'BaseService',
  127. delayTime: 1000,
  128. urlPrefix: '',
  129. metaFile: 'Violentmonkey',
  130. initialize() {
  131. this.progress = {
  132. finished: 0,
  133. total: 0,
  134. };
  135. this.config = serviceConfig(this.name);
  136. this.authState = serviceState([
  137. 'idle',
  138. 'initializing',
  139. 'authorizing', // in case some services require asynchronous requests to get access_tokens
  140. 'authorized',
  141. 'unauthorized',
  142. 'error',
  143. ], null, onStateChange);
  144. this.syncState = serviceState([
  145. 'idle',
  146. 'ready',
  147. 'syncing',
  148. 'error',
  149. ], null, onStateChange);
  150. // this.initToken();
  151. this.lastFetch = Promise.resolve();
  152. this.startSync = this.syncFactory();
  153. const events = getEventEmitter();
  154. ['on', 'off', 'fire']
  155. .forEach(key => {
  156. this[key] = (...args) => { events[key](...args); };
  157. });
  158. },
  159. log(...args) {
  160. console.log(...args); // eslint-disable-line no-console
  161. },
  162. syncFactory() {
  163. let promise;
  164. let debouncedResolve;
  165. const shouldSync = () => this.authState.is('authorized') && getCurrent() === this.name;
  166. const getReady = () => {
  167. if (!shouldSync()) return Promise.resolve();
  168. this.log('Ready to sync:', this.displayName);
  169. this.syncState.set('ready');
  170. working = working.then(() => new Promise(resolve => {
  171. debouncedResolve = debounce(resolve, 10 * 1000);
  172. debouncedResolve();
  173. }))
  174. .then(() => {
  175. if (shouldSync()) return this.sync();
  176. this.syncState.set('idle');
  177. })
  178. .catch(err => { console.error(err); })
  179. .then(() => {
  180. promise = null;
  181. debouncedResolve = null;
  182. });
  183. promise = working;
  184. };
  185. function startSync() {
  186. if (!promise) getReady();
  187. if (debouncedResolve) debouncedResolve();
  188. return promise;
  189. }
  190. return startSync;
  191. },
  192. prepareHeaders() {
  193. this.headers = {};
  194. },
  195. prepare() {
  196. this.authState.set('initializing');
  197. return (this.initToken() ? Promise.resolve(this.user()) : Promise.reject({
  198. type: 'unauthorized',
  199. }))
  200. .then(() => {
  201. this.authState.set('authorized');
  202. }, err => {
  203. if (err && err.type === 'unauthorized') {
  204. // _this.config.clear();
  205. this.authState.set('unauthorized');
  206. } else {
  207. console.error(err);
  208. this.authState.set('error');
  209. }
  210. this.syncState.set('idle');
  211. throw err;
  212. });
  213. },
  214. checkSync() {
  215. return this.prepare()
  216. .then(() => this.startSync());
  217. },
  218. user: noop,
  219. getMeta() {
  220. return this.get(this.metaFile)
  221. .then(data => JSON.parse(data));
  222. },
  223. initToken() {
  224. this.prepareHeaders();
  225. const token = this.config.get('token');
  226. this.headers.Authorization = token ? `Bearer ${token}` : null;
  227. return !!token;
  228. },
  229. loadData(options) {
  230. const { progress } = this;
  231. let { delay } = options;
  232. if (delay == null) {
  233. delay = this.delayTime;
  234. }
  235. let lastFetch = Promise.resolve();
  236. if (delay) {
  237. lastFetch = this.lastFetch
  238. .then(ts => new Promise(resolve => {
  239. const delta = delay - (Date.now() - ts);
  240. if (delta > 0) {
  241. setTimeout(resolve, delta);
  242. } else {
  243. resolve();
  244. }
  245. }))
  246. .then(() => Date.now());
  247. this.lastFetch = lastFetch;
  248. }
  249. progress.total += 1;
  250. onStateChange();
  251. return lastFetch.then(() => {
  252. let { prefix } = options;
  253. if (prefix == null) prefix = this.urlPrefix;
  254. const headers = Object.assign({}, this.headers, options.headers);
  255. let { url } = options;
  256. if (url.startsWith('/')) url = prefix + url;
  257. return request(url, {
  258. headers,
  259. method: options.method,
  260. body: options.body,
  261. responseType: options.responseType,
  262. });
  263. })
  264. .then(({ data }) => ({ data }), error => ({ error }))
  265. .then(({ data, error }) => {
  266. progress.finished += 1;
  267. onStateChange();
  268. if (error) return Promise.reject(error);
  269. return data;
  270. });
  271. },
  272. sync() {
  273. this.progress = {
  274. finished: 0,
  275. total: 0,
  276. };
  277. this.syncState.set('syncing');
  278. // Avoid simultaneous requests
  279. return this.getMeta()
  280. .then(remoteMeta => Promise.all([
  281. remoteMeta,
  282. this.list(),
  283. getScriptsByIndex('position'),
  284. ]))
  285. .then(([remoteMeta, remoteData, localData]) => {
  286. const remoteMetaInfo = remoteMeta.info || {};
  287. const remoteTimestamp = remoteMeta.timestamp || 0;
  288. let remoteChanged = !remoteTimestamp
  289. || Object.keys(remoteMetaInfo).length !== remoteData.length;
  290. const now = Date.now();
  291. const remoteItemMap = {};
  292. const localMeta = this.config.get('meta', {});
  293. const firstSync = !localMeta.timestamp;
  294. const outdated = firstSync || remoteTimestamp > localMeta.timestamp;
  295. this.log('First sync:', firstSync);
  296. this.log('Outdated:', outdated, '(', 'local:', localMeta.timestamp, 'remote:', remoteTimestamp, ')');
  297. const getRemote = [];
  298. const putRemote = [];
  299. const delRemote = [];
  300. const delLocal = [];
  301. remoteMeta.info = remoteData.reduce((info, item) => {
  302. remoteItemMap[item.uri] = item;
  303. let itemInfo = remoteMetaInfo[item.uri];
  304. if (!itemInfo) {
  305. itemInfo = {};
  306. remoteChanged = true;
  307. }
  308. info[item.uri] = itemInfo;
  309. if (!itemInfo.modified) {
  310. itemInfo.modified = now;
  311. remoteChanged = true;
  312. }
  313. return info;
  314. }, {});
  315. localData.forEach(item => {
  316. const remoteInfo = remoteMeta.info[item.uri];
  317. if (remoteInfo) {
  318. if (firstSync || !item.custom.modified || remoteInfo.modified > item.custom.modified) {
  319. const remoteItem = remoteItemMap[item.uri];
  320. getRemote.push(remoteItem);
  321. } else if (remoteInfo.modified < item.custom.modified) {
  322. putRemote.push(item);
  323. } else if (remoteInfo.position !== item.position) {
  324. remoteInfo.position = item.position;
  325. remoteChanged = true;
  326. }
  327. delete remoteItemMap[item.uri];
  328. } else if (firstSync || !outdated || item.custom.modified > remoteTimestamp) {
  329. putRemote.push(item);
  330. } else {
  331. delLocal.push(item);
  332. }
  333. });
  334. Object.keys(remoteItemMap).forEach(uri => {
  335. const item = remoteItemMap[uri];
  336. if (outdated) {
  337. getRemote.push(item);
  338. } else {
  339. delRemote.push(item);
  340. }
  341. });
  342. const promiseQueue = [
  343. ...getRemote.map(item => {
  344. this.log('Download script:', item.uri);
  345. return this.get(getFilename(item.uri))
  346. .then(raw => {
  347. const data = { more: {} };
  348. try {
  349. const obj = JSON.parse(raw);
  350. if (obj.version === 1) {
  351. data.code = obj.code;
  352. if (obj.more) data.more = obj.more;
  353. }
  354. } catch (e) {
  355. data.code = raw;
  356. }
  357. const remoteInfo = remoteMeta.info[item.uri];
  358. const { modified, position } = remoteInfo;
  359. data.modified = modified;
  360. if (position) data.more.position = position;
  361. if (!getOption('syncScriptStatus') && data.more) {
  362. delete data.more.enabled;
  363. }
  364. return parseScript(data)
  365. .then(res => { browser.runtime.sendMessage(res); });
  366. });
  367. }),
  368. ...putRemote.map(item => {
  369. this.log('Upload script:', item.uri);
  370. const data = JSON.stringify({
  371. version: 1,
  372. code: item.code,
  373. more: {
  374. custom: item.custom,
  375. enabled: item.enabled,
  376. update: item.update,
  377. },
  378. });
  379. remoteMeta.info[item.uri] = {
  380. modified: item.custom.modified,
  381. position: item.position,
  382. };
  383. remoteChanged = true;
  384. return this.put(getFilename(item.uri), data);
  385. }),
  386. ...delRemote.map(item => {
  387. this.log('Remove remote script:', item.uri);
  388. delete remoteMeta.info[item.uri];
  389. remoteChanged = true;
  390. return this.remove(getFilename(item.uri));
  391. }),
  392. ...delLocal.map(item => {
  393. this.log('Remove local script:', item.uri);
  394. return removeScript(item.id);
  395. }),
  396. ];
  397. promiseQueue.push(Promise.all(promiseQueue).then(() => checkPosition()).then(changed => {
  398. if (!changed) return;
  399. remoteChanged = true;
  400. return getScriptsByIndex('position', null, null, item => {
  401. const remoteInfo = remoteMeta.info[item.uri];
  402. if (remoteInfo) remoteInfo.position = item.position;
  403. });
  404. }));
  405. promiseQueue.push(Promise.all(promiseQueue).then(() => {
  406. const promises = [];
  407. if (remoteChanged) {
  408. remoteMeta.timestamp = Date.now();
  409. promises.push(this.put(this.metaFile, JSON.stringify(remoteMeta)));
  410. }
  411. localMeta.timestamp = remoteMeta.timestamp;
  412. localMeta.lastSync = Date.now();
  413. this.config.set('meta', localMeta);
  414. return Promise.all(promises);
  415. }));
  416. // ignore errors to ensure all promises are fulfilled
  417. return Promise.all(promiseQueue.map(promise => promise.then(noop, err => err || true)))
  418. .then(errors => errors.filter(Boolean))
  419. .then(errors => { if (errors.length) throw errors; });
  420. })
  421. .then(() => {
  422. this.syncState.set('idle');
  423. }, err => {
  424. this.syncState.set('error');
  425. this.log('Failed syncing:', this.name);
  426. this.log(err);
  427. });
  428. },
  429. });
  430. export function register(factory) {
  431. const service = typeof factory === 'function' ? factory() : factory;
  432. serviceNames.push(service.name);
  433. services[service.name] = service;
  434. return service;
  435. }
  436. function getCurrent() {
  437. return syncConfig.get('current');
  438. }
  439. function getService(name) {
  440. return services[name || getCurrent()];
  441. }
  442. export function initialize() {
  443. const service = getService();
  444. if (service) service.checkSync();
  445. }
  446. function syncOne(service) {
  447. if (service.syncState.is(['ready', 'syncing'])) return;
  448. if (service.authState.is(['idle', 'error'])) return service.checkSync();
  449. if (service.authState.is('authorized')) return service.startSync();
  450. }
  451. export function sync() {
  452. const service = getService();
  453. return service && Promise.resolve(syncOne(service)).then(autoSync);
  454. }
  455. export function checkAuthUrl(url) {
  456. return serviceNames.some(name => {
  457. const service = services[name];
  458. return service.checkAuth && service.checkAuth(url);
  459. });
  460. }
  461. export function authorize() {
  462. const service = getService();
  463. if (service) service.authorize();
  464. }
  465. export function revoke() {
  466. const service = getService();
  467. if (service) service.revoke();
  468. }
  469. hookOptions(data => {
  470. if ('sync.current' in data) initialize();
  471. });