summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-02-09 10:53:22 +0100
committerOskar Nyberg <oskar@mullvad.net>2023-02-09 10:53:22 +0100
commit9f9996b47029bceb9582ffdd5199a1db10287e8f (patch)
treea867bca5026dc75a4378437c7ebd017ebb9fbf7e /gui/src
parenta452a38ed484da45d483150ca35a38705b007dfc (diff)
parent8e4170b381f275f6aae787bffc624294b34be1b6 (diff)
downloadmullvadvpn-9f9996b47029bceb9582ffdd5199a1db10287e8f.tar.xz
mullvadvpn-9f9996b47029bceb9582ffdd5199a1db10287e8f.zip
Merge branch 'improve-notifications'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/account.ts5
-rw-r--r--gui/src/main/index.ts34
-rw-r--r--gui/src/main/notification-controller.ts241
-rw-r--r--gui/src/main/settings.ts6
-rw-r--r--gui/src/main/tray-icon-controller.ts122
-rw-r--r--gui/src/main/user-interface.ts18
-rw-r--r--gui/src/main/version.ts5
-rw-r--r--gui/src/shared/notifications/account-expired.ts10
-rw-r--r--gui/src/shared/notifications/block-when-disconnected.ts22
-rw-r--r--gui/src/shared/notifications/close-to-account-expiry.ts5
-rw-r--r--gui/src/shared/notifications/connected.ts12
-rw-r--r--gui/src/shared/notifications/connecting.ts9
-rw-r--r--gui/src/shared/notifications/disconnected.ts12
-rw-r--r--gui/src/shared/notifications/error.ts11
-rw-r--r--gui/src/shared/notifications/inconsistent-version.ts5
-rw-r--r--gui/src/shared/notifications/notification.ts18
-rw-r--r--gui/src/shared/notifications/reconnecting.ts9
-rw-r--r--gui/src/shared/notifications/unsupported-version.ts5
-rw-r--r--gui/src/shared/notifications/update-available.ts5
19 files changed, 370 insertions, 184 deletions
diff --git a/gui/src/main/account.ts b/gui/src/main/account.ts
index 00a3bf0dfd..564880fe61 100644
--- a/gui/src/main/account.ts
+++ b/gui/src/main/account.ts
@@ -1,3 +1,4 @@
+import { closeToExpiry } from '../shared/account-expiry';
import {
AccountToken,
DeviceEvent,
@@ -11,6 +12,7 @@ import log from '../shared/logging';
import {
AccountExpiredNotificationProvider,
CloseToAccountExpiryNotificationProvider,
+ SystemNotificationCategory,
} from '../shared/notifications/notification';
import { Scheduler } from '../shared/scheduler';
import AccountDataCache from './account-data-cache';
@@ -210,6 +212,9 @@ export default class Account {
const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now();
const delay = Math.min(twelveHours, remainingMilliseconds);
this.accountExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), delay);
+ } else if (!closeToExpiry(this.accountData.expiry)) {
+ // If no longer close to expiry, all previous notifications should be closed
+ this.delegate.closeNotificationsInCategory(SystemNotificationCategory.expiry);
}
}
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 40f9054958..7181f863bf 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -20,7 +20,10 @@ import { ITranslations, MacOsScrollbarVisibility } from '../shared/ipc-schema';
import { IChangelog, IHistoryObject } from '../shared/ipc-types';
import log, { ConsoleOutput, Logger } from '../shared/logging';
import { LogLevel } from '../shared/logging-types';
-import { SystemNotification } from '../shared/notifications/notification';
+import {
+ SystemNotification,
+ SystemNotificationCategory,
+} from '../shared/notifications/notification';
import Account, { AccountDelegate, LocaleProvider } from './account';
import { getOpenAtLogin } from './autostart';
import { readChangelog } from './changelog';
@@ -211,14 +214,6 @@ class ApplicationMain
public isLoggedIn = () => this.account.isLoggedIn();
- public notify = (notification: SystemNotification) => {
- this.notificationController.notify(
- notification,
- this.userInterface?.isWindowVisible() ?? false,
- this.settings.gui.enableSystemNotifications,
- );
- };
-
public disconnectAndQuit = async () => {
if (this.daemonRpc.isConnected) {
try {
@@ -316,6 +311,7 @@ class ApplicationMain
log.info('Quit initiated');
this.userInterface?.dispose();
+ this.notificationController.dispose();
// Unsubscribe the event handler
try {
@@ -945,12 +941,22 @@ class ApplicationMain
return shell.openExternal(url);
}
};
+ public showNotificationIcon = (value: boolean) => this.userInterface?.showNotificationIcon(value);
+
+ // NotificationSender
+ public notify = (notification: SystemNotification) => {
+ this.notificationController.notify(
+ notification,
+ this.userInterface?.isWindowVisible() ?? false,
+ this.settings.gui.enableSystemNotifications,
+ );
+ };
+ public closeNotificationsInCategory = (category: SystemNotificationCategory) =>
+ this.notificationController.closeNotificationsInCategory(category);
// UserInterfaceDelegate
- public cancelPendingNotifications = () =>
- this.notificationController.cancelPendingNotifications();
- public resetTunnelStateAnnouncements = () =>
- this.notificationController.resetTunnelStateAnnouncements();
+ public dismissActiveNotifications = () =>
+ this.notificationController.dismissActiveNotifications();
public isUnpinnedWindow = () => this.settings.gui.unpinnedWindow;
public updateAccountData = () => this.account.updateAccountData();
public getAccountData = () => this.account.accountData;
@@ -980,7 +986,7 @@ class ApplicationMain
// SettingsDelegate
public handleMonochromaticIconChange = (value: boolean) =>
- this.userInterface?.setUseMonochromaticTrayIcon(value) ?? Promise.resolve();
+ this.userInterface?.setMonochromaticIcon(value) ?? Promise.resolve();
public handleUnpinnedWindowChange = () =>
void this.userInterface?.recreateWindow(
this.account.isLoggedIn(),
diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts
index 3ea65fa5a0..6fbe6d4f19 100644
--- a/gui/src/main/notification-controller.ts
+++ b/gui/src/main/notification-controller.ts
@@ -1,4 +1,4 @@
-import { app, NativeImage, nativeImage, Notification } from 'electron';
+import { app, NativeImage, nativeImage, Notification as ElectronNotification } from 'electron';
import os from 'os';
import path from 'path';
@@ -12,23 +12,45 @@ import {
NotificationAction,
ReconnectingNotificationProvider,
SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from '../shared/notifications/notification';
+import { Scheduler } from '../shared/scheduler';
+
+const THROTTLE_DELAY = 500;
+
+export interface Notification {
+ specification: SystemNotification;
+ notification: ElectronNotification;
+}
export interface NotificationSender {
notify(notification: SystemNotification): void;
+ closeNotificationsInCategory(category: SystemNotificationCategory): void;
}
export interface NotificationControllerDelegate {
openApp(): void;
openLink(url: string, withAuth?: boolean): Promise<void>;
+ showNotificationIcon(value: boolean): void;
+}
+
+enum NotificationSuppressReason {
+ development,
+ windowVisible,
+ preference,
+ alreadyPresented,
}
export default class NotificationController {
- private lastTunnelStateAnnouncement?: { body: string; notification: Notification };
private reconnecting = false;
+
private presentedNotifications: { [key: string]: boolean } = {};
- private pendingNotifications: Notification[] = [];
+ private activeNotifications: Set<Notification> = new Set();
+ private dismissedNotifications: Set<SystemNotification> = new Set();
+ private throttledNotifications: Map<SystemNotification, Scheduler> = new Map();
+
private notificationTitle = process.platform === 'linux' ? app.name : '';
private notificationIcon?: NativeImage;
@@ -50,6 +72,13 @@ export default class NotificationController {
}
}
+ public dispose() {
+ this.throttledNotifications.forEach((scheduler) => scheduler.cancel());
+
+ this.activeNotifications.forEach((notification) => notification.notification.close());
+ this.activeNotifications.clear();
+ }
+
public notifyTunnelState(
tunnelState: TunnelState,
blockWhenDisconnected: boolean,
@@ -73,61 +102,119 @@ export default class NotificationController {
const notification = notificationProvider.getSystemNotification();
if (notification) {
- this.showTunnelStateNotification(
- notification,
- isWindowVisible,
- areSystemNotificationsEnabled,
- );
+ this.notify(notification, isWindowVisible, areSystemNotificationsEnabled);
} else {
log.error(
`Notification providers mayDisplay() returned true but getSystemNotification() returned undefined for ${notificationProvider.constructor.name}`,
);
}
+ } else {
+ this.closeNotificationsInCategory(SystemNotificationCategory.tunnelState);
}
this.reconnecting =
tunnelState.state === 'disconnecting' && tunnelState.details === 'reconnect';
}
- public cancelPendingNotifications() {
- for (const notification of this.pendingNotifications) {
- notification.close();
- }
+ // Closes still relevant notifications but still lets them affect notification dot in tray icon.
+ public dismissActiveNotifications() {
+ this.activeNotifications.forEach((notification) => {
+ notification.notification.close();
+ });
+ this.updateNotificationIcon();
}
- public resetTunnelStateAnnouncements() {
- this.lastTunnelStateAnnouncement = undefined;
+ public closeNotificationsInCategory(
+ category: SystemNotificationCategory,
+ severity?: SystemNotificationSeverityType,
+ ) {
+ this.activeNotifications.forEach((notification) => {
+ if (notification.specification.category === category) {
+ notification.notification.close();
+ }
+ });
+ this.dismissedNotifications.forEach((notification) => {
+ if (
+ notification.category === category &&
+ (severity === undefined || severity >= notification.severity)
+ ) {
+ this.dismissedNotifications.delete(notification);
+ }
+ });
+ this.updateNotificationIcon();
}
public notify(
systemNotification: SystemNotification,
- isWindowVisible: boolean,
- areSystemNotificationsEnabled: boolean,
+ windowVisible: boolean,
+ infoNotificationsEnabled: boolean,
) {
- if (
- this.evaluateNotification(systemNotification, isWindowVisible, areSystemNotificationsEnabled)
- ) {
- const notification = this.createNotification(systemNotification);
- this.addPendingNotification(notification);
- notification.show();
-
- if (!systemNotification.critical) {
- setTimeout(() => notification.close(), 4000);
+ const notificationSuppressReason = this.evaluateNotification(
+ systemNotification,
+ windowVisible,
+ infoNotificationsEnabled,
+ );
+ if (notificationSuppressReason !== undefined) {
+ if (
+ notificationSuppressReason === NotificationSuppressReason.preference ||
+ notificationSuppressReason === NotificationSuppressReason.windowVisible
+ ) {
+ this.dismissedNotifications.add(systemNotification);
+ this.updateNotificationIcon();
}
- return notification;
- } else {
return;
}
+
+ // Cancel throttled notifications within the same category
+ if (systemNotification.category !== undefined) {
+ this.throttledNotifications.forEach((scheduler, specification) => {
+ if (specification.category === systemNotification.category) {
+ scheduler.cancel();
+ this.throttledNotifications.delete(specification);
+ }
+ });
+ }
+
+ if (systemNotification.throttle) {
+ const scheduler = new Scheduler();
+ scheduler.schedule(() => {
+ this.throttledNotifications.delete(systemNotification);
+ this.notifyImpl(systemNotification);
+ }, THROTTLE_DELAY);
+
+ this.throttledNotifications.set(systemNotification, scheduler);
+ } else {
+ this.notifyImpl(systemNotification);
+ }
+ }
+
+ private notifyImpl(systemNotification: SystemNotification): Notification {
+ // Remove notifications in the same category if specified
+ if (systemNotification.category !== undefined) {
+ this.closeNotificationsInCategory(systemNotification.category, systemNotification.severity);
+ }
+
+ const notification = this.createNotification(systemNotification);
+ this.addActiveNotification(notification);
+ notification.notification.show();
+
+ // Close notification of low severity automatically
+ if (systemNotification.severity === SystemNotificationSeverityType.info) {
+ setTimeout(() => notification.notification.close(), 4000);
+ }
+
+ return notification;
}
- private createNotification(systemNotification: SystemNotification) {
- const notification = new Notification({
+ private createNotification(systemNotification: SystemNotification): Notification {
+ const notification = new ElectronNotification({
title: this.notificationTitle,
body: systemNotification.message,
silent: true,
icon: this.notificationIcon,
- timeoutType: systemNotification.critical ? 'never' : 'default',
+ timeoutType:
+ systemNotification.severity == SystemNotificationSeverityType.high ? 'never' : 'default',
});
// Action buttons are only available on macOS.
@@ -137,7 +224,12 @@ export default class NotificationController {
notification.on('action', () => this.performAction(systemNotification.action));
}
notification.on('click', () => this.notificationControllerDelegate.openApp());
- } else if (!(process.platform === 'win32' && systemNotification.critical)) {
+ } else if (
+ !(
+ process.platform === 'win32' &&
+ systemNotification.severity === SystemNotificationSeverityType.high
+ )
+ ) {
if (systemNotification.action) {
notification.on('click', () => this.performAction(systemNotification.action));
} else {
@@ -145,7 +237,7 @@ export default class NotificationController {
}
}
- return notification;
+ return { specification: systemNotification, notification };
}
private performAction(action?: NotificationAction) {
@@ -154,68 +246,53 @@ export default class NotificationController {
}
}
- private showTunnelStateNotification(
- systemNotification: SystemNotification,
- isWindowVisible: boolean,
- areSystemNotificationsEnabled: boolean,
- ) {
- const message = systemNotification.message;
- const lastAnnouncement = this.lastTunnelStateAnnouncement;
- const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message;
-
- if (sameAsLastNotification) {
- return;
- }
+ private addActiveNotification(notification: Notification) {
+ notification.notification.on('close', () => {
+ this.dismissedNotifications.add({ ...notification.specification });
+ this.activeNotifications.delete(notification);
+ this.updateNotificationIcon();
+ });
+ this.activeNotifications.add(notification);
+ this.updateNotificationIcon();
+ }
- if (lastAnnouncement) {
- lastAnnouncement.notification.close();
+ private updateNotificationIcon() {
+ for (const notification of this.activeNotifications) {
+ if (notification.specification.severity >= SystemNotificationSeverityType.medium) {
+ this.notificationControllerDelegate.showNotificationIcon(true);
+ return;
+ }
}
- const newNotification = this.notify(
- systemNotification,
- isWindowVisible,
- areSystemNotificationsEnabled,
- );
-
- if (newNotification) {
- this.lastTunnelStateAnnouncement = {
- body: message,
- notification: newNotification,
- };
+ for (const notification of this.dismissedNotifications) {
+ if (notification.severity >= SystemNotificationSeverityType.medium) {
+ this.notificationControllerDelegate.showNotificationIcon(true);
+ return;
+ }
}
- }
-
- private addPendingNotification(notification: Notification) {
- notification.on('close', () => {
- this.removePendingNotification(notification);
- });
-
- this.pendingNotifications.push(notification);
- }
- private removePendingNotification(notification: Notification) {
- const index = this.pendingNotifications.indexOf(notification);
- if (index !== -1) {
- this.pendingNotifications.splice(index, 1);
- }
+ this.notificationControllerDelegate.showNotificationIcon(false);
}
private evaluateNotification(
notification: SystemNotification,
isWindowVisible: boolean,
areSystemNotificationsEnabled: boolean,
- ) {
- const suppressDueToDevelopment =
- notification.suppressInDevelopment && process.env.NODE_ENV === 'development';
- const suppressDueToVisibleWindow = isWindowVisible;
- const suppressDueToPreference = !areSystemNotificationsEnabled && !notification.critical;
+ ): NotificationSuppressReason | undefined {
+ if (notification.suppressInDevelopment && process.env.NODE_ENV === 'development') {
+ return NotificationSuppressReason.development;
+ } else if (isWindowVisible) {
+ return NotificationSuppressReason.windowVisible;
+ } else if (
+ !areSystemNotificationsEnabled &&
+ notification.severity >= SystemNotificationSeverityType.low
+ ) {
+ return NotificationSuppressReason.preference;
+ } else if (this.suppressDueToAlreadyPresented(notification)) {
+ return NotificationSuppressReason.alreadyPresented;
+ }
- return (
- !suppressDueToDevelopment &&
- !suppressDueToVisibleWindow &&
- !suppressDueToPreference &&
- !this.suppressDueToAlreadyPresented(notification)
- );
+ return undefined;
}
private suppressDueToAlreadyPresented(notification: SystemNotification) {
diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts
index 26fd14e0fa..634a9109ee 100644
--- a/gui/src/main/settings.ts
+++ b/gui/src/main/settings.ts
@@ -9,7 +9,7 @@ import GuiSettings from './gui-settings';
import { IpcMainEventChannel } from './ipc-event-channel';
export interface SettingsDelegate {
- handleMonochromaticIconChange(value: boolean): Promise<void>;
+ handleMonochromaticIconChange(value: boolean): void;
handleUnpinnedWindowChange(): void;
}
@@ -139,9 +139,9 @@ export default class Settings implements Readonly<ISettings> {
}
private registerGuiSettingsListener() {
- this.guiSettings.onChange = async (newState, oldState) => {
+ this.guiSettings.onChange = (newState, oldState) => {
if (oldState.monochromaticIcon !== newState.monochromaticIcon) {
- await this.delegate.handleMonochromaticIconChange(newState.monochromaticIcon);
+ this.delegate.handleMonochromaticIconChange(newState.monochromaticIcon);
}
if (newState.autoConnect !== oldState.autoConnect) {
diff --git a/gui/src/main/tray-icon-controller.ts b/gui/src/main/tray-icon-controller.ts
index cee57c0161..df474bdc1b 100644
--- a/gui/src/main/tray-icon-controller.ts
+++ b/gui/src/main/tray-icon-controller.ts
@@ -10,24 +10,23 @@ const exec = promisify(execAsync);
export type TrayIconType = 'unsecured' | 'securing' | 'secured';
-type IconSets = {
- regular: NativeImage[];
- template: NativeImage[];
- white: NativeImage[];
- black: NativeImage[];
-};
+type IconParameters = { monochromatic: boolean; notification: boolean };
export default class TrayIconController {
private animation?: KeyframeAnimation;
- private iconSets: IconSets = { regular: [], template: [], white: [], black: [] };
private iconSet: NativeImage[] = [];
+ private iconParameters: IconParameters;
+
+ private updateThrottlePromise?: Promise<void>;
constructor(
private tray: Tray,
private iconTypeValue: TrayIconType,
- private useMonochromaticIconValue: boolean,
+ monochromaticIcon: boolean,
+ notificationIcon: boolean,
) {
- this.loadImages();
+ this.iconParameters = { monochromatic: monochromaticIcon, notification: notificationIcon };
+ void this.updateTheme();
}
public dispose() {
@@ -41,39 +40,25 @@ export default class TrayIconController {
return this.iconTypeValue;
}
- public async updateTheme() {
- if (this.useMonochromaticIconValue) {
- switch (process.platform) {
- case 'darwin':
- this.iconSet = this.iconSets.template;
- break;
- case 'win32': {
- if (await this.getSystemUsesLightTheme()) {
- this.iconSet = this.iconSets.black;
- } else {
- this.iconSet = this.iconSets.white;
- }
- break;
- }
- case 'linux':
- default:
- this.iconSet = this.iconSets.white;
- break;
- }
- } else {
- this.iconSet = this.iconSets.regular;
- }
+ public updateTheme(): Promise<void> {
+ // For some reason the icon doesn't update if the iconSet is changed to quickly. Adding a
+ // throttle fixes this issue.
+ this.updateThrottlePromise ??= new Promise((resolve) => {
+ setTimeout(() => {
+ this.updateThrottlePromise = undefined;
+ void this.updateThemeImpl().then(resolve);
+ }, 200);
+ });
- if (this.animation === undefined) {
- this.initAnimation();
- } else if (!this.animation.isRunning) {
- this.animation.play({ end: this.targetFrame() });
- }
+ return this.updateThrottlePromise;
}
- public async setUseMonochromaticIcon(useMonochromaticIcon: boolean) {
- this.useMonochromaticIconValue = useMonochromaticIcon;
- await this.updateTheme();
+ public setMonochromaticIcon(monochromaticIcon: boolean) {
+ void this.updateIconParameters({ monochromatic: monochromaticIcon });
+ }
+
+ public showNotificationIcon(notificationIcon: boolean) {
+ void this.updateIconParameters({ notification: notificationIcon });
}
public animateToIcon(type: TrayIconType) {
@@ -89,6 +74,30 @@ export default class TrayIconController {
animation.play({ end: frame });
}
+ private async updateThemeImpl() {
+ const systemUsesLightTheme = await this.getSystemUsesLightTheme();
+ this.iconSet = this.loadImages(systemUsesLightTheme);
+
+ if (this.animation === undefined) {
+ this.initAnimation();
+ } else if (!this.animation.isRunning) {
+ this.animation.play({ end: this.targetFrame() });
+ }
+ }
+
+ // This function uses a promise as a lock to prevent multiple simultaneous updates
+ private updateIconParameters(parameters: Partial<IconParameters>) {
+ if (
+ (parameters.monochromatic !== undefined &&
+ parameters.monochromatic !== this.iconParameters.monochromatic) ||
+ (parameters.notification !== undefined &&
+ parameters.notification !== this.iconParameters.notification)
+ ) {
+ this.iconParameters = { ...this.iconParameters, ...parameters };
+ void this.updateTheme();
+ }
+ }
+
private initAnimation() {
const initialFrame = this.targetFrame();
const animation = new KeyframeAnimation();
@@ -108,21 +117,22 @@ export default class TrayIconController {
}
};
- private loadImages() {
- this.iconSets.regular = this.loadImageSet('');
-
- switch (process.platform) {
- case 'darwin':
- this.iconSets.template = this.loadImageSet('Template');
- break;
- case 'win32':
- this.iconSets.white = this.loadImageSet('_white');
- this.iconSets.black = this.loadImageSet('_black');
- break;
- case 'linux':
- default:
- this.iconSets.white = this.loadImageSet('_white');
- break;
+ private loadImages(systemUsesLightTheme?: boolean): NativeImage[] {
+ const notificationIcon = this.iconParameters.notification ? '_notification' : '';
+ if (this.iconParameters.monochromatic) {
+ switch (process.platform) {
+ case 'darwin':
+ return this.loadImageSet(`${notificationIcon}Template`);
+ case 'win32':
+ return systemUsesLightTheme
+ ? this.loadImageSet(`_black${notificationIcon}`)
+ : this.loadImageSet(`_white${notificationIcon}`);
+ case 'linux':
+ default:
+ return this.loadImageSet(`_white${notificationIcon}`);
+ }
+ } else {
+ return this.loadImageSet(notificationIcon);
}
}
@@ -138,6 +148,10 @@ export default class TrayIconController {
}
private async getSystemUsesLightTheme(): Promise<boolean | undefined> {
+ if (process.platform !== 'win32') {
+ return undefined;
+ }
+
try {
// This registry entry contains information about the tray background color. This is
// needed to decide between white and black icons.
diff --git a/gui/src/main/user-interface.ts b/gui/src/main/user-interface.ts
index 78af002c58..bc239cec2c 100644
--- a/gui/src/main/user-interface.ts
+++ b/gui/src/main/user-interface.ts
@@ -25,8 +25,7 @@ import WindowController, { WindowControllerDelegate } from './window-controller'
const execAsync = promisify(exec);
export interface UserInterfaceDelegate {
- cancelPendingNotifications(): void;
- resetTunnelStateAnnouncements(): void;
+ dismissActiveNotifications(): void;
updateAccountData(): void;
connectTunnel(): void;
reconnectTunnel(): void;
@@ -91,7 +90,7 @@ export default class UserInterface implements WindowControllerDelegate {
monochromaticIcon: boolean,
) {
const iconType = this.trayIconType(tunnelState, blockWhenDisconnected);
- this.trayIconController = new TrayIconController(this.tray, iconType, monochromaticIcon);
+ this.trayIconController = new TrayIconController(this.tray, iconType, monochromaticIcon, false);
}
public async initializeWindow(isLoggedIn: boolean, tunnelState: TunnelState) {
@@ -185,9 +184,11 @@ export default class UserInterface implements WindowControllerDelegate {
public reloadWindow = () => this.windowController.window?.reload();
public isWindowVisible = () => this.windowController.isVisible();
public showWindow = () => this.windowController.show();
- public updateTrayTheme = () => this.trayIconController?.updateTheme();
- public setUseMonochromaticTrayIcon = (value: boolean) =>
- this.trayIconController?.setUseMonochromaticIcon(value);
+ public updateTrayTheme = () => this.trayIconController?.updateTheme() ?? Promise.resolve();
+ public setMonochromaticIcon = (value: boolean) =>
+ this.trayIconController?.setMonochromaticIcon(value);
+ public showNotificationIcon = (value: boolean) =>
+ this.trayIconController?.showNotificationIcon(value);
public setWindowIcon = (icon: string) => this.windowController.window?.setIcon(icon);
public updateTrayIcon(tunnelState: TunnelState, blockWhenDisconnected: boolean) {
@@ -319,7 +320,7 @@ export default class UserInterface implements WindowControllerDelegate {
this.blurNavigationResetScheduler.cancel();
// cancel notifications when window appears
- this.delegate.cancelPendingNotifications();
+ this.delegate.dismissActiveNotifications();
const accountData = this.delegate.getAccountData();
if (!accountData || closeToExpiry(accountData.expiry, 4) || hasExpired(accountData.expiry)) {
@@ -329,9 +330,6 @@ export default class UserInterface implements WindowControllerDelegate {
this.windowController.window?.on('blur', () => {
IpcMainEventChannel.window.notifyFocus?.(false);
-
- // ensure notification guard is reset
- this.delegate.resetTunnelStateAnnouncements();
});
// Use hide instead of blur to prevent the navigation reset from happening when bluring an
diff --git a/gui/src/main/version.ts b/gui/src/main/version.ts
index 5e45203c6c..fbe5b64574 100644
--- a/gui/src/main/version.ts
+++ b/gui/src/main/version.ts
@@ -5,6 +5,7 @@ import { ICurrentAppVersionInfo } from '../shared/ipc-types';
import log from '../shared/logging';
import {
InconsistentVersionNotificationProvider,
+ SystemNotificationCategory,
UnsupportedVersionNotificationProvider,
UpdateAvailableNotificationProvider,
} from '../shared/notifications/notification';
@@ -65,6 +66,8 @@ export default class Version {
});
if (notificationProvider.mayDisplay()) {
this.delegate.notify(notificationProvider.getSystemNotification());
+ } else {
+ this.delegate.closeNotificationsInCategory(SystemNotificationCategory.inconsistentVersion);
}
// notify renderer
@@ -105,6 +108,8 @@ export default class Version {
);
if (notificationProvider) {
this.delegate.notify(notificationProvider.getSystemNotification());
+ } else {
+ this.delegate.closeNotificationsInCategory(SystemNotificationCategory.newVersion);
}
IpcMainEventChannel.upgradeVersion.notify?.(upgradeVersion);
diff --git a/gui/src/shared/notifications/account-expired.ts b/gui/src/shared/notifications/account-expired.ts
index cbfc782eeb..a7af4f2c8b 100644
--- a/gui/src/shared/notifications/account-expired.ts
+++ b/gui/src/shared/notifications/account-expired.ts
@@ -2,7 +2,12 @@ import { links } from '../../config.json';
import { hasExpired } from '../account-expiry';
import { TunnelState } from '../daemon-rpc-types';
import { messages } from '../gettext';
-import { SystemNotification, SystemNotificationProvider } from './notification';
+import {
+ SystemNotification,
+ SystemNotificationCategory,
+ SystemNotificationProvider,
+ SystemNotificationSeverityType,
+} from './notification';
interface AccountExpiredNotificaitonContext {
accountExpiry: string;
@@ -23,7 +28,8 @@ export class AccountExpiredNotificationProvider implements SystemNotificationPro
public getSystemNotification(): SystemNotification {
return {
message: messages.pgettext('notifications', 'Account is out of time'),
- critical: true,
+ category: SystemNotificationCategory.expiry,
+ severity: SystemNotificationSeverityType.high,
presentOnce: { value: true, name: this.constructor.name },
action: {
type: 'open-url',
diff --git a/gui/src/shared/notifications/block-when-disconnected.ts b/gui/src/shared/notifications/block-when-disconnected.ts
index 91b464a299..c0b2f4e0f1 100644
--- a/gui/src/shared/notifications/block-when-disconnected.ts
+++ b/gui/src/shared/notifications/block-when-disconnected.ts
@@ -3,7 +3,14 @@ import { sprintf } from 'sprintf-js';
import { strings } from '../../config.json';
import { messages } from '../../shared/gettext';
import { TunnelState } from '../daemon-rpc-types';
-import { InAppNotification, InAppNotificationProvider } from './notification';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationCategory,
+ SystemNotificationProvider,
+ SystemNotificationSeverityType,
+} from './notification';
interface BlockWhenDisconnectedNotificationContext {
tunnelState: TunnelState;
@@ -11,7 +18,8 @@ interface BlockWhenDisconnectedNotificationContext {
hasExcludedApps: boolean;
}
-export class BlockWhenDisconnectedNotificationProvider implements InAppNotificationProvider {
+export class BlockWhenDisconnectedNotificationProvider
+ implements InAppNotificationProvider, SystemNotificationProvider {
public constructor(private context: BlockWhenDisconnectedNotificationContext) {}
public mayDisplay() {
@@ -22,6 +30,16 @@ export class BlockWhenDisconnectedNotificationProvider implements InAppNotificat
);
}
+ public getSystemNotification(): SystemNotification {
+ const message = messages.pgettext('notifications', 'Lockdown mode active, connection blocked');
+
+ return {
+ message,
+ severity: SystemNotificationSeverityType.info,
+ category: SystemNotificationCategory.tunnelState,
+ };
+ }
+
public getInAppNotification(): InAppNotification {
const lockdownModeSettingName = messages.pgettext('vpn-settings-view', 'Lockdown mode');
let subtitle = sprintf(
diff --git a/gui/src/shared/notifications/close-to-account-expiry.ts b/gui/src/shared/notifications/close-to-account-expiry.ts
index 82daf05562..a3f5e749ad 100644
--- a/gui/src/shared/notifications/close-to-account-expiry.ts
+++ b/gui/src/shared/notifications/close-to-account-expiry.ts
@@ -8,7 +8,9 @@ import {
InAppNotification,
InAppNotificationProvider,
SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface CloseToAccountExpiryNotificationContext {
@@ -38,7 +40,8 @@ export class CloseToAccountExpiryNotificationProvider
return {
message,
- critical: true,
+ category: SystemNotificationCategory.expiry,
+ severity: SystemNotificationSeverityType.medium,
action: {
type: 'open-url',
url: links.purchase,
diff --git a/gui/src/shared/notifications/connected.ts b/gui/src/shared/notifications/connected.ts
index 5fc667b78c..c66339fe9e 100644
--- a/gui/src/shared/notifications/connected.ts
+++ b/gui/src/shared/notifications/connected.ts
@@ -2,14 +2,19 @@ import { sprintf } from 'sprintf-js';
import { messages } from '../../shared/gettext';
import { TunnelState } from '../daemon-rpc-types';
-import { SystemNotificationProvider } from './notification';
+import {
+ SystemNotification,
+ SystemNotificationCategory,
+ SystemNotificationProvider,
+ SystemNotificationSeverityType,
+} from './notification';
export class ConnectedNotificationProvider implements SystemNotificationProvider {
public constructor(private context: TunnelState) {}
public mayDisplay = () => this.context.state === 'connected';
- public getSystemNotification() {
+ public getSystemNotification(): SystemNotification | undefined {
if (this.context.state === 'connected') {
let message = messages.pgettext('notifications', 'Connected');
const location = this.context.details.location?.hostname;
@@ -27,7 +32,8 @@ export class ConnectedNotificationProvider implements SystemNotificationProvider
return {
message,
- critical: false,
+ severity: SystemNotificationSeverityType.info,
+ category: SystemNotificationCategory.tunnelState,
};
} else {
return undefined;
diff --git a/gui/src/shared/notifications/connecting.ts b/gui/src/shared/notifications/connecting.ts
index b35d9ed3b6..214e015e10 100644
--- a/gui/src/shared/notifications/connecting.ts
+++ b/gui/src/shared/notifications/connecting.ts
@@ -5,7 +5,10 @@ import { TunnelState } from '../daemon-rpc-types';
import {
InAppNotification,
InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface ConnectingNotificationContext {
@@ -21,7 +24,7 @@ export class ConnectingNotificationProvider
return this.context.tunnelState.state === 'connecting' && !this.context.reconnecting;
}
- public getSystemNotification() {
+ public getSystemNotification(): SystemNotification | undefined {
if (this.context.tunnelState.state === 'connecting') {
let message = messages.pgettext('notifications', 'Connecting');
const location = this.context.tunnelState.details?.location?.hostname;
@@ -39,7 +42,9 @@ export class ConnectingNotificationProvider
return {
message,
- critical: false,
+ severity: SystemNotificationSeverityType.info,
+ category: SystemNotificationCategory.tunnelState,
+ throttle: true,
};
} else {
return undefined;
diff --git a/gui/src/shared/notifications/disconnected.ts b/gui/src/shared/notifications/disconnected.ts
index 05c53148ec..874cb11b3e 100644
--- a/gui/src/shared/notifications/disconnected.ts
+++ b/gui/src/shared/notifications/disconnected.ts
@@ -1,6 +1,11 @@
import { messages } from '../../shared/gettext';
import { TunnelState } from '../daemon-rpc-types';
-import { SystemNotificationProvider } from './notification';
+import {
+ SystemNotification,
+ SystemNotificationCategory,
+ SystemNotificationProvider,
+ SystemNotificationSeverityType,
+} from './notification';
interface DisconnectedNotificationContext {
tunnelState: TunnelState;
@@ -13,10 +18,11 @@ export class DisconnectedNotificationProvider implements SystemNotificationProvi
public mayDisplay = () =>
this.context.tunnelState.state === 'disconnected' && !this.context.blockWhenDisconnected;
- public getSystemNotification() {
+ public getSystemNotification(): SystemNotification | undefined {
return {
message: messages.pgettext('notifications', 'Disconnected and unsecure'),
- critical: false,
+ severity: SystemNotificationSeverityType.info,
+ category: SystemNotificationCategory.tunnelState,
};
}
}
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
index fa1848a0fb..67267a494e 100644
--- a/gui/src/shared/notifications/error.ts
+++ b/gui/src/shared/notifications/error.ts
@@ -12,7 +12,10 @@ import { messages } from '../gettext';
import {
InAppNotification,
InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface ErrorNotificationContext {
@@ -26,7 +29,7 @@ export class ErrorNotificationProvider
public mayDisplay = () => this.context.tunnelState.state === 'error';
- public getSystemNotification() {
+ public getSystemNotification(): SystemNotification | undefined {
if (this.context.tunnelState.state === 'error') {
let message = getMessage(this.context.tunnelState.details);
if (!this.context.tunnelState.details.blockingError && this.context.hasExcludedApps) {
@@ -41,7 +44,11 @@ export class ErrorNotificationProvider
return {
message,
- critical: !!this.context.tunnelState.details.blockingError,
+ severity:
+ this.context.tunnelState.details.blockingError === undefined
+ ? SystemNotificationSeverityType.low
+ : SystemNotificationSeverityType.high,
+ category: SystemNotificationCategory.tunnelState,
};
} else {
return undefined;
diff --git a/gui/src/shared/notifications/inconsistent-version.ts b/gui/src/shared/notifications/inconsistent-version.ts
index e6118c883e..f4a5616c43 100644
--- a/gui/src/shared/notifications/inconsistent-version.ts
+++ b/gui/src/shared/notifications/inconsistent-version.ts
@@ -3,7 +3,9 @@ import {
InAppNotification,
InAppNotificationProvider,
SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface InconsistentVersionNotificationContext {
@@ -19,7 +21,8 @@ export class InconsistentVersionNotificationProvider
public getSystemNotification(): SystemNotification {
return {
message: messages.pgettext('notifications', 'App is out of sync. Please quit and restart.'),
- critical: true,
+ category: SystemNotificationCategory.inconsistentVersion,
+ severity: SystemNotificationSeverityType.high,
presentOnce: { value: true, name: this.constructor.name },
suppressInDevelopment: true,
};
diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts
index 2152da0e79..10b5b21fc8 100644
--- a/gui/src/shared/notifications/notification.ts
+++ b/gui/src/shared/notifications/notification.ts
@@ -7,13 +7,29 @@ export type NotificationAction = {
export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error';
+export enum SystemNotificationSeverityType {
+ info = 0,
+ low,
+ medium,
+ high,
+}
+
+export enum SystemNotificationCategory {
+ tunnelState,
+ expiry,
+ newVersion,
+ inconsistentVersion,
+}
+
interface NotificationProvider {
mayDisplay(): boolean;
}
export interface SystemNotification {
message: string;
- critical: boolean;
+ severity: SystemNotificationSeverityType;
+ category: SystemNotificationCategory;
+ throttle?: boolean;
presentOnce?: { value: boolean; name: string };
suppressInDevelopment?: boolean;
action?: NotificationAction;
diff --git a/gui/src/shared/notifications/reconnecting.ts b/gui/src/shared/notifications/reconnecting.ts
index 9c20cad8c9..43491322f0 100644
--- a/gui/src/shared/notifications/reconnecting.ts
+++ b/gui/src/shared/notifications/reconnecting.ts
@@ -3,7 +3,10 @@ import { TunnelState } from '../daemon-rpc-types';
import {
InAppNotification,
InAppNotificationProvider,
+ SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
export class ReconnectingNotificationProvider
@@ -14,10 +17,12 @@ export class ReconnectingNotificationProvider
return this.context.state === 'disconnecting' && this.context.details === 'reconnect';
}
- public getSystemNotification() {
+ public getSystemNotification(): SystemNotification | undefined {
return {
message: messages.pgettext('notifications', 'Reconnecting'),
- critical: false,
+ severity: SystemNotificationSeverityType.info,
+ category: SystemNotificationCategory.tunnelState,
+ throttle: true,
};
}
diff --git a/gui/src/shared/notifications/unsupported-version.ts b/gui/src/shared/notifications/unsupported-version.ts
index 3db1db9fc7..8c2f87460b 100644
--- a/gui/src/shared/notifications/unsupported-version.ts
+++ b/gui/src/shared/notifications/unsupported-version.ts
@@ -4,7 +4,9 @@ import {
InAppNotification,
InAppNotificationProvider,
SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface UnsupportedVersionNotificationContext {
@@ -25,7 +27,8 @@ export class UnsupportedVersionNotificationProvider
public getSystemNotification(): SystemNotification {
return {
message: this.getMessage(),
- critical: true,
+ category: SystemNotificationCategory.newVersion,
+ severity: SystemNotificationSeverityType.high,
action: {
type: 'open-url',
url: this.context.suggestedIsBeta ? links.betaDownload : links.download,
diff --git a/gui/src/shared/notifications/update-available.ts b/gui/src/shared/notifications/update-available.ts
index 61a2452138..0394ef15a5 100644
--- a/gui/src/shared/notifications/update-available.ts
+++ b/gui/src/shared/notifications/update-available.ts
@@ -6,7 +6,9 @@ import {
InAppNotification,
InAppNotificationProvider,
SystemNotification,
+ SystemNotificationCategory,
SystemNotificationProvider,
+ SystemNotificationSeverityType,
} from './notification';
interface UpdateAvailableNotificationContext {
@@ -39,7 +41,8 @@ export class UpdateAvailableNotificationProvider
public getSystemNotification(): SystemNotification {
return {
message: this.systemMessage(),
- critical: false,
+ category: SystemNotificationCategory.newVersion,
+ severity: SystemNotificationSeverityType.medium,
action: {
type: 'open-url',
url: this.context.suggestedIsBeta ? links.betaDownload : links.download,