summaryrefslogtreecommitdiffhomepage
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
parentd7fdccb58e8205ae26023629873922979644bfdd (diff)
downloadmullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.tar.xz
mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.zip
Categorize notifications and their logic into notification definition
-rw-r--r--gui/src/main/index.ts81
-rw-r--r--gui/src/main/notification-controller.ts276
-rw-r--r--gui/src/renderer/components/Connect.tsx6
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx376
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx5
-rw-r--r--gui/src/renderer/containers/NotificationAreaContainer.tsx30
-rw-r--r--gui/src/shared/account-expiry.ts10
-rw-r--r--gui/src/shared/auth-failure.ts (renamed from gui/src/renderer/lib/auth-failure.ts)2
-rw-r--r--gui/src/shared/notifications/accountExpiry.ts56
-rw-r--r--gui/src/shared/notifications/blockWhenDisconnected.ts40
-rw-r--r--gui/src/shared/notifications/connected.ts32
-rw-r--r--gui/src/shared/notifications/connecting.ts51
-rw-r--r--gui/src/shared/notifications/disconnected.ts16
-rw-r--r--gui/src/shared/notifications/error.ts125
-rw-r--r--gui/src/shared/notifications/inconsistentVersion.ts41
-rw-r--r--gui/src/shared/notifications/notification.ts41
-rw-r--r--gui/src/shared/notifications/reconnecting.ts30
-rw-r--r--gui/src/shared/notifications/unsupportedVersion.ts65
-rw-r--r--gui/src/shared/notifications/updateAvailable.ts37
-rw-r--r--gui/test/auth-failure.spec.ts2
20 files changed, 783 insertions, 539 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;
+ }
+ }
}
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index f4070c459f..4acab681bc 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -3,8 +3,8 @@ import { Component, Styles, View } from 'reactxp';
import styled from 'styled-components';
import AccountExpiry from '../../shared/account-expiry';
import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer';
-import NotificationAreaContainer from '../containers/NotificationAreaContainer';
-import { AuthFailureKind, parseAuthFailure } from '../lib/auth-failure';
+import NotificationArea from '../components/NotificationArea';
+import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure';
import { LoginState } from '../redux/account/reducers';
import { IConnectionReduxState } from '../redux/connection/reducers';
import { Brand, HeaderBarStyle, SettingsBarButton } from './HeaderBar';
@@ -165,7 +165,7 @@ export default class Connect extends Component<IProps, IState> {
onSelectLocation={this.props.onSelectLocation}
/>
- <NotificationAreaContainer style={styles.notificationArea} />
+ <NotificationArea style={styles.notificationArea} />
</View>
</View>
);
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
index 01607c3840..bc76ba0885 100644
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -1,8 +1,23 @@
-import moment from 'moment';
-import * as React from 'react';
-import { Component, Types } from 'reactxp';
-import { sprintf } from 'sprintf-js';
-import { messages } from '../../shared/gettext';
+import { shell } from 'electron';
+import log from 'electron-log';
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { Types } from 'reactxp';
+import AccountExpiry from '../../shared/account-expiry';
+import {
+ AccountExpiryNotificationProvider,
+ BlockWhenDisconnectedNotificationProvider,
+ ConnectingNotificationProvider,
+ ErrorNotificationProvider,
+ InAppNotificationProvider,
+ InconsistentVersionNotificationProvider,
+ NotificationAction,
+ ReconnectingNotificationProvider,
+ UnsupportedVersionNotificationProvider,
+ UpdateAvailableNotificationProvider,
+} from '../../shared/notifications/notification';
+import { useAppContext } from '../context';
+import { IReduxState } from '../redux/store';
import {
NotificationActions,
NotificationBanner,
@@ -13,313 +28,82 @@ import {
NotificationTitle,
} from './NotificationBanner';
-import AccountExpiry from '../../shared/account-expiry';
-import { ErrorStateCause, TunnelParameterError, TunnelState } from '../../shared/daemon-rpc-types';
-import { parseAuthFailure } from '../lib/auth-failure';
-import { IVersionReduxState } from '../redux/version/reducers';
-
interface IProps {
style?: Types.ViewStyleRuleSet;
- accountExpiry?: AccountExpiry;
- tunnelState: TunnelState;
- version: IVersionReduxState;
- blockWhenDisconnected: boolean;
- onOpenDownloadLink: () => Promise<void>;
- onOpenBuyMoreLink: () => Promise<void>;
}
-type NotificationAreaPresentation =
- | { type: 'failure-unsecured'; reason: string }
- | { type: 'blocking'; reason: string }
- | { type: 'inconsistent-version' }
- | { type: 'unsupported-version'; upgradeVersion: string }
- | { type: 'update-available'; upgradeVersion: string }
- | { type: 'expires-soon'; timeLeft: string };
+export default function NotificationArea(props: IProps) {
+ const accountExpiry = useSelector((state: IReduxState) =>
+ state.account.expiry
+ ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
+ : undefined,
+ );
+ const tunnelState = useSelector((state: IReduxState) => state.connection.status);
+ const version = useSelector((state: IReduxState) => state.version);
+ const blockWhenDisconnected = useSelector(
+ (state: IReduxState) => state.settings.blockWhenDisconnected,
+ );
-type State = NotificationAreaPresentation & {
- visible: boolean;
-};
+ const notificationProviders: InAppNotificationProvider[] = [
+ new ConnectingNotificationProvider({ tunnelState }),
+ new ReconnectingNotificationProvider(tunnelState),
+ new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }),
+ new ErrorNotificationProvider(tunnelState),
+ new InconsistentVersionNotificationProvider({ consistent: version.consistent }),
+ new UnsupportedVersionNotificationProvider(version),
+ new UpdateAvailableNotificationProvider(version),
+ ];
-function getTunnelParameterMessage(err: TunnelParameterError): string {
- switch (err) {
- /// TODO: once bridge constraints can be set, add a more descriptive error message
- case 'no_matching_bridge_relay':
- case 'no_matching_relay':
- return messages.pgettext(
- 'in-app-notifications',
- 'No relay server matches the current settings. You can try changing the location or the relay settings.',
- );
- case 'no_wireguard_key':
- return messages.pgettext(
- 'in-app-notifications',
- 'Valid WireGuard key is missing. Manage keys under Advanced settings.',
- );
- case 'custom_tunnel_host_resultion_error':
- return messages.pgettext(
- 'in-app-notifications',
- 'Failed to resolve host of custom tunnel. Consider changing the settings',
- );
+ if (accountExpiry) {
+ notificationProviders.push(new AccountExpiryNotificationProvider({ accountExpiry }));
}
-}
-function getErrorCauseMessage(blockReason: ErrorStateCause): string {
- switch (blockReason.reason) {
- case 'auth_failed':
- return parseAuthFailure(blockReason.details).message;
- case 'ipv6_unavailable':
- return messages.pgettext(
- 'in-app-notifications',
- 'Could not configure IPv6, please enable it on your system or disable it in the app',
- );
- case 'set_firewall_policy_error': {
- let extraMessage = null;
- switch (process.platform) {
- case 'linux':
- extraMessage = messages.pgettext('in-app-notifications', 'Your kernel may be outdated');
- break;
- case 'win32':
- extraMessage = messages.pgettext(
- 'in-app-notifications',
- 'This might be caused by third party security software',
- );
- break;
- }
- return `${messages.pgettext(
- 'in-app-notifications',
- 'Failed to apply firewall rules. The device might currently be unsecured',
- )}${extraMessage ? '. ' + extraMessage : ''}`;
- }
- case 'set_dns_error':
- return messages.pgettext('in-app-notifications', 'Failed to set system DNS server');
- case 'start_tunnel_error':
- return messages.pgettext('in-app-notifications', 'Failed to start tunnel connection');
- case 'tunnel_parameter_error':
- return getTunnelParameterMessage(blockReason.details);
- case 'is_offline':
- return messages.pgettext(
- 'in-app-notifications',
- 'This device is offline, no tunnels can be established',
+ const notificationProvider = notificationProviders.find((notification) =>
+ notification.mayDisplay(),
+ );
+
+ if (notificationProvider) {
+ const notification = notificationProvider.getInAppNotification();
+
+ if (notification) {
+ return (
+ <NotificationBanner style={props.style} visible>
+ <NotificationIndicator type={notification.indicator} />
+ <NotificationContent>
+ <NotificationTitle>{notification.title}</NotificationTitle>
+ <NotificationSubtitle>{notification.subtitle}</NotificationSubtitle>
+ </NotificationContent>
+ {notification.action && <NotificationActionWrapper action={notification.action} />}
+ </NotificationBanner>
);
- case 'tap_adapter_problem':
- return messages.pgettext(
- 'in-app-notifications',
- "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app",
+ } else {
+ log.error(
+ `Notification providers mayDisplay() returned true but getInAppNotification() returned undefined for ${notificationProvider.constructor.name}`,
);
+ }
}
-}
-function capitalizeFirstLetter(inputString: string): string {
- return inputString.charAt(0).toUpperCase() + inputString.slice(1);
+ return <NotificationBanner style={props.style} visible={false} />;
}
-export default class NotificationArea extends Component<IProps, State> {
- public static getDerivedStateFromProps(props: IProps, state: State) {
- const { accountExpiry, blockWhenDisconnected, tunnelState, version } = props;
-
- switch (tunnelState.state) {
- case 'connecting':
- return {
- visible: true,
- type: 'blocking',
- reason: '',
- };
-
- case 'error':
- if (tunnelState.details.isBlocking) {
- return {
- visible: true,
- type: 'blocking',
- reason: getErrorCauseMessage(tunnelState.details.cause),
- };
- } else {
- return {
- visible: true,
- type: 'failure-unsecured',
- reason: getErrorCauseMessage(tunnelState.details.cause),
- };
- }
-
- case 'disconnecting':
- if (tunnelState.details === 'reconnect') {
- return {
- visible: true,
- type: 'blocking',
- reason: '',
- };
- }
- // fallthrough
-
- case 'disconnected':
- if (blockWhenDisconnected) {
- return {
- visible: true,
- type: 'blocking',
- reason: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'),
- };
- }
- // fallthrough
-
- default:
- if (!version.consistent) {
- return {
- visible: true,
- type: 'inconsistent-version',
- };
- }
-
- if (!version.supported && version.nextUpgrade) {
- return {
- visible: true,
- type: 'unsupported-version',
- upgradeVersion: version.nextUpgrade,
- };
- }
-
- if (version.nextUpgrade && version.nextUpgrade !== version.current) {
- return {
- visible: true,
- type: 'update-available',
- upgradeVersion: version.nextUpgrade,
- };
- }
+interface INotificationActionWrapperProps {
+ action: NotificationAction;
+}
- if (accountExpiry && accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate())) {
- return {
- visible: true,
- type: 'expires-soon',
- timeLeft: capitalizeFirstLetter(accountExpiry.remainingTime()),
- };
- }
+function NotificationActionWrapper(props: INotificationActionWrapperProps) {
+ const { openLinkWithAuth } = useAppContext();
- return {
- ...state,
- visible: false,
- };
+ const handlePress = useCallback(() => {
+ if (props.action.withAuth) {
+ return openLinkWithAuth(props.action.url);
+ } else {
+ return shell.openExternal(props.action.url);
}
- }
+ }, []);
- public state: State = {
- type: 'blocking',
- reason: '',
- visible: false,
- };
-
- public render() {
- return (
- <NotificationBanner style={this.props.style} visible={this.state.visible}>
- {this.state.type === 'failure-unsecured' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC')}
- </NotificationTitle>
- <NotificationSubtitle>
- {messages.pgettext(
- 'in-app-notifications',
- 'Failed to block all network traffic. Please troubleshoot or report the problem to us.',
- )}
- </NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'blocking' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'BLOCKING INTERNET')}
- </NotificationTitle>
- <NotificationSubtitle>{this.state.reason}</NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'inconsistent-version' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'INCONSISTENT VERSION')}
- </NotificationTitle>
- <NotificationSubtitle>
- {messages.pgettext(
- 'in-app-notifications',
- 'Inconsistent internal version information, please restart the app',
- )}
- </NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'unsupported-version' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION')}
- </NotificationTitle>
- <NotificationSubtitle>
- {sprintf(
- // TRANSLATORS: The in-app banner displayed to the user when the running app becomes unsupported.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - the newest available version of the app
- messages.pgettext(
- 'in-app-notifications',
- 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
- ),
- { version: this.state.upgradeVersion },
- )}
- </NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenDownloadLink} />
- </NotificationActions>
- </React.Fragment>
- )}
-
- {this.state.type === 'update-available' && (
- <React.Fragment>
- <NotificationIndicator type={'warning'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE')}
- </NotificationTitle>
- <NotificationSubtitle>
- {sprintf(
- // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - the newest available version of the app
- messages.pgettext(
- 'in-app-notifications',
- 'Install Mullvad VPN (%(version)s) to stay up to date',
- ),
- { version: this.state.upgradeVersion },
- )}
- </NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenDownloadLink} />
- </NotificationActions>
- </React.Fragment>
- )}
-
- {this.state.type === 'expires-soon' && (
- <React.Fragment>
- <NotificationIndicator type={'warning'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON')}
- </NotificationTitle>
- <NotificationSubtitle>{this.state.timeLeft}</NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenBuyMoreLink} />
- </NotificationActions>
- </React.Fragment>
- )}
- </NotificationBanner>
- );
- }
+ return (
+ <NotificationActions>
+ <NotificationOpenLinkAction onPress={handlePress} />
+ </NotificationActions>
+ );
}
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
index 5831676fcf..207c3f6081 100644
--- a/gui/src/renderer/components/NotificationBanner.tsx
+++ b/gui/src/renderer/components/NotificationBanner.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp';
import { colors } from '../../config.json';
+import { InAppNotificationIndicatorType } from '../../shared/notifications/notification';
import consumePromise from '../../shared/promise';
import { BlockingButton } from './AppButton';
import ImageView from './ImageView';
@@ -151,7 +152,7 @@ export class NotificationActions extends Component<INotificationActionsProps> {
}
interface INotificationIndicatorProps {
- type: 'success' | 'warning' | 'error';
+ type: InAppNotificationIndicatorType;
children?: React.ReactNode;
}
@@ -162,7 +163,7 @@ export class NotificationIndicator extends Component<INotificationIndicatorProps
}
interface INotificationBannerProps {
- children: React.ReactNode; // Array<NotificationContent | NotificationActions>,
+ children?: React.ReactNode; // Array<NotificationContent | NotificationActions>,
style?: Types.ViewStyleRuleSet;
visible: boolean;
animationDuration: number;
diff --git a/gui/src/renderer/containers/NotificationAreaContainer.tsx b/gui/src/renderer/containers/NotificationAreaContainer.tsx
deleted file mode 100644
index 1eed9f4a17..0000000000
--- a/gui/src/renderer/containers/NotificationAreaContainer.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { connect } from 'react-redux';
-
-import { shell } from 'electron';
-import { links } from '../../config.json';
-import AccountExpiry from '../../shared/account-expiry';
-import NotificationArea from '../components/NotificationArea';
-import withAppContext, { IAppContext } from '../context';
-import { IReduxState, ReduxDispatch } from '../redux/store';
-
-const mapStateToProps = (state: IReduxState, _props: IAppContext) => ({
- accountExpiry: state.account.expiry
- ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
- : undefined,
- tunnelState: state.connection.status,
- version: state.version,
- blockWhenDisconnected: state.settings.blockWhenDisconnected,
-});
-
-const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IAppContext) => {
- return {
- onOpenDownloadLink(): Promise<void> {
- return shell.openExternal(links.download);
- },
- onOpenBuyMoreLink(): Promise<void> {
- return props.app.openLinkWithAuth(links.purchase);
- },
- };
-};
-
-export default withAppContext(connect(mapStateToProps, mapDispatchToProps)(NotificationArea));
diff --git a/gui/src/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts
index 44c62a613e..d6acb0de91 100644
--- a/gui/src/shared/account-expiry.ts
+++ b/gui/src/shared/account-expiry.ts
@@ -40,15 +40,21 @@ export default class AccountExpiry {
}
}
- public remainingTime(): string {
+ public remainingTime(shouldCapitalizeFirstLetter?: boolean): string {
const duration = this.durationUntilExpiry();
- return sprintf(
+ const remaining = sprintf(
// TRANSLATORS: The remaining time left on the account displayed across the app.
// TRANSLATORS: Available placeholders:
// TRANSLATORS: %(duration)s - a localized remaining time (in minutes, hours, or days) until the account expiry
messages.pgettext('account-expiry', '%(duration)s left'),
{ duration },
);
+
+ return shouldCapitalizeFirstLetter ? capitalizeFirstLetter(remaining) : remaining;
}
}
+
+function capitalizeFirstLetter(inputString: string): string {
+ return inputString.charAt(0).toUpperCase() + inputString.slice(1);
+}
diff --git a/gui/src/renderer/lib/auth-failure.ts b/gui/src/shared/auth-failure.ts
index ead0b08a81..bbd990bf89 100644
--- a/gui/src/renderer/lib/auth-failure.ts
+++ b/gui/src/shared/auth-failure.ts
@@ -1,4 +1,4 @@
-import { messages } from '../../shared/gettext';
+import { messages } from './gettext';
export enum AuthFailureKind {
invalidAccount,
diff --git a/gui/src/shared/notifications/accountExpiry.ts b/gui/src/shared/notifications/accountExpiry.ts
new file mode 100644
index 0000000000..b749fb55c1
--- /dev/null
+++ b/gui/src/shared/notifications/accountExpiry.ts
@@ -0,0 +1,56 @@
+import moment from 'moment';
+import { sprintf } from 'sprintf-js';
+import { links } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import AccountExpiry from '../account-expiry';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationProvider,
+} from './notification';
+
+interface AccountExpiryContext {
+ accountExpiry: AccountExpiry;
+ tooSoon?: boolean;
+}
+
+export class AccountExpiryNotificationProvider
+ implements InAppNotificationProvider, SystemNotificationProvider {
+ public constructor(private context: AccountExpiryContext) {}
+
+ public mayDisplay() {
+ return (
+ !this.context.accountExpiry.hasExpired() &&
+ this.context.accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate()) &&
+ !this.context.tooSoon
+ );
+ }
+
+ public getSystemNotification(): SystemNotification {
+ const message = 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: this.context.accountExpiry.remainingTime(),
+ },
+ );
+
+ return {
+ message,
+ critical: true,
+ action: { type: 'open-url', url: links.purchase, withAuth: true },
+ };
+ }
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'warning',
+ title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'),
+ subtitle: this.context.accountExpiry.remainingTime(true),
+ action: { type: 'open-url', url: links.purchase, withAuth: true },
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/blockWhenDisconnected.ts b/gui/src/shared/notifications/blockWhenDisconnected.ts
new file mode 100644
index 0000000000..16b65c0672
--- /dev/null
+++ b/gui/src/shared/notifications/blockWhenDisconnected.ts
@@ -0,0 +1,40 @@
+import { messages } from '../../shared/gettext';
+import { TunnelState } from '../daemon-rpc-types';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotificationProvider,
+} from './notification';
+
+interface BlockWhenDisconnectedNotificationContext {
+ tunnelState: TunnelState;
+ blockWhenDisconnected: boolean;
+}
+
+export class BlockWhenDisconnectedNotificationProvider
+ implements InAppNotificationProvider, SystemNotificationProvider {
+ public constructor(private context: BlockWhenDisconnectedNotificationContext) {}
+
+ public mayDisplay() {
+ return (
+ (this.context.tunnelState.state === 'disconnecting' ||
+ this.context.tunnelState.state === 'disconnected') &&
+ this.context.blockWhenDisconnected
+ );
+ }
+
+ public getSystemNotification() {
+ return {
+ message: messages.pgettext('notifications', 'Blocking internet'),
+ critical: false,
+ };
+ }
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'error',
+ title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
+ subtitle: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'),
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/connected.ts b/gui/src/shared/notifications/connected.ts
new file mode 100644
index 0000000000..07f7f26ee9
--- /dev/null
+++ b/gui/src/shared/notifications/connected.ts
@@ -0,0 +1,32 @@
+import { sprintf } from 'sprintf-js';
+import { messages } from '../../shared/gettext';
+import { TunnelState } from '../daemon-rpc-types';
+import { SystemNotificationProvider } from './notification';
+
+export class ConnectedNotificationProvider implements SystemNotificationProvider {
+ public constructor(private context: TunnelState) {}
+
+ public mayDisplay = () => this.context.state === 'connected';
+
+ public getSystemNotification() {
+ if (this.context.state === 'connected') {
+ let message = messages.pgettext('notifications', 'Secured');
+ const location = this.context.details.location?.hostname;
+ if (location) {
+ // 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")
+ message = sprintf(messages.pgettext('notifications', 'Connected to %(location)s'), {
+ location,
+ });
+ }
+
+ return {
+ message,
+ critical: false,
+ };
+ } else {
+ return undefined;
+ }
+ }
+}
diff --git a/gui/src/shared/notifications/connecting.ts b/gui/src/shared/notifications/connecting.ts
new file mode 100644
index 0000000000..ac8c049c67
--- /dev/null
+++ b/gui/src/shared/notifications/connecting.ts
@@ -0,0 +1,51 @@
+import { sprintf } from 'sprintf-js';
+import { messages } from '../../shared/gettext';
+import { TunnelState } from '../daemon-rpc-types';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotificationProvider,
+} from './notification';
+
+interface ConnectingNotificationContext {
+ tunnelState: TunnelState;
+ reconnecting?: boolean;
+}
+
+export class ConnectingNotificationProvider
+ implements SystemNotificationProvider, InAppNotificationProvider {
+ public constructor(private context: ConnectingNotificationContext) {}
+
+ public mayDisplay() {
+ return this.context.tunnelState.state === 'connecting' && !this.context.reconnecting;
+ }
+
+ public getSystemNotification() {
+ if (this.context.tunnelState.state === 'connecting') {
+ let message = messages.pgettext('notifications', 'Connecting');
+ const location = this.context.tunnelState.details?.location?.hostname;
+ if (location) {
+ // 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")
+ message = sprintf(messages.pgettext('notifications', 'Connecting to %(location)s'), {
+ location,
+ });
+ }
+
+ return {
+ message,
+ critical: false,
+ };
+ } else {
+ return undefined;
+ }
+ }
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'error',
+ title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/disconnected.ts b/gui/src/shared/notifications/disconnected.ts
new file mode 100644
index 0000000000..52d6ba96db
--- /dev/null
+++ b/gui/src/shared/notifications/disconnected.ts
@@ -0,0 +1,16 @@
+import { messages } from '../../shared/gettext';
+import { TunnelState } from '../daemon-rpc-types';
+import { SystemNotificationProvider } from './notification';
+
+export class DisconnectedNotificationProvider implements SystemNotificationProvider {
+ public constructor(private context: TunnelState) {}
+
+ public mayDisplay = () => this.context.state === 'disconnected';
+
+ public getSystemNotification() {
+ return {
+ message: messages.pgettext('notifications', 'Unsecured'),
+ critical: false,
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
new file mode 100644
index 0000000000..a4ba97c81d
--- /dev/null
+++ b/gui/src/shared/notifications/error.ts
@@ -0,0 +1,125 @@
+import { parseAuthFailure } from '../auth-failure';
+import { IErrorState, TunnelState, TunnelParameterError } from '../daemon-rpc-types';
+import { messages } from '../gettext';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotificationProvider,
+} from './notification';
+
+export class ErrorNotificationProvider
+ implements SystemNotificationProvider, InAppNotificationProvider {
+ public constructor(private context: TunnelState) {}
+
+ public mayDisplay = () => this.context.state === 'error';
+
+ public getSystemNotification() {
+ return this.context.state === 'error'
+ ? {
+ message: getSystemNotificationMessage(this.context),
+ critical: !this.context.details.isBlocking,
+ }
+ : undefined;
+ }
+
+ public getInAppNotification(): InAppNotification | undefined {
+ return this.context.state === 'error'
+ ? {
+ indicator: 'error',
+ title: this.context.details.isBlocking
+ ? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET')
+ : messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC'),
+ subtitle: getInAppNotificationSubtitle(this.context),
+ }
+ : undefined;
+ }
+}
+
+function getSystemNotificationMessage(tunnelState: { state: 'error'; details: IErrorState }) {
+ if (!tunnelState.details.isBlocking) {
+ return messages.pgettext('notifications', 'Critical error (your attention is required)');
+ } else if (
+ tunnelState.details.cause.reason === 'tunnel_parameter_error' &&
+ tunnelState.details.cause.details === 'no_wireguard_key'
+ ) {
+ return messages.pgettext('notifications', 'Blocking internet: Valid WireGuard key is missing');
+ } else {
+ return messages.pgettext('notifications', 'Blocking internet');
+ }
+}
+
+function getInAppNotificationSubtitle(tunnelState: { state: 'error'; details: IErrorState }) {
+ if (!tunnelState.details.isBlocking) {
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'Failed to block all network traffic. Please troubleshoot or report the problem to us.',
+ );
+ } else {
+ const blockReason = tunnelState.details.cause;
+ switch (blockReason.reason) {
+ case 'auth_failed':
+ return parseAuthFailure(blockReason.details).message;
+ case 'ipv6_unavailable':
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'Could not configure IPv6, please enable it on your system or disable it in the app',
+ );
+ case 'set_firewall_policy_error': {
+ let extraMessage = null;
+ switch (process.platform) {
+ case 'linux':
+ extraMessage = messages.pgettext('in-app-notifications', 'Your kernel may be outdated');
+ break;
+ case 'win32':
+ extraMessage = messages.pgettext(
+ 'in-app-notifications',
+ 'This might be caused by third party security software',
+ );
+ break;
+ }
+ return `${messages.pgettext(
+ 'in-app-notifications',
+ 'Failed to apply firewall rules. The device might currently be unsecured',
+ )}${extraMessage ? '. ' + extraMessage : ''}`;
+ }
+ case 'set_dns_error':
+ return messages.pgettext('in-app-notifications', 'Failed to set system DNS server');
+ case 'start_tunnel_error':
+ return messages.pgettext('in-app-notifications', 'Failed to start tunnel connection');
+ case 'tunnel_parameter_error':
+ return getTunnelParameterMessage(blockReason.details);
+ case 'is_offline':
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'This device is offline, no tunnels can be established',
+ );
+ case 'tap_adapter_problem':
+ return messages.pgettext(
+ 'in-app-notifications',
+ "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app",
+ );
+ }
+ }
+}
+
+function getTunnelParameterMessage(err: TunnelParameterError): string {
+ switch (err) {
+ /// TODO: once bridge constraints can be set, add a more descriptive error message
+ case 'no_matching_bridge_relay':
+ case 'no_matching_relay':
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'No relay server matches the current settings. You can try changing the location or the relay settings.',
+ );
+ case 'no_wireguard_key':
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'Valid WireGuard key is missing. Manage keys under Advanced settings.',
+ );
+ case 'custom_tunnel_host_resultion_error':
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'Failed to resolve host of custom tunnel. Consider changing the settings',
+ );
+ }
+}
diff --git a/gui/src/shared/notifications/inconsistentVersion.ts b/gui/src/shared/notifications/inconsistentVersion.ts
new file mode 100644
index 0000000000..94c33bd925
--- /dev/null
+++ b/gui/src/shared/notifications/inconsistentVersion.ts
@@ -0,0 +1,41 @@
+import { messages } from '../../shared/gettext';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationProvider,
+} from './notification';
+
+interface InconsistentVersionNotificationContext {
+ consistent: boolean;
+}
+
+export class InconsistentVersionNotificationProvider
+ implements SystemNotificationProvider, InAppNotificationProvider {
+ public constructor(private context: InconsistentVersionNotificationContext) {}
+
+ public mayDisplay = () => !this.context.consistent;
+
+ public getSystemNotification(): SystemNotification {
+ return {
+ message: messages.pgettext(
+ 'notifications',
+ 'Inconsistent internal version information, please restart the app',
+ ),
+ critical: true,
+ presentOnce: { value: true, name: this.constructor.name },
+ suppressInDevelopment: true,
+ };
+ }
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'error',
+ title: messages.pgettext('in-app-notifications', 'INCONSISTENT VERSION'),
+ subtitle: messages.pgettext(
+ 'in-app-notifications',
+ 'Inconsistent internal version information, please restart the app',
+ ),
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts
new file mode 100644
index 0000000000..e7d89e65bb
--- /dev/null
+++ b/gui/src/shared/notifications/notification.ts
@@ -0,0 +1,41 @@
+export type NotificationAction = { type: 'open-url'; url: string; withAuth?: boolean };
+
+export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error';
+
+interface NotificationProvider {
+ mayDisplay(): boolean;
+}
+
+export interface SystemNotification {
+ message: string;
+ critical: boolean;
+ presentOnce?: { value: boolean; name: string };
+ suppressInDevelopment?: boolean;
+ action?: NotificationAction;
+}
+
+export interface InAppNotification {
+ indicator: InAppNotificationIndicatorType;
+ title: string;
+ subtitle?: string;
+ action?: NotificationAction;
+}
+
+export interface SystemNotificationProvider extends NotificationProvider {
+ getSystemNotification(): SystemNotification | undefined;
+}
+
+export interface InAppNotificationProvider extends NotificationProvider {
+ getInAppNotification(): InAppNotification | undefined;
+}
+
+export * from './accountExpiry';
+export * from './blockWhenDisconnected';
+export * from './connected';
+export * from './connecting';
+export * from './disconnected';
+export * from './error';
+export * from './inconsistentVersion';
+export * from './reconnecting';
+export * from './unsupportedVersion';
+export * from './updateAvailable';
diff --git a/gui/src/shared/notifications/reconnecting.ts b/gui/src/shared/notifications/reconnecting.ts
new file mode 100644
index 0000000000..2b328aeab2
--- /dev/null
+++ b/gui/src/shared/notifications/reconnecting.ts
@@ -0,0 +1,30 @@
+import { messages } from '../../shared/gettext';
+import { TunnelState } from '../daemon-rpc-types';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotificationProvider,
+} from './notification';
+
+export class ReconnectingNotificationProvider
+ implements SystemNotificationProvider, InAppNotificationProvider {
+ public constructor(private context: TunnelState) {}
+
+ public mayDisplay() {
+ return this.context.state === 'disconnecting' && this.context.details === 'reconnect';
+ }
+
+ public getSystemNotification() {
+ return {
+ message: messages.pgettext('notifications', 'Reconnecting'),
+ critical: false,
+ };
+ }
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'error',
+ title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/unsupportedVersion.ts b/gui/src/shared/notifications/unsupportedVersion.ts
new file mode 100644
index 0000000000..ed471bc586
--- /dev/null
+++ b/gui/src/shared/notifications/unsupportedVersion.ts
@@ -0,0 +1,65 @@
+import { sprintf } from 'sprintf-js';
+import { links } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import {
+ InAppNotification,
+ SystemNotification,
+ InAppNotificationProvider,
+ SystemNotificationProvider,
+} from './notification';
+
+interface UnsupportedVersionNotificationContext {
+ supported: boolean;
+ consistent: boolean;
+ nextUpgrade: string | null;
+}
+
+export class UnsupportedVersionNotificationProvider
+ implements SystemNotificationProvider, InAppNotificationProvider {
+ public constructor(private context: UnsupportedVersionNotificationContext) {}
+
+ public mayDisplay() {
+ return this.context.consistent && !this.context.supported && this.context.nextUpgrade !== null;
+ }
+
+ public getSystemNotification(): SystemNotification {
+ const message = 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: this.context.nextUpgrade },
+ );
+
+ return {
+ message,
+ critical: true,
+ action: { type: 'open-url', url: links.download },
+ presentOnce: { value: true, name: this.constructor.name },
+ suppressInDevelopment: true,
+ };
+ }
+
+ public getInAppNotification(): InAppNotification {
+ const subtitle = sprintf(
+ // TRANSLATORS: The in-app banner displayed to the user when the running app becomes unsupported.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - the newest available version of the app
+ messages.pgettext(
+ 'in-app-notifications',
+ 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
+ ),
+ { version: this.context.nextUpgrade },
+ );
+
+ return {
+ indicator: 'error',
+ title: messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION'),
+ subtitle,
+ action: { type: 'open-url', url: links.download },
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/updateAvailable.ts b/gui/src/shared/notifications/updateAvailable.ts
new file mode 100644
index 0000000000..4d449bff28
--- /dev/null
+++ b/gui/src/shared/notifications/updateAvailable.ts
@@ -0,0 +1,37 @@
+import { sprintf } from 'sprintf-js';
+import { links } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import { InAppNotification, InAppNotificationProvider } from './notification';
+
+interface UpdateAvailableNotificationContext {
+ current: string;
+ nextUpgrade: string | null;
+}
+
+export class UpdateAvailableNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: UpdateAvailableNotificationContext) {}
+
+ public mayDisplay() {
+ return this.context.nextUpgrade !== null && this.context.nextUpgrade !== this.context.current;
+ }
+
+ public getInAppNotification(): InAppNotification {
+ const subtitle = sprintf(
+ // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - the newest available version of the app
+ messages.pgettext(
+ 'in-app-notifications',
+ 'Install Mullvad VPN (%(version)s) to stay up to date',
+ ),
+ { version: this.context.nextUpgrade },
+ );
+
+ return {
+ indicator: 'warning',
+ title: messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE'),
+ subtitle,
+ action: { type: 'open-url', url: links.download },
+ };
+ }
+}
diff --git a/gui/test/auth-failure.spec.ts b/gui/test/auth-failure.spec.ts
index eaa08c4a1e..9f0072c499 100644
--- a/gui/test/auth-failure.spec.ts
+++ b/gui/test/auth-failure.spec.ts
@@ -1,6 +1,6 @@
import { expect } from 'chai';
import { it, describe } from 'mocha';
-import { parseAuthFailure, AuthFailureKind } from '../src/renderer/lib/auth-failure';
+import { parseAuthFailure, AuthFailureKind } from '../src/shared/auth-failure';
describe('auth_failed parsing', () => {
it('invalid line parsing works', () => {