diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-08-05 11:08:43 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-08-22 08:34:37 +0200 |
| commit | bed560fc96d02d714ebc690277929dbd902778a4 (patch) | |
| tree | cecf6d1686c1bcd61b40086593aa6fbe011f29f0 | |
| parent | 27754f1b721dd1d0b82febf3df61153567dc7567 (diff) | |
| download | mullvadvpn-bed560fc96d02d714ebc690277929dbd902778a4.tar.xz mullvadvpn-bed560fc96d02d714ebc690277929dbd902778a4.zip | |
Move tray and window handling to new UserInterface class
| -rw-r--r-- | gui/src/main/index.ts | 781 | ||||
| -rw-r--r-- | gui/src/main/notification-controller.ts | 2 | ||||
| -rw-r--r-- | gui/src/main/tray-icon-controller.ts | 111 | ||||
| -rw-r--r-- | gui/src/main/user-interface.ts | 624 | ||||
| -rw-r--r-- | gui/src/main/window-controller.ts | 25 |
5 files changed, 778 insertions, 765 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 6d57df3b5f..e43da2a3d2 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -1,24 +1,12 @@ 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 { app, dialog, 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'; @@ -81,12 +69,10 @@ import { IpcInput, OLD_LOG_FILES, } from './logging'; -import NotificationController from './notification-controller'; -import { isMacOs11OrNewer } from './platform-version'; +import NotificationController, { NotificationControllerDelegate } from './notification-controller'; import { resolveBin } from './proc'; import ReconnectionBackoff from './reconnection-backoff'; -import TrayIconController, { TrayIconType } from './tray-icon-controller'; -import WindowController from './window-controller'; +import UserInterface, { UserInterfaceDelegate } from './user-interface'; const execAsync = util.promisify(exec); @@ -100,32 +86,28 @@ 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 { +const SANDBOX_DISABLED = app.commandLine.hasSwitch('no-sandbox'); +const QUIT_WITHOUT_DISCONNECT = process.argv.includes(CommandLineOptions.quitWithoutDisconnect); +const FORCE_SHOW_CHANGES = process.argv.includes(CommandLineOptions.showChanges); +const NAVIGATION_RESET_DISABLED = process.argv.includes(CommandLineOptions.disableResetNavigation); + +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; +class ApplicationMain implements NotificationControllerDelegate, UserInterfaceDelegate { + private notificationController = new NotificationController(this); + private userInterface?: UserInterface; // True while file pickers are displayed which is used to decide if the Browser window should be // hidden when losing focus. @@ -246,18 +228,12 @@ class ApplicationMain { (accountData) => { this.accountData = accountData; - if (this.windowController) { - IpcMainEventChannel.account.notify(this.windowController.webContents, this.accountData); - } + IpcMainEventChannel.account.notify?.(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 }; @@ -268,7 +244,6 @@ class ApplicationMain { private stayConnectedOnQuit = false; private changelog?: IChangelog; - private forceShowChanges = process.argv.includes(Options.showChanges); private navigationHistory?: IHistoryObject; private scrollPositions: ScrollPositions = {}; @@ -287,7 +262,7 @@ 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() || QUIT_WITHOUT_DISCONNECT) { this.quitWithoutDisconnect(); return; } @@ -311,7 +286,7 @@ class ApplicationMain { // the signal `SIGUSR2`. if (process.env.NODE_ENV === 'development') { process.on('SIGUSR2', () => { - this.windowController?.window?.reload(); + this.userInterface?.reloadWindow(); }); } @@ -339,12 +314,12 @@ class ApplicationMain { 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(); } }); } @@ -400,11 +375,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; @@ -481,8 +452,8 @@ 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.daemonRpc.isConnected) { @@ -498,7 +469,7 @@ class ApplicationMain { } } - this.trayIconController?.dispose(); + this.userInterface?.dispose(); } private detectLocale(): string { @@ -544,29 +515,28 @@ class ApplicationMain { }); } - const window = await this.createWindow(); - const tray = this.createTray(); + this.userInterface = new UserInterface( + this, + this.daemonRpc, + SANDBOX_DISABLED, + NAVIGATION_RESET_DISABLED, + ); - 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.userInterface?.createTrayIconController( + this.tunnelState, + this.settings.blockWhenDisconnected, this.guiSettings.monochromaticIcon, - () => setImmediate(() => void this.connectTunnel()), - () => setImmediate(() => void this.reconnectTunnel()), - () => setImmediate(() => void this.disconnectTunnel()), ); - await this.trayIconController.updateTheme(); + await this.userInterface?.updateTrayTheme(); - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.daemonRpc.isConnected, this.tunnelState); + this.userInterface?.setTrayContextMenu(); + this.userInterface?.setTrayTooltip(); if (process.platform === 'win32') { nativeTheme.on('updated', async () => { if (this.guiSettings.monochromaticIcon) { - await this.trayIconController?.updateTheme(); + await this.userInterface?.updateTrayTheme(); } }); } @@ -574,79 +544,31 @@ 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); - } + await this.userInterface?.setUseMonochromaticTrayIcon(newState.monochromaticIcon); } if (newState.autoConnect !== oldState.autoConnect) { this.updateDaemonsAutoConnect(); } - if (this.windowController) { - IpcMainEventChannel.guiSettings.notify(this.windowController.webContents, newState); - } + IpcMainEventChannel.guiSettings.notify?.(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(); + }; private onDaemonConnected = async () => { const firstDaemonConnection = this.beforeFirstDaemonConnection; @@ -760,8 +682,8 @@ class ApplicationMain { // notify renderer, this.daemonRpc.isConnected could have changed if the daemon disconnected // again before this if-statement is reached. - if (this.windowController && this.daemonRpc.isConnected) { - IpcMainEventChannel.daemon.notifyConnected(this.windowController.webContents); + if (this.daemonRpc.isConnected) { + IpcMainEventChannel.daemon.notifyConnected?.(); } if (firstDaemonConnection) { @@ -770,7 +692,7 @@ class ApplicationMain { // show window when account is not set if (!this.isLoggedIn()) { - this.windowController?.show(); + this.userInterface?.showWindow(); } }; @@ -788,16 +710,13 @@ class ApplicationMain { this.tunnelStateFallback = undefined; if (wasConnected) { - // update the tray icon to indicate that the computer is not secure anymore - this.updateTrayIcon({ state: 'disconnected' }, false); - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.daemonRpc.isConnected, this.tunnelState); + this.userInterface?.updateTrayIcon({ state: 'disconnected' }, false); + this.userInterface?.setTrayContextMenu(); + this.userInterface?.setTrayTooltip(); // notify renderer process - if (this.windowController) { - IpcMainEventChannel.daemon.notifyDisconnected(this.windowController.webContents); - } + IpcMainEventChannel.daemon.notifyDisconnected?.(); } // recover connection on error @@ -843,12 +762,7 @@ class ApplicationMain { } else if ('device' in daemonEvent) { this.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) => { @@ -864,41 +778,15 @@ class ApplicationMain { 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, - ); + if (this.isPerformingPostUpgrade !== oldValue) { + IpcMainEventChannel.daemon.notifyIsPerformingPostUpgrade?.(this.isPerformingPostUpgrade); } } - private connectTunnel = async (): Promise<void> => { - if (connectEnabled(this.daemonRpc.isConnected, this.isLoggedIn(), this.tunnelState.state)) { - this.setOptimisticTunnelState('connecting'); - await this.daemonRpc.connectTunnel(); - } - }; - - private reconnectTunnel = async (): Promise<void> => { - if (reconnectEnabled(this.daemonRpc.isConnected, this.isLoggedIn(), this.tunnelState.state)) { - this.setOptimisticTunnelState('connecting'); - await this.daemonRpc.reconnectTunnel(); - } - }; - - private disconnectTunnel = async (): Promise<void> => { - if (disconnectEnabled(this.daemonRpc.isConnected, 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); - } + IpcMainEventChannel.accountHistory.notify?.(accountHistory); } // This function sets a new tunnel state as an assumed next state and saves the current state as @@ -943,10 +831,10 @@ class ApplicationMain { private setTunnelStateImpl(newState: TunnelState) { this.tunnelState = newState; - this.updateTrayIcon(newState, this.settings.blockWhenDisconnected); + this.userInterface?.updateTrayIcon(newState, this.settings.blockWhenDisconnected); - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.daemonRpc.isConnected, this.tunnelState); + this.userInterface?.setTrayContextMenu(); + this.userInterface?.setTrayTooltip(); this.notificationController.notifyTunnelState( newState, @@ -955,9 +843,7 @@ class ApplicationMain { this.accountData?.expiry, ); - if (this.windowController) { - IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState); - } + IpcMainEventChannel.tunnel.notify?.(newState); if (this.accountData) { this.detectStaleAccountExpiry(newState, new Date(this.accountData.expiry)); @@ -968,18 +854,16 @@ class ApplicationMain { const oldSettings = this.settings; this.settings = newSettings; - this.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected); + this.userInterface?.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected); if (oldSettings.showBetaReleases !== newSettings.showBetaReleases) { this.setLatestVersion(this.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 @@ -993,12 +877,7 @@ class ApplicationMain { }); this.windowsSplitTunnelingApplications = applications; - if (this.windowController) { - IpcMainEventChannel.windowsSplitTunneling.notify( - this.windowController.webContents, - applications, - ); - } + IpcMainEventChannel.windowsSplitTunneling.notify?.(applications); } private setRelays( @@ -1011,12 +890,7 @@ class ApplicationMain { 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, - }); - } + IpcMainEventChannel.relays.notify?.({ relays: filteredRelays, bridges: filteredBridges }); } private processRelaysForPresentation( @@ -1112,9 +986,7 @@ class ApplicationMain { } // notify renderer - if (this.windowController) { - IpcMainEventChannel.currentVersion.notify(this.windowController.webContents, versionInfo); - } + IpcMainEventChannel.currentVersion.notify?.(versionInfo); } private setLatestVersion(latestVersionInfo: IAppVersionInfo) { @@ -1153,9 +1025,7 @@ class ApplicationMain { this.notificationController.notify(notificationProvider.getSystemNotification()); } - if (this.windowController) { - IpcMainEventChannel.upgradeVersion.notify(this.windowController.webContents, upgradeVersion); - } + IpcMainEventChannel.upgradeVersion.notify?.(upgradeVersion); } private async fetchLatestVersion() { @@ -1185,15 +1055,9 @@ class ApplicationMain { } void this.updateAccountHistory(); - this.setTrayContextMenu(); - - if (this.windowController) { - IpcMainEventChannel.account.notifyDevice(this.windowController.webContents, deviceEvent); - } - } + this.userInterface?.setTrayContextMenu(); - private isLoggedIn(): boolean { - return this.deviceState?.type === 'logged in'; + IpcMainEventChannel.account.notifyDevice?.(deviceEvent); } private getAccountToken(): AccountToken | undefined { @@ -1202,81 +1066,6 @@ class ApplicationMain { : 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); - } - }); - } - private registerIpcListeners() { IpcMainEventChannel.state.handleGet(() => ({ isConnected: this.daemonRpc.isConnected, @@ -1298,7 +1087,7 @@ class ApplicationMain { windowsSplitTunnelingApplications: this.windowsSplitTunnelingApplications, macOsScrollbarVisibility: this.macOsScrollbarVisibility, changelog: this.changelog ?? [], - forceShowChanges: this.forceShowChanges, + forceShowChanges: FORCE_SHOW_CHANGES, navigationHistory: this.navigationHistory, scrollPositions: this.scrollPositions, })); @@ -1595,12 +1384,6 @@ class ApplicationMain { } } - private updateAccountData() { - if (this.daemonRpc.isConnected && this.isLoggedIn()) { - this.accountDataCache.fetch(this.getAccountToken()!); - } - } - private detectStaleAccountExpiry(tunnelState: TunnelState, accountExpiry: Date) { const hasExpired = new Date() >= accountExpiry; @@ -1659,9 +1442,7 @@ class ApplicationMain { try { await setOpenAtLogin(autoStart); - if (this.windowController) { - IpcMainEventChannel.autoStart.notify(this.windowController.webContents, autoStart); - } + IpcMainEventChannel.autoStart.notify?.(autoStart); this.updateDaemonsAutoConnect(); } catch (e) { @@ -1673,24 +1454,9 @@ class ApplicationMain { return Promise.resolve(); } - private async setUnpinnedWindow(unpinnedWindow: boolean): Promise<void> { + private async setUnpinnedWindow(unpinnedWindow: boolean) { 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(); - } + await this.userInterface?.recreateWindow(); } private updateCurrentLocale() { @@ -1707,8 +1473,8 @@ class ApplicationMain { relayLocations: relayLocationsTranslations, }; - this.setTrayContextMenu(); - this.trayIconController?.setTooltip(this.daemonRpc.isConnected, this.tunnelState); + this.userInterface?.setTrayContextMenu(); + this.userInterface?.setTrayTooltip(); } private blockPermissionRequests() { @@ -1776,323 +1542,38 @@ 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 shouldShowWindowOnStart(): boolean { + return this.guiSettings.unpinnedWindow && !this.guiSettings.startMinimized; } - 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 getProblemReportPath(id: string): string { + return path.join(app.getPath('temp'), `${id}.log`); } - 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.daemonRpc.isConnected, - 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.daemonRpc.isConnected, - 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(); - } - }); - } + IpcMainEventChannel.window.notifyMacOsScrollbarVisibility?.(this.macOsScrollbarVisibility); } - 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; - } - } - - 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 { @@ -2105,36 +1586,52 @@ class ApplicationMain { } else { return shell.openExternal(url); } - } + }; + public isWindowVisible = () => this.userInterface?.isWindowVisible() ?? false; + public areSystemNotificationsEnabled = () => this.guiSettings.enableSystemNotifications; - 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.guiSettings.unpinnedWindow; + public checkVolumes = () => this.daemonRpc.checkVolumes(); + public getAppQuitStage = () => this.quitStage; + public isConnectedToDaemon = () => this.daemonRpc.isConnected; + public getTunnelState = () => this.tunnelState; + public updateAccountData = () => { + if (this.daemonRpc.isConnected && this.isLoggedIn()) { + this.accountDataCache.fetch(this.getAccountToken()!); + } + }; + public isLoggedIn(): boolean { + return this.deviceState?.type === 'logged in'; } + public isBrowsingFiles = () => this.browsingFiles; + public getAccountData = () => this.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; + public connectTunnel = async (): Promise<void> => { + if (connectEnabled(this.daemonRpc.isConnected, this.isLoggedIn(), this.tunnelState.state)) { + this.setOptimisticTunnelState('connecting'); + await this.daemonRpc.connectTunnel(); } + }; - if (this.windowController?.webContents) { - IpcMainEventChannel.window.notifyMacOsScrollbarVisibility( - this.windowController.webContents, - this.macOsScrollbarVisibility, - ); + public reconnectTunnel = async (): Promise<void> => { + if (reconnectEnabled(this.daemonRpc.isConnected, this.isLoggedIn(), this.tunnelState.state)) { + this.setOptimisticTunnelState('connecting'); + await this.daemonRpc.reconnectTunnel(); } - } + }; + + public disconnectTunnel = async (): Promise<void> => { + if (disconnectEnabled(this.daemonRpc.isConnected, this.tunnelState.state)) { + this.setOptimisticTunnelState('disconnecting'); + await this.daemonRpc.disconnectTunnel(); + } + }; + /* eslint-enable @typescript-eslint/member-ordering */ } const applicationMain = new ApplicationMain(); diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts index 1f3b143590..a90090945f 100644 --- a/gui/src/main/notification-controller.ts +++ b/gui/src/main/notification-controller.ts @@ -15,7 +15,7 @@ import { SystemNotificationProvider, } from '../shared/notifications/notification'; -interface NotificationControllerDelegate { +export interface NotificationControllerDelegate { openApp(): void; openLink(url: string, withAuth?: boolean): Promise<void>; isWindowVisible(): boolean; 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/user-interface.ts b/gui/src/main/user-interface.ts new file mode 100644 index 0000000000..944d96108f --- /dev/null +++ b/gui/src/main/user-interface.ts @@ -0,0 +1,624 @@ +import { app, BrowserWindow, 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; + isUnpinnedWindow(): boolean; + getAppQuitStage(): AppQuitStage; + updateAccountData(): void; + isLoggedIn(): boolean; + getTunnelState(): TunnelState; + isBrowsingFiles(): boolean; + getAccountData(): IAccountData | undefined; + connectTunnel(): void; + reconnectTunnel(): void; + disconnectTunnel(): void; +} + +export default class UserInterface implements WindowControllerDelegate { + private windowController: WindowController; + + private tray: Tray; + private trayIconController?: TrayIconController; + + 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 createTrayIconController( + tunnelState: TunnelState, + blockWhenDisconnected: boolean, + monochromaticIcon: boolean, + ) { + const iconType = this.trayIconType(tunnelState, blockWhenDisconnected); + this.trayIconController = new TrayIconController(this.tray, iconType, monochromaticIcon); + } + + public async initializeWindow() { + if (!this.windowController.window) { + throw new Error('No window available in initializeWindow'); + } + + const window = this.windowController.window; + + this.registerWindowListener(this.delegate.getAccountData()); + 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(this.delegate.isBrowsingFiles()); + break; + case 'darwin': + this.installMacOsMenubarAppWindowHandlers(); + this.setMacOsAppMenu(); + break; + case 'linux': + this.setTrayContextMenu(); + 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 setTrayContextMenu = () => { + if (process.platform === 'linux') { + this.tray.setContextMenu( + this.createContextMenu( + this.daemonRpc.isConnected, + this.delegate.isLoggedIn(), + this.delegate.getTunnelState(), + ), + ); + } + }; + + public setTrayTooltip = () => { + const tooltip = this.createTooltipText( + this.daemonRpc.isConnected, + this.delegate.getTunnelState(), + ); + this.tray?.setToolTip(tooltip); + }; + + public async recreateWindow(): 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(); + 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 animateTrayToIcon = (type: TrayIconType) => this.trayIconController?.animateToIcon(type); + 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.animateTrayToIcon(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(accountData?: IAccountData) { + this.windowController.window?.on('focus', () => { + IpcMainEventChannel.window.notifyFocus?.(true); + + this.blurNavigationResetScheduler.cancel(); + + // cancel notifications when window appears + this.delegate.cancelPendingNotifications(); + + 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 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(browsingFiles: boolean) { + 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 && !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.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() { + this.tray.popUpContextMenu( + this.createContextMenu( + this.daemonRpc.isConnected, + this.delegate.isLoggedIn(), + this.delegate.getTunnelState(), + ), + ); + } + + 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/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); } |
