diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-03-01 17:36:15 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-03-01 17:36:15 +0100 |
| commit | 65ef2fcc8a4b58a92219ea0ae269c91f80be0062 (patch) | |
| tree | b69a7e097fcf2ff61a06b053147c564f3c0f0296 /gui/src/shared | |
| parent | 2610bd23035901ba0e25824629d3768b4430a708 (diff) | |
| parent | 1a1eb84364add292974d7dafe69761270c7397ef (diff) | |
| download | mullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.tar.xz mullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.zip | |
Merge branch 'remove-workspaces'
Diffstat (limited to 'gui/src/shared')
| -rw-r--r-- | gui/src/shared/daemon-rpc-types.ts | 254 | ||||
| -rw-r--r-- | gui/src/shared/gettext.ts | 80 | ||||
| -rw-r--r-- | gui/src/shared/gui-settings-state.ts | 5 | ||||
| -rw-r--r-- | gui/src/shared/ipc-event-channel.ts | 366 |
4 files changed, 705 insertions, 0 deletions
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts new file mode 100644 index 0000000000..c4e2f6e53f --- /dev/null +++ b/gui/src/shared/daemon-rpc-types.ts @@ -0,0 +1,254 @@ +export interface IAccountData { + expiry: string; +} +export type AccountToken = string; +export type Ip = string; +export interface ILocation { + ip?: string; + country: string; + city?: string; + latitude: number; + longitude: number; + mullvadExitIp: boolean; + hostname?: string; +} + +export type BlockReason = + | { + reason: + | 'ipv6_unavailable' + | 'set_firewall_policy_error' + | 'set_dns_error' + | 'start_tunnel_error' + | 'no_matching_relay' + | 'is_offline' + | 'tap_adapter_problem'; + } + | { reason: 'auth_failed'; details?: string }; + +export type AfterDisconnect = 'nothing' | 'block' | 'reconnect'; + +export type TunnelState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'blocked'; + +export type TunnelType = 'wireguard' | 'openvpn'; + +export type RelayProtocol = 'tcp' | 'udp'; + +export interface ITunnelEndpoint { + address: string; + protocol: RelayProtocol; + tunnel: TunnelType; +} + +export type TunnelStateTransition = + | { state: 'disconnected' } + | { state: 'connecting'; details?: ITunnelEndpoint } + | { state: 'connected'; details: ITunnelEndpoint } + | { state: 'disconnecting'; details: AfterDisconnect } + | { state: 'blocked'; details: BlockReason }; + +export type RelayLocation = + | { hostname: [string, string, string] } + | { city: [string, string] } + | { country: string }; + +export interface IOpenVpnConstraints { + port: 'any' | { only: number }; + protocol: 'any' | { only: RelayProtocol }; +} + +export interface IWireguardConstraints { + port: 'any' | { only: number }; +} + +type TunnelConstraints<OpenVpn, Wireguard> = { wireguard: Wireguard } | { openvpn: OpenVpn }; + +interface IRelaySettingsNormal<TTunnelConstraints> { + location: + | 'any' + | { + only: RelayLocation; + }; + tunnel: + | 'any' + | { + only: TTunnelConstraints; + }; +} + +export type ConnectionConfig = + | { + openvpn: { + endpoint: { + ip: string; + port: number; + protocol: RelayProtocol; + }; + username: string; + }; + } + | { + wireguard: { + tunnel: { + private_key: string; + addresses: string[]; + }; + peer: { + public_key: string; + addresses: string[]; + endpoint: string; + }; + gateway: string; + }; + }; + +// types describing the structure of RelaySettings +export interface IRelaySettingsCustom { + host: string; + config: ConnectionConfig; +} +export type RelaySettings = + | { + normal: IRelaySettingsNormal<TunnelConstraints<IOpenVpnConstraints, IWireguardConstraints>>; + } + | { + customTunnelEndpoint: IRelaySettingsCustom; + }; + +// types describing the partial update of RelaySettings +export type RelaySettingsNormalUpdate = Partial< + IRelaySettingsNormal< + TunnelConstraints<Partial<IOpenVpnConstraints>, Partial<IWireguardConstraints>> + > +>; + +export type RelaySettingsUpdate = + | { + normal: RelaySettingsNormalUpdate; + } + | { + customTunnelEndpoint: IRelaySettingsCustom; + }; + +export interface IRelayList { + countries: IRelayListCountry[]; +} + +export interface IRelayListCountry { + name: string; + code: string; + cities: IRelayListCity[]; +} + +export interface IRelayListCity { + name: string; + code: string; + latitude: number; + longitude: number; + relays: IRelayListHostname[]; +} + +export interface IRelayListHostname { + hostname: string; + ipv4AddrIn: string; + includeInCountry: boolean; + weight: number; +} + +export interface ITunnelOptions { + openvpn: { + mssfix?: number; + proxy?: ProxySettings; + }; + wireguard: { + mtu?: number; + // Only relevant on Linux + fwmark?: number; + }; + generic: { + enableIpv6: boolean; + }; +} + +export type ProxySettings = ILocalProxySettings | IRemoteProxySettings | IShadowsocksProxySettings; + +export interface ILocalProxySettings { + port: number; + peer: string; +} + +export interface IRemoteProxySettings { + address: string; + auth?: IRemoteProxyAuth; +} + +export interface IRemoteProxyAuth { + username: string; + password: string; +} + +export interface IShadowsocksProxySettings { + peer: string; + password: string; + cipher: string; +} + +export interface IAppVersionInfo { + currentIsSupported: boolean; + latest: { + latestStable: string; + latest: string; + }; +} + +export interface ISettings { + accountToken?: AccountToken; + allowLan: boolean; + autoConnect: boolean; + blockWhenDisconnected: boolean; + relaySettings: RelaySettings; + tunnelOptions: ITunnelOptions; +} + +export interface ISocketAddress { + host: string; + port: number; +} + +export function parseSocketAddress(socketAddrStr: string): ISocketAddress { + const re = new RegExp(/(.+):(\d+)$/); + const matches = socketAddrStr.match(re); + + if (!matches || matches.length < 3) { + throw new Error(`Failed to parse socket address from address string '${socketAddrStr}'`); + } + const socketAddress: ISocketAddress = { + host: matches[1], + port: Number(matches[2]), + }; + return socketAddress; +} + +export function compareRelayLocation(lhs: RelayLocation, rhs: RelayLocation) { + if ('country' in lhs && 'country' in rhs && lhs.country && rhs.country) { + return lhs.country === rhs.country; + } else if ('city' in lhs && 'city' in rhs && lhs.city && rhs.city) { + return lhs.city[0] === rhs.city[0] && lhs.city[1] === rhs.city[1]; + } else if ('hostname' in lhs && 'hostname' in rhs && lhs.hostname && rhs.hostname) { + return ( + lhs.hostname[0] === rhs.hostname[0] && + lhs.hostname[1] === rhs.hostname[1] && + lhs.hostname[2] === rhs.hostname[2] + ); + } else { + return false; + } +} + +export function compareRelayLocationLoose(lhs?: RelayLocation, rhs?: RelayLocation) { + if (lhs && rhs) { + return compareRelayLocation(lhs, rhs); + } else { + return lhs === rhs; + } +} diff --git a/gui/src/shared/gettext.ts b/gui/src/shared/gettext.ts new file mode 100644 index 0000000000..4722e16915 --- /dev/null +++ b/gui/src/shared/gettext.ts @@ -0,0 +1,80 @@ +import log from 'electron-log'; +import fs from 'fs'; +import { po } from 'gettext-parser'; +import Gettext from 'node-gettext'; +import path from 'path'; + +const SOURCE_LANGUAGE = 'en'; +let SELECTED_LANGUAGE = SOURCE_LANGUAGE; +const LOCALES_DIR = path.resolve(__dirname, '../../locales'); + +// `{debug: false}` option prevents Gettext from printing the warnings to console in development +// the errors are handled separately in the "error" handler below +const catalogue = new Gettext({ debug: false }); +catalogue.setTextDomain('messages'); +catalogue.on('error', (error: string) => { + // Filter out the "no translation was found" errors for the source language + if (SELECTED_LANGUAGE === SOURCE_LANGUAGE && error.indexOf('No translation was found') !== -1) { + return; + } + + log.warn(`Gettext error: ${error}`); +}); + +export function loadTranslations(currentLocale: string) { + // First look for exact match of the current locale + const preferredLocales = []; + + if (currentLocale !== SOURCE_LANGUAGE) { + preferredLocales.push(currentLocale); + } + + // In case of region bound locale like en-US, fallback to en. + const language = Gettext.getLanguageCode(currentLocale); + if (currentLocale !== language) { + preferredLocales.push(language); + } + + for (const locale of preferredLocales) { + if (parseTranslation(locale, 'messages')) { + log.info(`Loaded translations for ${locale}`); + catalogue.setLocale(locale); + + SELECTED_LANGUAGE = locale; + return; + } + } +} + +function parseTranslation(locale: string, domain: string): boolean { + const filename = path.join(LOCALES_DIR, locale, `${domain}.po`); + let buffer: Buffer; + + try { + buffer = fs.readFileSync(filename); + } catch (error) { + if (error.code !== 'ENOENT') { + log.error(`Cannot read the gettext file "${filename}": ${error.message}`); + } + return false; + } + + let translations: object; + try { + translations = po.parse(buffer); + } catch (error) { + log.error(`Cannot parse the gettext file "${filename}": ${error.message}`); + return false; + } + + catalogue.addTranslations(locale, domain, translations); + + return true; +} + +export const gettext = (msgid: string): string => { + return catalogue.gettext(msgid); +}; +export const pgettext = (msgctx: string, msgid: string): string => { + return catalogue.pgettext(msgctx, msgid); +}; diff --git a/gui/src/shared/gui-settings-state.ts b/gui/src/shared/gui-settings-state.ts new file mode 100644 index 0000000000..5bfb6e79c8 --- /dev/null +++ b/gui/src/shared/gui-settings-state.ts @@ -0,0 +1,5 @@ +export interface IGuiSettingsState { + autoConnect: boolean; + monochromaticIcon: boolean; + startMinimized: boolean; +} diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts new file mode 100644 index 0000000000..02480d8150 --- /dev/null +++ b/gui/src/shared/ipc-event-channel.ts @@ -0,0 +1,366 @@ +import { ipcMain, ipcRenderer, WebContents } from 'electron'; +import * as uuid from 'uuid'; + +import { IGuiSettingsState } from './gui-settings-state'; + +import { IAppUpgradeInfo, ICurrentAppVersionInfo } from '../main/index'; +import { + AccountToken, + IAccountData, + ILocation, + IRelayList, + ISettings, + RelaySettingsUpdate, + TunnelStateTransition, +} from './daemon-rpc-types'; + +export interface IAppStateSnapshot { + isConnected: boolean; + autoStart: boolean; + accountHistory: AccountToken[]; + tunnelState: TunnelStateTransition; + settings: ISettings; + location?: ILocation; + relays: IRelayList; + currentVersion: ICurrentAppVersionInfo; + upgradeVersion: IAppUpgradeInfo; + guiSettings: IGuiSettingsState; +} + +interface ISender<T> { + notify(webContents: WebContents, value: T): void; +} + +interface ISenderVoid { + notify(webContents: WebContents): void; +} + +interface IReceiver<T> { + listen(fn: (value: T) => void): void; +} + +interface ITunnelMethods extends IReceiver<TunnelStateTransition> { + connect(): Promise<void>; + disconnect(): Promise<void>; +} + +interface ITunnelHandlers extends ISender<TunnelStateTransition> { + handleConnect(fn: () => Promise<void>): void; + handleDisconnect(fn: () => Promise<void>): void; +} + +interface ISettingsMethods extends IReceiver<ISettings> { + setAllowLan(allowLan: boolean): Promise<void>; + setEnableIpv6(enableIpv6: boolean): Promise<void>; + setBlockWhenDisconnected(block: boolean): Promise<void>; + setOpenVpnMssfix(mssfix?: number): Promise<void>; + updateRelaySettings(update: RelaySettingsUpdate): Promise<void>; +} + +interface ISettingsHandlers extends ISender<ISettings> { + handleAllowLan(fn: (allowLan: boolean) => Promise<void>): void; + handleEnableIpv6(fn: (enableIpv6: boolean) => Promise<void>): void; + handleBlockWhenDisconnected(fn: (block: boolean) => Promise<void>): void; + handleOpenVpnMssfix(fn: (mssfix?: number) => Promise<void>): void; + handleUpdateRelaySettings(fn: (update: RelaySettingsUpdate) => Promise<void>): void; +} + +interface IGuiSettingsMethods extends IReceiver<IGuiSettingsState> { + setAutoConnect(autoConnect: boolean): void; + setStartMinimized(startMinimized: boolean): void; + setMonochromaticIcon(monochromaticIcon: boolean): void; +} + +interface IGuiSettingsHandlers extends ISender<IGuiSettingsState> { + handleAutoConnect(fn: (autoConnect: boolean) => void): void; + handleStartMinimized(fn: (startMinimized: boolean) => void): void; + handleMonochromaticIcon(fn: (monochromaticIcon: boolean) => void): void; +} + +interface IAccountHandlers { + handleSet(fn: (token: AccountToken) => Promise<void>): void; + handleUnset(fn: () => Promise<void>): void; + handleGetData(fn: (token: AccountToken) => Promise<IAccountData>): void; +} + +interface IAccountMethods { + set(token: AccountToken): Promise<void>; + unset(): Promise<void>; + getData(token: AccountToken): Promise<IAccountData>; +} + +interface IAccountHistoryHandlers extends ISender<AccountToken[]> { + handleRemoveItem(fn: (token: AccountToken) => Promise<void>): void; +} + +interface IAccountHistoryMethods extends IReceiver<AccountToken[]> { + removeItem(token: AccountToken): Promise<void>; +} + +interface IAutoStartMethods extends IReceiver<boolean> { + set(autoStart: boolean): Promise<void>; +} + +interface IAutoStartHandlers extends ISender<boolean> { + handleSet(fn: (value: boolean) => Promise<void>): void; +} + +/// Events names + +const DAEMON_CONNECTED = 'daemon-connected'; +const DAEMON_DISCONNECTED = 'daemon-disconnected'; + +const TUNNEL_STATE_CHANGED = 'tunnel-state-changed'; +const CONNECT_TUNNEL = 'connect-tunnel'; +const DISCONNECT_TUNNEL = 'disconnect-tunnel'; + +const SETTINGS_CHANGED = 'settings-changed'; +const SET_ALLOW_LAN = 'set-allow-lan'; +const SET_ENABLE_IPV6 = 'set-enable-ipv6'; +const SET_BLOCK_WHEN_DISCONNECTED = 'set-block-when-disconnected'; +const SET_OPENVPN_MSSFIX = 'set-openvpn-mssfix'; +const UPDATE_RELAY_SETTINGS = 'update-relay-settings'; + +const LOCATION_CHANGED = 'location-changed'; +const RELAYS_CHANGED = 'relays-changed'; +const CURRENT_VERSION_CHANGED = 'current-version-changed'; +const UPGRADE_VERSION_CHANGED = 'upgrade-version-changed'; + +const GUI_SETTINGS_CHANGED = 'gui-settings-changed'; +const SET_AUTO_CONNECT = 'set-auto-connect'; +const SET_MONOCHROMATIC_ICON = 'set-monochromatic-icon'; +const SET_START_MINIMIZED = 'set-start-minimized'; + +const GET_APP_STATE = 'get-app-state'; + +const ACCOUNT_HISTORY_CHANGED = 'account-history-changed'; +const REMOVE_ACCOUNT_HISTORY_ITEM = 'remove-account-history-item'; + +const SET_ACCOUNT = 'set-account'; +const UNSET_ACCOUNT = 'unset-account'; +const GET_ACCOUNT_DATA = 'get-account-data'; + +const AUTO_START_CHANGED = 'auto-start-changed'; +const SET_AUTO_START = 'set-auto-start'; + +/// Typed IPC event channel +/// +/// Static methods are meant to be provide the way to send the events from a renderer process, while +/// instance methods are meant to be used from a main process. +/// +export class IpcRendererEventChannel { + public static state = { + /// Synchronously sends the IPC request and returns the app state snapshot + get(): IAppStateSnapshot { + return ipcRenderer.sendSync(GET_APP_STATE); + }, + }; + + public static daemonConnected: IReceiver<void> = { + listen: listen(DAEMON_CONNECTED), + }; + + public static daemonDisconnected: IReceiver<string | undefined> = { + listen: listen(DAEMON_DISCONNECTED), + }; + + public static tunnel: ITunnelMethods = { + listen: listen(TUNNEL_STATE_CHANGED), + connect: requestSender(CONNECT_TUNNEL), + disconnect: requestSender(DISCONNECT_TUNNEL), + }; + + public static settings: ISettingsMethods = { + listen: listen(SETTINGS_CHANGED), + setAllowLan: requestSender(SET_ALLOW_LAN), + setEnableIpv6: requestSender(SET_ENABLE_IPV6), + setBlockWhenDisconnected: requestSender(SET_BLOCK_WHEN_DISCONNECTED), + setOpenVpnMssfix: requestSender(SET_OPENVPN_MSSFIX), + updateRelaySettings: requestSender(UPDATE_RELAY_SETTINGS), + }; + + public static location: IReceiver<ILocation> = { + listen: listen(LOCATION_CHANGED), + }; + + public static relays: IReceiver<IRelayList> = { + listen: listen(RELAYS_CHANGED), + }; + + public static currentVersion: IReceiver<ICurrentAppVersionInfo> = { + listen: listen(CURRENT_VERSION_CHANGED), + }; + + public static upgradeVersion: IReceiver<IAppUpgradeInfo> = { + listen: listen(UPGRADE_VERSION_CHANGED), + }; + + public static guiSettings: IGuiSettingsMethods = { + listen: listen(GUI_SETTINGS_CHANGED), + setAutoConnect: set(SET_AUTO_CONNECT), + setMonochromaticIcon: set(SET_MONOCHROMATIC_ICON), + setStartMinimized: set(SET_START_MINIMIZED), + }; + + public static autoStart: IAutoStartMethods = { + listen: listen(AUTO_START_CHANGED), + set: requestSender(SET_AUTO_START), + }; + + public static account: IAccountMethods = { + set: requestSender(SET_ACCOUNT), + unset: requestSender(UNSET_ACCOUNT), + getData: requestSender(GET_ACCOUNT_DATA), + }; + + public static accountHistory: IAccountHistoryMethods = { + listen: listen(ACCOUNT_HISTORY_CHANGED), + removeItem: requestSender(REMOVE_ACCOUNT_HISTORY_ITEM), + }; +} + +export class IpcMainEventChannel { + public static state = { + handleGet(fn: () => IAppStateSnapshot) { + ipcMain.on(GET_APP_STATE, (event: Electron.Event) => { + event.returnValue = fn(); + }); + }, + }; + + public static daemonConnected: ISenderVoid = { + notify: senderVoid(DAEMON_CONNECTED), + }; + + public static daemonDisconnected: ISender<string | undefined> = { + notify: sender(DAEMON_DISCONNECTED), + }; + + public static tunnel: ITunnelHandlers = { + notify: sender(TUNNEL_STATE_CHANGED), + handleConnect: requestHandler(CONNECT_TUNNEL), + handleDisconnect: requestHandler(DISCONNECT_TUNNEL), + }; + + public static location: ISender<ILocation> = { + notify: sender(LOCATION_CHANGED), + }; + + public static settings: ISettingsHandlers = { + notify: sender(SETTINGS_CHANGED), + handleAllowLan: requestHandler(SET_ALLOW_LAN), + handleEnableIpv6: requestHandler(SET_ENABLE_IPV6), + handleBlockWhenDisconnected: requestHandler(SET_BLOCK_WHEN_DISCONNECTED), + handleOpenVpnMssfix: requestHandler(SET_OPENVPN_MSSFIX), + handleUpdateRelaySettings: requestHandler(UPDATE_RELAY_SETTINGS), + }; + + public static relays: ISender<IRelayList> = { + notify: sender(RELAYS_CHANGED), + }; + + public static currentVersion: ISender<ICurrentAppVersionInfo> = { + notify: sender(CURRENT_VERSION_CHANGED), + }; + + public static upgradeVersion: ISender<IAppUpgradeInfo> = { + notify: sender(UPGRADE_VERSION_CHANGED), + }; + + public static guiSettings: IGuiSettingsHandlers = { + notify: sender(GUI_SETTINGS_CHANGED), + handleAutoConnect: handler(SET_AUTO_CONNECT), + handleMonochromaticIcon: handler(SET_MONOCHROMATIC_ICON), + handleStartMinimized: handler(SET_START_MINIMIZED), + }; + + public static autoStart: IAutoStartHandlers = { + notify: sender<boolean>(AUTO_START_CHANGED), + handleSet: requestHandler(SET_AUTO_START), + }; + + public static account: IAccountHandlers = { + handleSet: requestHandler(SET_ACCOUNT), + handleUnset: requestHandler(UNSET_ACCOUNT), + handleGetData: requestHandler(GET_ACCOUNT_DATA), + }; + + public static accountHistory: IAccountHistoryHandlers = { + notify: sender<AccountToken[]>(ACCOUNT_HISTORY_CHANGED), + handleRemoveItem: requestHandler(REMOVE_ACCOUNT_HISTORY_ITEM), + }; +} + +function listen<T>(event: string): (fn: (value: T) => void) => void { + return (fn: (value: T) => void) => { + ipcRenderer.on(event, (_event: Electron.Event, newState: T) => fn(newState)); + }; +} + +function set<T>(event: string): (value: T) => void { + return (newValue: T) => { + ipcRenderer.send(event, newValue); + }; +} + +function sender<T>(event: string): (webContents: WebContents, value: T) => void { + return (webContents: WebContents, value: T) => { + webContents.send(event, value); + }; +} + +function senderVoid(event: string): (webContents: WebContents) => void { + return (webContents: WebContents) => { + webContents.send(event); + }; +} + +function handler<T>(event: string): (handlerFn: (value: T) => void) => void { + return (handlerFn: (value: T) => void) => { + ipcMain.on(event, (_event: Electron.Event, newValue: T) => { + handlerFn(newValue); + }); + }; +} + +type RequestResult<T> = { type: 'success'; value: T } | { type: 'error'; message: string }; + +function requestHandler<T>(event: string): (fn: (...args: any[]) => Promise<T>) => void { + return (fn: (...args: any[]) => Promise<T>) => { + ipcMain.on(event, async (ipcEvent: Electron.Event, requestId: string, ...args: any[]) => { + const responseEvent = `${event}-${requestId}`; + try { + const result: RequestResult<T> = { type: 'success', value: await fn(...args) }; + + ipcEvent.sender.send(responseEvent, result); + } catch (error) { + const result: RequestResult<T> = { type: 'error', message: error.message || '' }; + + ipcEvent.sender.send(responseEvent, result); + } + }); + }; +} + +function requestSender<T>(event: string): (...args: any[]) => Promise<T> { + return (...args: any[]): Promise<T> => { + return new Promise((resolve: (result: T) => void, reject: (error: Error) => void) => { + const requestId = uuid.v4(); + const responseEvent = `${event}-${requestId}`; + + ipcRenderer.once(responseEvent, (_ipcEvent: Electron.Event, result: RequestResult<T>) => { + switch (result.type) { + case 'error': + reject(new Error(result.message)); + break; + + case 'success': + resolve(result.value); + break; + } + }); + + ipcRenderer.send(event, requestId, ...args); + }); + }; +} |
