diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-08-23 09:10:08 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-08-23 09:10:08 +0200 |
| commit | ea00c0c6c1fe38d01bee3ef0fe60d362749b64b0 (patch) | |
| tree | 2ca36afbe949dcb6db3d0f66aceac4340141fb77 | |
| parent | 7063f6454ccb8163ef9e3adc228875a421a80bf7 (diff) | |
| parent | 33547846ab4b7594d23b1772fea35e30e0b460fc (diff) | |
| download | mullvadvpn-ea00c0c6c1fe38d01bee3ef0fe60d362749b64b0.tar.xz mullvadvpn-ea00c0c6c1fe38d01bee3ef0fe60d362749b64b0.zip | |
Merge branch 'refactor-electron-app'
| -rw-r--r-- | gui/src/main/account.ts | 231 | ||||
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 43 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 1666 | ||||
| -rw-r--r-- | gui/src/main/ipc-event-channel.ts | 48 | ||||
| -rw-r--r-- | gui/src/main/notification-controller.ts | 50 | ||||
| -rw-r--r-- | gui/src/main/problem-report.ts | 72 | ||||
| -rw-r--r-- | gui/src/main/relay-list.ts | 109 | ||||
| -rw-r--r-- | gui/src/main/settings.ts | 241 | ||||
| -rw-r--r-- | gui/src/main/tray-icon-controller.ts | 111 | ||||
| -rw-r--r-- | gui/src/main/tunnel-state.ts | 90 | ||||
| -rw-r--r-- | gui/src/main/user-interface.ts | 640 | ||||
| -rw-r--r-- | gui/src/main/version.ts | 121 | ||||
| -rw-r--r-- | gui/src/main/window-controller.ts | 25 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 162 | ||||
| -rw-r--r-- | gui/src/shared/ipc-helpers.ts | 6 |
15 files changed, 1949 insertions, 1666 deletions
diff --git a/gui/src/main/account.ts b/gui/src/main/account.ts new file mode 100644 index 0000000000..00a3bf0dfd --- /dev/null +++ b/gui/src/main/account.ts @@ -0,0 +1,231 @@ +import { + AccountToken, + DeviceEvent, + DeviceState, + IAccountData, + IDeviceRemoval, + TunnelState, +} from '../shared/daemon-rpc-types'; +import { messages } from '../shared/gettext'; +import log from '../shared/logging'; +import { + AccountExpiredNotificationProvider, + CloseToAccountExpiryNotificationProvider, +} from '../shared/notifications/notification'; +import { Scheduler } from '../shared/scheduler'; +import AccountDataCache from './account-data-cache'; +import { DaemonRpc } from './daemon-rpc'; +import { InvalidAccountError } from './errors'; +import { IpcMainEventChannel } from './ipc-event-channel'; +import { NotificationSender } from './notification-controller'; +import { TunnelStateProvider } from './tunnel-state'; + +export interface LocaleProvider { + getLocale(): string; +} + +export interface AccountDelegate { + onDeviceEvent(): void; +} + +export default class Account { + private accountDataValue?: IAccountData = undefined; + private accountHistoryValue?: AccountToken = undefined; + private accountExpiryNotificationScheduler = new Scheduler(); + private accountDataCache = new AccountDataCache( + (accountToken) => { + return this.daemonRpc.getAccountData(accountToken); + }, + (accountData) => { + this.accountDataValue = accountData; + + IpcMainEventChannel.account.notify?.(this.accountData); + + this.handleAccountExpiry(); + }, + ); + + private deviceStateValue?: DeviceState; + + public constructor( + private delegate: AccountDelegate & TunnelStateProvider & LocaleProvider & NotificationSender, + private daemonRpc: DaemonRpc, + ) {} + + public get accountData() { + return this.accountDataValue; + } + + public get accountHistory() { + return this.accountHistoryValue; + } + + public get deviceState() { + return this.deviceStateValue; + } + + public registerIpcListeners() { + IpcMainEventChannel.account.handleCreate(() => this.createNewAccount()); + IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token)); + IpcMainEventChannel.account.handleLogout(() => this.logout()); + IpcMainEventChannel.account.handleGetWwwAuthToken(() => this.daemonRpc.getWwwAuthToken()); + IpcMainEventChannel.account.handleSubmitVoucher(async (voucherCode: string) => { + const currentAccountToken = this.getAccountToken(); + const response = await this.daemonRpc.submitVoucher(voucherCode); + + if (currentAccountToken) { + this.accountDataCache.handleVoucherResponse(currentAccountToken, response); + } + + return response; + }); + IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData()); + + IpcMainEventChannel.accountHistory.handleClear(async () => { + await this.daemonRpc.clearAccountHistory(); + void this.updateAccountHistory(); + }); + + IpcMainEventChannel.account.handleGetDeviceState(async () => { + try { + await this.daemonRpc.updateDevice(); + } catch (e) { + const error = e as Error; + log.warn(`Failed to update device info: ${error.message}`); + } + return this.daemonRpc.getDevice(); + }); + IpcMainEventChannel.account.handleListDevices((accountToken: AccountToken) => { + return this.daemonRpc.listDevices(accountToken); + }); + IpcMainEventChannel.account.handleRemoveDevice((deviceRemoval: IDeviceRemoval) => { + return this.daemonRpc.removeDevice(deviceRemoval); + }); + } + + public isLoggedIn(): boolean { + return this.deviceState?.type === 'logged in'; + } + + public updateAccountData = () => { + if (this.daemonRpc.isConnected && this.isLoggedIn()) { + this.accountDataCache.fetch(this.getAccountToken()!); + } + }; + + public detectStaleAccountExpiry(tunnelState: TunnelState) { + const hasExpired = !this.accountData || new Date() >= new Date(this.accountData.expiry); + + // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. + if (tunnelState.state === 'connected' && hasExpired) { + log.info('Detected the stale account expiry.'); + this.accountDataCache.invalidate(); + } + } + + public handleDeviceEvent(deviceEvent: DeviceEvent) { + this.deviceStateValue = deviceEvent.deviceState; + + switch (deviceEvent.deviceState.type) { + case 'logged in': + this.accountDataCache.fetch(deviceEvent.deviceState.accountAndDevice.accountToken); + break; + case 'logged out': + case 'revoked': + this.accountDataCache.invalidate(); + break; + } + + void this.updateAccountHistory(); + this.delegate.onDeviceEvent(); + + IpcMainEventChannel.account.notifyDevice?.(deviceEvent); + } + + public setAccountHistory(accountHistory?: AccountToken) { + this.accountHistoryValue = accountHistory; + + IpcMainEventChannel.accountHistory.notify?.(accountHistory); + } + + private async createNewAccount(): Promise<string> { + try { + return await this.daemonRpc.createNewAccount(); + } catch (e) { + const error = e as Error; + log.error(`Failed to create account: ${error.message}`); + throw error; + } + } + + private async login(accountToken: AccountToken): Promise<void> { + try { + await this.daemonRpc.loginAccount(accountToken); + } catch (e) { + const error = e as Error; + log.error(`Failed to login: ${error.message}`); + + if (error instanceof InvalidAccountError) { + throw Error(messages.gettext('Invalid account number')); + } else { + throw error; + } + } + } + + private async logout(): Promise<void> { + try { + await this.daemonRpc.logoutAccount(); + + this.accountExpiryNotificationScheduler.cancel(); + } catch (e) { + const error = e as Error; + log.info(`Failed to logout: ${error.message}`); + + throw error; + } + } + + private handleAccountExpiry() { + if (this.accountData) { + const expiredNotification = new AccountExpiredNotificationProvider({ + accountExpiry: this.accountData.expiry, + tunnelState: this.delegate.getTunnelState(), + }); + const closeToExpiryNotification = new CloseToAccountExpiryNotificationProvider({ + accountExpiry: this.accountData.expiry, + locale: this.delegate.getLocale(), + }); + + if (expiredNotification.mayDisplay()) { + this.accountExpiryNotificationScheduler.cancel(); + this.delegate.notify(expiredNotification.getSystemNotification()); + } else if ( + !this.accountExpiryNotificationScheduler.isRunning && + closeToExpiryNotification.mayDisplay() + ) { + this.delegate.notify(closeToExpiryNotification.getSystemNotification()); + + const twelveHours = 12 * 60 * 60 * 1000; + const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now(); + const delay = Math.min(twelveHours, remainingMilliseconds); + this.accountExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), delay); + } + } + } + + private async updateAccountHistory(): Promise<void> { + try { + this.setAccountHistory(await this.daemonRpc.getAccountHistory()); + } catch (e) { + const error = e as Error; + log.error(`Failed to fetch the account history: ${error.message}`); + } + } + + private getAccountToken(): AccountToken | undefined { + return this.deviceState?.type === 'logged in' + ? this.deviceState.accountAndDevice.accountToken + : undefined; + } +} diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 646a09a9c7..950cf1a4a3 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -67,6 +67,9 @@ import { import { ManagementServiceClient } from './management_interface/management_interface_grpc_pb'; import * as grpcTypes from './management_interface/management_interface_pb'; +const DAEMON_RPC_PATH = + process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn'; + const NETWORK_CALL_TIMEOUT = 10000; const CHANNEL_STATE_TIMEOUT = 1000 * 60 * 60; @@ -77,7 +80,10 @@ const invalidErrorStateCause = new Error( ); export class ConnectionObserver { - constructor(private openHandler: () => void, private closeHandler: (error?: Error) => void) {} + constructor( + private openHandler: () => void, + private closeHandler: (wasConnected: boolean, error?: Error) => void, + ) {} // Only meant to be called by DaemonRpc // @internal @@ -87,8 +93,8 @@ export class ConnectionObserver { // Only meant to be called by DaemonRpc // @internal - public onClose = (error?: Error) => { - this.closeHandler(error); + public onClose = (wasConnected: boolean, error?: Error) => { + this.closeHandler(wasConnected, error); }; } @@ -127,30 +133,34 @@ type CallFunctionArgument<T, R> = export class DaemonRpc { private client: ManagementServiceClient; - private isConnected = false; + private isConnectedValue = false; private connectionObservers: ConnectionObserver[] = []; private nextSubscriptionId = 0; private subscriptions: Map<number, grpc.ClientReadableStream<grpcTypes.DaemonEvent>> = new Map(); private reconnectionTimeout?: NodeJS.Timer; - constructor(connectionParams: string) { + constructor() { this.client = new ManagementServiceClient( - connectionParams, + DAEMON_RPC_PATH, grpc.credentials.createInsecure(), this.channelOptions(), ); } + public get isConnected() { + return this.isConnectedValue; + } + public connect(): Promise<void> { return new Promise((resolve, reject) => { this.client.waitForReady(this.deadlineFromNow(), (error) => { if (error) { - this.connectionObservers.forEach((observer) => observer.onClose(error)); + this.onClose(error); this.ensureConnectivity(); reject(error); } else { this.reconnectionTimeout = undefined; - this.isConnected = true; + this.isConnectedValue = true; this.connectionObservers.forEach((observer) => observer.onOpen()); this.setChannelCallback(); resolve(); @@ -160,7 +170,7 @@ export class DaemonRpc { } public disconnect() { - this.isConnected = false; + this.isConnectedValue = false; for (const subscriptionId of this.subscriptions.keys()) { this.removeSubscription(subscriptionId); @@ -637,6 +647,13 @@ export class DaemonRpc { } } + private onClose(error?: Error) { + const wasConnected = this.isConnectedValue; + this.isConnectedValue = false; + + this.connectionObservers.forEach((observer) => observer.onClose(wasConnected, error)); + } + private removeSubscription(id: number) { const subscription = this.subscriptions.get(id); if (subscription !== undefined) { @@ -679,15 +696,14 @@ export class DaemonRpc { } const wasConnected = this.isConnected; if (this.channelDisconnected(currentState)) { - this.connectionObservers.forEach((observer) => observer.onClose()); - this.isConnected = false; + this.onClose(); // Try and reconnect in case void this.connect().catch((error) => { log.error(`Failed to reconnect - ${error}`); }); this.setChannelCallback(currentState); } else if (!wasConnected && currentState === grpc.connectivityState.READY) { - this.isConnected = true; + this.isConnectedValue = true; this.connectionObservers.forEach((observer) => observer.onOpen()); this.setChannelCallback(currentState); } @@ -723,8 +739,7 @@ export class DaemonRpc { this.reconnectionTimeout = setTimeout(() => { const lastState = this.client.getChannel().getConnectivityState(true); if (this.channelDisconnected(lastState)) { - this.connectionObservers.forEach((observer) => observer.onClose()); - this.isConnected = false; + this.onClose(); } if (!this.isConnected) { void this.connect().catch((error) => { diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 669f406c19..99aaaf5a8b 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -1,73 +1,25 @@ -import { exec, execFile } from 'child_process'; -import { randomUUID } from 'crypto'; -import { - app, - BrowserWindow, - dialog, - Menu, - nativeImage, - nativeTheme, - screen, - session, - shell, - systemPreferences, - Tray, -} from 'electron'; +import { exec } from 'child_process'; +import { app, nativeTheme, session, shell, systemPreferences } from 'electron'; import fs from 'fs'; import * as path from 'path'; import util from 'util'; import config from '../config.json'; -import { closeToExpiry, hasExpired } from '../shared/account-expiry'; +import { hasExpired } from '../shared/account-expiry'; import { IWindowsApplication } from '../shared/application-types'; -import BridgeSettingsBuilder from '../shared/bridge-settings-builder'; -import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper'; -import { - AccountToken, - BridgeSettings, - BridgeState, - DaemonEvent, - DeviceEvent, - DeviceState, - IAccountData, - IAppVersionInfo, - IDeviceRemoval, - IDnsOptions, - IRelayList, - ISettings, - liftConstraint, - ObfuscationType, - Ownership, - RelaySettings, - RelaySettingsUpdate, - TunnelState, -} from '../shared/daemon-rpc-types'; +import { DaemonEvent, DeviceEvent, ISettings, TunnelState } from '../shared/daemon-rpc-types'; import { messages, relayLocations } from '../shared/gettext'; import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import { ITranslations, MacOsScrollbarVisibility } from '../shared/ipc-schema'; -import { - IChangelog, - ICurrentAppVersionInfo, - IHistoryObject, - ScrollPositions, -} from '../shared/ipc-types'; +import { IChangelog, IHistoryObject, ScrollPositions } from '../shared/ipc-types'; import log, { ConsoleOutput, Logger } from '../shared/logging'; import { LogLevel } from '../shared/logging-types'; -import { - AccountExpiredNotificationProvider, - CloseToAccountExpiryNotificationProvider, - InconsistentVersionNotificationProvider, - UnsupportedVersionNotificationProvider, - UpdateAvailableNotificationProvider, -} from '../shared/notifications/notification'; -import { Scheduler } from '../shared/scheduler'; -import AccountDataCache from './account-data-cache'; -import { getOpenAtLogin, setOpenAtLogin } from './autostart'; +import { SystemNotification } from '../shared/notifications/notification'; +import Account, { AccountDelegate, LocaleProvider } from './account'; +import { getOpenAtLogin } from './autostart'; import { readChangelog } from './changelog'; import { ConnectionObserver, DaemonRpc, SubscriptionListener } from './daemon-rpc'; -import { InvalidAccountError } from './errors'; import Expectation from './expectation'; -import GuiSettings from './gui-settings'; import { IpcMainEventChannel } from './ipc-event-channel'; import { findIconPath } from './linux-desktop-entry'; import { loadTranslations } from './load-translations'; @@ -81,12 +33,20 @@ import { IpcInput, OLD_LOG_FILES, } from './logging'; -import NotificationController from './notification-controller'; -import { isMacOs11OrNewer } from './platform-version'; -import { resolveBin } from './proc'; +import NotificationController, { + NotificationControllerDelegate, + NotificationSender, +} from './notification-controller'; +import * as problemReport from './problem-report'; import ReconnectionBackoff from './reconnection-backoff'; -import TrayIconController, { TrayIconType } from './tray-icon-controller'; -import WindowController from './window-controller'; +import RelayList from './relay-list'; +import Settings, { SettingsDelegate } from './settings'; +import TunnelStateHandler, { + TunnelStateHandlerDelegate, + TunnelStateProvider, +} from './tunnel-state'; +import UserInterface, { UserInterfaceDelegate } from './user-interface'; +import Version from './version'; const execAsync = util.promisify(exec); @@ -94,174 +54,54 @@ const execAsync = util.promisify(exec); const linuxSplitTunneling = process.platform === 'linux' && require('./linux-split-tunneling'); const windowsSplitTunneling = process.platform === 'win32' && require('./windows-split-tunneling'); -const DAEMON_RPC_PATH = - process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn'; - -const GUI_VERSION = app.getVersion().replace('.0', ''); -/// Mirrors the beta check regex in the daemon. Matches only well formed beta versions -const IS_BETA = /^(\d{4})\.(\d+)-beta(\d+)$/; - -const UPDATE_NOTIFICATION_DISABLED = process.env.MULLVAD_DISABLE_UPDATE_NOTIFICATION === '1'; - -const SANDBOX_DISABLED = app.commandLine.hasSwitch('no-sandbox'); - -const ALLOWED_PERMISSIONS = ['clipboard-sanitized-write']; - -enum Options { +enum CommandLineOptions { quitWithoutDisconnect = '--quit-without-disconnect', showChanges = '--show-changes', disableResetNavigation = '--disable-reset-navigation', // development only } -enum AppQuitStage { +export enum AppQuitStage { unready, initiated, ready, } -class ApplicationMain { - private notificationController = new NotificationController({ - openApp: () => this.windowController?.show(), - openLink: (url: string, withAuth?: boolean) => this.openLink(url, withAuth), - isWindowVisible: () => this.windowController?.isVisible() ?? false, - areSystemNotificationsEnabled: () => this.guiSettings.enableSystemNotifications, - }); - private windowController?: WindowController; - private tray?: Tray; - private trayIconController?: TrayIconController; +const ALLOWED_PERMISSIONS = ['clipboard-sanitized-write']; + +const SANDBOX_DISABLED = app.commandLine.hasSwitch('no-sandbox'); +const UPDATE_NOTIFICATION_DISABLED = process.env.MULLVAD_DISABLE_UPDATE_NOTIFICATION === '1'; + +class ApplicationMain + implements + NotificationSender, + TunnelStateProvider, + LocaleProvider, + NotificationControllerDelegate, + UserInterfaceDelegate, + TunnelStateHandlerDelegate, + SettingsDelegate, + AccountDelegate { + private daemonRpc = new DaemonRpc(); - // True while file pickers are displayed which is used to decide if the Browser window should be - // hidden when losing focus. - private browsingFiles = false; + private notificationController = new NotificationController(this); + private version = new Version(this, this.daemonRpc, UPDATE_NOTIFICATION_DISABLED); + private settings = new Settings(this, this.daemonRpc, this.version.currentVersion); + private relayList = new RelayList(); + private userInterface?: UserInterface; + private account: Account = new Account(this, this.daemonRpc); + private tunnelState = new TunnelStateHandler(this); - private daemonRpc = new DaemonRpc(DAEMON_RPC_PATH); private daemonEventListener?: SubscriptionListener<DaemonEvent>; private reconnectBackoff = new ReconnectionBackoff(); private beforeFirstDaemonConnection = true; - private connectedToDaemon = false; private isPerformingPostUpgrade = false; private quitStage = AppQuitStage.unready; - private accountData?: IAccountData = undefined; - private accountHistory?: AccountToken = undefined; - - // The current tunnel state - private tunnelState: TunnelState = { state: 'disconnected' }; - // When pressing connect/disconnect/reconnect the app assumes what the next state will be before - // it get's the new state from the daemon. The latest state from the daemon is saved as fallback - // if the assumed state isn't reached. - private tunnelStateFallback?: TunnelState; - // Scheduler for discarding the assumed next state. - private tunnelStateFallbackScheduler = new Scheduler(); - - private settings: ISettings = { - allowLan: false, - autoConnect: false, - blockWhenDisconnected: false, - showBetaReleases: false, - splitTunnel: { - enableExclusions: false, - appsList: [], - }, - relaySettings: { - normal: { - location: 'any', - tunnelProtocol: 'any', - providers: [], - ownership: Ownership.any, - openvpnConstraints: { - port: 'any', - protocol: 'any', - }, - wireguardConstraints: { - port: 'any', - ipVersion: 'any', - useMultihop: false, - entryLocation: 'any', - }, - }, - }, - bridgeSettings: { - normal: { - location: 'any', - providers: [], - ownership: Ownership.any, - }, - }, - bridgeState: 'auto', - tunnelOptions: { - generic: { - enableIpv6: false, - }, - openvpn: { - mssfix: undefined, - }, - wireguard: { - mtu: undefined, - }, - dns: { - state: 'default', - defaultOptions: { - blockAds: false, - blockTrackers: false, - blockMalware: false, - blockAdultContent: false, - blockGambling: false, - }, - customOptions: { - addresses: [], - }, - }, - }, - obfuscationSettings: { - selectedObfuscation: ObfuscationType.auto, - udp2tcpSettings: { - port: 'any', - }, - }, - }; - private deviceState?: DeviceState; - private guiSettings = new GuiSettings(); private tunnelStateExpectation?: Expectation; - private relays: IRelayList = { countries: [] }; - - private currentVersion: ICurrentAppVersionInfo = { - daemon: undefined, - gui: GUI_VERSION, - isConsistent: true, - isBeta: IS_BETA.test(GUI_VERSION), - }; - - private upgradeVersion: IAppVersionInfo = { - supported: true, - suggestedUpgrade: undefined, - }; - // The UI locale which is set once from onReady handler private locale = 'en'; - private accountExpiryNotificationScheduler = new Scheduler(); - - private accountDataCache = new AccountDataCache( - (accountToken) => { - return this.daemonRpc.getAccountData(accountToken); - }, - (accountData) => { - this.accountData = accountData; - - if (this.windowController) { - IpcMainEventChannel.account.notify(this.windowController.webContents, this.accountData); - } - - this.handleAccountExpiry(); - }, - ); - - private disableResetNavigation = process.argv.includes(Options.disableResetNavigation); - private blurNavigationResetScheduler = new Scheduler(); - private backgroundThrottleScheduler = new Scheduler(); - private rendererLog?: Logger; private translations: ITranslations = { locale: this.locale }; @@ -272,7 +112,6 @@ class ApplicationMain { private stayConnectedOnQuit = false; private changelog?: IChangelog; - private forceShowChanges = process.argv.includes(Options.showChanges); private navigationHistory?: IHistoryObject; private scrollPositions: ScrollPositions = {}; @@ -291,7 +130,10 @@ class ApplicationMain { // This ensures that only a single instance is running at the same time, but also exits if // there's no already running instance when the quit without disconnect flag is supplied. - if (!app.requestSingleInstanceLock() || process.argv.includes(Options.quitWithoutDisconnect)) { + if ( + !app.requestSingleInstanceLock() || + process.argv.includes(CommandLineOptions.quitWithoutDisconnect) + ) { this.quitWithoutDisconnect(); return; } @@ -305,7 +147,7 @@ class ApplicationMain { app.enableSandbox(); } - log.info(`Running version ${this.currentVersion.gui}`); + log.info(`Running version ${this.version.currentVersion.gui}`); if (process.platform === 'win32') { app.setAppUserModelId('net.mullvad.vpn'); @@ -315,11 +157,11 @@ class ApplicationMain { // the signal `SIGUSR2`. if (process.env.NODE_ENV === 'development') { process.on('SIGUSR2', () => { - this.windowController?.window?.reload(); + this.userInterface?.reloadWindow(); }); } - this.guiSettings.load(); + this.settings.gui.load(); this.changelog = readChangelog(); app.on('render-process-gone', (_event, _webContents, details) => { @@ -341,14 +183,53 @@ class ApplicationMain { app.on('quit', this.onQuit); } + public async performPostUpgradeCheck(): Promise<void> { + const oldValue = this.isPerformingPostUpgrade; + this.isPerformingPostUpgrade = await this.daemonRpc.isPerformingPostUpgrade(); + if (this.isPerformingPostUpgrade !== oldValue) { + IpcMainEventChannel.daemon.notifyIsPerformingPostUpgrade?.(this.isPerformingPostUpgrade); + } + } + + public connectTunnel = async (): Promise<void> => { + if (this.tunnelState.allowConnect(this.daemonRpc.isConnected, this.account.isLoggedIn())) { + this.tunnelState.expectNextTunnelState('connecting'); + await this.daemonRpc.connectTunnel(); + } + }; + + public reconnectTunnel = async (): Promise<void> => { + if (this.tunnelState.allowReconnect(this.daemonRpc.isConnected, this.account.isLoggedIn())) { + this.tunnelState.expectNextTunnelState('connecting'); + await this.daemonRpc.reconnectTunnel(); + } + }; + + public disconnectTunnel = async (): Promise<void> => { + if (this.tunnelState.allowDisconnect(this.daemonRpc.isConnected)) { + this.tunnelState.expectNextTunnelState('disconnecting'); + await this.daemonRpc.disconnectTunnel(); + } + }; + + public isLoggedIn = () => this.account.isLoggedIn(); + + public notify = (notification: SystemNotification) => { + this.notificationController.notify( + notification, + this.userInterface?.isWindowVisible() ?? false, + this.settings.gui.enableSystemNotifications, + ); + }; + private addSecondInstanceEventHandler() { app.on('second-instance', (_event, argv, _workingDirectory) => { - if (argv.includes(Options.quitWithoutDisconnect)) { + if (argv.includes(CommandLineOptions.quitWithoutDisconnect)) { // Quit if another instance is started with the quit without disconnect flag. this.quitWithoutDisconnect(); - } else if (this.windowController) { + } else { // If no action was provided to the new instance the window is opened. - this.windowController.show(); + this.userInterface?.showWindow(); } }); } @@ -404,11 +285,7 @@ class ApplicationMain { log.addOutput(new ConsoleOutput(LogLevel.debug)); } - private onActivate = () => { - if (this.windowController) { - this.windowController.show(); - } - }; + private onActivate = () => this.userInterface?.showWindow(); private quitWithoutDisconnect() { this.stayConnectedOnQuit = true; @@ -455,7 +332,7 @@ class ApplicationMain { if (this.stayConnectedOnQuit) { log.info('Not disconnecting tunnel on quit'); } else { - if (this.connectedToDaemon) { + if (this.daemonRpc.isConnected) { try { await this.daemonRpc.disconnectTunnel(); log.info('Disconnected the tunnel'); @@ -485,11 +362,11 @@ class ApplicationMain { // closing normally, even programmatically. Therefore re-enable the close button just before // quitting the app. // Github issue: https://github.com/electron/electron/issues/15008 - if (process.platform === 'darwin' && this.windowController?.window) { - this.windowController.window.closable = true; + if (process.platform === 'darwin') { + this.userInterface?.setWindowClosable(true); } - if (this.connectedToDaemon) { + if (this.daemonRpc.isConnected) { this.daemonRpc.disconnect(); } @@ -502,11 +379,11 @@ class ApplicationMain { } } - this.trayIconController?.dispose(); + this.userInterface?.dispose(); } private detectLocale(): string { - const preferredLocale = this.guiSettings.preferredLocale; + const preferredLocale = this.settings.gui.preferredLocale; if (preferredLocale === SYSTEM_PREFERRED_LOCALE_KEY) { return app.getLocale(); } else { @@ -548,29 +425,31 @@ class ApplicationMain { }); } - const window = await this.createWindow(); - const tray = this.createTray(); + this.userInterface = new UserInterface( + this, + this.daemonRpc, + SANDBOX_DISABLED, + process.argv.includes(CommandLineOptions.disableResetNavigation), + ); - const windowController = new WindowController(window, tray, this.guiSettings.unpinnedWindow); this.tunnelStateExpectation = new Expectation(async () => { - this.trayIconController = new TrayIconController( - tray, - windowController, - this.trayIconType(this.tunnelState, this.settings.blockWhenDisconnected), - this.guiSettings.monochromaticIcon, - () => setImmediate(() => void this.connectTunnel()), - () => setImmediate(() => void this.reconnectTunnel()), - () => setImmediate(() => void this.disconnectTunnel()), + this.userInterface?.createTrayIconController( + this.tunnelState.tunnelState, + this.settings.blockWhenDisconnected, + this.settings.gui.monochromaticIcon, ); - await this.trayIconController.updateTheme(); + await this.userInterface?.updateTrayTheme(); - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.connectedToDaemon, this.tunnelState); + this.userInterface?.updateTray( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + this.settings.blockWhenDisconnected, + ); if (process.platform === 'win32') { nativeTheme.on('updated', async () => { - if (this.guiSettings.monochromaticIcon) { - await this.trayIconController?.updateTheme(); + if (this.settings.gui.monochromaticIcon) { + await this.userInterface?.updateTrayTheme(); } }); } @@ -578,84 +457,26 @@ class ApplicationMain { this.registerIpcListeners(); - this.windowController = windowController; - this.tray = tray; - - this.guiSettings.onChange = async (newState, oldState) => { - if (oldState.monochromaticIcon !== newState.monochromaticIcon) { - if (this.trayIconController) { - await this.trayIconController.setUseMonochromaticIcon(newState.monochromaticIcon); - } - } - - if (newState.autoConnect !== oldState.autoConnect) { - this.updateDaemonsAutoConnect(); - } - - if (this.windowController) { - IpcMainEventChannel.guiSettings.notify(this.windowController.webContents, newState); - } - }; - if (this.shouldShowWindowOnStart() || process.env.NODE_ENV === 'development') { - windowController.show(); + this.userInterface.showWindow(); } - await this.initializeWindow(); - }; - - private async initializeWindow() { - if (this.windowController?.window && this.tray) { - this.registerWindowListener(this.windowController); - this.addContextMenu(this.windowController); - - if (process.env.NODE_ENV === 'development') { - await this.installDevTools(); - - // The devtools doesn't open on Windows if openDevTools is called without a delay here. - this.windowController.window.once('ready-to-show', () => { - this.windowController?.window?.webContents.openDevTools({ mode: 'detach' }); - }); - } - - switch (process.platform) { - case 'win32': - this.installWindowsMenubarAppWindowHandlers(this.tray, this.windowController); - break; - case 'darwin': - this.installMacOsMenubarAppWindowHandlers(this.windowController); - this.setMacOsAppMenu(); - break; - case 'linux': - this.setTrayContextMenu(); - this.setLinuxAppMenu(); - this.windowController.window.setMenuBarVisibility(false); - break; - } - - this.installWindowCloseHandler(this.windowController); - this.installTrayClickHandlers(); - this.trayIconController?.setWindowController(this.windowController); - - const filePath = path.resolve(path.join(__dirname, '../renderer/index.html')); - try { - await this.windowController.window.loadFile(filePath); - } catch (e) { - const error = e as Error; - log.error(`Failed to load index file: ${error.message}`); - } - - // disable pinch to zoom - if (this.windowController.webContents) { - void this.windowController.webContents.setVisualZoomLevelLimits(1, 1); + if (process.platform === 'linux') { + const icon = await findIconPath('mullvad-vpn'); + if (icon) { + this.userInterface.setWindowIcon(icon); } } - } + + await this.userInterface.initializeWindow( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + ); + }; private onDaemonConnected = async () => { const firstDaemonConnection = this.beforeFirstDaemonConnection; this.beforeFirstDaemonConnection = false; - this.connectedToDaemon = true; log.info('Connected to the daemon'); @@ -683,7 +504,7 @@ class ApplicationMain { // fetch account history try { - this.setAccountHistory(await this.daemonRpc.getAccountHistory()); + this.account.setAccountHistory(await this.daemonRpc.getAccountHistory()); } catch (e) { const error = e as Error; log.error(`Failed to fetch the account history: ${error.message}`); @@ -693,7 +514,7 @@ class ApplicationMain { // fetch the tunnel state try { - this.setTunnelState(await this.daemonRpc.getState()); + this.tunnelState.handleNewTunnelState(await this.daemonRpc.getState()); } catch (e) { const error = e as Error; log.error(`Failed to fetch the tunnel state: ${error.message}`); @@ -704,7 +525,7 @@ class ApplicationMain { // fetch device try { const deviceState = await this.daemonRpc.getDevice(); - this.handleDeviceEvent({ type: deviceState.type, deviceState } as DeviceEvent); + this.account.handleDeviceEvent({ type: deviceState.type, deviceState } as DeviceEvent); if (deviceState.type === 'logged in') { void this.daemonRpc .updateDevice() @@ -733,7 +554,7 @@ class ApplicationMain { // fetch relays try { - this.setRelays( + this.relayList.setRelays( await this.daemonRpc.getRelayLocations(), this.settings.relaySettings, this.settings.bridgeState, @@ -747,7 +568,7 @@ class ApplicationMain { // fetch the daemon's version try { - this.setDaemonVersion(await this.daemonRpc.getCurrentVersion()); + this.version.setDaemonVersion(await this.daemonRpc.getCurrentVersion()); } catch (e) { const error = e as Error; log.error(`Failed to fetch the daemon's version: ${error.message}`); @@ -757,16 +578,16 @@ class ApplicationMain { // fetch the latest version info in background if (!UPDATE_NOTIFICATION_DISABLED) { - void this.fetchLatestVersion(); + void this.version.fetchLatestVersion(); } // reset the reconnect backoff when connection established. this.reconnectBackoff.reset(); - // notify renderer, this.connectedToDaemon could have changed if the daemon disconnected again - // before this if-statement is reached. - if (this.windowController && this.connectedToDaemon) { - IpcMainEventChannel.daemon.notifyConnected(this.windowController.webContents); + // notify renderer, this.daemonRpc.isConnected could have changed if the daemon disconnected + // again before this if-statement is reached. + if (this.daemonRpc.isConnected) { + IpcMainEventChannel.daemon.notifyConnected?.(); } if (firstDaemonConnection) { @@ -774,36 +595,26 @@ class ApplicationMain { } // show window when account is not set - if (!this.isLoggedIn()) { - this.windowController?.show(); + if (!this.account.isLoggedIn()) { + this.userInterface?.showWindow(); } }; - private onDaemonDisconnected = (error?: Error) => { + private onDaemonDisconnected = (wasConnected: boolean, error?: Error) => { if (this.daemonEventListener) { this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener); } - // make sure we were connected before to distinguish between a failed attempt to reconnect and - // connection loss. - const wasConnected = this.connectedToDaemon; - // Reset the daemon event listener since it's going to be invalidated on disconnect this.daemonEventListener = undefined; - this.tunnelStateFallback = undefined; + this.tunnelState.resetFallback(); if (wasConnected) { - this.connectedToDaemon = false; - // update the tray icon to indicate that the computer is not secure anymore - this.updateTrayIcon({ state: 'disconnected' }, false); - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.connectedToDaemon, this.tunnelState); + this.userInterface?.updateTray(false, { state: 'disconnected' }, false); // notify renderer process - if (this.windowController) { - IpcMainEventChannel.daemon.notifyDisconnected(this.windowController.webContents); - } + IpcMainEventChannel.daemon.notifyDisconnected?.(); } // recover connection on error @@ -835,26 +646,21 @@ class ApplicationMain { const daemonEventListener = new SubscriptionListener( (daemonEvent: DaemonEvent) => { if ('tunnelState' in daemonEvent) { - this.setTunnelState(daemonEvent.tunnelState); + this.tunnelState.handleNewTunnelState(daemonEvent.tunnelState); } else if ('settings' in daemonEvent) { this.setSettings(daemonEvent.settings); } else if ('relayList' in daemonEvent) { - this.setRelays( + this.relayList.setRelays( daemonEvent.relayList, this.settings.relaySettings, this.settings.bridgeState, ); } else if ('appVersionInfo' in daemonEvent) { - this.setLatestVersion(daemonEvent.appVersionInfo); + this.version.setLatestVersion(daemonEvent.appVersionInfo); } else if ('device' in daemonEvent) { - this.handleDeviceEvent(daemonEvent.device); + this.account.handleDeviceEvent(daemonEvent.device); } else if ('deviceRemoval' in daemonEvent) { - if (this.windowController) { - IpcMainEventChannel.account.notifyDevices( - this.windowController.webContents, - daemonEvent.deviceRemoval, - ); - } + IpcMainEventChannel.account.notifyDevices?.(daemonEvent.deviceRemoval); } }, (error: Error) => { @@ -867,130 +673,29 @@ class ApplicationMain { return daemonEventListener; } - private async performPostUpgradeCheck(): Promise<void> { - const oldValue = this.isPerformingPostUpgrade; - this.isPerformingPostUpgrade = await this.daemonRpc.isPerformingPostUpgrade(); - if (this.windowController && this.isPerformingPostUpgrade !== oldValue) { - IpcMainEventChannel.daemon.notifyIsPerformingPostUpgrade( - this.windowController.webContents, - this.isPerformingPostUpgrade, - ); - } - } - - private connectTunnel = async (): Promise<void> => { - if (connectEnabled(this.connectedToDaemon, this.isLoggedIn(), this.tunnelState.state)) { - this.setOptimisticTunnelState('connecting'); - await this.daemonRpc.connectTunnel(); - } - }; - - private reconnectTunnel = async (): Promise<void> => { - if (reconnectEnabled(this.connectedToDaemon, this.isLoggedIn(), this.tunnelState.state)) { - this.setOptimisticTunnelState('connecting'); - await this.daemonRpc.reconnectTunnel(); - } - }; - - private disconnectTunnel = async (): Promise<void> => { - if (disconnectEnabled(this.connectedToDaemon, this.tunnelState.state)) { - this.setOptimisticTunnelState('disconnecting'); - await this.daemonRpc.disconnectTunnel(); - } - }; - - private setAccountHistory(accountHistory?: AccountToken) { - this.accountHistory = accountHistory; - - if (this.windowController) { - IpcMainEventChannel.accountHistory.notify(this.windowController.webContents, accountHistory); - } - } - - // This function sets a new tunnel state as an assumed next state and saves the current state as - // fallback. The fallback is used if the assumed next state isn't reached. - private setOptimisticTunnelState(state: 'connecting' | 'disconnecting') { - this.tunnelStateFallback = this.tunnelState; - - this.setTunnelStateImpl( - state === 'disconnecting' ? { state, details: 'nothing' as const } : { state }, - ); - - this.tunnelStateFallbackScheduler.schedule(() => { - if (this.tunnelStateFallback) { - this.setTunnelStateImpl(this.tunnelStateFallback); - this.tunnelStateFallback = undefined; - } - }, 3000); - } - - private setTunnelState(newState: TunnelState) { - // If there's a fallback state set then the app is in an assumed next state and need to check - // if it's now reached or if the current state should be ignored and set as the fallback state. - if (this.tunnelStateFallback) { - if (this.tunnelState.state === newState.state || newState.state === 'error') { - this.tunnelStateFallbackScheduler.cancel(); - this.tunnelStateFallback = undefined; - } else { - this.tunnelStateFallback = newState; - return; - } - } - - if (newState.state === 'disconnecting' && newState.details === 'reconnect') { - // When reconnecting there's no need of showing the disconnecting state. This switches to the - // connecting state immediately. - this.setOptimisticTunnelState('connecting'); - this.tunnelStateFallback = newState; - } else { - this.setTunnelStateImpl(newState); - } - } - - private setTunnelStateImpl(newState: TunnelState) { - this.tunnelState = newState; - this.updateTrayIcon(newState, this.settings.blockWhenDisconnected); - - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.connectedToDaemon, this.tunnelState); - - this.notificationController.notifyTunnelState( - newState, - this.settings.blockWhenDisconnected, - this.settings.splitTunnel.enableExclusions && this.settings.splitTunnel.appsList.length > 0, - this.accountData?.expiry, - ); - - if (this.windowController) { - IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState); - } - - if (this.accountData) { - this.detectStaleAccountExpiry(newState, new Date(this.accountData.expiry)); - } - } - private setSettings(newSettings: ISettings) { const oldSettings = this.settings; - this.settings = newSettings; + this.settings.handleNewSettings(newSettings); - this.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected); + this.userInterface?.updateTray( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + newSettings.blockWhenDisconnected, + ); if (oldSettings.showBetaReleases !== newSettings.showBetaReleases) { - this.setLatestVersion(this.upgradeVersion); + this.version.setLatestVersion(this.version.upgradeVersion); } - if (this.windowController) { - IpcMainEventChannel.settings.notify(this.windowController.webContents, newSettings); + IpcMainEventChannel.settings.notify?.(newSettings); - if (windowsSplitTunneling) { - void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList); - } + if (windowsSplitTunneling) { + void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList); } // since settings can have the relay constraints changed, the relay // list should also be updated - this.setRelays(this.relays, newSettings.relaySettings, newSettings.bridgeState); + this.relayList.updateSettings(newSettings.relaySettings, newSettings.bridgeState); } private async updateSplitTunnelingApplications(appList: string[]): Promise<void> { @@ -999,427 +704,47 @@ class ApplicationMain { }); this.windowsSplitTunnelingApplications = applications; - if (this.windowController) { - IpcMainEventChannel.windowsSplitTunneling.notify( - this.windowController.webContents, - applications, - ); - } - } - - private setRelays( - newRelayList: IRelayList, - relaySettings: RelaySettings, - bridgeState: BridgeState, - ) { - this.relays = newRelayList; - - const filteredRelays = this.processRelaysForPresentation(newRelayList, relaySettings); - const filteredBridges = this.processBridgesForPresentation(newRelayList, bridgeState); - - if (this.windowController) { - IpcMainEventChannel.relays.notify(this.windowController.webContents, { - relays: filteredRelays, - bridges: filteredBridges, - }); - } - } - - private processRelaysForPresentation( - relayList: IRelayList, - relaySettings: RelaySettings, - ): IRelayList { - const tunnelProtocol = - 'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined; - - const filteredCountries = relayList.countries - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => { - if (relay.endpointType != 'bridge') { - switch (tunnelProtocol) { - case 'openvpn': - return relay.endpointType == 'openvpn'; - - case 'wireguard': - return relay.endpointType == 'wireguard'; - - case 'any': { - const useMultihop = - 'normal' in relaySettings && - relaySettings.normal.wireguardConstraints.useMultihop; - return !useMultihop || relay.endpointType == 'wireguard'; - } - default: - return false; - } - } else { - return false; - } - }), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); - - return { - countries: filteredCountries, - }; - } - - private processBridgesForPresentation( - relayList: IRelayList, - bridgeState: BridgeState, - ): IRelayList { - if (bridgeState === 'on') { - const filteredCountries = relayList.countries - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => relay.endpointType == 'bridge'), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); - - return { countries: filteredCountries }; - } else { - return { countries: [] }; - } - } - - private setDaemonVersion(daemonVersion: string) { - const versionInfo = { - ...this.currentVersion, - daemon: daemonVersion, - isConsistent: daemonVersion === this.currentVersion.gui, - }; - - this.currentVersion = versionInfo; - - if (!versionInfo.isConsistent) { - log.info('Inconsistent version', { - guiVersion: versionInfo.gui, - daemonVersion: versionInfo.daemon, - }); - } - - // notify user about inconsistent version - const notificationProvider = new InconsistentVersionNotificationProvider({ - consistent: versionInfo.isConsistent, - }); - if (notificationProvider.mayDisplay()) { - this.notificationController.notify(notificationProvider.getSystemNotification()); - } - - // notify renderer - if (this.windowController) { - IpcMainEventChannel.currentVersion.notify(this.windowController.webContents, versionInfo); - } - } - - private setLatestVersion(latestVersionInfo: IAppVersionInfo) { - if (UPDATE_NOTIFICATION_DISABLED) { - return; - } - - const suggestedIsBeta = - latestVersionInfo.suggestedUpgrade !== undefined && - IS_BETA.test(latestVersionInfo.suggestedUpgrade); - - const upgradeVersion = { - ...latestVersionInfo, - suggestedIsBeta, - }; - - this.upgradeVersion = upgradeVersion; - - // notify user to update the app if it became unsupported - const notificationProviders = [ - new UnsupportedVersionNotificationProvider({ - supported: latestVersionInfo.supported, - consistent: this.currentVersion.isConsistent, - suggestedUpgrade: latestVersionInfo.suggestedUpgrade, - suggestedIsBeta, - }), - new UpdateAvailableNotificationProvider({ - suggestedUpgrade: latestVersionInfo.suggestedUpgrade, - suggestedIsBeta, - }), - ]; - const notificationProvider = notificationProviders.find((notificationProvider) => - notificationProvider.mayDisplay(), - ); - if (notificationProvider) { - this.notificationController.notify(notificationProvider.getSystemNotification()); - } - - if (this.windowController) { - IpcMainEventChannel.upgradeVersion.notify(this.windowController.webContents, upgradeVersion); - } - } - - private async fetchLatestVersion() { - try { - this.setLatestVersion(await this.daemonRpc.getVersionInfo()); - } catch (e) { - const error = e as Error; - log.error(`Failed to request the version info: ${error.message}`); - } - } - - private handleDeviceEvent(deviceEvent: DeviceEvent) { - this.deviceState = deviceEvent.deviceState; - - if (this.isPerformingPostUpgrade) { - void this.performPostUpgradeCheck(); - } - - switch (deviceEvent.deviceState.type) { - case 'logged in': - this.accountDataCache.fetch(deviceEvent.deviceState.accountAndDevice.accountToken); - break; - case 'logged out': - case 'revoked': - this.accountDataCache.invalidate(); - break; - } - - void this.updateAccountHistory(); - this.setTrayContextMenu(); - - if (this.windowController) { - IpcMainEventChannel.account.notifyDevice(this.windowController.webContents, deviceEvent); - } - } - - private isLoggedIn(): boolean { - return this.deviceState?.type === 'logged in'; - } - - private getAccountToken(): AccountToken | undefined { - return this.deviceState?.type === 'logged in' - ? this.deviceState.accountAndDevice.accountToken - : undefined; - } - - private trayIconType(tunnelState: TunnelState, blockWhenDisconnected: boolean): TrayIconType { - switch (tunnelState.state) { - case 'connected': - return 'secured'; - - case 'connecting': - return 'securing'; - - case 'error': - if (!tunnelState.details.blockFailure) { - return 'securing'; - } else { - return 'unsecured'; - } - case 'disconnecting': - return 'securing'; - - case 'disconnected': - if (blockWhenDisconnected) { - return 'securing'; - } else { - return 'unsecured'; - } - } - } - - private updateTrayIcon(tunnelState: TunnelState, blockWhenDisconnected: boolean) { - const type = this.trayIconType(tunnelState, blockWhenDisconnected); - - if (this.trayIconController) { - this.trayIconController.animateToIcon(type); - } - } - - private registerWindowListener(windowController: WindowController) { - windowController.window?.on('focus', () => { - IpcMainEventChannel.window.notifyFocus(windowController.webContents, true); - - this.blurNavigationResetScheduler.cancel(); - - // cancel notifications when window appears - this.notificationController.cancelPendingNotifications(); - - if ( - !this.accountData || - closeToExpiry(this.accountData.expiry, 4) || - hasExpired(this.accountData.expiry) - ) { - this.updateAccountData(); - } - }); - - windowController.window?.on('blur', () => { - IpcMainEventChannel.window.notifyFocus(windowController.webContents, false); - - // ensure notification guard is reset - this.notificationController.resetTunnelStateAnnouncements(); - }); - - // Use hide instead of blur to prevent the navigation reset from happening when bluring an - // unpinned window. - windowController.window?.on('hide', () => { - if (process.env.NODE_ENV !== 'development' || !this.disableResetNavigation) { - this.blurNavigationResetScheduler.schedule(() => { - this.windowController?.webContents?.setBackgroundThrottling(false); - IpcMainEventChannel.navigation.notifyReset(windowController.webContents); - - this.backgroundThrottleScheduler.schedule(() => { - this.windowController?.webContents?.setBackgroundThrottling(true); - }, 1_000); - }, 120_000); - } - }); + IpcMainEventChannel.windowsSplitTunneling.notify?.(applications); } private registerIpcListeners() { IpcMainEventChannel.state.handleGet(() => ({ - isConnected: this.connectedToDaemon, + isConnected: this.daemonRpc.isConnected, autoStart: getOpenAtLogin(), - accountData: this.accountData, - accountHistory: this.accountHistory, - tunnelState: this.tunnelState, - settings: this.settings, + accountData: this.account.accountData, + accountHistory: this.account.accountHistory, + tunnelState: this.tunnelState.tunnelState, + settings: this.settings.all, isPerformingPostUpgrade: this.isPerformingPostUpgrade, - deviceState: this.deviceState, - relayListPair: { - relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings), - bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState), - }, - currentVersion: this.currentVersion, - upgradeVersion: this.upgradeVersion, - guiSettings: this.guiSettings.state, + deviceState: this.account.deviceState, + relayListPair: this.relayList.getProcessedRelays( + this.settings.relaySettings, + this.settings.bridgeState, + ), + currentVersion: this.version.currentVersion, + upgradeVersion: this.version.upgradeVersion, + guiSettings: this.settings.gui.state, translations: this.translations, windowsSplitTunnelingApplications: this.windowsSplitTunnelingApplications, macOsScrollbarVisibility: this.macOsScrollbarVisibility, changelog: this.changelog ?? [], - forceShowChanges: this.forceShowChanges, + forceShowChanges: process.argv.includes(CommandLineOptions.showChanges), navigationHistory: this.navigationHistory, scrollPositions: this.scrollPositions, })); - IpcMainEventChannel.settings.handleSetAllowLan((allowLan: boolean) => - this.daemonRpc.setAllowLan(allowLan), - ); - IpcMainEventChannel.settings.handleSetShowBetaReleases((showBetaReleases: boolean) => - this.daemonRpc.setShowBetaReleases(showBetaReleases), - ); - IpcMainEventChannel.settings.handleSetEnableIpv6((enableIpv6: boolean) => - this.daemonRpc.setEnableIpv6(enableIpv6), - ); - IpcMainEventChannel.settings.handleSetBlockWhenDisconnected((blockWhenDisconnected: boolean) => - this.daemonRpc.setBlockWhenDisconnected(blockWhenDisconnected), - ); - IpcMainEventChannel.settings.handleSetBridgeState(async (bridgeState: BridgeState) => { - await this.daemonRpc.setBridgeState(bridgeState); - - // Reset bridge constraints to `any` when the state is set to auto or off - if (bridgeState === 'auto' || bridgeState === 'off') { - await this.daemonRpc.setBridgeSettings(new BridgeSettingsBuilder().location.any().build()); - } - }); - IpcMainEventChannel.settings.handleSetOpenVpnMssfix((mssfix?: number) => - this.daemonRpc.setOpenVpnMssfix(mssfix), - ); - IpcMainEventChannel.settings.handleSetWireguardMtu((mtu?: number) => - this.daemonRpc.setWireguardMtu(mtu), - ); - IpcMainEventChannel.settings.handleUpdateRelaySettings((update: RelaySettingsUpdate) => - this.daemonRpc.updateRelaySettings(update), - ); - IpcMainEventChannel.settings.handleUpdateBridgeSettings((bridgeSettings: BridgeSettings) => { - return this.daemonRpc.setBridgeSettings(bridgeSettings); - }); - IpcMainEventChannel.settings.handleSetDnsOptions((dns: IDnsOptions) => { - return this.daemonRpc.setDnsOptions(dns); - }); - IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => { - return this.setAutoStart(autoStart); - }); - IpcMainEventChannel.settings.handleSetObfuscationSettings((obfuscationSettings) => { - return this.daemonRpc.setObfuscationSettings(obfuscationSettings); - }); - IpcMainEventChannel.location.handleGet(() => this.daemonRpc.getLocation()); IpcMainEventChannel.tunnel.handleConnect(this.connectTunnel); IpcMainEventChannel.tunnel.handleReconnect(this.reconnectTunnel); IpcMainEventChannel.tunnel.handleDisconnect(this.disconnectTunnel); - IpcMainEventChannel.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => { - this.guiSettings.enableSystemNotifications = flag; - }); - - IpcMainEventChannel.guiSettings.handleSetAutoConnect((autoConnect: boolean) => { - this.guiSettings.autoConnect = autoConnect; - }); - - IpcMainEventChannel.guiSettings.handleSetStartMinimized((startMinimized: boolean) => { - this.guiSettings.startMinimized = startMinimized; - }); - - IpcMainEventChannel.guiSettings.handleSetMonochromaticIcon((monochromaticIcon: boolean) => { - this.guiSettings.monochromaticIcon = monochromaticIcon; - }); - - IpcMainEventChannel.guiSettings.handleSetUnpinnedWindow((unpinnedWindow: boolean) => { - void this.setUnpinnedWindow(unpinnedWindow); - }); - IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale: string) => { - this.guiSettings.preferredLocale = locale; + this.settings.gui.preferredLocale = locale; this.updateCurrentLocale(); return Promise.resolve(this.translations); }); - IpcMainEventChannel.account.handleCreate(() => this.createNewAccount()); - IpcMainEventChannel.account.handleLogin((token: AccountToken) => this.login(token)); - IpcMainEventChannel.account.handleLogout(() => this.logout()); - IpcMainEventChannel.account.handleGetWwwAuthToken(() => this.daemonRpc.getWwwAuthToken()); - IpcMainEventChannel.account.handleSubmitVoucher(async (voucherCode: string) => { - const currentAccountToken = this.getAccountToken(); - const response = await this.daemonRpc.submitVoucher(voucherCode); - - if (currentAccountToken) { - this.accountDataCache.handleVoucherResponse(currentAccountToken, response); - } - - return response; - }); - IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData()); - - IpcMainEventChannel.account.handleGetDeviceState(async () => { - try { - await this.daemonRpc.updateDevice(); - } catch (e) { - const error = e as Error; - log.warn(`Failed to update device info: ${error.message}`); - } - return this.daemonRpc.getDevice(); - }); - IpcMainEventChannel.account.handleListDevices((accountToken: AccountToken) => { - return this.daemonRpc.listDevices(accountToken); - }); - IpcMainEventChannel.account.handleRemoveDevice((deviceRemoval: IDeviceRemoval) => { - return this.daemonRpc.removeDevice(deviceRemoval); - }); - - IpcMainEventChannel.accountHistory.handleClear(async () => { - await this.daemonRpc.clearAccountHistory(); - void this.updateAccountHistory(); - }); - IpcMainEventChannel.linuxSplitTunneling.handleGetApplications(() => { return linuxSplitTunneling.getApplications(this.locale); }); @@ -1437,7 +762,7 @@ class ApplicationMain { // If the applications is a string (path) it's an application picked with the file picker // that we want to add to the list of additional applications. if (typeof application === 'string') { - this.guiSettings.addBrowsedForSplitTunnelingApplications(application); + this.settings.gui.addBrowsedForSplitTunnelingApplications(application); const applicationPath = await windowsSplitTunneling.addApplicationPathToCache(application); await this.daemonRpc.addSplitTunnelingApplication(applicationPath); } else { @@ -1451,82 +776,17 @@ class ApplicationMain { }); IpcMainEventChannel.windowsSplitTunneling.handleForgetManuallyAddedApplication( (application) => { - this.guiSettings.deleteBrowsedForSplitTunnelingApplications(application.absolutepath); + this.settings.gui.deleteBrowsedForSplitTunnelingApplications(application.absolutepath); return windowsSplitTunneling.removeApplicationFromCache(application); }, ); - IpcMainEventChannel.problemReport.handleCollectLogs((toRedact) => { - const id = randomUUID(); - const reportPath = this.getProblemReportPath(id); - const executable = resolveBin('mullvad-problem-report'); - const args = ['collect', '--output', reportPath]; - if (toRedact) { - args.push('--redact', toRedact); - } - - return new Promise((resolve, reject) => { - execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => { - if (error) { - log.error( - `Failed to collect a problem report. - Stdout: ${stdout.toString()} - Stderr: ${stderr.toString()}`, - ); - reject(error.message); - } else { - log.verbose(`Problem report was written to ${reportPath}`); - resolve(id); - } - }); - }); - }); - - IpcMainEventChannel.problemReport.handleSendReport(({ email, message, savedReportId }) => { - const executable = resolveBin('mullvad-problem-report'); - const reportPath = this.getProblemReportPath(savedReportId); - const args = ['send', '--email', email, '--message', message, '--report', reportPath]; - - return new Promise((resolve, reject) => { - execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => { - if (error) { - log.error( - `Failed to send a problem report. - Stdout: ${stdout.toString()} - Stderr: ${stderr.toString()}`, - ); - reject(error.message); - } else { - log.info('Problem report was sent.'); - resolve(); - } - }); - }); - }); - - IpcMainEventChannel.problemReport.handleViewLog((savedReportId) => - shell.openPath(this.getProblemReportPath(savedReportId)), - ); - IpcMainEventChannel.app.handleQuit(() => app.quit()); IpcMainEventChannel.app.handleOpenUrl(async (url) => { if (Object.values(config.links).find((link) => url.startsWith(link))) { await shell.openExternal(url); } }); - IpcMainEventChannel.app.handleShowOpenDialog(async (options) => { - this.browsingFiles = true; - const response = await dialog.showOpenDialog({ - defaultPath: app.getPath('home'), - ...options, - }); - this.browsingFiles = false; - return response; - }); - - IpcMainEventChannel.currentVersion.handleDisplayedChangelog(() => { - this.guiSettings.changelogDisplayedForVersion = this.currentVersion.gui; - }); IpcMainEventChannel.navigation.handleSetHistory((history) => { this.navigationHistory = history; @@ -1535,43 +795,26 @@ class ApplicationMain { this.scrollPositions = scrollPositions; }); + problemReport.registerIpcListeners(); + this.userInterface!.registerIpcListeners(); + this.settings.registerIpcListeners(); + this.account.registerIpcListeners(); + if (windowsSplitTunneling) { - this.guiSettings.browsedForSplitTunnelingApplications.forEach( + this.settings.gui.browsedForSplitTunnelingApplications.forEach( windowsSplitTunneling.addApplicationPathToCache, ); } } - private async createNewAccount(): Promise<string> { - try { - return await this.daemonRpc.createNewAccount(); - } catch (e) { - const error = e as Error; - log.error(`Failed to create account: ${error.message}`); - throw error; - } - } - - private async login(accountToken: AccountToken): Promise<void> { - try { - await this.daemonRpc.loginAccount(accountToken); - } catch (e) { - const error = e as Error; - log.error(`Failed to login: ${error.message}`); - - if (error instanceof InvalidAccountError) { - throw Error(messages.gettext('Invalid account number')); - } else { - throw error; - } - } - } - private async autoConnect() { if (process.env.NODE_ENV === 'development') { log.info('Skip autoconnect in development'); - } else if (this.isLoggedIn() && (!this.accountData || !hasExpired(this.accountData.expiry))) { - if (this.guiSettings.autoConnect) { + } else if ( + this.account.isLoggedIn() && + (!this.account.accountData || !hasExpired(this.account.accountData.expiry)) + ) { + if (this.settings.gui.autoConnect) { try { log.info('Autoconnect the tunnel'); @@ -1588,117 +831,6 @@ class ApplicationMain { } } - private async logout(): Promise<void> { - try { - await this.daemonRpc.logoutAccount(); - - this.accountExpiryNotificationScheduler.cancel(); - } catch (e) { - const error = e as Error; - log.info(`Failed to logout: ${error.message}`); - - throw error; - } - } - - private updateAccountData() { - if (this.connectedToDaemon && this.isLoggedIn()) { - this.accountDataCache.fetch(this.getAccountToken()!); - } - } - - private detectStaleAccountExpiry(tunnelState: TunnelState, accountExpiry: Date) { - const hasExpired = new Date() >= accountExpiry; - - // It's likely that the account expiry is stale if the daemon managed to establish the tunnel. - if (tunnelState.state === 'connected' && hasExpired) { - log.info('Detected the stale account expiry.'); - this.accountDataCache.invalidate(); - } - } - - private handleAccountExpiry() { - if (this.accountData) { - const expiredNotification = new AccountExpiredNotificationProvider({ - accountExpiry: this.accountData.expiry, - tunnelState: this.tunnelState, - }); - const closeToExpiryNotification = new CloseToAccountExpiryNotificationProvider({ - accountExpiry: this.accountData.expiry, - locale: this.locale, - }); - - if (expiredNotification.mayDisplay()) { - this.accountExpiryNotificationScheduler.cancel(); - this.notificationController.notify(expiredNotification.getSystemNotification()); - } else if ( - !this.accountExpiryNotificationScheduler.isRunning && - closeToExpiryNotification.mayDisplay() - ) { - this.notificationController.notify(closeToExpiryNotification.getSystemNotification()); - - const twelveHours = 12 * 60 * 60 * 1000; - const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now(); - const delay = Math.min(twelveHours, remainingMilliseconds); - this.accountExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), delay); - } - } - } - - private async updateAccountHistory(): Promise<void> { - try { - this.setAccountHistory(await this.daemonRpc.getAccountHistory()); - } catch (e) { - const error = e as Error; - log.error(`Failed to fetch the account history: ${error.message}`); - } - } - - private updateDaemonsAutoConnect() { - const daemonAutoConnect = this.guiSettings.autoConnect && getOpenAtLogin(); - if (daemonAutoConnect !== this.settings.autoConnect) { - void this.daemonRpc.setAutoConnect(daemonAutoConnect); - } - } - - private async setAutoStart(autoStart: boolean): Promise<void> { - try { - await setOpenAtLogin(autoStart); - - if (this.windowController) { - IpcMainEventChannel.autoStart.notify(this.windowController.webContents, autoStart); - } - - this.updateDaemonsAutoConnect(); - } catch (e) { - const error = e as Error; - log.error( - `Failed to update the autostart to ${autoStart.toString()}. ${error.message.toString()}`, - ); - } - return Promise.resolve(); - } - - private async setUnpinnedWindow(unpinnedWindow: boolean): Promise<void> { - this.guiSettings.unpinnedWindow = unpinnedWindow; - - if (this.tray && this.windowController) { - this.tray.removeAllListeners(); - - const window = await this.createWindow(); - - this.windowController.close(); - this.windowController = new WindowController( - window, - this.tray, - this.guiSettings.unpinnedWindow, - ); - - await this.initializeWindow(); - this.windowController.show(); - } - } - private updateCurrentLocale() { this.locale = this.detectLocale(); @@ -1713,8 +845,11 @@ class ApplicationMain { relayLocations: relayLocationsTranslations, }; - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.connectedToDaemon, this.tunnelState); + this.userInterface?.updateTray( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + this.settings.blockWhenDisconnected, + ); } private blockPermissionRequests() { @@ -1782,323 +917,34 @@ class ApplicationMain { }); } - private async installDevTools() { - const { default: installer, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } = await import( - 'electron-devtools-installer' - ); - const forceDownload = !!process.env.UPGRADE_EXTENSIONS; - const options = { forceDownload, loadExtensionOptions: { allowFileAccess: true } }; - try { - await installer(REACT_DEVELOPER_TOOLS, options); - await installer(REDUX_DEVTOOLS, options); - } catch (e) { - const error = e as Error; - log.info(`Error installing extension: ${error.message}`); - } - } - - private async createWindow(): Promise<BrowserWindow> { - const { width, height } = WindowController.getContentSize(this.guiSettings.unpinnedWindow); - - const options: Electron.BrowserWindowConstructorOptions = { - useContentSize: true, - width, - height, - resizable: false, - maximizable: false, - fullscreenable: false, - show: false, - frame: this.guiSettings.unpinnedWindow, - webPreferences: { - preload: path.join(__dirname, '../renderer/preloadBundle.js'), - nodeIntegration: false, - nodeIntegrationInWorker: false, - nodeIntegrationInSubFrames: false, - sandbox: !SANDBOX_DISABLED, - contextIsolation: true, - spellcheck: false, - devTools: process.env.NODE_ENV === 'development', - }, - }; - - switch (process.platform) { - case 'darwin': { - // setup window flags to mimic popover on macOS - const appWindow = new BrowserWindow({ - ...options, - titleBarStyle: this.guiSettings.unpinnedWindow ? 'default' : 'customButtonsOnHover', - minimizable: this.guiSettings.unpinnedWindow, - closable: this.guiSettings.unpinnedWindow, - transparent: !this.guiSettings.unpinnedWindow, - }); - - // make the window visible on all workspaces and prevent the icon from showing in the dock - // and app switcher. - if (this.guiSettings.unpinnedWindow) { - void app.dock.show(); - } else { - appWindow.setVisibleOnAllWorkspaces(true); - app.dock.hide(); - } - - return appWindow; - } - - case 'win32': { - // setup window flags to mimic an overlay window - const appWindow = new BrowserWindow({ - ...options, - // Due to a bug in Electron the app is sometimes placed behind other apps when opened. - // Setting alwaysOnTop to true ensures that the app is placed on top. Electron issue: - // https://github.com/electron/electron/issues/25915 - alwaysOnTop: !this.guiSettings.unpinnedWindow, - skipTaskbar: !this.guiSettings.unpinnedWindow, - // Workaround for sub-pixel anti-aliasing - // https://github.com/electron/electron/blob/main/docs/faq.md#the-font-looks-blurry-what-is-this-and-what-can-i-do - backgroundColor: '#fff', - }); - const WM_DEVICECHANGE = 0x0219; - const DBT_DEVICEARRIVAL = 0x8000; - const DBT_DEVICEREMOVECOMPLETE = 0x8004; - appWindow.hookWindowMessage(WM_DEVICECHANGE, (wParam) => { - const wParamL = wParam.readBigInt64LE(0); - if (wParamL != DBT_DEVICEARRIVAL && wParamL != DBT_DEVICEREMOVECOMPLETE) { - return; - } - this.daemonRpc - .checkVolumes() - .catch((error) => - log.error(`Unable to notify daemon of device event: ${error.message}`), - ); - }); - - appWindow.removeMenu(); - - return appWindow; - } - - case 'linux': - return new BrowserWindow({ - ...options, - icon: await findIconPath('mullvad-vpn'), - }); - - default: { - return new BrowserWindow(options); - } - } - } - - // On macOS, hotkeys are bound to the app menu and won't work if it's not set, - // even though the app menu itself is not visible because the app does not appear in the dock. - private setMacOsAppMenu() { - const mullvadVpnSubmenu: Electron.MenuItemConstructorOptions[] = [{ role: 'quit' }]; - if (process.env.NODE_ENV === 'development') { - mullvadVpnSubmenu.unshift({ role: 'reload' }, { role: 'forceReload' }); - } - - const template: Electron.MenuItemConstructorOptions[] = [ - { - label: 'Mullvad VPN', - submenu: mullvadVpnSubmenu, - }, - { - label: 'Edit', - submenu: [ - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { type: 'separator' }, - { role: 'selectAll' }, - ], - }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - } - - private setLinuxAppMenu() { - const template: Electron.MenuItemConstructorOptions[] = [ - { - label: 'Mullvad VPN', - submenu: [{ role: 'quit' }], - }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - } - - private addContextMenu(windowController: WindowController) { - const menuTemplate: Electron.MenuItemConstructorOptions[] = [ - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { type: 'separator' }, - { role: 'selectAll' }, - ]; - - // add inspect element on right click menu - windowController.window?.webContents.on( - 'context-menu', - (_e: Event, props: { x: number; y: number; isEditable: boolean }) => { - const inspectTemplate = [ - { - label: 'Inspect element', - click() { - windowController.window?.webContents.openDevTools({ mode: 'detach' }); - windowController.window?.webContents.inspectElement(props.x, props.y); - }, - }, - ]; - - if (props.isEditable) { - // mixin 'inspect element' into standard menu when in development mode - if (process.env.NODE_ENV === 'development') { - const inputMenu: Electron.MenuItemConstructorOptions[] = [ - { type: 'separator' }, - ...inspectTemplate, - ]; - - Menu.buildFromTemplate(inputMenu).popup({ window: windowController.window }); - } else { - Menu.buildFromTemplate(menuTemplate).popup({ window: windowController.window }); - } - } else if (process.env.NODE_ENV === 'development') { - // display inspect element for all non-editable - // elements when in development mode - Menu.buildFromTemplate(inspectTemplate).popup({ window: windowController.window }); - } - }, - ); - } - - private createTray(): Tray { - const tray = new Tray(nativeImage.createEmpty()); - tray.setToolTip('Mullvad VPN'); - - // disable double click on tray icon since it causes weird delay - tray.setIgnoreDoubleClickEvents(true); - - return tray; + private shouldShowWindowOnStart(): boolean { + return this.settings.gui.unpinnedWindow && !this.settings.gui.startMinimized; } - private installTrayClickHandlers() { - switch (process.platform) { - case 'win32': - if (this.guiSettings.unpinnedWindow) { - // This needs to be executed on click since if it is added to the tray icon it will be - // displayed on left click as well. - this.tray?.on('right-click', () => - this.trayIconController?.popUpContextMenu( - this.connectedToDaemon, - this.isLoggedIn(), - this.tunnelState, - ), - ); - this.tray?.on('click', () => this.windowController?.show()); - } else { - this.tray?.on('right-click', () => this.windowController?.hide()); - this.tray?.on('click', () => this.windowController?.toggle()); - } + private async updateMacOsScrollbarVisibility(): Promise<void> { + const command = + 'defaults read kCFPreferencesAnyApplication AppleShowScrollBars || echo Automatic'; + const { stdout } = await execAsync(command); + switch (stdout.trim()) { + case 'WhenScrolling': + this.macOsScrollbarVisibility = MacOsScrollbarVisibility.whenScrolling; break; - case 'darwin': - this.tray?.on('right-click', () => this.windowController?.hide()); - this.tray?.on('click', (event) => { - if (event.metaKey) { - setImmediate(() => this.windowController?.updatePosition()); - } else { - if (isMacOs11OrNewer() && !this.windowController?.isVisible()) { - // This is a workaround for this Electron issue, when it's resolved - // `this.windowController?.toggle()` should do the trick on all platforms: - // https://github.com/electron/electron/issues/28776 - const contextMenu = Menu.buildFromTemplate([]); - contextMenu.on('menu-will-show', () => this.windowController?.show()); - this.tray?.popUpContextMenu(contextMenu); - } else { - this.windowController?.toggle(); - } - } - }); + case 'Always': + this.macOsScrollbarVisibility = MacOsScrollbarVisibility.always; break; - case 'linux': - this.tray?.on('click', () => this.windowController?.show()); + case 'Automatic': + default: + this.macOsScrollbarVisibility = MacOsScrollbarVisibility.automatic; break; } - } - - private setTrayContextMenu() { - this.trayIconController?.setContextMenu( - this.connectedToDaemon, - this.isLoggedIn(), - this.tunnelState, - ); - } - - private installWindowsMenubarAppWindowHandlers(tray: Tray, windowController: WindowController) { - if (!this.guiSettings.unpinnedWindow) { - windowController.window?.on('blur', () => { - // Detect if blur happened when user had a cursor above the tray icon. - const trayBounds = tray.getBounds(); - const cursorPos = screen.getCursorScreenPoint(); - const isCursorInside = - cursorPos.x >= trayBounds.x && - cursorPos.y >= trayBounds.y && - cursorPos.x <= trayBounds.x + trayBounds.width && - cursorPos.y <= trayBounds.y + trayBounds.height; - if (!isCursorInside && !this.browsingFiles) { - windowController.hide(); - } - }); - } - } - - // setup NSEvent monitor to fix inconsistent window.blur on macOS - // see https://github.com/electron/electron/issues/8689 - private installMacOsMenubarAppWindowHandlers(windowController: WindowController) { - if (!this.guiSettings.unpinnedWindow) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { NSEventMonitor, NSEventMask } = require('nseventmonitor'); - const macEventMonitor = new NSEventMonitor(); - const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown; - - windowController.window?.on('show', () => - macEventMonitor.start(eventMask, () => windowController.hide()), - ); - windowController.window?.on('hide', () => macEventMonitor.stop()); - windowController.window?.on('blur', () => { - // Make sure to hide the menubar window when other program captures the focus. - // But avoid doing that when dev tools capture the focus to make it possible to inspect the UI - if ( - windowController.window?.isVisible() && - !windowController.window?.webContents.isDevToolsFocused() - ) { - windowController.hide(); - } - }); - } - } - - private installWindowCloseHandler(windowController: WindowController) { - if (this.guiSettings.unpinnedWindow) { - windowController.window?.on('close', (closeEvent: Event) => { - if (this.quitStage !== AppQuitStage.ready) { - closeEvent.preventDefault(); - windowController.hide(); - } - }); - } - } - private shouldShowWindowOnStart(): boolean { - switch (process.platform) { - case 'win32': - case 'darwin': - case 'linux': - return this.guiSettings.unpinnedWindow && !this.guiSettings.startMinimized; - default: - return true; - } + IpcMainEventChannel.window.notifyMacOsScrollbarVisibility?.(this.macOsScrollbarVisibility); } - private async openLink(url: string, withAuth?: boolean) { + /* eslint-disable @typescript-eslint/member-ordering */ + // NotificationControllerDelagate + public openApp = () => this.userInterface?.showWindow(); + public openLink = async (url: string, withAuth?: boolean) => { if (withAuth) { let token = ''; try { @@ -2111,36 +957,66 @@ class ApplicationMain { } else { return shell.openExternal(url); } - } + }; - private getProblemReportPath(id: string): string { - return path.join(app.getPath('temp'), `${id}.log`); - } + // UserInterfaceDelegate + public cancelPendingNotifications = () => + this.notificationController.cancelPendingNotifications(); + public resetTunnelStateAnnouncements = () => + this.notificationController.resetTunnelStateAnnouncements(); + public isUnpinnedWindow = () => this.settings.gui.unpinnedWindow; + public getAppQuitStage = () => this.quitStage; + public updateAccountData = () => this.account.updateAccountData(); + public getAccountData = () => this.account.accountData; - private async updateMacOsScrollbarVisibility(): Promise<void> { - const command = - 'defaults read kCFPreferencesAnyApplication AppleShowScrollBars || echo Automatic'; - const { stdout } = await execAsync(command); - switch (stdout.trim()) { - case 'WhenScrolling': - this.macOsScrollbarVisibility = MacOsScrollbarVisibility.whenScrolling; - break; - case 'Always': - this.macOsScrollbarVisibility = MacOsScrollbarVisibility.always; - break; - case 'Automatic': - default: - this.macOsScrollbarVisibility = MacOsScrollbarVisibility.automatic; - break; + // TunnelStateHandlerDelegate + public handleTunnelStateUpdate = (tunnelState: TunnelState) => { + this.userInterface?.updateTray( + this.account.isLoggedIn(), + tunnelState, + this.settings.blockWhenDisconnected, + ); + + this.notificationController.notifyTunnelState( + tunnelState, + this.settings.blockWhenDisconnected, + this.settings.splitTunnel.enableExclusions && this.settings.splitTunnel.appsList.length > 0, + this.userInterface?.isWindowVisible() ?? false, + this.settings.gui.enableSystemNotifications, + this.account.accountData?.expiry, + ); + + IpcMainEventChannel.tunnel.notify?.(tunnelState); + + if (this.account.accountData) { + this.account.detectStaleAccountExpiry(tunnelState); } + }; - if (this.windowController?.webContents) { - IpcMainEventChannel.window.notifyMacOsScrollbarVisibility( - this.windowController.webContents, - this.macOsScrollbarVisibility, - ); + // SettingsDelegate + public handleMonochromaticIconChange = (value: boolean) => + this.userInterface?.setUseMonochromaticTrayIcon(value) ?? Promise.resolve(); + public handleUnpinnedWindowChange = () => + void this.userInterface?.recreateWindow( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + ); + + // AccountDelegate + public getLocale = () => this.locale; + public getTunnelState = () => this.tunnelState.tunnelState; + public onDeviceEvent = () => { + this.userInterface?.updateTray( + this.account.isLoggedIn(), + this.tunnelState.tunnelState, + this.settings.blockWhenDisconnected, + ); + + if (this.isPerformingPostUpgrade) { + void this.performPostUpgradeCheck(); } - } + }; + /* eslint-enable @typescript-eslint/member-ordering */ } const applicationMain = new ApplicationMain(); diff --git a/gui/src/main/ipc-event-channel.ts b/gui/src/main/ipc-event-channel.ts index d883f52152..d189634950 100644 --- a/gui/src/main/ipc-event-channel.ts +++ b/gui/src/main/ipc-event-channel.ts @@ -1,6 +1,48 @@ -import { ipcMain } from 'electron'; +import { ipcMain, WebContents } from 'electron'; -import { createIpcMain } from '../shared/ipc-helpers'; +import { createIpcMain, IpcMain, Schema } from '../shared/ipc-helpers'; import { ipcSchema } from '../shared/ipc-schema'; -export const IpcMainEventChannel = createIpcMain(ipcSchema, ipcMain); +// Type where the notify functions have been replaced with either `undefined` if no `WebContents` is +// available, or with the function curried with the `WebContents`. +type IpcMainBootstrappedWithWebContents<S extends Schema> = { + [GK in keyof IpcMain<S>]: { + [CK in keyof IpcMain<S>[GK]]: CK extends `notify${string}` + ? undefined | ((arg: Parameters<IpcMain<S>[GK][CK]>[1]) => ReturnType<IpcMain<S>[GK][CK]>) + : IpcMain<S>[GK][CK]; + }; +}; + +const ipcMainFromSchema = createIpcMain(ipcSchema, ipcMain); +// eslint-disable-next-line @typescript-eslint/naming-convention +export let IpcMainEventChannel = bootstrapIpcMainWithWebContents(); + +// Curries all notify functions with `WebContents` if it's not `undefined`. If it is `undefined` +// then the whole function will be replaced with `undefined`. +function bootstrapIpcMainWithWebContents( + webContents?: WebContents, +): IpcMainBootstrappedWithWebContents<typeof ipcSchema> { + return Object.fromEntries( + Object.entries(ipcMainFromSchema).map(([groupKey, group]) => { + const newGroup = Object.fromEntries( + Object.entries(group).map(([callKey, call]) => { + if (callKey.startsWith('notify')) { + const newCall = webContents + ? (arg: Parameters<typeof call>[1]) => call(webContents, arg) + : undefined; + return [callKey, newCall]; + } else { + return [callKey, call]; + } + }), + ); + + return [groupKey, newGroup]; + }), + ) as IpcMainBootstrappedWithWebContents<typeof ipcSchema>; +} + +// Change the `IpcMainEventChannel` for a new one with a new `WebContents`. +export function changeIpcWebContents(webContents?: WebContents) { + IpcMainEventChannel = bootstrapIpcMainWithWebContents(webContents); +} diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts index 1f3b143590..06a05366a7 100644 --- a/gui/src/main/notification-controller.ts +++ b/gui/src/main/notification-controller.ts @@ -15,11 +15,13 @@ import { SystemNotificationProvider, } from '../shared/notifications/notification'; -interface NotificationControllerDelegate { +export interface NotificationSender { + notify(notification: SystemNotification): void; +} + +export interface NotificationControllerDelegate { openApp(): void; openLink(url: string, withAuth?: boolean): Promise<void>; - isWindowVisible(): boolean; - areSystemNotificationsEnabled(): boolean; } export default class NotificationController { @@ -52,6 +54,8 @@ export default class NotificationController { tunnelState: TunnelState, blockWhenDisconnected: boolean, hasExcludedApps: boolean, + isWindowVisible: boolean, + areSystemNotificationsEnabled: boolean, accountExpiry?: string, ) { const notificationProviders: SystemNotificationProvider[] = [ @@ -70,7 +74,11 @@ export default class NotificationController { const notification = notificationProvider.getSystemNotification(); if (notification) { - this.showTunnelStateNotification(notification); + this.showTunnelStateNotification( + notification, + isWindowVisible, + areSystemNotificationsEnabled, + ); } else { log.error( `Notification providers mayDisplay() returned true but getSystemNotification() returned undefined for ${notificationProvider.constructor.name}`, @@ -92,8 +100,14 @@ export default class NotificationController { this.lastTunnelStateAnnouncement = undefined; } - public notify(systemNotification: SystemNotification) { - if (this.evaluateNotification(systemNotification)) { + public notify( + systemNotification: SystemNotification, + isWindowVisible: boolean, + areSystemNotificationsEnabled: boolean, + ) { + if ( + this.evaluateNotification(systemNotification, isWindowVisible, areSystemNotificationsEnabled) + ) { const notification = this.createNotification(systemNotification); this.addPendingNotification(notification); notification.show(); @@ -141,7 +155,11 @@ export default class NotificationController { } } - private showTunnelStateNotification(systemNotification: SystemNotification) { + private showTunnelStateNotification( + systemNotification: SystemNotification, + isWindowVisible: boolean, + areSystemNotificationsEnabled: boolean, + ) { const message = systemNotification.message; const lastAnnouncement = this.lastTunnelStateAnnouncement; const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message; @@ -154,7 +172,11 @@ export default class NotificationController { lastAnnouncement.notification.close(); } - const newNotification = this.notify(systemNotification); + const newNotification = this.notify( + systemNotification, + isWindowVisible, + areSystemNotificationsEnabled, + ); if (newNotification) { this.lastTunnelStateAnnouncement = { @@ -179,13 +201,15 @@ export default class NotificationController { } } - private evaluateNotification(notification: SystemNotification) { + private evaluateNotification( + notification: SystemNotification, + isWindowVisible: boolean, + areSystemNotificationsEnabled: boolean, + ) { const suppressDueToDevelopment = notification.suppressInDevelopment && process.env.NODE_ENV === 'development'; - const suppressDueToVisibleWindow = this.notificationControllerDelegate.isWindowVisible(); - const suppressDueToPreference = - !this.notificationControllerDelegate.areSystemNotificationsEnabled() && - !notification.critical; + const suppressDueToVisibleWindow = isWindowVisible; + const suppressDueToPreference = !areSystemNotificationsEnabled && !notification.critical; return ( !suppressDueToDevelopment && diff --git a/gui/src/main/problem-report.ts b/gui/src/main/problem-report.ts new file mode 100644 index 0000000000..be84814d72 --- /dev/null +++ b/gui/src/main/problem-report.ts @@ -0,0 +1,72 @@ +import { execFile } from 'child_process'; +import { randomUUID } from 'crypto'; +import { app, shell } from 'electron'; +import * as path from 'path'; + +import log from '../shared/logging'; +import { IpcMainEventChannel } from './ipc-event-channel'; +import { resolveBin } from './proc'; + +export function registerIpcListeners() { + IpcMainEventChannel.problemReport.handleCollectLogs(collectLogs); + + IpcMainEventChannel.problemReport.handleSendReport(({ email, message, savedReportId }) => { + return send(email, message, savedReportId); + }); + + IpcMainEventChannel.problemReport.handleViewLog((savedReportId) => + shell.openPath(getProblemReportPath(savedReportId)), + ); +} + +function collectLogs(toRedact?: string): Promise<string> { + const id = randomUUID(); + const reportPath = getProblemReportPath(id); + const executable = resolveBin('mullvad-problem-report'); + const args = ['collect', '--output', reportPath]; + if (toRedact) { + args.push('--redact', toRedact); + } + + return new Promise((resolve, reject) => { + execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => { + if (error) { + log.error( + `Failed to collect a problem report. + Stdout: ${stdout.toString()} + Stderr: ${stderr.toString()}`, + ); + reject(error.message); + } else { + log.verbose(`Problem report was written to ${reportPath}`); + resolve(id); + } + }); + }); +} + +function send(email: string, message: string, savedReportId: string): Promise<void> { + const executable = resolveBin('mullvad-problem-report'); + const reportPath = getProblemReportPath(savedReportId); + const args = ['send', '--email', email, '--message', message, '--report', reportPath]; + + return new Promise((resolve, reject) => { + execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => { + if (error) { + log.error( + `Failed to send a problem report. + Stdout: ${stdout.toString()} + Stderr: ${stderr.toString()}`, + ); + reject(error.message); + } else { + log.info('Problem report was sent.'); + resolve(); + } + }); + }); +} + +function getProblemReportPath(id: string): string { + return path.join(app.getPath('temp'), `${id}.log`); +} diff --git a/gui/src/main/relay-list.ts b/gui/src/main/relay-list.ts new file mode 100644 index 0000000000..8583f58776 --- /dev/null +++ b/gui/src/main/relay-list.ts @@ -0,0 +1,109 @@ +import { BridgeState, IRelayList, liftConstraint, RelaySettings } from '../shared/daemon-rpc-types'; +import { IpcMainEventChannel } from './ipc-event-channel'; + +interface RelayLists { + relays: IRelayList; + bridges: IRelayList; +} + +export default class RelayList { + private relays: IRelayList = { countries: [] }; + + public setRelays( + newRelayList: IRelayList, + relaySettings: RelaySettings, + bridgeState: BridgeState, + ) { + this.relays = newRelayList; + + const processedRelays = this.processRelays(newRelayList, relaySettings, bridgeState); + IpcMainEventChannel.relays.notify?.(processedRelays); + } + + public updateSettings(relaySettings: RelaySettings, bridgeState: BridgeState) { + this.setRelays(this.relays, relaySettings, bridgeState); + } + + public getProcessedRelays(relaySettings: RelaySettings, bridgeState: BridgeState) { + return this.processRelays(this.relays, relaySettings, bridgeState); + } + + private processRelays( + relayList: IRelayList, + relaySettings: RelaySettings, + bridgeState: BridgeState, + ): RelayLists { + const filteredRelays = this.processRelaysForPresentation(relayList, relaySettings); + const filteredBridges = this.processBridgesForPresentation(relayList, bridgeState); + + return { relays: filteredRelays, bridges: filteredBridges }; + } + + private processRelaysForPresentation( + relayList: IRelayList, + relaySettings: RelaySettings, + ): IRelayList { + const tunnelProtocol = + 'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined; + + const filteredCountries = relayList.countries + .map((country) => ({ + ...country, + cities: country.cities + .map((city) => ({ + ...city, + relays: city.relays.filter((relay) => { + if (relay.endpointType != 'bridge') { + switch (tunnelProtocol) { + case 'openvpn': + return relay.endpointType == 'openvpn'; + + case 'wireguard': + return relay.endpointType == 'wireguard'; + + case 'any': { + const useMultihop = + 'normal' in relaySettings && + relaySettings.normal.wireguardConstraints.useMultihop; + return !useMultihop || relay.endpointType == 'wireguard'; + } + default: + return false; + } + } else { + return false; + } + }), + })) + .filter((city) => city.relays.length > 0), + })) + .filter((country) => country.cities.length > 0); + + return { + countries: filteredCountries, + }; + } + + private processBridgesForPresentation( + relayList: IRelayList, + bridgeState: BridgeState, + ): IRelayList { + if (bridgeState === 'on') { + const filteredCountries = relayList.countries + .map((country) => ({ + ...country, + cities: country.cities + .map((city) => ({ + ...city, + relays: city.relays.filter((relay) => relay.endpointType == 'bridge'), + })) + .filter((city) => city.relays.length > 0), + })) + .filter((country) => country.cities.length > 0); + + return { countries: filteredCountries }; + } else { + return { countries: [] }; + } + } +} diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts new file mode 100644 index 0000000000..3a12852011 --- /dev/null +++ b/gui/src/main/settings.ts @@ -0,0 +1,241 @@ +import BridgeSettingsBuilder from '../shared/bridge-settings-builder'; +import { ISettings, ObfuscationType, Ownership } from '../shared/daemon-rpc-types'; +import { ICurrentAppVersionInfo } from '../shared/ipc-types'; +import log from '../shared/logging'; +import { getOpenAtLogin, setOpenAtLogin } from './autostart'; +import { DaemonRpc } from './daemon-rpc'; +import GuiSettings from './gui-settings'; +import { IpcMainEventChannel } from './ipc-event-channel'; + +export interface SettingsDelegate { + handleMonochromaticIconChange(value: boolean): Promise<void>; + handleUnpinnedWindowChange(): void; +} + +export default class Settings implements Readonly<ISettings> { + private guiSettings = new GuiSettings(); + + private settingsValue: ISettings = { + allowLan: false, + autoConnect: false, + blockWhenDisconnected: false, + showBetaReleases: false, + splitTunnel: { + enableExclusions: false, + appsList: [], + }, + relaySettings: { + normal: { + location: 'any', + tunnelProtocol: 'any', + providers: [], + ownership: Ownership.any, + openvpnConstraints: { + port: 'any', + protocol: 'any', + }, + wireguardConstraints: { + port: 'any', + ipVersion: 'any', + useMultihop: false, + entryLocation: 'any', + }, + }, + }, + bridgeSettings: { + normal: { + location: 'any', + providers: [], + ownership: Ownership.any, + }, + }, + bridgeState: 'auto', + tunnelOptions: { + generic: { + enableIpv6: false, + }, + openvpn: { + mssfix: undefined, + }, + wireguard: { + mtu: undefined, + }, + dns: { + state: 'default', + defaultOptions: { + blockAds: false, + blockTrackers: false, + blockMalware: false, + blockAdultContent: false, + blockGambling: false, + }, + customOptions: { + addresses: [], + }, + }, + }, + obfuscationSettings: { + selectedObfuscation: ObfuscationType.auto, + udp2tcpSettings: { + port: 'any', + }, + }, + }; + + public constructor( + private delegate: SettingsDelegate, + private daemonRpc: DaemonRpc, + private currentVersion: ICurrentAppVersionInfo, + ) {} + + public registerIpcListeners() { + this.registerGuiSettingsListener(); + + IpcMainEventChannel.settings.handleSetAllowLan((allowLan) => + this.daemonRpc.setAllowLan(allowLan), + ); + IpcMainEventChannel.settings.handleSetShowBetaReleases((showBetaReleases) => + this.daemonRpc.setShowBetaReleases(showBetaReleases), + ); + IpcMainEventChannel.settings.handleSetEnableIpv6((enableIpv6) => + this.daemonRpc.setEnableIpv6(enableIpv6), + ); + IpcMainEventChannel.settings.handleSetBlockWhenDisconnected((blockWhenDisconnected) => + this.daemonRpc.setBlockWhenDisconnected(blockWhenDisconnected), + ); + IpcMainEventChannel.settings.handleSetBridgeState(async (bridgeState) => { + await this.daemonRpc.setBridgeState(bridgeState); + + // Reset bridge constraints to `any` when the state is set to auto or off + if (bridgeState === 'auto' || bridgeState === 'off') { + await this.daemonRpc.setBridgeSettings(new BridgeSettingsBuilder().location.any().build()); + } + }); + IpcMainEventChannel.settings.handleSetOpenVpnMssfix((mssfix?: number) => + this.daemonRpc.setOpenVpnMssfix(mssfix), + ); + IpcMainEventChannel.settings.handleSetWireguardMtu((mtu?: number) => + this.daemonRpc.setWireguardMtu(mtu), + ); + IpcMainEventChannel.settings.handleUpdateRelaySettings((update) => + this.daemonRpc.updateRelaySettings(update), + ); + IpcMainEventChannel.settings.handleUpdateBridgeSettings((bridgeSettings) => { + return this.daemonRpc.setBridgeSettings(bridgeSettings); + }); + IpcMainEventChannel.settings.handleSetDnsOptions((dns) => { + return this.daemonRpc.setDnsOptions(dns); + }); + IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => { + return this.setAutoStart(autoStart); + }); + IpcMainEventChannel.settings.handleSetObfuscationSettings((obfuscationSettings) => { + return this.daemonRpc.setObfuscationSettings(obfuscationSettings); + }); + + IpcMainEventChannel.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => { + this.guiSettings.enableSystemNotifications = flag; + }); + + IpcMainEventChannel.guiSettings.handleSetAutoConnect((autoConnect: boolean) => { + this.guiSettings.autoConnect = autoConnect; + }); + + IpcMainEventChannel.guiSettings.handleSetStartMinimized((startMinimized: boolean) => { + this.guiSettings.startMinimized = startMinimized; + }); + + IpcMainEventChannel.guiSettings.handleSetMonochromaticIcon((monochromaticIcon: boolean) => { + this.guiSettings.monochromaticIcon = monochromaticIcon; + }); + + IpcMainEventChannel.guiSettings.handleSetUnpinnedWindow((unpinnedWindow: boolean) => { + this.guiSettings.unpinnedWindow = unpinnedWindow; + this.delegate.handleUnpinnedWindowChange(); + }); + + IpcMainEventChannel.currentVersion.handleDisplayedChangelog(() => { + this.guiSettings.changelogDisplayedForVersion = this.currentVersion.gui; + }); + } + + public get all() { + return this.settingsValue; + } + + public get allowLan() { + return this.settingsValue.allowLan; + } + public get autoConnect() { + return this.settingsValue.autoConnect; + } + public get blockWhenDisconnected() { + return this.settingsValue.blockWhenDisconnected; + } + public get showBetaReleases() { + return this.settingsValue.showBetaReleases; + } + public get relaySettings() { + return this.settingsValue.relaySettings; + } + public get tunnelOptions() { + return this.settingsValue.tunnelOptions; + } + public get bridgeSettings() { + return this.settingsValue.bridgeSettings; + } + public get bridgeState() { + return this.settingsValue.bridgeState; + } + public get splitTunnel() { + return this.settingsValue.splitTunnel; + } + public get obfuscationSettings() { + return this.settingsValue.obfuscationSettings; + } + + public get gui() { + return this.guiSettings; + } + + public handleNewSettings(newSettings: ISettings) { + this.settingsValue = newSettings; + } + + private registerGuiSettingsListener() { + this.guiSettings.onChange = async (newState, oldState) => { + if (oldState.monochromaticIcon !== newState.monochromaticIcon) { + await this.delegate.handleMonochromaticIconChange(newState.monochromaticIcon); + } + + if (newState.autoConnect !== oldState.autoConnect) { + this.updateDaemonsAutoConnect(); + } + + IpcMainEventChannel.guiSettings.notify?.(newState); + }; + } + + private async setAutoStart(autoStart: boolean): Promise<void> { + try { + await setOpenAtLogin(autoStart); + + IpcMainEventChannel.autoStart.notify?.(autoStart); + + this.updateDaemonsAutoConnect(); + } catch (e) { + const error = e as Error; + log.error( + `Failed to update the autostart to ${autoStart.toString()}. ${error.message.toString()}`, + ); + } + return Promise.resolve(); + } + + private updateDaemonsAutoConnect() { + const daemonAutoConnect = this.guiSettings.autoConnect && getOpenAtLogin(); + if (daemonAutoConnect !== this.settingsValue.autoConnect) { + void this.daemonRpc.setAutoConnect(daemonAutoConnect); + } + } +} diff --git a/gui/src/main/tray-icon-controller.ts b/gui/src/main/tray-icon-controller.ts index 723228eac6..3543415b1e 100644 --- a/gui/src/main/tray-icon-controller.ts +++ b/gui/src/main/tray-icon-controller.ts @@ -1,15 +1,10 @@ import { exec as execAsync } from 'child_process'; -import { Menu, NativeImage, nativeImage, Tray } from 'electron'; +import { NativeImage, nativeImage, Tray } from 'electron'; import path from 'path'; -import { sprintf } from 'sprintf-js'; import { promisify } from 'util'; -import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper'; -import { ILocation, TunnelState } from '../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../shared/gettext'; import log from '../shared/logging'; import KeyframeAnimation from './keyframe-animation'; -import WindowController from './window-controller'; const exec = promisify(execAsync); @@ -29,12 +24,8 @@ export default class TrayIconController { constructor( private tray: Tray, - private windowController: WindowController, private iconTypeValue: TrayIconType, private useMonochromaticIconValue: boolean, - private connect: () => void, - private reconnect: () => void, - private disconnect: () => void, ) { this.loadImages(); } @@ -46,10 +37,6 @@ export default class TrayIconController { } } - public setWindowController(windowController: WindowController) { - this.windowController = windowController; - } - get iconType(): TrayIconType { return this.iconTypeValue; } @@ -102,66 +89,6 @@ export default class TrayIconController { animation.play({ end: frame }); } - public setContextMenu(connectedToDaemon: boolean, loggedIn: boolean, tunnelState: TunnelState) { - if (process.platform === 'linux') { - this.tray.setContextMenu(this.createContextMenu(connectedToDaemon, loggedIn, tunnelState)); - } - } - - public setTooltip(connectedToDaemon: boolean, tunnelState: TunnelState) { - const tooltip = this.createTooltipText(connectedToDaemon, tunnelState); - this.tray?.setToolTip(tooltip); - } - - public popUpContextMenu(connectedToDaemon: boolean, loggedIn: boolean, tunnelState: TunnelState) { - this.tray.popUpContextMenu(this.createContextMenu(connectedToDaemon, loggedIn, tunnelState)); - } - - private createTooltipText(connectedToDaemon: boolean, tunnelState: TunnelState): string { - if (!connectedToDaemon) { - return messages.pgettext('tray-icon-context-menu', 'Disconnected from system service'); - } - - switch (tunnelState.state) { - case 'disconnected': - return messages.gettext('Disconnected'); - case 'disconnecting': - return messages.gettext('Disconnecting'); - case 'connecting': { - const location = this.createLocationString(tunnelState.details?.location); - return location - ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connecting. %(location)s'), { - location, - }) - : messages.gettext('Connecting'); - } - case 'connected': { - const location = this.createLocationString(tunnelState.details.location); - return location - ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connected. %(location)s'), { - location, - }) - : messages.gettext('Connected'); - } - } - - return 'Mullvad VPN'; - } - - private createLocationString(location?: ILocation): string | undefined { - if (location === undefined) { - return undefined; - } - - const country = relayLocations.gettext(location.country); - return location.city - ? sprintf(messages.pgettext('tray-icon-tooltip', '%(city)s, %(country)s'), { - city: relayLocations.gettext(location.city), - country, - }) - : country; - } - private initAnimation() { const initialFrame = this.targetFrame(); const animation = new KeyframeAnimation(); @@ -252,40 +179,4 @@ export default class TrayIconController { return 8; } } - - private createContextMenu( - connectedToDaemon: boolean, - loggedIn: boolean, - tunnelState: TunnelState, - ) { - const template: Electron.MenuItemConstructorOptions[] = [ - { - label: sprintf(messages.pgettext('tray-icon-context-menu', 'Open %(mullvadVpn)s'), { - mullvadVpn: 'Mullvad VPN', - }), - click: () => this.windowController.show(), - }, - { type: 'separator' }, - { - id: 'connect', - label: messages.gettext('Connect'), - enabled: connectEnabled(connectedToDaemon, loggedIn, tunnelState.state), - click: this.connect, - }, - { - id: 'reconnect', - label: messages.gettext('Reconnect'), - enabled: reconnectEnabled(connectedToDaemon, loggedIn, tunnelState.state), - click: this.reconnect, - }, - { - id: 'disconnect', - label: messages.gettext('Disconnect'), - enabled: disconnectEnabled(connectedToDaemon, tunnelState.state), - click: this.disconnect, - }, - ]; - - return Menu.buildFromTemplate(template); - } } diff --git a/gui/src/main/tunnel-state.ts b/gui/src/main/tunnel-state.ts new file mode 100644 index 0000000000..029386f3b7 --- /dev/null +++ b/gui/src/main/tunnel-state.ts @@ -0,0 +1,90 @@ +import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper'; +import { TunnelState } from '../shared/daemon-rpc-types'; +import { Scheduler } from '../shared/scheduler'; + +export interface TunnelStateProvider { + getTunnelState(): TunnelState; +} + +export interface TunnelStateHandlerDelegate { + handleTunnelStateUpdate(tunnelState: TunnelState): void; +} + +export default class TunnelStateHandler { + // The current tunnel state + private tunnelStateValue: TunnelState = { state: 'disconnected' }; + // When pressing connect/disconnect/reconnect the app assumes what the next state will be before + // it get's the new state from the daemon. The latest state from the daemon is saved as fallback + // if the assumed state isn't reached. + private tunnelStateFallback?: TunnelState; + // Scheduler for discarding the assumed next state. + private tunnelStateFallbackScheduler = new Scheduler(); + + public constructor(private delegate: TunnelStateHandlerDelegate) {} + + public get tunnelState() { + return this.tunnelStateValue; + } + + public resetFallback() { + this.tunnelStateFallbackScheduler.cancel(); + this.tunnelStateFallback = undefined; + } + + // This function sets a new tunnel state as an assumed next state and saves the current state as + // fallback. The fallback is used if the assumed next state isn't reached. + public expectNextTunnelState(state: 'connecting' | 'disconnecting') { + this.tunnelStateFallback = this.tunnelState; + + this.setTunnelState( + state === 'disconnecting' ? { state, details: 'nothing' as const } : { state }, + ); + + this.tunnelStateFallbackScheduler.schedule(() => { + if (this.tunnelStateFallback) { + this.setTunnelState(this.tunnelStateFallback); + this.tunnelStateFallback = undefined; + } + }, 3000); + } + + public handleNewTunnelState(newState: TunnelState) { + // If there's a fallback state set then the app is in an assumed next state and need to check + // if it's now reached or if the current state should be ignored and set as the fallback state. + if (this.tunnelStateFallback) { + if (this.tunnelState.state === newState.state || newState.state === 'error') { + this.tunnelStateFallbackScheduler.cancel(); + this.tunnelStateFallback = undefined; + } else { + this.tunnelStateFallback = newState; + return; + } + } + + if (newState.state === 'disconnecting' && newState.details === 'reconnect') { + // When reconnecting there's no need of showing the disconnecting state. This switches to the + // connecting state immediately. + this.expectNextTunnelState('connecting'); + this.tunnelStateFallback = newState; + } else { + this.setTunnelState(newState); + } + } + + public allowConnect(connectToDaemon: boolean, isLoggedIn: boolean) { + return connectEnabled(connectToDaemon, isLoggedIn, this.tunnelState.state); + } + + public allowReconnect(connectToDaemon: boolean, isLoggedIn: boolean) { + return reconnectEnabled(connectToDaemon, isLoggedIn, this.tunnelState.state); + } + + public allowDisconnect(connectToDaemon: boolean) { + return disconnectEnabled(connectToDaemon, this.tunnelState.state); + } + + private setTunnelState(newState: TunnelState) { + this.tunnelStateValue = newState; + this.delegate.handleTunnelStateUpdate(newState); + } +} diff --git a/gui/src/main/user-interface.ts b/gui/src/main/user-interface.ts new file mode 100644 index 0000000000..b7a05c7d23 --- /dev/null +++ b/gui/src/main/user-interface.ts @@ -0,0 +1,640 @@ +import { app, BrowserWindow, dialog, Menu, nativeImage, screen, Tray } from 'electron'; +import path from 'path'; +import { sprintf } from 'sprintf-js'; + +import { closeToExpiry, hasExpired } from '../shared/account-expiry'; +import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper'; +import { IAccountData, ILocation, TunnelState } from '../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../shared/gettext'; +import log from '../shared/logging'; +import { Scheduler } from '../shared/scheduler'; +import { DaemonRpc } from './daemon-rpc'; +import { AppQuitStage } from './index'; +import { changeIpcWebContents, IpcMainEventChannel } from './ipc-event-channel'; +import { isMacOs11OrNewer } from './platform-version'; +import TrayIconController, { TrayIconType } from './tray-icon-controller'; +import WindowController, { WindowControllerDelegate } from './window-controller'; + +export interface UserInterfaceDelegate { + cancelPendingNotifications(): void; + resetTunnelStateAnnouncements(): void; + updateAccountData(): void; + connectTunnel(): void; + reconnectTunnel(): void; + disconnectTunnel(): void; + isUnpinnedWindow(): boolean; + isLoggedIn(): boolean; + getAppQuitStage(): AppQuitStage; + getAccountData(): IAccountData | undefined; + getTunnelState(): TunnelState; +} + +export default class UserInterface implements WindowControllerDelegate { + private windowController: WindowController; + + private tray: Tray; + private trayIconController?: TrayIconController; + + // True while file pickers are displayed which is used to decide if the Browser window should be + // hidden when losing focus. + private browsingFiles = false; + + private blurNavigationResetScheduler = new Scheduler(); + private backgroundThrottleScheduler = new Scheduler(); + + public constructor( + private delegate: UserInterfaceDelegate, + private daemonRpc: DaemonRpc, + private sandboxDisabled: boolean, + private navigationResetDisabled: boolean, + ) { + const window = this.createWindow(); + changeIpcWebContents(window.webContents); + + this.windowController = this.createWindowController(window); + this.tray = this.createTray(); + } + + public registerIpcListeners() { + IpcMainEventChannel.app.handleShowOpenDialog(async (options) => { + this.browsingFiles = true; + const response = await dialog.showOpenDialog({ + defaultPath: app.getPath('home'), + ...options, + }); + this.browsingFiles = false; + return response; + }); + } + + public createTrayIconController( + tunnelState: TunnelState, + blockWhenDisconnected: boolean, + monochromaticIcon: boolean, + ) { + const iconType = this.trayIconType(tunnelState, blockWhenDisconnected); + this.trayIconController = new TrayIconController(this.tray, iconType, monochromaticIcon); + } + + public async initializeWindow(isLoggedIn: boolean, tunnelState: TunnelState) { + if (!this.windowController.window) { + throw new Error('No window available in initializeWindow'); + } + + const window = this.windowController.window; + + this.registerWindowListener(); + this.addContextMenu(); + + if (process.env.NODE_ENV === 'development') { + await this.installDevTools(); + + // The devtools doesn't open on Windows if openDevTools is called without a delay here. + window.once('ready-to-show', () => window.webContents.openDevTools({ mode: 'detach' })); + } + + switch (process.platform) { + case 'win32': + this.installWindowsMenubarAppWindowHandlers(); + break; + case 'darwin': + this.installMacOsMenubarAppWindowHandlers(); + this.setMacOsAppMenu(); + break; + case 'linux': + this.setTrayContextMenu(isLoggedIn, tunnelState); + this.setLinuxAppMenu(); + window.setMenuBarVisibility(false); + break; + } + + this.installWindowCloseHandler(); + this.installTrayClickHandlers(); + + const filePath = path.resolve(path.join(__dirname, '../renderer/index.html')); + try { + await window.loadFile(filePath); + } catch (e) { + const error = e as Error; + log.error(`Failed to load index file: ${error.message}`); + } + + // disable pinch to zoom + if (this.windowController.webContents) { + void this.windowController.webContents.setVisualZoomLevelLimits(1, 1); + } + } + + public updateTray = ( + isLoggedIn: boolean, + tunnelState: TunnelState, + blockWhenDisconnected: boolean, + ) => { + this.updateTrayIcon(tunnelState, blockWhenDisconnected); + this.setTrayContextMenu(isLoggedIn, tunnelState); + this.setTrayTooltip(tunnelState); + }; + + public async recreateWindow(isLoggedIn: boolean, tunnelState: TunnelState): Promise<void> { + if (this.tray) { + this.tray.removeAllListeners(); + + const window = this.createWindow(); + changeIpcWebContents(window.webContents); + + this.windowController.close(); + this.windowController = new WindowController(this, window); + + await this.initializeWindow(isLoggedIn, tunnelState); + this.windowController.show(); + } + } + + 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 setWindowIcon = (icon: string) => this.windowController.window?.setIcon(icon); + + public setWindowClosable = (value: boolean) => { + if (this.windowController.window) { + this.windowController.window.closable = value; + } + }; + + public updateTrayIcon(tunnelState: TunnelState, blockWhenDisconnected: boolean) { + const type = this.trayIconType(tunnelState, blockWhenDisconnected); + this.trayIconController?.animateToIcon(type); + } + + public dispose = () => this.trayIconController?.dispose(); + + private createTray(): Tray { + const tray = new Tray(nativeImage.createEmpty()); + tray.setToolTip('Mullvad VPN'); + + // disable double click on tray icon since it causes weird delay + tray.setIgnoreDoubleClickEvents(true); + + return tray; + } + + private createWindow(): BrowserWindow { + const unpinnedWindow = this.delegate.isUnpinnedWindow(); + const { width, height } = WindowController.getContentSize(unpinnedWindow); + + const options: Electron.BrowserWindowConstructorOptions = { + useContentSize: true, + width, + height, + resizable: false, + maximizable: false, + fullscreenable: false, + show: false, + frame: unpinnedWindow, + webPreferences: { + preload: path.join(__dirname, '../renderer/preloadBundle.js'), + nodeIntegration: false, + nodeIntegrationInWorker: false, + nodeIntegrationInSubFrames: false, + sandbox: !this.sandboxDisabled, + contextIsolation: true, + spellcheck: false, + devTools: process.env.NODE_ENV === 'development', + }, + }; + + switch (process.platform) { + case 'darwin': { + // setup window flags to mimic popover on macOS + const appWindow = new BrowserWindow({ + ...options, + titleBarStyle: unpinnedWindow ? 'default' : 'customButtonsOnHover', + minimizable: unpinnedWindow, + closable: unpinnedWindow, + transparent: !unpinnedWindow, + }); + + // make the window visible on all workspaces and prevent the icon from showing in the dock + // and app switcher. + if (unpinnedWindow) { + void app.dock.show(); + } else { + appWindow.setVisibleOnAllWorkspaces(true); + app.dock.hide(); + } + + return appWindow; + } + + case 'win32': { + // setup window flags to mimic an overlay window + const appWindow = new BrowserWindow({ + ...options, + // Due to a bug in Electron the app is sometimes placed behind other apps when opened. + // Setting alwaysOnTop to true ensures that the app is placed on top. Electron issue: + // https://github.com/electron/electron/issues/25915 + alwaysOnTop: !unpinnedWindow, + skipTaskbar: !unpinnedWindow, + // Workaround for sub-pixel anti-aliasing + // https://github.com/electron/electron/blob/main/docs/faq.md#the-font-looks-blurry-what-is-this-and-what-can-i-do + backgroundColor: '#fff', + }); + const WM_DEVICECHANGE = 0x0219; + const DBT_DEVICEARRIVAL = 0x8000; + const DBT_DEVICEREMOVECOMPLETE = 0x8004; + appWindow.hookWindowMessage(WM_DEVICECHANGE, (wParam) => { + const wParamL = wParam.readBigInt64LE(0); + if (wParamL != DBT_DEVICEARRIVAL && wParamL != DBT_DEVICEREMOVECOMPLETE) { + return; + } + this.daemonRpc + .checkVolumes() + .catch((error) => + log.error(`Unable to notify daemon of device event: ${error.message}`), + ); + }); + + appWindow.removeMenu(); + + return appWindow; + } + + default: + return new BrowserWindow(options); + } + } + + private createWindowController(window: BrowserWindow) { + return new WindowController(this, window); + } + + private registerWindowListener() { + this.windowController.window?.on('focus', () => { + IpcMainEventChannel.window.notifyFocus?.(true); + + this.blurNavigationResetScheduler.cancel(); + + // cancel notifications when window appears + this.delegate.cancelPendingNotifications(); + + const accountData = this.delegate.getAccountData(); + if (!accountData || closeToExpiry(accountData.expiry, 4) || hasExpired(accountData.expiry)) { + this.delegate.updateAccountData(); + } + }); + + 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 + // unpinned window. + this.windowController.window?.on('hide', () => { + if (process.env.NODE_ENV !== 'development' || !this.navigationResetDisabled) { + this.blurNavigationResetScheduler.schedule(() => { + this.windowController.webContents?.setBackgroundThrottling(false); + IpcMainEventChannel.navigation.notifyReset?.(); + + this.backgroundThrottleScheduler.schedule(() => { + this.windowController.webContents?.setBackgroundThrottling(true); + }, 1_000); + }, 120_000); + } + }); + } + + private setTrayContextMenu(isLoggedIn: boolean, tunnelState: TunnelState) { + if (process.platform === 'linux') { + this.tray.setContextMenu( + this.createContextMenu(this.daemonRpc.isConnected, isLoggedIn, tunnelState), + ); + } + } + + private setTrayTooltip(tunnelState: TunnelState) { + const tooltip = this.createTooltipText(this.daemonRpc.isConnected, tunnelState); + this.tray?.setToolTip(tooltip); + } + + private addContextMenu() { + const menuTemplate: Electron.MenuItemConstructorOptions[] = [ + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { type: 'separator' }, + { role: 'selectAll' }, + ]; + + // add inspect element on right click menu + this.windowController.window?.webContents.on( + 'context-menu', + (_e: Event, props: { x: number; y: number; isEditable: boolean }) => { + const inspectTemplate = [ + { + label: 'Inspect element', + click: () => { + this.windowController.window?.webContents.openDevTools({ mode: 'detach' }); + this.windowController.window?.webContents.inspectElement(props.x, props.y); + }, + }, + ]; + + if (props.isEditable) { + // mixin 'inspect element' into standard menu when in development mode + if (process.env.NODE_ENV === 'development') { + const inputMenu: Electron.MenuItemConstructorOptions[] = [ + { type: 'separator' }, + ...inspectTemplate, + ]; + + Menu.buildFromTemplate(inputMenu).popup({ window: this.windowController.window }); + } else { + Menu.buildFromTemplate(menuTemplate).popup({ window: this.windowController.window }); + } + } else if (process.env.NODE_ENV === 'development') { + // display inspect element for all non-editable + // elements when in development mode + Menu.buildFromTemplate(inspectTemplate).popup({ window: this.windowController.window }); + } + }, + ); + } + + private async installDevTools() { + const { default: installer, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } = await import( + 'electron-devtools-installer' + ); + const forceDownload = !!process.env.UPGRADE_EXTENSIONS; + const options = { forceDownload, loadExtensionOptions: { allowFileAccess: true } }; + try { + await installer(REACT_DEVELOPER_TOOLS, options); + await installer(REDUX_DEVTOOLS, options); + } catch (e) { + const error = e as Error; + log.info(`Error installing extension: ${error.message}`); + } + } + + // On macOS, hotkeys are bound to the app menu and won't work if it's not set, + // even though the app menu itself is not visible because the app does not appear in the dock. + private setMacOsAppMenu() { + const mullvadVpnSubmenu: Electron.MenuItemConstructorOptions[] = [{ role: 'quit' }]; + if (process.env.NODE_ENV === 'development') { + mullvadVpnSubmenu.unshift({ role: 'reload' }, { role: 'forceReload' }); + } + + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: 'Mullvad VPN', + submenu: mullvadVpnSubmenu, + }, + { + label: 'Edit', + submenu: [ + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { type: 'separator' }, + { role: 'selectAll' }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + } + + private setLinuxAppMenu() { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: 'Mullvad VPN', + submenu: [{ role: 'quit' }], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + } + + private installWindowsMenubarAppWindowHandlers() { + if (this.delegate.isUnpinnedWindow()) { + return; + } + + this.windowController.window?.on('blur', () => { + // Detect if blur happened when user had a cursor above the tray icon. + const trayBounds = this.tray.getBounds(); + const cursorPos = screen.getCursorScreenPoint(); + const isCursorInside = + cursorPos.x >= trayBounds.x && + cursorPos.y >= trayBounds.y && + cursorPos.x <= trayBounds.x + trayBounds.width && + cursorPos.y <= trayBounds.y + trayBounds.height; + if (!isCursorInside && !this.browsingFiles) { + this.windowController.hide(); + } + }); + } + + // setup NSEvent monitor to fix inconsistent window.blur on macOS + // see https://github.com/electron/electron/issues/8689 + private installMacOsMenubarAppWindowHandlers() { + if (this.delegate.isUnpinnedWindow()) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { NSEventMonitor, NSEventMask } = require('nseventmonitor'); + const macEventMonitor = new NSEventMonitor(); + const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown; + + this.windowController.window?.on('show', () => + macEventMonitor.start(eventMask, () => this.windowController.hide()), + ); + this.windowController.window?.on('hide', () => macEventMonitor.stop()); + this.windowController.window?.on('blur', () => { + // Make sure to hide the menubar window when other program captures the focus. + // But avoid doing that when dev tools capture the focus to make it possible to inspect the UI + if ( + this.windowController.window?.isVisible() && + !this.windowController.window?.webContents.isDevToolsFocused() + ) { + this.windowController.hide(); + } + }); + } + + private installWindowCloseHandler() { + if (!this.delegate.isUnpinnedWindow()) { + return; + } + + this.windowController.window?.on('close', (closeEvent: Event) => { + if (this.delegate.getAppQuitStage() !== AppQuitStage.ready) { + closeEvent.preventDefault(); + this.windowController.hide(); + } + }); + } + + private installTrayClickHandlers() { + switch (process.platform) { + case 'win32': + if (this.delegate.isUnpinnedWindow()) { + // This needs to be executed on click since if it is added to the tray icon it will be + // displayed on left click as well. + this.tray?.on('right-click', () => + this.popUpContextMenu(this.delegate.isLoggedIn(), this.delegate.getTunnelState()), + ); + this.tray?.on('click', () => this.windowController.show()); + } else { + this.tray?.on('right-click', () => this.windowController.hide()); + this.tray?.on('click', () => this.windowController.toggle()); + } + break; + case 'darwin': + this.tray?.on('right-click', () => this.windowController.hide()); + this.tray?.on('click', (event) => { + if (event.metaKey) { + setImmediate(() => this.windowController.updatePosition()); + } else { + if (isMacOs11OrNewer() && !this.windowController.isVisible()) { + // This is a workaround for this Electron issue, when it's resolved + // `this.windowController.toggle()` should do the trick on all platforms: + // https://github.com/electron/electron/issues/28776 + const contextMenu = Menu.buildFromTemplate([]); + contextMenu.on('menu-will-show', () => this.windowController.show()); + this.tray?.popUpContextMenu(contextMenu); + } else { + this.windowController.toggle(); + } + } + }); + break; + case 'linux': + this.tray?.on('click', () => this.windowController.show()); + break; + } + } + + private popUpContextMenu(isLoggedIn: boolean, tunnelState: TunnelState) { + this.tray.popUpContextMenu( + this.createContextMenu(this.daemonRpc.isConnected, isLoggedIn, tunnelState), + ); + } + + private createTooltipText(connectedToDaemon: boolean, tunnelState: TunnelState): string { + if (!connectedToDaemon) { + return messages.pgettext('tray-icon-context-menu', 'Disconnected from system service'); + } + + switch (tunnelState.state) { + case 'disconnected': + return messages.gettext('Disconnected'); + case 'disconnecting': + return messages.gettext('Disconnecting'); + case 'connecting': { + const location = this.createLocationString(tunnelState.details?.location); + return location + ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connecting. %(location)s'), { + location, + }) + : messages.gettext('Connecting'); + } + case 'connected': { + const location = this.createLocationString(tunnelState.details.location); + return location + ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connected. %(location)s'), { + location, + }) + : messages.gettext('Connected'); + } + } + + return 'Mullvad VPN'; + } + + private createLocationString(location?: ILocation): string | undefined { + if (location === undefined) { + return undefined; + } + + const country = relayLocations.gettext(location.country); + return location.city + ? sprintf(messages.pgettext('tray-icon-tooltip', '%(city)s, %(country)s'), { + city: relayLocations.gettext(location.city), + country, + }) + : country; + } + + private createContextMenu( + connectedToDaemon: boolean, + loggedIn: boolean, + tunnelState: TunnelState, + ) { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: sprintf(messages.pgettext('tray-icon-context-menu', 'Open %(mullvadVpn)s'), { + mullvadVpn: 'Mullvad VPN', + }), + click: () => this.windowController.show(), + }, + { type: 'separator' }, + { + id: 'connect', + label: messages.gettext('Connect'), + enabled: connectEnabled(connectedToDaemon, loggedIn, tunnelState.state), + click: this.delegate.connectTunnel, + }, + { + id: 'reconnect', + label: messages.gettext('Reconnect'), + enabled: reconnectEnabled(connectedToDaemon, loggedIn, tunnelState.state), + click: this.delegate.reconnectTunnel, + }, + { + id: 'disconnect', + label: messages.gettext('Disconnect'), + enabled: disconnectEnabled(connectedToDaemon, tunnelState.state), + click: this.delegate.disconnectTunnel, + }, + ]; + + return Menu.buildFromTemplate(template); + } + + private trayIconType(tunnelState: TunnelState, blockWhenDisconnected: boolean): TrayIconType { + switch (tunnelState.state) { + case 'connected': + return 'secured'; + + case 'connecting': + return 'securing'; + + case 'error': + if (!tunnelState.details.blockFailure) { + return 'securing'; + } else { + return 'unsecured'; + } + case 'disconnecting': + return 'securing'; + + case 'disconnected': + if (blockWhenDisconnected) { + return 'securing'; + } else { + return 'unsecured'; + } + } + } + + /* eslint-disable @typescript-eslint/member-ordering */ + // WindowControllerDelegate + public getTrayBounds = () => this.tray.getBounds(); + public isUnpinnedWindow = () => this.delegate.isUnpinnedWindow(); + /* eslint-enable @typescript-eslint/member-ordering */ +} diff --git a/gui/src/main/version.ts b/gui/src/main/version.ts new file mode 100644 index 0000000000..5e45203c6c --- /dev/null +++ b/gui/src/main/version.ts @@ -0,0 +1,121 @@ +import { app } from 'electron'; + +import { IAppVersionInfo } from '../shared/daemon-rpc-types'; +import { ICurrentAppVersionInfo } from '../shared/ipc-types'; +import log from '../shared/logging'; +import { + InconsistentVersionNotificationProvider, + UnsupportedVersionNotificationProvider, + UpdateAvailableNotificationProvider, +} from '../shared/notifications/notification'; +import { DaemonRpc } from './daemon-rpc'; +import { IpcMainEventChannel } from './ipc-event-channel'; +import { NotificationSender } from './notification-controller'; + +const GUI_VERSION = app.getVersion().replace('.0', ''); +/// Mirrors the beta check regex in the daemon. Matches only well formed beta versions +const IS_BETA = /^(\d{4})\.(\d+)-beta(\d+)$/; + +export default class Version { + private currentVersionData: ICurrentAppVersionInfo = { + daemon: undefined, + gui: GUI_VERSION, + isConsistent: true, + isBeta: IS_BETA.test(GUI_VERSION), + }; + + private upgradeVersionData: IAppVersionInfo = { + supported: true, + suggestedUpgrade: undefined, + }; + + public constructor( + private delegate: NotificationSender, + private daemonRpc: DaemonRpc, + private updateNotificationDisabled: boolean, + ) {} + + public get currentVersion() { + return this.currentVersionData; + } + + public get upgradeVersion() { + return this.upgradeVersionData; + } + + public setDaemonVersion(daemonVersion: string) { + const versionInfo = { + ...this.currentVersionData, + daemon: daemonVersion, + isConsistent: daemonVersion === this.currentVersionData.gui, + }; + + this.currentVersionData = versionInfo; + + if (!versionInfo.isConsistent) { + log.info('Inconsistent version', { + guiVersion: versionInfo.gui, + daemonVersion: versionInfo.daemon, + }); + } + + // notify user about inconsistent version + const notificationProvider = new InconsistentVersionNotificationProvider({ + consistent: versionInfo.isConsistent, + }); + if (notificationProvider.mayDisplay()) { + this.delegate.notify(notificationProvider.getSystemNotification()); + } + + // notify renderer + IpcMainEventChannel.currentVersion.notify?.(versionInfo); + } + + public setLatestVersion(latestVersionInfo: IAppVersionInfo) { + if (this.updateNotificationDisabled) { + return; + } + + const suggestedIsBeta = + latestVersionInfo.suggestedUpgrade !== undefined && + IS_BETA.test(latestVersionInfo.suggestedUpgrade); + + const upgradeVersion = { + ...latestVersionInfo, + suggestedIsBeta, + }; + + this.upgradeVersionData = upgradeVersion; + + // notify user to update the app if it became unsupported + const notificationProviders = [ + new UnsupportedVersionNotificationProvider({ + supported: latestVersionInfo.supported, + consistent: this.currentVersionData.isConsistent, + suggestedUpgrade: latestVersionInfo.suggestedUpgrade, + suggestedIsBeta, + }), + new UpdateAvailableNotificationProvider({ + suggestedUpgrade: latestVersionInfo.suggestedUpgrade, + suggestedIsBeta, + }), + ]; + const notificationProvider = notificationProviders.find((notificationProvider) => + notificationProvider.mayDisplay(), + ); + if (notificationProvider) { + this.delegate.notify(notificationProvider.getSystemNotification()); + } + + IpcMainEventChannel.upgradeVersion.notify?.(upgradeVersion); + } + + public async fetchLatestVersion() { + try { + this.setLatestVersion(await this.daemonRpc.getVersionInfo()); + } catch (e) { + const error = e as Error; + log.error(`Failed to request the version info: ${error.message}`); + } + } +} diff --git a/gui/src/main/window-controller.ts b/gui/src/main/window-controller.ts index b737f4da96..0c3e5bb36c 100644 --- a/gui/src/main/window-controller.ts +++ b/gui/src/main/window-controller.ts @@ -15,6 +15,11 @@ interface IWindowPositioning { getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters; } +export interface WindowControllerDelegate { + getTrayBounds: Tray['getBounds']; + isUnpinnedWindow(): boolean; +} + // Tray applications are positioned aproximately 10px from the tray in Windows 11. const MARGIN = isWindows11OrNewer() ? 10 : 0; @@ -39,15 +44,11 @@ class StandaloneWindowPositioning implements IWindowPositioning { } class AttachedToTrayWindowPositioning implements IWindowPositioning { - private tray: Tray; - - constructor(tray: Tray) { - this.tray = tray; - } + constructor(private delegate: WindowControllerDelegate) {} public getPosition(window: BrowserWindow): IPosition { const windowBounds = window.getBounds(); - const trayBounds = this.tray.getBounds(); + const trayBounds = this.delegate.getTrayBounds(); const activeDisplay = screen.getDisplayNearestPoint({ x: trayBounds.x, @@ -98,7 +99,7 @@ class AttachedToTrayWindowPositioning implements IWindowPositioning { } public getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters { - const trayBounds = this.tray.getBounds(); + const trayBounds = this.delegate.getTrayBounds(); const windowBounds = window.getBounds(); const arrowPosition = trayBounds.x - windowBounds.x + trayBounds.width * 0.5; return { @@ -148,12 +149,12 @@ export default class WindowController { return this.webContentsValue.isDestroyed() ? undefined : this.webContentsValue; } - constructor(windowValue: BrowserWindow, tray: Tray, private unpinnedWindow: boolean) { + constructor(private delegate: WindowControllerDelegate, windowValue: BrowserWindow) { this.windowValue = windowValue; this.webContentsValue = windowValue.webContents; - this.windowPositioning = unpinnedWindow + this.windowPositioning = delegate.isUnpinnedWindow() ? new StandaloneWindowPositioning() - : new AttachedToTrayWindowPositioning(tray); + : new AttachedToTrayWindowPositioning(delegate); this.installDisplayMetricsHandler(); this.installHideHandler(); @@ -239,7 +240,7 @@ export default class WindowController { if (this.window) { const shapeParameters = this.windowPositioning.getWindowShapeParameters(this.window); - IpcMainEventChannel.window.notifyShape(this.webContentsValue, shapeParameters); + IpcMainEventChannel.window.notifyShape?.(shapeParameters); } } @@ -282,7 +283,7 @@ export default class WindowController { } private forceResizeWindow() { - const { width, height } = WindowController.getContentSize(this.unpinnedWindow); + const { width, height } = WindowController.getContentSize(this.delegate.isUnpinnedWindow()); this.window?.setContentSize(width, height); } diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index f8d1796fc1..f9127ffd77 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -21,11 +21,10 @@ import { RelaySettings, RelaySettingsUpdate, TunnelState, - VoucherResponse, } from '../shared/daemon-rpc-types'; import { messages, relayLocations } from '../shared/gettext'; import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; -import { IRelayListPair, LaunchApplicationResult } from '../shared/ipc-schema'; +import { IRelayListPair } from '../shared/ipc-schema'; import { IChangelog, ICurrentAppVersionInfo, @@ -289,6 +288,51 @@ export default class AppRenderer { ); } + public submitVoucher = (code: string) => IpcRendererEventChannel.account.submitVoucher(code); + public updateAccountData = () => IpcRendererEventChannel.account.updateData(); + public getDeviceState = () => IpcRendererEventChannel.account.getDeviceState(); + public removeDevice = (device: IDeviceRemoval) => + IpcRendererEventChannel.account.removeDevice(device); + public connectTunnel = () => IpcRendererEventChannel.tunnel.connect(); + public disconnectTunnel = () => IpcRendererEventChannel.tunnel.disconnect(); + public reconnectTunnel = () => IpcRendererEventChannel.tunnel.reconnect(); + public updateRelaySettings = (relaySettings: RelaySettingsUpdate) => + IpcRendererEventChannel.settings.updateRelaySettings(relaySettings); + public updateBridgeSettings = (bridgeSettings: BridgeSettings) => + IpcRendererEventChannel.settings.updateBridgeSettings(bridgeSettings); + public setDnsOptions = (dnsOptions: IDnsOptions) => + IpcRendererEventChannel.settings.setDnsOptions(dnsOptions); + public clearAccountHistory = () => IpcRendererEventChannel.accountHistory.clear(); + public setAutoConnect = (value: boolean) => + IpcRendererEventChannel.guiSettings.setAutoConnect(value); + public setEnableSystemNotifications = (value: boolean) => + IpcRendererEventChannel.guiSettings.setEnableSystemNotifications(value); + public setStartMinimized = (value: boolean) => + IpcRendererEventChannel.guiSettings.setStartMinimized(value); + public setMonochromaticIcon = (value: boolean) => + IpcRendererEventChannel.guiSettings.setMonochromaticIcon(value); + public setUnpinnedWindow = (value: boolean) => + IpcRendererEventChannel.guiSettings.setUnpinnedWindow(value); + public getLinuxSplitTunnelingApplications = () => + IpcRendererEventChannel.linuxSplitTunneling.getApplications(); + public launchExcludedApplication = (application: ILinuxSplitTunnelingApplication | string) => + IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application); + public setSplitTunnelingState = (state: boolean) => + IpcRendererEventChannel.windowsSplitTunneling.setState(state); + public addSplitTunnelingApplication = (application: string | IWindowsApplication) => + IpcRendererEventChannel.windowsSplitTunneling.addApplication(application); + public forgetManuallyAddedSplitTunnelingApplication = (application: IWindowsApplication) => + IpcRendererEventChannel.windowsSplitTunneling.forgetManuallyAddedApplication(application); + public setObfuscationSettings = (obfuscationSettings: ObfuscationSettings) => + IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings); + public collectProblemReport = (toRedact: string | undefined) => + IpcRendererEventChannel.problemReport.collectLogs(toRedact); + public viewLog = (path: string) => IpcRendererEventChannel.problemReport.viewLog(path); + public quit = () => IpcRendererEventChannel.app.quit(); + public openUrl = (url: string) => IpcRendererEventChannel.app.openUrl(url); + public showOpenDialog = (options: Electron.OpenDialogOptions) => + IpcRendererEventChannel.app.showOpenDialog(options); + public login = async (accountToken: AccountToken) => { const actions = this.reduxActions; actions.account.startLogin(accountToken); @@ -358,56 +402,12 @@ export default class AppRenderer { } } - public submitVoucher(voucherCode: string): Promise<VoucherResponse> { - return IpcRendererEventChannel.account.submitVoucher(voucherCode); - } - - public updateAccountData(): void { - IpcRendererEventChannel.account.updateData(); - } - - public getDeviceState = (): Promise<DeviceState> => { - return IpcRendererEventChannel.account.getDeviceState(); - }; - public fetchDevices = async (accountToken: AccountToken): Promise<Array<IDevice>> => { const devices = await IpcRendererEventChannel.account.listDevices(accountToken); this.reduxActions.account.updateDevices(devices); return devices; }; - public removeDevice(deviceRemoval: IDeviceRemoval): Promise<void> { - return IpcRendererEventChannel.account.removeDevice(deviceRemoval); - } - - public async connectTunnel(): Promise<void> { - return IpcRendererEventChannel.tunnel.connect(); - } - - public async disconnectTunnel(): Promise<void> { - return IpcRendererEventChannel.tunnel.disconnect(); - } - - public async reconnectTunnel(): Promise<void> { - return IpcRendererEventChannel.tunnel.reconnect(); - } - - public updateRelaySettings(relaySettings: RelaySettingsUpdate) { - return IpcRendererEventChannel.settings.updateRelaySettings(relaySettings); - } - - public updateBridgeSettings(bridgeSettings: BridgeSettings) { - return IpcRendererEventChannel.settings.updateBridgeSettings(bridgeSettings); - } - - public setDnsOptions(dns: IDnsOptions) { - return IpcRendererEventChannel.settings.setDnsOptions(dns); - } - - public clearAccountHistory(): Promise<void> { - return IpcRendererEventChannel.accountHistory.clear(); - } - public openLinkWithAuth = async (link: string): Promise<void> => { let token = ''; try { @@ -461,72 +461,20 @@ export default class AppRenderer { await IpcRendererEventChannel.settings.setWireguardMtu(mtu); }; - public setAutoConnect(autoConnect: boolean) { - IpcRendererEventChannel.guiSettings.setAutoConnect(autoConnect); - } - - public setEnableSystemNotifications(flag: boolean) { - IpcRendererEventChannel.guiSettings.setEnableSystemNotifications(flag); - } - public setAutoStart = (autoStart: boolean): Promise<void> => { this.storeAutoStart(autoStart); return IpcRendererEventChannel.autoStart.set(autoStart); }; - public setStartMinimized(startMinimized: boolean) { - IpcRendererEventChannel.guiSettings.setStartMinimized(startMinimized); - } - - public setMonochromaticIcon(monochromaticIcon: boolean) { - IpcRendererEventChannel.guiSettings.setMonochromaticIcon(monochromaticIcon); - } - - public setUnpinnedWindow(unpinnedWindow: boolean) { - IpcRendererEventChannel.guiSettings.setUnpinnedWindow(unpinnedWindow); - } - - public getLinuxSplitTunnelingApplications() { - return IpcRendererEventChannel.linuxSplitTunneling.getApplications(); - } - public getWindowsSplitTunnelingApplications(updateCache = false) { return IpcRendererEventChannel.windowsSplitTunneling.getApplications(updateCache); } - public launchExcludedApplication( - application: ILinuxSplitTunnelingApplication | string, - ): Promise<LaunchApplicationResult> { - return IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application); - } - - public setSplitTunnelingState = (enabled: boolean): Promise<void> => { - return IpcRendererEventChannel.windowsSplitTunneling.setState(enabled); - }; - - public addSplitTunnelingApplication(application: IWindowsApplication | string): Promise<void> { - return IpcRendererEventChannel.windowsSplitTunneling.addApplication(application); - } - public removeSplitTunnelingApplication(application: IWindowsApplication) { void IpcRendererEventChannel.windowsSplitTunneling.removeApplication(application); } - public forgetManuallyAddedSplitTunnelingApplication(application: IWindowsApplication) { - return IpcRendererEventChannel.windowsSplitTunneling.forgetManuallyAddedApplication( - application, - ); - } - - public setObfuscationSettings(obfuscationSettings: ObfuscationSettings) { - return IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings); - } - - public collectProblemReport(toRedact?: string): Promise<string> { - return IpcRendererEventChannel.problemReport.collectLogs(toRedact); - } - public async sendProblemReport( email: string, message: string, @@ -535,24 +483,6 @@ export default class AppRenderer { await IpcRendererEventChannel.problemReport.sendReport({ email, message, savedReportId }); } - public viewLog(id: string): Promise<string> { - return IpcRendererEventChannel.problemReport.viewLog(id); - } - - public quit(): void { - IpcRendererEventChannel.app.quit(); - } - - public openUrl(url: string): Promise<void> { - return IpcRendererEventChannel.app.openUrl(url); - } - - public showOpenDialog( - options: Electron.OpenDialogOptions, - ): Promise<Electron.OpenDialogReturnValue> { - return IpcRendererEventChannel.app.showOpenDialog(options); - } - public getPreferredLocaleList(): IPreferredLocaleDescriptor[] { return [ { diff --git a/gui/src/shared/ipc-helpers.ts b/gui/src/shared/ipc-helpers.ts index cde27bb32b..1680b45d11 100644 --- a/gui/src/shared/ipc-helpers.ts +++ b/gui/src/shared/ipc-helpers.ts @@ -23,7 +23,7 @@ interface RendererToMain<T, R> { // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyIpcCall = MainToRenderer<any> | RendererToMain<any, any>; -type Schema = Record<string, Record<string, AnyIpcCall>>; +export type Schema = Record<string, Record<string, AnyIpcCall>>; // Renames all IPC calls, e.g. `callName` to either `notifyCallName` or `handleCallName` depending // on direction. @@ -48,14 +48,14 @@ type IpcRendererFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-rende : ReturnType<I['send']>; // Transforms the provided schema to the correct type for the main event channel. -type IpcMain<S extends Schema> = { +export type IpcMain<S extends Schema> = { [G in keyof S]: { [K in keyof S[G] as IpcMainKey<string & K, S[G][K]>]: IpcMainFn<S[G][K]>; }; }; // Transforms the provided schema to the correct type for the renderer event channel. -type IpcRenderer<S extends Schema> = { +export type IpcRenderer<S extends Schema> = { [G in keyof S]: { [K in keyof S[G] as IpcRendererKey<string & K, S[G][K]>]: IpcRendererFn<S[G][K]>; }; |
