summaryrefslogtreecommitdiffhomepage
path: root/gui/src/main
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-03-01 17:36:15 +0100
committerAndrej Mihajlov <and@mullvad.net>2019-03-01 17:36:15 +0100
commit65ef2fcc8a4b58a92219ea0ae269c91f80be0062 (patch)
treeb69a7e097fcf2ff61a06b053147c564f3c0f0296 /gui/src/main
parent2610bd23035901ba0e25824629d3768b4430a708 (diff)
parent1a1eb84364add292974d7dafe69761270c7397ef (diff)
downloadmullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.tar.xz
mullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.zip
Merge branch 'remove-workspaces'
Diffstat (limited to 'gui/src/main')
-rw-r--r--gui/src/main/autostart.ts73
-rw-r--r--gui/src/main/daemon-rpc.ts515
-rw-r--r--gui/src/main/errors.ts29
-rw-r--r--gui/src/main/gui-settings.ts84
-rw-r--r--gui/src/main/index.ts1147
-rw-r--r--gui/src/main/jsonrpc-client.ts483
-rw-r--r--gui/src/main/keyframe-animation.ts130
-rw-r--r--gui/src/main/notification-controller.ts177
-rw-r--r--gui/src/main/proc.ts25
-rw-r--r--gui/src/main/reconnection-backoff.ts22
-rw-r--r--gui/src/main/tray-icon-controller.ts89
-rw-r--r--gui/src/main/window-controller.ts253
12 files changed, 3027 insertions, 0 deletions
diff --git a/gui/src/main/autostart.ts b/gui/src/main/autostart.ts
new file mode 100644
index 0000000000..a42691550c
--- /dev/null
+++ b/gui/src/main/autostart.ts
@@ -0,0 +1,73 @@
+import { app } from 'electron';
+import log from 'electron-log';
+import * as fs from 'fs';
+import * as path from 'path';
+import { promisify } from 'util';
+
+const DESKTOP_FILE_NAME = 'mullvad-vpn.desktop';
+
+const mkdirAsync = promisify(fs.mkdir);
+const statAsync = promisify(fs.stat);
+const symlinkAsync = promisify(fs.symlink);
+const unlinkAsync = promisify(fs.unlink);
+
+export function getOpenAtLogin() {
+ if (process.platform === 'linux') {
+ try {
+ const autostartDir = path.join(app.getPath('appData'), 'autostart');
+ const autostartFilePath = path.join(autostartDir, DESKTOP_FILE_NAME);
+
+ fs.accessSync(autostartFilePath);
+
+ return true;
+ } catch (error) {
+ log.error(`Failed to check autostart file: ${error.message}`);
+ return false;
+ }
+ } else {
+ return app.getLoginItemSettings().openAtLogin;
+ }
+}
+
+export async function setOpenAtLogin(openAtLogin: boolean) {
+ if (process.platform === 'linux') {
+ try {
+ const desktopFilePath = path.join('/usr/share/applications', DESKTOP_FILE_NAME);
+ const autostartDir = path.join(app.getPath('appData'), 'autostart');
+ const autostartFilePath = path.join(autostartDir, DESKTOP_FILE_NAME);
+
+ if (openAtLogin) {
+ await createDirIfNecessary(autostartDir);
+ await symlinkAsync(desktopFilePath, autostartFilePath);
+ } else {
+ await unlinkAsync(autostartFilePath);
+ }
+ } catch (error) {
+ log.error(`Failed to set auto-start: ${error.message}`);
+ }
+ } else {
+ app.setLoginItemSettings({ openAtLogin });
+ }
+}
+
+const createDirIfNecessary = async (directory: string) => {
+ let stat;
+ try {
+ stat = await statAsync(directory);
+ } catch (error) {
+ // Path doesn't exist, so it has to be created
+ return mkdirAsync(directory);
+ }
+
+ // Is there a file instead of a directory?
+ if (!stat.isDirectory()) {
+ // Try to remove existing file and replace it with a new directory
+ try {
+ await unlinkAsync(directory);
+ } catch (error) {
+ log.error(`Failed to remove path before creating a directory for it: ${error.message}`);
+ }
+
+ return mkdirAsync(directory);
+ }
+};
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
new file mode 100644
index 0000000000..a52c3b431a
--- /dev/null
+++ b/gui/src/main/daemon-rpc.ts
@@ -0,0 +1,515 @@
+import {
+ AccountToken,
+ IAccountData,
+ IAppVersionInfo,
+ ILocation,
+ IRelayList,
+ ISettings,
+ RelaySettingsUpdate,
+ TunnelStateTransition,
+} from '../shared/daemon-rpc-types';
+import { CommunicationError, InvalidAccountError, NoDaemonError } from './errors';
+import JsonRpcClient, {
+ RemoteError as JsonRpcRemoteError,
+ SocketTransport,
+ TimeOutError as JsonRpcTimeOutError,
+} from './jsonrpc-client';
+
+import { validate } from 'validated/object';
+import {
+ arrayOf,
+ boolean,
+ enumeration,
+ maybe,
+ Node as SchemaNode,
+ number,
+ object,
+ oneOf,
+ partialObject,
+ string,
+} from 'validated/schema';
+
+const locationSchema = maybe(
+ partialObject({
+ ip: maybe(string),
+ country: string,
+ city: maybe(string),
+ latitude: number,
+ longitude: number,
+ mullvad_exit_ip: boolean,
+ hostname: maybe(string),
+ }),
+);
+
+const constraint = <T>(constraintValue: SchemaNode<T>) => {
+ return oneOf(
+ string, // any
+ object({
+ only: constraintValue,
+ }),
+ );
+};
+
+const customTunnelEndpointSchema = oneOf(
+ object({
+ openvpn: object({
+ endpoint: object({
+ address: string,
+ protocol: enumeration('udp', 'tcp'),
+ }),
+ username: string,
+ password: string,
+ }),
+ }),
+ object({
+ wireguard: object({
+ tunnel: object({
+ private_key: string,
+ addresses: arrayOf(string),
+ }),
+ peer: object({
+ public_key: string,
+ allowed_ips: arrayOf(string),
+ endpoint: string,
+ }),
+ gateway: string,
+ }),
+ }),
+);
+
+const relaySettingsSchema = oneOf(
+ object({
+ normal: partialObject({
+ location: constraint(
+ oneOf(
+ object({
+ hostname: arrayOf(string),
+ }),
+ object({
+ city: arrayOf(string),
+ }),
+ object({
+ country: string,
+ }),
+ ),
+ ),
+ tunnel: constraint(
+ oneOf(
+ object({
+ openvpn: partialObject({
+ port: constraint(number),
+ protocol: constraint(enumeration('udp', 'tcp')),
+ }),
+ }),
+ object({
+ wireguard: partialObject({
+ port: constraint(number),
+ }),
+ }),
+ ),
+ ),
+ }),
+ }),
+ object({
+ custom_tunnel_endpoint: partialObject({
+ host: string,
+ config: customTunnelEndpointSchema,
+ }),
+ }),
+);
+
+const relayListSchema = partialObject({
+ countries: arrayOf(
+ partialObject({
+ name: string,
+ code: string,
+ cities: arrayOf(
+ partialObject({
+ name: string,
+ code: string,
+ latitude: number,
+ longitude: number,
+ relays: arrayOf(
+ partialObject({
+ hostname: string,
+ ipv4_addr_in: string,
+ include_in_country: boolean,
+ weight: number,
+ }),
+ ),
+ }),
+ ),
+ }),
+ ),
+});
+
+const openVpnProxySchema = maybe(
+ oneOf(
+ object({
+ local: partialObject({
+ port: number,
+ peer: string,
+ }),
+ }),
+ object({
+ remote: partialObject({
+ address: string,
+ auth: maybe(
+ partialObject({
+ username: string,
+ password: string,
+ }),
+ ),
+ }),
+ }),
+ object({
+ shadowsocks: partialObject({
+ peer: string,
+ password: string,
+ cipher: string,
+ }),
+ }),
+ ),
+);
+
+const tunnelOptionsSchema = partialObject({
+ openvpn: partialObject({
+ mssfix: maybe(number),
+ proxy: openVpnProxySchema,
+ }),
+ wireguard: partialObject({
+ mtu: maybe(number),
+ // only relevant on linux
+ fmwark: maybe(number),
+ }),
+ generic: partialObject({
+ enable_ipv6: boolean,
+ }),
+});
+
+const accountDataSchema = partialObject({
+ expiry: string,
+});
+
+const tunnelStateTransitionSchema = oneOf(
+ object({
+ state: enumeration('disconnecting'),
+ details: enumeration('nothing', 'block', 'reconnect'),
+ }),
+ object({
+ state: enumeration('connecting', 'connected'),
+ details: partialObject({
+ address: string,
+ protocol: enumeration('tcp', 'udp'),
+ tunnel_type: enumeration('wireguard', 'openvpn'),
+ }),
+ }),
+ object({
+ state: enumeration('blocked'),
+ details: oneOf(
+ object({
+ reason: enumeration(
+ 'ipv6_unavailable',
+ 'set_firewall_policy_error',
+ 'set_dns_error',
+ 'start_tunnel_error',
+ 'no_matching_relay',
+ 'is_offline',
+ 'tap_adapter_problem',
+ ),
+ }),
+ object({
+ reason: enumeration('auth_failed'),
+ details: maybe(string),
+ }),
+ ),
+ }),
+ object({
+ state: enumeration('connected', 'connecting', 'disconnected'),
+ }),
+);
+
+const appVersionInfoSchema = partialObject({
+ current_is_supported: boolean,
+ latest: partialObject({
+ latest_stable: string,
+ latest: string,
+ }),
+});
+
+export class ConnectionObserver {
+ constructor(private openHandler: () => void, private closeHandler: (error?: Error) => void) {}
+
+ // Only meant to be called by DaemonRpc
+ // @internal
+ public onOpen = () => {
+ this.openHandler();
+ };
+
+ // Only meant to be called by DaemonRpc
+ // @internal
+ public onClose = (error?: Error) => {
+ this.closeHandler(error);
+ };
+}
+
+export class SubscriptionListener<T> {
+ constructor(
+ private eventHandler: (payload: T) => void,
+ private errorHandler: (error: Error) => void,
+ ) {}
+
+ // Only meant to be called by DaemonRpc
+ // @internal
+ public onEvent(payload: T) {
+ this.eventHandler(payload);
+ }
+
+ // Only meant to be called by DaemonRpc
+ // @internal
+ public onError(error: Error) {
+ this.errorHandler(error);
+ }
+}
+
+const settingsSchema = partialObject({
+ account_token: maybe(string),
+ allow_lan: boolean,
+ auto_connect: boolean,
+ block_when_disconnected: boolean,
+ relay_settings: relaySettingsSchema,
+ tunnel_options: tunnelOptionsSchema,
+});
+
+export class ResponseParseError extends Error {
+ constructor(message: string, private validationErrorValue?: Error) {
+ super(message);
+ }
+
+ get validationError(): Error | undefined {
+ return this.validationErrorValue;
+ }
+}
+
+// Timeout used for RPC calls that do networking
+const NETWORK_CALL_TIMEOUT = 10000;
+
+export class DaemonRpc {
+ private transport = new JsonRpcClient(new SocketTransport());
+
+ public connect(connectionParams: { path: string }) {
+ this.transport.connect(connectionParams);
+ }
+
+ public disconnect() {
+ this.transport.disconnect();
+ }
+
+ public addConnectionObserver(observer: ConnectionObserver) {
+ this.transport.on('open', observer.onOpen).on('close', observer.onClose);
+ }
+
+ public removeConnectionObserver(observer: ConnectionObserver) {
+ this.transport.off('open', observer.onOpen).off('close', observer.onClose);
+ }
+
+ public async getAccountData(accountToken: AccountToken): Promise<IAccountData> {
+ let response;
+ try {
+ response = await this.transport.send('get_account_data', accountToken, NETWORK_CALL_TIMEOUT);
+ } catch (error) {
+ if (error instanceof JsonRpcRemoteError) {
+ switch (error.code) {
+ case -200: // Account doesn't exist
+ throw new InvalidAccountError();
+ case -32603: // Internal error
+ throw new CommunicationError();
+ }
+ } else if (error instanceof JsonRpcTimeOutError) {
+ throw new NoDaemonError();
+ } else {
+ throw error;
+ }
+ }
+
+ try {
+ return validate(accountDataSchema, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_account_data', error);
+ }
+ }
+
+ public async getRelayLocations(): Promise<IRelayList> {
+ const response = await this.transport.send('get_relay_locations');
+ try {
+ return camelCaseObjectKeys(validate(relayListSchema, response)) as IRelayList;
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_relay_locations', error);
+ }
+ }
+
+ public async setAccount(accountToken?: AccountToken): Promise<void> {
+ await this.transport.send('set_account', [accountToken]);
+ }
+
+ public async updateRelaySettings(relaySettings: RelaySettingsUpdate): Promise<void> {
+ await this.transport.send('update_relay_settings', [underscoreObjectKeys(relaySettings)]);
+ }
+
+ public async setAllowLan(allowLan: boolean): Promise<void> {
+ await this.transport.send('set_allow_lan', [allowLan]);
+ }
+
+ public async setEnableIpv6(enableIpv6: boolean): Promise<void> {
+ await this.transport.send('set_enable_ipv6', [enableIpv6]);
+ }
+
+ public async setBlockWhenDisconnected(blockWhenDisconnected: boolean): Promise<void> {
+ await this.transport.send('set_block_when_disconnected', [blockWhenDisconnected]);
+ }
+
+ public async setOpenVpnMssfix(mssfix?: number): Promise<void> {
+ await this.transport.send('set_openvpn_mssfix', [mssfix]);
+ }
+
+ public async setAutoConnect(autoConnect: boolean): Promise<void> {
+ await this.transport.send('set_auto_connect', [autoConnect]);
+ }
+
+ public async connectTunnel(): Promise<void> {
+ await this.transport.send('connect');
+ }
+
+ public async disconnectTunnel(): Promise<void> {
+ await this.transport.send('disconnect');
+ }
+
+ public async getLocation(): Promise<ILocation | undefined> {
+ const response = await this.transport.send('get_current_location', [], NETWORK_CALL_TIMEOUT);
+ try {
+ const validatedObject = validate(locationSchema, response);
+ if (validatedObject) {
+ return camelCaseObjectKeys(validatedObject) as ILocation;
+ } else {
+ return undefined;
+ }
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_current_location', error);
+ }
+ }
+
+ public async getState(): Promise<TunnelStateTransition> {
+ const response = await this.transport.send('get_state');
+ try {
+ return camelCaseObjectKeys(
+ validate(tunnelStateTransitionSchema, response),
+ ) as TunnelStateTransition;
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_state', error);
+ }
+ }
+
+ public async getSettings(): Promise<ISettings> {
+ const response = await this.transport.send('get_settings');
+ try {
+ return camelCaseObjectKeys(validate(settingsSchema, response)) as ISettings;
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_settings', error);
+ }
+ }
+
+ public subscribeStateListener(
+ listener: SubscriptionListener<TunnelStateTransition>,
+ ): Promise<void> {
+ return this.transport.subscribe('new_state', (payload) => {
+ try {
+ const newState = camelCaseObjectKeys(
+ validate(tunnelStateTransitionSchema, payload),
+ ) as TunnelStateTransition;
+ listener.onEvent(newState);
+ } catch (error) {
+ listener.onError(new ResponseParseError('Invalid payload from new_state', error));
+ }
+ });
+ }
+
+ public subscribeSettingsListener(listener: SubscriptionListener<ISettings>): Promise<void> {
+ return this.transport.subscribe('settings', (payload) => {
+ try {
+ const newSettings = camelCaseObjectKeys(validate(settingsSchema, payload)) as ISettings;
+ listener.onEvent(newSettings);
+ } catch (error) {
+ listener.onError(new ResponseParseError('Invalid payload from settings', error));
+ }
+ });
+ }
+
+ public async getAccountHistory(): Promise<AccountToken[]> {
+ const response = await this.transport.send('get_account_history');
+ try {
+ return validate(arrayOf(string), response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_account_history');
+ }
+ }
+
+ public async removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
+ await this.transport.send('remove_account_from_history', accountToken);
+ }
+
+ public async getCurrentVersion(): Promise<string> {
+ const response = await this.transport.send('get_current_version');
+ try {
+ return validate(string, response);
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_current_version');
+ }
+ }
+
+ public async getVersionInfo(): Promise<IAppVersionInfo> {
+ const response = await this.transport.send('get_version_info', [], NETWORK_CALL_TIMEOUT);
+ try {
+ return camelCaseObjectKeys(validate(appVersionInfoSchema, response)) as IAppVersionInfo;
+ } catch (error) {
+ throw new ResponseParseError('Invalid response from get_version_info');
+ }
+ }
+}
+
+function underscoreToCamelCase(str: string): string {
+ return str.replace(/_([a-z])/gi, (matches) => matches[1].toUpperCase());
+}
+
+function camelCaseToUnderscore(str: string): string {
+ return str
+ .replace(/[a-z0-9][A-Z]/g, (matches) => `${matches[0]}_${matches[1].toLowerCase()}`)
+ .toLowerCase();
+}
+
+function camelCaseObjectKeys(anObject: { [key: string]: any }) {
+ return transformObjectKeys(anObject, underscoreToCamelCase);
+}
+
+function underscoreObjectKeys(anObject: { [key: string]: any }) {
+ return transformObjectKeys(anObject, camelCaseToUnderscore);
+}
+
+function transformObjectKeys(
+ anObject: { [key: string]: any },
+ keyTransformer: (key: string) => string,
+) {
+ for (const sourceKey of Object.keys(anObject)) {
+ const targetKey = keyTransformer(sourceKey);
+ const sourceValue = anObject[sourceKey];
+
+ anObject[targetKey] =
+ sourceValue !== null && typeof sourceValue === 'object'
+ ? transformObjectKeys(sourceValue, keyTransformer)
+ : sourceValue;
+
+ if (sourceKey !== targetKey) {
+ delete anObject[sourceKey];
+ }
+ }
+ return anObject;
+}
diff --git a/gui/src/main/errors.ts b/gui/src/main/errors.ts
new file mode 100644
index 0000000000..f13b99e3e9
--- /dev/null
+++ b/gui/src/main/errors.ts
@@ -0,0 +1,29 @@
+export class NoCreditError extends Error {
+ constructor() {
+ super("Account doesn't have enough credit available for connection");
+ }
+}
+
+export class NoInternetError extends Error {
+ constructor() {
+ super('Internet connectivity is currently unavailable');
+ }
+}
+
+export class NoDaemonError extends Error {
+ constructor() {
+ super('Could not connect to Mullvad daemon');
+ }
+}
+
+export class InvalidAccountError extends Error {
+ constructor() {
+ super('Invalid account number');
+ }
+}
+
+export class CommunicationError extends Error {
+ constructor() {
+ super('api.mullvad.net is blocked, please check your firewall');
+ }
+}
diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts
new file mode 100644
index 0000000000..57c034b162
--- /dev/null
+++ b/gui/src/main/gui-settings.ts
@@ -0,0 +1,84 @@
+import { app } from 'electron';
+import log from 'electron-log';
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { IGuiSettingsState } from '../shared/gui-settings-state';
+
+export default class GuiSettings {
+ get state(): IGuiSettingsState {
+ return this.stateValue;
+ }
+
+ set autoConnect(newValue: boolean) {
+ this.changeStateAndNotify({ ...this.stateValue, autoConnect: newValue });
+ }
+
+ get autoConnect(): boolean {
+ return this.stateValue.autoConnect;
+ }
+
+ set monochromaticIcon(newValue: boolean) {
+ this.changeStateAndNotify({ ...this.stateValue, monochromaticIcon: newValue });
+ }
+
+ get monochromaticIcon(): boolean {
+ return this.stateValue.monochromaticIcon;
+ }
+
+ set startMinimized(newValue: boolean) {
+ this.changeStateAndNotify({ ...this.stateValue, startMinimized: newValue });
+ }
+
+ get startMinimized(): boolean {
+ return this.stateValue.startMinimized;
+ }
+
+ public onChange?: (newState: IGuiSettingsState, oldState: IGuiSettingsState) => void;
+
+ private stateValue: IGuiSettingsState = {
+ autoConnect: true,
+ monochromaticIcon: false,
+ startMinimized: false,
+ };
+
+ public load() {
+ try {
+ const settingsFile = this.filePath();
+ const contents = fs.readFileSync(settingsFile, 'utf8');
+ const settings = JSON.parse(contents);
+
+ this.stateValue.autoConnect =
+ typeof settings.autoConnect === 'boolean' ? settings.autoConnect : true;
+ this.stateValue.monochromaticIcon = settings.monochromaticIcon || false;
+ this.stateValue.startMinimized = settings.startMinimized || false;
+ } catch (error) {
+ log.error(`Failed to read GUI settings file: ${error}`);
+ }
+ }
+
+ public store() {
+ try {
+ const settingsFile = this.filePath();
+
+ fs.writeFileSync(settingsFile, JSON.stringify(this.stateValue));
+ } catch (error) {
+ log.error(`Failed to write GUI settings file: ${error}`);
+ }
+ }
+
+ private filePath() {
+ return path.join(app.getPath('userData'), 'gui_settings.json');
+ }
+
+ private changeStateAndNotify(newState: IGuiSettingsState) {
+ const oldState = this.stateValue;
+ this.stateValue = newState;
+
+ this.store();
+
+ if (this.onChange) {
+ this.onChange({ ...newState }, oldState);
+ }
+ }
+}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
new file mode 100644
index 0000000000..fb5f6efb54
--- /dev/null
+++ b/gui/src/main/index.ts
@@ -0,0 +1,1147 @@
+import { execFile } from 'child_process';
+import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from 'electron';
+import log from 'electron-log';
+import * as fs from 'fs';
+import mkdirp from 'mkdirp';
+import * as path from 'path';
+import * as uuid from 'uuid';
+import {
+ AccountToken,
+ IAppVersionInfo,
+ ILocation,
+ IRelayList,
+ ISettings,
+ RelaySettingsUpdate,
+ TunnelStateTransition,
+} from '../shared/daemon-rpc-types';
+import { loadTranslations } from '../shared/gettext';
+import { IpcMainEventChannel } from '../shared/ipc-event-channel';
+import { getOpenAtLogin, setOpenAtLogin } from './autostart';
+import { ConnectionObserver, DaemonRpc, SubscriptionListener } from './daemon-rpc';
+import GuiSettings from './gui-settings';
+import NotificationController from './notification-controller';
+import { resolveBin } from './proc';
+import ReconnectionBackoff from './reconnection-backoff';
+import TrayIconController, { TrayIconType } from './tray-icon-controller';
+import WindowController from './window-controller';
+
+const RELAY_LIST_UPDATE_INTERVAL = 60 * 60 * 1000;
+const VERSION_UPDATE_INTERVAL = 24 * 60 * 60 * 1000;
+
+const DAEMON_RPC_PATH =
+ process.platform === 'win32' ? '//./pipe/Mullvad VPN' : '/var/run/mullvad-vpn';
+
+enum AppQuitStage {
+ unready,
+ initiated,
+ ready,
+}
+
+export interface ICurrentAppVersionInfo {
+ gui: string;
+ daemon: string;
+ isConsistent: boolean;
+}
+
+export interface IAppUpgradeInfo extends IAppVersionInfo {
+ nextUpgrade?: string;
+ upToDate: boolean;
+}
+
+class ApplicationMain {
+ private notificationController = new NotificationController();
+ private windowController?: WindowController;
+ private trayIconController?: TrayIconController;
+
+ private daemonRpc = new DaemonRpc();
+ private reconnectBackoff = new ReconnectionBackoff();
+ private connectedToDaemon = false;
+
+ private logFilePath = '';
+ private oldLogFilePath?: string;
+ private quitStage = AppQuitStage.unready;
+
+ private accountHistory: AccountToken[] = [];
+ private tunnelState: TunnelStateTransition = { state: 'disconnected' };
+ private settings: ISettings = {
+ accountToken: undefined,
+ allowLan: false,
+ autoConnect: false,
+ blockWhenDisconnected: false,
+ relaySettings: {
+ normal: {
+ location: 'any',
+ tunnel: 'any',
+ },
+ },
+ tunnelOptions: {
+ generic: {
+ enableIpv6: false,
+ },
+ openvpn: {
+ mssfix: undefined,
+ proxy: undefined,
+ },
+ wireguard: {
+ mtu: undefined,
+ fwmark: undefined,
+ },
+ },
+ };
+ private guiSettings = new GuiSettings();
+ private location?: ILocation;
+ private lastDisconnectedLocation?: ILocation;
+
+ private relays: IRelayList = { countries: [] };
+ private relaysInterval?: NodeJS.Timeout;
+
+ private currentVersion: ICurrentAppVersionInfo = {
+ daemon: '',
+ gui: '',
+ isConsistent: true,
+ };
+
+ private upgradeVersion: IAppUpgradeInfo = {
+ currentIsSupported: true,
+ latest: {
+ latestStable: '',
+ latest: '',
+ },
+ nextUpgrade: undefined,
+ upToDate: true,
+ };
+ private latestVersionInterval?: NodeJS.Timeout;
+
+ public run() {
+ // Since electron's GPU blacklists are broken, GPU acceleration won't work on older distros
+ if (process.platform === 'linux') {
+ app.commandLine.appendSwitch('--disable-gpu');
+ }
+
+ this.overrideAppPaths();
+
+ if (this.ensureSingleInstance()) {
+ return;
+ }
+
+ this.initLogging();
+
+ log.info(`Running version ${app.getVersion()}`);
+
+ if (process.platform === 'win32') {
+ app.setAppUserModelId('net.mullvad.vpn');
+ }
+
+ this.guiSettings.load();
+
+ app.on('activate', this.onActivate);
+ app.on('ready', this.onReady);
+ app.on('window-all-closed', () => app.quit());
+ app.on('before-quit', this.onBeforeQuit);
+ }
+
+ private ensureSingleInstance() {
+ if (app.requestSingleInstanceLock()) {
+ app.on('second-instance', (_event, _commandLine, _workingDirectory) => {
+ if (this.windowController) {
+ this.windowController.show();
+ }
+ });
+ return false;
+ } else {
+ app.quit();
+ return true;
+ }
+ }
+
+ private overrideAppPaths() {
+ // This ensures that on Windows the %LOCALAPPDATA% directory is used instead of the %ADDDATA%
+ // directory that has roaming contents
+ if (process.platform === 'win32') {
+ const appDataDir = process.env.LOCALAPPDATA;
+ if (appDataDir) {
+ app.setPath('appData', appDataDir);
+ app.setPath('userData', path.join(appDataDir, app.getName()));
+ } else {
+ throw new Error('Missing %LOCALAPPDATA% environment variable');
+ }
+ }
+ }
+
+ private initLogging() {
+ const logDirectory = this.getLogsDirectory();
+ const format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}][{level}] {text}';
+
+ this.logFilePath = path.join(logDirectory, 'frontend.log');
+
+ log.transports.console.format = format;
+ log.transports.file.format = format;
+ if (process.env.NODE_ENV === 'development') {
+ log.transports.console.level = 'debug';
+
+ // Disable log file in development
+ log.transports.file.level = false;
+ } else {
+ // Create log folder
+ mkdirp.sync(logDirectory);
+
+ // Backup previous log file if it exists
+ try {
+ fs.accessSync(this.logFilePath);
+ this.oldLogFilePath = path.join(logDirectory, 'frontend.old.log');
+ fs.renameSync(this.logFilePath, this.oldLogFilePath);
+ } catch (error) {
+ // No previous log file exists
+ }
+
+ // Configure logging to file
+ log.transports.console.level = 'debug';
+ log.transports.file.level = 'debug';
+ log.transports.file.file = this.logFilePath;
+
+ log.debug(`Logging to ${this.logFilePath}`);
+ }
+ }
+
+ // Returns platform specific logs folder for application
+ // See open issue and PR on Github:
+ // 1. https://github.com/electron/electron/issues/10118
+ // 2. https://github.com/electron/electron/pull/10191
+ private getLogsDirectory() {
+ switch (process.platform) {
+ case 'darwin':
+ // macOS: ~/Library/Logs/{appname}
+ return path.join(app.getPath('home'), 'Library/Logs', app.getName());
+ default:
+ // Windows: %LOCALAPPDATA%\{appname}\logs
+ // Linux: ~/.config/{appname}/logs
+ return path.join(app.getPath('userData'), 'logs');
+ }
+ }
+
+ private onActivate = () => {
+ if (this.windowController) {
+ this.windowController.show();
+ }
+ };
+
+ private onBeforeQuit = async (event: Electron.Event) => {
+ switch (this.quitStage) {
+ case AppQuitStage.unready:
+ // postpone the app shutdown
+ event.preventDefault();
+
+ this.quitStage = AppQuitStage.initiated;
+ await this.prepareToQuit();
+
+ // terminate the app
+ this.quitStage = AppQuitStage.ready;
+ app.quit();
+ break;
+
+ case AppQuitStage.initiated:
+ // prevent immediate exit, the app will quit after running the shutdown routine
+ event.preventDefault();
+ return;
+
+ case AppQuitStage.ready:
+ // let the app quit freely at this point
+ break;
+ }
+ };
+
+ private async prepareToQuit() {
+ if (this.connectedToDaemon) {
+ try {
+ await this.daemonRpc.disconnectTunnel();
+ log.info('Disconnected the tunnel');
+ } catch (e) {
+ log.error(`Failed to disconnect the tunnel: ${e.message}`);
+ }
+ } else {
+ log.info('Cannot close the tunnel because there is no active connection to daemon.');
+ }
+ }
+
+ private onReady = async () => {
+ loadTranslations(app.getLocale());
+
+ this.daemonRpc.addConnectionObserver(
+ new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected),
+ );
+ this.connectToDaemon();
+
+ const window = this.createWindow();
+ const tray = this.createTray();
+
+ const windowController = new WindowController(window, tray);
+ const trayIconController = new TrayIconController(
+ tray,
+ 'unsecured',
+ process.platform === 'darwin' && this.guiSettings.monochromaticIcon,
+ );
+
+ this.registerWindowListener(windowController);
+ this.registerIpcListeners();
+ this.setAppMenu();
+ this.addContextMenu(window);
+
+ this.windowController = windowController;
+ this.trayIconController = trayIconController;
+
+ this.guiSettings.onChange = (newState, oldState) => {
+ if (
+ process.platform === 'darwin' &&
+ oldState.monochromaticIcon !== newState.monochromaticIcon
+ ) {
+ if (this.trayIconController) {
+ this.trayIconController.useMonochromaticIcon = newState.monochromaticIcon;
+ }
+ }
+
+ if (newState.autoConnect !== oldState.autoConnect) {
+ this.updateDaemonsAutoConnect();
+ }
+
+ if (this.windowController) {
+ IpcMainEventChannel.guiSettings.notify(this.windowController.webContents, newState);
+ }
+ };
+
+ if (process.env.NODE_ENV === 'development') {
+ await this.installDevTools();
+ window.webContents.openDevTools({ mode: 'detach' });
+ }
+
+ switch (process.platform) {
+ case 'win32':
+ this.installWindowsMenubarAppWindowHandlers(tray, windowController);
+ break;
+ case 'darwin':
+ this.installMacOsMenubarAppWindowHandlers(tray, windowController);
+ break;
+ case 'linux':
+ this.installGenericMenubarAppWindowHandlers(tray, windowController);
+ this.installLinuxWindowCloseHandler(windowController);
+ break;
+ default:
+ this.installGenericMenubarAppWindowHandlers(tray, windowController);
+ break;
+ }
+
+ if (this.shouldShowWindowOnStart() || process.env.NODE_ENV === 'development') {
+ windowController.show();
+ }
+
+ window.loadFile(path.resolve(path.join(__dirname, '../renderer/index.html')));
+ };
+
+ private onDaemonConnected = async () => {
+ this.connectedToDaemon = true;
+
+ // subscribe to events
+ try {
+ await this.subscribeEvents();
+ } catch (error) {
+ log.error(`Failed to subscribe: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch account history
+ try {
+ this.setAccountHistory(await this.daemonRpc.getAccountHistory());
+ } catch (error) {
+ log.error(`Failed to fetch the account history: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch the tunnel state
+ try {
+ this.setTunnelState(await this.daemonRpc.getState());
+ } catch (error) {
+ log.error(`Failed to fetch the tunnel state: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch settings
+ try {
+ this.setSettings(await this.daemonRpc.getSettings());
+ } catch (error) {
+ log.error(`Failed to fetch settings: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch relays
+ try {
+ this.setRelays(await this.daemonRpc.getRelayLocations());
+ } catch (error) {
+ log.error(`Failed to fetch relay locations: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch the daemon's version
+ try {
+ this.setDaemonVersion(await this.daemonRpc.getCurrentVersion());
+ } catch (error) {
+ log.error(`Failed to fetch the daemon's version: ${error.message}`);
+
+ return this.recoverFromBootstrapError(error);
+ }
+
+ // fetch the latest version info in background
+ this.fetchLatestVersion();
+
+ // start periodic updates
+ this.startRelaysPeriodicUpdates();
+ this.startLatestVersionPeriodicUpdates();
+
+ // notify user about inconsistent version
+ if (
+ process.env.NODE_ENV !== 'development' &&
+ !this.shouldSuppressNotifications() &&
+ !this.currentVersion.isConsistent
+ ) {
+ this.notificationController.notifyInconsistentVersion();
+ }
+
+ // reset the reconnect backoff when connection established.
+ this.reconnectBackoff.reset();
+
+ // notify renderer
+ if (this.windowController) {
+ IpcMainEventChannel.daemonConnected.notify(this.windowController.webContents);
+ }
+ };
+
+ private onDaemonDisconnected = (error?: Error) => {
+ // make sure we were connected before to distinguish between a failed attempt to reconnect and
+ // connection loss.
+ const wasConnected = this.connectedToDaemon;
+
+ if (wasConnected) {
+ this.connectedToDaemon = false;
+
+ // stop periodic updates
+ this.stopRelaysPeriodicUpdates();
+ this.stopLatestVersionPeriodicUpdates();
+
+ // notify renderer process
+ if (this.windowController) {
+ IpcMainEventChannel.daemonDisconnected.notify(
+ this.windowController.webContents,
+ error ? error.message : undefined,
+ );
+ }
+ }
+
+ // recover connection on error
+ if (error) {
+ if (wasConnected) {
+ log.error(`Lost connection to daemon: ${error.message}`);
+ } else {
+ log.error(`Failed to connect to daemon: ${error.message}`);
+ }
+
+ this.reconnectToDaemon();
+ } else {
+ log.info('Disconnected from the daemon');
+ }
+ };
+
+ private connectToDaemon() {
+ this.daemonRpc.connect({ path: DAEMON_RPC_PATH });
+ }
+
+ private reconnectToDaemon() {
+ this.reconnectBackoff.attempt(() => {
+ this.connectToDaemon();
+ });
+ }
+
+ private recoverFromBootstrapError(_error?: Error) {
+ // Attempt to reconnect to daemon if the program fails to fetch settings, tunnel state or
+ // subscribe for RPC events.
+ this.daemonRpc.disconnect();
+
+ this.reconnectToDaemon();
+ }
+
+ private async subscribeEvents(): Promise<void> {
+ const stateListener = new SubscriptionListener(
+ (newState: TunnelStateTransition) => {
+ this.setTunnelState(newState);
+ },
+ (error: Error) => {
+ log.error(`Cannot deserialize the new state: ${error.message}`);
+ },
+ );
+
+ const settingsListener = new SubscriptionListener(
+ (newSettings: ISettings) => {
+ this.setSettings(newSettings);
+ },
+ (error: Error) => {
+ log.error(`Cannot deserialize the new settings: ${error.message}`);
+ },
+ );
+
+ await Promise.all([
+ this.daemonRpc.subscribeStateListener(stateListener),
+ this.daemonRpc.subscribeSettingsListener(settingsListener),
+ ]);
+ }
+
+ private setAccountHistory(accountHistory: AccountToken[]) {
+ this.accountHistory = accountHistory;
+
+ if (this.windowController) {
+ IpcMainEventChannel.accountHistory.notify(this.windowController.webContents, accountHistory);
+ }
+ }
+
+ private setTunnelState(newState: TunnelStateTransition) {
+ this.tunnelState = newState;
+ this.updateTrayIcon(newState, this.settings.blockWhenDisconnected);
+ this.updateLocation();
+
+ if (!this.shouldSuppressNotifications()) {
+ this.notificationController.notifyTunnelState(newState);
+ }
+
+ if (this.windowController) {
+ IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState);
+ }
+ }
+
+ private setSettings(newSettings: ISettings) {
+ const oldSettings = this.settings;
+ this.settings = newSettings;
+
+ this.updateTrayIcon(this.tunnelState, newSettings.blockWhenDisconnected);
+
+ if (oldSettings.accountToken !== newSettings.accountToken) {
+ this.updateAccountHistory();
+ }
+
+ if (this.windowController) {
+ IpcMainEventChannel.settings.notify(this.windowController.webContents, newSettings);
+ }
+ }
+
+ private setLocation(newLocation: ILocation) {
+ this.location = newLocation;
+
+ if (this.windowController) {
+ IpcMainEventChannel.location.notify(this.windowController.webContents, newLocation);
+ }
+ }
+
+ private setRelays(newRelayList: IRelayList) {
+ this.relays = newRelayList;
+
+ if (this.windowController) {
+ IpcMainEventChannel.relays.notify(this.windowController.webContents, newRelayList);
+ }
+ }
+
+ private startRelaysPeriodicUpdates() {
+ log.debug('Start relays periodic updates');
+
+ const handler = async () => {
+ try {
+ this.setRelays(await this.daemonRpc.getRelayLocations());
+ } catch (error) {
+ log.error(`Failed to fetch relay locations: ${error.message}`);
+ }
+ };
+
+ this.relaysInterval = global.setInterval(handler, RELAY_LIST_UPDATE_INTERVAL);
+ }
+
+ private stopRelaysPeriodicUpdates() {
+ if (this.relaysInterval) {
+ clearInterval(this.relaysInterval);
+ this.relaysInterval = undefined;
+
+ log.debug('Stop relays periodic updates');
+ }
+ }
+
+ private setDaemonVersion(daemonVersion: string) {
+ const guiVersion = app.getVersion().replace('.0', '');
+ const versionInfo = {
+ daemon: daemonVersion,
+ gui: guiVersion,
+ isConsistent: daemonVersion === guiVersion,
+ };
+
+ this.currentVersion = versionInfo;
+
+ // notify renderer
+ if (this.windowController) {
+ IpcMainEventChannel.currentVersion.notify(this.windowController.webContents, versionInfo);
+ }
+ }
+
+ private setLatestVersion(latestVersionInfo: IAppVersionInfo) {
+ function isBeta(version: string) {
+ return version.includes('-');
+ }
+
+ function nextUpgrade(
+ current: string,
+ latest: string,
+ latestStable: string,
+ ): string | undefined {
+ if (isBeta(current)) {
+ return current === latest ? undefined : latest;
+ } else {
+ return current === latestStable ? undefined : latestStable;
+ }
+ }
+
+ function checkIfLatest(current: string, latest: string, latestStable: string): boolean {
+ // perhaps -beta?
+ if (isBeta(current)) {
+ return current === latest;
+ } else {
+ // must be stable
+ return current === latestStable;
+ }
+ }
+
+ const currentVersionInfo = this.currentVersion;
+ const latestVersion = latestVersionInfo.latest.latest;
+ const latestStableVersion = latestVersionInfo.latest.latestStable;
+
+ // the reason why we rely on daemon version here is because daemon obtains the version info
+ // based on its built-in version information
+ const isUpToDate = checkIfLatest(currentVersionInfo.daemon, latestVersion, latestStableVersion);
+ const upgradeVersion = nextUpgrade(
+ currentVersionInfo.daemon,
+ latestVersion,
+ latestStableVersion,
+ );
+
+ const upgradeInfo = {
+ ...latestVersionInfo,
+ nextUpgrade: upgradeVersion,
+ upToDate: isUpToDate,
+ };
+
+ this.upgradeVersion = upgradeInfo;
+
+ // notify user to update the app if it became unsupported
+ if (
+ process.env.NODE_ENV !== 'development' &&
+ !this.shouldSuppressNotifications() &&
+ currentVersionInfo.isConsistent &&
+ !latestVersionInfo.currentIsSupported &&
+ upgradeVersion
+ ) {
+ this.notificationController.notifyUnsupportedVersion(upgradeVersion);
+ }
+
+ if (this.windowController) {
+ IpcMainEventChannel.upgradeVersion.notify(this.windowController.webContents, upgradeInfo);
+ }
+ }
+
+ private async fetchLatestVersion() {
+ try {
+ this.setLatestVersion(await this.daemonRpc.getVersionInfo());
+ } catch (error) {
+ log.error(`Failed to request the version info: ${error.message}`);
+ }
+ }
+
+ private startLatestVersionPeriodicUpdates() {
+ const handler = () => {
+ this.fetchLatestVersion();
+ };
+ this.latestVersionInterval = global.setInterval(handler, VERSION_UPDATE_INTERVAL);
+ }
+
+ private stopLatestVersionPeriodicUpdates() {
+ if (this.latestVersionInterval) {
+ clearInterval(this.latestVersionInterval);
+
+ this.latestVersionInterval = undefined;
+ }
+ }
+
+ private shouldSuppressNotifications(): boolean {
+ return this.windowController ? this.windowController.isVisible() : false;
+ }
+
+ private async updateLocation() {
+ const state = this.tunnelState.state;
+
+ if (state === 'connected' || state === 'disconnected' || state === 'connecting') {
+ try {
+ // It may take some time to fetch the new user location.
+ // So take the user to the last known location when disconnected.
+ if (state === 'disconnected' && this.lastDisconnectedLocation) {
+ this.setLocation(this.lastDisconnectedLocation);
+ }
+
+ // Fetch the new user location
+ const location = await this.daemonRpc.getLocation();
+ // If the location is currently unavailable, do nothing! This only ever
+ // happens when a custom relay is set or we are in a blocked state.
+ if (!location) {
+ return;
+ }
+
+ // Cache the user location
+ // Note: hostname is only set for relay servers.
+ if (location.hostname === null) {
+ this.lastDisconnectedLocation = location;
+ }
+
+ // Broadcast the new location.
+ // There is a chance that the location is not stale if the tunnel state before the location
+ // request is the same as after receiving the response.
+ if (this.tunnelState.state === state) {
+ this.setLocation(location);
+ }
+ } catch (error) {
+ log.error(`Failed to update the location: ${error.message}`);
+ }
+ }
+ }
+
+ private trayIconType(
+ tunnelState: TunnelStateTransition,
+ blockWhenDisconnected: boolean,
+ ): TrayIconType {
+ switch (tunnelState.state) {
+ case 'connected':
+ return 'secured';
+
+ case 'connecting':
+ return 'securing';
+
+ case 'blocked':
+ switch (tunnelState.details.reason) {
+ case 'set_firewall_policy_error':
+ return 'unsecured';
+ default:
+ return 'securing';
+ }
+
+ case 'disconnecting':
+ return 'securing';
+
+ case 'disconnected':
+ if (blockWhenDisconnected) {
+ return 'securing';
+ } else {
+ return 'unsecured';
+ }
+ }
+ }
+
+ private updateTrayIcon(tunnelState: TunnelStateTransition, blockWhenDisconnected: boolean) {
+ const type = this.trayIconType(tunnelState, blockWhenDisconnected);
+
+ if (this.trayIconController) {
+ this.trayIconController.animateToIcon(type);
+ }
+ }
+
+ private registerWindowListener(windowController: WindowController) {
+ windowController.window.on('show', () => {
+ // cancel notifications when window appears
+ this.notificationController.cancelPendingNotifications();
+
+ windowController.send('window-shown');
+ });
+
+ windowController.window.on('hide', () => {
+ // ensure notification guard is reset
+ this.notificationController.resetTunnelStateAnnouncements();
+ });
+ }
+
+ private registerIpcListeners() {
+ IpcMainEventChannel.state.handleGet(() => ({
+ isConnected: this.connectedToDaemon,
+ autoStart: getOpenAtLogin(),
+ accountHistory: this.accountHistory,
+ tunnelState: this.tunnelState,
+ settings: this.settings,
+ location: this.location,
+ relays: this.relays,
+ currentVersion: this.currentVersion,
+ upgradeVersion: this.upgradeVersion,
+ guiSettings: this.guiSettings.state,
+ }));
+
+ IpcMainEventChannel.settings.handleAllowLan((allowLan: boolean) =>
+ this.daemonRpc.setAllowLan(allowLan),
+ );
+ IpcMainEventChannel.settings.handleEnableIpv6((enableIpv6: boolean) =>
+ this.daemonRpc.setEnableIpv6(enableIpv6),
+ );
+ IpcMainEventChannel.settings.handleBlockWhenDisconnected((blockWhenDisconnected: boolean) =>
+ this.daemonRpc.setBlockWhenDisconnected(blockWhenDisconnected),
+ );
+ IpcMainEventChannel.settings.handleOpenVpnMssfix((mssfix?: number) =>
+ this.daemonRpc.setOpenVpnMssfix(mssfix),
+ );
+ IpcMainEventChannel.settings.handleUpdateRelaySettings((update: RelaySettingsUpdate) =>
+ this.daemonRpc.updateRelaySettings(update),
+ );
+
+ IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => {
+ return this.setAutoStart(autoStart);
+ });
+
+ IpcMainEventChannel.tunnel.handleConnect(() => this.daemonRpc.connectTunnel());
+ IpcMainEventChannel.tunnel.handleDisconnect(() => this.daemonRpc.disconnectTunnel());
+
+ IpcMainEventChannel.guiSettings.handleAutoConnect((autoConnect: boolean) => {
+ this.guiSettings.autoConnect = autoConnect;
+ });
+
+ IpcMainEventChannel.guiSettings.handleStartMinimized((startMinimized: boolean) => {
+ this.guiSettings.startMinimized = startMinimized;
+ });
+
+ IpcMainEventChannel.guiSettings.handleMonochromaticIcon((monochromaticIcon: boolean) => {
+ this.guiSettings.monochromaticIcon = monochromaticIcon;
+ });
+
+ IpcMainEventChannel.account.handleSet((token: AccountToken) =>
+ this.daemonRpc.setAccount(token),
+ );
+ IpcMainEventChannel.account.handleUnset(() => this.daemonRpc.setAccount());
+ IpcMainEventChannel.account.handleGetData((token: AccountToken) =>
+ this.daemonRpc.getAccountData(token),
+ );
+
+ IpcMainEventChannel.accountHistory.handleRemoveItem(async (token: AccountToken) => {
+ await this.daemonRpc.removeAccountFromHistory(token);
+ this.updateAccountHistory();
+ });
+
+ ipcMain.on('show-window', () => {
+ const windowController = this.windowController;
+ if (windowController) {
+ windowController.show();
+ }
+ });
+
+ ipcMain.on('collect-logs', (event: Electron.Event, requestId: string, toRedact: string[]) => {
+ const reportPath = path.join(app.getPath('temp'), uuid.v4() + '.log');
+ const executable = resolveBin('problem-report');
+ const args = ['collect', '--output', reportPath];
+ if (toRedact.length > 0) {
+ args.push('--redact', ...toRedact, '--');
+ }
+ args.push(this.logFilePath);
+ if (this.oldLogFilePath) {
+ args.push(this.oldLogFilePath);
+ }
+
+ execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
+ if (error) {
+ log.error(
+ `Failed to collect a problem report: ${error.message}
+ Stdout: ${stdout.toString()}
+ Stderr: ${stderr.toString()}`,
+ );
+
+ event.sender.send('collect-logs-reply', requestId, {
+ success: false,
+ error: error.message,
+ });
+ } else {
+ log.debug(`Problem report was written to ${reportPath}`);
+
+ event.sender.send('collect-logs-reply', requestId, {
+ success: true,
+ reportPath,
+ });
+ }
+ });
+ });
+
+ ipcMain.on(
+ 'send-problem-report',
+ (
+ event: Electron.Event,
+ requestId: string,
+ email: string,
+ message: string,
+ savedReport: string,
+ ) => {
+ const executable = resolveBin('problem-report');
+ const args = ['send', '--email', email, '--message', message, '--report', savedReport];
+
+ execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
+ if (error) {
+ log.error(
+ `Failed to send a problem report: ${error.message}
+ Stdout: ${stdout.toString()}
+ Stderr: ${stderr.toString()}`,
+ );
+
+ event.sender.send('send-problem-report-reply', requestId, {
+ success: false,
+ error: error.message,
+ });
+ } else {
+ log.info('Problem report was sent.');
+
+ event.sender.send('send-problem-report-reply', requestId, {
+ success: true,
+ });
+ }
+ });
+ },
+ );
+ }
+
+ private async updateAccountHistory(): Promise<void> {
+ try {
+ this.setAccountHistory(await this.daemonRpc.getAccountHistory());
+ } catch (error) {
+ log.error(`Failed to fetch the account history: ${error.message}`);
+ }
+ }
+
+ private updateDaemonsAutoConnect() {
+ const daemonAutoConnect = this.guiSettings.autoConnect && getOpenAtLogin();
+ if (daemonAutoConnect !== this.settings.autoConnect) {
+ 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 (error) {
+ log.error(
+ `Failed to update the autostart to ${autoStart.toString()}. ${error.message.toString()}`,
+ );
+ }
+ return Promise.resolve();
+ }
+
+ private async installDevTools() {
+ const installer = require('electron-devtools-installer');
+ const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
+ const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
+ for (const name of extensions) {
+ try {
+ await installer.default(installer[name], forceDownload);
+ } catch (e) {
+ log.info(`Error installing ${name} extension: ${e.message}`);
+ }
+ }
+ }
+
+ private createWindow(): BrowserWindow {
+ const contentHeight = 568;
+
+ // the size of transparent area around arrow on macOS
+ const headerBarArrowHeight = 12;
+
+ const options = {
+ width: 320,
+ minWidth: 320,
+ height: contentHeight,
+ minHeight: contentHeight,
+ resizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ show: false,
+ frame: false,
+ };
+
+ switch (process.platform) {
+ case 'darwin': {
+ // setup window flags to mimic popover on macOS
+ const appWindow = new BrowserWindow({
+ ...options,
+ height: contentHeight + headerBarArrowHeight,
+ minHeight: contentHeight + headerBarArrowHeight,
+ transparent: true,
+ });
+
+ // make the window visible on all workspaces
+ appWindow.setVisibleOnAllWorkspaces(true);
+
+ return appWindow;
+ }
+
+ case 'win32':
+ // setup window flags to mimic an overlay window
+ return new BrowserWindow({
+ ...options,
+ transparent: true,
+ skipTaskbar: true,
+ });
+
+ default:
+ return new BrowserWindow(options);
+ }
+ }
+
+ private setAppMenu() {
+ const template: Electron.MenuItemConstructorOptions[] = [
+ {
+ label: 'Mullvad',
+ submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }],
+ },
+ {
+ label: 'Edit',
+ submenu: [
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { type: 'separator' },
+ { role: 'selectall' },
+ ],
+ },
+ ];
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
+ }
+
+ private addContextMenu(window: BrowserWindow) {
+ const menuTemplate: Electron.MenuItemConstructorOptions[] = [
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { type: 'separator' },
+ { role: 'selectall' },
+ ];
+
+ // add inspect element on right click menu
+ window.webContents.on(
+ 'context-menu',
+ (_e: Event, props: { x: number; y: number; isEditable: boolean }) => {
+ const inspectTemplate = [
+ {
+ label: 'Inspect element',
+ click() {
+ window.webContents.openDevTools({ mode: 'detach' });
+ 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 });
+ } else {
+ Menu.buildFromTemplate(menuTemplate).popup({ 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 });
+ }
+ },
+ );
+ }
+
+ 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);
+
+ // disable icon highlight on macOS
+ if (process.platform === 'darwin') {
+ tray.setHighlightMode('never');
+ }
+
+ return tray;
+ }
+
+ private installWindowsMenubarAppWindowHandlers(tray: Tray, windowController: WindowController) {
+ tray.on('click', () => windowController.toggle());
+ tray.on('right-click', () => windowController.hide());
+
+ 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) {
+ windowController.hide();
+ }
+ });
+ }
+
+ // setup NSEvent monitor to fix inconsistent window.blur on macOS
+ // see https://github.com/electron/electron/issues/8689
+ private installMacOsMenubarAppWindowHandlers(tray: Tray, windowController: WindowController) {
+ // $FlowFixMe: this module is only available on macOS
+ const { NSEventMonitor, NSEventMask } = require('nseventmonitor');
+ const macEventMonitor = new NSEventMonitor();
+ // tslint:disable-next-line
+ const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown;
+ const window = windowController.window;
+
+ window.on('show', () => macEventMonitor.start(eventMask, () => windowController.hide()));
+ window.on('hide', () => macEventMonitor.stop());
+ tray.on('click', () => windowController.toggle());
+ }
+
+ private installGenericMenubarAppWindowHandlers(tray: Tray, windowController: WindowController) {
+ tray.on('click', () => {
+ windowController.toggle();
+ });
+ }
+
+ private installLinuxWindowCloseHandler(windowController: WindowController) {
+ windowController.window.on('close', (closeEvent: Event) => {
+ if (process.platform === 'linux' && this.quitStage !== AppQuitStage.ready) {
+ closeEvent.preventDefault();
+ windowController.hide();
+ }
+ });
+ }
+
+ private shouldShowWindowOnStart(): boolean {
+ switch (process.platform) {
+ case 'win32':
+ return false;
+ case 'darwin':
+ return false;
+ case 'linux':
+ return !this.guiSettings.startMinimized;
+ default:
+ return true;
+ }
+ }
+}
+
+const applicationMain = new ApplicationMain();
+applicationMain.run();
diff --git a/gui/src/main/jsonrpc-client.ts b/gui/src/main/jsonrpc-client.ts
new file mode 100644
index 0000000000..2d66aad50d
--- /dev/null
+++ b/gui/src/main/jsonrpc-client.ts
@@ -0,0 +1,483 @@
+import assert from 'assert';
+import log from 'electron-log';
+import { EventEmitter } from 'events';
+import jsonrpc from 'jsonrpc-lite';
+import JSONStream from 'JSONStream';
+import * as net from 'net';
+import * as uuid from 'uuid';
+
+export interface IUnansweredRequest {
+ resolve: (value: any) => void;
+ reject: (value: any) => void;
+ timerId: NodeJS.Timeout;
+ message: object;
+}
+
+export interface IJsonRpcErrorResponse {
+ type: 'error';
+ payload: {
+ id: string;
+ error: {
+ code: number;
+ message: string;
+ };
+ };
+}
+export interface IJsonRpcNotification {
+ type: 'notification';
+ payload: {
+ method: string;
+ params: {
+ subscription: string;
+ result: any;
+ };
+ };
+}
+export interface IJsonRpcSuccess {
+ type: 'success';
+ payload: {
+ id: string;
+ result: any;
+ };
+}
+export type JsonRpcMessage = IJsonRpcErrorResponse | IJsonRpcNotification | IJsonRpcSuccess;
+
+export class RemoteError extends Error {
+ constructor(private codeValue: number, private detailsValue: string) {
+ super(`Remote JSON-RPC error ${codeValue}: ${detailsValue}`);
+ }
+
+ get code(): number {
+ return this.codeValue;
+ }
+
+ get details(): string {
+ return this.detailsValue;
+ }
+}
+
+export class TimeOutError extends Error {
+ constructor(private jsonRpcMessageValue: object) {
+ super('Request timed out');
+ }
+
+ get jsonRpcMessage(): object {
+ return this.jsonRpcMessageValue;
+ }
+}
+
+export class SubscriptionError extends Error {
+ constructor(message: string, private replyValue: any) {
+ super(`${message}: ${JSON.stringify(replyValue)}`);
+ }
+
+ get reply(): any {
+ return this.replyValue;
+ }
+}
+
+export class WebSocketError extends Error {
+ get code(): number {
+ return this.codeValue;
+ }
+
+ private static reason(code: number): string {
+ switch (code) {
+ case 1006:
+ return 'Abnormal closure';
+ case 1011:
+ return 'Internal error';
+ case 1012:
+ return 'Service restart';
+ case 1014:
+ return 'Bad gateway';
+ default:
+ return `Unknown (${code})`;
+ }
+ }
+ constructor(private codeValue: number) {
+ super(WebSocketError.reason(codeValue));
+ }
+}
+
+export class TransportError extends Error {}
+
+const DEFAULT_TIMEOUT_MILLIS = 5000;
+
+export default class JsonRpcClient<T> extends EventEmitter {
+ private unansweredRequests: Map<string, IUnansweredRequest> = new Map();
+ private subscriptions: Map<string | number, (value: any) => void> = new Map();
+ private transport: ITransport<T>;
+
+ constructor(transport: ITransport<T>) {
+ super();
+
+ this.transport = transport;
+ }
+
+ /// Connect websocket
+ public connect(connectionParams: T): Promise<void> {
+ return new Promise((resolve, reject) => {
+ this.disconnect();
+
+ log.info('Connecting to transport with params', connectionParams);
+
+ // A flag used to determine if Promise was resolved.
+ let isPromiseResolved = false;
+
+ const transport = this.transport;
+
+ transport.onOpen = () => {
+ log.info('Transport is connected');
+ this.emit('open');
+
+ // Resolve the Promise
+ resolve();
+ isPromiseResolved = true;
+ };
+
+ transport.onMessage = (obj) => {
+ this.onMessage(obj);
+ };
+
+ transport.onClose = (error?: Error) => {
+ // Remove all subscriptions since they are connection based
+ this.subscriptions.clear();
+
+ this.emit('close', error);
+
+ // Prevent rejecting a previously resolved Promise.
+ if (!isPromiseResolved) {
+ reject(error);
+ }
+ };
+ transport.connect(connectionParams);
+
+ this.transport = transport;
+ });
+ }
+
+ public disconnect() {
+ if (this.transport) {
+ this.transport.close();
+ }
+ }
+
+ public async subscribe(event: string, listener: (value: any) => void): Promise<void> {
+ log.silly(`Adding a listener for ${event}`);
+
+ try {
+ const subscriptionId = await this.send(`${event}_subscribe`);
+ if (typeof subscriptionId === 'string' || typeof subscriptionId === 'number') {
+ this.subscriptions.set(subscriptionId, listener);
+ } else {
+ throw new SubscriptionError(
+ 'The subscription id was not a string or a number',
+ subscriptionId,
+ );
+ }
+ } catch (e) {
+ log.error(`Failed adding listener to ${event}: ${e.message}`);
+ throw e;
+ }
+ }
+
+ public send(action: string, data?: any, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<any> {
+ return new Promise((resolve, reject) => {
+ const transport = this.transport;
+ if (!transport) {
+ reject(new Error('RPC client transport is not connected.'));
+ return;
+ }
+
+ const id = uuid.v4();
+ const payload = this.prepareParams(data);
+ const timerId = global.setTimeout(() => this.onTimeout(id), timeout);
+ const message = jsonrpc.request(id, action, payload);
+ this.unansweredRequests.set(id, {
+ resolve,
+ reject,
+ timerId,
+ message,
+ });
+
+ try {
+ log.silly('Sending message', id, action);
+ transport.send(JSON.stringify(message));
+ } catch (error) {
+ log.error(`Failed sending RPC message "${action}": ${error.message}`);
+
+ // clean up on error
+ this.unansweredRequests.delete(id);
+ clearTimeout(timerId);
+
+ throw error;
+ }
+ });
+ }
+
+ private prepareParams(data?: any): any[] | object {
+ // JSONRPC only accepts arrays and objects as params, but
+ // this isn't very nice to use, so this method wraps other
+ // types in an array. The choice of array is based on try-and-error
+
+ if (data === undefined) {
+ return [];
+ } else if (data === null) {
+ return [null];
+ } else if (Array.isArray(data) || typeof data === 'object') {
+ return data;
+ } else {
+ return [data];
+ }
+ }
+
+ private onTimeout(requestId: string) {
+ const request = this.unansweredRequests.get(requestId);
+
+ this.unansweredRequests.delete(requestId);
+
+ if (request) {
+ log.warn(`Request ${requestId} timed out: `, request.message);
+ request.reject(new TimeOutError(request.message));
+ } else {
+ log.warn(`Request ${requestId} timed out but it seems to already have been answered`);
+ }
+ }
+
+ private onMessage(obj: object) {
+ let message: any;
+ try {
+ // @ts-ignore
+ message = jsonrpc.parseObject(obj);
+ } catch (error) {
+ log.error(`Failed to parse JSON-RPC message: ${error} for object`);
+ return;
+ }
+
+ if (message.type === 'notification') {
+ this.onNotification(message);
+ } else {
+ this.onReply(message);
+ }
+ }
+
+ private onNotification(message: IJsonRpcNotification) {
+ const subscriptionId = message.payload.params.subscription;
+ const listener = this.subscriptions.get(subscriptionId);
+
+ if (listener) {
+ log.silly(`Got notification for ${message.payload.method}`);
+ listener(message.payload.params.result);
+ } else {
+ log.warn(`Got notification for ${message.payload.method} but no one is listening for it`);
+ }
+ }
+
+ private onReply(message: IJsonRpcErrorResponse | IJsonRpcSuccess) {
+ const id = message.payload.id;
+ const request = this.unansweredRequests.get(id);
+ this.unansweredRequests.delete(id);
+
+ if (request) {
+ log.silly('Got answer to', id, message.type);
+
+ clearTimeout(request.timerId);
+
+ if (message.type === 'error') {
+ const error = message.payload.error;
+ request.reject(new RemoteError(error.code, error.message));
+ } else {
+ const reply = message.payload.result;
+ request.resolve(reply);
+ }
+ } else {
+ log.warn(`Got reply to ${id} but no one was waiting for it`);
+ }
+ }
+}
+
+interface ITransport<T> {
+ onOpen: () => void;
+ onMessage: (data: object) => void;
+ onClose: (error?: Error) => void;
+ close(): void;
+ send(message: string): void;
+ connect(params: T): void;
+}
+
+export class WebsocketTransport implements ITransport<string> {
+ public ws?: WebSocket;
+
+ constructor(ws?: WebSocket) {
+ this.ws = ws;
+ }
+ public onOpen = () => {
+ // no-op
+ };
+ public onMessage = (_message: object) => {
+ // no-op
+ };
+ public onClose = (_error?: Error) => {
+ // no-op
+ };
+
+ public close() {
+ if (this.ws) {
+ this.ws.close();
+ }
+ }
+
+ public send(msg: string) {
+ if (this.ws) {
+ this.ws.send(msg);
+ }
+ }
+
+ public connect(params: string): void {
+ if (this.ws) {
+ this.ws.close();
+ }
+ this.ws = new WebSocket(params);
+ this.ws.onopen = (_event) => {
+ this.onOpen();
+ };
+ this.ws.onmessage = (event) => {
+ try {
+ const data = event.data;
+ if (typeof data === 'string') {
+ const msg = JSON.parse(data);
+ this.onMessage(msg);
+ } else {
+ throw event;
+ }
+ } catch (error) {
+ log.error('Got invalid reply from server: ', error);
+ }
+ };
+
+ this.ws.onclose = (event) => {
+ log.info(`The websocket connection closed with code: ${event.code}`);
+ if (event.code === 1000) {
+ this.onClose();
+ } else {
+ this.onClose(new WebSocketError(event.code));
+ }
+ };
+ }
+}
+
+// Given the correct parameters, this transport supports named pipes/unix
+// domain sockets, and also TCP/UDP sockets
+export class SocketTransport implements ITransport<{ path: string }> {
+ private connection?: net.Socket;
+ private jsonStream?: NodeJS.ReadWriteStream;
+ private socketReady = false;
+ private lastError?: Error;
+ public onMessage = (_message: object) => {
+ // no-op
+ };
+ public onClose = (_error?: Error) => {
+ // no-op
+ };
+ public onOpen = () => {
+ // no-op
+ };
+
+ public connect(options: { path: string }) {
+ assert(!this.connection, 'Make sure to close the existing socket');
+
+ const jsonStream = JSONStream.parse(null)
+ .on('data', this.onJsonStreamData)
+ .once('error', this.onJsonStreamError);
+
+ const connection = new net.Socket()
+ .once('ready', this.onSocketReady)
+ .once('error', this.onSocketError)
+ .once('close', this.onSocketClose);
+
+ this.connection = connection;
+ this.jsonStream = jsonStream;
+ this.socketReady = false;
+ this.lastError = undefined;
+
+ log.debug('Connect socket');
+
+ connection.pipe(jsonStream);
+ connection.connect(options);
+ }
+
+ public close() {
+ if (this.connection) {
+ log.debug('Close socket');
+
+ // closing socket is not synchronous, so remove all of the event handlers first
+ this.connection
+ .removeListener('ready', this.onSocketReady)
+ .removeListener('error', this.onSocketError)
+ .removeListener('close', this.onSocketClose);
+
+ this.jsonStream!.removeListener('data', this.onJsonStreamData).removeListener(
+ 'error',
+ this.onJsonStreamError,
+ );
+
+ try {
+ this.connection.end();
+ } catch (error) {
+ log.error('Failed to close the socket: ', error);
+ }
+
+ this.connection = undefined;
+ this.jsonStream = undefined;
+ this.onClose();
+ }
+ }
+
+ public send(msg: string) {
+ if (this.socketReady && this.connection) {
+ this.connection.write(msg);
+ } else {
+ throw new TransportError('Socket not connected');
+ }
+ }
+
+ private onSocketReady = () => {
+ this.socketReady = true;
+
+ log.debug('Socket is ready');
+
+ this.onOpen();
+ };
+
+ private onSocketError = (error: Error) => {
+ this.lastError = error;
+
+ log.error('Socket error: ', error);
+ };
+
+ private onSocketClose = (hadError: boolean) => {
+ if (hadError) {
+ log.debug(`Socket was closed due to an error: `, this.lastError);
+
+ this.onClose(this.lastError);
+ } else {
+ log.debug(`Socket was closed by peer`);
+
+ this.onClose(new TransportError('Socket was closed by peer'));
+ }
+ };
+
+ private onJsonStreamData = (data: object) => {
+ this.onMessage(data);
+ };
+
+ private onJsonStreamError = (error: Error) => {
+ log.error('Socket JSON stream error: ', error);
+
+ if (this.connection) {
+ // This will destroy the socket and emit "error" and "close" events
+ this.connection.destroy(error);
+ }
+ };
+}
diff --git a/gui/src/main/keyframe-animation.ts b/gui/src/main/keyframe-animation.ts
new file mode 100644
index 0000000000..25cd83a5db
--- /dev/null
+++ b/gui/src/main/keyframe-animation.ts
@@ -0,0 +1,130 @@
+export type OnFrameFn = (frame: number) => void;
+export type OnFinishFn = () => void;
+
+export interface IKeyframeAnimationOptions {
+ start?: number;
+ end: number;
+}
+export type KeyframeAnimationRange = [number, number];
+
+export default class KeyframeAnimation {
+ private speedValue: number = 200; // ms
+
+ private onFrameValue?: OnFrameFn;
+ private onFinishValue?: OnFinishFn;
+
+ private currentFrame: number = 0;
+ private targetFrame: number = 0;
+
+ private isRunningValue: boolean = false;
+ private isFinishedValue: boolean = false;
+
+ private timeout?: NodeJS.Timeout;
+
+ set onFrame(newValue: OnFrameFn | undefined) {
+ this.onFrameValue = newValue;
+ }
+ get onFrame(): OnFrameFn | undefined {
+ return this.onFrameValue;
+ }
+
+ // called when animation finished
+ set onFinish(newValue: OnFinishFn | undefined) {
+ this.onFinishValue = newValue;
+ }
+ get onFinish(): OnFinishFn | undefined {
+ return this.onFinishValue;
+ }
+
+ // pace per frame in ms
+ set speed(newValue: number) {
+ this.speedValue = newValue;
+ }
+ get speed(): number {
+ return this.speedValue;
+ }
+
+ get isRunning(): boolean {
+ return this.isRunningValue;
+ }
+
+ get isFinished(): boolean {
+ return this.isFinishedValue;
+ }
+
+ public play(options: IKeyframeAnimationOptions) {
+ const { start, end } = options;
+
+ if (start !== undefined) {
+ this.currentFrame = start;
+ }
+
+ this.targetFrame = end;
+
+ this.isRunningValue = true;
+ this.isFinishedValue = false;
+
+ this.unscheduleUpdate();
+
+ this.render();
+ this.scheduleUpdate();
+ }
+
+ public stop() {
+ this.isRunningValue = false;
+ this.unscheduleUpdate();
+ }
+
+ private unscheduleUpdate() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = undefined;
+ }
+ }
+
+ private scheduleUpdate() {
+ this.timeout = global.setTimeout(() => this.onUpdateFrame(), this.speedValue);
+ }
+
+ private render() {
+ if (this.onFrameValue) {
+ this.onFrameValue(this.currentFrame);
+ }
+ }
+
+ private didFinish() {
+ this.isFinishedValue = true;
+ this.isRunningValue = false;
+
+ if (this.onFinishValue) {
+ this.onFinishValue();
+ }
+ }
+
+ private onUpdateFrame() {
+ this.advanceFrame();
+
+ if (!this.isFinishedValue) {
+ this.render();
+
+ // check once again since onFrame() may stop animation
+ if (this.isRunningValue) {
+ this.scheduleUpdate();
+ }
+ }
+ }
+
+ private advanceFrame() {
+ if (this.isFinishedValue) {
+ return;
+ }
+
+ if (this.currentFrame === this.targetFrame) {
+ this.didFinish();
+ } else if (this.currentFrame < this.targetFrame) {
+ this.currentFrame += 1;
+ } else {
+ this.currentFrame -= 1;
+ }
+ }
+}
diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts
new file mode 100644
index 0000000000..35314aa73a
--- /dev/null
+++ b/gui/src/main/notification-controller.ts
@@ -0,0 +1,177 @@
+import { app, nativeImage, NativeImage, Notification, shell } from 'electron';
+import path from 'path';
+import { sprintf } from 'sprintf-js';
+import config from '../config.json';
+import { TunnelStateTransition } from '../shared/daemon-rpc-types';
+import { pgettext } from '../shared/gettext';
+
+export default class NotificationController {
+ private lastTunnelStateAnnouncement?: { body: string; notification: Notification };
+ private reconnecting = false;
+ private presentedNotifications: { [key: string]: boolean } = {};
+ private pendingNotifications: Notification[] = [];
+ private notificationTitle = process.platform === 'linux' ? app.getName() : '';
+ private notificationIcon?: NativeImage;
+
+ constructor() {
+ if (process.platform === 'linux') {
+ const basePath = path.resolve(path.join(__dirname, '../../assets/images'));
+ this.notificationIcon = nativeImage.createFromPath(
+ path.join(basePath, 'icon-notification.png'),
+ );
+ }
+ }
+
+ public notifyTunnelState(tunnelState: TunnelStateTransition) {
+ switch (tunnelState.state) {
+ case 'connecting':
+ if (!this.reconnecting) {
+ this.showTunnelStateNotification(pgettext('notifications', 'Connecting'));
+ }
+ break;
+ case 'connected':
+ this.showTunnelStateNotification(pgettext('notifications', 'Secured'));
+ break;
+ case 'disconnected':
+ this.showTunnelStateNotification(pgettext('notifications', 'Unsecured'));
+ break;
+ case 'blocked':
+ switch (tunnelState.details.reason) {
+ case 'set_firewall_policy_error':
+ this.showTunnelStateNotification(
+ pgettext('notifications', 'Critical failure - Unsecured'),
+ );
+ break;
+ default:
+ this.showTunnelStateNotification(pgettext('notifications', 'Blocked all connections'));
+ break;
+ }
+ break;
+ case 'disconnecting':
+ switch (tunnelState.details) {
+ case 'nothing':
+ case 'block':
+ // no-op
+ break;
+ case 'reconnect':
+ this.showTunnelStateNotification(pgettext('notifications', 'Reconnecting'));
+ this.reconnecting = true;
+ return;
+ }
+ break;
+ }
+
+ this.reconnecting = false;
+ }
+
+ public notifyInconsistentVersion() {
+ this.presentNotificationOnce('inconsistent-version', () => {
+ const notification = new Notification({
+ title: this.notificationTitle,
+ body: pgettext(
+ 'notifications',
+ 'Inconsistent internal version information, please restart the app',
+ ),
+ silent: true,
+ icon: this.notificationIcon,
+ });
+ this.scheduleNotification(notification);
+ });
+ }
+
+ public notifyUnsupportedVersion(upgradeVersion: string) {
+ this.presentNotificationOnce('unsupported-version', () => {
+ const notification = new Notification({
+ title: this.notificationTitle,
+ body: sprintf(
+ // TRANSLATORS: The system notification displayed to the user when the running app becomes unsupported.
+ // TRANSLATORS: Available placeholder:
+ // TRANSLATORS: %(version) - the newest available version of the app
+ pgettext(
+ 'notifications',
+ 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
+ ),
+ {
+ version: upgradeVersion,
+ },
+ ),
+ silent: true,
+ icon: this.notificationIcon,
+ });
+
+ notification.on('click', () => {
+ shell.openExternal(config.links.download);
+ });
+
+ this.scheduleNotification(notification);
+ });
+ }
+
+ public cancelPendingNotifications() {
+ for (const notification of this.pendingNotifications) {
+ notification.close();
+ }
+ }
+
+ public resetTunnelStateAnnouncements() {
+ this.lastTunnelStateAnnouncement = undefined;
+ }
+
+ private showTunnelStateNotification(message: string) {
+ const lastAnnouncement = this.lastTunnelStateAnnouncement;
+ const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message;
+
+ if (sameAsLastNotification) {
+ return;
+ }
+
+ const newNotification = new Notification({
+ title: this.notificationTitle,
+ body: message,
+ silent: true,
+ icon: this.notificationIcon,
+ });
+
+ if (lastAnnouncement) {
+ lastAnnouncement.notification.close();
+ }
+
+ this.lastTunnelStateAnnouncement = {
+ body: message,
+ notification: newNotification,
+ };
+
+ this.scheduleNotification(newNotification);
+ }
+
+ private presentNotificationOnce(notificationName: string, presentNotification: () => void) {
+ const presented = this.presentedNotifications;
+ if (!presented[notificationName]) {
+ presented[notificationName] = true;
+ presentNotification();
+ }
+ }
+
+ private scheduleNotification(notification: Notification) {
+ this.addPendingNotification(notification);
+
+ notification.show();
+
+ setTimeout(() => notification.close(), 4000);
+ }
+
+ private addPendingNotification(notification: Notification) {
+ notification.on('close', () => {
+ this.removePendingNotification(notification);
+ });
+
+ this.pendingNotifications.push(notification);
+ }
+
+ private removePendingNotification(notification: Notification) {
+ const index = this.pendingNotifications.indexOf(notification);
+ if (index !== -1) {
+ this.pendingNotifications.splice(index, 1);
+ }
+ }
+}
diff --git a/gui/src/main/proc.ts b/gui/src/main/proc.ts
new file mode 100644
index 0000000000..238c08c172
--- /dev/null
+++ b/gui/src/main/proc.ts
@@ -0,0 +1,25 @@
+import path from 'path';
+
+export function resolveBin(binaryName: string) {
+ return path.resolve(getBasePath(), binaryName + getExtension());
+}
+
+function getBasePath(): string {
+ if (process.env.NODE_ENV === 'development') {
+ return (
+ process.env.MULLVAD_PATH || path.resolve(path.join(__dirname, '../../../../target/debug'))
+ );
+ } else {
+ return process.resourcesPath!;
+ }
+}
+
+function getExtension() {
+ switch (process.platform) {
+ case 'win32':
+ return '.exe';
+
+ default:
+ return '';
+ }
+}
diff --git a/gui/src/main/reconnection-backoff.ts b/gui/src/main/reconnection-backoff.ts
new file mode 100644
index 0000000000..5709f053d7
--- /dev/null
+++ b/gui/src/main/reconnection-backoff.ts
@@ -0,0 +1,22 @@
+/*
+ * Used to calculate the time to wait before reconnecting to the daemon.
+ * It uses a linear backoff function that goes from 500ms to 3000ms.
+ */
+export default class ReconnectionBackoff {
+ private attemptValue = 0;
+
+ public attempt(handler: () => void) {
+ setTimeout(handler, this.getIncreasedBackoff());
+ }
+
+ public reset() {
+ this.attemptValue = 0;
+ }
+
+ private getIncreasedBackoff() {
+ if (this.attemptValue < 6) {
+ this.attemptValue++;
+ }
+ return this.attemptValue * 500;
+ }
+}
diff --git a/gui/src/main/tray-icon-controller.ts b/gui/src/main/tray-icon-controller.ts
new file mode 100644
index 0000000000..f3333a2636
--- /dev/null
+++ b/gui/src/main/tray-icon-controller.ts
@@ -0,0 +1,89 @@
+import { nativeImage, NativeImage, Tray } from 'electron';
+import path from 'path';
+import KeyframeAnimation from './keyframe-animation';
+
+export type TrayIconType = 'unsecured' | 'securing' | 'secured';
+
+export default class TrayIconController {
+ private animation?: KeyframeAnimation;
+ private iconImages: NativeImage[] = [];
+ private monochromaticIconImages: NativeImage[] = [];
+
+ constructor(
+ tray: Tray,
+ private iconTypeValue: TrayIconType,
+ private useMonochromaticIconValue: boolean,
+ ) {
+ this.loadImages();
+
+ const initialFrame = this.targetFrame();
+ const animation = new KeyframeAnimation();
+ animation.speed = 100;
+ animation.onFrame = (frameNumber) => tray.setImage(this.imageForFrame(frameNumber));
+ animation.play({ start: initialFrame, end: initialFrame });
+
+ this.animation = animation;
+ }
+
+ public dispose() {
+ if (this.animation) {
+ this.animation.stop();
+ this.animation = undefined;
+ }
+ }
+
+ get iconType(): TrayIconType {
+ return this.iconTypeValue;
+ }
+
+ set useMonochromaticIcon(useMonochromaticIcon: boolean) {
+ this.useMonochromaticIconValue = useMonochromaticIcon;
+
+ if (this.animation && !this.animation.isRunning) {
+ this.animation.play({ end: this.targetFrame() });
+ }
+ }
+
+ public animateToIcon(type: TrayIconType) {
+ if (this.iconTypeValue === type || !this.animation) {
+ return;
+ }
+
+ this.iconTypeValue = type;
+
+ const animation = this.animation;
+ const frame = this.targetFrame();
+
+ animation.play({ end: frame });
+ }
+
+ private loadImages() {
+ const basePath = path.resolve(path.join(__dirname, '../../assets/images/menubar icons'));
+ const frames = Array.from({ length: 10 }, (_, i) => i + 1);
+
+ this.iconImages = frames.map((frame) =>
+ nativeImage.createFromPath(path.join(basePath, `lock-${frame}.png`)),
+ );
+
+ this.monochromaticIconImages = frames.map((frame) =>
+ nativeImage.createFromPath(path.join(basePath, `lock-${frame}Template.png`)),
+ );
+ }
+
+ private targetFrame(): number {
+ switch (this.iconTypeValue) {
+ case 'unsecured':
+ return 0;
+ case 'securing':
+ return 9;
+ case 'secured':
+ return 8;
+ }
+ }
+
+ private imageForFrame(frame: number): NativeImage {
+ return this.useMonochromaticIconValue
+ ? this.monochromaticIconImages[frame]
+ : this.iconImages[frame];
+ }
+}
diff --git a/gui/src/main/window-controller.ts b/gui/src/main/window-controller.ts
new file mode 100644
index 0000000000..3822ecb23f
--- /dev/null
+++ b/gui/src/main/window-controller.ts
@@ -0,0 +1,253 @@
+import { BrowserWindow, Display, screen, Tray, WebContents } from 'electron';
+
+interface IPosition {
+ x: number;
+ y: number;
+}
+
+export interface IWindowShapeParameters {
+ arrowPosition?: number;
+}
+
+interface IWindowPositioning {
+ getPosition(window: BrowserWindow): IPosition;
+ getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters;
+}
+
+class StandaloneWindowPositioning implements IWindowPositioning {
+ public getPosition(window: BrowserWindow): IPosition {
+ const windowBounds = window.getBounds();
+
+ const primaryDisplay = screen.getPrimaryDisplay();
+ const workArea = primaryDisplay.workArea;
+ const maxX = workArea.x + workArea.width - windowBounds.width;
+ const maxY = workArea.y + workArea.height - windowBounds.height;
+
+ const x = Math.min(Math.max(windowBounds.x, workArea.x), maxX);
+ const y = Math.min(Math.max(windowBounds.y, workArea.y), maxY);
+
+ return { x, y };
+ }
+
+ public getWindowShapeParameters(_window: BrowserWindow): IWindowShapeParameters {
+ return {};
+ }
+}
+
+class AttachedToTrayWindowPositioning implements IWindowPositioning {
+ private tray: Tray;
+
+ constructor(tray: Tray) {
+ this.tray = tray;
+ }
+
+ public getPosition(window: BrowserWindow): IPosition {
+ const windowBounds = window.getBounds();
+ const trayBounds = this.tray.getBounds();
+
+ const activeDisplay = screen.getDisplayNearestPoint({
+ x: trayBounds.x,
+ y: trayBounds.y,
+ });
+ const workArea = activeDisplay.workArea;
+ const placement = this.getTrayPlacement();
+ const maxX = workArea.x + workArea.width - windowBounds.width;
+ const maxY = workArea.y + workArea.height - windowBounds.height;
+
+ let x = 0;
+ let y = 0;
+
+ switch (placement) {
+ case 'top':
+ x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
+ y = workArea.y;
+ break;
+
+ case 'bottom':
+ x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
+ y = workArea.y + workArea.height - windowBounds.height;
+ break;
+
+ case 'left':
+ x = workArea.x;
+ y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ break;
+
+ case 'right':
+ x = workArea.width - windowBounds.width;
+ y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
+ break;
+
+ case 'none':
+ x = workArea.x + (workArea.width - windowBounds.width) * 0.5;
+ y = workArea.y + (workArea.height - windowBounds.height) * 0.5;
+ break;
+ }
+
+ x = Math.min(Math.max(x, workArea.x), maxX);
+ y = Math.min(Math.max(y, workArea.y), maxY);
+
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ };
+ }
+
+ public getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters {
+ const trayBounds = this.tray.getBounds();
+ const windowBounds = window.getBounds();
+ const arrowPosition = trayBounds.x - windowBounds.x + trayBounds.width * 0.5;
+ return {
+ arrowPosition,
+ };
+ }
+
+ private getTrayPlacement() {
+ switch (process.platform) {
+ case 'darwin':
+ // macOS has menubar always placed at the top
+ return 'top';
+
+ case 'win32': {
+ // taskbar occupies some part of the screen excluded from work area
+ const primaryDisplay = screen.getPrimaryDisplay();
+ const displaySize = primaryDisplay.size;
+ const workArea = primaryDisplay.workArea;
+
+ if (workArea.width < displaySize.width) {
+ return workArea.x > 0 ? 'left' : 'right';
+ } else if (workArea.height < displaySize.height) {
+ return workArea.y > 0 ? 'top' : 'bottom';
+ } else {
+ return 'none';
+ }
+ }
+
+ default:
+ return 'none';
+ }
+ }
+}
+
+export default class WindowController {
+ private width: number;
+ private height: number;
+ private windowPositioning: IWindowPositioning;
+ private isWindowReady = false;
+
+ get window(): BrowserWindow {
+ return this.windowValue;
+ }
+
+ get webContents(): WebContents {
+ return this.windowValue.webContents;
+ }
+
+ constructor(private windowValue: BrowserWindow, tray: Tray) {
+ const [width, height] = windowValue.getSize();
+ this.width = width;
+ this.height = height;
+ this.windowPositioning =
+ process.platform === 'linux'
+ ? new StandaloneWindowPositioning()
+ : new AttachedToTrayWindowPositioning(tray);
+
+ this.installDisplayMetricsHandler();
+ this.installWindowReadyHandlers();
+ }
+
+ public show(whenReady: boolean = true) {
+ if (whenReady) {
+ this.executeWhenWindowIsReady(() => this.showImmediately());
+ } else {
+ this.showImmediately();
+ }
+ }
+
+ public hide() {
+ this.windowValue.hide();
+ }
+
+ public toggle() {
+ if (this.windowValue.isVisible()) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
+ public isVisible(): boolean {
+ return this.windowValue.isVisible();
+ }
+
+ public send(event: string, ...data: any[]): void {
+ this.windowValue.webContents.send(event, ...data);
+ }
+
+ private showImmediately() {
+ const window = this.windowValue;
+
+ this.updatePosition();
+ this.notifyUpdateWindowShape();
+
+ window.show();
+ window.focus();
+ }
+
+ private updatePosition() {
+ const { x, y } = this.windowPositioning.getPosition(this.windowValue);
+ this.windowValue.setPosition(x, y, false);
+ }
+
+ private notifyUpdateWindowShape() {
+ const shapeParameters = this.windowPositioning.getWindowShapeParameters(this.windowValue);
+ this.windowValue.webContents.send('update-window-shape', shapeParameters);
+ }
+
+ // Installs display event handlers to update the window position on any changes in the display or
+ // workarea dimensions.
+ private installDisplayMetricsHandler() {
+ screen.addListener('display-metrics-changed', this.onDisplayMetricsChanged);
+ this.windowValue.once('closed', () => {
+ screen.removeListener('display-metrics-changed', this.onDisplayMetricsChanged);
+ });
+ }
+
+ private onDisplayMetricsChanged = (
+ _event: Electron.Event,
+ _display: Display,
+ changedMetrics: string[],
+ ) => {
+ if (changedMetrics.includes('workArea') && this.windowValue.isVisible()) {
+ this.updatePosition();
+ this.notifyUpdateWindowShape();
+ }
+
+ // On linux, the window won't be properly rescaled back to it's original
+ // size if the DPI scaling factor is changed.
+ // https://github.com/electron/electron/issues/11050
+ if (process.platform === 'linux' && changedMetrics.includes('scaleFactor')) {
+ this.forceResizeWindow();
+ }
+ };
+
+ private forceResizeWindow() {
+ this.windowValue.setSize(this.width, this.height);
+ }
+
+ private installWindowReadyHandlers() {
+ this.windowValue.once('ready-to-show', () => {
+ this.isWindowReady = true;
+ });
+ }
+
+ private executeWhenWindowIsReady(closure: () => void) {
+ if (this.isWindowReady) {
+ closure();
+ } else {
+ this.windowValue.once('ready-to-show', () => {
+ closure();
+ });
+ }
+ }
+}