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