summaryrefslogtreecommitdiffhomepage
path: root/gui/src/main
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-08-16 15:38:40 +0300
committerAndrej Mihajlov <and@mullvad.net>2019-08-16 15:38:40 +0300
commit65553ebf473ae849b79b326861629eb08babe48e (patch)
tree7d4b50da9869b77b228b3efbbbb956c0492a43d0 /gui/src/main
parent95af6cd0baf777978d9892c610fe2ee2a0974ebb (diff)
parent6180eb5f9d496ecff9f7526d63bdb15b5b1f6c45 (diff)
downloadmullvadvpn-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.ts118
-rw-r--r--gui/src/main/errors.ts6
-rw-r--r--gui/src/main/index.ts112
-rw-r--r--gui/src/main/window-controller.ts4
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