diff options
Diffstat (limited to 'gui/src/main')
| -rw-r--r-- | gui/src/main/autostart.ts | 73 | ||||
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 515 | ||||
| -rw-r--r-- | gui/src/main/errors.ts | 29 | ||||
| -rw-r--r-- | gui/src/main/gui-settings.ts | 84 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 1147 | ||||
| -rw-r--r-- | gui/src/main/jsonrpc-client.ts | 483 | ||||
| -rw-r--r-- | gui/src/main/keyframe-animation.ts | 130 | ||||
| -rw-r--r-- | gui/src/main/notification-controller.ts | 177 | ||||
| -rw-r--r-- | gui/src/main/proc.ts | 25 | ||||
| -rw-r--r-- | gui/src/main/reconnection-backoff.ts | 22 | ||||
| -rw-r--r-- | gui/src/main/tray-icon-controller.ts | 89 | ||||
| -rw-r--r-- | gui/src/main/window-controller.ts | 253 |
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(); + }); + } + } +} |
