diff options
Diffstat (limited to 'gui/src/main')
| -rw-r--r-- | gui/src/main/account.ts | 5 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 34 | ||||
| -rw-r--r-- | gui/src/main/notification-controller.ts | 241 | ||||
| -rw-r--r-- | gui/src/main/settings.ts | 6 | ||||
| -rw-r--r-- | gui/src/main/tray-icon-controller.ts | 122 | ||||
| -rw-r--r-- | gui/src/main/user-interface.ts | 18 | ||||
| -rw-r--r-- | gui/src/main/version.ts | 5 |
7 files changed, 268 insertions, 163 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); |
