diff --git a/internal_packages/onboarding/lib/onboarding-store.es6 b/internal_packages/onboarding/lib/onboarding-store.es6 index f3455c478..bb42bf486 100644 --- a/internal_packages/onboarding/lib/onboarding-store.es6 +++ b/internal_packages/onboarding/lib/onboarding-store.es6 @@ -137,10 +137,10 @@ class OnboardingStore extends NylasStore { this.trigger(); } - _onAuthenticationJSONReceived = (json) => { + _onAuthenticationJSONReceived = async (json) => { const isFirstAccount = AccountStore.accounts().length === 0; - Actions.setNylasIdentity(json); + await IdentityStore.saveIdentity(json); setTimeout(() => { if (isFirstAccount) { diff --git a/spec/auto-update-manager-spec.coffee b/spec/auto-update-manager-spec.coffee index 36d60df10..2c459c745 100644 --- a/spec/auto-update-manager-spec.coffee +++ b/spec/auto-update-manager-spec.coffee @@ -11,8 +11,6 @@ describe "AutoUpdateManager", -> get: (key) => if key is 'nylas.accounts' return @accounts - if key is 'nylas.identity.id' - return @nylasIdentityId if key is 'env' return 'production' onDidChange: (key, callback) => diff --git a/spec/stores/feature-usage-store-spec.es6 b/spec/stores/feature-usage-store-spec.es6 new file mode 100644 index 000000000..1379a1ea4 --- /dev/null +++ b/spec/stores/feature-usage-store-spec.es6 @@ -0,0 +1,82 @@ +import {TaskQueueStatusStore} from 'nylas-exports' +import FeatureUsageStore from '../../src/flux/stores/feature-usage-store' +import Task from '../../src/flux/tasks/task' +import SendFeatureUsageEventTask from '../../src/flux/tasks/send-feature-usage-event-task' +import IdentityStore from '../../src/flux/stores/identity-store' + +describe("FeatureUsageStore", function featureUsageStoreSpec() { + beforeEach(() => { + this.oldIdent = IdentityStore._identity; + IdentityStore._identity = {id: 'foo'} + IdentityStore._identity.feature_usage = { + "is-usable": { + quota: 10, + peroid: 'monthly', + used_in_period: 8, + feature_limit_name: 'Usable Group A', + }, + "not-usable": { + quota: 10, + peroid: 'monthly', + used_in_period: 10, + feature_limit_name: 'Unusable Group A', + }, + } + }); + + afterEach(() => { + IdentityStore._identity = this.oldIdent + }); + + describe("isUsable", () => { + it("returns true if a feature hasn't met it's quota", () => { + expect(FeatureUsageStore.isUsable("is-usable")).toBe(true) + }); + + it("returns false if a feature is at its quota", () => { + expect(FeatureUsageStore.isUsable("not-usable")).toBe(false) + }); + + it("warns if asking for an unsupported feature", () => { + spyOn(NylasEnv, "reportError") + expect(FeatureUsageStore.isUsable("unsupported")).toBe(false) + expect(NylasEnv.reportError).toHaveBeenCalled() + }); + }); + + describe("useFeature", () => { + beforeEach(() => { + spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success)); + spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => { + IdentityStore._identity = ident + }) + spyOn(TaskQueueStatusStore, "waitForPerformLocal").andReturn(Promise.resolve()) + }); + + it("returns the num remaining if successful", async () => { + let numLeft = await FeatureUsageStore.useFeature('is-usable'); + expect(numLeft).toBe(1) + numLeft = await FeatureUsageStore.useFeature('is-usable'); + expect(numLeft).toBe(0) + }); + + it("throws if it was over quota", async () => { + try { + await FeatureUsageStore.useFeature("not-usable"); + throw new Error("This should throw") + } catch (err) { + expect(err.message).toMatch(/not usable/) + } + }); + + it("throws if using an unsupported feature", async () => { + spyOn(NylasEnv, "reportError") + try { + await FeatureUsageStore.useFeature("unsupported"); + throw new Error("This should throw") + } catch (err) { + expect(err.message).toMatch(/supported/) + } + }); + }); +}); diff --git a/spec/stores/identity-store-spec.es6 b/spec/stores/identity-store-spec.es6 new file mode 100644 index 000000000..073e917e3 --- /dev/null +++ b/spec/stores/identity-store-spec.es6 @@ -0,0 +1,75 @@ +import {ipcRenderer} from 'electron'; +import {KeyManager, DatabaseTransaction, SendFeatureUsageEventTask} from 'nylas-exports' +import IdentityStore from '../../src/flux/stores/identity-store' + +const TEST_NYLAS_ID = "icihsnqh4pwujyqihlrj70vh" +const TEST_TOKEN = "test-token" + +describe("IdentityStore", function identityStoreSpec() { + beforeEach(() => { + this.identityJSON = { + valid_until: 1500093224, + firstname: "Nylas 050", + lastname: "Test", + free_until: 1500006814, + email: "nylas050test@evanmorikawa.com", + id: TEST_NYLAS_ID, + seen_welcome_page: true, + } + }); + + it("logs out of nylas identity properly", async () => { + IdentityStore._identity = this.identityJSON; + spyOn(NylasEnv.config, 'unset') + spyOn(KeyManager, "deletePassword") + spyOn(ipcRenderer, "send") + spyOn(DatabaseTransaction.prototype, "persistJSONBlob").andReturn(Promise.resolve()) + + const promise = IdentityStore._onLogoutNylasIdentity() + IdentityStore._onIdentityChanged(null) + return promise.then(() => { + expect(KeyManager.deletePassword).toHaveBeenCalled() + expect(ipcRenderer.send).toHaveBeenCalled() + expect(ipcRenderer.send.calls[0].args[1]).toBe("application:relaunch-to-initial-windows") + expect(DatabaseTransaction.prototype.persistJSONBlob).toHaveBeenCalled() + const ident = DatabaseTransaction.prototype.persistJSONBlob.calls[0].args[1] + expect(ident).toBe(null) + }) + }); + + it("can log a feature usage event", () => { + spyOn(IdentityStore, "nylasIDRequest"); + spyOn(IdentityStore, "saveIdentity"); + IdentityStore._identity = this.identityJSON + IdentityStore._identity.token = TEST_TOKEN; + IdentityStore._onEnvChanged() + const t = new SendFeatureUsageEventTask("snooze"); + t.performRemote() + const opts = IdentityStore.nylasIDRequest.calls[0].args[0] + expect(opts).toEqual({ + method: "POST", + url: "https://billing.nylas.com/n1/user/feature_usage_event", + body: { + feature_name: 'snooze', + }, + }) + }); + + describe("returning the identity object", () => { + it("returns the identity as null if it looks blank", () => { + IdentityStore._identity = null; + expect(IdentityStore.identity()).toBe(null); + IdentityStore._identity = {}; + expect(IdentityStore.identity()).toBe(null); + IdentityStore._identity = {token: 'bad'}; + expect(IdentityStore.identity()).toBe(null); + }); + + it("returns a proper clone of the identity", () => { + IdentityStore._identity = {id: 'bar', deep: {obj: 'baz'}}; + const ident = IdentityStore.identity(); + IdentityStore._identity.deep.obj = 'changed'; + expect(ident.deep.obj).toBe('baz'); + }); + }); +}); diff --git a/src/K2 b/src/K2 index d85ca0a93..ae7815e42 160000 --- a/src/K2 +++ b/src/K2 @@ -1 +1 @@ -Subproject commit d85ca0a9387a1f2ca059e60fd3bf59b7d381cb85 +Subproject commit ae7815e42cda1f9e18e43474c601f01d6614af17 diff --git a/src/browser/application.es6 b/src/browser/application.es6 index e6f052ddd..dd6e0f7f9 100644 --- a/src/browser/application.es6 +++ b/src/browser/application.es6 @@ -10,6 +10,8 @@ import {EventEmitter} from 'events'; import WindowManager from './window-manager'; import FileListCache from './file-list-cache'; +import DatabaseReader from './database-reader'; +import ConfigMigrator from './config-migrator'; import ApplicationMenu from './application-menu'; import AutoUpdateManager from './auto-update-manager'; import SystemTrayManager from './system-tray-manager'; @@ -24,7 +26,7 @@ let clipboard = null; // The application's singleton class. // export default class Application extends EventEmitter { - start(options) { + async start(options) { const {resourcePath, configDirPath, version, devMode, specMode, safeMode} = options; // Normalize to make sure drive letter case is consistent on Windows @@ -38,12 +40,18 @@ export default class Application extends EventEmitter { this.fileListCache = new FileListCache(); this.nylasProtocolHandler = new NylasProtocolHandler(this.resourcePath, this.safeMode); + this.databaseReader = new DatabaseReader({configDirPath, specMode}); + await this.databaseReader.open(); + const Config = require('../config'); const config = new Config(); this.config = config; this.configPersistenceManager = new ConfigPersistenceManager({configDirPath, resourcePath}); config.load(); + this.configMigrator = new ConfigMigrator(this.config, this.databaseReader); + this.configMigrator.migrate() + this.packageMigrationManager = new PackageMigrationManager({config, configDirPath, version}) this.packageMigrationManager.migrate() @@ -52,7 +60,7 @@ export default class Application extends EventEmitter { initializeInBackground = false; } - this.autoUpdateManager = new AutoUpdateManager(version, config, specMode); + this.autoUpdateManager = new AutoUpdateManager(version, config, specMode, this.databaseReader); this.applicationMenu = new ApplicationMenu(version); this.windowManager = new WindowManager({ resourcePath: this.resourcePath, @@ -123,7 +131,6 @@ export default class Application extends EventEmitter { } } - // On Windows, removing a file can fail if a process still has it open. When // we close windows and log out, we need to wait for these processes to completely // exit and then delete the file. It's hard to tell when this happens, so we just @@ -160,7 +167,7 @@ export default class Application extends EventEmitter { openWindowsForTokenState() { const accounts = this.config.get('nylas.accounts'); const hasAccount = accounts && accounts.length > 0; - const hasN1ID = this.config.get('nylas.identity.id'); + const hasN1ID = this._getNylasId(); if (hasAccount && hasN1ID) { this.windowManager.ensureWindow(WindowManager.MAIN_WINDOW); @@ -173,7 +180,14 @@ export default class Application extends EventEmitter { } } + _getNylasId() { + const identity = this.databaseReader.getJSONBlob("NylasID") || {} + return identity.id + } + _relaunchToInitialWindows = ({resetConfig, resetDatabase} = {}) => { + // This will re-fetch the NylasID to update the feed url + this.autoUpdateManager.updateFeedURL() this.setDatabasePhase('close'); this.windowManager.destroyAllWindows(); @@ -270,6 +284,10 @@ export default class Application extends EventEmitter { this.on('application:relaunch-to-initial-windows', this._relaunchToInitialWindows); + this.on('application:onIdentityChanged', () => { + this.autoUpdateManager.updateFeedURL() + }); + this.on('application:quit', () => { app.quit() }); diff --git a/src/browser/auto-update-manager.es6 b/src/browser/auto-update-manager.es6 index 5264920be..7b4d5c472 100644 --- a/src/browser/auto-update-manager.es6 +++ b/src/browser/auto-update-manager.es6 @@ -18,31 +18,28 @@ const preferredChannel = 'nylas-mail' export default class AutoUpdateManager extends EventEmitter { - constructor(version, config, specMode) { + constructor(version, config, specMode, databaseReader) { super(); this.state = IdleState; this.version = version; this.config = config; + this.databaseReader = databaseReader this.specMode = specMode; this.preferredChannel = preferredChannel; - this._updateFeedURL(); + this.updateFeedURL(); - this.config.onDidChange( - 'nylas.identity.id', - this._updateFeedURL - ); this.config.onDidChange( 'nylas.accounts', - this._updateFeedURL + this.updateFeedURL ); setTimeout(() => this.setupAutoUpdater(), 0); } parameters = () => { - let updaterId = this.config.get("nylas.identity.id"); + let updaterId = (this.databaseReader.getJSONBlob("NylasID") || {}).id if (!updaterId) { updaterId = "anonymous"; } @@ -66,7 +63,7 @@ export default class AutoUpdateManager extends EventEmitter { }; } - _updateFeedURL = () => { + updateFeedURL = () => { const params = this.parameters(); let host = `edgehill.nylas.com`; diff --git a/src/browser/config-migrator.es6 b/src/browser/config-migrator.es6 new file mode 100644 index 000000000..a65b723c6 --- /dev/null +++ b/src/browser/config-migrator.es6 @@ -0,0 +1,25 @@ +export default class ConfigMigrator { + constructor(config, database) { + this.config = config; + this.database = database; + } + + migrate() { + /** + * In version before 1.0.21 we stored the Nylas ID Identity in the Config. + * After 1.0.21 we moved it into the JSONBlob Database Store. + */ + const oldIdentity = this.config.get("nylas.identity") || {}; + if (!oldIdentity.id) return; + const key = "NylasID" + const q = `REPLACE INTO JSONBlob (id, data, client_id) VALUES (?,?,?)`; + const jsonBlobData = { + id: key, + clientId: key, + serverId: key, + json: oldIdentity, + } + this.database.database.prepare(q).run([key, JSON.stringify(jsonBlobData), key]) + this.config.set("nylas.identity", null) + } +} diff --git a/src/browser/database-reader.es6 b/src/browser/database-reader.es6 new file mode 100644 index 000000000..90226e9b5 --- /dev/null +++ b/src/browser/database-reader.es6 @@ -0,0 +1,22 @@ +import {setupDatabase, databasePath} from '../database-helpers' + +export default class DatabaseReader { + constructor({configDirPath, specMode}) { + this.databasePath = databasePath(configDirPath, specMode) + } + + async open() { + this.database = await setupDatabase(this.databasePath) + } + + getJSONBlob(key) { + const q = `SELECT * FROM JSONBlob WHERE id = '${key}'`; + try { + const row = this.database.prepare(q).get(); + if (!row || !row.data) return null + return (JSON.parse(row.data) || {}).json + } catch (err) { + return null + } + } +} diff --git a/src/flux/actions.es6 b/src/flux/actions.es6 index aeb27ee5a..68bd46589 100644 --- a/src/flux/actions.es6 +++ b/src/flux/actions.es6 @@ -149,7 +149,6 @@ class Actions { /* Public: Manage the Nylas identity */ - static setNylasIdentity = ActionScopeWindow; static logoutNylasIdentity = ActionScopeWindow; /* diff --git a/src/flux/stores/feature-usage-store.es6 b/src/flux/stores/feature-usage-store.es6 new file mode 100644 index 000000000..c671ea084 --- /dev/null +++ b/src/flux/stores/feature-usage-store.es6 @@ -0,0 +1,51 @@ +import Rx from 'rx-lite' +import NylasStore from 'nylas-store' +import Actions from '../actions' +import IdentityStore from './identity-store' +import TaskQueueStatusStore from './task-queue-status-store' +import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task' + +/** + * FeatureUsageStore is backed by the IdentityStore + * + * The billing site is responsible for returning with the Identity object + * a usage hash that includes all supported features, their quotas for the + * user, and the current usage of that user. We keep a cache locally + */ +class FeatureUsageStore extends NylasStore { + activate() { + /** + * The IdentityStore triggers both after we update it, and when it + * polls for new data every several minutes or so. + */ + this._sub = Rx.Observable.fromStore(IdentityStore).subscribe(() => { + this.trigger() + }) + } + + isUsable(feature) { + const usage = this._featureUsage() + if (!usage[feature]) { + NylasEnv.reportError(`${feature} isn't supported`); + return false + } + return usage[feature].used_in_period < usage[feature].quota + } + + async useFeature(featureName) { + if (!this.isUsable(featureName)) { + throw new Error(`${featureName} is not usable! Check "FeatureUsageStore.isUsable" first`); + } + const task = new SendFeatureUsageEventTask(featureName) + Actions.queueTask(task); + await TaskQueueStatusStore.waitForPerformLocal(task) + const feat = IdentityStore.identity().feature_usage[featureName] + return feat.quota - feat.used_in_period + } + + _featureUsage() { + return Object.assign({}, IdentityStore.identity().feature_usage) || {} + } +} + +export default new FeatureUsageStore() diff --git a/src/flux/stores/identity-store.es6 b/src/flux/stores/identity-store.es6 index 18794288c..e7a73b839 100644 --- a/src/flux/stores/identity-store.es6 +++ b/src/flux/stores/identity-store.es6 @@ -1,40 +1,132 @@ +import Rx from 'rx-lite' import NylasStore from 'nylas-store'; import {ipcRenderer} from 'electron'; import request from 'request'; import url from 'url' -import KeyManager from '../../key-manager' -import Actions from '../actions'; import Utils from '../models/utils'; - -const configIdentityKey = "nylas.identity"; +import Actions from '../actions'; +import KeyManager from '../../key-manager' +import DatabaseStore from './database-store' // Note this key name is used when migrating to Nylas Pro accounts from // old N1. -const KEY_NAME = 'Nylas Account'; +const KEYCHAIN_NAME = 'Nylas Account'; class IdentityStore extends NylasStore { constructor() { super(); + this._savePromises = [] + this._identity = null + } + async activate() { NylasEnv.config.onDidChange('env', this._onEnvChanged); this._onEnvChanged(); - this.listenTo(Actions.setNylasIdentity, this._onSetNylasIdentity); this.listenTo(Actions.logoutNylasIdentity, this._onLogoutNylasIdentity); - NylasEnv.config.onDidChange(configIdentityKey, () => { - this._loadIdentity(); - this.trigger(); - }); + const q = DatabaseStore.findJSONBlob("NylasID"); + this._disp = Rx.Observable.fromQuery(q).subscribe(this._onIdentityChanged) - this._loadIdentity(); + const identity = await DatabaseStore.run(q) + this._onIdentityChanged(identity) - if (NylasEnv.isMainWindow() && ['staging', 'production'].includes(NylasEnv.config.get('env'))) { - setInterval(this.fetchIdentity, 1000 * 60 * 60); // 1 hour - this.fetchIdentity(); + this._fetchAndPollRemoteIdentity() + } + + deactivate() { + this._disp.dispose(); + this.stopListeningToAll() + } + + identity() { + if (!this._identity || !this._identity.id) return null + return Utils.deepClone(this._identity); + } + + identityId() { + if (!this._identity) { + return null; } + return this._identity.id; + } + + _fetchAndPollRemoteIdentity() { + if (!NylasEnv.isMainWindow()) return; + if (!['staging', 'production'].includes(NylasEnv.config.get('env'))) return; + /** + * We only need to re-fetch the identity to synchronize ourselves + * with any changes a user did on a separate computer. Any updates + * they do on their primary computer will be optimistically updated. + * We also update from the server's version every + * `SendFeatureUsageEventTask` + */ + setInterval(this._fetchIdentity, 1000 * 60 * 10); // 10 minutes + // Don't await for this! + this._fetchIdentity(); + } + + /** + * Saves the identity to the database. The local cache will be updated + * once the database change comes back through + */ + async saveIdentity(identity) { + if (identity && identity.token) { + KeyManager.replacePassword(KEYCHAIN_NAME, identity.token) + delete identity.token; + } + if (!identity) { + KeyManager.deletePassword(KEYCHAIN_NAME) + } + const savePromise = new Promise((resolve, reject) => { + this._savePromises.push({ + resolve: resolve, + rejectTimeout: setTimeout(() => { + reject(new Error("Identity never persisted to database")) + }, 10000), + }); + }) + await DatabaseStore.inTransaction((t) => { + return t.persistJSONBlob("NylasID", identity) + }); + return savePromise + } + + /** + * When the identity changes in the database, update our local store + * cache and set the token from the keychain. + */ + _onIdentityChanged = (newIdentity) => { + const oldId = ((this._identity || {}).id) + this._identity = newIdentity + if (this._identity && this._identity.id) { + if (!this._identity.token) { + this._identity.token = KeyManager.getPassword(KEYCHAIN_NAME); + } + } else { + // It's possible the identity exists as an empty object. If the + // object looks blank, set the identity to null. + this._identity = null + } + const newId = ((this._identity || {}).id); + if (oldId !== newId) { + ipcRenderer.send('command', 'onIdentityChanged'); + } + if (this._savePromises.length > 0) { + for (const {resolve, rejectTimeout} of this._savePromises) { + resolve(); + clearTimeout(rejectTimeout) + } + this._savePromises = [] + } + this.trigger(); + } + + _onLogoutNylasIdentity = async () => { + await this.saveIdentity(null) + ipcRenderer.send('command', 'application:relaunch-to-initial-windows'); } _onEnvChanged = () => { @@ -50,24 +142,6 @@ class IdentityStore extends NylasStore { } } - _loadIdentity() { - this._identity = NylasEnv.config.get(configIdentityKey); - if (this._identity) { - this._identity.token = KeyManager.getPassword(KEY_NAME, {migrateFromService: "Nylas"}); - } - } - - identity() { - return this._identity; - } - - identityId() { - if (!this._identity) { - return null; - } - return this._identity.id; - } - /** * This passes utm_source, utm_campaign, and utm_content params to the * N1 billing site. Please reference: @@ -118,30 +192,37 @@ class IdentityStore extends NylasStore { }); } - fetchIdentity = () => { + _fetchIdentity = async () => { if (!this._identity || !this._identity.token) { return Promise.resolve(); } - return this.fetchPath('/n1/user').then((json) => { - const nextIdentity = Object.assign({}, this._identity, json); - this._onSetNylasIdentity(nextIdentity); - }); + const json = await this.fetchPath('/n1/user') + const nextIdentity = Object.assign({}, this._identity, json); + return this.saveIdentity(nextIdentity); } - fetchPath = (path) => { - return new Promise((resolve, reject) => { - const requestId = Utils.generateTempId(); - const options = { - method: 'GET', - url: `${this.URLRoot}${path}`, - startTime: Date.now(), - auth: { - username: this._identity.token, - password: '', - sendImmediately: true, - }, - }; + fetchPath = async (path) => { + const options = { + method: 'GET', + url: `${this.URLRoot}${path}`, + startTime: Date.now(), + }; + try { + await this.nylasIDRequest(options) + } catch (err) { + const error = err || new Error(`IdentityStore.fetchPath: ${path} ${err.message}.`) + NylasEnv.reportError(error) + } + } + nylasIDRequest(options) { + return new Promise((resolve, reject) => { + options.auth = { + username: this._identity.token, + password: '', + sendImmediately: true, + } + const requestId = Utils.generateTempId(); Actions.willMakeAPIRequest({ request: options, requestId: requestId, @@ -157,27 +238,12 @@ class IdentityStore extends NylasStore { try { return resolve(JSON.parse(body)); } catch (err) { - NylasEnv.reportError(new Error(`IdentityStore.fetchPath: ${path} ${err.message}.`)) + return reject(err) } } - return reject(error || new Error(`IdentityStore.fetchPath: ${path} ${response.statusCode}.`)); + return reject(error) }); - }); - } - - _onLogoutNylasIdentity = () => { - KeyManager.deletePassword(KEY_NAME); - NylasEnv.config.unset(configIdentityKey); - ipcRenderer.send('command', 'application:relaunch-to-initial-windows'); - } - - _onSetNylasIdentity = (identity) => { - if (identity.token) { - KeyManager.replacePassword(KEY_NAME, identity.token) - delete identity.token; - } - NylasEnv.config.set(configIdentityKey, identity); - this.trigger() + }) } } diff --git a/src/flux/tasks/send-feature-usage-event-task.es6 b/src/flux/tasks/send-feature-usage-event-task.es6 new file mode 100644 index 000000000..c0194cdb9 --- /dev/null +++ b/src/flux/tasks/send-feature-usage-event-task.es6 @@ -0,0 +1,49 @@ +import Task from './task'; +import NylasAPI from '../nylas-api' +import {APIError} from '../errors' +import IdentityStore from '../stores/identity-store' + +export default class SendFeatureUsageEventTask extends Task { + constructor(featureName) { + super(); + this.featureName = featureName + } + + async performLocal(increment = 1) { + const newIdent = IdentityStore.identity(); + if (!newIdent.feature_usage[this.featureName]) { + throw new Error(`Can't use ${this.featureName}. Does not exist on identity`) + } + newIdent.feature_usage[this.featureName].used_in_period += increment + await IdentityStore.saveIdentity(newIdent) + } + + revert() { + this.performLocal(-1) + } + + async performRemote() { + const options = { + method: 'POST', + url: `${IdentityStore.URLRoot}/n1/user/feature_usage_event`, + body: {feature_name: this.featureName}, + }; + try { + const updatedIdentity = await IdentityStore.nylasIDRequest(options); + await IdentityStore.saveIdentity(updatedIdentity); + return Promise.resolve(Task.Status.Success) + } catch (err) { + if (err instanceof APIError) { + if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) { + this.revert() + return Promise.resolve([Task.Status.Failed, err]) + } + return Promise.resolve(Task.Status.Retry) + } + + this.revert() + NylasEnv.reportError(err); + return Promise.resolve([Task.Status.Failed, err]) + } + } +} diff --git a/src/global/nylas-exports.es6 b/src/global/nylas-exports.es6 index 8058667eb..6a30ca612 100644 --- a/src/global/nylas-exports.es6 +++ b/src/global/nylas-exports.es6 @@ -129,6 +129,7 @@ lazyLoadAndRegisterTask(`SyncbackCategoryTask`, 'syncback-category-task'); lazyLoadAndRegisterTask(`SyncbackMetadataTask`, 'syncback-metadata-task'); lazyLoadAndRegisterTask(`PerformSendActionTask`, 'perform-send-action-task'); lazyLoadAndRegisterTask(`ReprocessMailRulesTask`, 'reprocess-mail-rules-task'); +lazyLoadAndRegisterTask(`SendFeatureUsageEventTask`, 'send-feature-usage-event-task'); lazyLoadAndRegisterTask(`EnsureMessageInSentFolderTask`, 'ensure-message-in-sent-folder-task'); // Stores @@ -153,6 +154,7 @@ lazyLoadAndRegisterStore(`WorkspaceStore`, 'workspace-store'); lazyLoadAndRegisterStore(`MailRulesStore`, 'mail-rules-store'); lazyLoadAndRegisterStore(`FileUploadStore`, 'file-upload-store'); lazyLoadAndRegisterStore(`SendActionsStore`, 'send-actions-store'); +lazyLoadAndRegisterStore(`FeatureUsageStore`, 'feature-usage-store'); lazyLoadAndRegisterStore(`ThreadCountsStore`, 'thread-counts-store'); lazyLoadAndRegisterStore(`FileDownloadStore`, 'file-download-store'); lazyLoadAndRegisterStore(`UpdateChannelStore`, 'update-channel-store'); diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee index 0976410be..dee9f6c02 100644 --- a/src/nylas-env.coffee +++ b/src/nylas-env.coffee @@ -193,6 +193,8 @@ class NylasEnvConstructor unless @inSpecMode() @actionBridge = new ActionBridge(ipcRenderer) + @extendRxObservables() + # Nylas exports is designed to provide a lazy-loaded set of globally # accessible objects to all packages. Upon require, nylas-exports will # fill the TaskRegistry, StoreRegistry, and DatabaseObjectRegistries @@ -657,18 +659,17 @@ class NylasEnvConstructor @savedState.columnWidths ?= {} @savedState.columnWidths[id] - startWindow: -> + startWindow: => @loadConfig() - {packageLoadingDeferred, windowType} = @getLoadSettings() - @extendRxObservables() - StoreRegistry.activateAllStores() - @keymaps.loadKeymaps() - @themes.loadBaseStylesheets() - @packages.loadPackages(windowType) unless packageLoadingDeferred - @deserializePackageStates() unless packageLoadingDeferred - @initializeReactRoot() - @packages.activate() unless packageLoadingDeferred - @menu.update() + {packageLoadingDeferred, windowType, title} = @getLoadSettings() + return StoreRegistry.activateAllStores().then => + @keymaps.loadKeymaps() + @themes.loadBaseStylesheets() + @packages.loadPackages(windowType) unless packageLoadingDeferred + @deserializePackageStates() unless packageLoadingDeferred + @initializeReactRoot() + @packages.activate() unless packageLoadingDeferred + @menu.update() # Call this method when establishing a real application window. startRootWindow: -> @@ -683,6 +684,8 @@ class NylasEnvConstructor window.requestAnimationFrame => @displayWindow() unless initializeInBackground @startWindow() + # These don't need to wait for the window's stores and such + # to fully activate: @requireUserInitScript() unless safeMode @showMainWindow() ipcRenderer.send('window-command', 'window:loaded') @@ -692,12 +695,11 @@ class NylasEnvConstructor # hot windows), the packages won't be loaded until `populateHotWindow` # gets fired. startSecondaryWindow: -> - @extendRxObservables() document.getElementById("application-loading-cover")?.remove() - @startWindow() - @initializeBasicSheet() - ipcRenderer.on("load-settings-changed", @populateHotWindow) - ipcRenderer.send('window-command', 'window:loaded') + @startWindow().then => + @initializeBasicSheet() + ipcRenderer.on("load-settings-changed", @populateHotWindow) + ipcRenderer.send('window-command', 'window:loaded') # We setup the initial Sheet for hot windows. This is the default title # bar, stoplights, etc. This saves ~100ms when populating the hot diff --git a/src/registries/store-registry.es6 b/src/registries/store-registry.es6 index 251f6396b..061695b10 100644 --- a/src/registries/store-registry.es6 +++ b/src/registries/store-registry.es6 @@ -9,13 +9,22 @@ class StoreRegistry extends SerializableRegistry { * It also kicks off a fairly large tree of require statements that * takes considerable time to process. */ - activateAllStores() { + async activateAllStores() { for (const name of Object.keys(this._constructorFactories)) { // All we need to do is hit `require` on the store. This will // construct the object an initialize the require cache. The // stores are now available in nylas-exports or from the node // require cache. - this.get(name) + const store = this.get(name); + + /** + * Some stores may have extra activation work to do. This work may + * be asynchronous. We detect that here and call the store's + * activate methods. + */ + if (store.activate) { + await store.activate() + } } } }