diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-11-16 14:33:36 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-11-20 10:48:22 +0100 |
| commit | 07b0c8747a23e7973b26e65e4015f4c8a81f0758 (patch) | |
| tree | abc48cdacdc890e85de5c87861866109a29e80d7 | |
| parent | a9d5f93836279dd7c1b14ddcf7449422bd7dd788 (diff) | |
| download | mullvadvpn-07b0c8747a23e7973b26e65e4015f4c8a81f0758.tar.xz mullvadvpn-07b0c8747a23e7973b26e65e4015f4c8a81f0758.zip | |
Move notifications and version fetch to the main process
| -rw-r--r-- | gui/flow-libs/electron.js.flow | 37 | ||||
| -rw-r--r-- | gui/flow-libs/notification.js.flow | 51 | ||||
| -rwxr-xr-x | gui/packages/desktop/electron-builder.yml | 1 | ||||
| -rw-r--r-- | gui/packages/desktop/src/main/index.js | 185 | ||||
| -rw-r--r-- | gui/packages/desktop/src/main/notification-controller.js (renamed from gui/packages/desktop/src/renderer/lib/notification-controller.js) | 52 | ||||
| -rw-r--r-- | gui/packages/desktop/src/main/window-controller.js | 4 | ||||
| -rw-r--r-- | gui/packages/desktop/src/renderer/app.js | 116 |
7 files changed, 266 insertions, 180 deletions
diff --git a/gui/flow-libs/electron.js.flow b/gui/flow-libs/electron.js.flow index bc37e5a524..52df468272 100644 --- a/gui/flow-libs/electron.js.flow +++ b/gui/flow-libs/electron.js.flow @@ -197,6 +197,43 @@ declare module 'electron' { popup(browserWindow?: BrowserWindow, options?: PopupOptions): void; } + // https://electronjs.org/docs/api/structures/notification-action + declare type NotificationAction = { + type: 'button', + text?: string + }; + + // https://electronjs.org/docs/api/notification + declare type NotificationOptions = { + title?: string, + subtitle?: string, + body: string, + silent?: boolean, + icon?: string | NativeImage, + hasReply?: boolean, + replyPlaceholder?: string, + sound?: string, + actions?: Array<NotificationAction>, + closeButtonText?: string + }; + + declare class Notification extends EventEmitter { + static isSupported(): boolean; + constructor(options: NotificationOptions): void; + show(): void; + close(): void; + title: string; + subtitle: string; + body: string; + silent: boolean; + hasReply: boolean; + replyPlaceholder: string; + sound: string; + actions: Array<NotificationAction>, + closeButtonText: string; + } + + // http://electron.atom.io/docs/api/app declare class App extends EventEmitter { diff --git a/gui/flow-libs/notification.js.flow b/gui/flow-libs/notification.js.flow deleted file mode 100644 index bf5dda3af2..0000000000 --- a/gui/flow-libs/notification.js.flow +++ /dev/null @@ -1,51 +0,0 @@ -/* Notification */ -type NotificationPermission = 'default' | 'denied' | 'granted'; -type NotificationDirection = 'auto' | 'ltr' | 'rtl'; -type VibratePattern = number | Array<number>; -type NotificationAction = { action: string, title: string, icon?: string }; -type NotificationOptions = { - dir: NotificationDirection, - lang: string, - body: string, - tag: string, - image: string, - icon: string, - badge: string, - sound: string, - vibrate: VibratePattern, - timestamp: number, - renotify: boolean, - silent: boolean, - requireInteraction: boolean, - data: ?any, - actions: Array<NotificationAction>, -}; - -declare class Notification extends EventTarget { - constructor(title: string, options?: $Shape<NotificationOptions>): void; - static permission: NotificationPermission; - static requestPermission( - callback?: (perm: NotificationPermission) => mixed, - ): Promise<NotificationPermission>; - static maxActions: number; - onclick: (evt: Event) => any; - onerror: (evt: Event) => any; - title: string; - dir: NotificationDirection; - lang: string; - body: string; - tag: string; - image: string; - icon: string; - badge: string; - sound: string; - vibrate: Array<number>; - timestamp: number; - renotify: boolean; - silent: boolean; - requireInteraction: boolean; - data: any; - actions: Array<NotificationAction>; - - close(): void; -} diff --git a/gui/packages/desktop/electron-builder.yml b/gui/packages/desktop/electron-builder.yml index d08895d848..c5124623cf 100755 --- a/gui/packages/desktop/electron-builder.yml +++ b/gui/packages/desktop/electron-builder.yml @@ -37,6 +37,7 @@ mac: category: public.app-category.tools extendInfo: LSUIElement: true + NSUserNotificationAlertStyle: alert extraResources: - from: ../../../target/release/mullvad to: . diff --git a/gui/packages/desktop/src/main/index.js b/gui/packages/desktop/src/main/index.js index 6de9df40a5..3c1d76459e 100644 --- a/gui/packages/desktop/src/main/index.js +++ b/gui/packages/desktop/src/main/index.js @@ -8,6 +8,7 @@ import mkdirp from 'mkdirp'; import uuid from 'uuid'; import { app, screen, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'; +import NotificationController from './notification-controller'; import WindowController from './window-controller'; import TrayIconController from './tray-icon-controller'; @@ -20,19 +21,38 @@ import { defaultSettings, defaultTunnelStateTransition, } from './daemon-rpc'; -import type { RelayList, TunnelState, TunnelStateTransition, Settings } from './daemon-rpc'; +import type { + AppVersionInfo, + RelayList, + Settings, + TunnelState, + TunnelStateTransition, +} from './daemon-rpc'; import ReconnectionBackoff from './reconnection-backoff'; import { resolveBin } from './proc'; const RELAY_LIST_UPDATE_INTERVAL = 60 * 60 * 1000; +const VERSION_UPDATE_INTERVAL = 24 * 60 * 60 * 1000; const DAEMON_RPC_PATH = process.platform === 'win32' ? '//./pipe/Mullvad VPN' : '/var/run/mullvad-vpn'; type AppQuitStage = 'unready' | 'initiated' | 'ready'; +export type CurrentAppVersionInfo = { + gui: string, + daemon: string, + isConsistent: boolean, +}; + +export type AppUpgradeInfo = { + nextUpgrade: ?string, + upToDate: boolean, +} & AppVersionInfo; + const ApplicationMain = { + _notificationController: new NotificationController(), _windowController: (null: ?WindowController), _trayIconController: (null: ?TrayIconController), @@ -46,9 +66,27 @@ const ApplicationMain = { _tunnelState: defaultTunnelStateTransition(), _settings: defaultSettings(), + _relays: ({ countries: [] }: RelayList), _relaysInterval: (null: ?IntervalID), + _currentVersion: ({ + daemon: '', + gui: '', + isConsistent: true, + }: CurrentAppVersionInfo), + + _upgradeVersion: ({ + currentIsSupported: true, + latest: { + latestStable: '', + latest: '', + }, + nextUpgrade: null, + upToDate: true, + }: AppUpgradeInfo), + _latestVersionInterval: (null: ?IntervalID), + run() { // Since electron's GPU blacklists are broken, GPU acceleration won't work on older distros if (process.platform === 'linux') { @@ -288,8 +326,30 @@ const ApplicationMain = { return this._recoverFromBootstrapError(error); } + // fetch the daemon's version + try { + this._setDaemonVersion(await this._daemonRpc.getCurrentVersion()); + } catch (error) { + log.error(`Failed to fetch the daemon's version: ${error.message}`); + + return this._recoverFromBootstrapError(error); + } + + // fetch the latest version info in background + this._fetchLatestVersion(); + // start periodic updates this._startRelaysPeriodicUpdates(); + this._startLatestVersionPeriodicUpdates(); + + // notify user about inconsistent version + if ( + process.env.NODE_ENV !== 'development' && + !this._shouldSuppressNotifications() && + !this._currentVersion.isConsistent + ) { + this._notificationController.notifyInconsistentVersion(); + } // reset the reconnect backoff when connection established. this._reconnectBackoff.reset(); @@ -310,6 +370,7 @@ const ApplicationMain = { // stop periodic updates this._stopRelaysPeriodicUpdates(); + this._stopLatestVersionPeriodicUpdates(); // notify renderer process if (this._windowController) { @@ -375,11 +436,17 @@ const ApplicationMain = { }, _setTunnelState(newState: TunnelStateTransition) { + const windowController = this._windowController; + this._tunnelState = newState; this._updateTrayIcon(newState.state); - if (this._windowController) { - this._windowController.send('tunnel-state-changed', newState); + if (!this._shouldSuppressNotifications()) { + this._notificationController.notifyTunnelState(newState); + } + + if (windowController) { + windowController.send('tunnel-state-changed', newState); } }, @@ -422,6 +489,109 @@ const ApplicationMain = { } }, + _setDaemonVersion(daemonVersion: string) { + const guiVersion = app.getVersion().replace('.0', ''); + const versionInfo = { + daemon: daemonVersion, + gui: guiVersion, + isConsistent: daemonVersion === guiVersion, + }; + + this._currentVersion = versionInfo; + + // notify renderer + if (this._windowController) { + this._windowController.send('current-version-changed', versionInfo); + } + }, + + _setLatestVersion(latestVersionInfo: AppVersionInfo) { + function isBeta(version: string) { + return version.includes('-'); + } + + function nextUpgrade(current: string, latest: string, latestStable: string): ?string { + if (isBeta(current)) { + return current === latest ? null : latest; + } else { + return current === latestStable ? null : latestStable; + } + } + + function checkIfLatest(current: string, latest: string, latestStable: string): boolean { + // perhaps -beta? + if (isBeta(current)) { + return current === latest; + } else { + // must be stable + return current === latestStable; + } + } + + const currentVersionInfo = this._currentVersion; + const latestVersion = latestVersionInfo.latest.latest; + const latestStableVersion = latestVersionInfo.latest.latestStable; + + // the reason why we rely on daemon version here is because daemon obtains the version info + // based on its built-in version information + const isUpToDate = checkIfLatest(currentVersionInfo.daemon, latestVersion, latestStableVersion); + const upgradeVersion = nextUpgrade( + currentVersionInfo.daemon, + latestVersion, + latestStableVersion, + ); + + const upgradeInfo = { + ...latestVersionInfo, + nextUpgrade: upgradeVersion, + upToDate: isUpToDate, + }; + + this._upgradeVersion = upgradeInfo; + + // notify user to update the app if it became unsupported + if ( + process.env.NODE_ENV !== 'development' && + !this._shouldSuppressNotifications() && + currentVersionInfo.isConsistent && + !latestVersionInfo.currentIsSupported && + upgradeVersion + ) { + this._notificationController.notifyUnsupportedVersion(upgradeVersion); + } + + if (this._windowController) { + this._windowController.send('upgrade-version-changed', upgradeInfo); + } + }, + + async _fetchLatestVersion() { + try { + this._setLatestVersion(await this._daemonRpc.getVersionInfo()); + } catch (error) { + console.error(`Failed to request the version info: ${error.message}`); + } + }, + + _startLatestVersionPeriodicUpdates() { + const handler = () => { + this._fetchLatestVersion(); + }; + this._latestVersionInterval = setInterval(handler, VERSION_UPDATE_INTERVAL); + }, + + _stopLatestVersionPeriodicUpdates() { + if (this._latestVersionInterval) { + clearInterval(this._latestVersionInterval); + + this._latestVersionInterval = null; + } + }, + + _shouldSuppressNotifications() { + return this._windowController && this._windowController.isVisible(); + }, + _updateTrayIcon(tunnelState: TunnelState) { const iconTypes: { [TunnelState]: TrayIconType } = { connected: 'secured', @@ -436,9 +606,12 @@ const ApplicationMain = { }, _registerWindowListener(windowController: WindowController) { - const window = windowController.window; + windowController.window.on('show', () => { + // cancel notifications when window appears + this._notificationController.cancelPendingNotifications(); - window.on('show', () => window.webContents.send('window-shown')); + windowController.send('window-shown'); + }); }, _registerIpcListeners() { @@ -470,6 +643,8 @@ const ApplicationMain = { this._tunnelState, this._settings, this._relays, + this._currentVersion, + this._upgradeVersion, ); }); diff --git a/gui/packages/desktop/src/renderer/lib/notification-controller.js b/gui/packages/desktop/src/main/notification-controller.js index 06118b3562..da77836063 100644 --- a/gui/packages/desktop/src/renderer/lib/notification-controller.js +++ b/gui/packages/desktop/src/main/notification-controller.js @@ -1,10 +1,10 @@ // @flow -import { remote } from 'electron'; +import { shell, Notification } from 'electron'; import log from 'electron-log'; -import config from '../../config'; +import config from '../config'; -import type { TunnelStateTransition } from './daemon-rpc-proxy'; +import type { TunnelStateTransition } from './daemon-rpc'; export default class NotificationController { _lastTunnelStateNotification: ?Notification; @@ -48,41 +48,33 @@ export default class NotificationController { } notifyInconsistentVersion() { - if (remote.getCurrentWindow().isVisible()) { - return; - } - this._presentNotificationOnce('inconsistent-version', () => { - const notification = new Notification(remote.app.getName(), { + const notification = new Notification({ body: 'Inconsistent internal version information, please restart the app', silent: true, }); - this._addPendingNotification(notification); + this._scheduleNotification(notification); }); } notifyUnsupportedVersion(upgradeVersion: string) { - if (remote.getCurrentWindow().isVisible()) { - return; - } - this._presentNotificationOnce('unsupported-version', () => { - const notification = new Notification(remote.app.getName(), { + const notification = new Notification({ body: `You are running an unsupported app version. Please upgrade to ${upgradeVersion} now to ensure your security`, silent: true, }); - notification.addEventListener('click', () => { - remote.shell.openExternal(config.links.download); + notification.on('click', () => { + shell.openExternal(config.links.download); }); - this._addPendingNotification(notification); + this._scheduleNotification(notification); }); } cancelPendingNotifications() { for (const notification of this._pendingNotifications) { - this._closeNotification(notification); + notification.close(); } } @@ -90,21 +82,21 @@ export default class NotificationController { const lastNotification = this._lastTunnelStateNotification; const sameAsLastNotification = lastNotification && lastNotification.body === message; - if (sameAsLastNotification || remote.getCurrentWindow().isVisible()) { + if (sameAsLastNotification) { return; } - const newNotification = new Notification(remote.app.getName(), { + const newNotification = new Notification({ body: message, silent: true, }); if (lastNotification) { - this._closeNotification(lastNotification); + lastNotification.close(); } this._lastTunnelStateNotification = newNotification; - this._addPendingNotification(newNotification); + this._scheduleNotification(newNotification); } _presentNotificationOnce(notificationName: string, presentNotification: () => void) { @@ -115,22 +107,14 @@ export default class NotificationController { } } - _closeNotification(notification: Notification) { - // If the notification is closed too soon, it might still get shown. If that happens, close() - // should be called again so that it is closed immediately. - // Tracking issue: https://github.com/electron/electron/issues/12887 - notification.addEventListener('show', () => { - notification.close(); - }); + _scheduleNotification(notification: Notification) { + this._addPendingNotification(notification); - notification.close(); + notification.show(); } _addPendingNotification(notification: Notification) { - // Quirk: chromium postpones the 'close' event until new notifications pump the queue or window - // becomes visible. It's possible that there is going to be one stale notification in - // `_pendingNotifications` array but that shouldn't be a big deal. - notification.addEventListener('close', () => { + notification.on('close', () => { this._removePendingNotification(notification); }); diff --git a/gui/packages/desktop/src/main/window-controller.js b/gui/packages/desktop/src/main/window-controller.js index 0943364dab..faabe63c91 100644 --- a/gui/packages/desktop/src/main/window-controller.js +++ b/gui/packages/desktop/src/main/window-controller.js @@ -175,6 +175,10 @@ export default class WindowController { } } + isVisible(): boolean { + return this._window.isVisible(); + } + send(event: string, ...data: Array<mixed>): void { this._window.webContents.send(event, ...data); } diff --git a/gui/packages/desktop/src/renderer/app.js b/gui/packages/desktop/src/renderer/app.js index fd863ccb50..1c456fd7d5 100644 --- a/gui/packages/desktop/src/renderer/app.js +++ b/gui/packages/desktop/src/renderer/app.js @@ -1,7 +1,7 @@ // @flow import log from 'electron-log'; -import { remote, webFrame, ipcRenderer } from 'electron'; +import { webFrame, ipcRenderer } from 'electron'; import * as React from 'react'; import { bindActionCreators } from 'redux'; import { Provider } from 'react-redux'; @@ -14,7 +14,6 @@ import { createMemoryHistory } from 'history'; import { InvalidAccountError } from '../main/errors'; import makeRoutes from './routes'; -import NotificationController from './lib/notification-controller'; import configureStore from './redux/store'; import accountActions from './redux/account/actions'; @@ -24,6 +23,8 @@ import versionActions from './redux/version/actions'; import userInterfaceActions from './redux/userinterface/actions'; import type { WindowShapeParameters } from '../main/window-controller'; +import type { CurrentAppVersionInfo, AppUpgradeInfo } from '../main'; + import type { AccountToken, Settings, @@ -43,7 +44,6 @@ import DaemonRpcProxy, { import type { ReduxStore } from './redux/store'; export default class AppRenderer { - _notificationController = new NotificationController(); _memoryHistory = createMemoryHistory(); _reduxStore: ReduxStore; _reduxActions: *; @@ -93,8 +93,6 @@ export default class AppRenderer { if (this._connectedToDaemon) { this.updateAccountExpiry(); } - - this._notificationController.cancelPendingNotifications(); }); ipcRenderer.on('daemon-connected', () => { @@ -117,6 +115,17 @@ export default class AppRenderer { this._setRelays(newRelays); }); + ipcRenderer.on( + 'current-version-changed', + (_event: Event, currentVersion: CurrentAppVersionInfo) => { + this._setCurrentVersion(currentVersion); + }, + ); + + ipcRenderer.on('upgrade-version-changed', (_event: Event, upgradeVersion: AppUpgradeInfo) => { + this._setUpgradeVersion(upgradeVersion); + }); + // Request the initial state from main process ipcRenderer.on( 'get-state-reply', @@ -126,10 +135,14 @@ export default class AppRenderer { tunnelState: TunnelStateTransition, settings: Settings, relays: RelayList, + currentVersion: CurrentAppVersionInfo, + upgradeVersion: AppUpgradeInfo, ) => { this._setTunnelState(tunnelState); this._setSettings(settings); this._setRelays(relays); + this._setCurrentVersion(currentVersion); + this._setUpgradeVersion(upgradeVersion); if (isConnected) { this._onDaemonConnected(); @@ -356,88 +369,10 @@ export default class AppRenderer { actions.settings.updateAutoConnect(autoConnect); } - async _getAppComponentsVersions() { - const daemonVersion = await this._daemonRpc.getCurrentVersion(); - const guiVersion = remote.app.getVersion().replace('.0', ''); - return { - daemon: daemonVersion, - gui: guiVersion, - isConsistent: daemonVersion === guiVersion, - }; - } - - async _fetchCurrentVersion() { - const actions = this._reduxActions; - const versions = await this._getAppComponentsVersions(); - - // notify user about inconsistent version - if (process.env.NODE_ENV !== 'development' && !versions.isConsistent) { - this._notificationController.notifyInconsistentVersion(); - } - - actions.version.updateVersion(versions.gui, versions.isConsistent); - } - - async _fetchLatestVersionInfo() { - function isBeta(version: string) { - return version.includes('-'); - } - - function nextUpgrade(current: string, latest: string, latestStable: string): ?string { - if (isBeta(current)) { - return current === latest ? null : latest; - } else { - return current === latestStable ? null : latestStable; - } - } - - function checkIfLatest(current: string, latest: string, latestStable: string): boolean { - // perhaps -beta? - if (isBeta(current)) { - return current === latest; - } else { - // must be stable - return current === latestStable; - } - } - - const versions = await this._getAppComponentsVersions(); - const versionInfo = await this._daemonRpc.getVersionInfo(); - const latestVersion = versionInfo.latest.latest; - const latestStableVersion = versionInfo.latest.latestStable; - - // the reason why we rely on daemon version here is because daemon obtains the version info - // based on its built-in version information - const isUpToDate = checkIfLatest(versions.daemon, latestVersion, latestStableVersion); - const upgradeVersion = nextUpgrade(versions.daemon, latestVersion, latestStableVersion); - - // notify user to update the app if it became unsupported - if ( - process.env.NODE_ENV !== 'development' && - versions.isConsistent && - !versionInfo.currentIsSupported && - upgradeVersion - ) { - this._notificationController.notifyUnsupportedVersion(upgradeVersion); - } - - this._reduxActions.version.updateLatest({ - ...versionInfo, - nextUpgrade: upgradeVersion, - upToDate: isUpToDate, - }); - } - async _onDaemonConnected() { this._connectedToDaemon = true; try { - await this._fetchCurrentVersion(); - } catch (error) { - log.error(`Cannot fetch the current version: ${error.message}`); - } - - try { await this._fetchAccountHistory(); } catch (error) { log.error(`Cannot fetch the account history: ${error.message}`); @@ -445,12 +380,6 @@ export default class AppRenderer { // set the appropriate start view await this._setStartView(); - - try { - await this._fetchLatestVersionInfo(); - } catch (error) { - log.error(`Cannot fetch the latest version information: ${error.message}`); - } } _onDaemonDisconnected(error: ?Error) { @@ -521,7 +450,6 @@ export default class AppRenderer { this._updateConnectionStatus(tunnelState); this._updateUserLocation(tunnelState.state); - this._notificationController.notifyTunnelState(tunnelState); } _setSettings(newSettings: Settings) { @@ -537,6 +465,14 @@ export default class AppRenderer { this._setRelaySettings(newSettings.relaySettings); } + _setCurrentVersion(versionInfo: CurrentAppVersionInfo) { + this._reduxActions.version.updateVersion(versionInfo.gui, versionInfo.isConsistent); + } + + _setUpgradeVersion(upgradeVersion: AppUpgradeInfo) { + this._reduxActions.version.updateLatest(upgradeVersion); + } + async _updateUserLocation(tunnelState: TunnelState) { if (['connected', 'connecting', 'disconnected'].includes(tunnelState)) { try { |
