1
0

sync-manager.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /* global API msg */// msg.js
  2. /* global chromeLocal */// storage-util.js
  3. /* global compareRevision */// common.js
  4. /* global iconMan */
  5. /* global prefs */
  6. /* global tokenMan */
  7. 'use strict';
  8. const syncMan = (() => {
  9. //#region Init
  10. const SYNC_DELAY = 1; // minutes
  11. const SYNC_INTERVAL = 30; // minutes
  12. const STATES = Object.freeze({
  13. connected: 'connected',
  14. connecting: 'connecting',
  15. disconnected: 'disconnected',
  16. disconnecting: 'disconnecting',
  17. });
  18. const STORAGE_KEY = 'sync/state/';
  19. const status = /** @namespace SyncManager.Status */ {
  20. STATES,
  21. state: STATES.disconnected,
  22. syncing: false,
  23. progress: null,
  24. currentDriveName: null,
  25. errorMessage: null,
  26. login: false,
  27. };
  28. let lastError = null;
  29. let ctrl;
  30. let currentDrive;
  31. /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
  32. let ready = prefs.ready.then(() => {
  33. ready = true;
  34. prefs.subscribe('sync.enabled',
  35. (_, val) => val === 'none'
  36. ? syncMan.stop()
  37. : syncMan.start(val, true),
  38. {runNow: true});
  39. });
  40. chrome.alarms.onAlarm.addListener(async ({name}) => {
  41. if (name === 'syncNow') {
  42. await syncMan.syncNow();
  43. }
  44. });
  45. //#endregion
  46. //#region Exports
  47. return {
  48. async delete(...args) {
  49. if (ready.then) await ready;
  50. if (!currentDrive) return;
  51. schedule();
  52. return ctrl.delete(...args);
  53. },
  54. /** @returns {Promise<SyncManager.Status>} */
  55. async getStatus() {
  56. return status;
  57. },
  58. async login(name) {
  59. if (ready.then) await ready;
  60. if (!name) name = prefs.get('sync.enabled');
  61. await tokenMan.revokeToken(name);
  62. try {
  63. await tokenMan.getToken(name, true);
  64. status.login = true;
  65. } catch (err) {
  66. status.login = false;
  67. throw err;
  68. } finally {
  69. emitStatusChange();
  70. }
  71. },
  72. async put(...args) {
  73. if (ready.then) await ready;
  74. if (!currentDrive) return;
  75. schedule();
  76. return ctrl.put(...args);
  77. },
  78. async start(name, fromPref = false) {
  79. if (ready.then) await ready;
  80. if (!ctrl) await initController();
  81. if (currentDrive) return;
  82. currentDrive = getDrive(name);
  83. ctrl.use(currentDrive);
  84. status.state = STATES.connecting;
  85. status.currentDriveName = currentDrive.name;
  86. emitStatusChange();
  87. if (fromPref) {
  88. status.login = true;
  89. } else {
  90. try {
  91. await syncMan.login(name);
  92. } catch (err) {
  93. console.error(err);
  94. status.errorMessage = err.message;
  95. lastError = err;
  96. emitStatusChange();
  97. return syncMan.stop();
  98. }
  99. }
  100. await ctrl.init();
  101. await syncMan.syncNow(name);
  102. prefs.set('sync.enabled', name);
  103. status.state = STATES.connected;
  104. schedule(SYNC_INTERVAL);
  105. emitStatusChange();
  106. },
  107. async stop() {
  108. if (ready.then) await ready;
  109. if (!currentDrive) return;
  110. chrome.alarms.clear('syncNow');
  111. status.state = STATES.disconnecting;
  112. emitStatusChange();
  113. try {
  114. await ctrl.uninit();
  115. await tokenMan.revokeToken(currentDrive.name);
  116. await chromeLocal.remove(STORAGE_KEY + currentDrive.name);
  117. } catch (e) {}
  118. currentDrive = null;
  119. prefs.set('sync.enabled', 'none');
  120. status.state = STATES.disconnected;
  121. status.currentDriveName = null;
  122. status.login = false;
  123. emitStatusChange();
  124. },
  125. async syncNow() {
  126. if (ready.then) await ready;
  127. if (!currentDrive || !status.login) {
  128. console.warn('cannot sync when disconnected');
  129. return;
  130. }
  131. try {
  132. await ctrl.syncNow();
  133. status.errorMessage = null;
  134. lastError = null;
  135. } catch (err) {
  136. err.message = translateErrorMessage(err);
  137. status.errorMessage = err.message;
  138. lastError = err;
  139. if (isGrantError(err)) {
  140. status.login = false;
  141. }
  142. }
  143. emitStatusChange();
  144. },
  145. };
  146. //#endregion
  147. //#region Utils
  148. async function initController() {
  149. await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
  150. ctrl = dbToCloud.dbToCloud({
  151. onGet(id) {
  152. return API.styles.getByUUID(id);
  153. },
  154. onPut(doc) {
  155. return API.styles.putByUUID(doc);
  156. },
  157. onDelete(id, rev) {
  158. return API.styles.deleteByUUID(id, rev);
  159. },
  160. async onFirstSync() {
  161. for (const i of await API.styles.getAll()) {
  162. ctrl.put(i._id, i._rev);
  163. }
  164. },
  165. onProgress(e) {
  166. if (e.phase === 'start') {
  167. status.syncing = true;
  168. } else if (e.phase === 'end') {
  169. status.syncing = false;
  170. status.progress = null;
  171. } else {
  172. status.progress = e;
  173. }
  174. emitStatusChange();
  175. },
  176. compareRevision,
  177. getState(drive) {
  178. return chromeLocal.getValue(STORAGE_KEY + drive.name);
  179. },
  180. setState(drive, state) {
  181. return chromeLocal.setValue(STORAGE_KEY + drive.name, state);
  182. },
  183. retryMaxAttempts: 10,
  184. retryExp: 1.2,
  185. retryDelay: 6,
  186. });
  187. }
  188. function emitStatusChange() {
  189. msg.broadcastExtension({method: 'syncStatusUpdate', status});
  190. iconMan.overrideBadge(getErrorBadge());
  191. }
  192. function isNetworkError(err) {
  193. return (
  194. err.name === 'TypeError' && /networkerror|failed to fetch/i.test(err.message) ||
  195. err.code === 502
  196. );
  197. }
  198. function isGrantError(err) {
  199. if (err.code === 401) return true;
  200. if (err.code === 400 && /invalid_grant/.test(err.message)) return true;
  201. if (err.name === 'TokenError') return true;
  202. return false;
  203. }
  204. function getErrorBadge() {
  205. if (status.state === STATES.connected &&
  206. (!status.login || lastError && !isNetworkError(lastError))) {
  207. return {
  208. text: 'x',
  209. color: '#F00',
  210. title: !status.login ? 'syncErrorRelogin' : `${
  211. chrome.i18n.getMessage('syncError')
  212. }\n---------------------\n${
  213. // splitting to limit each line length
  214. lastError.message.replace(/.{60,}?\s(?=.{30,})/g, '$&\n')
  215. }`,
  216. };
  217. }
  218. }
  219. function getDrive(name) {
  220. if (name === 'dropbox' || name === 'google' || name === 'onedrive') {
  221. return dbToCloud.drive[name]({
  222. getAccessToken: () => tokenMan.getToken(name),
  223. });
  224. }
  225. throw new Error(`unknown cloud name: ${name}`);
  226. }
  227. function schedule(delay = SYNC_DELAY) {
  228. chrome.alarms.create('syncNow', {
  229. delayInMinutes: delay, // fractional values are supported
  230. periodInMinutes: SYNC_INTERVAL,
  231. });
  232. }
  233. function translateErrorMessage(err) {
  234. if (err.name === 'LockError') {
  235. return browser.i18n.getMessage('syncErrorLock', new Date(err.expire).toLocaleString([], {timeStyle: 'short'}));
  236. }
  237. return err.message || String(err);
  238. }
  239. //#endregion
  240. })();