diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-08-16 15:38:40 +0300 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-08-16 15:38:40 +0300 |
| commit | 65553ebf473ae849b79b326861629eb08babe48e (patch) | |
| tree | 7d4b50da9869b77b228b3efbbbb956c0492a43d0 /gui/src/main | |
| parent | 95af6cd0baf777978d9892c610fe2ee2a0974ebb (diff) | |
| parent | 6180eb5f9d496ecff9f7526d63bdb15b5b1f6c45 (diff) | |
| download | mullvadvpn-65553ebf473ae849b79b326861629eb08babe48e.tar.xz mullvadvpn-65553ebf473ae849b79b326861629eb08babe48e.zip | |
Merge branch 'move-login-to-main-process'
Diffstat (limited to 'gui/src/main')
| -rw-r--r-- | gui/src/main/account-data-cache.ts | 118 | ||||
| -rw-r--r-- | gui/src/main/errors.ts | 6 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 112 | ||||
| -rw-r--r-- | gui/src/main/window-controller.ts | 4 |
4 files changed, 225 insertions, 15 deletions
diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts new file mode 100644 index 0000000000..24f9c0453a --- /dev/null +++ b/gui/src/main/account-data-cache.ts @@ -0,0 +1,118 @@ +import log from 'electron-log'; +import { AccountToken, IAccountData } from '../shared/daemon-rpc-types'; + +export enum AccountFetchRetryAction { + stop, + retry, +} +interface IAccountFetchWatcher { + onFinish: () => void; + onError: (error: Error) => AccountFetchRetryAction; +} + +// An account data cache that helps to throttle RPC requests to get_account_data and retain the +// cached value for 1 minute. +export default class AccountDataCache { + private currentAccount?: AccountToken; + private expiresAt?: Date; + private fetchAttempt = 0; + private fetchRetryTimeout?: NodeJS.Timeout; + private watchers: IAccountFetchWatcher[] = []; + + constructor( + private fetchHandler: (token: AccountToken) => Promise<IAccountData>, + private updateHandler: (data?: IAccountData) => void, + ) {} + + public fetch(accountToken: AccountToken, watcher?: IAccountFetchWatcher) { + // invalidate cache if account token has changed + if (accountToken !== this.currentAccount) { + this.invalidate(); + this.currentAccount = accountToken; + } + + // Only fetch is value has expired + if (this.isExpired()) { + if (watcher) { + this.watchers.push(watcher); + } + + this.performFetch(accountToken); + } else if (watcher) { + watcher.onFinish(); + } + } + + public invalidate() { + if (this.fetchRetryTimeout) { + clearTimeout(this.fetchRetryTimeout); + this.fetchRetryTimeout = undefined; + this.fetchAttempt = 0; + } + + this.expiresAt = undefined; + this.updateHandler(); + this.notifyWatchers((watcher) => { + watcher.onError(new Error('Cancelled')); + }); + } + + private setValue(value: IAccountData) { + this.expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration + this.updateHandler(value); + this.notifyWatchers((watcher) => watcher.onFinish()); + } + + private isExpired() { + return !this.expiresAt || this.expiresAt < new Date(); + } + + private async performFetch(accountToken: AccountToken) { + try { + // it's possible for invalidate() to be called or for a fetch for a different account token + // to start before this fetch completes, so checking if the current account token is the one + // used is necessary below. + const accountData = await this.fetchHandler(accountToken); + + if (this.currentAccount === accountToken) { + this.setValue(accountData); + } + } catch (error) { + if (this.currentAccount === accountToken) { + this.handleFetchError(accountToken, error); + } + } + } + + private handleFetchError(accountToken: AccountToken, error: any) { + let shouldRetry = true; + + this.notifyWatchers((watcher) => { + if (watcher.onError(error) === AccountFetchRetryAction.stop) { + shouldRetry = false; + } + }); + + if (shouldRetry) { + this.scheduleRetry(accountToken); + } + } + + private scheduleRetry(accountToken: AccountToken) { + this.fetchAttempt += 1; + + // tslint:disable-next-line + const delay = Math.min(2048, 1 << (this.fetchAttempt + 2)) * 1000; + + log.warn(`Failed to fetch account data. Retrying in ${delay} ms`); + + this.fetchRetryTimeout = global.setTimeout(() => { + this.fetchRetryTimeout = undefined; + this.performFetch(accountToken); + }, delay); + } + + private notifyWatchers(notify: (watcher: IAccountFetchWatcher) => void) { + this.watchers.splice(0).forEach(notify); + } +} diff --git a/gui/src/main/errors.ts b/gui/src/main/errors.ts index 85adf965a5..261ee7a164 100644 --- a/gui/src/main/errors.ts +++ b/gui/src/main/errors.ts @@ -1,9 +1,3 @@ -export class NoCreditError extends Error { - constructor() { - super("Account doesn't have enough credit available for connection"); - } -} - export class NoDaemonError extends Error { constructor() { super('Could not connect to Mullvad daemon'); diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 1d766629cf..d7e13615f9 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -8,6 +8,7 @@ import { AccountToken, BridgeState, DaemonEvent, + IAccountData, IAppVersionInfo, ILocation, IRelayList, @@ -27,6 +28,7 @@ import { getRendererLogFile, setupLogging, } from '../shared/logging'; +import AccountDataCache, { AccountFetchRetryAction } from './account-data-cache'; import { getOpenAtLogin, setOpenAtLogin } from './autostart'; import { ConnectionObserver, @@ -34,6 +36,7 @@ import { ResponseParseError, SubscriptionListener, } from './daemon-rpc'; +import { InvalidAccountError } from './errors'; import GuiSettings from './gui-settings'; import NotificationController from './notification-controller'; import { resolveBin } from './proc'; @@ -63,6 +66,8 @@ export interface IAppUpgradeInfo extends IAppVersionInfo { upToDate: boolean; } +type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error }; + class ApplicationMain { private notificationController = new NotificationController(); private windowController?: WindowController; @@ -73,6 +78,7 @@ class ApplicationMain { private connectedToDaemon = false; private quitStage = AppQuitStage.unready; + private accountData?: IAccountData = undefined; private accountHistory: AccountToken[] = []; private tunnelState: TunnelState = { state: 'disconnected' }; private settings: ISettings = { @@ -136,6 +142,19 @@ class ApplicationMain { private wireguardPublicKey?: string; + private accountDataCache = new AccountDataCache( + (accountToken) => { + return this.daemonRpc.getAccountData(accountToken); + }, + (accountData) => { + this.accountData = accountData; + + if (this.windowController) { + IpcMainEventChannel.account.notify(this.windowController.webContents, accountData); + } + }, + ); + public run() { // Since electron's GPU blacklists are broken, GPU acceleration won't work on older distros if (process.platform === 'linux') { @@ -554,6 +573,10 @@ class ApplicationMain { if (this.windowController) { IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState); } + + if (this.accountData) { + this.detectStaleAccountExpiry(newState, new Date(this.accountData.expiry)); + } } private setSettings(newSettings: ISettings) { @@ -562,6 +585,9 @@ class ApplicationMain { this.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected); + // make sure to invalidate the account data cache when account tokens change + this.updateAccountDataOnAccountChange(oldSettings.accountToken, newSettings.accountToken); + if (oldSettings.accountToken !== newSettings.accountToken) { this.updateAccountHistory(); this.fetchWireguardKey(); @@ -840,7 +866,7 @@ class ApplicationMain { // cancel notifications when window appears this.notificationController.cancelPendingNotifications(); - windowController.send('window-shown'); + this.updateAccountExpiryIfNeeded(); }); windowController.window.on('hide', () => { @@ -854,6 +880,7 @@ class ApplicationMain { locale: this.locale, isConnected: this.connectedToDaemon, autoStart: getOpenAtLogin(), + accountData: this.accountData, accountHistory: this.accountHistory, tunnelState: this.tunnelState, settings: this.settings, @@ -907,13 +934,8 @@ class ApplicationMain { this.guiSettings.monochromaticIcon = monochromaticIcon; }); - IpcMainEventChannel.account.handleSet((token: AccountToken) => - this.daemonRpc.setAccount(token), - ); - IpcMainEventChannel.account.handleUnset(() => this.daemonRpc.setAccount()); - IpcMainEventChannel.account.handleGetData((token: AccountToken) => - this.daemonRpc.getAccountData(token), - ); + IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token)); + IpcMainEventChannel.account.handleLogout(() => this.logout()); IpcMainEventChannel.accountHistory.handleRemoveItem(async (token: AccountToken) => { await this.daemonRpc.removeAccountFromHistory(token); @@ -1003,6 +1025,80 @@ class ApplicationMain { ); } + private async login(accountToken: AccountToken): Promise<void> { + try { + const verification = await this.verifyAccount(accountToken); + + if (verification.status === 'deferred') { + log.warn(`Failed to get account data, logging in anyway: ${verification.error.message}`); + } + + await this.daemonRpc.setAccount(accountToken); + } catch (error) { + log.error(`Failed to login: ${error.message}`); + + if (error instanceof InvalidAccountError) { + throw Error(messages.gettext('Invalid account number')); + } else { + throw error; + } + } + } + + private async logout(): Promise<void> { + try { + await this.daemonRpc.setAccount(); + } catch (error) { + log.info(`Failed to logout: ${error.message}`); + + throw error; + } + } + + private verifyAccount(accountToken: AccountToken): Promise<AccountVerification> { + return new Promise((resolve, reject) => { + this.accountDataCache.invalidate(); + this.accountDataCache.fetch(accountToken, { + onFinish: () => resolve({ status: 'verified' }), + onError: (error): AccountFetchRetryAction => { + if (error instanceof InvalidAccountError) { + reject(error); + return AccountFetchRetryAction.stop; + } else { + resolve({ status: 'deferred', error }); + return AccountFetchRetryAction.retry; + } + }, + }); + }); + } + + private updateAccountDataOnAccountChange(oldAccount?: string, newAccount?: string) { + if (oldAccount && !newAccount) { + this.accountDataCache.invalidate(); + } else if (!oldAccount && newAccount) { + this.accountDataCache.fetch(newAccount); + } else if (oldAccount && newAccount && oldAccount !== newAccount) { + this.accountDataCache.fetch(newAccount); + } + } + + private updateAccountExpiryIfNeeded() { + if (this.connectedToDaemon && this.settings.accountToken) { + this.accountDataCache.fetch(this.settings.accountToken); + } + } + + private detectStaleAccountExpiry(tunnelState: TunnelState, accountExpiry: Date) { + const hasExpired = new Date() >= accountExpiry; + + // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. + if (tunnelState.state === 'connected' && hasExpired) { + log.info('Detected the stale account expiry.'); + this.accountDataCache.invalidate(); + } + } + private async updateAccountHistory(): Promise<void> { try { this.setAccountHistory(await this.daemonRpc.getAccountHistory()); diff --git a/gui/src/main/window-controller.ts b/gui/src/main/window-controller.ts index 3822ecb23f..e681b2a595 100644 --- a/gui/src/main/window-controller.ts +++ b/gui/src/main/window-controller.ts @@ -1,4 +1,5 @@ import { BrowserWindow, Display, screen, Tray, WebContents } from 'electron'; +import { IpcMainEventChannel } from '../shared/ipc-event-channel'; interface IPosition { x: number; @@ -201,7 +202,8 @@ export default class WindowController { private notifyUpdateWindowShape() { const shapeParameters = this.windowPositioning.getWindowShapeParameters(this.windowValue); - this.windowValue.webContents.send('update-window-shape', shapeParameters); + + IpcMainEventChannel.windowShape.notify(this.windowValue.webContents, shapeParameters); } // Installs display event handlers to update the window position on any changes in the display or |
