summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-08-23 09:10:08 +0200
committerOskar Nyberg <oskar@mullvad.net>2022-08-23 09:10:08 +0200
commitea00c0c6c1fe38d01bee3ef0fe60d362749b64b0 (patch)
tree2ca36afbe949dcb6db3d0f66aceac4340141fb77
parent7063f6454ccb8163ef9e3adc228875a421a80bf7 (diff)
parent33547846ab4b7594d23b1772fea35e30e0b460fc (diff)
downloadmullvadvpn-ea00c0c6c1fe38d01bee3ef0fe60d362749b64b0.tar.xz
mullvadvpn-ea00c0c6c1fe38d01bee3ef0fe60d362749b64b0.zip
Merge branch 'refactor-electron-app'
-rw-r--r--gui/src/main/account.ts231
-rw-r--r--gui/src/main/daemon-rpc.ts43
-rw-r--r--gui/src/main/index.ts1666
-rw-r--r--gui/src/main/ipc-event-channel.ts48
-rw-r--r--gui/src/main/notification-controller.ts50
-rw-r--r--gui/src/main/problem-report.ts72
-rw-r--r--gui/src/main/relay-list.ts109
-rw-r--r--gui/src/main/settings.ts241
-rw-r--r--gui/src/main/tray-icon-controller.ts111
-rw-r--r--gui/src/main/tunnel-state.ts90
-rw-r--r--gui/src/main/user-interface.ts640
-rw-r--r--gui/src/main/version.ts121
-rw-r--r--gui/src/main/window-controller.ts25
-rw-r--r--gui/src/renderer/app.tsx162
-rw-r--r--gui/src/shared/ipc-helpers.ts6
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]>;
};