summaryrefslogtreecommitdiffhomepage
path: root/gui/src/main
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-06-04 13:45:06 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-06-10 13:46:17 +0200
commitbfd3b02a3936451ae43867f8f3e2f986a2a2f9bc (patch)
tree3ab241e849f8f22f5bc0a8e5af226c8b30e4552e /gui/src/main
parentd7fdccb58e8205ae26023629873922979644bfdd (diff)
downloadmullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.tar.xz
mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.zip
Categorize notifications and their logic into notification definition
Diffstat (limited to 'gui/src/main')
-rw-r--r--gui/src/main/index.ts81
-rw-r--r--gui/src/main/notification-controller.ts276
2 files changed, 153 insertions, 204 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index ba9f8a9baa..b911a19c6a 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -1,8 +1,7 @@
import { execFile } from 'child_process';
-import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from 'electron';
+import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from 'electron';
import log from 'electron-log';
import mkdirp from 'mkdirp';
-import moment from 'moment';
import * as path from 'path';
import * as uuid from 'uuid';
import AccountExpiry from '../shared/account-expiry';
@@ -28,6 +27,11 @@ import { loadTranslations, messages } from '../shared/gettext';
import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
import { IpcMainEventChannel } from '../shared/ipc-event-channel';
import {
+ AccountExpiryNotificationProvider,
+ InconsistentVersionNotificationProvider,
+ UnsupportedVersionNotificationProvider,
+} from '../shared/notifications/notification';
+import {
backupLogFile,
getLogsDirectory,
getMainLogFile,
@@ -76,7 +80,11 @@ export interface IAppUpgradeInfo extends IAppVersionInfo {
type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error };
class ApplicationMain {
- private notificationController = new NotificationController();
+ private notificationController = new NotificationController({
+ openLink: (url: string, withAuth?: boolean) => this.openLink(url, withAuth),
+ isWindowVisible: () => this.windowController?.isVisible() ?? false,
+ areSystemNotificationsEnabled: () => this.guiSettings.enableSystemNotifications,
+ });
private windowController?: WindowController;
private trayIconController?: TrayIconController;
@@ -463,12 +471,11 @@ class ApplicationMain {
consumePromise(this.fetchLatestVersion());
// notify user about inconsistent version
- if (
- process.env.NODE_ENV !== 'development' &&
- !this.shouldSuppressNotifications(true) &&
- !this.currentVersion.isConsistent
- ) {
- this.notificationController.notifyInconsistentVersion();
+ const notificationProvider = new InconsistentVersionNotificationProvider({
+ consistent: this.currentVersion.isConsistent,
+ });
+ if (notificationProvider.mayDisplay()) {
+ this.notificationController.notify(notificationProvider.getSystemNotification());
}
// reset the reconnect backoff when connection established.
@@ -602,9 +609,7 @@ class ApplicationMain {
this.updateTrayIcon(newState, this.settings.blockWhenDisconnected);
consumePromise(this.updateLocation());
- if (!this.shouldSuppressNotifications(false)) {
- this.notificationController.notifyTunnelState(newState);
- }
+ this.notificationController.notifyTunnelState(newState, this.settings.blockWhenDisconnected);
if (this.windowController) {
IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState);
@@ -784,14 +789,13 @@ class ApplicationMain {
this.upgradeVersion = upgradeInfo;
// notify user to update the app if it became unsupported
- if (
- process.env.NODE_ENV !== 'development' &&
- !this.shouldSuppressNotifications(true) &&
- currentVersionInfo.isConsistent &&
- !latestVersionInfo.supported &&
- upgradeVersion
- ) {
- this.notificationController.notifyUnsupportedVersion(upgradeVersion);
+ const notificationProvider = new UnsupportedVersionNotificationProvider({
+ supported: latestVersionInfo.supported,
+ consistent: currentVersionInfo.isConsistent,
+ nextUpgrade: upgradeVersion,
+ });
+ if (notificationProvider.mayDisplay()) {
+ this.notificationController.notify(notificationProvider.getSystemNotification());
}
if (this.windowController) {
@@ -807,16 +811,6 @@ class ApplicationMain {
}
}
- private shouldSuppressNotifications(isCriticalNotification: boolean): boolean {
- const isVisible = this.windowController ? this.windowController.isVisible() : false;
-
- if (isCriticalNotification) {
- return isVisible;
- } else {
- return isVisible || !this.guiSettings.enableSystemNotifications;
- }
- }
-
private async updateLocation() {
const tunnelState = this.tunnelState;
@@ -1194,13 +1188,12 @@ class ApplicationMain {
private notifyOfAccountExpiry() {
if (this.accountData) {
const accountExpiry = new AccountExpiry(this.accountData.expiry, this.locale);
- if (
- accountExpiry &&
- !accountExpiry.hasExpired() &&
- !this.accountExpiryNotificationTimeout &&
- accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate())
- ) {
- this.notificationController.closeToExpiryNotification(accountExpiry);
+ const notificationProvider = new AccountExpiryNotificationProvider({
+ accountExpiry,
+ tooSoon: this.accountExpiryNotificationTimeout !== undefined,
+ });
+ if (notificationProvider.mayDisplay()) {
+ this.notificationController.notify(notificationProvider.getSystemNotification());
this.accountExpiryNotificationTimeout = global.setTimeout(() => {
this.accountExpiryNotificationTimeout = undefined;
this.notifyOfAccountExpiry();
@@ -1497,6 +1490,20 @@ class ApplicationMain {
return true;
}
}
+
+ private async openLink(url: string, withAuth?: boolean) {
+ if (withAuth) {
+ let token = '';
+ try {
+ token = await this.daemonRpc.getWwwAuthToken();
+ } catch (e) {
+ log.error(`Failed to get the WWW auth token: ${e.message}`);
+ }
+ return shell.openExternal(`${url}?token=${token}`);
+ } else {
+ return shell.openExternal(url);
+ }
+ }
}
const applicationMain = new ApplicationMain();
diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts
index 9c3cf2193c..548c20e122 100644
--- a/gui/src/main/notification-controller.ts
+++ b/gui/src/main/notification-controller.ts
@@ -1,13 +1,26 @@
-import { app, nativeImage, NativeImage, Notification, shell } from 'electron';
+import { app, nativeImage, NativeImage, Notification } from 'electron';
+import log from 'electron-log';
import os from 'os';
import path from 'path';
-import { sprintf } from 'sprintf-js';
-import config from '../config.json';
-import AccountExpiry from '../shared/account-expiry';
import { TunnelState } from '../shared/daemon-rpc-types';
-import { messages } from '../shared/gettext';
+import {
+ BlockWhenDisconnectedNotificationProvider,
+ ConnectedNotificationProvider,
+ ConnectingNotificationProvider,
+ DisconnectedNotificationProvider,
+ ErrorNotificationProvider,
+ ReconnectingNotificationProvider,
+ SystemNotification,
+ SystemNotificationProvider,
+} from '../shared/notifications/notification';
import consumePromise from '../shared/promise';
+interface NotificationControllerDelegate {
+ openLink(url: string, withAuth?: boolean): Promise<void>;
+ isWindowVisible(): boolean;
+ areSystemNotificationsEnabled(): boolean;
+}
+
export default class NotificationController {
private lastTunnelStateAnnouncement?: { body: string; notification: Notification };
private reconnecting = false;
@@ -16,7 +29,7 @@ export default class NotificationController {
private notificationTitle = process.platform === 'linux' ? app.name : '';
private notificationIcon?: NativeImage;
- constructor() {
+ constructor(private notificationControllerDelegate: NotificationControllerDelegate) {
let usePngIcon;
if (process.platform === 'linux') {
usePngIcon = true;
@@ -34,147 +47,34 @@ export default class NotificationController {
}
}
- public notifyTunnelState(tunnelState: TunnelState) {
- switch (tunnelState.state) {
- case 'connecting':
- if (!this.reconnecting) {
- const details = tunnelState.details;
- if (details && details.location && details.location.hostname) {
- const msg = sprintf(
- // TRANSLATORS: The message showed when a server is being connected to.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(location) - name of the server location we're connecting to (e.g. "se-got-003")
- messages.pgettext('notifications', 'Connecting to %(location)s'),
- {
- location: details.location.hostname,
- },
- );
- this.showTunnelStateNotification(msg);
- } else {
- this.showTunnelStateNotification(messages.pgettext('notifications', 'Connecting'));
- }
- }
- break;
- case 'connected':
- {
- const details = tunnelState.details;
- if (details.location && details.location.hostname) {
- const msg = sprintf(
- // TRANSLATORS: The message showed when a server has been connected to.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(location) - name of the server location we're connected to (e.g. "se-got-003")
- messages.pgettext('notifications', 'Connected to %(location)s'),
- {
- location: details.location.hostname,
- },
- );
- this.showTunnelStateNotification(msg);
- } else {
- this.showTunnelStateNotification(messages.pgettext('notifications', 'Secured'));
- }
- }
- break;
- case 'disconnected':
- this.showTunnelStateNotification(messages.pgettext('notifications', 'Unsecured'));
- break;
- case 'error':
- if (tunnelState.details.isBlocking) {
- if (
- tunnelState.details.cause.reason === 'tunnel_parameter_error' &&
- tunnelState.details.cause.details === 'no_wireguard_key'
- ) {
- this.showTunnelStateNotification(
- messages.pgettext(
- 'notifications',
- 'Blocking internet: Valid WireGuard key is missing',
- ),
- );
- } else {
- this.showTunnelStateNotification(
- messages.pgettext('notifications', 'Blocking internet'),
- );
- }
- } else {
- this.showTunnelStateNotification(
- messages.pgettext('notifications', 'Critical error (your attention is required)'),
- );
- }
- break;
- case 'disconnecting':
- switch (tunnelState.details) {
- case 'nothing':
- case 'block':
- // no-op
- break;
- case 'reconnect':
- this.showTunnelStateNotification(messages.pgettext('notifications', 'Reconnecting'));
- this.reconnecting = true;
- return;
- }
- break;
- }
+ public notifyTunnelState(tunnelState: TunnelState, blockWhenDisconnected: boolean) {
+ const notificationProviders: SystemNotificationProvider[] = [
+ new ConnectingNotificationProvider({ tunnelState, reconnecting: this.reconnecting }),
+ new ConnectedNotificationProvider(tunnelState),
+ new ReconnectingNotificationProvider(tunnelState),
+ new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }),
+ new DisconnectedNotificationProvider(tunnelState),
+ new ErrorNotificationProvider(tunnelState),
+ ];
- this.reconnecting = false;
- }
+ const notificationProvider = notificationProviders.find((notification) =>
+ notification.mayDisplay(),
+ );
- public notifyInconsistentVersion() {
- this.presentNotificationOnce('inconsistent-version', () => {
- const notification = new Notification({
- title: this.notificationTitle,
- body: messages.pgettext(
- 'notifications',
- 'Inconsistent internal version information, please restart the app',
- ),
- silent: true,
- icon: this.notificationIcon,
- });
- this.scheduleNotification(notification);
- });
- }
+ if (notificationProvider) {
+ const notification = notificationProvider.getSystemNotification();
- public notifyUnsupportedVersion(upgradeVersion: string) {
- this.presentNotificationOnce('unsupported-version', () => {
- const notification = new Notification({
- title: this.notificationTitle,
- body: sprintf(
- // TRANSLATORS: The system notification displayed to the user when the running app becomes unsupported.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(version) - the newest available version of the app
- messages.pgettext(
- 'notifications',
- 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
- ),
- {
- version: upgradeVersion,
- },
- ),
- silent: true,
- icon: this.notificationIcon,
- });
-
- notification.on('click', () => {
- consumePromise(shell.openExternal(config.links.download));
- });
-
- this.scheduleNotification(notification);
- });
- }
+ if (notification) {
+ this.showTunnelStateNotification(notification);
+ } else {
+ log.error(
+ `Notification providers mayDisplay() returned true but getSystemNotification() returned undefined for ${notificationProvider.constructor.name}`,
+ );
+ }
+ }
- public closeToExpiryNotification(accountExpiry: AccountExpiry) {
- const duration = accountExpiry.durationUntilExpiry();
- const notification = new Notification({
- title: this.notificationTitle,
- body: sprintf(
- // TRANSLATORS: The system notification displayed to the user when the account credit is close to expiry.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(duration)s - remaining time, e.g. "2 days"
- messages.pgettext('notifications', 'Account credit expires in %(duration)s'),
- { duration },
- ),
- silent: true,
- icon: this.notificationIcon,
- });
- this.scheduleNotification(notification);
+ this.reconnecting =
+ tunnelState.state === 'disconnecting' && tunnelState.details === 'reconnect';
}
public cancelPendingNotifications() {
@@ -187,47 +87,59 @@ export default class NotificationController {
this.lastTunnelStateAnnouncement = undefined;
}
- private showTunnelStateNotification(message: string) {
- const lastAnnouncement = this.lastTunnelStateAnnouncement;
- const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message;
+ public notify(systemNotification: SystemNotification) {
+ if (this.evaluateNotification(systemNotification)) {
+ const notification = this.createNotification(systemNotification);
+ this.addPendingNotification(notification);
+ notification.show();
- if (sameAsLastNotification) {
+ setTimeout(() => notification.close(), 4000);
+
+ return notification;
+ } else {
return;
}
+ }
- const newNotification = new Notification({
+ private createNotification(systemNotification: SystemNotification) {
+ const notification = new Notification({
title: this.notificationTitle,
- body: message,
+ body: systemNotification.message,
silent: true,
icon: this.notificationIcon,
});
- if (lastAnnouncement) {
- lastAnnouncement.notification.close();
+ if (systemNotification.action) {
+ const { withAuth, url } = systemNotification.action;
+ notification.on('click', () => {
+ consumePromise(this.notificationControllerDelegate.openLink(url, withAuth));
+ });
}
- this.lastTunnelStateAnnouncement = {
- body: message,
- notification: newNotification,
- };
-
- this.scheduleNotification(newNotification);
+ return notification;
}
- private presentNotificationOnce(notificationName: string, presentNotification: () => void) {
- const presented = this.presentedNotifications;
- if (!presented[notificationName]) {
- presented[notificationName] = true;
- presentNotification();
+ private showTunnelStateNotification(systemNotification: SystemNotification) {
+ const message = systemNotification.message;
+ const lastAnnouncement = this.lastTunnelStateAnnouncement;
+ const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message;
+
+ if (sameAsLastNotification) {
+ return;
}
- }
- private scheduleNotification(notification: Notification) {
- this.addPendingNotification(notification);
+ if (lastAnnouncement) {
+ lastAnnouncement.notification.close();
+ }
- notification.show();
+ const newNotification = this.notify(systemNotification);
- setTimeout(() => notification.close(), 4000);
+ if (newNotification) {
+ this.lastTunnelStateAnnouncement = {
+ body: message,
+ notification: newNotification,
+ };
+ }
}
private addPendingNotification(notification: Notification) {
@@ -244,4 +156,34 @@ export default class NotificationController {
this.pendingNotifications.splice(index, 1);
}
}
+
+ private evaluateNotification(notification: SystemNotification) {
+ const suppressDueToDevelopment =
+ notification.suppressInDevelopment && process.env.NODE_ENV === 'development';
+ const suppressDueToVisibleWindow = this.notificationControllerDelegate.isWindowVisible();
+ const suppressDueToPreference =
+ !this.notificationControllerDelegate.areSystemNotificationsEnabled() &&
+ !notification.critical;
+
+ return (
+ !suppressDueToDevelopment &&
+ !suppressDueToVisibleWindow &&
+ !suppressDueToPreference &&
+ !this.suppressDueToAlreadyPresented(notification)
+ );
+ }
+
+ private suppressDueToAlreadyPresented(notification: SystemNotification) {
+ const presented = this.presentedNotifications;
+ if (notification.presentOnce?.value) {
+ if (presented[notification.presentOnce.name]) {
+ return true;
+ } else {
+ presented[notification.presentOnce.name] = true;
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
}