summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-03-01 17:36:15 +0100
committerAndrej Mihajlov <and@mullvad.net>2019-03-01 17:36:15 +0100
commit65ef2fcc8a4b58a92219ea0ae269c91f80be0062 (patch)
treeb69a7e097fcf2ff61a06b053147c564f3c0f0296 /gui/src
parent2610bd23035901ba0e25824629d3768b4430a708 (diff)
parent1a1eb84364add292974d7dafe69761270c7397ef (diff)
downloadmullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.tar.xz
mullvadvpn-65ef2fcc8a4b58a92219ea0ae269c91f80be0062.zip
Merge branch 'remove-workspaces'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/config.json32
-rw-r--r--gui/src/main/autostart.ts73
-rw-r--r--gui/src/main/daemon-rpc.ts515
-rw-r--r--gui/src/main/errors.ts29
-rw-r--r--gui/src/main/gui-settings.ts84
-rw-r--r--gui/src/main/index.ts1147
-rw-r--r--gui/src/main/jsonrpc-client.ts483
-rw-r--r--gui/src/main/keyframe-animation.ts130
-rw-r--r--gui/src/main/notification-controller.ts177
-rw-r--r--gui/src/main/proc.ts25
-rw-r--r--gui/src/main/reconnection-backoff.ts22
-rw-r--r--gui/src/main/tray-icon-controller.ts89
-rw-r--r--gui/src/main/window-controller.ts253
-rw-r--r--gui/src/renderer/app.tsx655
-rw-r--r--gui/src/renderer/components/Accordion.tsx136
-rw-r--r--gui/src/renderer/components/Account.tsx117
-rw-r--r--gui/src/renderer/components/AccountStyles.tsx68
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx313
-rw-r--r--gui/src/renderer/components/AdvancedSettingsStyles.tsx54
-rw-r--r--gui/src/renderer/components/AppButton.tsx94
-rw-r--r--gui/src/renderer/components/AppButtonStyles.tsx62
-rw-r--r--gui/src/renderer/components/Cell.tsx295
-rw-r--r--gui/src/renderer/components/ChevronButton.tsx34
-rw-r--r--gui/src/renderer/components/CityRow.tsx108
-rw-r--r--gui/src/renderer/components/ClipboardLabel.tsx51
-rw-r--r--gui/src/renderer/components/Connect.tsx272
-rw-r--r--gui/src/renderer/components/ConnectStyles.tsx61
-rw-r--r--gui/src/renderer/components/ConnectionInfo.tsx121
-rw-r--r--gui/src/renderer/components/ConnectionInfoDisclosure.tsx89
-rw-r--r--gui/src/renderer/components/CountryRow.tsx118
-rw-r--r--gui/src/renderer/components/CustomScrollbars.css59
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx475
-rw-r--r--gui/src/renderer/components/HeaderBar.tsx147
-rw-r--r--gui/src/renderer/components/ImageView.tsx73
-rw-r--r--gui/src/renderer/components/Launch.tsx60
-rw-r--r--gui/src/renderer/components/Layout.tsx34
-rw-r--r--gui/src/renderer/components/LayoutStyles.tsx17
-rw-r--r--gui/src/renderer/components/Login.tsx479
-rw-r--r--gui/src/renderer/components/LoginStyles.tsx156
-rw-r--r--gui/src/renderer/components/Map.tsx106
-rw-r--r--gui/src/renderer/components/Modal.tsx52
-rw-r--r--gui/src/renderer/components/NavigationBar.tsx414
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx295
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx272
-rw-r--r--gui/src/renderer/components/PlatformWindow.tsx33
-rw-r--r--gui/src/renderer/components/Preferences.tsx169
-rw-r--r--gui/src/renderer/components/PreferencesStyles.tsx20
-rw-r--r--gui/src/renderer/components/RelayRow.tsx59
-rw-r--r--gui/src/renderer/components/RelayStatusIndicator.tsx50
-rw-r--r--gui/src/renderer/components/SecuredLabel.tsx63
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx249
-rw-r--r--gui/src/renderer/components/SelectLocationStyles.tsx22
-rw-r--r--gui/src/renderer/components/Settings.tsx188
-rw-r--r--gui/src/renderer/components/SettingsHeader.tsx53
-rw-r--r--gui/src/renderer/components/SettingsStyles.tsx54
-rw-r--r--gui/src/renderer/components/Support.tsx406
-rw-r--r--gui/src/renderer/components/SupportStyles.tsx138
-rw-r--r--gui/src/renderer/components/SvgMap.tsx371
-rw-r--r--gui/src/renderer/components/Switch.css44
-rw-r--r--gui/src/renderer/components/Switch.tsx146
-rw-r--r--gui/src/renderer/components/TransitionContainer.tsx229
-rw-r--r--gui/src/renderer/components/TunnelControl.tsx272
-rw-r--r--gui/src/renderer/containers/AccountPage.tsx33
-rw-r--r--gui/src/renderer/containers/AdvancedSettingsPage.tsx98
-rw-r--r--gui/src/renderer/containers/ConnectPage.tsx118
-rw-r--r--gui/src/renderer/containers/LaunchPage.tsx22
-rw-r--r--gui/src/renderer/containers/LoginPage.tsx42
-rw-r--r--gui/src/renderer/containers/PlatformWindowContainer.tsx10
-rw-r--r--gui/src/renderer/containers/PreferencesPage.tsx55
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx41
-rw-r--r--gui/src/renderer/containers/SettingsPage.tsx34
-rw-r--r--gui/src/renderer/containers/SupportPage.tsx38
-rw-r--r--gui/src/renderer/index.html21
-rw-r--r--gui/src/renderer/index.ts9
-rw-r--r--gui/src/renderer/lib/account-expiry.ts31
-rw-r--r--gui/src/renderer/lib/auth-failure.ts92
-rw-r--r--gui/src/renderer/lib/problem-report.ts53
-rw-r--r--gui/src/renderer/lib/relay-settings-builder.ts139
-rw-r--r--gui/src/renderer/lib/transition-rule.ts36
-rw-r--r--gui/src/renderer/redux/account/actions.ts112
-rw-r--r--gui/src/renderer/redux/account/reducers.ts94
-rw-r--r--gui/src/renderer/redux/connection/actions.ts119
-rw-r--r--gui/src/renderer/redux/connection/reducers.ts77
-rw-r--r--gui/src/renderer/redux/settings/actions.ts121
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts138
-rw-r--r--gui/src/renderer/redux/store.ts80
-rw-r--r--gui/src/renderer/redux/support/actions.ts30
-rw-r--r--gui/src/renderer/redux/support/reducers.ts35
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts29
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts26
-rw-r--r--gui/src/renderer/redux/version/actions.ts36
-rw-r--r--gui/src/renderer/redux/version/reducers.ts47
-rw-r--r--gui/src/renderer/routes.tsx61
-rw-r--r--gui/src/renderer/transitions.ts103
-rw-r--r--gui/src/shared/daemon-rpc-types.ts254
-rw-r--r--gui/src/shared/gettext.ts80
-rw-r--r--gui/src/shared/gui-settings-state.ts5
-rw-r--r--gui/src/shared/ipc-event-channel.ts366
98 files changed, 13597 insertions, 0 deletions
diff --git a/gui/src/config.json b/gui/src/config.json
new file mode 100644
index 0000000000..e3f340ec96
--- /dev/null
+++ b/gui/src/config.json
@@ -0,0 +1,32 @@
+{
+ "links": {
+ "createAccount": "https://mullvad.net/account/create/",
+ "purchase": "https://mullvad.net/account/login/",
+ "faq": "https://mullvad.net/faq/",
+ "download": "https://mullvad.net/download/",
+ "supportEmail": "mailto:support@mullvad.net"
+ },
+ "colors": {
+ "darkBlue": "rgb(25, 46, 69)",
+ "blue": "rgb(41, 77, 115)",
+ "green": "rgb(68, 173, 77)",
+ "darkGreen": "rgb(32, 84, 37)",
+ "red": "rgb(208, 2, 27)",
+ "darkYellow": "rgb(142, 78, 19)",
+ "yellow": "rgb(255, 213, 36)",
+ "white": "rgb(255, 255, 255)",
+ "white80": "rgba(255, 255, 255, 0.8)",
+ "white60": "rgba(255, 255, 255, 0.6)",
+ "white40": "rgba(255, 255, 255, 0.4)",
+ "white20": "rgba(255, 255, 255, 0.2)",
+ "blue20": "rgba(41, 77, 115, 0.2)",
+ "blue40": "rgba(41, 77, 115, 0.4)",
+ "blue60": "rgba(41, 77, 115, 0.6)",
+ "blue80": "rgba(41, 77, 115, 0.8)",
+ "red95": "rgba(208, 2, 27, 0.95)",
+ "red40": "rgba(208, 2, 27, 0.40)",
+ "red45": "rgba(208, 2, 27, 0.45)",
+ "green90": "rgba(68, 173, 77, 0.9)",
+ "green40": "rgba(68, 173, 77, 0.4)"
+ }
+}
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();
+ });
+ }
+ }
+}
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
new file mode 100644
index 0000000000..d039b24a43
--- /dev/null
+++ b/gui/src/renderer/app.tsx
@@ -0,0 +1,655 @@
+import {
+ ConnectedRouter,
+ push as pushHistory,
+ replace as replaceHistory,
+} from 'connected-react-router';
+import { ipcRenderer, remote, webFrame } from 'electron';
+import log from 'electron-log';
+import { createMemoryHistory } from 'history';
+import * as React from 'react';
+import { Provider } from 'react-redux';
+import { bindActionCreators } from 'redux';
+
+import { InvalidAccountError } from '../main/errors';
+import makeRoutes from './routes';
+
+import accountActions from './redux/account/actions';
+import connectionActions from './redux/connection/actions';
+import settingsActions from './redux/settings/actions';
+import configureStore from './redux/store';
+import userInterfaceActions from './redux/userinterface/actions';
+import versionActions from './redux/version/actions';
+
+import { IAppUpgradeInfo, ICurrentAppVersionInfo } from '../main';
+import { IWindowShapeParameters } from '../main/window-controller';
+import { loadTranslations } from '../shared/gettext';
+import { IGuiSettingsState } from '../shared/gui-settings-state';
+import { IpcRendererEventChannel } from '../shared/ipc-event-channel';
+
+import {
+ AccountToken,
+ ConnectionConfig,
+ IAccountData,
+ ILocation,
+ IRelayList,
+ ISettings,
+ RelaySettings,
+ RelaySettingsUpdate,
+ TunnelStateTransition,
+} from '../shared/daemon-rpc-types';
+
+export default class AppRenderer {
+ private memoryHistory = createMemoryHistory();
+ private reduxStore = configureStore(this.memoryHistory);
+ private reduxActions: { [key: string]: any };
+ private accountDataCache = new AccountDataCache(
+ (accountToken) => {
+ return IpcRendererEventChannel.account.getData(accountToken);
+ },
+ (accountData) => {
+ const expiry = accountData ? accountData.expiry : null;
+ this.reduxActions.account.updateAccountExpiry(expiry);
+ },
+ );
+
+ private tunnelState: TunnelStateTransition;
+ private settings: ISettings;
+ private guiSettings: IGuiSettingsState;
+ private connectedToDaemon = false;
+ private autoConnected = false;
+ private doingLogin = false;
+ private loginTimer?: NodeJS.Timeout;
+
+ constructor() {
+ const dispatch = this.reduxStore.dispatch;
+ this.reduxActions = {
+ account: bindActionCreators(accountActions, dispatch),
+ connection: bindActionCreators(connectionActions, dispatch),
+ settings: bindActionCreators(settingsActions, dispatch),
+ version: bindActionCreators(versionActions, dispatch),
+ userInterface: bindActionCreators(userInterfaceActions, dispatch),
+ history: bindActionCreators(
+ {
+ push: pushHistory,
+ replace: replaceHistory,
+ },
+ dispatch,
+ ),
+ };
+
+ ipcRenderer.on(
+ 'update-window-shape',
+ (_event: Electron.Event, shapeParams: IWindowShapeParameters) => {
+ if (typeof shapeParams.arrowPosition === 'number') {
+ this.reduxActions.userInterface.updateWindowArrowPosition(shapeParams.arrowPosition);
+ }
+ },
+ );
+
+ ipcRenderer.on('window-shown', () => {
+ if (this.connectedToDaemon) {
+ this.updateAccountExpiry();
+ }
+ });
+
+ IpcRendererEventChannel.daemonConnected.listen(() => {
+ this.onDaemonConnected();
+ });
+
+ IpcRendererEventChannel.daemonDisconnected.listen((errorMessage?: string) => {
+ this.onDaemonDisconnected(errorMessage ? new Error(errorMessage) : undefined);
+ });
+
+ IpcRendererEventChannel.accountHistory.listen((newAccountHistory: AccountToken[]) => {
+ this.setAccountHistory(newAccountHistory);
+ });
+
+ IpcRendererEventChannel.tunnel.listen((newState: TunnelStateTransition) => {
+ this.setTunnelState(newState);
+ });
+
+ IpcRendererEventChannel.settings.listen((newSettings: ISettings) => {
+ const oldSettings = this.settings;
+
+ this.setSettings(newSettings);
+ this.handleAccountChange(oldSettings.accountToken, newSettings.accountToken);
+ });
+
+ IpcRendererEventChannel.location.listen((newLocation: ILocation) => {
+ this.setLocation(newLocation);
+ });
+
+ IpcRendererEventChannel.relays.listen((newRelays: IRelayList) => {
+ this.setRelays(newRelays);
+ });
+
+ IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
+ this.setCurrentVersion(currentVersion);
+ });
+
+ IpcRendererEventChannel.upgradeVersion.listen((upgradeVersion: IAppUpgradeInfo) => {
+ this.setUpgradeVersion(upgradeVersion);
+ });
+
+ IpcRendererEventChannel.guiSettings.listen((guiSettings: IGuiSettingsState) => {
+ this.setGuiSettings(guiSettings);
+ });
+
+ IpcRendererEventChannel.autoStart.listen((autoStart: boolean) => {
+ this.storeAutoStart(autoStart);
+ });
+
+ // Request the initial state from the main process
+ const initialState = IpcRendererEventChannel.state.get();
+
+ this.tunnelState = initialState.tunnelState;
+ this.settings = initialState.settings;
+ this.guiSettings = initialState.guiSettings;
+
+ this.setAccountHistory(initialState.accountHistory);
+ this.setTunnelState(initialState.tunnelState);
+ this.setSettings(initialState.settings);
+
+ if (initialState.location) {
+ this.setLocation(initialState.location);
+ }
+
+ this.setRelays(initialState.relays);
+ this.setCurrentVersion(initialState.currentVersion);
+ this.setUpgradeVersion(initialState.upgradeVersion);
+ this.setGuiSettings(initialState.guiSettings);
+ this.storeAutoStart(initialState.autoStart);
+
+ if (initialState.isConnected) {
+ this.onDaemonConnected();
+ }
+
+ // disable pinch to zoom
+ webFrame.setVisualZoomLevelLimits(1, 1);
+
+ // Load translations
+ loadTranslations(remote.app.getLocale());
+ }
+
+ public renderView() {
+ return (
+ <Provider store={this.reduxStore}>
+ <ConnectedRouter history={this.memoryHistory}>{makeRoutes({ app: this })}</ConnectedRouter>
+ </Provider>
+ );
+ }
+
+ public async login(accountToken: AccountToken) {
+ const actions = this.reduxActions;
+ actions.account.startLogin(accountToken);
+
+ log.info('Logging in');
+
+ this.doingLogin = true;
+
+ try {
+ const verification = await this.verifyAccount(accountToken);
+
+ if (verification.status === 'deferred') {
+ log.warn(`Failed to get account data, logging in anyway: ${verification.error.message}`);
+ }
+
+ await IpcRendererEventChannel.account.set(accountToken);
+
+ // Redirect the user after some time to allow for the 'Logged in' screen to be visible
+ this.loginTimer = global.setTimeout(async () => {
+ this.memoryHistory.replace('/connect');
+
+ try {
+ log.info('Auto-connecting the tunnel');
+ await this.connectTunnel();
+ } catch (error) {
+ log.error(`Failed to auto-connect the tunnel: ${error.message}`);
+ }
+ }, 1000);
+ } catch (error) {
+ log.error('Failed to log in,', error.message);
+
+ actions.account.loginFailed(error);
+ }
+ }
+
+ public verifyAccount(accountToken: AccountToken): Promise<AccountVerification> {
+ return new Promise((resolve, reject) => {
+ this.accountDataCache.invalidate();
+ this.accountDataCache.fetch(accountToken, {
+ onFinish: () => resolve({ status: 'verified' }),
+ onError: (error): AccountFetchRetryAction => {
+ if (error instanceof InvalidAccountError) {
+ reject(error);
+ return AccountFetchRetryAction.stop;
+ } else {
+ resolve({ status: 'deferred', error });
+ return AccountFetchRetryAction.retry;
+ }
+ },
+ });
+ });
+ }
+
+ public async logout() {
+ try {
+ await IpcRendererEventChannel.account.unset();
+ } catch (e) {
+ log.info('Failed to logout: ', e.message);
+ }
+ }
+
+ public async connectTunnel(): Promise<void> {
+ const state = this.tunnelState.state;
+
+ // connect only if tunnel is disconnected or blocked.
+ if (state === 'disconnecting' || state === 'disconnected' || state === 'blocked') {
+ // switch to the connecting state ahead of time to make the app look more responsive
+ this.reduxActions.connection.connecting(null);
+
+ return IpcRendererEventChannel.tunnel.connect();
+ }
+ }
+
+ public disconnectTunnel(): Promise<void> {
+ return IpcRendererEventChannel.tunnel.disconnect();
+ }
+
+ public updateRelaySettings(relaySettings: RelaySettingsUpdate) {
+ return IpcRendererEventChannel.settings.updateRelaySettings(relaySettings);
+ }
+
+ public updateAccountExpiry() {
+ if (this.settings.accountToken) {
+ this.accountDataCache.fetch(this.settings.accountToken);
+ }
+ }
+
+ public async removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
+ return IpcRendererEventChannel.accountHistory.removeItem(accountToken);
+ }
+
+ public async setAllowLan(allowLan: boolean) {
+ const actions = this.reduxActions;
+ await IpcRendererEventChannel.settings.setAllowLan(allowLan);
+ actions.settings.updateAllowLan(allowLan);
+ }
+
+ public async setEnableIpv6(enableIpv6: boolean) {
+ const actions = this.reduxActions;
+ await IpcRendererEventChannel.settings.setEnableIpv6(enableIpv6);
+ actions.settings.updateEnableIpv6(enableIpv6);
+ }
+
+ public async setBlockWhenDisconnected(blockWhenDisconnected: boolean) {
+ const actions = this.reduxActions;
+ await IpcRendererEventChannel.settings.setBlockWhenDisconnected(blockWhenDisconnected);
+ actions.settings.updateBlockWhenDisconnected(blockWhenDisconnected);
+ }
+
+ public async setOpenVpnMssfix(mssfix?: number) {
+ const actions = this.reduxActions;
+ actions.settings.updateOpenVpnMssfix(mssfix);
+ await IpcRendererEventChannel.settings.setOpenVpnMssfix(mssfix);
+ }
+
+ public async setAutoConnect(autoConnect: boolean) {
+ return IpcRendererEventChannel.guiSettings.setAutoConnect(autoConnect);
+ }
+
+ public async setAutoStart(autoStart: boolean): Promise<void> {
+ this.storeAutoStart(autoStart);
+
+ return IpcRendererEventChannel.autoStart.set(autoStart);
+ }
+
+ public setStartMinimized(startMinimized: boolean) {
+ IpcRendererEventChannel.guiSettings.setStartMinimized(startMinimized);
+ }
+
+ public setMonochromaticIcon(monochromaticIcon: boolean) {
+ IpcRendererEventChannel.guiSettings.setMonochromaticIcon(monochromaticIcon);
+ }
+
+ private setRelaySettings(relaySettings: RelaySettings) {
+ const actions = this.reduxActions;
+
+ if ('normal' in relaySettings) {
+ const payload: { [key: string]: any } = {};
+ const normal = relaySettings.normal;
+ const tunnel = normal.tunnel;
+ const location = normal.location;
+
+ payload.location = location === 'any' ? 'any' : location.only;
+
+ if (tunnel === 'any') {
+ payload.port = 'any';
+ payload.protocol = 'any';
+ } else {
+ const constraints = tunnel.only;
+ if ('openvpn' in constraints) {
+ const { port, protocol } = constraints.openvpn;
+ payload.port = port === 'any' ? port : port.only;
+ payload.protocol = protocol === 'any' ? protocol : protocol.only;
+ }
+
+ if ('wireguard' in constraints) {
+ const { port } = constraints.wireguard;
+ payload.port = port === 'any' ? port : port.only;
+ payload.protocol = 'udp';
+ }
+ }
+
+ actions.settings.updateRelay({
+ normal: payload,
+ });
+ } else if ('customTunnelEndpoint' in relaySettings) {
+ const customTunnelEndpoint = relaySettings.customTunnelEndpoint;
+ const host = customTunnelEndpoint.host;
+ const config: ConnectionConfig = customTunnelEndpoint.config;
+
+ let port = 0;
+ let protocol = 'udp';
+ if ('openvpn' in config) {
+ port = config.openvpn.endpoint.port;
+ protocol = config.openvpn.endpoint.protocol;
+ }
+
+ if ('wireguard' in config) {
+ // TODO: handle wireguard
+ }
+
+ actions.settings.updateRelay({
+ customTunnelEndpoint: {
+ host,
+ port,
+ protocol,
+ },
+ });
+ }
+ }
+
+ private async onDaemonConnected() {
+ this.connectedToDaemon = true;
+
+ if (this.settings.accountToken) {
+ this.memoryHistory.replace('/connect');
+
+ // try to autoconnect the tunnel
+ await this.autoConnect();
+ } else {
+ this.memoryHistory.replace('/login');
+
+ // show window when account is not set
+ ipcRenderer.send('show-window');
+ }
+ }
+
+ private onDaemonDisconnected(error?: Error) {
+ const wasConnected = this.connectedToDaemon;
+
+ this.connectedToDaemon = false;
+
+ if (error && wasConnected) {
+ this.memoryHistory.replace('/');
+ }
+ }
+
+ private async autoConnect() {
+ if (process.env.NODE_ENV === 'development') {
+ log.info('Skip autoconnect in development');
+ } else if (this.autoConnected) {
+ log.info('Skip autoconnect because it was done before');
+ } else if (this.settings.accountToken) {
+ if (this.guiSettings.autoConnect) {
+ try {
+ log.info('Autoconnect the tunnel');
+
+ await this.connectTunnel();
+
+ this.autoConnected = true;
+ } catch (error) {
+ log.error(`Failed to autoconnect the tunnel: ${error.message}`);
+ }
+ } else {
+ log.info('Skip autoconnect because GUI setting is disabled');
+ }
+ } else {
+ log.info('Skip autoconnect because account token is not set');
+ }
+ }
+
+ private setAccountHistory(accountHistory: AccountToken[]) {
+ this.reduxActions.account.updateAccountHistory(accountHistory);
+ }
+
+ private setTunnelState(tunnelState: TunnelStateTransition) {
+ const actions = this.reduxActions;
+
+ log.debug(`Tunnel state: ${tunnelState.state}`);
+
+ this.tunnelState = tunnelState;
+
+ switch (tunnelState.state) {
+ case 'connecting':
+ actions.connection.connecting(tunnelState.details);
+ break;
+
+ case 'connected':
+ actions.connection.connected(tunnelState.details);
+ break;
+
+ case 'disconnecting':
+ actions.connection.disconnecting(tunnelState.details);
+ break;
+
+ case 'disconnected':
+ actions.connection.disconnected();
+ break;
+
+ case 'blocked':
+ actions.connection.blocked(tunnelState.details);
+ break;
+ }
+ }
+
+ private setSettings(newSettings: ISettings) {
+ this.settings = newSettings;
+
+ const reduxSettings = this.reduxActions.settings;
+ const reduxAccount = this.reduxActions.account;
+
+ reduxSettings.updateAllowLan(newSettings.allowLan);
+ reduxSettings.updateEnableIpv6(newSettings.tunnelOptions.generic.enableIpv6);
+ reduxSettings.updateBlockWhenDisconnected(newSettings.blockWhenDisconnected);
+ reduxSettings.updateOpenVpnMssfix(newSettings.tunnelOptions.openvpn.mssfix);
+
+ this.setRelaySettings(newSettings.relaySettings);
+
+ if (newSettings.accountToken) {
+ reduxAccount.updateAccountToken(newSettings.accountToken);
+ reduxAccount.loggedIn();
+ } else {
+ reduxAccount.loggedOut();
+ }
+ }
+
+ private handleAccountChange(oldAccount?: string, newAccount?: string) {
+ if (oldAccount && !newAccount) {
+ this.accountDataCache.invalidate();
+
+ if (this.loginTimer) {
+ clearTimeout(this.loginTimer);
+ }
+
+ this.memoryHistory.replace('/login');
+ } else if (!oldAccount && newAccount) {
+ this.accountDataCache.fetch(newAccount);
+
+ if (!this.doingLogin) {
+ this.memoryHistory.replace('/connect');
+ }
+ } else if (oldAccount && newAccount && oldAccount !== newAccount) {
+ this.accountDataCache.fetch(newAccount);
+ }
+
+ this.doingLogin = false;
+ }
+
+ private setLocation(location: ILocation) {
+ this.reduxActions.connection.newLocation(location);
+ }
+
+ private setRelays(relayList: IRelayList) {
+ const locations = relayList.countries.map((country) => ({
+ name: country.name,
+ code: country.code,
+ hasActiveRelays: country.cities.some((city) => city.relays.length > 0),
+ cities: country.cities.map((city) => ({
+ name: city.name,
+ code: city.code,
+ latitude: city.latitude,
+ longitude: city.longitude,
+ hasActiveRelays: city.relays.length > 0,
+ relays: city.relays,
+ })),
+ }));
+
+ this.reduxActions.settings.updateRelayLocations(locations);
+ }
+
+ private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
+ this.reduxActions.version.updateVersion(versionInfo.gui, versionInfo.isConsistent);
+ }
+
+ private setUpgradeVersion(upgradeVersion: IAppUpgradeInfo) {
+ this.reduxActions.version.updateLatest(upgradeVersion);
+ }
+
+ private setGuiSettings(guiSettings: IGuiSettingsState) {
+ this.guiSettings = guiSettings;
+ this.reduxActions.settings.updateGuiSettings(guiSettings);
+ }
+
+ private storeAutoStart(autoStart: boolean) {
+ this.reduxActions.settings.updateAutoStart(autoStart);
+ }
+}
+
+type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error };
+export enum AccountFetchRetryAction {
+ stop,
+ retry,
+}
+interface IAccountFetchWatcher {
+ onFinish: () => void;
+ onError: (error: Error) => AccountFetchRetryAction;
+}
+
+// An account data cache that helps to throttle RPC requests to get_account_data and retain the
+// cached value for 1 minute.
+export class AccountDataCache {
+ private currentAccount?: AccountToken;
+ private expiresAt?: Date;
+ private fetchAttempt = 0;
+ private fetchRetryTimeout?: NodeJS.Timeout;
+ private watchers: IAccountFetchWatcher[] = [];
+
+ constructor(
+ private fetchHandler: (token: AccountToken) => Promise<IAccountData>,
+ private updateHandler: (data?: IAccountData) => void,
+ ) {}
+
+ public fetch(accountToken: AccountToken, watcher?: IAccountFetchWatcher) {
+ // invalidate cache if account token has changed
+ if (accountToken !== this.currentAccount) {
+ this.invalidate();
+ this.currentAccount = accountToken;
+ }
+
+ // Only fetch is value has expired
+ if (this.isExpired()) {
+ if (watcher) {
+ this.watchers.push(watcher);
+ }
+
+ this.performFetch(accountToken);
+ } else if (watcher) {
+ watcher.onFinish();
+ }
+ }
+
+ public invalidate() {
+ if (this.fetchRetryTimeout) {
+ clearTimeout(this.fetchRetryTimeout);
+ this.fetchRetryTimeout = undefined;
+ this.fetchAttempt = 0;
+ }
+
+ this.expiresAt = undefined;
+ this.updateHandler();
+ this.notifyWatchers((watcher) => {
+ watcher.onError(new Error('Cancelled'));
+ });
+ }
+
+ private setValue(value: IAccountData) {
+ this.expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration
+ this.updateHandler(value);
+ this.notifyWatchers((watcher) => watcher.onFinish());
+ }
+
+ private isExpired() {
+ return !this.expiresAt || this.expiresAt < new Date();
+ }
+
+ private async performFetch(accountToken: AccountToken) {
+ try {
+ // it's possible for invalidate() to be called or for a fetch for a different account token
+ // to start before this fetch completes, so checking if the current account token is the one
+ // used is necessary below.
+ const accountData = await this.fetchHandler(accountToken);
+
+ if (this.currentAccount === accountToken) {
+ this.setValue(accountData);
+ }
+ } catch (error) {
+ if (this.currentAccount === accountToken) {
+ this.handleFetchError(accountToken, error);
+ }
+ }
+ }
+
+ private handleFetchError(accountToken: AccountToken, error: any) {
+ let shouldRetry = true;
+
+ this.notifyWatchers((watcher) => {
+ if (watcher.onError(error) === AccountFetchRetryAction.stop) {
+ shouldRetry = false;
+ }
+ });
+
+ if (shouldRetry) {
+ this.scheduleRetry(accountToken);
+ }
+ }
+
+ private scheduleRetry(accountToken: AccountToken) {
+ this.fetchAttempt += 1;
+
+ // tslint:disable-next-line
+ const delay = Math.min(2048, 1 << (this.fetchAttempt + 2)) * 1000;
+
+ log.warn(`Failed to fetch account data. Retrying in ${delay} ms`);
+
+ this.fetchRetryTimeout = global.setTimeout(() => {
+ this.fetchRetryTimeout = undefined;
+ this.performFetch(accountToken);
+ }, delay);
+ }
+
+ private notifyWatchers(notify: (watcher: IAccountFetchWatcher) => void) {
+ this.watchers.splice(0).forEach(notify);
+ }
+}
diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx
new file mode 100644
index 0000000000..19c9ddee56
--- /dev/null
+++ b/gui/src/renderer/components/Accordion.tsx
@@ -0,0 +1,136 @@
+import * as React from 'react';
+import { Animated, Component, Styles, Types, UserInterface, View } from 'reactxp';
+
+interface IProps {
+ expanded: boolean;
+ animationDuration: number;
+ style?: Types.AnimatedViewStyleRuleSet;
+ children?: React.ReactNode;
+}
+
+interface IState {
+ applyAnimatedStyle: boolean;
+ mountChildren: boolean;
+}
+
+const containerOverflowStyle = Styles.createViewStyle({ overflow: 'hidden' });
+
+export default class Accordion extends Component<IProps, IState> {
+ public static defaultProps = {
+ expanded: true,
+ animationDuration: 350,
+ };
+
+ public state: IState = {
+ applyAnimatedStyle: false,
+ mountChildren: false,
+ };
+
+ private heightValue = Animated.createValue(0);
+ private animatedStyle = Styles.createAnimatedViewStyle({
+ height: this.heightValue,
+ });
+
+ private containerRef = React.createRef<Animated.View>();
+ private contentRef = React.createRef<View>();
+ private animation?: Types.Animated.CompositeAnimation = undefined;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ applyAnimatedStyle: !props.expanded,
+ mountChildren: props.expanded,
+ };
+ }
+
+ public componentWillUnmount() {
+ if (this.animation) {
+ this.animation.stop();
+ }
+ }
+
+ public componentDidUpdate(oldProps: IProps, oldState: IState) {
+ if (this.props.expanded !== oldProps.expanded) {
+ // make sure the children are mounted first before expanding the accordion
+ if (this.props.expanded && !this.state.mountChildren) {
+ this.setState({ mountChildren: true });
+ } else {
+ this.animate(this.props.expanded);
+ }
+ } else if (this.state.mountChildren && !oldState.mountChildren) {
+ // run animations once the children are mounted
+ this.animate(this.props.expanded);
+ }
+ }
+
+ public render() {
+ const { style, children, expanded, animationDuration, ...otherProps } = this.props;
+ const containerStyles = this.state.applyAnimatedStyle
+ ? [style, containerOverflowStyle, this.animatedStyle]
+ : [style];
+
+ return (
+ <Animated.View {...otherProps} style={containerStyles} ref={this.containerRef}>
+ <View ref={this.contentRef}>{this.state.mountChildren && children}</View>
+ </Animated.View>
+ );
+ }
+
+ private async animate(expand: boolean) {
+ const containerView = this.containerRef.current;
+ const contentView = this.contentRef.current;
+ if (!containerView || !contentView) {
+ return;
+ }
+
+ if (this.animation) {
+ this.animation.stop();
+ this.animation = undefined;
+ }
+
+ const containerLayout = await UserInterface.measureLayoutRelativeToWindow(containerView);
+ const contentLayout = await UserInterface.measureLayoutRelativeToAncestor(
+ contentView,
+ containerView,
+ );
+
+ // the content is expanded when the animated style is not applied,
+ // so reset the initial animated value to the current layout's height.
+ if (!this.state.applyAnimatedStyle) {
+ this.heightValue.setValue(containerLayout.height);
+ }
+
+ const toValue = expand ? contentLayout.height : 0;
+
+ // calculate the animation duration based on travel distance
+ const multiplier =
+ Math.abs(toValue - containerLayout.height) / Math.max(1, contentLayout.height);
+ const duration = Math.ceil(this.props.animationDuration * multiplier);
+
+ const animation = Animated.timing(this.heightValue, {
+ toValue,
+ easing: Animated.Easing.InOut(),
+ duration,
+ useNativeDriver: true,
+ });
+
+ this.animation = animation;
+
+ const onAnimationEnd = ({ finished }: Types.Animated.EndResult) => {
+ if (finished) {
+ this.animation = undefined;
+
+ // reset the height after transition to let element layout naturally
+ // if animation finished without interruption
+ if (expand) {
+ this.setState({ applyAnimatedStyle: false });
+ }
+ }
+ };
+
+ this.setState({ applyAnimatedStyle: true }, () => {
+ animation.start(onAnimationEnd);
+ });
+ }
+}
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx
new file mode 100644
index 0000000000..aaa3b637c9
--- /dev/null
+++ b/gui/src/renderer/components/Account.tsx
@@ -0,0 +1,117 @@
+import moment from 'moment';
+import * as React from 'react';
+import { Component, Text, View } from 'reactxp';
+import { pgettext } from '../../shared/gettext';
+import styles from './AccountStyles';
+import * as AppButton from './AppButton';
+import ClipboardLabel from './ClipboardLabel';
+import { Container, Layout } from './Layout';
+import { BackBarItem, NavigationBar } from './NavigationBar';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+
+import { AccountToken } from '../../shared/daemon-rpc-types';
+
+interface IProps {
+ accountToken?: AccountToken;
+ accountExpiry?: string;
+ expiryLocale: string;
+ isOffline: boolean;
+ onLogout: () => void;
+ onClose: () => void;
+ onBuyMore: () => void;
+}
+
+export default class Account extends Component<IProps> {
+ public render() {
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.account}>
+ <NavigationBar>
+ <BackBarItem action={this.props.onClose}>
+ {// TRANSLATORS: Back button in navigation bar
+ pgettext('account-nav', 'Settings')}
+ </BackBarItem>
+ </NavigationBar>
+
+ <View style={styles.account__container}>
+ <SettingsHeader>
+ <HeaderTitle>{pgettext('account-view', 'Account')}</HeaderTitle>
+ </SettingsHeader>
+
+ <View style={styles.account__content}>
+ <View style={styles.account__main}>
+ <View style={styles.account__row}>
+ <Text style={styles.account__row_label}>
+ {pgettext('account-view', 'Account ID')}
+ </Text>
+ <ClipboardLabel
+ style={styles.account__row_value}
+ value={this.props.accountToken || ''}
+ message={pgettext('account-view', 'COPIED TO CLIPBOARD!')}
+ />
+ </View>
+
+ <View style={styles.account__row}>
+ <Text style={styles.account__row_label}>Paid until</Text>
+ <FormattedAccountExpiry
+ expiry={this.props.accountExpiry}
+ locale={this.props.expiryLocale}
+ />
+ </View>
+
+ <View style={styles.account__footer}>
+ <AppButton.GreenButton
+ style={styles.account__buy_button}
+ disabled={this.props.isOffline}
+ onPress={this.props.onBuyMore}>
+ <AppButton.Label>
+ {pgettext('account-view', 'Buy more credit')}
+ </AppButton.Label>
+ <AppButton.Icon source="icon-extLink" height={16} width={16} />
+ </AppButton.GreenButton>
+ <AppButton.RedButton onPress={this.props.onLogout}>
+ {pgettext('account-view', 'Log out')}
+ </AppButton.RedButton>
+ </View>
+ </View>
+ </View>
+ </View>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+}
+
+function FormattedAccountExpiry(props: { expiry?: string; locale: string }) {
+ if (!props.expiry) {
+ return (
+ <Text style={styles.account__row_value}>
+ {pgettext('account-view', 'Currently unavailable')}
+ </Text>
+ );
+ }
+
+ const expiry = moment(props.expiry);
+
+ if (expiry.isSameOrBefore(moment())) {
+ return (
+ <Text style={styles.account__out_of_time}>{pgettext('account-view', 'OUT OF TIME')}</Text>
+ );
+ }
+
+ const formatOptions = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ };
+
+ return (
+ <Text style={styles.account__row_value}>
+ {expiry.toDate().toLocaleString(props.locale, formatOptions)}
+ </Text>
+ );
+}
diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx
new file mode 100644
index 0000000000..bb230ee759
--- /dev/null
+++ b/gui/src/renderer/components/AccountStyles.tsx
@@ -0,0 +1,68 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ account: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ account__container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ paddingBottom: 48,
+ }),
+ account__scrollview: Styles.createViewStyle({
+ flex: 1,
+ }),
+ account__content: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ account__main: Styles.createViewStyle({
+ marginBottom: 24,
+ }),
+ account__row: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingLeft: 24,
+ paddingRight: 24,
+ marginBottom: 24,
+ }),
+ account__footer: Styles.createViewStyle({
+ paddingLeft: 24,
+ paddingRight: 24,
+ }),
+ account__buy_button: Styles.createViewStyle({
+ marginBottom: 24,
+ }),
+ account__row_label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ color: colors.white60,
+ marginBottom: 9,
+ }),
+ account__row_value: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ lineHeight: 19,
+ fontWeight: '800',
+ color: colors.white,
+ }),
+ account__out_of_time: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ fontWeight: '800',
+ color: colors.red,
+ }),
+ account__footer_label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ color: colors.white80,
+ }),
+};
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx
new file mode 100644
index 0000000000..be1dd6b104
--- /dev/null
+++ b/gui/src/renderer/components/AdvancedSettings.tsx
@@ -0,0 +1,313 @@
+import * as React from 'react';
+import { Component, View } from 'reactxp';
+import { sprintf } from 'sprintf-js';
+import { colors } from '../../config.json';
+import { RelayProtocol } from '../../shared/daemon-rpc-types';
+import { pgettext } from '../../shared/gettext';
+import styles from './AdvancedSettingsStyles';
+import * as Cell from './Cell';
+import { Container, Layout } from './Layout';
+import {
+ BackBarItem,
+ NavigationBar,
+ NavigationContainer,
+ NavigationScrollbars,
+ TitleBarItem,
+} from './NavigationBar';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import Switch from './Switch';
+
+const MIN_MSSFIX_VALUE = 1000;
+const MAX_MSSFIX_VALUE = 1450;
+const PROTOCOLS: RelayProtocol[] = ['udp', 'tcp'];
+const UDP_PORTS = [1194, 1195, 1196, 1197, 1300, 1301, 1302];
+const TCP_PORTS = [80, 443];
+
+const PORT_ITEMS: { [key in RelayProtocol]: Array<ISelectorItem<number>> } = {
+ udp: UDP_PORTS.map(mapPortToSelectorItem),
+ tcp: TCP_PORTS.map(mapPortToSelectorItem),
+};
+
+const PROTOCOL_ITEMS: Array<ISelectorItem<RelayProtocol>> = PROTOCOLS.map((value) => ({
+ label: value.toUpperCase(),
+ value,
+}));
+
+function mapPortToSelectorItem(value: number): ISelectorItem<number> {
+ return { label: value.toString(), value };
+}
+
+interface IProps {
+ enableIpv6: boolean;
+ blockWhenDisconnected: boolean;
+ protocol?: RelayProtocol;
+ mssfix?: number;
+ port?: number;
+ setEnableIpv6: (value: boolean) => void;
+ setBlockWhenDisconnected: (value: boolean) => void;
+ setOpenVpnMssfix: (value: number | undefined) => void;
+ setRelayProtocolAndPort: (protocol?: RelayProtocol, port?: number) => void;
+ onClose: () => void;
+}
+
+interface IState {
+ persistedMssfix?: number;
+ editedMssfix?: number;
+ focusOnMssfix: boolean;
+}
+
+export default class AdvancedSettings extends Component<IProps, IState> {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ persistedMssfix: props.mssfix,
+ editedMssfix: props.mssfix,
+ focusOnMssfix: false,
+ };
+ }
+
+ public componentDidUpdate(_oldProps: IProps, _oldState: IState) {
+ if (this.props.mssfix !== this.state.persistedMssfix) {
+ this.setState((state, props) => ({
+ ...state,
+ persistedMssfix: props.mssfix,
+ editedMssfix: state.focusOnMssfix ? state.editedMssfix : props.mssfix,
+ }));
+ }
+ }
+
+ public render() {
+ const mssfixStyle = this.mssfixIsValid()
+ ? styles.advanced_settings__mssfix_valid_value
+ : styles.advanced_settings__mssfix_invalid_value;
+ const mssfixValue = this.state.editedMssfix;
+
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.advanced_settings}>
+ <NavigationContainer>
+ <NavigationBar>
+ <BackBarItem action={this.props.onClose}>
+ {// TRANSLATORS: Back button in navigation bar
+ pgettext('advanced-settings-nav', 'Settings')}
+ </BackBarItem>
+ <TitleBarItem>
+ {// TRANSLATORS: Title label in navigation bar
+ pgettext('advanced-settings-nav', 'Advanced')}
+ </TitleBarItem>
+ </NavigationBar>
+
+ <View style={styles.advanced_settings__container}>
+ <NavigationScrollbars style={styles.advanced_settings__scrollview}>
+ <SettingsHeader>
+ <HeaderTitle>{pgettext('advanced-settings-view', 'Advanced')}</HeaderTitle>
+ </SettingsHeader>
+
+ <Cell.Container>
+ <Cell.Label>{pgettext('advanced-settings-view', 'Enable IPv6')}</Cell.Label>
+ <Switch isOn={this.props.enableIpv6} onChange={this.props.setEnableIpv6} />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext(
+ 'advanced-settings-view',
+ 'Enable IPv6 communication through the tunnel.',
+ )}
+ </Cell.Footer>
+
+ <Cell.Container>
+ <Cell.Label textStyle={styles.advanced_settings__block_when_disconnected_label}>
+ {pgettext('advanced-settings-view', 'Block when disconnected')}
+ </Cell.Label>
+ <Switch
+ isOn={this.props.blockWhenDisconnected}
+ onChange={this.props.setBlockWhenDisconnected}
+ />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext(
+ 'advanced-settings-view',
+ "Unless connected, always block all network traffic, even when you've disconnected or quit the app.",
+ )}
+ </Cell.Footer>
+
+ <View style={styles.advanced_settings__content}>
+ <Selector
+ title={pgettext('advanced-settings-view', 'Network protocols')}
+ values={PROTOCOL_ITEMS}
+ value={this.props.protocol}
+ onSelect={this.onSelectProtocol}
+ />
+
+ <View style={styles.advanced_settings__cell_spacer} />
+
+ {this.props.protocol ? (
+ <Selector
+ title={sprintf(
+ // TRANSLATORS: The title for the port selector section.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP)
+ pgettext('advanced-settings-view', '%(portType)s port'),
+ {
+ portType: this.props.protocol.toUpperCase(),
+ },
+ )}
+ values={PORT_ITEMS[this.props.protocol]}
+ value={this.props.port}
+ onSelect={this.onSelectPort}
+ />
+ ) : (
+ undefined
+ )}
+ </View>
+
+ <Cell.Container>
+ <Cell.Label>{pgettext('advanced-settings-view', 'Mssfix')}</Cell.Label>
+ <Cell.InputFrame style={styles.advanced_settings__mssfix_frame}>
+ <Cell.Input
+ keyboardType={'numeric'}
+ maxLength={4}
+ placeholder={pgettext('advanced-settings-view', 'Default')}
+ value={mssfixValue ? mssfixValue.toString() : ''}
+ style={mssfixStyle}
+ onChangeText={this.onMssfixChange}
+ onFocus={this.onMssfixFocus}
+ onBlur={this.onMssfixBlur}
+ />
+ </Cell.InputFrame>
+ </Cell.Container>
+ <Cell.Footer>
+ {sprintf(
+ // TRANSLATORS: The hint displayed below the Mssfix input field.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(max)d - the maximum possible mssfix value
+ // TRANSLATORS: %(min)d - the minimum possible mssfix value
+ pgettext(
+ 'advanced-settings-view',
+ 'Set OpenVPN MSS value. Valid range: %(min)d - %(max)d.',
+ ),
+ {
+ min: MIN_MSSFIX_VALUE,
+ max: MAX_MSSFIX_VALUE,
+ },
+ )}
+ </Cell.Footer>
+ </NavigationScrollbars>
+ </View>
+ </NavigationContainer>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private onSelectProtocol = (protocol?: RelayProtocol) => {
+ this.props.setRelayProtocolAndPort(protocol);
+ };
+
+ private onSelectPort = (port?: number) => {
+ this.props.setRelayProtocolAndPort(this.props.protocol, port);
+ };
+
+ private onMssfixChange = (mssfixString: string) => {
+ const mssfix = mssfixString.replace(/[^0-9]/g, '');
+
+ if (mssfix === '') {
+ this.setState({ editedMssfix: undefined });
+ } else {
+ this.setState({ editedMssfix: parseInt(mssfix, 10) });
+ }
+ };
+
+ private onMssfixFocus = () => {
+ this.setState({ focusOnMssfix: true });
+ };
+
+ private onMssfixBlur = () => {
+ this.setState({ focusOnMssfix: false });
+
+ if (this.mssfixIsValid()) {
+ this.props.setOpenVpnMssfix(this.state.editedMssfix);
+ this.setState((state, _props) => ({ persistedMssfix: state.editedMssfix }));
+ }
+ };
+
+ private mssfixIsValid(): boolean {
+ const mssfix = this.state.editedMssfix;
+
+ return mssfix === undefined || (mssfix >= MIN_MSSFIX_VALUE && mssfix <= MAX_MSSFIX_VALUE);
+ }
+}
+
+interface ISelectorItem<T> {
+ label: string;
+ value: T;
+}
+
+interface ISelectorProps<T> {
+ title: string;
+ values: Array<ISelectorItem<T>>;
+ value?: T;
+ onSelect: (value?: T) => void;
+}
+
+class Selector<T> extends Component<ISelectorProps<T>> {
+ public render() {
+ return (
+ <Cell.Section>
+ <Cell.SectionTitle>{this.props.title}</Cell.SectionTitle>
+ <SelectorCell
+ key={'auto'}
+ selected={this.props.value === undefined}
+ onSelect={this.props.onSelect}>
+ {pgettext('advanced-settings-view', 'Automatic')}
+ </SelectorCell>
+ {this.props.values.map((item, i) => (
+ <SelectorCell
+ key={i}
+ value={item.value}
+ selected={item.value === this.props.value}
+ onSelect={this.props.onSelect}>
+ {item.label}
+ </SelectorCell>
+ ))}
+ </Cell.Section>
+ );
+ }
+}
+
+interface ISelectorCell<T> {
+ value?: T;
+ selected: boolean;
+ onSelect: (value?: T) => void;
+ children?: React.ReactText;
+}
+
+class SelectorCell<T> extends Component<ISelectorCell<T>> {
+ public render() {
+ return (
+ <Cell.CellButton
+ style={this.props.selected ? styles.advanced_settings__cell_selected_hover : undefined}
+ cellHoverStyle={
+ this.props.selected ? styles.advanced_settings__cell_selected_hover : undefined
+ }
+ onPress={this.onPress}>
+ <Cell.Icon
+ style={this.props.selected ? undefined : styles.advanced_settings__cell_icon_invisible}
+ source="icon-tick"
+ width={24}
+ height={24}
+ tintColor={colors.white}
+ />
+ <Cell.Label>{this.props.children}</Cell.Label>
+ </Cell.CellButton>
+ );
+ }
+
+ private onPress = () => {
+ if (!this.props.selected) {
+ this.props.onSelect(this.props.value);
+ }
+ };
+}
diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx
new file mode 100644
index 0000000000..fd8be66c98
--- /dev/null
+++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx
@@ -0,0 +1,54 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ advanced_settings: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ advanced_settings__container: Styles.createViewStyle({
+ flex: 1,
+ }),
+ // plain CSS style
+ advanced_settings__scrollview: {
+ flex: 1,
+ },
+ advanced_settings__content: Styles.createViewStyle({
+ flex: 0,
+ }),
+ advanced_settings__cell_hover: Styles.createButtonStyle({
+ backgroundColor: colors.blue80,
+ }),
+ advanced_settings__cell_selected_hover: Styles.createButtonStyle({
+ backgroundColor: colors.green,
+ }),
+ advanced_settings__cell_spacer: Styles.createViewStyle({
+ height: 24,
+ }),
+ advanced_settings__cell_icon_invisible: Styles.createViewStyle({
+ opacity: 0,
+ }),
+ advanced_settings__cell_label: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 20,
+ fontWeight: '900',
+ lineHeight: 26,
+ letterSpacing: -0.2,
+ color: colors.white,
+ flex: 0,
+ }),
+ advanced_settings__mssfix_frame: Styles.createViewStyle({
+ flexGrow: 0,
+ flexShrink: 0,
+ flexBasis: 80,
+ }),
+ advanced_settings__mssfix_valid_value: Styles.createTextStyle({
+ color: colors.white,
+ }),
+ advanced_settings__mssfix_invalid_value: Styles.createTextStyle({
+ color: colors.red,
+ }),
+ advanced_settings__block_when_disconnected_label: Styles.createTextStyle({
+ letterSpacing: -0.5,
+ }),
+};
diff --git a/gui/src/renderer/components/AppButton.tsx b/gui/src/renderer/components/AppButton.tsx
new file mode 100644
index 0000000000..17aafb7fc1
--- /dev/null
+++ b/gui/src/renderer/components/AppButton.tsx
@@ -0,0 +1,94 @@
+import * as React from 'react';
+import { Button, Component, Text, Types } from 'reactxp';
+import { colors } from '../../config.json';
+import styles from './AppButtonStyles';
+import ImageView from './ImageView';
+
+interface ILabelProps {
+ children?: React.ReactText;
+}
+
+export class Label extends Component<ILabelProps> {
+ public render() {
+ return <Text style={styles.label}>{this.props.children}</Text>;
+ }
+}
+
+interface IIconProps {
+ source: string;
+ width?: number;
+ height?: number;
+}
+
+export class Icon extends Component<IIconProps> {
+ public render() {
+ return (
+ <ImageView
+ source={this.props.source}
+ width={this.props.width}
+ height={this.props.height}
+ tintColor={colors.white}
+ style={styles.icon}
+ />
+ );
+ }
+}
+
+interface IProps {
+ children?: React.ReactNode;
+ style?: Types.ButtonStyleRuleSet;
+ disabled?: boolean;
+ onPress?: () => void;
+}
+
+interface IState {
+ hovered: boolean;
+}
+
+class BaseButton extends Component<IProps, IState> {
+ public state: IState = { hovered: false };
+
+ public backgroundStyle = (): Types.ButtonStyleRuleSet => {
+ throw new Error('Implement backgroundStyle in subclasses.');
+ };
+ public onHoverStart = () => (!this.props.disabled ? this.setState({ hovered: true }) : null);
+ public onHoverEnd = () => (!this.props.disabled ? this.setState({ hovered: false }) : null);
+
+ public render() {
+ const { children, style, ...otherProps } = this.props;
+
+ return (
+ <Button
+ {...otherProps}
+ style={[styles.common, this.backgroundStyle(), style]}
+ onHoverStart={this.onHoverStart}
+ onHoverEnd={this.onHoverEnd}>
+ {React.Children.map(children, (child) =>
+ typeof child === 'string' ? <Label>{child as string}</Label> : child,
+ )}
+ </Button>
+ );
+ }
+}
+
+export class RedButton extends BaseButton {
+ public backgroundStyle = () => (this.state.hovered ? styles.redHover : styles.red);
+}
+
+export class GreenButton extends BaseButton {
+ public backgroundStyle = () => (this.state.hovered ? styles.greenHover : styles.green);
+}
+
+export class BlueButton extends BaseButton {
+ public backgroundStyle = () => (this.state.hovered ? styles.blueHover : styles.blue);
+}
+
+export class TransparentButton extends BaseButton {
+ public backgroundStyle = () =>
+ this.state.hovered ? styles.transparentHover : styles.transparent;
+}
+
+export class RedTransparentButton extends BaseButton {
+ public backgroundStyle = () =>
+ this.state.hovered ? styles.redTransparentHover : styles.redTransparent;
+}
diff --git a/gui/src/renderer/components/AppButtonStyles.tsx b/gui/src/renderer/components/AppButtonStyles.tsx
new file mode 100644
index 0000000000..c5cc6dfc6b
--- /dev/null
+++ b/gui/src/renderer/components/AppButtonStyles.tsx
@@ -0,0 +1,62 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ red: Styles.createButtonStyle({
+ backgroundColor: colors.red,
+ }),
+ redHover: Styles.createButtonStyle({
+ backgroundColor: colors.red95,
+ }),
+ green: Styles.createButtonStyle({
+ backgroundColor: colors.green,
+ }),
+ greenHover: Styles.createButtonStyle({
+ backgroundColor: colors.green90,
+ }),
+ blue: Styles.createButtonStyle({
+ backgroundColor: colors.blue80,
+ }),
+ blueHover: Styles.createButtonStyle({
+ backgroundColor: colors.blue60,
+ }),
+ transparent: Styles.createButtonStyle({
+ backgroundColor: colors.white20,
+ }),
+ transparentHover: Styles.createButtonStyle({
+ backgroundColor: colors.white40,
+ }),
+ redTransparent: Styles.createButtonStyle({
+ backgroundColor: colors.red40,
+ }),
+ redTransparentHover: Styles.createButtonStyle({
+ backgroundColor: colors.red45,
+ }),
+ icon: Styles.createViewStyle({
+ position: 'absolute',
+ alignSelf: 'flex-end',
+ right: 8,
+ marginLeft: 8,
+ }),
+ common: Styles.createViewStyle({
+ cursor: 'default',
+ paddingTop: 9,
+ paddingLeft: 9,
+ paddingRight: 9,
+ paddingBottom: 9,
+ borderRadius: 4,
+ flex: 1,
+ flexDirection: 'column',
+ alignContent: 'center',
+ justifyContent: 'center',
+ }),
+ label: Styles.createTextStyle({
+ alignSelf: 'center',
+ fontFamily: 'DINPro',
+ fontSize: 20,
+ fontWeight: '900',
+ lineHeight: 26,
+ flex: 1,
+ color: colors.white,
+ }),
+};
diff --git a/gui/src/renderer/components/Cell.tsx b/gui/src/renderer/components/Cell.tsx
new file mode 100644
index 0000000000..b108708934
--- /dev/null
+++ b/gui/src/renderer/components/Cell.tsx
@@ -0,0 +1,295 @@
+import * as React from 'react';
+import { Button, Component, Styles, Text, TextInput, Types, View } from 'reactxp';
+import { colors } from '../../config.json';
+import ImageView from './ImageView';
+
+const styles = {
+ cellButton: {
+ base: Styles.createButtonStyle({
+ backgroundColor: colors.blue,
+ paddingVertical: 0,
+ paddingHorizontal: 16,
+ marginBottom: 1,
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignContent: 'center',
+ cursor: 'default',
+ }),
+ section: Styles.createButtonStyle({
+ backgroundColor: colors.blue40,
+ }),
+ hover: Styles.createButtonStyle({
+ backgroundColor: colors.blue80,
+ }),
+ },
+ cellContainer: Styles.createViewStyle({
+ backgroundColor: colors.blue,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingLeft: 16,
+ paddingRight: 12,
+ }),
+ footer: {
+ container: Styles.createViewStyle({
+ paddingTop: 8,
+ paddingRight: 24,
+ paddingBottom: 24,
+ paddingLeft: 24,
+ }),
+ text: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ color: colors.white80,
+ }),
+ },
+ label: {
+ container: Styles.createViewStyle({
+ marginLeft: 8,
+ marginTop: 14,
+ marginBottom: 14,
+ flex: 1,
+ }),
+ text: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 20,
+ fontWeight: '900',
+ lineHeight: 26,
+ letterSpacing: -0.2,
+ color: colors.white,
+ }),
+ },
+ input: {
+ frame: Styles.createViewStyle({
+ flexGrow: 0,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ borderRadius: 4,
+ paddingHorizontal: 2,
+ paddingVertical: 2,
+ }),
+ text: Styles.createTextStyle({
+ color: colors.white,
+ backgroundColor: 'transparent',
+ fontFamily: 'Open Sans',
+ fontSize: 20,
+ fontWeight: '600',
+ lineHeight: 26,
+ textAlign: 'right',
+ }),
+ },
+ icon: Styles.createViewStyle({
+ marginLeft: 8,
+ }),
+ subtext: Styles.createTextStyle({
+ color: colors.white60,
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '800',
+ flex: -1,
+ textAlign: 'right',
+ marginLeft: 8,
+ }),
+ sectionTitle: Styles.createTextStyle({
+ backgroundColor: colors.blue,
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ marginBottom: 1,
+ fontFamily: 'DINPro',
+ fontSize: 20,
+ fontWeight: '900',
+ lineHeight: 26,
+ color: colors.white,
+ }),
+};
+
+interface ICellButtonProps {
+ children?: React.ReactNode;
+ disabled?: boolean;
+ cellHoverStyle?: Types.StyleRuleSetRecursive<Types.ButtonStyleRuleSet>;
+ style?: Types.StyleRuleSetRecursive<Types.ButtonStyleRuleSet>;
+ onPress?: () => void;
+}
+
+interface IState {
+ hovered: boolean;
+}
+
+const CellSectionContext = React.createContext<boolean>(false);
+const CellHoverContext = React.createContext<boolean>(false);
+
+export class CellButton extends Component<ICellButtonProps, IState> {
+ public state = { hovered: false };
+
+ public onHoverStart = () => (!this.props.disabled ? this.setState({ hovered: true }) : null);
+ public onHoverEnd = () => (!this.props.disabled ? this.setState({ hovered: false }) : null);
+
+ public render() {
+ const { children, style, cellHoverStyle, ...otherProps } = this.props;
+ const hoverStyle = cellHoverStyle || styles.cellButton.hover;
+ return (
+ <CellSectionContext.Consumer>
+ {(containedInSection) => (
+ <Button
+ style={[
+ styles.cellButton.base,
+ containedInSection ? styles.cellButton.section : undefined,
+ style,
+ this.state.hovered ? hoverStyle : undefined,
+ ]}
+ onHoverStart={this.onHoverStart}
+ onHoverEnd={this.onHoverEnd}
+ {...otherProps}>
+ <CellHoverContext.Provider value={this.state.hovered}>
+ {children}
+ </CellHoverContext.Provider>
+ </Button>
+ )}
+ </CellSectionContext.Consumer>
+ );
+ }
+}
+
+interface ISectionTitleProps {
+ children?: React.ReactText;
+}
+
+export function SectionTitle(props: ISectionTitleProps) {
+ return <Text style={styles.sectionTitle}>{props.children}</Text>;
+}
+
+interface ISectionProps {
+ children?: React.ReactNode;
+}
+
+export class Section extends Component<ISectionProps> {
+ public render() {
+ return (
+ <View>
+ <CellSectionContext.Provider value={true}>
+ {this.props.children}
+ </CellSectionContext.Provider>
+ </View>
+ );
+ }
+}
+
+interface IContainerProps {
+ children: React.ReactNode;
+}
+
+export function Container({ children }: IContainerProps) {
+ return <View style={styles.cellContainer}>{children}</View>;
+}
+
+interface ILabelProps {
+ containerStyle?: Types.ViewStyleRuleSet;
+ textStyle?: Types.TextStyleRuleSet;
+ cellHoverContainerStyle?: Types.ViewStyleRuleSet;
+ cellHoverTextStyle?: Types.TextStyleRuleSet;
+ onPress?: (event: Types.SyntheticEvent) => void;
+ children?: React.ReactNode;
+}
+
+export function Label(props: ILabelProps) {
+ const {
+ children,
+ containerStyle,
+ textStyle,
+ cellHoverContainerStyle,
+ cellHoverTextStyle,
+ ...otherProps
+ } = props;
+
+ return (
+ <CellHoverContext.Consumer>
+ {(hovered) => (
+ <View
+ style={[
+ styles.label.container,
+ containerStyle,
+ hovered ? cellHoverContainerStyle : undefined,
+ ]}
+ {...otherProps}>
+ <Text style={[styles.label.text, textStyle, hovered ? cellHoverTextStyle : undefined]}>
+ {children}
+ </Text>
+ </View>
+ )}
+ </CellHoverContext.Consumer>
+ );
+}
+
+interface InputFrameProps {
+ children?: React.ReactNode;
+ style?: Types.StyleRuleSetRecursive<Types.ViewStyleRuleSet>;
+}
+
+export function InputFrame(props: InputFrameProps) {
+ const { style, children } = props;
+
+ return <View style={[styles.input.frame, style]}>{children}</View>;
+}
+
+export const Input = React.forwardRef(function InputT(
+ props: Types.TextInputProps,
+ ref?: React.Ref<TextInput>,
+) {
+ const { style, ...otherProps } = props;
+
+ return (
+ <TextInput
+ ref={ref as any}
+ maxLength={10}
+ placeholderTextColor={colors.white60}
+ autoCorrect={false}
+ autoFocus={false}
+ style={[styles.input.text, style]}
+ {...otherProps}
+ />
+ );
+});
+
+type SubTextProps = Types.TextProps & {
+ cellHoverStyle?: Types.ViewStyle;
+};
+
+export function SubText(props: SubTextProps) {
+ const { children, ref: _, style, cellHoverStyle, ...otherProps } = props;
+
+ return (
+ <CellHoverContext.Consumer>
+ {(hovered) => (
+ <Text style={[styles.subtext, style, hovered ? cellHoverStyle : undefined]} {...otherProps}>
+ {children}
+ </Text>
+ )}
+ </CellHoverContext.Consumer>
+ );
+}
+
+export function Icon(props: ImageView['props']) {
+ const { children: _children, style, tintColor, tintHoverColor, ...otherProps } = props;
+
+ return (
+ <CellHoverContext.Consumer>
+ {(hovered) => (
+ <ImageView
+ tintColor={(hovered && tintHoverColor) || tintColor || colors.white60}
+ style={[styles.icon, style]}
+ {...otherProps}
+ />
+ )}
+ </CellHoverContext.Consumer>
+ );
+}
+
+export function Footer({ children }: IContainerProps) {
+ return (
+ <View style={styles.footer.container}>
+ <Text style={styles.footer.text}>{children}</Text>
+ </View>
+ );
+}
diff --git a/gui/src/renderer/components/ChevronButton.tsx b/gui/src/renderer/components/ChevronButton.tsx
new file mode 100644
index 0000000000..04aabf6a99
--- /dev/null
+++ b/gui/src/renderer/components/ChevronButton.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { Component, Styles, Types } from 'reactxp';
+import { colors } from '../../config.json';
+import * as Cell from './Cell';
+
+interface IProps {
+ up: boolean;
+ onPress?: (event: Types.SyntheticEvent) => void;
+ style?: Types.StyleRuleSetRecursive<Types.ViewStyleRuleSet>;
+}
+
+const style = Styles.createViewStyle({
+ flex: 0,
+ alignSelf: 'stretch',
+ justifyContent: 'center',
+ paddingRight: 16,
+ paddingLeft: 16,
+});
+
+export default class ChevronButton extends Component<IProps> {
+ public render() {
+ return (
+ <Cell.Icon
+ style={[style, this.props.style]}
+ tintColor={colors.white80}
+ tintHoverColor={colors.white}
+ onPress={this.props.onPress}
+ source={this.props.up ? 'icon-chevron-up' : 'icon-chevron-down'}
+ height={24}
+ width={24}
+ />
+ );
+ }
+}
diff --git a/gui/src/renderer/components/CityRow.tsx b/gui/src/renderer/components/CityRow.tsx
new file mode 100644
index 0000000000..9f95aa2289
--- /dev/null
+++ b/gui/src/renderer/components/CityRow.tsx
@@ -0,0 +1,108 @@
+import * as React from 'react';
+import { Component, Styles, Types, View } from 'reactxp';
+import { colors } from '../../config.json';
+import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
+import Accordion from './Accordion';
+import * as Cell from './Cell';
+import ChevronButton from './ChevronButton';
+import RelayRow from './RelayRow';
+import RelayStatusIndicator from './RelayStatusIndicator';
+
+type RelayRowElement = React.ReactElement<RelayRow['props']>;
+
+interface IProps {
+ name: string;
+ hasActiveRelays: boolean;
+ location: RelayLocation;
+ selected: boolean;
+ expanded: boolean;
+ onSelect?: (location: RelayLocation) => void;
+ onExpand?: (location: RelayLocation, value: boolean) => void;
+ children?: RelayRowElement | RelayRowElement[];
+}
+
+const styles = {
+ base: Styles.createButtonStyle({
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingRight: 0,
+ paddingLeft: 40,
+ backgroundColor: colors.blue40,
+ }),
+ selected: Styles.createButtonStyle({
+ backgroundColor: colors.green,
+ }),
+};
+
+export default class CityRow extends Component<IProps> {
+ public static compareProps(oldProps: IProps, nextProps: IProps): boolean {
+ if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) {
+ return false;
+ }
+
+ if (
+ oldProps.name !== nextProps.name ||
+ oldProps.hasActiveRelays !== nextProps.hasActiveRelays ||
+ oldProps.selected !== nextProps.selected ||
+ oldProps.expanded !== nextProps.expanded ||
+ !compareRelayLocation(oldProps.location, nextProps.location)
+ ) {
+ return false;
+ }
+
+ const currChildren = React.Children.toArray(oldProps.children || []) as RelayRowElement[];
+ const nextChildren = React.Children.toArray(nextProps.children || []) as RelayRowElement[];
+
+ for (let i = 0; i < currChildren.length; i++) {
+ const currChild = currChildren[i];
+ const nextChild = nextChildren[i];
+
+ if (!RelayRow.compareProps(currChild.props, nextChild.props)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public shouldComponentUpdate(nextProps: IProps) {
+ return !CityRow.compareProps(this.props, nextProps);
+ }
+
+ public render() {
+ const hasChildren = React.Children.count(this.props.children) > 1;
+
+ return (
+ <View>
+ <Cell.CellButton
+ onPress={this.handlePress}
+ disabled={!this.props.hasActiveRelays}
+ cellHoverStyle={this.props.selected ? styles.selected : undefined}
+ style={[styles.base, this.props.selected ? styles.selected : undefined]}>
+ <RelayStatusIndicator
+ isActive={this.props.hasActiveRelays}
+ isSelected={this.props.selected}
+ />
+ <Cell.Label>{this.props.name}</Cell.Label>
+
+ {hasChildren && <ChevronButton onPress={this.toggleCollapse} up={this.props.expanded} />}
+ </Cell.CellButton>
+
+ {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>}
+ </View>
+ );
+ }
+
+ private toggleCollapse = (event: Types.SyntheticEvent) => {
+ if (this.props.onExpand) {
+ this.props.onExpand(this.props.location, !this.props.expanded);
+ }
+ event.stopPropagation();
+ };
+
+ private handlePress = () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(this.props.location);
+ }
+ };
+}
diff --git a/gui/src/renderer/components/ClipboardLabel.tsx b/gui/src/renderer/components/ClipboardLabel.tsx
new file mode 100644
index 0000000000..fdb9982b3f
--- /dev/null
+++ b/gui/src/renderer/components/ClipboardLabel.tsx
@@ -0,0 +1,51 @@
+import * as React from 'react';
+import { Clipboard, Component, Text, Types } from 'reactxp';
+
+interface IProps {
+ value: string;
+ delay: number;
+ message: string;
+ style?: Types.TextStyleRuleSet;
+}
+
+interface IState {
+ showsMessage: boolean;
+}
+
+export default class ClipboardLabel extends Component<IProps, IState> {
+ public static defaultProps: Partial<IProps> = {
+ delay: 3000,
+ message: 'Copied!',
+ };
+
+ public state: IState = {
+ showsMessage: false,
+ };
+
+ private timer?: NodeJS.Timeout;
+
+ public componentWillUnmount() {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+ }
+
+ public render() {
+ return (
+ <Text style={this.props.style} onPress={this.handlePress}>
+ {this.state.showsMessage ? this.props.message : this.props.value}
+ </Text>
+ );
+ }
+
+ private handlePress = () => {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+
+ this.timer = global.setTimeout(() => this.setState({ showsMessage: false }), this.props.delay);
+ this.setState({ showsMessage: true });
+
+ Clipboard.setText(this.props.value);
+ };
+}
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
new file mode 100644
index 0000000000..4d68c55897
--- /dev/null
+++ b/gui/src/renderer/components/Connect.tsx
@@ -0,0 +1,272 @@
+import * as React from 'react';
+import { Component, View } from 'reactxp';
+import { links } from '../../config.json';
+import { NoCreditError, NoInternetError } from '../../main/errors';
+import { ITunnelEndpoint, parseSocketAddress } from '../../shared/daemon-rpc-types';
+import { pgettext } from '../../shared/gettext';
+import * as AppButton from './AppButton';
+import styles from './ConnectStyles';
+import { Brand, HeaderBarStyle, SettingsBarButton } from './HeaderBar';
+import ImageView from './ImageView';
+import { Container, Header, Layout } from './Layout';
+import Map, { MarkerStyle, ZoomLevel } from './Map';
+import NotificationArea from './NotificationArea';
+import TunnelControl, { IRelayInAddress, IRelayOutAddress } from './TunnelControl';
+
+import AccountExpiry from '../lib/account-expiry';
+import { IConnectionReduxState } from '../redux/connection/reducers';
+import { IVersionReduxState } from '../redux/version/reducers';
+
+interface IProps {
+ connection: IConnectionReduxState;
+ version: IVersionReduxState;
+ accountExpiry?: AccountExpiry;
+ selectedRelayName: string;
+ connectionInfoOpen: boolean;
+ blockWhenDisconnected: boolean;
+ onSettings: () => void;
+ onSelectLocation: () => void;
+ onConnect: () => void;
+ onDisconnect: () => void;
+ onExternalLink: (url: string) => void;
+ onToggleConnectionInfo: (value: boolean) => void;
+}
+
+type MarkerOrSpinner = 'marker' | 'spinner';
+
+export default class Connect extends Component<IProps> {
+ public render() {
+ const error = this.checkForErrors();
+ const child = error ? this.renderError(error) : this.renderMap();
+
+ return (
+ <Layout>
+ <Header barStyle={this.headerBarStyle()}>
+ <Brand />
+ <SettingsBarButton onPress={this.props.onSettings} />
+ </Header>
+ <Container>{child}</Container>
+ </Layout>
+ );
+ }
+
+ public renderError(error: Error) {
+ let title = '';
+ let message = '';
+
+ if (error instanceof NoCreditError) {
+ title = pgettext('connect-view', 'Out of time');
+
+ message = pgettext(
+ 'connect-view',
+ 'Buy more time, so you can continue using the internet securely',
+ );
+ }
+
+ if (error instanceof NoInternetError) {
+ title = pgettext('connect-view', 'Offline');
+
+ message = pgettext(
+ 'connect-view',
+ 'Your internet connection will be secured when you get back online',
+ );
+ }
+
+ const { isBlocked } = this.props.connection;
+
+ return (
+ <View style={styles.connect}>
+ <View style={styles.status_icon}>
+ <ImageView source="icon-fail" height={60} width={60} />
+ </View>
+ <View style={styles.body}>
+ <View style={styles.error_title}>{title}</View>
+ <View style={styles.error_message}>{message}</View>
+ {error instanceof NoCreditError ? (
+ <View>
+ <AppButton.GreenButton disabled={isBlocked} onPress={this.handleBuyMorePress}>
+ <AppButton.Label>Buy more time</AppButton.Label>
+ <AppButton.Icon source="icon-extLink" height={16} width={16} />
+ </AppButton.GreenButton>
+ </View>
+ ) : null}
+ </View>
+ </View>
+ );
+ }
+
+ public renderMap() {
+ const status = this.props.connection.status;
+
+ const relayOutAddress: IRelayOutAddress = {
+ ipv4: this.props.connection.ip,
+ };
+ const relayInAddress: IRelayInAddress | undefined =
+ (status.state === 'connecting' || status.state === 'connected') && status.details
+ ? this.tunnelEndpointToRelayInAddress(status.details)
+ : undefined;
+
+ return (
+ <View style={styles.connect}>
+ <Map style={styles.map} {...this.getMapProps()} />
+ <View style={styles.container}>
+ {/* show spinner when connecting */}
+ {this.showMarkerOrSpinner() === 'spinner' ? (
+ <View style={styles.status_icon}>
+ <ImageView source="icon-spinner" height={60} width={60} />
+ </View>
+ ) : null}
+
+ <TunnelControl
+ tunnelState={this.props.connection.status}
+ selectedRelayName={this.props.selectedRelayName}
+ city={this.props.connection.city}
+ country={this.props.connection.country}
+ hostname={this.props.connection.hostname}
+ defaultConnectionInfoOpen={this.props.connectionInfoOpen}
+ relayInAddress={relayInAddress}
+ relayOutAddress={relayOutAddress}
+ onConnect={this.props.onConnect}
+ onDisconnect={this.props.onDisconnect}
+ onSelectLocation={this.props.onSelectLocation}
+ onToggleConnectionInfo={this.props.onToggleConnectionInfo}
+ />
+
+ <NotificationArea
+ style={styles.notification_area}
+ tunnelState={this.props.connection.status}
+ version={this.props.version}
+ accountExpiry={this.props.accountExpiry}
+ openExternalLink={this.props.onExternalLink}
+ blockWhenDisconnected={this.props.blockWhenDisconnected}
+ />
+ </View>
+ </View>
+ );
+ }
+
+ private handleBuyMorePress = () => {
+ this.props.onExternalLink(links.purchase);
+ };
+
+ private headerBarStyle(): HeaderBarStyle {
+ const { status } = this.props.connection;
+ switch (status.state) {
+ case 'disconnected':
+ return HeaderBarStyle.error;
+ case 'connecting':
+ case 'connected':
+ return HeaderBarStyle.success;
+ case 'blocked':
+ switch (status.details.reason) {
+ case 'set_firewall_policy_error':
+ return HeaderBarStyle.error;
+ default:
+ return HeaderBarStyle.success;
+ }
+ case 'disconnecting':
+ switch (status.details) {
+ case 'block':
+ case 'reconnect':
+ return HeaderBarStyle.success;
+ case 'nothing':
+ return HeaderBarStyle.error;
+ default:
+ throw new Error(`Invalid action after disconnection: ${status.details}`);
+ }
+ }
+ }
+
+ private checkForErrors(): Error | undefined {
+ // Offline?
+ if (!this.props.connection.isOnline) {
+ return new NoInternetError();
+ }
+
+ // No credit?
+ if (this.props.accountExpiry && this.props.accountExpiry.hasExpired()) {
+ return new NoCreditError();
+ }
+
+ return undefined;
+ }
+
+ private getMapProps(): Map['props'] {
+ const {
+ longitude,
+ latitude,
+ status: { state },
+ } = this.props.connection;
+
+ // when the user location is known
+ if (typeof longitude === 'number' && typeof latitude === 'number') {
+ return {
+ center: [longitude, latitude],
+ // do not show the marker when connecting or reconnecting
+ showMarker: this.showMarkerOrSpinner() === 'marker',
+ markerStyle: this.getMarkerStyle(),
+ // zoom in when connected
+ zoomLevel: state === 'connected' ? ZoomLevel.low : ZoomLevel.medium,
+ // a magic offset to align marker with spinner
+ offset: [0, 123],
+ };
+ } else {
+ return {
+ center: [0, 0],
+ showMarker: false,
+ markerStyle: MarkerStyle.unsecure,
+ // show the world when user location is not known
+ zoomLevel: ZoomLevel.high,
+ // remove the offset since the marker is hidden
+ offset: [0, 0],
+ };
+ }
+ }
+
+ private getMarkerStyle(): MarkerStyle {
+ const { status } = this.props.connection;
+
+ switch (status.state) {
+ case 'connecting':
+ case 'connected':
+ return MarkerStyle.secure;
+ case 'blocked':
+ switch (status.details.reason) {
+ case 'set_firewall_policy_error':
+ return MarkerStyle.unsecure;
+ default:
+ return MarkerStyle.secure;
+ }
+ case 'disconnected':
+ return MarkerStyle.unsecure;
+ case 'disconnecting':
+ switch (status.details) {
+ case 'block':
+ case 'reconnect':
+ return MarkerStyle.secure;
+ case 'nothing':
+ return MarkerStyle.unsecure;
+ default:
+ throw new Error(`Invalid action after disconnection: ${status.details}`);
+ }
+ }
+ }
+
+ private showMarkerOrSpinner(): MarkerOrSpinner {
+ const status = this.props.connection.status;
+
+ return status.state === 'connecting' ||
+ (status.state === 'disconnecting' && status.details === 'reconnect')
+ ? 'spinner'
+ : 'marker';
+ }
+
+ private tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): IRelayInAddress {
+ const socketAddr = parseSocketAddress(tunnelEndpoint.address);
+ return {
+ ip: socketAddr.host,
+ port: socketAddr.port,
+ protocol: tunnelEndpoint.protocol,
+ };
+ }
+}
diff --git a/gui/src/renderer/components/ConnectStyles.tsx b/gui/src/renderer/components/ConnectStyles.tsx
new file mode 100644
index 0000000000..645541000b
--- /dev/null
+++ b/gui/src/renderer/components/ConnectStyles.tsx
@@ -0,0 +1,61 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ connect: Styles.createViewStyle({
+ flex: 1,
+ }),
+ map: Styles.createViewStyle({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ // @ts-ignore
+ zIndex: 0,
+ }),
+ body: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingLeft: 24,
+ paddingRight: 24,
+ paddingBottom: 0,
+ marginTop: 186,
+ flex: 1,
+ }),
+ container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ position: 'relative' /* need this for z-index to work to cover map */,
+ // @ts-ignore
+ zIndex: 1,
+ }),
+ status_icon: Styles.createViewStyle({
+ position: 'absolute',
+ alignSelf: 'center',
+ width: 60,
+ height: 60,
+ marginTop: 94,
+ }),
+ notification_area: Styles.createViewStyle({
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ right: 0,
+ }),
+ error_title: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 32,
+ fontWeight: '900',
+ lineHeight: 40,
+ color: colors.white,
+ marginBottom: 8,
+ }),
+ error_message: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ lineHeight: 20,
+ fontWeight: '600',
+ color: colors.white,
+ marginBottom: 24,
+ }),
+};
diff --git a/gui/src/renderer/components/ConnectionInfo.tsx b/gui/src/renderer/components/ConnectionInfo.tsx
new file mode 100644
index 0000000000..5306ec97a3
--- /dev/null
+++ b/gui/src/renderer/components/ConnectionInfo.tsx
@@ -0,0 +1,121 @@
+import * as React from 'react';
+import { Component, Styles, Text, Types, View } from 'reactxp';
+import { default as ConnectionInfoDisclosure } from './ConnectionInfoDisclosure';
+
+const styles = {
+ row: Styles.createViewStyle({
+ flexDirection: 'row',
+ marginTop: 3,
+ }),
+ caption: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ color: 'rgb(255, 255, 255)',
+ flex: 0,
+ flexBasis: 30,
+ marginRight: 8,
+ }),
+ value: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ color: 'rgb(255, 255, 255)',
+ letterSpacing: -0.2,
+ }),
+ header: Styles.createViewStyle({
+ flexDirection: 'row',
+ alignItems: 'center',
+ }),
+ hostname: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ lineHeight: 20,
+ fontWeight: '600',
+ color: 'rgb(255, 255, 255)',
+ flex: 1,
+ }),
+};
+
+interface IInAddress {
+ ip: string;
+ port: number;
+ protocol: string;
+}
+
+interface IOutAddress {
+ ipv4?: string;
+ ipv6?: string;
+}
+
+interface IProps {
+ hostname?: string;
+ inAddress?: IInAddress;
+ outAddress?: IOutAddress;
+ defaultOpen?: boolean;
+ style?: Types.ViewStyleRuleSet | Types.ViewStyleRuleSet[];
+ onToggle?: (isOpen: boolean) => void;
+}
+
+interface IState {
+ isOpen: boolean;
+}
+
+export default class ConnectionInfo extends Component<IProps, IState> {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ isOpen: props.defaultOpen === true,
+ };
+ }
+
+ public render() {
+ const { inAddress, outAddress } = this.props;
+
+ return (
+ <View style={this.props.style}>
+ <View style={styles.header}>
+ <Text style={styles.hostname}>{this.props.hostname || ''}</Text>
+ <ConnectionInfoDisclosure defaultOpen={this.props.defaultOpen} onToggle={this.onToggle}>
+ {'Connection details'}
+ </ConnectionInfoDisclosure>
+ </View>
+
+ {this.state.isOpen && (
+ <React.Fragment>
+ {inAddress && (
+ <View style={styles.row}>
+ <Text style={styles.caption}>{'In'}</Text>
+ <Text style={styles.value}>
+ {`${inAddress.ip}:${inAddress.port} ${inAddress.protocol.toUpperCase()}`}
+ </Text>
+ </View>
+ )}
+
+ {outAddress && (outAddress.ipv4 || outAddress.ipv6) && (
+ <View style={styles.row}>
+ <Text style={styles.caption}>{'Out'}</Text>
+ <View>
+ {outAddress.ipv4 && <Text style={styles.value}>{outAddress.ipv4}</Text>}
+ {outAddress.ipv6 && <Text style={styles.value}>{outAddress.ipv6}</Text>}
+ </View>
+ </View>
+ )}
+ </React.Fragment>
+ )}
+ </View>
+ );
+ }
+
+ private onToggle = (isOpen: boolean) => {
+ this.setState(
+ (state) => ({ ...state, isOpen }),
+ () => {
+ if (this.props.onToggle) {
+ this.props.onToggle(isOpen);
+ }
+ },
+ );
+ };
+}
diff --git a/gui/src/renderer/components/ConnectionInfoDisclosure.tsx b/gui/src/renderer/components/ConnectionInfoDisclosure.tsx
new file mode 100644
index 0000000000..93cd17b1d0
--- /dev/null
+++ b/gui/src/renderer/components/ConnectionInfoDisclosure.tsx
@@ -0,0 +1,89 @@
+import * as React from 'react';
+import { Component, Styles, Text, Types, View } from 'reactxp';
+import ImageView from './ImageView';
+
+const styles = {
+ container: Styles.createViewStyle({
+ flexDirection: 'row',
+ alignItems: 'center',
+ }),
+ caption: {
+ base: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ color: 'rgb(255, 255, 255, 0.4)',
+ }),
+ hovered: Styles.createTextStyle({
+ color: 'rgb(255, 255, 255)',
+ }),
+ },
+};
+
+interface IProps {
+ onToggle?: (isOpen: boolean) => void;
+ defaultOpen?: boolean;
+ children: string;
+ style?: Types.ViewStyleRuleSet | Types.ViewStyleRuleSet[];
+}
+
+interface IState {
+ isHovered: boolean;
+ isOpen: boolean;
+}
+
+export default class ConnectionInfoDisclosure extends Component<IProps, IState> {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ isHovered: false,
+ isOpen: props.defaultOpen === true,
+ };
+ }
+
+ public render() {
+ const tintColor = this.state.isHovered ? 'rgb(255, 255, 255)' : 'rgb(255, 255, 255, 0.4)';
+
+ return (
+ <View
+ style={[styles.container, this.props.style]}
+ onMouseEnter={this.onMouseEnter}
+ onMouseLeave={this.onMouseLeave}
+ onPress={this.onToggle}>
+ <Text
+ style={[styles.caption.base, this.state.isHovered ? styles.caption.hovered : undefined]}>
+ {this.props.children}
+ </Text>
+ <ImageView
+ source={this.state.isOpen ? 'icon-chevron-up' : 'icon-chevron-down'}
+ width={24}
+ height={24}
+ tintColor={tintColor}
+ />
+ </View>
+ );
+ }
+
+ private onMouseEnter = () => {
+ this.setState({ isHovered: true });
+ };
+
+ private onMouseLeave = () => {
+ this.setState({ isHovered: false });
+ };
+
+ private onToggle = () => {
+ this.setState(
+ (state) => ({
+ ...state,
+ isOpen: !state.isOpen,
+ }),
+ () => {
+ if (this.props.onToggle) {
+ this.props.onToggle(this.state.isOpen);
+ }
+ },
+ );
+ };
+}
diff --git a/gui/src/renderer/components/CountryRow.tsx b/gui/src/renderer/components/CountryRow.tsx
new file mode 100644
index 0000000000..e468dea109
--- /dev/null
+++ b/gui/src/renderer/components/CountryRow.tsx
@@ -0,0 +1,118 @@
+import * as React from 'react';
+import { Component, Styles, Types, View } from 'reactxp';
+import { colors } from '../../config.json';
+import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
+import Accordion from './Accordion';
+import * as Cell from './Cell';
+import ChevronButton from './ChevronButton';
+import CityRow from './CityRow';
+import RelayStatusIndicator from './RelayStatusIndicator';
+
+type CityRowElement = React.ReactElement<CityRow['props']>;
+
+interface IProps {
+ name: string;
+ hasActiveRelays: boolean;
+ location: RelayLocation;
+ selected: boolean;
+ expanded: boolean;
+ onSelect?: (location: RelayLocation) => void;
+ onExpand?: (location: RelayLocation, value: boolean) => void;
+ children?: CityRowElement | CityRowElement[];
+}
+
+const styles = {
+ container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 0,
+ }),
+ base: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingLeft: 20,
+ paddingRight: 0,
+ }),
+ selected: Styles.createViewStyle({
+ backgroundColor: colors.green,
+ }),
+};
+
+export default class CountryRow extends Component<IProps> {
+ public static compareProps(oldProps: IProps, nextProps: IProps) {
+ if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) {
+ return false;
+ }
+
+ if (
+ oldProps.name !== nextProps.name ||
+ oldProps.hasActiveRelays !== nextProps.hasActiveRelays ||
+ oldProps.selected !== nextProps.selected ||
+ oldProps.expanded !== nextProps.expanded ||
+ !compareRelayLocation(oldProps.location, nextProps.location)
+ ) {
+ return false;
+ }
+
+ const currChildren = React.Children.toArray(oldProps.children || []) as CityRowElement[];
+ const nextChildren = React.Children.toArray(nextProps.children || []) as CityRowElement[];
+
+ for (let i = 0; i < currChildren.length; i++) {
+ const currChild = currChildren[i];
+ const nextChild = nextChildren[i];
+
+ if (!CityRow.compareProps(currChild.props, nextChild.props)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public shouldComponentUpdate(nextProps: IProps) {
+ return !CountryRow.compareProps(this.props, nextProps);
+ }
+
+ public render() {
+ const childrenArray = React.Children.toArray(this.props.children || []) as CityRowElement[];
+ const numChildren = childrenArray.length;
+ const onlyChild = numChildren === 1 ? childrenArray[0] : undefined;
+ const numOnlyChildChildren = onlyChild
+ ? React.Children.count(onlyChild.props.children || [])
+ : 0;
+ const hasChildren = numChildren > 1 || numOnlyChildChildren > 1;
+
+ return (
+ <View style={styles.container}>
+ <Cell.CellButton
+ cellHoverStyle={this.props.selected ? styles.selected : undefined}
+ style={[styles.base, this.props.selected ? styles.selected : undefined]}
+ onPress={this.handlePress}
+ disabled={!this.props.hasActiveRelays}>
+ <RelayStatusIndicator
+ isActive={this.props.hasActiveRelays}
+ isSelected={this.props.selected}
+ />
+ <Cell.Label>{this.props.name}</Cell.Label>
+ {hasChildren ? (
+ <ChevronButton onPress={this.toggleCollapse} up={this.props.expanded} />
+ ) : null}
+ </Cell.CellButton>
+
+ {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>}
+ </View>
+ );
+ }
+
+ private toggleCollapse = (event: Types.SyntheticEvent) => {
+ if (this.props.onExpand) {
+ this.props.onExpand(this.props.location, !this.props.expanded);
+ }
+ event.stopPropagation();
+ };
+
+ private handlePress = () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(this.props.location);
+ }
+ };
+}
diff --git a/gui/src/renderer/components/CustomScrollbars.css b/gui/src/renderer/components/CustomScrollbars.css
new file mode 100644
index 0000000000..8e5e2c9b11
--- /dev/null
+++ b/gui/src/renderer/components/CustomScrollbars.css
@@ -0,0 +1,59 @@
+.custom-scrollbars {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.custom-scrollbars__scrollable {
+ width: 100%;
+ height: 100%;
+}
+
+.custom-scrollbars__scrollable::-webkit-scrollbar {
+ display: none;
+}
+
+.custom-scrollbars__track {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 16px;
+ background-color: rgba(0, 0, 0, 0.1);
+ opacity: 0;
+ transition: width 0.1s ease-in-out, opacity 0.25s ease-in-out;
+ z-index: 98;
+ pointer-events: none;
+}
+
+.custom-scrollbars__track--visible {
+ opacity: 1;
+ pointer-events: all;
+}
+
+.custom-scrollbars__thumb {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ width: 8px;
+ transition: width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out,
+ opacity 0.25s ease-in-out;
+ opacity: 0;
+ z-index: 99;
+ pointer-events: none;
+}
+
+.custom-scrollbars__thumb--wide {
+ width: 12px;
+ border-radius: 6px;
+}
+
+.custom-scrollbars__thumb--active {
+ background-color: rgba(255, 255, 255, 0.4);
+}
+
+.custom-scrollbars__thumb--visible {
+ /* thumb appears without animation */
+ transition: width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out,
+ background-color 0.1s ease-in-out;
+ opacity: 1;
+}
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
new file mode 100644
index 0000000000..d909dd79dd
--- /dev/null
+++ b/gui/src/renderer/components/CustomScrollbars.tsx
@@ -0,0 +1,475 @@
+import * as React from 'react';
+
+const AUTOHIDE_TIMEOUT = 1000;
+
+interface IProps {
+ autoHide: boolean;
+ trackPadding: { x: number; y: number };
+ onScroll?: (value: IScrollEvent) => void;
+ style?: React.CSSProperties;
+ children?: React.ReactNode;
+}
+
+interface IState {
+ canScroll: boolean;
+ showScrollIndicators: boolean;
+ showTrack: boolean;
+ isTrackHovered: boolean;
+ isDragging: boolean;
+ dragStart: {
+ x: number;
+ y: number;
+ };
+ isWide: boolean;
+}
+
+export interface IScrollEvent {
+ scrollLeft: number;
+ scrollTop: number;
+}
+export type ScrollPosition = 'top' | 'bottom' | 'middle';
+
+interface IScrollbarUpdateContext {
+ size: boolean;
+ position: boolean;
+}
+
+export default class CustomScrollbars extends React.Component<IProps, IState> {
+ public static defaultProps: IProps = {
+ // auto-hide on macOS by default
+ autoHide: process.platform === 'darwin',
+ trackPadding: { x: 2, y: 2 },
+ };
+
+ public state = {
+ canScroll: false,
+ showScrollIndicators: true,
+ showTrack: false,
+ isTrackHovered: false,
+ isDragging: false,
+ dragStart: { x: 0, y: 0 },
+ isWide: false,
+ };
+
+ private scrollableRef = React.createRef<HTMLDivElement>();
+ private trackRef = React.createRef<HTMLDivElement>();
+ private thumbRef = React.createRef<HTMLDivElement>();
+ private autoHideTimer?: NodeJS.Timeout;
+
+ public scrollTo(x: number, y: number) {
+ const scrollable = this.scrollableRef.current;
+ if (scrollable) {
+ scrollable.scrollLeft = x;
+ scrollable.scrollTop = y;
+ }
+ }
+
+ public scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) {
+ const scrollable = this.scrollableRef.current;
+ if (scrollable) {
+ // throw if child is not a descendant of scroll view
+ if (!scrollable.contains(child)) {
+ throw new Error(
+ 'Cannot scroll to an element which is not a descendant of CustomScrollbars.',
+ );
+ }
+
+ const scrollTop = this.computeScrollTop(scrollable, child, scrollPosition);
+ this.scrollTo(0, scrollTop);
+ }
+ }
+
+ public componentDidMount() {
+ this.updateScrollbarsHelper({
+ position: true,
+ size: true,
+ });
+
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ document.addEventListener('mousedown', this.handleMouseDown);
+
+ // show scroll indicators briefly when mounted
+ if (this.props.autoHide) {
+ this.startAutoHide();
+ }
+ }
+
+ public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
+ const prevProps = this.props;
+ const prevState = this.state;
+
+ return (
+ prevProps.children !== nextProps.children ||
+ prevProps.autoHide !== nextProps.autoHide ||
+ prevProps.trackPadding.x !== nextProps.trackPadding.x ||
+ prevProps.trackPadding.y !== nextProps.trackPadding.y ||
+ prevState.canScroll !== nextState.canScroll ||
+ prevState.showScrollIndicators !== nextState.showScrollIndicators ||
+ prevState.showTrack !== nextState.showTrack ||
+ prevState.isTrackHovered !== nextState.isTrackHovered ||
+ prevState.isDragging !== nextState.isDragging ||
+ prevState.isWide !== nextState.isWide
+ );
+ }
+
+ public componentWillUnmount() {
+ this.stopAutoHide();
+
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ document.removeEventListener('mousedown', this.handleMouseDown);
+ }
+
+ public componentDidUpdate() {
+ this.updateScrollbarsHelper({
+ position: true,
+ size: true,
+ });
+ }
+
+ public render() {
+ const {
+ autoHide: _autoHide,
+ trackPadding: _trackPadding,
+ onScroll: _onScroll,
+ children,
+ ...otherProps
+ } = this.props;
+ const showScrollbars = this.state.canScroll && this.state.showScrollIndicators;
+ const thumbAnimationClass = showScrollbars ? ' custom-scrollbars__thumb--visible' : '';
+ const thumbActiveClass =
+ this.state.isTrackHovered || this.state.isDragging ? ' custom-scrollbars__thumb--active' : '';
+ const thumbWideClass = this.state.isWide ? ' custom-scrollbars__thumb--wide' : '';
+ const trackClass =
+ showScrollbars && this.state.showTrack ? ' custom-scrollbars__track--visible' : '';
+
+ return (
+ <div {...otherProps} className="custom-scrollbars">
+ <div className={`custom-scrollbars__track ${trackClass}`} ref={this.trackRef} />
+ <div
+ className={`custom-scrollbars__thumb ${thumbWideClass} ${thumbActiveClass} ${thumbAnimationClass}`}
+ style={{ position: 'absolute', top: 0, right: 0 }}
+ ref={this.thumbRef}
+ />
+ <div
+ className="custom-scrollbars__scrollable"
+ style={{ overflow: 'auto' }}
+ onScroll={this.onScroll}
+ ref={this.scrollableRef}>
+ {children}
+ </div>
+ </div>
+ );
+ }
+
+ private onScroll = () => {
+ this.updateScrollbarsHelper({ position: true });
+
+ if (this.props.autoHide) {
+ this.ensureScrollbarsVisible();
+
+ // only auto-hide when scrolling with mousewheel
+ if (!this.state.isDragging) {
+ this.startAutoHide();
+ }
+ } else {
+ // only auto-shrink when scrolling with mousewheel
+ if (!this.state.isDragging) {
+ this.startAutoShrink();
+ }
+ }
+
+ const scrollView = this.scrollableRef.current;
+ if (scrollView && this.props.onScroll) {
+ this.props.onScroll({
+ scrollLeft: scrollView.scrollLeft,
+ scrollTop: scrollView.scrollTop,
+ });
+ }
+ };
+
+ private handleEnterTrack = () => {
+ this.stopAutoHide();
+ this.setState({
+ isTrackHovered: true,
+ showScrollIndicators: true,
+ showTrack: true,
+ isWide: true,
+ });
+ };
+
+ private handleLeaveTrack = () => {
+ this.setState({
+ isTrackHovered: false,
+ });
+
+ // do not hide the scrollbar if user is dragging a thumb but left the track area.
+ if (!this.state.isDragging) {
+ if (this.props.autoHide) {
+ this.startAutoHide();
+ } else {
+ this.startAutoShrink();
+ }
+ }
+ };
+
+ private handleMouseDown = (event: MouseEvent) => {
+ const thumb = this.thumbRef.current;
+ const cursorPosition = {
+ x: event.clientX,
+ y: event.clientY,
+ };
+
+ // initiate dragging when user clicked inside of thumb
+ if (thumb && this.isPointInsideOfElement(thumb, cursorPosition)) {
+ this.setState({
+ isDragging: true,
+ dragStart: this.getPointRelativeToElement(thumb, cursorPosition),
+ });
+ }
+ };
+
+ private handleMouseUp = (event: MouseEvent) => {
+ if (!this.state.isDragging) {
+ return;
+ }
+
+ this.setState({
+ isDragging: false,
+ });
+
+ const track = this.trackRef.current;
+ if (track) {
+ // Make sure to auto-hide the scrollbar if cursor ended up outside of scroll track
+ const cursorPosition = {
+ x: event.clientX,
+ y: event.clientY,
+ };
+
+ if (!this.isPointInsideOfElement(track, cursorPosition)) {
+ if (this.props.autoHide) {
+ this.startAutoHide();
+ } else {
+ this.startAutoShrink();
+ }
+ }
+ }
+ };
+
+ private handleMouseMove = (event: MouseEvent) => {
+ const scrollable = this.scrollableRef.current;
+ const thumb = this.thumbRef.current;
+ const track = this.trackRef.current;
+
+ const cursorPosition = {
+ x: event.clientX,
+ y: event.clientY,
+ };
+
+ if (this.state.isDragging && scrollable && thumb) {
+ // the content height of the scroll view
+ const scrollHeight = scrollable.scrollHeight;
+
+ // the visible height of the scroll view
+ const visibleHeight = scrollable.offsetHeight;
+
+ // lowest point of scrollTop
+ const maxScrollTop = scrollHeight - visibleHeight;
+
+ // Map absolute cursor coordinate to point in scroll container
+ const pointInScrollContainer = this.getPointRelativeToElement(scrollable, cursorPosition);
+
+ // calculate the thumb boundary to make sure that the visual appearance of
+ // a thumb at the lowest point matches the bottom of scrollable view
+ const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight;
+ const thumbTop =
+ pointInScrollContainer.y - this.state.dragStart.y - this.props.trackPadding.y;
+ const newScrollTop = (thumbTop / thumbBoundary) * maxScrollTop;
+
+ scrollable.scrollTop = newScrollTop;
+ }
+
+ if (scrollable && track) {
+ const intersectsTrack = this.isPointInsideOfElement(track, cursorPosition);
+
+ if (!this.state.isTrackHovered && intersectsTrack) {
+ this.handleEnterTrack();
+ } else if (this.state.isTrackHovered && !intersectsTrack) {
+ this.handleLeaveTrack();
+ }
+ }
+ };
+
+ private ensureScrollbarsVisible() {
+ if (!this.state.showScrollIndicators) {
+ this.setState({
+ showScrollIndicators: true,
+ });
+ }
+ }
+
+ private startAutoHide() {
+ if (this.autoHideTimer) {
+ clearTimeout(this.autoHideTimer);
+ }
+ this.autoHideTimer = global.setTimeout(() => {
+ this.setState({
+ showScrollIndicators: false,
+ showTrack: false,
+ isWide: false,
+ });
+ }, AUTOHIDE_TIMEOUT);
+ }
+
+ private startAutoShrink() {
+ if (this.autoHideTimer) {
+ clearTimeout(this.autoHideTimer);
+ }
+
+ this.autoHideTimer = global.setTimeout(() => {
+ this.setState({
+ showTrack: false,
+ isWide: false,
+ });
+ }, AUTOHIDE_TIMEOUT);
+ }
+
+ private stopAutoHide() {
+ if (this.autoHideTimer) {
+ clearTimeout(this.autoHideTimer);
+ this.autoHideTimer = undefined;
+ }
+ }
+
+ private isPointInsideOfElement(element: HTMLElement, point: { x: number; y: number }) {
+ const rect = element.getBoundingClientRect();
+ return (
+ point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
+ );
+ }
+
+ private getPointRelativeToElement(element: HTMLElement, point: { x: number; y: number }) {
+ const rect = element.getBoundingClientRect();
+ return {
+ x: point.x - rect.left,
+ y: point.y - rect.top,
+ };
+ }
+
+ private computeTrackLength(scrollable: HTMLElement) {
+ return scrollable.offsetHeight - this.props.trackPadding.y * 2;
+ }
+
+ // Computes the position of child element within scrollable container
+ private computeOffsetTop(scrollable: HTMLElement, child: HTMLElement) {
+ let offsetTop = 0;
+ let node = child;
+
+ while (scrollable.contains(node)) {
+ offsetTop += node.offsetTop;
+ if (node.offsetParent) {
+ node = node.offsetParent as HTMLElement;
+ } else {
+ break;
+ }
+ }
+
+ return offsetTop;
+ }
+
+ private computeScrollTop(
+ scrollable: HTMLElement,
+ child: HTMLElement,
+ scrollPosition: ScrollPosition,
+ ) {
+ const offsetTop = this.computeOffsetTop(scrollable, child);
+
+ switch (scrollPosition) {
+ case 'top':
+ return offsetTop;
+
+ case 'bottom':
+ return offsetTop - (scrollable.offsetHeight - child.clientHeight);
+
+ case 'middle':
+ return offsetTop - (scrollable.offsetHeight - child.clientHeight) * 0.5;
+ }
+ }
+
+ private computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) {
+ // the content height of the scroll view
+ const scrollHeight = scrollable.scrollHeight;
+
+ // the visible height of the scroll view
+ const visibleHeight = scrollable.offsetHeight;
+
+ // scroll offset
+ const scrollTop = scrollable.scrollTop;
+
+ // lowest point of scrollTop
+ const maxScrollTop = scrollHeight - visibleHeight;
+
+ // calculate scroll position within 0..1 range
+ const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0;
+
+ // calculate the thumb boundary to make sure that the visual appearance of
+ // a thumb at the lowest point matches the bottom of scrollable view
+ const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight;
+
+ // calculate thumb position based on scroll progress and thumb boundary
+ // adding vertical inset to adjust the thumb's appearance
+ const thumbPosition = thumbBoundary * scrollPosition + this.props.trackPadding.y;
+
+ return {
+ x: -this.props.trackPadding.x,
+ y: thumbPosition,
+ };
+ }
+
+ private computeThumbHeight(scrollable: HTMLElement) {
+ const scrollHeight = scrollable.scrollHeight;
+ const visibleHeight = scrollable.offsetHeight;
+
+ const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight;
+
+ // ensure that the scroll thumb doesn't shrink to nano size
+ return Math.max(thumbHeight, 8);
+ }
+
+ private updateScrollbarsHelper(updateFlags: Partial<IScrollbarUpdateContext>) {
+ const scrollable = this.scrollableRef.current;
+ const thumb = this.thumbRef.current;
+ if (scrollable && thumb) {
+ this.updateScrollbars(scrollable, thumb, updateFlags);
+ }
+ }
+
+ private updateScrollbars(
+ scrollable: HTMLElement,
+ thumb: HTMLElement,
+ context: Partial<IScrollbarUpdateContext>,
+ ) {
+ if (context.size) {
+ const thumbHeight = this.computeThumbHeight(scrollable);
+ thumb.style.setProperty('height', thumbHeight + 'px');
+
+ // hide thumb when there is nothing to scroll
+ const canScroll = thumbHeight < scrollable.offsetHeight;
+ if (this.state.canScroll !== canScroll) {
+ this.setState({ canScroll });
+
+ // flash the scroll indicators when the view becomes scrollable
+ if (this.props.autoHide && canScroll) {
+ this.startAutoHide();
+ this.ensureScrollbarsVisible();
+ }
+ }
+ }
+
+ if (context.position) {
+ const { x, y } = this.computeThumbPosition(scrollable, thumb);
+ thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`);
+ }
+ }
+}
diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx
new file mode 100644
index 0000000000..cc73b1a292
--- /dev/null
+++ b/gui/src/renderer/components/HeaderBar.tsx
@@ -0,0 +1,147 @@
+import { remote } from 'electron';
+import * as React from 'react';
+import { Button, Component, Styles, Text, Types, View } from 'reactxp';
+import ImageView from './ImageView';
+
+export enum HeaderBarStyle {
+ default = 'default',
+ defaultDark = 'defaultDark',
+ error = 'error',
+ success = 'success',
+}
+
+export interface IHeaderBarProps {
+ barStyle: HeaderBarStyle;
+ style?: Types.ViewStyleRuleSet;
+}
+
+const headerBarStyles = {
+ container: {
+ base: Styles.createViewStyle({
+ paddingTop: 12,
+ paddingBottom: 12,
+ paddingLeft: 12,
+ paddingRight: 12,
+ }),
+ platformOverride: {
+ darwin: Styles.createViewStyle({
+ paddingTop: 24,
+ }),
+ linux: Styles.createViewStyle({
+ appRegion: 'drag',
+ }),
+ },
+ },
+ content: Styles.createViewStyle({
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ // the size of "brand" logo
+ minHeight: 51,
+ }),
+ barStyle: {
+ default: Styles.createViewStyle({
+ backgroundColor: 'rgb(41, 77, 115)', // colors.blue
+ }),
+ defaultDark: Styles.createViewStyle({
+ backgroundColor: 'rgb(25, 46, 69)', // colors.darkBlue
+ }),
+ error: Styles.createViewStyle({
+ backgroundColor: 'rgb(208, 2, 27)', // colors.red
+ }),
+ success: Styles.createViewStyle({
+ backgroundColor: 'rgb(68, 173, 77)', // colors.green
+ }),
+ },
+};
+
+export default class HeaderBar extends Component<IHeaderBarProps> {
+ public static defaultProps: IHeaderBarProps = {
+ barStyle: HeaderBarStyle.default,
+ };
+
+ public render() {
+ const style = [
+ headerBarStyles.container.base,
+ // @ts-ignore
+ headerBarStyles.container.platformOverride[process.platform],
+ headerBarStyles.barStyle[this.props.barStyle],
+ this.props.style,
+ ];
+
+ return (
+ <View style={style}>
+ <View style={headerBarStyles.content}>{this.props.children}</View>
+ </View>
+ );
+ }
+}
+
+const brandStyles = {
+ container: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ }),
+ title: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 24,
+ fontWeight: '900',
+ lineHeight: 30,
+ letterSpacing: -0.5,
+ color: 'rgba(255, 255, 255, 0.6)', // colors.white60
+ marginLeft: 8,
+ }),
+};
+
+export class Brand extends Component {
+ public render() {
+ return (
+ <View style={brandStyles.container}>
+ <ImageView width={50} height={50} source="logo-icon" />
+ <Text style={brandStyles.title}>{remote.app.getName().toUpperCase()}</Text>
+ </View>
+ );
+ }
+}
+
+interface ISettingsButtonProps {
+ onPress?: () => void;
+}
+
+const settingsBarButtonStyles = {
+ container: {
+ base: Styles.createViewStyle({
+ cursor: 'default',
+ padding: 0,
+ marginLeft: 8,
+ }),
+ platformOverride: {
+ linux: Styles.createViewStyle({
+ appRegion: 'no-drag',
+ }),
+ },
+ },
+};
+
+export class SettingsBarButton extends Component<ISettingsButtonProps> {
+ public render() {
+ return (
+ <Button
+ style={[
+ settingsBarButtonStyles.container.base,
+ // @ts-ignore
+ settingsBarButtonStyles.container.platformOverride[process.platform],
+ ]}
+ onPress={this.props.onPress}>
+ <ImageView
+ height={24}
+ width={24}
+ source="icon-settings"
+ tintColor={'rgba(255, 255, 255, 0.6)'}
+ tintHoverColor={'rgba(255, 255, 255, 0.8)'}
+ />
+ </Button>
+ );
+ }
+}
diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx
new file mode 100644
index 0000000000..1f6a90493a
--- /dev/null
+++ b/gui/src/renderer/components/ImageView.tsx
@@ -0,0 +1,73 @@
+import * as React from 'react';
+import { Component, Types, View } from 'reactxp';
+
+interface IProps {
+ source: string;
+ width?: number;
+ height?: number;
+ tintColor?: string;
+ tintHoverColor?: string;
+ disabled?: boolean;
+ onPress?: (event: Types.SyntheticEvent) => void;
+ style?: Types.StyleRuleSetRecursive<Types.ViewStyleRuleSet>;
+}
+
+interface IState {
+ hovered: boolean;
+}
+
+export default class ImageView extends Component<IProps, IState> {
+ public state = { hovered: false };
+
+ public render() {
+ const { source, width, height, tintColor, tintHoverColor, ...otherProps } = this.props;
+ const url = `../../assets/images/${source}.svg`;
+ let image;
+
+ const activeTintColor = (this.state.hovered && tintHoverColor) || tintColor;
+
+ if (activeTintColor) {
+ const maskWidth = typeof width === 'number' ? `${width}px` : 'auto';
+ const maskHeight = typeof height === 'number' ? `${height}px` : 'auto';
+ image = (
+ <div
+ style={{
+ WebkitMaskImage: `url('${url}')`,
+ WebkitMaskRepeat: 'no-repeat',
+ WebkitMaskSize: `${maskWidth} ${maskHeight}`,
+ backgroundColor: activeTintColor,
+ lineHeight: 0,
+ }}>
+ <img
+ src={url}
+ width={width}
+ height={height}
+ style={{
+ visibility: 'hidden',
+ }}
+ />
+ </div>
+ );
+ } else {
+ image = <img src={url} width={width} height={height} />;
+ }
+
+ return (
+ <View {...otherProps} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
+ {image}
+ </View>
+ );
+ }
+
+ private onHoverStart = () => {
+ if (!this.props.disabled) {
+ this.setState({ hovered: true });
+ }
+ };
+
+ private onHoverEnd = () => {
+ if (!this.props.disabled) {
+ this.setState({ hovered: false });
+ }
+ };
+}
diff --git a/gui/src/renderer/components/Launch.tsx b/gui/src/renderer/components/Launch.tsx
new file mode 100644
index 0000000000..72fdde9d2f
--- /dev/null
+++ b/gui/src/renderer/components/Launch.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react';
+import { Component, Styles, Text, View } from 'reactxp';
+import { colors } from '../../config.json';
+import { pgettext } from '../../shared/gettext';
+import { SettingsBarButton } from './HeaderBar';
+import ImageView from './ImageView';
+import { Container, Header, Layout } from './Layout';
+
+const styles = {
+ container: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: -150,
+ }),
+ logo: Styles.createViewStyle({
+ marginBottom: 4,
+ }),
+ title: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 24,
+ fontWeight: '900',
+ lineHeight: 30,
+ letterSpacing: -0.5,
+ color: colors.white60,
+ marginBottom: 4,
+ }),
+ subtitle: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 14,
+ lineHeight: 20,
+ color: colors.white40,
+ }),
+};
+
+interface IProps {
+ openSettings: () => void;
+}
+
+export default class Launch extends Component<IProps> {
+ public render() {
+ return (
+ <Layout>
+ <Header>
+ <SettingsBarButton onPress={this.props.openSettings} />
+ </Header>
+ <Container>
+ <View style={styles.container}>
+ <ImageView height={120} width={120} source="logo-icon" style={styles.logo} />
+ <Text style={styles.title}>{pgettext('launch-view', 'MULLVAD VPN')}</Text>
+ <Text style={styles.subtitle}>
+ {pgettext('launch-view', 'Connecting to daemon...')}
+ </Text>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+}
diff --git a/gui/src/renderer/components/Layout.tsx b/gui/src/renderer/components/Layout.tsx
new file mode 100644
index 0000000000..01f4aa701d
--- /dev/null
+++ b/gui/src/renderer/components/Layout.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { Component, View } from 'reactxp';
+import HeaderBar from './HeaderBar';
+import styles from './LayoutStyles';
+
+export class Header extends Component<HeaderBar['props']> {
+ public static defaultProps = HeaderBar.defaultProps;
+
+ public render() {
+ return (
+ <View style={[styles.header, this.props.style]}>
+ <HeaderBar barStyle={this.props.barStyle}>{this.props.children}</HeaderBar>
+ </View>
+ );
+ }
+}
+
+interface IContainerProps {
+ children: React.ReactNode;
+}
+export class Container extends Component<IContainerProps> {
+ public render() {
+ return <View style={styles.container}>{this.props.children}</View>;
+ }
+}
+
+interface ILayoutProps {
+ children: React.ReactNode;
+}
+export class Layout extends Component<ILayoutProps> {
+ public render() {
+ return <View style={styles.layout}>{this.props.children}</View>;
+ }
+}
diff --git a/gui/src/renderer/components/LayoutStyles.tsx b/gui/src/renderer/components/LayoutStyles.tsx
new file mode 100644
index 0000000000..cebe3f2588
--- /dev/null
+++ b/gui/src/renderer/components/LayoutStyles.tsx
@@ -0,0 +1,17 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ layout: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ header: Styles.createViewStyle({
+ flex: 0,
+ }),
+ container: Styles.createViewStyle({
+ flex: 1,
+ backgroundColor: colors.blue,
+ overflow: 'hidden',
+ }),
+};
diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx
new file mode 100644
index 0000000000..81b9e6f838
--- /dev/null
+++ b/gui/src/renderer/components/Login.tsx
@@ -0,0 +1,479 @@
+import * as React from 'react';
+import { Animated, Component, Styles, Text, TextInput, Types, UserInterface, View } from 'reactxp';
+import { colors, links } from '../../config.json';
+import { pgettext } from '../../shared/gettext';
+import Accordion from './Accordion';
+import * as AppButton from './AppButton';
+import * as Cell from './Cell';
+import { Brand, SettingsBarButton } from './HeaderBar';
+import ImageView from './ImageView';
+import { Container, Header, Layout } from './Layout';
+import styles from './LoginStyles';
+
+import { AccountToken } from '../../shared/daemon-rpc-types';
+import { LoginState } from '../redux/account/reducers';
+
+interface IProps {
+ accountToken?: AccountToken;
+ accountHistory: AccountToken[];
+ loginError?: Error;
+ loginState: LoginState;
+ openSettings?: () => void;
+ openExternalLink: (type: string) => void;
+ login: (accountToken: AccountToken) => void;
+ resetLoginError: () => void;
+ updateAccountToken: (accountToken: AccountToken) => void;
+ removeAccountTokenFromHistory: (accountToken: AccountToken) => Promise<void>;
+}
+
+interface IState {
+ isActive: boolean;
+}
+
+const MIN_ACCOUNT_TOKEN_LENGTH = 10;
+
+export default class Login extends Component<IProps, IState> {
+ public state: IState = {
+ isActive: true,
+ };
+
+ private accountInput = React.createRef<TextInput>();
+ private shouldResetLoginError = false;
+
+ private showsFooter = true;
+ private footerAnimatedValue = Animated.createValue(0);
+ private footerAnimation?: Types.Animated.CompositeAnimation;
+ private footerAnimationStyle: Types.AnimatedViewStyleRuleSet;
+ private footerRef = React.createRef<Animated.View>();
+
+ private isLoginButtonActive = false;
+ private loginButtonAnimatedValue = Animated.createValue(0);
+ private loginButtonAnimation?: Types.Animated.CompositeAnimation;
+ private loginButtonAnimationStyle: Types.AnimatedViewStyleRuleSet;
+
+ constructor(props: IProps) {
+ super(props);
+
+ if (props.loginState === 'failed') {
+ this.shouldResetLoginError = true;
+ }
+
+ this.footerAnimationStyle = Styles.createAnimatedViewStyle({
+ transform: [{ translateY: this.footerAnimatedValue }],
+ });
+
+ this.loginButtonAnimationStyle = Styles.createAnimatedViewStyle({
+ backgroundColor: Animated.interpolate(
+ this.loginButtonAnimatedValue,
+ [0.0, 1.0],
+ [colors.white, colors.green],
+ ),
+ });
+ }
+
+ public componentDidMount() {
+ this.setFooterVisibility(this.shouldShowFooter());
+ }
+
+ public componentDidUpdate(prevProps: IProps, _prevState: IState) {
+ if (
+ this.props.loginState !== prevProps.loginState &&
+ this.props.loginState === 'failed' &&
+ !this.shouldResetLoginError
+ ) {
+ this.shouldResetLoginError = true;
+
+ // focus on login field when failed to log in
+ const accountInput = this.accountInput.current;
+ if (accountInput) {
+ accountInput.focus();
+ }
+ }
+
+ this.setLoginButtonActive(this.shouldActivateLoginButton());
+ this.setFooterVisibility(this.shouldShowFooter());
+ }
+
+ public render() {
+ return (
+ <Layout>
+ <Header>
+ <Brand />
+ <SettingsBarButton onPress={this.props.openSettings} />
+ </Header>
+ <Container>
+ <View style={styles.login_form}>
+ {this.getStatusIcon()}
+ <Text style={styles.title}>{this.formTitle()}</Text>
+
+ {this.createLoginForm()}
+ </View>
+
+ <Animated.View
+ ref={this.footerRef}
+ style={[styles.login_footer, this.footerAnimationStyle]}>
+ {this.createFooter()}
+ </Animated.View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private onCreateAccount = () => this.props.openExternalLink(links.createAccount);
+
+ private onFocus = () => {
+ this.setState({ isActive: true });
+ };
+
+ private onBlur = (e: Types.SyntheticEvent) => {
+ // TOOD: relatedTarget is not exposed by ReactXP and may not work on non-web platforms.
+ // Find a workaround.
+ // @ts-ignore
+ const relatedTarget = e.relatedTarget;
+
+ // restore focus if click happened within dropdown
+ if (relatedTarget) {
+ if (this.accountInput.current) {
+ this.accountInput.current.focus();
+ }
+ return;
+ }
+
+ this.setState({ isActive: false });
+ };
+
+ private async setLoginButtonActive(isActive: boolean) {
+ if (this.isLoginButtonActive === isActive) {
+ return;
+ }
+
+ const animation = Animated.timing(this.loginButtonAnimatedValue, {
+ toValue: isActive ? 1 : 0,
+ easing: Animated.Easing.Linear(),
+ duration: 250,
+ });
+
+ const oldAnimation = this.loginButtonAnimation;
+ if (oldAnimation) {
+ oldAnimation.stop();
+ }
+
+ animation.start();
+
+ this.loginButtonAnimation = animation;
+ this.isLoginButtonActive = isActive;
+ }
+
+ private async setFooterVisibility(show: boolean) {
+ if (this.showsFooter === show || !this.footerRef.current) {
+ return;
+ }
+
+ this.showsFooter = show;
+
+ const layout = await UserInterface.measureLayoutRelativeToWindow(this.footerRef.current);
+ const value = show ? 0 : layout.height;
+
+ const animation = Animated.timing(this.footerAnimatedValue, {
+ toValue: value,
+ easing: Animated.Easing.InOut(),
+ duration: 250,
+ });
+
+ const oldAnimation = this.footerAnimation;
+ if (oldAnimation) {
+ oldAnimation.stop();
+ }
+
+ animation.start();
+
+ this.footerAnimation = animation;
+ }
+
+ private onSubmit = () => {
+ const accountToken = this.props.accountToken;
+ if (accountToken && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH) {
+ this.props.login(accountToken);
+ }
+ };
+
+ private onInputChange = (value: string) => {
+ // reset error when user types in the new account number
+ if (this.shouldResetLoginError) {
+ this.shouldResetLoginError = false;
+ this.props.resetLoginError();
+ }
+
+ const accountToken = value.replace(/[^0-9]/g, '');
+
+ this.props.updateAccountToken(accountToken);
+ };
+
+ private formTitle() {
+ switch (this.props.loginState) {
+ case 'logging in':
+ return pgettext('login-view', 'Logging in...');
+ case 'failed':
+ return pgettext('login-view', 'Login failed');
+ case 'ok':
+ return pgettext('login-view', 'Logged in');
+ default:
+ return pgettext('login-view', 'Login');
+ }
+ }
+
+ private formSubtitle() {
+ const { loginState, loginError } = this.props;
+ switch (loginState) {
+ case 'failed':
+ return (loginError && loginError.message) || pgettext('login-view', 'Unknown error');
+ case 'logging in':
+ return pgettext('login-view', 'Checking account number');
+ case 'ok':
+ return pgettext('login-view', 'Correct account number');
+ default:
+ return pgettext('login-view', 'Enter your account number');
+ }
+ }
+
+ private getStatusIcon() {
+ const statusIconPath = this.getStatusIconPath();
+ return (
+ <View style={styles.status_icon}>
+ {statusIconPath ? <ImageView source={statusIconPath} height={48} width={48} /> : null}
+ </View>
+ );
+ }
+
+ private getStatusIconPath(): string | undefined {
+ switch (this.props.loginState) {
+ case 'logging in':
+ return 'icon-spinner';
+ case 'failed':
+ return 'icon-fail';
+ case 'ok':
+ return 'icon-success';
+ default:
+ return undefined;
+ }
+ }
+
+ private accountInputGroupStyles(): Types.ViewStyleRuleSet[] {
+ const classes = [styles.account_input_group];
+ if (this.state.isActive) {
+ classes.push(styles.account_input_group__active);
+ }
+
+ switch (this.props.loginState) {
+ case 'logging in':
+ case 'ok':
+ classes.push(styles.account_input_group__inactive);
+ break;
+ case 'failed':
+ classes.push(styles.account_input_group__error);
+ break;
+ }
+
+ return classes;
+ }
+
+ private accountInputButtonStyles() {
+ const classes: Array<
+ Types.StyleRuleSet<Types.AnimatedViewStyle> | Types.StyleRuleSet<Types.ViewStyle>
+ > = [styles.input_button];
+
+ if (this.props.loginState === 'logging in' || this.props.loginState === 'ok') {
+ classes.push(styles.input_button__invisible);
+ }
+
+ classes.push(this.loginButtonAnimationStyle);
+
+ return classes;
+ }
+
+ private accountInputArrowStyles(): Types.ViewStyleRuleSet[] {
+ const { loginState } = this.props;
+ const classes = [styles.input_arrow];
+
+ if (loginState === 'logging in') {
+ classes.push(styles.input_arrow__invisible);
+ }
+
+ return classes;
+ }
+
+ private shouldActivateLoginButton(): boolean {
+ const { accountToken } = this.props;
+ if (accountToken && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH) {
+ return true;
+ }
+ return false;
+ }
+
+ private shouldEnableAccountInput() {
+ // enable account input always except when "logging in" or "logged in"
+ return this.props.loginState !== 'logging in' && this.props.loginState !== 'ok';
+ }
+
+ private shouldShowAccountHistory() {
+ return (
+ this.shouldEnableAccountInput() && this.state.isActive && this.props.accountHistory.length > 0
+ );
+ }
+
+ private shouldShowFooter() {
+ return (
+ (this.props.loginState === 'none' || this.props.loginState === 'failed') &&
+ !this.shouldShowAccountHistory()
+ );
+ }
+
+ private onSelectAccountFromHistory = (accountToken: string) => {
+ this.props.updateAccountToken(accountToken);
+ this.props.login(accountToken);
+ };
+
+ private onRemoveAccountFromHistory = (accountToken: string) => {
+ this.removeAccountFromHistory(accountToken);
+ };
+
+ private async removeAccountFromHistory(accountToken: AccountToken) {
+ try {
+ await this.props.removeAccountTokenFromHistory(accountToken);
+
+ // TODO: Remove account from memory
+ } catch (error) {
+ // TODO: Show error
+ }
+ }
+
+ private createLoginForm() {
+ return (
+ <View>
+ <Text style={styles.subtitle}>{this.formSubtitle()}</Text>
+ <View style={this.accountInputGroupStyles()}>
+ <View style={styles.account_input_backdrop}>
+ <TextInput
+ style={styles.account_input_textfield}
+ placeholder="0000 0000 0000 0000"
+ placeholderTextColor={colors.blue40}
+ value={this.props.accountToken || ''}
+ autoCorrect={false}
+ editable={this.shouldEnableAccountInput()}
+ onFocus={this.onFocus}
+ onBlur={this.onBlur}
+ onChangeText={this.onInputChange}
+ onSubmitEditing={this.onSubmit}
+ returnKeyType="done"
+ keyboardType="numeric"
+ autoFocus={true}
+ ref={this.accountInput}
+ />
+ <Animated.View style={this.accountInputButtonStyles()} onPress={this.onSubmit}>
+ <ImageView
+ style={this.accountInputArrowStyles()}
+ source="icon-arrow"
+ height={16}
+ width={24}
+ tintColor="rgb(255, 255, 255)"
+ />
+ </Animated.View>
+ </View>
+ <Accordion expanded={this.shouldShowAccountHistory()}>
+ {
+ <AccountDropdown
+ items={this.props.accountHistory.slice().reverse()}
+ onSelect={this.onSelectAccountFromHistory}
+ onRemove={this.onRemoveAccountFromHistory}
+ />
+ }
+ </Accordion>
+ </View>
+ </View>
+ );
+ }
+
+ private createFooter() {
+ return (
+ <View>
+ <Text style={styles.login_footer__prompt}>
+ {pgettext('login-view', "Don't have an account number?")}
+ </Text>
+ <AppButton.BlueButton onPress={this.onCreateAccount}>
+ <AppButton.Label>{pgettext('login-view', 'Create account')}</AppButton.Label>
+ <AppButton.Icon source="icon-extLink" height={16} width={16} />
+ </AppButton.BlueButton>
+ </View>
+ );
+ }
+}
+
+interface IAccountDropdownProps {
+ items: AccountToken[];
+ onSelect: (value: AccountToken) => void;
+ onRemove: (value: AccountToken) => void;
+}
+
+class AccountDropdown extends Component<IAccountDropdownProps> {
+ public render() {
+ const uniqueItems = [...new Set(this.props.items)];
+ return (
+ <View>
+ {uniqueItems.map((token) => (
+ <AccountDropdownItem
+ key={token}
+ value={token}
+ label={token}
+ onSelect={this.props.onSelect}
+ onRemove={this.props.onRemove}
+ />
+ ))}
+ </View>
+ );
+ }
+}
+
+interface IAccountDropdownItemProps {
+ label: string;
+ value: AccountToken;
+ onRemove: (value: AccountToken) => void;
+ onSelect: (value: AccountToken) => void;
+}
+
+class AccountDropdownItem extends Component<IAccountDropdownItemProps> {
+ public render() {
+ return (
+ <View>
+ <View style={styles.account_dropdown__spacer} />
+ <Cell.CellButton
+ style={styles.account_dropdown__item}
+ cellHoverStyle={styles.account_dropdown__item_hover}>
+ <Cell.Label
+ textStyle={styles.account_dropdown__label}
+ containerStyle={styles.account_dropdown__label_container}
+ cellHoverTextStyle={styles.account_dropdown__label_hover}
+ onPress={this.handleSelect}>
+ {this.props.label}
+ </Cell.Label>
+ <ImageView
+ style={styles.account_dropdown__remove}
+ tintColor={colors.blue40}
+ tintHoverColor={colors.blue}
+ source="icon-close-sml"
+ height={16}
+ width={16}
+ onPress={this.handleRemove}
+ />
+ </Cell.CellButton>
+ </View>
+ );
+ }
+
+ private handleSelect = () => {
+ this.props.onSelect(this.props.value);
+ };
+
+ private handleRemove = () => {
+ this.props.onRemove(this.props.value);
+ };
+}
diff --git a/gui/src/renderer/components/LoginStyles.tsx b/gui/src/renderer/components/LoginStyles.tsx
new file mode 100644
index 0000000000..53cf650c99
--- /dev/null
+++ b/gui/src/renderer/components/LoginStyles.tsx
@@ -0,0 +1,156 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ login_footer: Styles.createViewStyle({
+ flex: 0,
+ paddingTop: 16,
+ paddingBottom: 24,
+ paddingLeft: 24,
+ paddingRight: 24,
+ backgroundColor: colors.darkBlue,
+ }),
+ status_icon: Styles.createViewStyle({
+ flex: 0,
+ marginBottom: 30,
+ alignItems: 'center',
+ height: 48,
+ }),
+ login_form: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'column',
+ overflow: 'visible',
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingLeft: 24,
+ paddingRight: 24,
+ marginTop: 83,
+ marginBottom: 0,
+ marginRight: 0,
+ marginLeft: 0,
+ }),
+ account_input_group: Styles.createViewStyle({
+ borderWidth: 2,
+ borderRadius: 8,
+ borderColor: 'transparent',
+ }),
+ account_input_group__active: Styles.createViewStyle({
+ borderColor: colors.darkBlue,
+ }),
+ account_input_group__inactive: Styles.createViewStyle({
+ opacity: 0.6,
+ }),
+ account_input_group__error: Styles.createViewStyle({
+ borderColor: colors.red40,
+ }),
+ account_input_backdrop: Styles.createViewStyle({
+ backgroundColor: colors.white,
+ borderColor: colors.darkBlue,
+ flexDirection: 'row',
+ }),
+ input_button: Styles.createViewStyle({
+ flex: 0,
+ borderWidth: 0,
+ width: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ }),
+ input_button__invisible: Styles.createViewStyle({
+ backgroundColor: colors.white,
+ opacity: 0,
+ }),
+ input_arrow: Styles.createViewStyle({
+ flex: 0,
+ borderWidth: 0,
+ width: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ }),
+ input_arrow__invisible: Styles.createViewStyle({
+ opacity: 0,
+ }),
+ account_dropdown__spacer: Styles.createViewStyle({
+ height: 1,
+ backgroundColor: colors.darkBlue,
+ }),
+ account_dropdown__item: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingRight: 0,
+ paddingLeft: 0,
+ paddingBottom: 0,
+ marginBottom: 0,
+ flexDirection: 'row',
+ alignItems: 'stretch',
+ backgroundColor: colors.white60,
+ cursor: 'default',
+ }),
+ account_dropdown__item_hover: Styles.createViewStyle({
+ backgroundColor: colors.white40,
+ }),
+ account_dropdown__remove: Styles.createViewStyle({
+ justifyContent: 'center',
+ paddingTop: 10,
+ paddingRight: 12,
+ paddingBottom: 12,
+ paddingLeft: 12,
+ marginLeft: 0,
+ }),
+ account_dropdown__label_hover: Styles.createTextStyle({
+ color: colors.blue,
+ }),
+ account_dropdown__label_container: Styles.createViewStyle({
+ marginLeft: 12,
+ marginTop: 11,
+ marginBottom: 11,
+ }),
+
+ login_footer__prompt: Styles.createTextStyle({
+ color: colors.white80,
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 18,
+ letterSpacing: -0.2,
+ marginBottom: 8,
+ }),
+ title: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 32,
+ fontWeight: '900',
+ lineHeight: 44,
+ letterSpacing: -0.7,
+ color: colors.white,
+ marginBottom: 7,
+ flex: 0,
+ }),
+ subtitle: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ lineHeight: 15,
+ fontWeight: '600',
+ letterSpacing: -0.2,
+ color: colors.white80,
+ marginBottom: 8,
+ }),
+ account_input_textfield: Styles.createTextInputStyle({
+ borderWidth: 0,
+ paddingTop: 10,
+ paddingRight: 12,
+ paddingLeft: 12,
+ paddingBottom: 12,
+ fontFamily: 'DINPro',
+ fontSize: 20,
+ fontWeight: '900',
+ lineHeight: 26,
+ color: colors.blue,
+ backgroundColor: 'transparent',
+ flex: 1,
+ }),
+ account_dropdown__label: Styles.createTextStyle({
+ color: colors.blue80,
+ borderWidth: 0,
+ textAlign: 'left',
+ marginLeft: 0,
+ cursor: 'default',
+ }),
+};
diff --git a/gui/src/renderer/components/Map.tsx b/gui/src/renderer/components/Map.tsx
new file mode 100644
index 0000000000..4fc77cb739
--- /dev/null
+++ b/gui/src/renderer/components/Map.tsx
@@ -0,0 +1,106 @@
+import * as React from 'react';
+import { Component, Types, View } from 'reactxp';
+
+import SvgMap from './SvgMap';
+
+export enum ZoomLevel {
+ high,
+ medium,
+ low,
+}
+
+export enum MarkerStyle {
+ secure,
+ unsecure,
+}
+
+interface IProps {
+ center: [number, number]; // longitude, latitude
+ offset: [number, number]; // offset [x, y] from the center of the map
+ zoomLevel: ZoomLevel;
+ showMarker: boolean;
+ markerStyle: MarkerStyle;
+ style?: Types.StyleRuleSetRecursive<Types.ViewStyleRuleSet>;
+}
+
+interface IState {
+ bounds: {
+ width: number;
+ height: number;
+ };
+}
+
+export default class Map extends Component<IProps, IState> {
+ public state: IState = {
+ bounds: {
+ width: 0,
+ height: 0,
+ },
+ };
+
+ public render() {
+ const { width, height } = this.state.bounds;
+ const readyToRenderTheMap = width > 0 && height > 0;
+ return (
+ <View style={this.props.style} onLayout={this.onLayout}>
+ {readyToRenderTheMap && (
+ <SvgMap
+ width={width}
+ height={height}
+ center={this.props.center}
+ offset={this.props.offset}
+ zoomLevel={this.zoomLevel(this.props.zoomLevel)}
+ showMarker={this.props.showMarker}
+ markerImagePath={this.markerImage(this.props.markerStyle)}
+ />
+ )}
+ </View>
+ );
+ }
+
+ public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
+ const oldProps = this.props;
+ const oldState = this.state;
+ return (
+ oldProps.center[0] !== nextProps.center[0] ||
+ oldProps.center[1] !== nextProps.center[1] ||
+ oldProps.offset[0] !== nextProps.offset[0] ||
+ oldProps.offset[1] !== nextProps.offset[1] ||
+ oldProps.zoomLevel !== nextProps.zoomLevel ||
+ oldProps.showMarker !== nextProps.showMarker ||
+ oldProps.markerStyle !== nextProps.markerStyle ||
+ oldState.bounds.width !== nextState.bounds.width ||
+ oldState.bounds.height !== nextState.bounds.height
+ );
+ }
+
+ private onLayout = (layoutInfo: Types.ViewOnLayoutEvent) => {
+ this.setState({
+ bounds: {
+ width: layoutInfo.width,
+ height: layoutInfo.height,
+ },
+ });
+ };
+
+ // TODO: Remove zoom level in favor of center + coordinate span
+ private zoomLevel(variant: ZoomLevel) {
+ switch (variant) {
+ case ZoomLevel.high:
+ return 1;
+ case ZoomLevel.medium:
+ return 20;
+ case ZoomLevel.low:
+ return 40;
+ }
+ }
+
+ private markerImage(style: MarkerStyle): string {
+ switch (style) {
+ case MarkerStyle.secure:
+ return '../../assets/images/location-marker-secure.svg';
+ case MarkerStyle.unsecure:
+ return '../../assets/images/location-marker-unsecure.svg';
+ }
+ }
+}
diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx
new file mode 100644
index 0000000000..5742e68f2b
--- /dev/null
+++ b/gui/src/renderer/components/Modal.tsx
@@ -0,0 +1,52 @@
+import * as React from 'react';
+
+export class ModalContent extends React.Component {
+ public render() {
+ return (
+ <div
+ style={{
+ position: 'absolute',
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ }}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+export class ModalAlert extends React.Component {
+ public render() {
+ return (
+ <div
+ style={{
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ position: 'absolute',
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ }}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+interface IModalContainerProps {
+ children?: React.ReactNode;
+}
+
+export class ModalContainer extends React.Component<IModalContainerProps> {
+ public render() {
+ return <div style={{ position: 'relative', flex: 1 }}>{this.props.children}</div>;
+ }
+}
diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx
new file mode 100644
index 0000000000..6bec03ca80
--- /dev/null
+++ b/gui/src/renderer/components/NavigationBar.tsx
@@ -0,0 +1,414 @@
+import * as React from 'react';
+import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp';
+import { colors } from '../../config.json';
+import CustomScrollbars, { IScrollEvent } from './CustomScrollbars';
+import ImageView from './ImageView';
+
+const styles = {
+ navigationBar: {
+ default: Styles.createViewStyle({
+ flex: 0,
+ flexDirection: 'row',
+ paddingHorizontal: 12,
+ paddingBottom: 12,
+ }),
+ separator: Styles.createViewStyle({
+ borderStyle: 'solid',
+ borderBottomWidth: 1,
+ borderColor: 'rgba(0, 0, 0, 0.2)',
+ }),
+ darwin: Styles.createViewStyle({
+ paddingTop: 24,
+ }),
+ win32: Styles.createViewStyle({
+ paddingTop: 12,
+ }),
+ linux: Styles.createViewStyle({
+ paddingTop: 12,
+ appRegion: 'drag',
+ }),
+ },
+ navigationBarTitle: {
+ container: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'column',
+ justifyContent: 'center',
+ }),
+ label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ fontWeight: '600',
+ lineHeight: 22,
+ color: colors.white,
+ alignSelf: 'center',
+ }),
+ },
+ closeBarItem: {
+ default: Styles.createViewStyle({
+ cursor: 'default',
+ appRegion: 'no-drag',
+ }),
+ icon: Styles.createViewStyle({
+ flex: 0,
+ opacity: 0.6,
+ }),
+ },
+ backBarButton: {
+ default: Styles.createViewStyle({
+ borderWidth: 0,
+ padding: 0,
+ margin: 0,
+ cursor: 'default',
+ appRegion: 'no-drag',
+ }),
+ content: Styles.createViewStyle({
+ flexDirection: 'row',
+ alignItems: 'center',
+ }),
+ label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.white60,
+ }),
+ icon: Styles.createViewStyle({
+ opacity: 0.6,
+ marginRight: 8,
+ }),
+ },
+};
+
+interface INavigationScrollContextValue {
+ scrollTop: number;
+ onScroll: (event: IScrollEvent) => void;
+}
+
+const NavigationScrollContext = React.createContext<INavigationScrollContextValue>({
+ scrollTop: 0,
+ onScroll: (_event: IScrollEvent) => {
+ // no-op
+ },
+});
+
+export class NavigationContainer extends Component {
+ public state = {
+ scrollTop: 0,
+ };
+
+ public render() {
+ return (
+ <NavigationScrollContext.Provider
+ value={{ scrollTop: this.state.scrollTop, onScroll: this.onScroll }}>
+ {this.props.children}
+ </NavigationScrollContext.Provider>
+ );
+ }
+
+ private onScroll = (event: IScrollEvent) => {
+ this.setState({
+ scrollTop: event.scrollTop,
+ });
+ };
+}
+
+interface INavigationScrollbarsProps {
+ onScroll?: (value: IScrollEvent) => void;
+ style?: React.CSSProperties;
+ children?: React.ReactNode;
+}
+export const NavigationScrollbars = React.forwardRef(function NavigationScrollbarsT(
+ props: INavigationScrollbarsProps,
+ ref?: React.Ref<CustomScrollbars>,
+) {
+ return (
+ <NavigationScrollContext.Consumer>
+ {(context) => {
+ const { style, children, ...otherProps } = props;
+ const wrappedOnScroll = (scroll: IScrollEvent) => {
+ context.onScroll(scroll);
+
+ if (otherProps.onScroll) {
+ otherProps.onScroll(scroll);
+ }
+ };
+
+ return (
+ <CustomScrollbars ref={ref} style={style} onScroll={wrappedOnScroll}>
+ {children}
+ </CustomScrollbars>
+ );
+ }}
+ </NavigationScrollContext.Consumer>
+ );
+});
+
+interface IPrivateTitleBarItemProps {
+ visible: boolean;
+ titleAdjustment: number;
+ children?: React.ReactText;
+}
+
+class PrivateTitleBarItem extends Component<IPrivateTitleBarItemProps> {
+ public shouldComponentUpdate(nextProps: IPrivateTitleBarItemProps) {
+ return (
+ this.props.visible !== nextProps.visible ||
+ this.props.titleAdjustment !== nextProps.titleAdjustment ||
+ this.props.children !== nextProps.children
+ );
+ }
+
+ public render() {
+ const titleAdjustment = this.props.titleAdjustment;
+ const titleAdjustmentStyle = Styles.createViewStyle(
+ {
+ paddingRight: titleAdjustment > 0 ? titleAdjustment : 0,
+ paddingLeft: titleAdjustment < 0 ? Math.abs(titleAdjustment) : 0,
+ },
+ false,
+ );
+
+ return (
+ <View style={[styles.navigationBarTitle.container, titleAdjustmentStyle]}>
+ <PrivateBarItemAnimationContainer visible={this.props.visible}>
+ <Text style={styles.navigationBarTitle.label}>{this.props.children}</Text>
+ </PrivateBarItemAnimationContainer>
+ </View>
+ );
+ }
+}
+
+interface IPrivateBarItemAnimationContainerProps {
+ visible: boolean;
+ children?: React.ReactNode;
+}
+
+class PrivateBarItemAnimationContainer extends Component<IPrivateBarItemAnimationContainerProps> {
+ private opacityValue: Animated.Value;
+ private opacityStyle: Types.AnimatedViewStyleRuleSet;
+ private animation?: Types.Animated.CompositeAnimation;
+
+ constructor(props: IPrivateBarItemAnimationContainerProps) {
+ super(props);
+
+ this.opacityValue = Animated.createValue(props.visible ? 1 : 0);
+ this.opacityStyle = Styles.createAnimatedViewStyle({
+ opacity: this.opacityValue,
+ });
+ }
+
+ public shouldComponentUpdate(nextProps: IPrivateBarItemAnimationContainerProps) {
+ return this.props.visible !== nextProps.visible || this.props.children !== nextProps.children;
+ }
+
+ public componentDidUpdate() {
+ this.animateOpacity(this.props.visible);
+ }
+
+ public componentWillUnmount() {
+ if (this.animation) {
+ this.animation.stop();
+ }
+ }
+
+ public render() {
+ return <Animated.View style={this.opacityStyle}>{this.props.children}</Animated.View>;
+ }
+
+ private animateOpacity(visible: boolean) {
+ const oldAnimation = this.animation;
+ if (oldAnimation) {
+ oldAnimation.stop();
+ }
+
+ const animation = Animated.timing(this.opacityValue, {
+ toValue: visible ? 1 : 0,
+ easing: Animated.Easing.InOut(),
+ duration: 250,
+ });
+
+ animation.start();
+
+ this.animation = animation;
+ }
+}
+
+interface INavigationBarProps {
+ children?: React.ReactNode;
+}
+
+export const NavigationBar = React.forwardRef(function NavigationBarT(
+ props: INavigationBarProps,
+ ref?: React.Ref<PrivateNavigationBar>,
+) {
+ return (
+ <NavigationScrollContext.Consumer>
+ {(context) => (
+ <PrivateNavigationBar ref={ref} scrollTop={context.scrollTop}>
+ {props.children}
+ </PrivateNavigationBar>
+ )}
+ </NavigationScrollContext.Consumer>
+ );
+});
+
+interface IPrivateNavigationBarProps {
+ scrollTop: number;
+ children?: React.ReactNode;
+}
+
+interface IPrivateNavigationBarState {
+ titleAdjustment: number;
+ showsBarSeparator: boolean;
+ showsBarTitle: boolean;
+}
+
+const PrivateTitleBarItemContext = React.createContext({
+ titleAdjustment: 0,
+ visible: false,
+ titleRef: React.createRef<PrivateTitleBarItem>(),
+});
+
+class PrivateNavigationBar extends Component<
+ IPrivateNavigationBarProps,
+ IPrivateNavigationBarState
+> {
+ public static defaultProps: Partial<IPrivateNavigationBarProps> = {
+ scrollTop: 0,
+ };
+
+ public static getDerivedStateFromProps(
+ props: IPrivateNavigationBarProps,
+ state: IPrivateNavigationBarState,
+ ) {
+ // that's where SettingsHeader.HeaderTitle intersects the navigation bar
+ const showsBarSeparator = props.scrollTop > 11;
+
+ // that's when SettingsHeader.HeaderTitle goes behind the navigation bar
+ const showsBarTitle = props.scrollTop > 30;
+
+ return {
+ ...state,
+ showsBarSeparator,
+ showsBarTitle,
+ };
+ }
+
+ public state: IPrivateNavigationBarState = {
+ titleAdjustment: 0,
+ showsBarSeparator: false,
+ showsBarTitle: false,
+ };
+
+ private titleViewRef = React.createRef<PrivateTitleBarItem>();
+
+ public shouldComponentUpdate(
+ nextProps: IPrivateNavigationBarProps,
+ nextState: IPrivateNavigationBarState,
+ ) {
+ return (
+ this.props.children !== nextProps.children ||
+ this.state.titleAdjustment !== nextState.titleAdjustment ||
+ this.state.showsBarSeparator !== nextState.showsBarSeparator ||
+ this.state.showsBarTitle !== nextState.showsBarTitle
+ );
+ }
+
+ public render() {
+ return (
+ <View
+ style={[
+ styles.navigationBar.default,
+ this.state.showsBarSeparator ? styles.navigationBar.separator : undefined,
+ this.getPlatformStyle(),
+ ]}
+ onLayout={this.onLayout}>
+ <PrivateTitleBarItemContext.Provider
+ value={{
+ titleAdjustment: this.state.titleAdjustment,
+ visible: this.state.showsBarTitle,
+ titleRef: this.titleViewRef,
+ }}>
+ {this.props.children}
+ </PrivateTitleBarItemContext.Provider>
+ </View>
+ );
+ }
+
+ private getPlatformStyle(): Types.ViewStyleRuleSet | undefined {
+ switch (process.platform) {
+ case 'darwin':
+ return styles.navigationBar.darwin;
+ case 'win32':
+ return styles.navigationBar.win32;
+ case 'linux':
+ return styles.navigationBar.linux;
+ default:
+ return undefined;
+ }
+ }
+
+ private onLayout = async (containerLayout: Types.ViewOnLayoutEvent) => {
+ const titleView = this.titleViewRef.current;
+ if (titleView) {
+ // calculate the title layout frame
+ const titleLayout = await UserInterface.measureLayoutRelativeToAncestor(titleView, this);
+
+ // calculate the remaining space at the right hand side
+ const trailingSpace = containerLayout.width - (titleLayout.x + titleLayout.width);
+
+ this.setState({
+ titleAdjustment: titleLayout.x - trailingSpace,
+ });
+ }
+ };
+}
+
+interface ITitleBarItemProps {
+ children?: React.ReactText;
+}
+export function TitleBarItem(props: ITitleBarItemProps) {
+ return (
+ <PrivateTitleBarItemContext.Consumer>
+ {(context) => (
+ <PrivateTitleBarItem
+ titleAdjustment={context.titleAdjustment}
+ visible={context.visible}
+ ref={context.titleRef}>
+ {props.children}
+ </PrivateTitleBarItem>
+ )}
+ </PrivateTitleBarItemContext.Consumer>
+ );
+}
+
+interface ICloseBarItemProps {
+ action: () => void;
+}
+
+export class CloseBarItem extends Component<ICloseBarItemProps> {
+ public render() {
+ return (
+ <Button style={[styles.closeBarItem.default]} onPress={this.props.action}>
+ <ImageView height={24} width={24} style={[styles.closeBarItem.icon]} source="icon-close" />
+ </Button>
+ );
+ }
+}
+
+interface IBackBarItemProps {
+ children?: React.ReactText;
+ action: () => void;
+}
+
+export class BackBarItem extends Component<IBackBarItemProps> {
+ public render() {
+ return (
+ <Button style={styles.backBarButton.default} onPress={this.props.action}>
+ <View style={styles.backBarButton.content}>
+ <ImageView style={styles.backBarButton.icon} source="icon-back" />
+ <Text style={styles.backBarButton.label}>{this.props.children}</Text>
+ </View>
+ </Button>
+ );
+ }
+}
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
new file mode 100644
index 0000000000..543ba90edf
--- /dev/null
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -0,0 +1,295 @@
+import moment from 'moment';
+import * as React from 'react';
+import { Component, Types } from 'reactxp';
+import { sprintf } from 'sprintf-js';
+import { links } from '../../config.json';
+import { pgettext } from '../../shared/gettext';
+import {
+ NotificationActions,
+ NotificationBanner,
+ NotificationContent,
+ NotificationIndicator,
+ NotificationOpenLinkAction,
+ NotificationSubtitle,
+ NotificationTitle,
+} from './NotificationBanner';
+
+import { BlockReason, TunnelStateTransition } from '../../shared/daemon-rpc-types';
+import AccountExpiry from '../lib/account-expiry';
+import { AuthFailureError } from '../lib/auth-failure';
+import { IVersionReduxState } from '../redux/version/reducers';
+
+interface IProps {
+ style?: Types.ViewStyleRuleSet;
+ accountExpiry?: AccountExpiry;
+ tunnelState: TunnelStateTransition;
+ version: IVersionReduxState;
+ openExternalLink: (url: string) => void;
+ blockWhenDisconnected: boolean;
+}
+
+type NotificationAreaPresentation =
+ | { type: 'failure-unsecured'; reason: string }
+ | { type: 'blocking'; reason: string }
+ | { type: 'inconsistent-version' }
+ | { type: 'unsupported-version'; upgradeVersion: string }
+ | { type: 'update-available'; upgradeVersion: string }
+ | { type: 'expires-soon'; timeLeft: string };
+
+type State = NotificationAreaPresentation & {
+ visible: boolean;
+};
+
+function getBlockReasonMessage(blockReason: BlockReason): string {
+ switch (blockReason.reason) {
+ case 'auth_failed': {
+ return new AuthFailureError(blockReason.details).message;
+ }
+ case 'ipv6_unavailable':
+ return pgettext(
+ 'in-app-notifications',
+ 'Could not configure IPv6, please enable it on your system or disable it in the app',
+ );
+ case 'set_firewall_policy_error':
+ return pgettext(
+ 'in-app-notifications',
+ 'Failed to apply firewall rules. The device might currently be unsecured',
+ );
+ case 'set_dns_error':
+ return pgettext('in-app-notifications', 'Failed to set system DNS server');
+ case 'start_tunnel_error':
+ return pgettext('in-app-notifications', 'Failed to start tunnel connection');
+ case 'no_matching_relay':
+ return pgettext('in-app-notifications', 'No relay server matches the current settings');
+ case 'is_offline':
+ return pgettext(
+ 'in-app-notifications',
+ 'This device is offline, no tunnels can be established',
+ );
+ case 'tap_adapter_problem':
+ return pgettext(
+ 'in-app-notifications',
+ "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app",
+ );
+ }
+}
+
+function capitalizeFirstLetter(inputString: string): string {
+ return inputString.charAt(0).toUpperCase() + inputString.slice(1);
+}
+
+export default class NotificationArea extends Component<IProps, State> {
+ public static getDerivedStateFromProps(props: IProps, state: State) {
+ const { accountExpiry, blockWhenDisconnected, tunnelState, version } = props;
+
+ switch (tunnelState.state) {
+ case 'connecting':
+ return {
+ visible: true,
+ type: 'blocking',
+ reason: '',
+ };
+
+ case 'blocked':
+ switch (tunnelState.details.reason) {
+ case 'set_firewall_policy_error':
+ return {
+ visible: true,
+ type: 'failure-unsecured',
+ reason: getBlockReasonMessage(tunnelState.details),
+ };
+ default:
+ return {
+ visible: true,
+ type: 'blocking',
+ reason: getBlockReasonMessage(tunnelState.details),
+ };
+ }
+
+ case 'disconnecting':
+ if (tunnelState.details === 'reconnect') {
+ return {
+ visible: true,
+ type: 'blocking',
+ reason: '',
+ };
+ }
+ // fallthrough
+
+ case 'disconnected':
+ if (blockWhenDisconnected) {
+ return {
+ visible: true,
+ type: 'blocking',
+ reason: '',
+ };
+ }
+ // fallthrough
+
+ default:
+ if (!version.consistent) {
+ return {
+ visible: true,
+ type: 'inconsistent-version',
+ };
+ }
+
+ if (!version.currentIsSupported && version.nextUpgrade) {
+ return {
+ visible: true,
+ type: 'unsupported-version',
+ upgradeVersion: version.nextUpgrade,
+ };
+ }
+
+ if (!version.upToDate && version.nextUpgrade) {
+ return {
+ visible: true,
+ type: 'update-available',
+ upgradeVersion: version.nextUpgrade,
+ };
+ }
+
+ if (accountExpiry && accountExpiry.willHaveExpiredIn(moment().add(3, 'days'))) {
+ return {
+ visible: true,
+ type: 'expires-soon',
+ timeLeft: capitalizeFirstLetter(accountExpiry.remainingTime()),
+ };
+ }
+
+ return {
+ ...state,
+ visible: false,
+ };
+ }
+ }
+
+ public state: State = {
+ type: 'blocking',
+ reason: '',
+ visible: false,
+ };
+
+ public render() {
+ return (
+ <NotificationBanner style={this.props.style} visible={this.state.visible}>
+ {this.state.type === 'failure-unsecured' && (
+ <React.Fragment>
+ <NotificationIndicator type={'error'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'FAILURE - UNSECURED')}
+ </NotificationTitle>
+ <NotificationSubtitle>{this.state.reason}</NotificationSubtitle>
+ </NotificationContent>
+ </React.Fragment>
+ )}
+
+ {this.state.type === 'blocking' && (
+ <React.Fragment>
+ <NotificationIndicator type={'error'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'BLOCKING INTERNET')}
+ </NotificationTitle>
+ <NotificationSubtitle>{this.state.reason}</NotificationSubtitle>
+ </NotificationContent>
+ </React.Fragment>
+ )}
+
+ {this.state.type === 'inconsistent-version' && (
+ <React.Fragment>
+ <NotificationIndicator type={'error'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'INCONSISTENT VERSION')}
+ </NotificationTitle>
+ <NotificationSubtitle>
+ {pgettext(
+ 'in-app-notifications',
+ 'Inconsistent internal version information, please restart the app',
+ )}
+ </NotificationSubtitle>
+ </NotificationContent>
+ </React.Fragment>
+ )}
+
+ {this.state.type === 'unsupported-version' && (
+ <React.Fragment>
+ <NotificationIndicator type={'error'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'UNSUPPORTED VERSION')}
+ </NotificationTitle>
+ <NotificationSubtitle>
+ {sprintf(
+ // TRANSLATORS: The in-app banner displayed to the user when the running app becomes unsupported.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - the newest available version of the app
+ pgettext(
+ 'in-app-notifications',
+ 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
+ ),
+ { version: this.state.upgradeVersion },
+ )}
+ </NotificationSubtitle>
+ </NotificationContent>
+ <NotificationActions>
+ <NotificationOpenLinkAction onPress={this.handleOpenDownloadLink} />
+ </NotificationActions>
+ </React.Fragment>
+ )}
+
+ {this.state.type === 'update-available' && (
+ <React.Fragment>
+ <NotificationIndicator type={'warning'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'UPDATE AVAILABLE')}
+ </NotificationTitle>
+ <NotificationSubtitle>
+ {sprintf(
+ // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - the newest available version of the app
+ pgettext(
+ 'in-app-notifications',
+ 'Install Mullvad VPN (%(version)s) to stay up to date',
+ ),
+ { version: this.state.upgradeVersion },
+ )}
+ </NotificationSubtitle>
+ </NotificationContent>
+ <NotificationActions>
+ <NotificationOpenLinkAction onPress={this.handleOpenDownloadLink} />
+ </NotificationActions>
+ </React.Fragment>
+ )}
+
+ {this.state.type === 'expires-soon' && (
+ <React.Fragment>
+ <NotificationIndicator type={'warning'} />
+ <NotificationContent>
+ <NotificationTitle>
+ {pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON')}
+ </NotificationTitle>
+ <NotificationSubtitle>{this.state.timeLeft}</NotificationSubtitle>
+ </NotificationContent>
+ <NotificationActions>
+ <NotificationOpenLinkAction onPress={this.handleOpenBuyMoreLink} />
+ </NotificationActions>
+ </React.Fragment>
+ )}
+ </NotificationBanner>
+ );
+ }
+
+ private handleOpenDownloadLink = () => {
+ this.props.openExternalLink(links.download);
+ };
+
+ private handleOpenBuyMoreLink = () => {
+ this.props.openExternalLink(links.purchase);
+ };
+}
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
new file mode 100644
index 0000000000..56efb41611
--- /dev/null
+++ b/gui/src/renderer/components/NotificationBanner.tsx
@@ -0,0 +1,272 @@
+import * as React from 'react';
+import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp';
+import { colors } from '../../config.json';
+import ImageView from './ImageView';
+
+const styles = {
+ collapsible: Styles.createViewStyle({
+ backgroundColor: 'rgba(25, 38, 56, 0.95)',
+ overflow: 'hidden',
+ }),
+ drawer: Styles.createViewStyle({
+ justifyContent: 'flex-end',
+ }),
+ container: Styles.createViewStyle({
+ flexDirection: 'row',
+ paddingTop: 8,
+ paddingLeft: 20,
+ paddingRight: 10,
+ paddingBottom: 8,
+ }),
+ indicator: {
+ base: Styles.createViewStyle({
+ width: 10,
+ height: 10,
+ flex: 0,
+ borderRadius: 5,
+ marginTop: 4,
+ marginRight: 8,
+ }),
+ warning: Styles.createViewStyle({
+ backgroundColor: colors.yellow,
+ }),
+ success: Styles.createViewStyle({
+ backgroundColor: colors.green,
+ }),
+ error: Styles.createViewStyle({
+ backgroundColor: colors.red,
+ }),
+ },
+ textContainer: Styles.createViewStyle({
+ flex: 1,
+ }),
+ actionContainer: Styles.createViewStyle({
+ flex: 0,
+ flexDirection: 'column',
+ justifyContent: 'center',
+ marginLeft: 5,
+ }),
+ actionButton: Styles.createButtonStyle({
+ flex: 1,
+ justifyContent: 'center',
+ cursor: 'default',
+ paddingLeft: 5,
+ paddingRight: 5,
+ }),
+ title: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '800',
+ lineHeight: 18,
+ color: colors.white,
+ }),
+ subtitle: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 18,
+ color: colors.white60,
+ }),
+};
+
+export class NotificationTitle extends Component {
+ public render() {
+ return <Text style={styles.title}>{this.props.children}</Text>;
+ }
+}
+
+export class NotificationSubtitle extends Component {
+ public render() {
+ return React.Children.count(this.props.children) > 0 ? (
+ <Text style={styles.subtitle}>{this.props.children}</Text>
+ ) : null;
+ }
+}
+
+export class NotificationOpenLinkAction extends Component<{ onPress: () => void }> {
+ public state = {
+ hovered: false,
+ };
+
+ public render() {
+ return (
+ <Button
+ style={styles.actionButton}
+ onPress={this.props.onPress}
+ onHoverStart={this.onHoverStart}
+ onHoverEnd={this.onHoverEnd}>
+ <ImageView
+ height={12}
+ width={12}
+ tintColor={this.state.hovered ? colors.white80 : colors.white60}
+ source="icon-extLink"
+ />
+ </Button>
+ );
+ }
+
+ private onHoverStart = () => {
+ this.setState({ hovered: true });
+ };
+
+ private onHoverEnd = () => {
+ this.setState({ hovered: false });
+ };
+}
+
+export class NotificationContent extends Component {
+ public render() {
+ return <View style={styles.textContainer}>{this.props.children}</View>;
+ }
+}
+
+export class NotificationActions extends Component {
+ public render() {
+ return <View style={styles.actionContainer}>{this.props.children}</View>;
+ }
+}
+
+export class NotificationIndicator extends Component<{ type: 'success' | 'warning' | 'error' }> {
+ public render() {
+ return <View style={[styles.indicator.base, styles.indicator[this.props.type]]} />;
+ }
+}
+
+interface INotificationBannerProps {
+ children: React.ReactNode; // Array<NotificationContent | NotificationActions>,
+ style?: Types.ViewStyleRuleSet;
+ visible: boolean;
+ animationDuration: number;
+}
+
+interface INotificationBannerState {
+ contentPinnedToBottom: boolean;
+}
+
+export class NotificationBanner extends Component<
+ INotificationBannerProps,
+ INotificationBannerState
+> {
+ public static defaultProps = {
+ animationDuration: 350,
+ };
+
+ public state = {
+ contentPinnedToBottom: false,
+ };
+
+ private containerRef = React.createRef<Animated.View>();
+ private contentHeight = 0;
+ private heightValue = Animated.createValue(0);
+ private animationStyle: Types.AnimatedViewStyleRuleSet;
+ private animation?: Types.Animated.CompositeAnimation;
+ private didFinishFirstLayoutPass = false;
+
+ constructor(props: INotificationBannerProps) {
+ super(props);
+
+ this.animationStyle = Styles.createAnimatedViewStyle({
+ height: this.heightValue,
+ });
+ }
+
+ public shouldComponentUpdate(
+ nextProps: INotificationBannerProps,
+ nextState: INotificationBannerState,
+ ) {
+ return (
+ this.props.children !== nextProps.children ||
+ this.props.visible !== nextProps.visible ||
+ this.state.contentPinnedToBottom !== nextState.contentPinnedToBottom
+ );
+ }
+
+ public componentDidUpdate(prevProps: INotificationBannerProps) {
+ if (prevProps.visible !== this.props.visible) {
+ // enable drawer-like animation when changing banner's visibility
+ this.setState({ contentPinnedToBottom: true }, () => {
+ this.animateHeightChanges();
+ });
+ }
+ }
+
+ public componentWillUnmount() {
+ if (this.animation) {
+ this.animation.stop();
+ }
+ }
+
+ public render() {
+ return (
+ <Animated.View
+ style={[
+ styles.collapsible,
+ this.state.contentPinnedToBottom ? styles.drawer : undefined,
+ this.animationStyle,
+ this.props.style,
+ ]}
+ ref={this.containerRef}>
+ <View onLayout={this.onLayout}>
+ <View style={styles.container}>{this.props.children}</View>
+ </View>
+ </Animated.View>
+ );
+ }
+
+ private onLayout = ({ height }: Types.ViewOnLayoutEvent) => {
+ const oldHeight = this.contentHeight;
+ this.contentHeight = height;
+
+ // The first layout pass should not be animated because this would cause the initially visible
+ // notification banner to slide down each time the component is mounted.
+ if (this.didFinishFirstLayoutPass) {
+ if (oldHeight !== height) {
+ this.animateHeightChanges();
+ }
+ } else {
+ this.didFinishFirstLayoutPass = true;
+ if (this.props.visible) {
+ this.stopAnimation();
+ this.heightValue.setValue(height);
+ }
+ }
+ };
+
+ private async animateHeightChanges() {
+ const containerView = this.containerRef.current;
+ if (!containerView) {
+ return;
+ }
+
+ this.stopAnimation();
+
+ // calculate the animation duration based on travel distance
+ const layout = await UserInterface.measureLayoutRelativeToWindow(containerView);
+ const toValue = this.props.visible ? this.contentHeight : 0;
+ const multiplier = Math.abs(toValue - layout.height) / Math.max(1, this.contentHeight);
+ const duration = Math.ceil(this.props.animationDuration * multiplier);
+
+ const animation = Animated.timing(this.heightValue, {
+ toValue,
+ easing: Animated.Easing.InOut(),
+ duration,
+ useNativeDriver: true,
+ });
+
+ this.animation = animation;
+
+ animation.start(({ finished }) => {
+ if (finished) {
+ // disable drawer-like animations for content updates when the banner is visible
+ this.setState({ contentPinnedToBottom: false });
+ }
+ });
+ }
+
+ private stopAnimation() {
+ if (this.animation) {
+ this.animation.stop();
+ this.animation = undefined;
+ }
+ }
+}
diff --git a/gui/src/renderer/components/PlatformWindow.tsx b/gui/src/renderer/components/PlatformWindow.tsx
new file mode 100644
index 0000000000..baf446fd5b
--- /dev/null
+++ b/gui/src/renderer/components/PlatformWindow.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react';
+import { Component, Styles, View } from 'reactxp';
+
+interface IProps {
+ arrowPosition?: number;
+}
+
+export default class PlatformWindow extends Component<IProps> {
+ public render() {
+ let style;
+
+ if (process.platform === 'darwin') {
+ const arrowPosition = this.props.arrowPosition;
+ let arrowPositionCss = '50%';
+
+ if (typeof arrowPosition === 'number') {
+ const arrowWidth = 30;
+ const adjustedArrowPosition = arrowPosition - arrowWidth * 0.5;
+ arrowPositionCss = `${adjustedArrowPosition}px`;
+ }
+
+ const webkitMask = [
+ `url(../../assets/images/app-triangle.svg) ${arrowPositionCss} 0% no-repeat`,
+ `url(../../assets/images/app-header-backdrop.svg) no-repeat`,
+ ];
+
+ // @ts-ignore
+ style = Styles.createViewStyle({ WebkitMask: webkitMask.join(',') }, false);
+ }
+
+ return <View style={style}>{this.props.children}</View>;
+ }
+}
diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx
new file mode 100644
index 0000000000..9804a1c953
--- /dev/null
+++ b/gui/src/renderer/components/Preferences.tsx
@@ -0,0 +1,169 @@
+import * as React from 'react';
+import { Component, View } from 'reactxp';
+import { pgettext } from '../../shared/gettext';
+import * as Cell from './Cell';
+import { Container, Layout } from './Layout';
+import {
+ BackBarItem,
+ NavigationBar,
+ NavigationContainer,
+ NavigationScrollbars,
+ TitleBarItem,
+} from './NavigationBar';
+import styles from './PreferencesStyles';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import Switch from './Switch';
+
+export interface IPreferencesProps {
+ autoStart: boolean;
+ autoConnect: boolean;
+ allowLan: boolean;
+ monochromaticIcon: boolean;
+ startMinimized: boolean;
+ enableMonochromaticIconToggle: boolean;
+ enableStartMinimizedToggle: boolean;
+ setAutoStart: (autoStart: boolean) => void;
+ setAutoConnect: (autoConnect: boolean) => void;
+ setAllowLan: (allowLan: boolean) => void;
+ setStartMinimized: (startMinimized: boolean) => void;
+ setMonochromaticIcon: (monochromaticIcon: boolean) => void;
+ onClose: () => void;
+}
+
+export default class Preferences extends Component<IPreferencesProps> {
+ public render() {
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.preferences}>
+ <NavigationContainer>
+ <NavigationBar>
+ <BackBarItem action={this.props.onClose}>
+ {// TRANSLATORS: Back button in navigation bar
+ pgettext('preferences-nav', 'Settings')}
+ </BackBarItem>
+ <TitleBarItem>
+ {// TRANSLATORS: Title label in navigation bar
+ pgettext('preferences-nav', 'Preferences')}
+ </TitleBarItem>
+ </NavigationBar>
+
+ <View style={styles.preferences__container}>
+ <NavigationScrollbars>
+ <SettingsHeader>
+ <HeaderTitle>{pgettext('preferences-view', 'Preferences')}</HeaderTitle>
+ </SettingsHeader>
+
+ <View style={styles.preferences__content}>
+ <Cell.Container>
+ <Cell.Label>
+ {pgettext('preferences-view', 'Launch app on start-up')}
+ </Cell.Label>
+ <Switch isOn={this.props.autoStart} onChange={this.onChangeAutoStart} />
+ </Cell.Container>
+ <View style={styles.preferences__separator} />
+
+ <Cell.Container>
+ <Cell.Label>{pgettext('preferences-view', 'Auto-connect')}</Cell.Label>
+ <Switch isOn={this.props.autoConnect} onChange={this.props.setAutoConnect} />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext(
+ 'preferences-view',
+ 'Automatically connect to a server when the app launches.',
+ )}
+ </Cell.Footer>
+
+ <Cell.Container>
+ <Cell.Label>
+ {pgettext('preferences-view', 'Local network sharing')}
+ </Cell.Label>
+ <Switch isOn={this.props.allowLan} onChange={this.props.setAllowLan} />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext(
+ 'preferences-view',
+ 'Allows access to other devices on the same network for sharing, printing etc.',
+ )}
+ </Cell.Footer>
+
+ <MonochromaticIconToggle
+ enable={this.props.enableMonochromaticIconToggle}
+ monochromaticIcon={this.props.monochromaticIcon}
+ onChange={this.props.setMonochromaticIcon}
+ />
+
+ <StartMinimizedToggle
+ enable={this.props.enableStartMinimizedToggle}
+ startMinimized={this.props.startMinimized}
+ onChange={this.props.setStartMinimized}
+ />
+ </View>
+ </NavigationScrollbars>
+ </View>
+ </NavigationContainer>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private onChangeAutoStart = (autoStart: boolean) => {
+ this.props.setAutoStart(autoStart);
+ };
+}
+
+interface IMonochromaticIconProps {
+ enable: boolean;
+ monochromaticIcon: boolean;
+ onChange: (value: boolean) => void;
+}
+
+class MonochromaticIconToggle extends Component<IMonochromaticIconProps> {
+ public render() {
+ if (this.props.enable) {
+ return (
+ <View>
+ <Cell.Container>
+ <Cell.Label>{pgettext('preferences-view', 'Monochromatic tray icon')}</Cell.Label>
+ <Switch isOn={this.props.monochromaticIcon} onChange={this.props.onChange} />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext(
+ 'preferences-view',
+ 'Use a monochromatic tray icon instead of a colored one.',
+ )}
+ </Cell.Footer>
+ </View>
+ );
+ } else {
+ return null;
+ }
+ }
+}
+
+interface IStartMinimizedProps {
+ enable: boolean;
+ startMinimized: boolean;
+ onChange: (value: boolean) => void;
+}
+
+class StartMinimizedToggle extends Component<IStartMinimizedProps> {
+ public render() {
+ if (this.props.enable) {
+ return (
+ <View>
+ <Cell.Container>
+ <Cell.Label>{pgettext('preferences-view', 'Start minimized')}</Cell.Label>
+ <Switch isOn={this.props.startMinimized} onChange={this.props.onChange} />
+ </Cell.Container>
+ <Cell.Footer>
+ {pgettext('preferences-view', 'Show only the tray icon when the app starts.')}
+ </Cell.Footer>
+ </View>
+ );
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/gui/src/renderer/components/PreferencesStyles.tsx b/gui/src/renderer/components/PreferencesStyles.tsx
new file mode 100644
index 0000000000..47a37e8b4c
--- /dev/null
+++ b/gui/src/renderer/components/PreferencesStyles.tsx
@@ -0,0 +1,20 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ preferences: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ preferences__container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ preferences__content: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ preferences__separator: Styles.createViewStyle({
+ height: 1,
+ }),
+};
diff --git a/gui/src/renderer/components/RelayRow.tsx b/gui/src/renderer/components/RelayRow.tsx
new file mode 100644
index 0000000000..69f632e04e
--- /dev/null
+++ b/gui/src/renderer/components/RelayRow.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import { Component, Styles } from 'reactxp';
+import { colors } from '../../config.json';
+import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
+import * as Cell from './Cell';
+import RelayStatusIndicator from './RelayStatusIndicator';
+
+interface IProps {
+ location: RelayLocation;
+ hostname: string;
+ selected: boolean;
+ onSelect?: (location: RelayLocation) => void;
+}
+
+const styles = {
+ base: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingBottom: 0,
+ paddingRight: 0,
+ paddingLeft: 60,
+ backgroundColor: colors.blue20,
+ }),
+ selected: Styles.createViewStyle({
+ backgroundColor: colors.green,
+ }),
+};
+
+export default class RelayRow extends Component<IProps> {
+ public static compareProps(oldProps: IProps, nextProps: IProps) {
+ return (
+ oldProps.hostname === nextProps.hostname &&
+ oldProps.selected === nextProps.selected &&
+ compareRelayLocation(oldProps.location, nextProps.location)
+ );
+ }
+
+ public shouldComponentUpdate(nextProps: IProps) {
+ return !RelayRow.compareProps(this.props, nextProps);
+ }
+
+ public render() {
+ return (
+ <Cell.CellButton
+ onPress={this.handlePress}
+ cellHoverStyle={this.props.selected ? styles.selected : undefined}
+ style={[styles.base, this.props.selected ? styles.selected : undefined]}>
+ <RelayStatusIndicator isActive={true} isSelected={this.props.selected} />
+
+ <Cell.Label>{this.props.hostname}</Cell.Label>
+ </Cell.CellButton>
+ );
+ }
+
+ private handlePress = () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(this.props.location);
+ }
+ };
+}
diff --git a/gui/src/renderer/components/RelayStatusIndicator.tsx b/gui/src/renderer/components/RelayStatusIndicator.tsx
new file mode 100644
index 0000000000..3409d78331
--- /dev/null
+++ b/gui/src/renderer/components/RelayStatusIndicator.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react';
+import { Component, Styles, View } from 'reactxp';
+import { colors } from '../../config.json';
+import * as Cell from './Cell';
+
+const styles = {
+ relay_status: Styles.createViewStyle({
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ marginLeft: 4,
+ marginRight: 4,
+ }),
+ relay_status__inactive: Styles.createViewStyle({
+ backgroundColor: colors.red95,
+ }),
+ relay_status__active: Styles.createViewStyle({
+ backgroundColor: colors.green90,
+ }),
+ tick_icon: Styles.createViewStyle({
+ marginLeft: 0,
+ marginRight: 0,
+ }),
+};
+
+interface IProps {
+ isActive: boolean;
+ isSelected: boolean;
+}
+
+export default class RelayStatusIndicator extends Component<IProps> {
+ public render() {
+ return this.props.isSelected ? (
+ <Cell.Icon
+ style={styles.tick_icon}
+ tintColor={colors.white}
+ source="icon-tick"
+ height={24}
+ width={24}
+ />
+ ) : (
+ <View
+ style={[
+ styles.relay_status,
+ this.props.isActive ? styles.relay_status__active : styles.relay_status__inactive,
+ ]}
+ />
+ );
+ }
+}
diff --git a/gui/src/renderer/components/SecuredLabel.tsx b/gui/src/renderer/components/SecuredLabel.tsx
new file mode 100644
index 0000000000..6ae19ab7d1
--- /dev/null
+++ b/gui/src/renderer/components/SecuredLabel.tsx
@@ -0,0 +1,63 @@
+import * as React from 'react';
+import { Component, Styles, Text, Types } from 'reactxp';
+import { gettext } from '../../shared/gettext';
+
+export enum SecuredDisplayStyle {
+ secured,
+ blocked,
+ securing,
+ unsecured,
+}
+
+interface IProps {
+ displayStyle: SecuredDisplayStyle;
+ style: Types.TextStyleRuleSet;
+}
+
+const styles = {
+ securing: Styles.createTextStyle({
+ color: 'rgb(255, 255, 255)', // white
+ }),
+ secured: Styles.createTextStyle({
+ color: 'rgb(68, 173, 77)', // green
+ }),
+ unsecured: Styles.createTextStyle({
+ color: 'rgb(208, 2, 27)', // red
+ }),
+};
+
+export default class SecuredLabel extends Component<IProps> {
+ public render() {
+ return <Text style={[this.props.style, this.getTextStyle()]}>{this.getText()}</Text>;
+ }
+
+ private getText() {
+ switch (this.props.displayStyle) {
+ case SecuredDisplayStyle.secured:
+ return gettext('SECURE CONNECTION');
+
+ case SecuredDisplayStyle.blocked:
+ return gettext('BLOCKED CONNECTION');
+
+ case SecuredDisplayStyle.securing:
+ return gettext('CREATING SECURE CONNECTION');
+
+ case SecuredDisplayStyle.unsecured:
+ return gettext('UNSECURED CONNECTION');
+ }
+ }
+
+ private getTextStyle() {
+ switch (this.props.displayStyle) {
+ case SecuredDisplayStyle.secured:
+ case SecuredDisplayStyle.blocked:
+ return styles.secured;
+
+ case SecuredDisplayStyle.securing:
+ return styles.securing;
+
+ case SecuredDisplayStyle.unsecured:
+ return styles.unsecured;
+ }
+ }
+}
diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx
new file mode 100644
index 0000000000..c68893d314
--- /dev/null
+++ b/gui/src/renderer/components/SelectLocation.tsx
@@ -0,0 +1,249 @@
+import * as React from 'react';
+import ReactDOM from 'react-dom';
+import { Component, View } from 'reactxp';
+import { pgettext } from '../../shared/gettext';
+import CustomScrollbars from './CustomScrollbars';
+import { Container, Layout } from './Layout';
+import {
+ CloseBarItem,
+ NavigationBar,
+ NavigationContainer,
+ NavigationScrollbars,
+ TitleBarItem,
+} from './NavigationBar';
+import styles from './SelectLocationStyles';
+import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+
+import CityRow from './CityRow';
+import CountryRow from './CountryRow';
+import RelayRow from './RelayRow';
+
+import {
+ compareRelayLocation,
+ compareRelayLocationLoose,
+ RelayLocation,
+} from '../../shared/daemon-rpc-types';
+import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers';
+
+interface IProps {
+ relaySettings: RelaySettingsRedux;
+ relayLocations: IRelayLocationRedux[];
+ onClose: () => void;
+ onSelect: (location: RelayLocation) => void;
+}
+
+interface IState {
+ selectedLocation?: RelayLocation;
+ expandedItems: RelayLocation[];
+}
+
+export default class SelectLocation extends Component<IProps, IState> {
+ public state: IState = {
+ expandedItems: [],
+ };
+ private selectedCellRef = React.createRef<React.ReactNode>();
+ private scrollViewRef = React.createRef<CustomScrollbars>();
+
+ constructor(props: IProps) {
+ super(props);
+
+ if ('normal' in this.props.relaySettings) {
+ const location = this.props.relaySettings.normal.location;
+
+ if (typeof location === 'object') {
+ const expandedItems: RelayLocation[] = [];
+
+ if ('city' in location) {
+ expandedItems.push({ country: location.city[0] });
+ } else if ('hostname' in location) {
+ expandedItems.push({ country: location.hostname[0] });
+ expandedItems.push({ city: [location.hostname[0], location.hostname[1]] });
+ }
+
+ this.state = {
+ selectedLocation: location,
+ expandedItems,
+ };
+ }
+ }
+ }
+
+ public componentDidUpdate(oldProps: IProps) {
+ const currentLocation = this.state.selectedLocation;
+ let newLocation =
+ 'normal' in this.props.relaySettings ? this.props.relaySettings.normal.location : undefined;
+
+ let oldLocation =
+ 'normal' in oldProps.relaySettings ? oldProps.relaySettings.normal.location : undefined;
+
+ if (newLocation === 'any') {
+ newLocation = undefined;
+ }
+
+ if (oldLocation === 'any') {
+ oldLocation = undefined;
+ }
+
+ if (
+ !compareRelayLocationLoose(oldLocation, newLocation) &&
+ !compareRelayLocationLoose(currentLocation, newLocation)
+ ) {
+ this.setState({ selectedLocation: newLocation });
+ }
+ }
+
+ public componentDidMount() {
+ // restore scroll to the selected cell
+ const cell = this.selectedCellRef.current;
+ const scrollView = this.scrollViewRef.current;
+ if (scrollView && cell) {
+ // TODO: Fix the browser specific code
+ const cellDOMNode = ReactDOM.findDOMNode(cell as Element);
+ if (cellDOMNode instanceof HTMLElement) {
+ scrollView.scrollToElement(cellDOMNode, 'middle');
+ }
+ }
+ }
+
+ public render() {
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.select_location}>
+ <NavigationContainer>
+ <NavigationBar>
+ <CloseBarItem action={this.props.onClose} />
+ <TitleBarItem>
+ {// TRANSLATORS: Title label in navigation bar
+ pgettext('select-location-nav', 'Select location')}
+ </TitleBarItem>
+ </NavigationBar>
+ <View style={styles.container}>
+ <NavigationScrollbars ref={this.scrollViewRef}>
+ <View style={styles.content}>
+ <SettingsHeader style={styles.subtitle_header}>
+ <HeaderTitle>
+ {pgettext('select-location-view', 'Select location')}
+ </HeaderTitle>
+ <HeaderSubTitle>
+ {pgettext(
+ 'select-location-view',
+ 'While connected, your real location is masked with a private and secure location in the selected region',
+ )}
+ </HeaderSubTitle>
+ </SettingsHeader>
+
+ {this.props.relayLocations.map((relayCountry) => {
+ const countryLocation: RelayLocation = { country: relayCountry.code };
+
+ return (
+ <CountryRow
+ key={getLocationKey(countryLocation)}
+ name={relayCountry.name}
+ hasActiveRelays={relayCountry.hasActiveRelays}
+ expanded={this.isExpanded(countryLocation)}
+ onSelect={this.handleSelection}
+ onExpand={this.handleExpand}
+ {...this.getCommonCellProps(countryLocation)}>
+ {relayCountry.cities.map((relayCity) => {
+ const cityLocation: RelayLocation = {
+ city: [relayCountry.code, relayCity.code],
+ };
+
+ return (
+ <CityRow
+ key={getLocationKey(cityLocation)}
+ name={relayCity.name}
+ hasActiveRelays={relayCity.hasActiveRelays}
+ expanded={this.isExpanded(cityLocation)}
+ onSelect={this.handleSelection}
+ onExpand={this.handleExpand}
+ {...this.getCommonCellProps(cityLocation)}>
+ {relayCity.relays.map((relay) => {
+ const relayLocation: RelayLocation = {
+ hostname: [relayCountry.code, relayCity.code, relay.hostname],
+ };
+
+ return (
+ <RelayRow
+ key={getLocationKey(relayLocation)}
+ hostname={relay.hostname}
+ onSelect={this.handleSelection}
+ {...this.getCommonCellProps(relayLocation)}
+ />
+ );
+ })}
+ </CityRow>
+ );
+ })}
+ </CountryRow>
+ );
+ })}
+ </View>
+ </NavigationScrollbars>
+ </View>
+ </NavigationContainer>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private isExpanded(relayLocation: RelayLocation) {
+ return this.state.expandedItems.some((location) =>
+ compareRelayLocation(location, relayLocation),
+ );
+ }
+
+ private isSelected(relayLocation: RelayLocation) {
+ return compareRelayLocationLoose(this.state.selectedLocation, relayLocation);
+ }
+
+ private handleSelection = (location: RelayLocation) => {
+ if (!compareRelayLocationLoose(this.state.selectedLocation, location)) {
+ this.setState({ selectedLocation: location }, () => {
+ this.props.onSelect(location);
+ });
+ }
+ };
+
+ private handleExpand = (location: RelayLocation, expand: boolean) => {
+ this.setState((state) => {
+ const expandedItems = state.expandedItems.filter(
+ (item) => !compareRelayLocation(item, location),
+ );
+
+ if (expand) {
+ expandedItems.push(location);
+ }
+
+ return {
+ ...state,
+ expandedItems,
+ };
+ });
+ };
+
+ private getCommonCellProps<T>(
+ location: RelayLocation,
+ ): { location: RelayLocation; selected: boolean; ref?: React.RefObject<T> } {
+ const selected = this.isSelected(location);
+ const ref = selected ? (this.selectedCellRef as React.RefObject<T>) : undefined;
+
+ return { ref, selected, location };
+ }
+}
+
+function getLocationKey(location: RelayLocation): string {
+ const components: string[] = [];
+
+ if ('city' in location) {
+ components.push(...location.city);
+ } else if ('country' in location) {
+ components.push(location.country);
+ } else if ('hostname' in location) {
+ components.push(...location.hostname);
+ }
+
+ return ([] as string[]).concat(components).join('-');
+}
diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/SelectLocationStyles.tsx
new file mode 100644
index 0000000000..0c9ab0d3c5
--- /dev/null
+++ b/gui/src/renderer/components/SelectLocationStyles.tsx
@@ -0,0 +1,22 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ select_location: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ title_header: Styles.createViewStyle({
+ paddingBottom: 0,
+ }),
+ subtitle_header: Styles.createViewStyle({
+ paddingTop: 0,
+ }),
+ content: Styles.createViewStyle({
+ overflow: 'visible',
+ }),
+};
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
new file mode 100644
index 0000000000..5ad50e6e0d
--- /dev/null
+++ b/gui/src/renderer/components/Settings.tsx
@@ -0,0 +1,188 @@
+import * as React from 'react';
+import { Component, Text, View } from 'reactxp';
+import { colors, links } from '../../config.json';
+import { pgettext } from '../../shared/gettext';
+import AccountExpiry from '../lib/account-expiry';
+import * as AppButton from './AppButton';
+import * as Cell from './Cell';
+import ImageView from './ImageView';
+import { Container, Layout } from './Layout';
+import {
+ CloseBarItem,
+ NavigationBar,
+ NavigationContainer,
+ NavigationScrollbars,
+ TitleBarItem,
+} from './NavigationBar';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import styles from './SettingsStyles';
+
+import { LoginState } from '../redux/account/reducers';
+
+export interface IProps {
+ loginState: LoginState;
+ accountExpiry?: string;
+ appVersion: string;
+ consistentVersion: boolean;
+ upToDateVersion: boolean;
+ isOffline: boolean;
+ onQuit: () => void;
+ onClose: () => void;
+ onViewAccount: () => void;
+ onViewSupport: () => void;
+ onViewPreferences: () => void;
+ onViewAdvancedSettings: () => void;
+ onExternalLink: (url: string) => void;
+}
+
+export default class Settings extends Component<IProps> {
+ public render() {
+ return (
+ <Layout>
+ <Container>
+ <View style={styles.settings}>
+ <NavigationContainer>
+ <NavigationBar>
+ <CloseBarItem action={this.props.onClose} />
+ <TitleBarItem>
+ {// TRANSLATORS: Title label in navigation bar
+ pgettext('settings-view-nav', 'Settings')}
+ </TitleBarItem>
+ </NavigationBar>
+
+ <View style={styles.settings__container}>
+ <NavigationScrollbars style={styles.settings__scrollview}>
+ <View style={styles.settings__content}>
+ <SettingsHeader>
+ <HeaderTitle>{pgettext('settings-view', 'Settings')}</HeaderTitle>
+ </SettingsHeader>
+ <View>
+ {this.renderTopButtons()}
+ {this.renderMiddleButtons()}
+ {this.renderBottomButtons()}
+ </View>
+ {this.renderQuitButton()}
+ </View>
+ </NavigationScrollbars>
+ </View>
+ </NavigationContainer>
+ </View>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private renderQuitButton() {
+ return (
+ <View style={styles.settings__footer}>
+ <AppButton.RedButton onPress={this.props.onQuit}>
+ {pgettext('settings-view', 'Quit app')}
+ </AppButton.RedButton>
+ </View>
+ );
+ }
+
+ private renderTopButtons() {
+ const isLoggedIn = this.props.loginState === 'ok';
+ if (!isLoggedIn) {
+ return null;
+ }
+
+ const expiry = this.props.accountExpiry ? new AccountExpiry(this.props.accountExpiry) : null;
+ const isOutOfTime = expiry ? expiry.hasExpired() : false;
+ const formattedExpiry = expiry ? expiry.remainingTime().toUpperCase() : '';
+
+ const outOfTimeMessage = pgettext('settings-view', 'OUT OF TIME');
+
+ return (
+ <View>
+ <View>
+ <Cell.CellButton onPress={this.props.onViewAccount}>
+ <Cell.Label>{pgettext('settings-view', 'Account')}</Cell.Label>
+ <Cell.SubText style={styles.settings__account_paid_until_label__error}>
+ {isOutOfTime ? outOfTimeMessage : formattedExpiry}
+ </Cell.SubText>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+ </View>
+
+ <Cell.CellButton onPress={this.props.onViewPreferences}>
+ <Cell.Label>{pgettext('settings-view', 'Preferences')}</Cell.Label>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+
+ <Cell.CellButton onPress={this.props.onViewAdvancedSettings}>
+ <Cell.Label>{pgettext('settings-view', 'Advanced')}</Cell.Label>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+ <View style={styles.settings__cell_spacer} />
+ </View>
+ );
+ }
+
+ private renderMiddleButtons() {
+ let icon;
+ let footer;
+ if (!this.props.consistentVersion || !this.props.upToDateVersion) {
+ const inconsistentVersionMessage = pgettext(
+ 'settings-view',
+ 'Inconsistent internal version information, please restart the app.',
+ );
+
+ const updateAvailableMessage = pgettext(
+ 'settings-view',
+ 'Update available, download to remain safe.',
+ );
+
+ const message = !this.props.consistentVersion
+ ? inconsistentVersionMessage
+ : updateAvailableMessage;
+
+ icon = (
+ <ImageView
+ source="icon-alert"
+ tintColor={colors.red}
+ style={styles.settings__version_warning}
+ />
+ );
+ footer = (
+ <View style={styles.settings__cell_footer}>
+ <Text style={styles.settings__cell_footer_label}>{message}</Text>
+ </View>
+ );
+ } else {
+ footer = <View style={styles.settings__cell_spacer} />;
+ }
+
+ return (
+ <View>
+ <Cell.CellButton disabled={this.props.isOffline} onPress={this.openDownloadLink}>
+ {icon}
+ <Cell.Label>{pgettext('settings-view', 'App version')}</Cell.Label>
+ <Cell.SubText>{this.props.appVersion}</Cell.SubText>
+ <Cell.Icon height={16} width={16} source="icon-extLink" />
+ </Cell.CellButton>
+ {footer}
+ </View>
+ );
+ }
+
+ private openDownloadLink = () => this.props.onExternalLink(links.download);
+ private openFaqLink = () => this.props.onExternalLink(links.faq);
+
+ private renderBottomButtons() {
+ return (
+ <View>
+ <Cell.CellButton onPress={this.props.onViewSupport}>
+ <Cell.Label>{pgettext('settings-view', 'Report a problem')}</Cell.Label>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+
+ <Cell.CellButton disabled={this.props.isOffline} onPress={this.openFaqLink}>
+ <Cell.Label>{pgettext('settings-view', 'FAQs & Guides')}</Cell.Label>
+ <Cell.Icon height={16} width={16} source="icon-extLink" />
+ </Cell.CellButton>
+ </View>
+ );
+ }
+}
diff --git a/gui/src/renderer/components/SettingsHeader.tsx b/gui/src/renderer/components/SettingsHeader.tsx
new file mode 100644
index 0000000000..e099d29a2d
--- /dev/null
+++ b/gui/src/renderer/components/SettingsHeader.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { Component, Styles, Text, Types, View } from 'reactxp';
+
+const styles = {
+ header: {
+ default: Styles.createViewStyle({
+ flex: 0,
+ paddingTop: 4,
+ paddingRight: 24,
+ paddingLeft: 24,
+ paddingBottom: 24,
+ }),
+ },
+ title: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 32,
+ fontWeight: '900',
+ lineHeight: 40,
+ color: 'rgb(255, 255, 255)',
+ }),
+ subtitle: Styles.createTextStyle({
+ marginTop: 4,
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ overflow: 'visible',
+ color: 'rgba(255, 255, 255, 0.8)', // colors.white80
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ }),
+};
+
+interface ISettingsHeaderProps {
+ style?: Types.ViewStyleRuleSet;
+}
+
+export default class SettingsHeader extends Component<ISettingsHeaderProps> {
+ public render() {
+ return <View style={[styles.header.default, this.props.style]}>{this.props.children}</View>;
+ }
+}
+
+export class HeaderTitle extends Component {
+ public render() {
+ return <Text style={[styles.title]}>{this.props.children}</Text>;
+ }
+}
+
+export class HeaderSubTitle extends Component {
+ public render() {
+ return <Text style={[styles.subtitle]}>{this.props.children}</Text>;
+ }
+}
diff --git a/gui/src/renderer/components/SettingsStyles.tsx b/gui/src/renderer/components/SettingsStyles.tsx
new file mode 100644
index 0000000000..d70d413c6a
--- /dev/null
+++ b/gui/src/renderer/components/SettingsStyles.tsx
@@ -0,0 +1,54 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ settings: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ settings__container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ settings__content: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ justifyContent: 'space-between',
+ overflow: 'visible',
+ }),
+ // plain CSS style
+ settings__scrollview: {
+ flex: 1,
+ },
+ settings__cell_spacer: Styles.createViewStyle({
+ height: 24,
+ flex: 0,
+ }),
+ settings__cell_footer: Styles.createViewStyle({
+ paddingTop: 8,
+ paddingRight: 24,
+ paddingBottom: 24,
+ paddingLeft: 24,
+ }),
+ settings__footer: Styles.createViewStyle({
+ paddingTop: 24,
+ paddingBottom: 24,
+ paddingLeft: 24,
+ paddingRight: 24,
+ }),
+ settings__version_warning: Styles.createViewStyle({
+ marginLeft: 8,
+ }),
+
+ settings__account_paid_until_label__error: Styles.createTextStyle({
+ color: colors.red,
+ }),
+ settings__cell_footer_label: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ color: colors.white60,
+ }),
+};
diff --git a/gui/src/renderer/components/Support.tsx b/gui/src/renderer/components/Support.tsx
new file mode 100644
index 0000000000..25e7c2d7d9
--- /dev/null
+++ b/gui/src/renderer/components/Support.tsx
@@ -0,0 +1,406 @@
+import * as React from 'react';
+import { Component, Text, TextInput, View } from 'reactxp';
+import { gettext, pgettext } from '../../shared/gettext';
+import * as AppButton from './AppButton';
+import ImageView from './ImageView';
+import { Container, Layout } from './Layout';
+import { ModalAlert, ModalContainer, ModalContent } from './Modal';
+import { BackBarItem, NavigationBar } from './NavigationBar';
+import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+import styles from './SupportStyles';
+
+import { AccountToken } from '../../shared/daemon-rpc-types';
+import { ISupportReportForm } from '../redux/support/actions';
+
+enum SendState {
+ Initial,
+ Confirm,
+ Sending,
+ Success,
+ Failed,
+}
+
+interface ISupportState {
+ email: string;
+ message: string;
+ savedReport?: string;
+ sendState: SendState;
+}
+
+interface ISupportProps {
+ defaultEmail: string;
+ defaultMessage: string;
+ accountHistory: AccountToken[];
+ isOffline: boolean;
+ onClose: () => void;
+ viewLog: (path: string) => void;
+ saveReportForm: (form: ISupportReportForm) => void;
+ clearReportForm: () => void;
+ collectProblemReport: (accountsToRedact: string[]) => Promise<string>;
+ sendProblemReport: (email: string, message: string, savedReport: string) => Promise<void>;
+}
+
+export default class Support extends Component<ISupportProps, ISupportState> {
+ public state = {
+ email: '',
+ message: '',
+ savedReport: undefined,
+ sendState: SendState.Initial,
+ };
+
+ private collectLogPromise?: Promise<string>;
+
+ constructor(props: ISupportProps) {
+ super(props);
+
+ // seed initial data from props
+ this.state.email = props.defaultEmail;
+ this.state.message = props.defaultMessage;
+ }
+
+ public validate() {
+ return this.state.message.trim().length > 0;
+ }
+
+ public onChangeEmail = (email: string) => {
+ this.setState({ email }, () => {
+ this.saveFormData();
+ });
+ };
+
+ public onChangeDescription = (description: string) => {
+ this.setState({ message: description }, () => {
+ this.saveFormData();
+ });
+ };
+
+ public onViewLog = async (): Promise<void> => {
+ try {
+ const reportPath = await this.collectLog();
+ this.props.viewLog(reportPath);
+ } catch (error) {
+ // TODO: handle error
+ }
+ };
+
+ public onSend = async (): Promise<void> => {
+ switch (this.state.sendState) {
+ case SendState.Initial:
+ if (this.state.email.length === 0) {
+ this.setState({ sendState: SendState.Confirm });
+ } else {
+ try {
+ await this.sendReport();
+ } catch (error) {
+ // No-op
+ }
+ }
+ return Promise.resolve();
+
+ case SendState.Confirm:
+ try {
+ await this.sendReport();
+ } catch (error) {
+ // No-op
+ }
+ return Promise.resolve();
+
+ default:
+ break;
+ }
+
+ return Promise.resolve();
+ };
+
+ public onCancelConfirmation = () => {
+ this.setState({ sendState: SendState.Initial });
+ };
+
+ public render() {
+ const { sendState } = this.state;
+ const header = (
+ <SettingsHeader>
+ <HeaderTitle>{pgettext('support-view', 'Report a problem')}</HeaderTitle>
+ {(sendState === SendState.Initial || sendState === SendState.Confirm) && (
+ <HeaderSubTitle>
+ {pgettext(
+ 'support-view',
+ "To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.",
+ )}
+ </HeaderSubTitle>
+ )}
+ </SettingsHeader>
+ );
+
+ const content = this.renderContent();
+
+ return (
+ <Layout>
+ <Container>
+ <ModalContainer>
+ <ModalContent>
+ <View style={styles.support}>
+ <NavigationBar>
+ <BackBarItem action={this.props.onClose}>
+ {// TRANSLATORS: Back button in navigation bar
+ pgettext('support-nav', 'Settings')}
+ </BackBarItem>
+ </NavigationBar>
+ <View style={styles.support__container}>
+ {header}
+ {content}
+ </View>
+ </View>
+ </ModalContent>
+ {sendState === SendState.Confirm ? (
+ <ModalAlert>{this.renderConfirm()}</ModalAlert>
+ ) : (
+ undefined
+ )}
+ </ModalContainer>
+ </Container>
+ </Layout>
+ );
+ }
+
+ private saveFormData() {
+ this.props.saveReportForm({
+ email: this.state.email,
+ message: this.state.message,
+ });
+ }
+
+ private async collectLog(): Promise<string> {
+ if (this.collectLogPromise) {
+ return this.collectLogPromise;
+ } else {
+ const collectPromise = this.props.collectProblemReport(this.props.accountHistory);
+
+ // save promise to prevent subsequent requests
+ this.collectLogPromise = collectPromise;
+
+ try {
+ const reportPath = await collectPromise;
+ return new Promise((resolve) => {
+ this.setState({ savedReport: reportPath }, () => resolve(reportPath));
+ });
+ } catch (error) {
+ this.collectLogPromise = undefined;
+
+ throw error;
+ }
+ }
+ }
+
+ private sendReport(): Promise<void> {
+ return new Promise((resolve, reject) => {
+ this.setState({ sendState: SendState.Sending }, async () => {
+ try {
+ const { email, message } = this.state;
+ const reportPath = await this.collectLog();
+ await this.props.sendProblemReport(email, message, reportPath);
+ this.props.clearReportForm();
+ this.setState({ sendState: SendState.Success }, () => {
+ resolve();
+ });
+ } catch (error) {
+ this.setState({ sendState: SendState.Failed }, () => {
+ reject(error);
+ });
+ }
+ });
+ });
+ }
+
+ private renderContent() {
+ switch (this.state.sendState) {
+ case SendState.Initial:
+ case SendState.Confirm:
+ return this.renderForm();
+ case SendState.Sending:
+ return this.renderSending();
+ case SendState.Success:
+ return this.renderSent();
+ case SendState.Failed:
+ return this.renderFailed();
+ default:
+ return null;
+ }
+ }
+
+ private renderConfirm() {
+ return <ConfirmNoEmailDialog onConfirm={this.onSend} onDismiss={this.onCancelConfirmation} />;
+ }
+
+ private renderForm() {
+ return (
+ <View style={styles.support__content}>
+ <View style={styles.support__form}>
+ <View style={styles.support__form_row_email}>
+ <TextInput
+ style={styles.support__form_email}
+ placeholder={pgettext('support-view', 'Your email (optional)')}
+ defaultValue={this.state.email}
+ onChangeText={this.onChangeEmail}
+ keyboardType="email-address"
+ />
+ </View>
+ <View style={styles.support__form_row_message}>
+ <View style={styles.support__form_message_scroll_wrap}>
+ <TextInput
+ style={styles.support__form_message}
+ placeholder={pgettext('support-view', 'Describe your problem')}
+ defaultValue={this.state.message}
+ multiline={true}
+ onChangeText={this.onChangeDescription}
+ />
+ </View>
+ </View>
+ <View style={styles.support__footer}>
+ <AppButton.BlueButton style={styles.view_logs_button} onPress={this.onViewLog}>
+ <AppButton.Label>{pgettext('support-view', 'View app logs')}</AppButton.Label>
+ <AppButton.Icon source="icon-extLink" height={16} width={16} />
+ </AppButton.BlueButton>
+ <AppButton.GreenButton disabled={!this.validate()} onPress={this.onSend}>
+ {pgettext('support-view', 'Send')}
+ </AppButton.GreenButton>
+ </View>
+ </View>
+ </View>
+ );
+ }
+
+ private renderSending() {
+ return (
+ <View style={styles.support__content}>
+ <View style={styles.support__form}>
+ <View style={styles.support__form_row}>
+ <View style={styles.support__status_icon}>
+ <ImageView source="icon-spinner" height={60} width={60} />
+ </View>
+ <View style={styles.support__status_security__secure}>
+ {gettext('SECURE CONNECTION')}
+ </View>
+ <Text style={styles.support__send_status}>
+ {pgettext('support-view', 'Sending...')}
+ </Text>
+ </View>
+ </View>
+ </View>
+ );
+ }
+
+ private renderSent() {
+ // TRANSLATORS: The message displayed to the user after submitting the problem report, given that the user left his or her email for us to reach back.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(email)s
+ const reachBackMessage: React.ReactNodeArray = pgettext(
+ 'support-view',
+ 'If needed we will contact you on %(email)s',
+ ).split('%(email)s', 2);
+ reachBackMessage.splice(
+ 1,
+ 0,
+ <Text key={'email'} style={styles.support__sent_email}>
+ {this.state.email}
+ </Text>,
+ );
+
+ return (
+ <View style={styles.support__content}>
+ <View style={styles.support__form}>
+ <View style={styles.support__form_row}>
+ <View style={styles.support__status_icon}>
+ <ImageView source="icon-success" height={60} width={60} />
+ </View>
+ <Text style={styles.support__status_security__secure}>
+ {gettext('SECURE CONNECTION')}
+ </Text>
+ <Text style={styles.support__send_status}>{pgettext('support-view', 'Sent')}</Text>
+
+ <Text style={styles.support__sent_message}>
+ {pgettext('support-view', 'Thanks! We will look into this.')}
+ </Text>
+ {this.state.email.trim().length > 0 ? (
+ <Text style={styles.support__sent_message}>{reachBackMessage}</Text>
+ ) : null}
+ </View>
+ </View>
+ </View>
+ );
+ }
+
+ private renderFailed() {
+ return (
+ <View style={styles.support__content}>
+ <View style={styles.support__form}>
+ <View style={styles.support__form_row}>
+ <View style={styles.support__status_icon}>
+ <ImageView source="icon-fail" height={60} width={60} />
+ </View>
+ <Text style={styles.support__status_security__secure}>
+ {gettext('SECURE CONNECTION')}
+ </Text>
+ <Text style={styles.support__send_status}>
+ {pgettext('support-view', 'Failed to send')}
+ </Text>
+ <Text style={styles.support__sent_message}>
+ {pgettext(
+ 'support-view',
+ "You may need to go back to the app's main screen and click Disconnect before trying again. Don't worry, the information you entered will remain in the form.",
+ )}
+ </Text>
+ </View>
+ </View>
+ <View style={styles.support__footer}>
+ <AppButton.BlueButton style={styles.edit_message_button} onPress={this.handleEditMessage}>
+ {pgettext('support-view', 'Edit message')}
+ </AppButton.BlueButton>
+ <AppButton.GreenButton onPress={this.onSend}>
+ {pgettext('support-view', 'Try again')}
+ </AppButton.GreenButton>
+ </View>
+ </View>
+ );
+ }
+
+ private handleEditMessage = () => {
+ this.setState({ sendState: SendState.Initial });
+ };
+}
+
+interface IConfirmNoEmailDialogProps {
+ onConfirm: () => void;
+ onDismiss: () => void;
+}
+
+class ConfirmNoEmailDialog extends Component<IConfirmNoEmailDialogProps> {
+ public render() {
+ return (
+ <View style={styles.confirm_no_email_background}>
+ <View style={styles.confirm_no_email_dialog}>
+ <Text style={styles.confirm_no_email_warning}>
+ {pgettext(
+ 'support-view',
+ 'You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.',
+ )}
+ </Text>
+ <AppButton.GreenButton onPress={this.confirm}>
+ {pgettext('support-view', 'Send anyway')}
+ </AppButton.GreenButton>
+ <AppButton.RedButton onPress={this.dismiss} style={styles.confirm_no_email_back_button}>
+ {pgettext('support-view', 'Back')}
+ </AppButton.RedButton>
+ </View>
+ </View>
+ );
+ }
+
+ private confirm = () => {
+ this.props.onConfirm();
+ };
+
+ private dismiss = () => {
+ this.props.onDismiss();
+ };
+}
diff --git a/gui/src/renderer/components/SupportStyles.tsx b/gui/src/renderer/components/SupportStyles.tsx
new file mode 100644
index 0000000000..bcc05b7b50
--- /dev/null
+++ b/gui/src/renderer/components/SupportStyles.tsx
@@ -0,0 +1,138 @@
+import { Styles } from 'reactxp';
+import { colors } from '../../config.json';
+
+export default {
+ support: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ flex: 1,
+ }),
+ support__container: Styles.createViewStyle({
+ flexDirection: 'column',
+ flex: 1,
+ }),
+ support__content: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ }),
+ support__form: Styles.createViewStyle({
+ flex: 1,
+ flexDirection: 'column',
+ }),
+ support__form_row: Styles.createViewStyle({
+ paddingLeft: 22,
+ paddingRight: 22,
+ marginBottom: 12,
+ }),
+ support__form_row_email: Styles.createViewStyle({
+ paddingLeft: 22,
+ paddingRight: 22,
+ marginBottom: 12,
+ }),
+ support__form_row_message: Styles.createViewStyle({
+ flex: 1,
+ paddingLeft: 22,
+ paddingRight: 22,
+ }),
+ support__form_message_scroll_wrap: Styles.createViewStyle({
+ flex: 1,
+ borderRadius: 4,
+ overflow: 'hidden',
+ }),
+ support__footer: Styles.createViewStyle({
+ paddingTop: 16,
+ paddingBottom: 16,
+ paddingLeft: 24,
+ paddingRight: 24,
+ flexDirection: 'column',
+ flex: 0,
+ }),
+ support__status_icon: Styles.createViewStyle({
+ alignItems: 'center',
+ marginBottom: 32,
+ }),
+ view_logs_button: Styles.createViewStyle({
+ marginBottom: 16,
+ }),
+ edit_message_button: Styles.createViewStyle({
+ marginBottom: 16,
+ }),
+ support__form_email: Styles.createTextStyle({
+ flex: 1,
+ borderRadius: 4,
+ overflow: 'hidden',
+ paddingTop: 14,
+ paddingLeft: 14,
+ paddingRight: 14,
+ paddingBottom: 14,
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ lineHeight: 26,
+ color: colors.blue,
+ backgroundColor: colors.white,
+ }),
+ support__form_message: Styles.createTextStyle({
+ paddingTop: 14,
+ paddingLeft: 14,
+ paddingRight: 14,
+ paddingBottom: 14,
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.blue,
+ backgroundColor: colors.white,
+ flex: 1,
+ }),
+ support__sent_message: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 13,
+ fontWeight: '600',
+ overflow: 'visible',
+ color: colors.white60,
+ lineHeight: 20,
+ letterSpacing: -0.2,
+ }),
+ support__sent_email: Styles.createTextStyle({
+ fontWeight: '900',
+ color: colors.white,
+ }),
+ support__status_security__secure: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ fontWeight: '800',
+ lineHeight: 22,
+ marginBottom: 4,
+ color: colors.green,
+ }),
+ support__send_status: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 34,
+ fontWeight: '900',
+ letterSpacing: -0.9,
+ color: colors.white,
+ marginBottom: 4,
+ }),
+ confirm_no_email_background: Styles.createViewStyle({
+ flex: 1,
+ justifyContent: 'center',
+ paddingLeft: 14,
+ paddingRight: 14,
+ }),
+ confirm_no_email_dialog: Styles.createViewStyle({
+ backgroundColor: colors.darkBlue,
+ borderRadius: 11,
+ padding: 16,
+ }),
+ confirm_no_email_warning: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ fontWeight: '500',
+ lineHeight: 20,
+ color: colors.white80,
+ marginBottom: 12,
+ }),
+ confirm_no_email_back_button: Styles.createViewStyle({
+ marginTop: 16,
+ }),
+};
diff --git a/gui/src/renderer/components/SvgMap.tsx b/gui/src/renderer/components/SvgMap.tsx
new file mode 100644
index 0000000000..25c77de7f2
--- /dev/null
+++ b/gui/src/renderer/components/SvgMap.tsx
@@ -0,0 +1,371 @@
+import { geoTimes } from 'd3-geo-projection';
+import rbush from 'rbush';
+import * as React from 'react';
+import {
+ ComposableMap,
+ Geographies,
+ Geography,
+ Marker,
+ Markers,
+ ZoomableGroup,
+} from 'react-simple-maps';
+
+import geographyData from '../../../assets/geo/geometry.json';
+import statesProvincesLinesData from '../../../assets/geo/states-provinces-lines.json';
+
+import cityTreeData from '../../../assets/geo/cities.rbush.json';
+import countryTreeData from '../../../assets/geo/countries.rbush.json';
+import geometryTreeData from '../../../assets/geo/geometry.rbush.json';
+import statesProvincesLinesTreeData from '../../../assets/geo/states-provinces-lines.rbush.json';
+
+// Infer the GeoProjection type from the `geoTimes()` return value
+type GeoProjection = ReturnType<typeof geoTimes>;
+
+interface ICountryLeaf extends rbush.BBox {
+ id: string;
+ properties: {
+ name: string;
+ };
+ geometry: {
+ type: string;
+ coordinates: [number, number];
+ };
+}
+
+interface ICityLeaf extends rbush.BBox {
+ id: string;
+ properties: {
+ scalerank: number;
+ name: string;
+ latitude: number;
+ longitude: number;
+ };
+ geometry: {
+ type: string;
+ coordinates: [number, number];
+ };
+}
+
+interface IGeometryLeaf extends rbush.BBox {
+ id: string;
+}
+
+interface IProvinceAndStateLineLeaf extends rbush.BBox {
+ id: string;
+}
+
+const countryTree = rbush<ICountryLeaf>().fromJSON(countryTreeData);
+const cityTree = rbush<ICityLeaf>().fromJSON(cityTreeData);
+const geometryTree = rbush<IGeometryLeaf>().fromJSON(geometryTreeData);
+const provincesStatesLinesTree = rbush<IProvinceAndStateLineLeaf>().fromJSON(
+ statesProvincesLinesTreeData,
+);
+
+type BBox = [number, number, number, number];
+
+export interface IProps {
+ width: number;
+ height: number;
+ center: [number, number]; // longitude, latitude
+ offset: [number, number]; // [x, y] in points
+ zoomLevel: number;
+ showMarker: boolean;
+ markerImagePath: string;
+}
+
+interface IState {
+ zoomCenter: [number, number];
+ zoomLevel: number;
+ visibleCities: ICityLeaf[];
+ visibleCountries: ICountryLeaf[];
+ visibleGeometry: IGeometryLeaf[];
+ visibleStatesProvincesLines: IProvinceAndStateLineLeaf[];
+ viewportBbox: BBox;
+}
+
+const MOVE_SPEED = 2000;
+
+// @TODO: Calculate zoom level based on (center + span) (aka MKCoordinateSpan)
+export default class SvgMap extends React.Component<IProps, IState> {
+ public state: IState = {
+ zoomCenter: [0, 0],
+ zoomLevel: 1,
+ visibleCities: [],
+ visibleCountries: [],
+ visibleGeometry: [],
+ visibleStatesProvincesLines: [],
+ viewportBbox: [0, 0, 0, 0],
+ };
+
+ private projectionConfig = {
+ scale: 160,
+ };
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = this.getNextState(null, props);
+ }
+
+ public UNSAFE_componentWillReceiveProps(nextProps: IProps) {
+ if (this.shouldInvalidateState(nextProps)) {
+ this.setState((prevState) => this.getNextState(prevState, nextProps));
+ }
+ }
+
+ public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
+ return (
+ this.props.width !== nextProps.width ||
+ this.props.height !== nextProps.height ||
+ this.props.center[0] !== nextProps.center[0] ||
+ this.props.center[1] !== nextProps.center[1] ||
+ this.props.offset[0] !== nextProps.offset[0] ||
+ this.props.offset[1] !== nextProps.offset[1] ||
+ this.props.zoomLevel !== nextProps.zoomLevel ||
+ this.props.showMarker !== nextProps.showMarker ||
+ this.props.markerImagePath !== nextProps.markerImagePath ||
+ this.state.zoomCenter !== nextState.zoomCenter ||
+ this.state.zoomLevel !== nextState.zoomLevel
+ );
+ }
+
+ public render() {
+ const mapStyle = {
+ width: '100%',
+ height: '100%',
+ backgroundColor: '#192e45',
+ };
+
+ const zoomableGroupStyle = {
+ transition: `transform ${MOVE_SPEED}ms ease-in-out`,
+ };
+
+ const geographyStyle = this.mergeRsmStyle({
+ default: {
+ fill: '#294d73',
+ stroke: '#192e45',
+ strokeWidth: `${1 / this.state.zoomLevel}`,
+ },
+ });
+
+ const stateProvinceLineStyle = this.mergeRsmStyle({
+ default: {
+ fill: 'transparent',
+ stroke: '#192e45',
+ strokeWidth: `${1 / this.state.zoomLevel}`,
+ },
+ });
+
+ const markerStyle = this.mergeRsmStyle({
+ default: {
+ transition: `transform ${MOVE_SPEED}ms ease-in-out`,
+ },
+ });
+
+ // disable CSS transition when moving between locations
+ // by using the different "key"
+ const userMarker = this.props.showMarker && (
+ <Marker
+ key={`user-location-${this.props.center.join('-')}`}
+ marker={{ coordinates: this.props.center }}
+ style={markerStyle}>
+ <image x="-30" y="-30" xlinkHref={this.props.markerImagePath} />
+ </Marker>
+ );
+
+ const countryMarkers = this.state.visibleCountries.map((item) => (
+ <Marker
+ key={`country-${item.id}`}
+ marker={{ coordinates: item.geometry.coordinates }}
+ style={markerStyle}>
+ <text fill="rgba(255,255,255,.6)" fontSize="22" textAnchor="middle">
+ {item.properties.name}
+ </text>
+ </Marker>
+ ));
+
+ const cityMarkers = this.state.visibleCities.map((item) => (
+ <Marker
+ key={`city-${item.id}`}
+ marker={{ coordinates: item.geometry.coordinates }}
+ style={markerStyle}>
+ <circle r="2" fill="rgba(255,255,255,.6)" />
+ <text x="0" y="-10" fill="rgba(255,255,255,.6)" fontSize="16" textAnchor="middle">
+ {item.properties.name}
+ </text>
+ </Marker>
+ ));
+
+ return (
+ <ComposableMap
+ width={this.props.width}
+ height={this.props.height}
+ style={mapStyle}
+ projection={this.getProjection}
+ projectionConfig={this.projectionConfig}>
+ <ZoomableGroup
+ center={this.state.zoomCenter}
+ zoom={this.state.zoomLevel}
+ disablePanning={false}
+ style={zoomableGroupStyle}>
+ <Geographies geography={geographyData} disableOptimization={true}>
+ {(geographies, projection) => {
+ return this.state.visibleGeometry.map(({ id }) => (
+ <Geography
+ key={id}
+ geography={geographies[parseInt(id, 10)]}
+ projection={projection}
+ style={geographyStyle}
+ />
+ ));
+ }}
+ </Geographies>
+ <Geographies geography={statesProvincesLinesData} disableOptimization={true}>
+ {(geographies, projection) => {
+ return this.state.visibleStatesProvincesLines.map(({ id }) => (
+ <Geography
+ key={id}
+ geography={geographies[parseInt(id, 10)]}
+ projection={projection}
+ style={stateProvinceLineStyle}
+ />
+ ));
+ }}
+ </Geographies>
+ <Markers>{[...countryMarkers, ...cityMarkers, userMarker]}</Markers>
+ </ZoomableGroup>
+ </ComposableMap>
+ );
+ }
+
+ private mergeRsmStyle(style: { [key: string]: any }) {
+ const defaultStyle = style.default || {};
+ return {
+ default: defaultStyle,
+ hover: style.hover || defaultStyle,
+ pressed: style.pressed || defaultStyle,
+ };
+ }
+
+ private getProjection(
+ width: number,
+ height: number,
+ config: {
+ scale?: number;
+ xOffset?: number;
+ yOffset?: number;
+ rotation?: [number, number, number];
+ precision?: number;
+ },
+ ) {
+ const scale = config.scale || 160;
+ const xOffset = config.xOffset || 0;
+ const yOffset = config.yOffset || 0;
+ const rotation = config.rotation || [0, 0, 0];
+ const precision = config.precision || 0.1;
+
+ return geoTimes()
+ .scale(scale)
+ .translate([xOffset + width / 2, yOffset + height / 2])
+ .rotate(rotation)
+ .precision(precision);
+ }
+
+ private getZoomCenter(
+ center: [number, number],
+ offset: [number, number],
+ projection: GeoProjection,
+ zoom: number,
+ ): [number, number] {
+ const pos = projection(center)!;
+ return projection.invert!([pos[0] + offset[0] / zoom, pos[1] + offset[1] / zoom])!;
+ }
+
+ private getViewportGeoBoundingBox(
+ centerCoordinate: [number, number],
+ width: number,
+ height: number,
+ projection: GeoProjection,
+ zoom: number,
+ ): BBox {
+ const center = projection(centerCoordinate)!;
+ const halfWidth = (width * 0.5) / zoom;
+ const halfHeight = (height * 0.5) / zoom;
+
+ const northWest = projection.invert!([center[0] - halfWidth, center[1] - halfHeight])!;
+ const southEast = projection.invert!([center[0] + halfWidth, center[1] + halfHeight])!;
+
+ // normalize to [minX, minY, maxX, maxY]
+ return [
+ Math.min(northWest[0], southEast[0]),
+ Math.min(northWest[1], southEast[1]),
+ Math.max(northWest[0], southEast[0]),
+ Math.max(northWest[1], southEast[1]),
+ ];
+ }
+
+ private shouldInvalidateState(nextProps: IProps) {
+ const oldProps = this.props;
+ return (
+ oldProps.width !== nextProps.width ||
+ oldProps.height !== nextProps.height ||
+ oldProps.center[0] !== nextProps.center[0] ||
+ oldProps.center[1] !== nextProps.center[1] ||
+ oldProps.offset[0] !== nextProps.offset[0] ||
+ oldProps.offset[1] !== nextProps.offset[1] ||
+ oldProps.zoomLevel !== nextProps.zoomLevel
+ );
+ }
+
+ private getNextState(prevState: IState | null, nextProps: IProps): IState {
+ const { width, height, center, offset, zoomLevel } = nextProps;
+
+ const projection = this.getProjection(width, height, this.projectionConfig);
+ const zoomCenter = this.getZoomCenter(center, offset, projection, zoomLevel);
+ const viewportBbox = this.getViewportGeoBoundingBox(
+ zoomCenter,
+ width,
+ height,
+ projection,
+ zoomLevel,
+ );
+
+ const viewportBboxMatch = {
+ minX: viewportBbox[0],
+ minY: viewportBbox[1],
+ maxX: viewportBbox[2],
+ maxY: viewportBbox[3],
+ };
+
+ // combine previous and current viewports to get the rough area of transition
+ const combinedViewportBboxMatch = prevState
+ ? {
+ minX: Math.min(viewportBbox[0], prevState.viewportBbox[0]),
+ minY: Math.min(viewportBbox[1], prevState.viewportBbox[1]),
+ maxX: Math.max(viewportBbox[2], prevState.viewportBbox[2]),
+ maxY: Math.max(viewportBbox[3], prevState.viewportBbox[3]),
+ }
+ : {
+ minX: viewportBbox[0],
+ minY: viewportBbox[1],
+ maxX: viewportBbox[2],
+ maxY: viewportBbox[3],
+ };
+
+ const visibleCountries =
+ zoomLevel < 5 || zoomLevel > 20 ? [] : countryTree.search(viewportBboxMatch);
+ const visibleCities = zoomLevel >= 40 ? cityTree.search(viewportBboxMatch) : [];
+ const visibleGeometry = geometryTree.search(combinedViewportBboxMatch);
+ const visibleStatesProvincesLines = provincesStatesLinesTree.search(combinedViewportBboxMatch);
+
+ return {
+ zoomCenter,
+ zoomLevel,
+ visibleCities,
+ visibleCountries,
+ visibleGeometry,
+ visibleStatesProvincesLines,
+ viewportBbox,
+ };
+ }
+}
diff --git a/gui/src/renderer/components/Switch.css b/gui/src/renderer/components/Switch.css
new file mode 100644
index 0000000000..22cc3360c8
--- /dev/null
+++ b/gui/src/renderer/components/Switch.css
@@ -0,0 +1,44 @@
+.switch {
+ display: block;
+ position: relative;
+ -webkit-appearance: none;
+ border-radius: 16px;
+ width: 52px;
+ height: 32px;
+ border: 2px solid white;
+ background-color: transparent;
+ transition: 300ms ease-in-out all;
+}
+
+.switch:checked {
+ text-align: right;
+}
+
+.switch::after {
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ display: block;
+ content: '';
+ width: 24px;
+ height: 24px;
+ border-radius: 24px;
+ background-color: #d0021b;
+ transition: 200ms ease-in-out all;
+ transform: translate3d(0, 0, 0);
+}
+
+.switch:active::after {
+ width: 28px;
+}
+
+.switch:active:checked::after {
+ transform: translate3d(0, 0, 0);
+ left: 18px;
+}
+
+.switch:checked::after {
+ background-color: #44ad4d;
+ transform: translate3d(0, 0, 0);
+ left: 22px;
+}
diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
new file mode 100644
index 0000000000..eef588f44b
--- /dev/null
+++ b/gui/src/renderer/components/Switch.tsx
@@ -0,0 +1,146 @@
+import * as React from 'react';
+
+const CLICK_TIMEOUT = 1000;
+const MOVE_THRESHOLD = 10;
+
+interface IProps {
+ className?: string;
+ isOn: boolean;
+ onChange?: (isOn: boolean) => void;
+}
+
+interface IState {
+ ignoreChange: boolean;
+ initialPos: { x: number; y: number };
+ startTime?: number;
+}
+
+export default class Switch extends React.Component<IProps, IState> {
+ public static defaultProps: Partial<IProps> = {
+ isOn: false,
+ onChange: undefined,
+ };
+
+ public state: IState = {
+ ignoreChange: false,
+ initialPos: { x: 0, y: 0 },
+ startTime: undefined,
+ };
+
+ public isCapturingMouseEvents = false;
+ public ref = React.createRef<HTMLInputElement>();
+
+ public componentWillUnmount() {
+ // guard from abrupt programmatic unmount
+ if (this.isCapturingMouseEvents) {
+ this.stopCapturingMouseEvents();
+ }
+ }
+
+ public render() {
+ const { isOn, onChange, ...otherProps } = this.props;
+ const className = ('switch ' + (otherProps.className || '')).trim();
+ return (
+ <input
+ {...otherProps}
+ type="checkbox"
+ ref={this.ref}
+ className={className}
+ checked={isOn}
+ onMouseDown={this.handleMouseDown}
+ onChange={this.handleChange}
+ />
+ );
+ }
+
+ private handleMouseDown = (e: React.MouseEvent<HTMLInputElement>) => {
+ const { clientX: x, clientY: y } = e;
+ this.startCapturingMouseEvents();
+ this.setState({
+ initialPos: { x, y },
+ startTime: e.timeStamp,
+ });
+ };
+
+ private handleMouseMove = (e: MouseEvent) => {
+ const inputElement = this.ref.current;
+ const { x: x0 } = this.state.initialPos;
+ const { clientX: x, clientY: y } = e;
+ const dx = Math.abs(x0 - x);
+
+ if (dx < MOVE_THRESHOLD) {
+ return;
+ }
+
+ const isOn = !!this.props.isOn;
+ let nextOn = isOn;
+
+ if (x < x0 && isOn) {
+ nextOn = false;
+ } else if (x > x0 && !isOn) {
+ nextOn = true;
+ }
+
+ if (isOn !== nextOn) {
+ this.setState({
+ initialPos: { x, y },
+ ignoreChange: true,
+ });
+
+ if (inputElement) {
+ inputElement.checked = nextOn;
+ }
+
+ this.notify(nextOn);
+ }
+ };
+
+ private handleMouseUp = () => {
+ this.stopCapturingMouseEvents();
+ };
+
+ private handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const startTime = this.state.startTime;
+ const eventTarget = e.target;
+
+ if (typeof startTime !== 'number') {
+ throw new Error('startTime must be a number.');
+ }
+
+ const dt = e.timeStamp - startTime;
+
+ if (this.state.ignoreChange) {
+ this.setState({ ignoreChange: false });
+ e.preventDefault();
+ } else if (dt > CLICK_TIMEOUT) {
+ e.preventDefault();
+ } else {
+ this.notify(eventTarget.checked);
+ }
+ };
+
+ private notify(isOn: boolean) {
+ const onChange = this.props.onChange;
+ if (onChange) {
+ onChange(isOn);
+ }
+ }
+
+ private startCapturingMouseEvents() {
+ if (this.isCapturingMouseEvents) {
+ throw new Error('startCapturingMouseEvents() is called out of order.');
+ }
+ document.addEventListener('mousemove', this.handleMouseMove);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = true;
+ }
+
+ private stopCapturingMouseEvents() {
+ if (!this.isCapturingMouseEvents) {
+ throw new Error('stopCapturingMouseEvents() is called out of order.');
+ }
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ this.isCapturingMouseEvents = false;
+ }
+}
diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx
new file mode 100644
index 0000000000..a2632653d3
--- /dev/null
+++ b/gui/src/renderer/components/TransitionContainer.tsx
@@ -0,0 +1,229 @@
+import * as React from 'react';
+import { Animated, Component, Styles, Types, UserInterface, View } from 'reactxp';
+import { ITransitionGroupProps } from '../transitions';
+
+interface IProps extends ITransitionGroupProps {
+ children: React.ReactNode;
+}
+
+interface IState {
+ previousChildren?: React.ReactNode;
+ childrenAnimation?: Types.AnimatedViewStyleRuleSet;
+ previousChildrenAnimation?: Types.AnimatedViewStyleRuleSet;
+ dimensions: Types.Dimensions;
+ isAnimating: boolean;
+}
+
+const dimensions = UserInterface.measureWindow();
+const styles = {
+ animationDefaultStyle: Styles.createViewStyle({
+ position: 'absolute',
+ width: dimensions.width,
+ height: dimensions.height,
+ }),
+ allowPointerEventsStyle: Styles.createViewStyle({
+ // @ts-ignore
+ pointerEvents: 'auto',
+ }),
+ transitionContainerStyle: Styles.createViewStyle({
+ width: dimensions.width,
+ height: dimensions.height,
+ }),
+};
+
+export default class TransitionContainer extends Component<IProps, IState> {
+ public state: IState = {
+ dimensions: UserInterface.measureWindow(),
+ isAnimating: false,
+ };
+
+ public UNSAFE_componentWillReceiveProps(nextProps: IProps) {
+ switch (nextProps.name) {
+ case 'slide-up':
+ this.slideUpTransition(nextProps);
+ break;
+ case 'slide-down':
+ this.slideDownTransition(nextProps);
+ break;
+ case 'push':
+ this.pushTransition(nextProps);
+ break;
+ case 'pop':
+ this.popTransition(nextProps);
+ break;
+ default:
+ break;
+ }
+ }
+
+ public onFinishedAnimation = (_result: Types.Animated.EndResult) => {
+ this.setState({
+ childrenAnimation: styles.allowPointerEventsStyle,
+ previousChildren: null,
+ isAnimating: false,
+ });
+ };
+
+ public slideUpTransition(nextProps: IProps) {
+ const currentTranslationValue = Animated.createValue(this.state.dimensions.height);
+ this.setState(
+ {
+ previousChildren: this.props.children,
+ childrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 1,
+ transform: [{ translateY: currentTranslationValue }],
+ }),
+ previousChildrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 0,
+ transform: [{ translateY: Animated.createValue(0) }],
+ }),
+ isAnimating: true,
+ },
+ () => {
+ Animated.timing(currentTranslationValue, {
+ toValue: 0,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }).start(this.onFinishedAnimation);
+ },
+ );
+ }
+
+ public slideDownTransition(nextProps: IProps) {
+ const previousTranslationValue = Animated.createValue(0);
+ this.setState(
+ {
+ previousChildren: this.props.children,
+ childrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 0,
+ transform: [{ translateY: Animated.createValue(0) }],
+ }),
+ previousChildrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 1,
+ transform: [{ translateY: previousTranslationValue }],
+ }),
+ isAnimating: true,
+ },
+ () => {
+ Animated.timing(previousTranslationValue, {
+ toValue: this.state.dimensions.height,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }).start(this.onFinishedAnimation);
+ },
+ );
+ }
+
+ public pushTransition(nextProps: IProps) {
+ const currentTranslationValue = Animated.createValue(this.state.dimensions.width);
+ const previousTranslationValue = Animated.createValue(0);
+ this.setState(
+ {
+ previousChildren: this.props.children,
+ childrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 1,
+ transform: [{ translateX: currentTranslationValue }],
+ }),
+ previousChildrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 0,
+ transform: [{ translateX: previousTranslationValue }],
+ }),
+ isAnimating: true,
+ },
+ () => {
+ const compositeAnimation = Animated.parallel([
+ Animated.timing(currentTranslationValue, {
+ toValue: 0,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }),
+ Animated.timing(previousTranslationValue, {
+ toValue: -this.state.dimensions.width / 2,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }),
+ ]);
+ compositeAnimation.start(this.onFinishedAnimation);
+ },
+ );
+ }
+
+ public popTransition(nextProps: IProps) {
+ const currentTranslationValue = Animated.createValue(-this.state.dimensions.width / 2);
+ const previousTranslationValue = Animated.createValue(0);
+ this.setState(
+ {
+ previousChildren: this.props.children,
+ childrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 0,
+ transform: [{ translateX: currentTranslationValue }],
+ }),
+ previousChildrenAnimation: Styles.createAnimatedViewStyle({
+ // @ts-ignore
+ zIndex: 1,
+ transform: [{ translateX: previousTranslationValue }],
+ }),
+ isAnimating: true,
+ },
+ () => {
+ const compositeAnimation = Animated.parallel([
+ Animated.timing(currentTranslationValue, {
+ toValue: 0,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }),
+ Animated.timing(previousTranslationValue, {
+ toValue: this.state.dimensions.width,
+ easing: Animated.Easing.InOut(),
+ duration: nextProps.duration,
+ }),
+ ]);
+ compositeAnimation.start(this.onFinishedAnimation);
+ },
+ );
+ }
+
+ public render() {
+ const { children } = this.props;
+ const {
+ isAnimating,
+ previousChildren,
+ childrenAnimation,
+ previousChildrenAnimation,
+ } = this.state;
+
+ return (
+ <View style={styles.transitionContainerStyle} ignorePointerEvents={isAnimating}>
+ {previousChildren && (
+ <Animated.View
+ key={getChildKey(previousChildren)}
+ style={[styles.animationDefaultStyle, previousChildrenAnimation]}>
+ {previousChildren}
+ </Animated.View>
+ )}
+
+ <Animated.View
+ key={getChildKey(children)}
+ style={[styles.animationDefaultStyle, childrenAnimation]}>
+ {children}
+ </Animated.View>
+ </View>
+ );
+ }
+}
+
+function getChildKey(child?: React.ReactNode): string | number | undefined {
+ return child &&
+ typeof child === 'object' &&
+ 'key' in child &&
+ (typeof child.key === 'string' || typeof child.key === 'number')
+ ? child.key
+ : undefined;
+}
diff --git a/gui/src/renderer/components/TunnelControl.tsx b/gui/src/renderer/components/TunnelControl.tsx
new file mode 100644
index 0000000000..862b22cf05
--- /dev/null
+++ b/gui/src/renderer/components/TunnelControl.tsx
@@ -0,0 +1,272 @@
+import * as React from 'react';
+import { Component, Styles, Text, Types, View } from 'reactxp';
+import { colors } from '../../config.json';
+import { pgettext } from '../../shared/gettext';
+import * as AppButton from './AppButton';
+import ConnectionInfo from './ConnectionInfo';
+import SecuredLabel, { SecuredDisplayStyle } from './SecuredLabel';
+
+import { RelayProtocol, TunnelStateTransition } from '../../shared/daemon-rpc-types';
+
+export interface IRelayInAddress {
+ ip: string;
+ port: number;
+ protocol: RelayProtocol;
+}
+
+export interface IRelayOutAddress {
+ ipv4?: string;
+ ipv6?: string;
+}
+
+interface ITunnelControlProps {
+ tunnelState: TunnelStateTransition;
+ selectedRelayName: string;
+ city?: string;
+ country?: string;
+ hostname?: string;
+ defaultConnectionInfoOpen?: boolean;
+ relayInAddress?: IRelayInAddress;
+ relayOutAddress?: IRelayOutAddress;
+ onConnect: () => void;
+ onDisconnect: () => void;
+ onSelectLocation: () => void;
+ onToggleConnectionInfo: (value: boolean) => void;
+}
+
+const styles = {
+ body: Styles.createViewStyle({
+ paddingTop: 0,
+ paddingLeft: 24,
+ paddingRight: 24,
+ paddingBottom: 0,
+ marginTop: 186,
+ flex: 1,
+ }),
+ footer: Styles.createViewStyle({
+ flex: 0,
+ paddingBottom: 16,
+ paddingLeft: 24,
+ paddingRight: 24,
+ }),
+ wrapper: Styles.createViewStyle({
+ flex: 1,
+ }),
+ switch_location_button: Styles.createViewStyle({
+ marginBottom: 16,
+ }),
+ status_security: Styles.createTextStyle({
+ fontFamily: 'Open Sans',
+ fontSize: 16,
+ fontWeight: '800',
+ lineHeight: 22,
+ marginBottom: 4,
+ }),
+ status_location: Styles.createTextStyle({
+ flexDirection: 'column',
+ marginBottom: 4,
+ }),
+ status_location_text: Styles.createTextStyle({
+ fontFamily: 'DINPro',
+ fontSize: 34,
+ lineHeight: 36,
+ fontWeight: '900',
+ overflow: 'hidden',
+ letterSpacing: -0.9,
+ color: colors.white,
+ }),
+};
+
+export default class TunnelControl extends Component<ITunnelControlProps> {
+ public render() {
+ const Location = ({ children }: { children?: React.ReactNode }) => (
+ <View style={styles.status_location}>{children}</View>
+ );
+ const City = () => <Text style={styles.status_location_text}>{this.props.city}</Text>;
+ const Country = () => <Text style={styles.status_location_text}>{this.props.country}</Text>;
+
+ const SwitchLocation = () => {
+ return (
+ <AppButton.TransparentButton
+ style={styles.switch_location_button}
+ onPress={this.props.onSelectLocation}>
+ {pgettext('tunnel-control', 'Switch location')}
+ </AppButton.TransparentButton>
+ );
+ };
+
+ const SelectedLocation = () => (
+ <AppButton.TransparentButton
+ style={styles.switch_location_button}
+ onPress={this.props.onSelectLocation}>
+ <AppButton.Label>{this.props.selectedRelayName}</AppButton.Label>
+ <AppButton.Icon height={12} width={7} source="icon-chevron" />
+ </AppButton.TransparentButton>
+ );
+
+ const Connect = () => (
+ <AppButton.GreenButton onPress={this.props.onConnect}>
+ {pgettext('tunnel-control', 'Secure my connection')}
+ </AppButton.GreenButton>
+ );
+
+ const Disconnect = () => (
+ <AppButton.RedTransparentButton onPress={this.props.onDisconnect}>
+ {pgettext('tunnel-control', 'Disconnect')}
+ </AppButton.RedTransparentButton>
+ );
+
+ const Cancel = () => (
+ <AppButton.RedTransparentButton onPress={this.props.onDisconnect}>
+ {pgettext('tunnel-control', 'Cancel')}
+ </AppButton.RedTransparentButton>
+ );
+
+ const Secured = ({ displayStyle }: { displayStyle: SecuredDisplayStyle }) => (
+ <SecuredLabel style={styles.status_security} displayStyle={displayStyle} />
+ );
+ const Footer = ({ children }: { children: React.ReactNode }) => (
+ <View style={styles.footer}>{children}</View>
+ );
+
+ const connectionDetails = (
+ <ConnectionInfo
+ hostname={this.props.hostname}
+ inAddress={this.props.relayInAddress}
+ outAddress={this.props.relayOutAddress}
+ defaultOpen={this.props.defaultConnectionInfoOpen}
+ onToggle={this.props.onToggleConnectionInfo}
+ />
+ );
+
+ let state = this.props.tunnelState.state;
+
+ switch (this.props.tunnelState.state) {
+ case 'blocked':
+ switch (this.props.tunnelState.details.reason) {
+ case 'set_firewall_policy_error':
+ state = 'disconnected';
+ break;
+ default:
+ state = 'blocked';
+ break;
+ }
+ break;
+
+ case 'disconnecting':
+ switch (this.props.tunnelState.details) {
+ case 'block':
+ state = 'blocked';
+ break;
+ case 'reconnect':
+ state = 'connecting';
+ break;
+ default:
+ state = 'disconnecting';
+ break;
+ }
+ break;
+ }
+
+ switch (state) {
+ case 'connecting':
+ return (
+ <Wrapper>
+ <Body>
+ <Secured displayStyle={SecuredDisplayStyle.securing} />
+ <Location>
+ <City />
+ <Country />
+ </Location>
+ {connectionDetails}
+ </Body>
+ <Footer>
+ <SwitchLocation />
+ <Cancel />
+ </Footer>
+ </Wrapper>
+ );
+ case 'connected':
+ return (
+ <Wrapper>
+ <Body>
+ <Secured displayStyle={SecuredDisplayStyle.secured} />
+ <Location>
+ <City />
+ <Country />
+ </Location>
+ {connectionDetails}
+ </Body>
+ <Footer>
+ <SwitchLocation />
+ <Disconnect />
+ </Footer>
+ </Wrapper>
+ );
+
+ case 'blocked':
+ return (
+ <Wrapper>
+ <Body>
+ <Secured displayStyle={SecuredDisplayStyle.blocked} />
+ </Body>
+ <Footer>
+ <SwitchLocation />
+ <Cancel />
+ </Footer>
+ </Wrapper>
+ );
+
+ case 'disconnecting':
+ return (
+ <Wrapper>
+ <Body>
+ <Secured displayStyle={SecuredDisplayStyle.secured} />
+ <Location>
+ <Country />
+ </Location>
+ </Body>
+ <Footer>
+ <SelectedLocation />
+ <Connect />
+ </Footer>
+ </Wrapper>
+ );
+
+ case 'disconnected':
+ return (
+ <Wrapper>
+ <Body>
+ <Secured displayStyle={SecuredDisplayStyle.unsecured} />
+ <Location>
+ <Country />
+ </Location>
+ </Body>
+ <Footer>
+ <SelectedLocation />
+ <Connect />
+ </Footer>
+ </Wrapper>
+ );
+
+ default:
+ throw new Error(`Unknown TunnelState: ${this.props.tunnelState}`);
+ }
+ }
+}
+
+interface IContainerProps {
+ children?: Types.ReactNode;
+}
+
+class Wrapper extends Component<IContainerProps> {
+ public render() {
+ return <View style={styles.wrapper}>{this.props.children}</View>;
+ }
+}
+
+class Body extends Component<IContainerProps> {
+ public render() {
+ return <View style={styles.body}>{this.props.children}</View>;
+ }
+}
diff --git a/gui/src/renderer/containers/AccountPage.tsx b/gui/src/renderer/containers/AccountPage.tsx
new file mode 100644
index 0000000000..33dad1e1cb
--- /dev/null
+++ b/gui/src/renderer/containers/AccountPage.tsx
@@ -0,0 +1,33 @@
+import { goBack } from 'connected-react-router';
+import { remote, shell } from 'electron';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { links } from '../../config.json';
+import Account from '../components/Account';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ accountToken: state.account.accountToken,
+ accountExpiry: state.account.expiry,
+ expiryLocale: remote.app.getLocale(),
+ isOffline: state.connection.isBlocked,
+});
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ goBack }, dispatch);
+ return {
+ onLogout: () => {
+ props.app.logout();
+ },
+ onClose: () => {
+ history.goBack();
+ },
+ onBuyMore: () => shell.openExternal(links.purchase),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Account);
diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx
new file mode 100644
index 0000000000..5fbdd57b25
--- /dev/null
+++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx
@@ -0,0 +1,98 @@
+import { goBack } from 'connected-react-router';
+import log from 'electron-log';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { RelayProtocol } from '../../shared/daemon-rpc-types';
+import AdvancedSettings from '../components/AdvancedSettings';
+import RelaySettingsBuilder from '../lib/relay-settings-builder';
+
+import { RelaySettingsRedux } from '../redux/settings/reducers';
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => {
+ const protocolAndPort = mapRelaySettingsToProtocolAndPort(state.settings.relaySettings);
+
+ return {
+ enableIpv6: state.settings.enableIpv6,
+ blockWhenDisconnected: state.settings.blockWhenDisconnected,
+ mssfix: state.settings.openVpn.mssfix,
+ ...protocolAndPort,
+ };
+};
+
+const mapRelaySettingsToProtocolAndPort = (relaySettings: RelaySettingsRedux) => {
+ if ('normal' in relaySettings) {
+ const { protocol, port } = relaySettings.normal;
+ return {
+ protocol: protocol === 'any' ? undefined : protocol,
+ port: port === 'any' ? undefined : port,
+ };
+ } else if ('customTunnelEndpoint' in relaySettings) {
+ const { protocol, port } = relaySettings.customTunnelEndpoint;
+ return { protocol, port };
+ } else {
+ throw new Error('Unknown type of relay settings.');
+ }
+};
+
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ goBack }, dispatch);
+ return {
+ onClose: () => {
+ history.goBack();
+ },
+ setRelayProtocolAndPort: async (protocol?: RelayProtocol, port?: number) => {
+ const relayUpdate = RelaySettingsBuilder.normal()
+ .tunnel.openvpn((openvpn) => {
+ if (protocol) {
+ openvpn.protocol.exact(protocol);
+ } else {
+ openvpn.protocol.any();
+ }
+
+ if (port) {
+ openvpn.port.exact(port);
+ } else {
+ openvpn.port.any();
+ }
+ })
+ .build();
+
+ try {
+ await props.app.updateRelaySettings(relayUpdate);
+ } catch (e) {
+ log.error('Failed to update relay settings', e.message);
+ }
+ },
+
+ setEnableIpv6: async (enableIpv6: boolean) => {
+ try {
+ await props.app.setEnableIpv6(enableIpv6);
+ } catch (e) {
+ log.error('Failed to update enable IPv6', e.message);
+ }
+ },
+
+ setBlockWhenDisconnected: async (blockWhenDisconnected: boolean) => {
+ try {
+ await props.app.setBlockWhenDisconnected(blockWhenDisconnected);
+ } catch (e) {
+ log.error('Failed to update block when disconnected', e.message);
+ }
+ },
+
+ setOpenVpnMssfix: async (mssfix?: number) => {
+ try {
+ await props.app.setOpenVpnMssfix(mssfix);
+ } catch (e) {
+ log.error('Failed to update mssfix value', e.message);
+ }
+ },
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(AdvancedSettings);
diff --git a/gui/src/renderer/containers/ConnectPage.tsx b/gui/src/renderer/containers/ConnectPage.tsx
new file mode 100644
index 0000000000..cb78c2e8b3
--- /dev/null
+++ b/gui/src/renderer/containers/ConnectPage.tsx
@@ -0,0 +1,118 @@
+import { push } from 'connected-react-router';
+import { shell } from 'electron';
+import log from 'electron-log';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { sprintf } from 'sprintf-js';
+import { pgettext } from '../../shared/gettext';
+import Connect from '../components/Connect';
+import AccountExpiry from '../lib/account-expiry';
+import userInterfaceActions from '../redux/userinterface/actions';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers';
+
+function getRelayName(
+ relaySettings: RelaySettingsRedux,
+ relayLocations: IRelayLocationRedux[],
+): string {
+ if ('normal' in relaySettings) {
+ const location = relaySettings.normal.location;
+
+ if (location === 'any') {
+ return 'Automatic';
+ } else if ('country' in location) {
+ const country = relayLocations.find(({ code }) => code === location.country);
+ if (country) {
+ // TODO: translate
+ return country.name;
+ }
+ } else if ('city' in location) {
+ const [countryCode, cityCode] = location.city;
+ const country = relayLocations.find(({ code }) => code === countryCode);
+ if (country) {
+ const city = country.cities.find(({ code }) => code === cityCode);
+ if (city) {
+ // TODO: translate
+ return city.name;
+ }
+ }
+ } else if ('hostname' in location) {
+ const [countryCode, cityCode, hostname] = location.hostname;
+ const country = relayLocations.find(({ code }) => code === countryCode);
+ if (country) {
+ const city = country.cities.find(({ code }) => code === cityCode);
+ if (city) {
+ return sprintf(
+ // TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to.
+ // TRANSLATORS: Example: Malmö (se-mma-001)
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(city)s - a city name
+ // TRANSLATORS: %(hostname)s - a hostname
+ pgettext('connect-container', '%(city)s (%(hostname)s)'),
+ {
+ city: city.name,
+ hostname,
+ },
+ );
+ }
+ }
+ }
+
+ return 'Unknown';
+ } else if (relaySettings.customTunnelEndpoint) {
+ return 'Custom';
+ } else {
+ throw new Error('Unsupported relay settings.');
+ }
+}
+
+const mapStateToProps = (state: IReduxState) => {
+ return {
+ accountExpiry: state.account.expiry ? new AccountExpiry(state.account.expiry) : undefined,
+ selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations),
+ connection: state.connection,
+ version: state.version,
+ connectionInfoOpen: state.userInterface.connectionInfoOpen,
+ blockWhenDisconnected: state.settings.blockWhenDisconnected,
+ };
+};
+
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const userInterface = bindActionCreators(userInterfaceActions, dispatch);
+ const history = bindActionCreators({ push }, dispatch);
+
+ return {
+ onToggleConnectionInfo: (isOpen: boolean) => {
+ userInterface.updateConnectionInfoOpen(isOpen);
+ },
+ onSettings: () => {
+ history.push('/settings');
+ },
+ onSelectLocation: () => {
+ history.push('/select-location');
+ },
+ onConnect: async () => {
+ try {
+ await props.app.connectTunnel();
+ } catch (error) {
+ log.error(`Failed to connect the tunnel: ${error.message}`);
+ }
+ },
+ onDisconnect: () => {
+ try {
+ props.app.disconnectTunnel();
+ } catch (error) {
+ log.error(`Failed to disconnect the tunnel: ${error.message}`);
+ }
+ },
+ onExternalLink: (url: string) => shell.openExternal(url),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Connect);
diff --git a/gui/src/renderer/containers/LaunchPage.tsx b/gui/src/renderer/containers/LaunchPage.tsx
new file mode 100644
index 0000000000..8619b33d87
--- /dev/null
+++ b/gui/src/renderer/containers/LaunchPage.tsx
@@ -0,0 +1,22 @@
+import { push } from 'connected-react-router';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import Launch from '../components/Launch';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (_state: IReduxState) => ({});
+const mapDispatchToProps = (dispatch: ReduxDispatch, _props: ISharedRouteProps) => {
+ const history = bindActionCreators({ push }, dispatch);
+ return {
+ openSettings: () => {
+ history.push('/settings');
+ },
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Launch);
diff --git a/gui/src/renderer/containers/LoginPage.tsx b/gui/src/renderer/containers/LoginPage.tsx
new file mode 100644
index 0000000000..e1f70fbedb
--- /dev/null
+++ b/gui/src/renderer/containers/LoginPage.tsx
@@ -0,0 +1,42 @@
+import { push } from 'connected-react-router';
+import { shell } from 'electron';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import Login from '../components/Login';
+import accountActions from '../redux/account/actions';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => {
+ const { accountToken, accountHistory, error, status } = state.account;
+ return {
+ accountToken,
+ accountHistory,
+ loginError: error,
+ loginState: status,
+ };
+};
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ push }, dispatch);
+ const { resetLoginError, updateAccountToken } = bindActionCreators(accountActions, dispatch);
+ return {
+ openSettings: () => {
+ history.push('/settings');
+ },
+ login: (account: string) => {
+ props.app.login(account);
+ },
+ resetLoginError: () => {
+ resetLoginError();
+ },
+ openExternalLink: (url: string) => shell.openExternal(url),
+ updateAccountToken,
+ removeAccountTokenFromHistory: (token: string) => props.app.removeAccountFromHistory(token),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Login);
diff --git a/gui/src/renderer/containers/PlatformWindowContainer.tsx b/gui/src/renderer/containers/PlatformWindowContainer.tsx
new file mode 100644
index 0000000000..f39cd82600
--- /dev/null
+++ b/gui/src/renderer/containers/PlatformWindowContainer.tsx
@@ -0,0 +1,10 @@
+import { connect } from 'react-redux';
+import PlatformWindow from '../components/PlatformWindow';
+
+import { IReduxState } from '../redux/store';
+
+const mapStateToProps = (state: IReduxState) => ({
+ arrowPosition: state.userInterface.arrowPosition,
+});
+
+export default connect(mapStateToProps)(PlatformWindow);
diff --git a/gui/src/renderer/containers/PreferencesPage.tsx b/gui/src/renderer/containers/PreferencesPage.tsx
new file mode 100644
index 0000000000..4147b42cfc
--- /dev/null
+++ b/gui/src/renderer/containers/PreferencesPage.tsx
@@ -0,0 +1,55 @@
+import { goBack } from 'connected-react-router';
+import log from 'electron-log';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import Preferences from '../components/Preferences';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ autoStart: state.settings.autoStart,
+ autoConnect: state.settings.guiSettings.autoConnect,
+ allowLan: state.settings.allowLan,
+ monochromaticIcon: state.settings.guiSettings.monochromaticIcon,
+ startMinimized: state.settings.guiSettings.startMinimized,
+});
+
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ goBack }, dispatch);
+ return {
+ onClose: () => {
+ history.goBack();
+ },
+ setAutoStart: async (autoStart: boolean) => {
+ try {
+ await props.app.setAutoStart(autoStart);
+ } catch (error) {
+ log.error(`Cannot set auto-start: ${error.message}`);
+ }
+ },
+ setAutoConnect: async (autoConnect: boolean) => {
+ try {
+ props.app.setAutoConnect(autoConnect);
+ } catch (error) {
+ log.error(`Cannot set auto-connect: ${error.message}`);
+ }
+ },
+ setAllowLan: (allowLan: boolean) => {
+ props.app.setAllowLan(allowLan);
+ },
+ setStartMinimized: (startMinimized: boolean) => {
+ props.app.setStartMinimized(startMinimized);
+ },
+ enableStartMinimizedToggle: process.platform === 'linux',
+ setMonochromaticIcon: (monochromaticIcon: boolean) => {
+ props.app.setMonochromaticIcon(monochromaticIcon);
+ },
+ enableMonochromaticIconToggle: process.platform === 'darwin',
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Preferences);
diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx
new file mode 100644
index 0000000000..a517c34b3f
--- /dev/null
+++ b/gui/src/renderer/containers/SelectLocationPage.tsx
@@ -0,0 +1,41 @@
+import { goBack } from 'connected-react-router';
+import log from 'electron-log';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { RelayLocation } from '../../shared/daemon-rpc-types';
+import SelectLocation from '../components/SelectLocation';
+import RelaySettingsBuilder from '../lib/relay-settings-builder';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ relaySettings: state.settings.relaySettings,
+ relayLocations: state.settings.relayLocations,
+});
+const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
+ const history = bindActionCreators({ goBack }, dispatch);
+ return {
+ onClose: () => history.goBack(),
+ onSelect: async (relayLocation: RelayLocation) => {
+ // dismiss the view first
+ history.goBack();
+
+ try {
+ const relayUpdate = RelaySettingsBuilder.normal()
+ .location.fromRaw(relayLocation)
+ .build();
+
+ await props.app.updateRelaySettings(relayUpdate);
+ await props.app.connectTunnel();
+ } catch (e) {
+ log.error(`Failed to select server: ${e.message}`);
+ }
+ },
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(SelectLocation);
diff --git a/gui/src/renderer/containers/SettingsPage.tsx b/gui/src/renderer/containers/SettingsPage.tsx
new file mode 100644
index 0000000000..da4a10cdb2
--- /dev/null
+++ b/gui/src/renderer/containers/SettingsPage.tsx
@@ -0,0 +1,34 @@
+import { goBack, push } from 'connected-react-router';
+import { remote, shell } from 'electron';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import Settings from '../components/Settings';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ loginState: state.account.status,
+ accountExpiry: state.account.expiry,
+ appVersion: state.version.current,
+ consistentVersion: state.version.consistent,
+ upToDateVersion: state.version.upToDate,
+ isOffline: state.connection.isBlocked,
+});
+const mapDispatchToProps = (dispatch: ReduxDispatch, _props: ISharedRouteProps) => {
+ const history = bindActionCreators({ push, goBack }, dispatch);
+ return {
+ onQuit: () => remote.app.quit(),
+ onClose: () => history.goBack(),
+ onViewAccount: () => history.push('/settings/account'),
+ onViewSupport: () => history.push('/settings/support'),
+ onViewPreferences: () => history.push('/settings/preferences'),
+ onViewAdvancedSettings: () => history.push('/settings/advanced'),
+ onExternalLink: (url: string) => shell.openExternal(url),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Settings);
diff --git a/gui/src/renderer/containers/SupportPage.tsx b/gui/src/renderer/containers/SupportPage.tsx
new file mode 100644
index 0000000000..00be7fee44
--- /dev/null
+++ b/gui/src/renderer/containers/SupportPage.tsx
@@ -0,0 +1,38 @@
+import { goBack } from 'connected-react-router';
+import { shell } from 'electron';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import Support from '../components/Support';
+import { collectProblemReport, sendProblemReport } from '../lib/problem-report';
+
+import { IReduxState, ReduxDispatch } from '../redux/store';
+import supportActions from '../redux/support/actions';
+import { ISharedRouteProps } from '../routes';
+
+const mapStateToProps = (state: IReduxState) => ({
+ defaultEmail: state.support.email,
+ defaultMessage: state.support.message,
+ accountHistory: state.account.accountHistory,
+ isOffline: state.connection.isBlocked,
+});
+
+const mapDispatchToProps = (dispatch: ReduxDispatch, _props: ISharedRouteProps) => {
+ const { saveReportForm, clearReportForm } = bindActionCreators(supportActions, dispatch);
+ const history = bindActionCreators({ goBack }, dispatch);
+
+ return {
+ onClose: () => {
+ history.goBack();
+ },
+ viewLog: (path: string) => shell.openItem(path),
+ saveReportForm,
+ clearReportForm,
+ collectProblemReport,
+ sendProblemReport,
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Support);
diff --git a/gui/src/renderer/index.html b/gui/src/renderer/index.html
new file mode 100644
index 0000000000..c88955b3a2
--- /dev/null
+++ b/gui/src/renderer/index.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Mullvad VPN</title>
+ <link rel="stylesheet" href="../../assets/css/style.css" />
+ </head>
+ <body>
+ <div id="app" class="app-container"></div>
+ <script>var exports = {};</script>
+ <script src="./index.js"></script>
+ <script>
+ if (process.env.BROWSER_SYNC_CLIENT_URL) {
+ const current = document.currentScript;
+ const script = document.createElement('script');
+ script.src = process.env.BROWSER_SYNC_CLIENT_URL;
+ script.async = true;
+ current.parentNode.insertBefore(script, current);
+ }
+ </script>
+ </body>
+</html>
diff --git a/gui/src/renderer/index.ts b/gui/src/renderer/index.ts
new file mode 100644
index 0000000000..aada6fd97b
--- /dev/null
+++ b/gui/src/renderer/index.ts
@@ -0,0 +1,9 @@
+import * as RX from 'reactxp';
+import App from './app';
+
+const app = new App();
+const view = app.renderView();
+
+RX.App.initialize(true, true);
+RX.UserInterface.setMainView(view);
+RX.UserInterface.useCustomScrollbars(true);
diff --git a/gui/src/renderer/lib/account-expiry.ts b/gui/src/renderer/lib/account-expiry.ts
new file mode 100644
index 0000000000..e781caeffe
--- /dev/null
+++ b/gui/src/renderer/lib/account-expiry.ts
@@ -0,0 +1,31 @@
+import moment from 'moment';
+import { sprintf } from 'sprintf-js';
+import { pgettext } from '../../shared/gettext';
+
+export default class AccountExpiry {
+ private expiry: moment.Moment;
+
+ constructor(expiry: string) {
+ this.expiry = moment(expiry);
+ }
+
+ public hasExpired(): boolean {
+ return this.willHaveExpiredIn(moment());
+ }
+
+ public willHaveExpiredIn(time: moment.Moment): boolean {
+ return this.expiry.isSameOrBefore(time);
+ }
+
+ public remainingTime(): string {
+ const duration = this.expiry.fromNow(true);
+
+ return sprintf(
+ // TRANSLATORS: The remaining time left on the account displayed across the app.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(duration)s - a localized remaining time (in minutes, hours, or days) until the account expiry
+ pgettext('account-expiry', '%(duration)s left'),
+ { duration },
+ );
+ }
+}
diff --git a/gui/src/renderer/lib/auth-failure.ts b/gui/src/renderer/lib/auth-failure.ts
new file mode 100644
index 0000000000..7ac9b4b527
--- /dev/null
+++ b/gui/src/renderer/lib/auth-failure.ts
@@ -0,0 +1,92 @@
+import log from 'electron-log';
+import { pgettext } from '../../shared/gettext';
+
+export enum AuthFailureKind {
+ invalidAccount,
+ expiredAccount,
+ tooManyConnections,
+ unknown,
+}
+
+export class AuthFailureError extends Error {
+ private kindValue: AuthFailureKind;
+ private unknownErrorMessage?: string;
+
+ get kind(): AuthFailureKind {
+ return this.kindValue;
+ }
+
+ get message(): string {
+ switch (this.kindValue) {
+ case AuthFailureKind.invalidAccount:
+ return pgettext(
+ 'auth-failure',
+ "You've logged in with an account number that is not valid. Please log out and try another one.",
+ );
+
+ case AuthFailureKind.expiredAccount:
+ return pgettext(
+ 'auth-failure',
+ 'You have no more VPN time left on this account. Please log in on our website to buy more credit.',
+ );
+
+ case AuthFailureKind.tooManyConnections:
+ return pgettext(
+ 'auth-failure',
+ 'This account has too many simultaneous connections. Disconnect another device or try connecting again shortly.',
+ );
+
+ case AuthFailureKind.unknown:
+ return (
+ this.unknownErrorMessage || pgettext('auth-failure', 'Account authentication failed.')
+ );
+ }
+ }
+
+ constructor(reason?: string) {
+ super();
+
+ if (!reason) {
+ log.error('Received invalid auth_failed reason: ', reason);
+
+ this.kindValue = AuthFailureKind.unknown;
+ return;
+ }
+
+ const results = /^\[(\w+)\]\s*(.*)$/.exec(reason);
+
+ if (results && results.length === 3) {
+ const rawReasonId = results[1];
+ const kindValue = rawReasonIdToFailureKind(rawReasonId);
+
+ if (kindValue === AuthFailureKind.unknown) {
+ log.error(`Received unknown auth_failed message id - ${rawReasonId}`);
+ }
+
+ this.kindValue = kindValue;
+ this.unknownErrorMessage = results[2];
+ } else {
+ log.error(`Received invalid auth_failed message - "${reason}"`);
+
+ this.kindValue = AuthFailureKind.unknown;
+ this.unknownErrorMessage = reason;
+ }
+ }
+}
+
+function rawReasonIdToFailureKind(id: string): AuthFailureKind {
+ // These strings should match up with mullvad-types/src/auth_failed.rs
+ switch (id) {
+ case 'INVALID_ACCOUNT':
+ return AuthFailureKind.invalidAccount;
+
+ case 'EXPIRED_ACCOUNT':
+ return AuthFailureKind.expiredAccount;
+
+ case 'TOO_MANY_CONNECTIONS':
+ return AuthFailureKind.tooManyConnections;
+
+ default:
+ return AuthFailureKind.unknown;
+ }
+}
diff --git a/gui/src/renderer/lib/problem-report.ts b/gui/src/renderer/lib/problem-report.ts
new file mode 100644
index 0000000000..7213ed8d07
--- /dev/null
+++ b/gui/src/renderer/lib/problem-report.ts
@@ -0,0 +1,53 @@
+import { ipcRenderer } from 'electron';
+import * as uuid from 'uuid';
+
+interface IErrorResult {
+ success: false;
+ error: string;
+}
+type CollectResult = { success: true; reportPath: string } | IErrorResult;
+type SendResult = { success: true } | IErrorResult;
+
+const collectProblemReport = (toRedact: string[]): Promise<string> => {
+ return new Promise((resolve, reject) => {
+ const requestId = uuid.v4();
+ const responseListener = (
+ _event: Electron.Event,
+ responseId: string,
+ result: CollectResult,
+ ) => {
+ if (responseId === requestId) {
+ ipcRenderer.removeListener('collect-logs-reply', responseListener);
+ if (result.success) {
+ resolve(result.reportPath);
+ } else {
+ reject(new Error(result.error));
+ }
+ }
+ };
+
+ ipcRenderer.on('collect-logs-reply', responseListener);
+ ipcRenderer.send('collect-logs', requestId, toRedact);
+ });
+};
+
+const sendProblemReport = (email: string, message: string, savedReport: string): Promise<void> => {
+ return new Promise((resolve, reject) => {
+ const requestId = uuid.v4();
+ const responseListener = (_event: Electron.Event, responseId: string, result: SendResult) => {
+ if (requestId === responseId) {
+ ipcRenderer.removeListener('send-problem-report-reply', responseListener);
+ if (result.success) {
+ resolve();
+ } else {
+ reject(new Error(result.error));
+ }
+ }
+ };
+
+ ipcRenderer.on('send-problem-report-reply', responseListener);
+ ipcRenderer.send('send-problem-report', requestId, email, message, savedReport);
+ });
+};
+
+export { collectProblemReport, sendProblemReport };
diff --git a/gui/src/renderer/lib/relay-settings-builder.ts b/gui/src/renderer/lib/relay-settings-builder.ts
new file mode 100644
index 0000000000..72dc95e759
--- /dev/null
+++ b/gui/src/renderer/lib/relay-settings-builder.ts
@@ -0,0 +1,139 @@
+import {
+ IOpenVpnConstraints,
+ RelayLocation,
+ RelayProtocol,
+ RelaySettingsNormalUpdate,
+ RelaySettingsUpdate,
+} from '../../shared/daemon-rpc-types';
+
+interface ILocationBuilder<Self> {
+ country: (country: string) => Self;
+ city: (country: string, city: string) => Self;
+ hostname: (country: string, city: string, hostname: string) => Self;
+ any: () => Self;
+ fromRaw: (location: 'any' | RelayLocation) => Self;
+}
+
+interface IExactOrAny<T, Self> {
+ exact(value: T): Self;
+ any(): Self;
+}
+
+interface IOpenVPNConfigurator {
+ port: IExactOrAny<number, IOpenVPNConfigurator>;
+ protocol: IExactOrAny<RelayProtocol, IOpenVPNConfigurator>;
+}
+
+interface ITunnelBuilder {
+ openvpn(
+ configurator: (openVpnConfigurator: IOpenVPNConfigurator) => void,
+ ): NormalRelaySettingsBuilder;
+ any(): NormalRelaySettingsBuilder;
+}
+
+class NormalRelaySettingsBuilder {
+ private payload: RelaySettingsNormalUpdate = {};
+
+ public build(): RelaySettingsUpdate {
+ return {
+ normal: this.payload,
+ };
+ }
+
+ get location(): ILocationBuilder<NormalRelaySettingsBuilder> {
+ return {
+ country: (country: string) => {
+ this.payload.location = { only: { country } };
+ return this;
+ },
+ city: (country: string, city: string) => {
+ this.payload.location = { only: { city: [country, city] } };
+ return this;
+ },
+ hostname: (country: string, city: string, hostname: string) => {
+ this.payload.location = { only: { hostname: [country, city, hostname] } };
+ return this;
+ },
+ any: () => {
+ this.payload.location = 'any';
+ return this;
+ },
+ fromRaw(location: 'any' | RelayLocation) {
+ if (location === 'any') {
+ return this.any();
+ } else if ('hostname' in location) {
+ const [country, city, hostname] = location.hostname;
+ return this.hostname(country, city, hostname);
+ } else if ('city' in location) {
+ const [country, city] = location.city;
+ return this.city(country, city);
+ } else if ('country' in location) {
+ return this.country(location.country);
+ }
+
+ throw new Error(
+ 'Unsupported value of RelayLocation' + (location && JSON.stringify(location)),
+ );
+ },
+ };
+ }
+
+ get tunnel(): ITunnelBuilder {
+ const updateOpenvpn = (next: Partial<IOpenVpnConstraints>) => {
+ const tunnel = this.payload.tunnel;
+ if (typeof tunnel === 'string' || typeof tunnel === 'undefined') {
+ this.payload.tunnel = {
+ only: {
+ openvpn: next,
+ },
+ };
+ } else if (typeof tunnel === 'object') {
+ const prev = tunnel.only && 'openvpn' in tunnel.only ? tunnel.only.openvpn : {};
+ this.payload.tunnel = {
+ only: {
+ openvpn: { ...prev, ...next },
+ },
+ };
+ }
+ };
+
+ return {
+ openvpn: (configurator: (configurator: IOpenVPNConfigurator) => void) => {
+ const openvpnBuilder: IOpenVPNConfigurator = {
+ get port() {
+ const apply = (port: 'any' | { only: number }) => {
+ updateOpenvpn({ port });
+ return this;
+ };
+ return {
+ exact: (value: number) => apply({ only: value }),
+ any: () => apply('any'),
+ };
+ },
+ get protocol() {
+ const apply = (protocol: 'any' | { only: RelayProtocol }) => {
+ updateOpenvpn({ protocol });
+ return this;
+ };
+ return {
+ exact: (value: RelayProtocol) => apply({ only: value }),
+ any: () => apply('any'),
+ };
+ },
+ };
+
+ configurator(openvpnBuilder);
+
+ return this;
+ },
+ any: () => {
+ this.payload.tunnel = 'any';
+ return this;
+ },
+ };
+ }
+}
+
+export default {
+ normal: () => new NormalRelaySettingsBuilder(),
+};
diff --git a/gui/src/renderer/lib/transition-rule.ts b/gui/src/renderer/lib/transition-rule.ts
new file mode 100644
index 0000000000..ae0e2ad5b7
--- /dev/null
+++ b/gui/src/renderer/lib/transition-rule.ts
@@ -0,0 +1,36 @@
+export interface ITransitionDescriptor {
+ name: string;
+ duration: number;
+}
+
+export interface ITransitionFork {
+ forward: ITransitionDescriptor;
+ backward: ITransitionDescriptor;
+}
+
+export interface ITransitionMatch {
+ direction: 'forward' | 'backward';
+ descriptor: ITransitionDescriptor;
+}
+
+export default class TransitionRule {
+ constructor(private from: string | null, private to: string, private fork: ITransitionFork) {}
+
+ public match(fromRoute: string | null, toRoute: string): ITransitionMatch | null {
+ if ((!this.from || this.from === fromRoute) && this.to === toRoute) {
+ return {
+ direction: 'forward',
+ descriptor: this.fork.forward,
+ };
+ }
+
+ if ((!this.from || this.from === toRoute) && this.to === fromRoute) {
+ return {
+ direction: 'backward',
+ descriptor: this.fork.backward,
+ };
+ }
+
+ return null;
+ }
+}
diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts
new file mode 100644
index 0000000000..8dba590737
--- /dev/null
+++ b/gui/src/renderer/redux/account/actions.ts
@@ -0,0 +1,112 @@
+import { AccountToken } from '../../../shared/daemon-rpc-types';
+
+interface IStartLoginAction {
+ type: 'START_LOGIN';
+ accountToken: AccountToken;
+}
+
+interface ILoggedInAction {
+ type: 'LOGGED_IN';
+}
+
+interface ILoginFailedAction {
+ type: 'LOGIN_FAILED';
+ error: Error;
+}
+
+interface ILoggedOutAction {
+ type: 'LOGGED_OUT';
+}
+
+interface IResetLoginErrorAction {
+ type: 'RESET_LOGIN_ERROR';
+}
+
+interface IUpdateAccountTokenAction {
+ type: 'UPDATE_ACCOUNT_TOKEN';
+ token: AccountToken;
+}
+
+interface IUpdateAccountHistoryAction {
+ type: 'UPDATE_ACCOUNT_HISTORY';
+ accountHistory: AccountToken[];
+}
+
+interface IUpdateAccountExpiryAction {
+ type: 'UPDATE_ACCOUNT_EXPIRY';
+ expiry: string;
+}
+
+export type AccountAction =
+ | IStartLoginAction
+ | ILoggedInAction
+ | ILoginFailedAction
+ | ILoggedOutAction
+ | IResetLoginErrorAction
+ | IUpdateAccountTokenAction
+ | IUpdateAccountHistoryAction
+ | IUpdateAccountExpiryAction;
+
+function startLogin(accountToken: AccountToken): IStartLoginAction {
+ return {
+ type: 'START_LOGIN',
+ accountToken,
+ };
+}
+
+function loggedIn(): ILoggedInAction {
+ return {
+ type: 'LOGGED_IN',
+ };
+}
+
+function loginFailed(error: Error): ILoginFailedAction {
+ return {
+ type: 'LOGIN_FAILED',
+ error,
+ };
+}
+
+function loggedOut(): ILoggedOutAction {
+ return {
+ type: 'LOGGED_OUT',
+ };
+}
+
+function resetLoginError(): IResetLoginErrorAction {
+ return {
+ type: 'RESET_LOGIN_ERROR',
+ };
+}
+
+function updateAccountToken(token: AccountToken): IUpdateAccountTokenAction {
+ return {
+ type: 'UPDATE_ACCOUNT_TOKEN',
+ token,
+ };
+}
+
+function updateAccountHistory(accountHistory: AccountToken[]): IUpdateAccountHistoryAction {
+ return {
+ type: 'UPDATE_ACCOUNT_HISTORY',
+ accountHistory,
+ };
+}
+
+function updateAccountExpiry(expiry: string): IUpdateAccountExpiryAction {
+ return {
+ type: 'UPDATE_ACCOUNT_EXPIRY',
+ expiry,
+ };
+}
+
+export default {
+ startLogin,
+ loggedIn,
+ loginFailed,
+ loggedOut,
+ resetLoginError,
+ updateAccountToken,
+ updateAccountHistory,
+ updateAccountExpiry,
+};
diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts
new file mode 100644
index 0000000000..754ea09d13
--- /dev/null
+++ b/gui/src/renderer/redux/account/reducers.ts
@@ -0,0 +1,94 @@
+import { AccountToken } from '../../../shared/daemon-rpc-types';
+import { ReduxAction } from '../store';
+
+export type LoginState = 'none' | 'logging in' | 'failed' | 'ok';
+export interface IAccountReduxState {
+ accountToken?: AccountToken;
+ accountHistory: AccountToken[];
+ expiry?: string; // ISO8601
+ status: LoginState;
+ error?: Error;
+}
+
+const initialState: IAccountReduxState = {
+ accountToken: undefined,
+ accountHistory: [],
+ expiry: undefined,
+ status: 'none',
+ error: undefined,
+};
+
+export default function(
+ state: IAccountReduxState = initialState,
+ action: ReduxAction,
+): IAccountReduxState {
+ switch (action.type) {
+ case 'START_LOGIN':
+ return {
+ ...state,
+ ...{
+ status: 'logging in',
+ accountToken: action.accountToken,
+ error: undefined,
+ },
+ };
+ case 'LOGGED_IN':
+ return {
+ ...state,
+ ...{
+ status: 'ok',
+ error: undefined,
+ },
+ };
+ case 'LOGIN_FAILED':
+ return {
+ ...state,
+ ...{
+ status: 'failed',
+ accountToken: undefined,
+ error: action.error,
+ },
+ };
+ case 'LOGGED_OUT':
+ return {
+ ...state,
+ ...{
+ status: 'none',
+ accountToken: undefined,
+ expiry: undefined,
+ error: undefined,
+ },
+ };
+ case 'RESET_LOGIN_ERROR':
+ return {
+ ...state,
+ ...{
+ status: 'none',
+ error: undefined,
+ },
+ };
+ case 'UPDATE_ACCOUNT_TOKEN':
+ return {
+ ...state,
+ ...{
+ accountToken: action.token,
+ },
+ };
+ case 'UPDATE_ACCOUNT_HISTORY':
+ return {
+ ...state,
+ ...{
+ accountHistory: action.accountHistory,
+ },
+ };
+ case 'UPDATE_ACCOUNT_EXPIRY':
+ return {
+ ...state,
+ ...{
+ expiry: action.expiry,
+ },
+ };
+ }
+
+ return state;
+}
diff --git a/gui/src/renderer/redux/connection/actions.ts b/gui/src/renderer/redux/connection/actions.ts
new file mode 100644
index 0000000000..f24d080a57
--- /dev/null
+++ b/gui/src/renderer/redux/connection/actions.ts
@@ -0,0 +1,119 @@
+import { AfterDisconnect, BlockReason, ITunnelEndpoint } from '../../../shared/daemon-rpc-types';
+
+interface IConnectingAction {
+ type: 'CONNECTING';
+ tunnelEndpoint?: ITunnelEndpoint;
+}
+
+interface IConnectedAction {
+ type: 'CONNECTED';
+ tunnelEndpoint: ITunnelEndpoint;
+}
+
+interface IDisconnectedAction {
+ type: 'DISCONNECTED';
+}
+
+interface IDisconnectingAction {
+ type: 'DISCONNECTING';
+ afterDisconnect: AfterDisconnect;
+}
+
+interface IBlockedAction {
+ type: 'BLOCKED';
+ reason: BlockReason;
+}
+
+interface INewLocationAction {
+ type: 'NEW_LOCATION';
+ newLocation: {
+ country: string;
+ city?: string;
+ latitude: number;
+ longitude: number;
+ mullvadExitIp: boolean;
+ hostname?: string;
+ };
+}
+
+interface IOnlineAction {
+ type: 'ONLINE';
+}
+
+interface IOfflineAction {
+ type: 'OFFLINE';
+}
+
+export type ConnectionAction =
+ | INewLocationAction
+ | IConnectingAction
+ | IConnectedAction
+ | IDisconnectedAction
+ | IDisconnectingAction
+ | IBlockedAction
+ | IOnlineAction
+ | IOfflineAction;
+
+function connecting(tunnelEndpoint?: ITunnelEndpoint): IConnectingAction {
+ return {
+ type: 'CONNECTING',
+ tunnelEndpoint,
+ };
+}
+
+function connected(tunnelEndpoint: ITunnelEndpoint): IConnectedAction {
+ return {
+ type: 'CONNECTED',
+ tunnelEndpoint,
+ };
+}
+
+function disconnected(): IDisconnectedAction {
+ return {
+ type: 'DISCONNECTED',
+ };
+}
+
+function disconnecting(afterDisconnect: AfterDisconnect): IDisconnectingAction {
+ return {
+ type: 'DISCONNECTING',
+ afterDisconnect,
+ };
+}
+
+function blocked(reason: BlockReason): IBlockedAction {
+ return {
+ type: 'BLOCKED',
+ reason,
+ };
+}
+
+function newLocation(location: INewLocationAction['newLocation']): INewLocationAction {
+ return {
+ type: 'NEW_LOCATION',
+ newLocation: location,
+ };
+}
+
+function online(): IOnlineAction {
+ return {
+ type: 'ONLINE',
+ };
+}
+
+function offline(): IOfflineAction {
+ return {
+ type: 'OFFLINE',
+ };
+}
+
+export default {
+ newLocation,
+ connecting,
+ connected,
+ disconnected,
+ disconnecting,
+ blocked,
+ online,
+ offline,
+};
diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts
new file mode 100644
index 0000000000..bd22a6f110
--- /dev/null
+++ b/gui/src/renderer/redux/connection/reducers.ts
@@ -0,0 +1,77 @@
+import { Ip, TunnelStateTransition } from '../../../shared/daemon-rpc-types';
+import { ReduxAction } from '../store';
+
+export interface IConnectionReduxState {
+ status: TunnelStateTransition;
+ isOnline: boolean;
+ isBlocked: boolean;
+ ip?: Ip;
+ hostname?: string;
+ latitude?: number;
+ longitude?: number;
+ country?: string;
+ city?: string;
+}
+
+const initialState: IConnectionReduxState = {
+ status: { state: 'disconnected' },
+ isOnline: true,
+ isBlocked: false,
+ ip: undefined,
+ hostname: undefined,
+ latitude: undefined,
+ longitude: undefined,
+ country: undefined,
+ city: undefined,
+};
+
+export default function(
+ state: IConnectionReduxState = initialState,
+ action: ReduxAction,
+): IConnectionReduxState {
+ switch (action.type) {
+ case 'NEW_LOCATION':
+ const { hostname, latitude, longitude, city, country } = action.newLocation;
+ return { ...state, hostname, latitude, longitude, city, country };
+
+ case 'CONNECTING':
+ return {
+ ...state,
+ status: { state: 'connecting', details: action.tunnelEndpoint },
+ isBlocked: true,
+ };
+
+ case 'CONNECTED':
+ return {
+ ...state,
+ status: { state: 'connected', details: action.tunnelEndpoint },
+ isBlocked: false,
+ };
+
+ case 'DISCONNECTED':
+ return { ...state, status: { state: 'disconnected' }, isBlocked: false };
+
+ case 'DISCONNECTING':
+ return {
+ ...state,
+ status: { state: 'disconnecting', details: action.afterDisconnect },
+ isBlocked: true,
+ };
+
+ case 'BLOCKED':
+ return {
+ ...state,
+ status: { state: 'blocked', details: action.reason },
+ isBlocked: action.reason.reason !== 'set_firewall_policy_error',
+ };
+
+ case 'ONLINE':
+ return { ...state, isOnline: true };
+
+ case 'OFFLINE':
+ return { ...state, isOnline: false };
+
+ default:
+ return state;
+ }
+}
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
new file mode 100644
index 0000000000..4587a1c513
--- /dev/null
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -0,0 +1,121 @@
+import { IGuiSettingsState } from '../../../shared/gui-settings-state';
+import { IRelayLocationRedux, RelaySettingsRedux } from './reducers';
+
+export interface IUpdateGuiSettingsAction {
+ type: 'UPDATE_GUI_SETTINGS';
+ guiSettings: IGuiSettingsState;
+}
+
+export interface IUpdateRelayAction {
+ type: 'UPDATE_RELAY';
+ relay: RelaySettingsRedux;
+}
+
+export interface IUpdateRelayLocationsAction {
+ type: 'UPDATE_RELAY_LOCATIONS';
+ relayLocations: IRelayLocationRedux[];
+}
+
+export interface IUpdateAllowLanAction {
+ type: 'UPDATE_ALLOW_LAN';
+ allowLan: boolean;
+}
+
+export interface IUpdateEnableIpv6Action {
+ type: 'UPDATE_ENABLE_IPV6';
+ enableIpv6: boolean;
+}
+
+export interface IUpdateBlockWhenDisconnectedAction {
+ type: 'UPDATE_BLOCK_WHEN_DISCONNECTED';
+ blockWhenDisconnected: boolean;
+}
+
+export interface IUpdateOpenVpnMssfixAction {
+ type: 'UPDATE_OPENVPN_MSSFIX';
+ mssfix?: number;
+}
+
+export interface IUpdateAutoStartAction {
+ type: 'UPDATE_AUTO_START';
+ autoStart: boolean;
+}
+
+export type SettingsAction =
+ | IUpdateGuiSettingsAction
+ | IUpdateRelayAction
+ | IUpdateRelayLocationsAction
+ | IUpdateAllowLanAction
+ | IUpdateEnableIpv6Action
+ | IUpdateBlockWhenDisconnectedAction
+ | IUpdateOpenVpnMssfixAction
+ | IUpdateAutoStartAction;
+
+function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction {
+ return {
+ type: 'UPDATE_GUI_SETTINGS',
+ guiSettings,
+ };
+}
+
+function updateRelay(relay: RelaySettingsRedux): IUpdateRelayAction {
+ return {
+ type: 'UPDATE_RELAY',
+ relay,
+ };
+}
+
+function updateRelayLocations(relayLocations: IRelayLocationRedux[]): IUpdateRelayLocationsAction {
+ return {
+ type: 'UPDATE_RELAY_LOCATIONS',
+ relayLocations,
+ };
+}
+
+function updateAllowLan(allowLan: boolean): IUpdateAllowLanAction {
+ return {
+ type: 'UPDATE_ALLOW_LAN',
+ allowLan,
+ };
+}
+
+function updateEnableIpv6(enableIpv6: boolean): IUpdateEnableIpv6Action {
+ return {
+ type: 'UPDATE_ENABLE_IPV6',
+ enableIpv6,
+ };
+}
+
+function updateBlockWhenDisconnected(
+ blockWhenDisconnected: boolean,
+): IUpdateBlockWhenDisconnectedAction {
+ return {
+ type: 'UPDATE_BLOCK_WHEN_DISCONNECTED',
+ blockWhenDisconnected,
+ };
+}
+
+function updateOpenVpnMssfix(mssfix?: number): IUpdateOpenVpnMssfixAction {
+ return {
+ type: 'UPDATE_OPENVPN_MSSFIX',
+ mssfix,
+ };
+}
+
+function updateAutoStart(autoStart: boolean): IUpdateAutoStartAction {
+ return {
+ type: 'UPDATE_AUTO_START',
+ autoStart,
+ };
+}
+
+export default {
+ updateGuiSettings,
+ updateRelay,
+ updateRelayLocations,
+ updateAllowLan,
+ updateEnableIpv6,
+ updateBlockWhenDisconnected,
+ updateOpenVpnMssfix,
+ updateAutoStart,
+};
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
new file mode 100644
index 0000000000..60a610ece0
--- /dev/null
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -0,0 +1,138 @@
+import { RelayLocation, RelayProtocol } from '../../../shared/daemon-rpc-types';
+import { IGuiSettingsState } from '../../../shared/gui-settings-state';
+import { ReduxAction } from '../store';
+
+export type RelaySettingsRedux =
+ | {
+ normal: {
+ location: 'any' | RelayLocation;
+ port: 'any' | number;
+ protocol: 'any' | RelayProtocol;
+ };
+ }
+ | {
+ customTunnelEndpoint: {
+ host: string;
+ port: number;
+ protocol: RelayProtocol;
+ };
+ };
+
+export interface IRelayLocationRelayRedux {
+ hostname: string;
+ ipv4AddrIn: string;
+ ipv4AddrExit: string;
+ includeInCountry: boolean;
+ weight: number;
+}
+
+export interface IRelayLocationCityRedux {
+ name: string;
+ code: string;
+ latitude: number;
+ longitude: number;
+ hasActiveRelays: boolean;
+ relays: IRelayLocationRelayRedux[];
+}
+
+export interface IRelayLocationRedux {
+ name: string;
+ code: string;
+ hasActiveRelays: boolean;
+ cities: IRelayLocationCityRedux[];
+}
+
+export interface ISettingsReduxState {
+ autoStart: boolean;
+ guiSettings: IGuiSettingsState;
+ relaySettings: RelaySettingsRedux;
+ relayLocations: IRelayLocationRedux[];
+ allowLan: boolean;
+ enableIpv6: boolean;
+ blockWhenDisconnected: boolean;
+ openVpn: {
+ mssfix?: number;
+ };
+}
+
+const initialState: ISettingsReduxState = {
+ autoStart: false,
+ guiSettings: {
+ autoConnect: true,
+ monochromaticIcon: false,
+ startMinimized: false,
+ },
+ relaySettings: {
+ normal: {
+ location: 'any',
+ port: 'any',
+ protocol: 'any',
+ },
+ },
+ relayLocations: [],
+ allowLan: false,
+ enableIpv6: true,
+ blockWhenDisconnected: false,
+ openVpn: {},
+};
+
+export default function(
+ state: ISettingsReduxState = initialState,
+ action: ReduxAction,
+): ISettingsReduxState {
+ switch (action.type) {
+ case 'UPDATE_GUI_SETTINGS':
+ return {
+ ...state,
+ guiSettings: action.guiSettings,
+ };
+
+ case 'UPDATE_RELAY':
+ return {
+ ...state,
+ relaySettings: action.relay,
+ };
+
+ case 'UPDATE_RELAY_LOCATIONS':
+ return {
+ ...state,
+ relayLocations: action.relayLocations,
+ };
+
+ case 'UPDATE_ALLOW_LAN':
+ return {
+ ...state,
+ allowLan: action.allowLan,
+ };
+
+ case 'UPDATE_ENABLE_IPV6':
+ return {
+ ...state,
+ enableIpv6: action.enableIpv6,
+ };
+
+ case 'UPDATE_BLOCK_WHEN_DISCONNECTED':
+ return {
+ ...state,
+ blockWhenDisconnected: action.blockWhenDisconnected,
+ };
+
+ case 'UPDATE_OPENVPN_MSSFIX':
+ return {
+ ...state,
+ openVpn: {
+ ...state.openVpn,
+ mssfix: action.mssfix,
+ },
+ };
+
+ case 'UPDATE_AUTO_START':
+ return {
+ ...state,
+ autoStart: action.autoStart,
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts
new file mode 100644
index 0000000000..e1324607dc
--- /dev/null
+++ b/gui/src/renderer/redux/store.ts
@@ -0,0 +1,80 @@
+import { connectRouter, push, replace, routerMiddleware } from 'connected-react-router';
+import { applyMiddleware, combineReducers, compose, createStore, Dispatch, Store } from 'redux';
+
+import accountActions, { AccountAction } from './account/actions';
+import accountReducer, { IAccountReduxState } from './account/reducers';
+import connectionActions, { ConnectionAction } from './connection/actions';
+import connectionReducer, { IConnectionReduxState } from './connection/reducers';
+import settingsActions, { SettingsAction } from './settings/actions';
+import settingsReducer, { ISettingsReduxState } from './settings/reducers';
+import supportActions, { SupportAction } from './support/actions';
+import supportReducer, { ISupportReduxState } from './support/reducers';
+import userInterfaceActions, { UserInterfaceAction } from './userinterface/actions';
+import userInterfaceReducer, { IUserInterfaceReduxState } from './userinterface/reducers';
+import versionActions, { VersionAction } from './version/actions';
+import versionReducer, { IVersionReduxState } from './version/reducers';
+
+import { History } from 'history';
+
+export interface IReduxState {
+ account: IAccountReduxState;
+ connection: IConnectionReduxState;
+ settings: ISettingsReduxState;
+ support: ISupportReduxState;
+ version: IVersionReduxState;
+ userInterface: IUserInterfaceReduxState;
+}
+
+export type ReduxAction =
+ | AccountAction
+ | ConnectionAction
+ | SettingsAction
+ | SupportAction
+ | VersionAction
+ | UserInterfaceAction;
+export type ReduxStore = Store<IReduxState, ReduxAction>;
+export type ReduxDispatch = Dispatch<ReduxAction>;
+
+export default function configureStore(
+ routerHistory: History,
+ initialState?: IReduxState,
+): ReduxStore {
+ const actionCreators = {
+ ...accountActions,
+ ...connectionActions,
+ ...settingsActions,
+ ...supportActions,
+ ...versionActions,
+ ...userInterfaceActions,
+ pushRoute: (route: string) => push(route),
+ replaceRoute: (route: string) => replace(route),
+ };
+
+ const reducers = {
+ account: accountReducer,
+ connection: connectionReducer,
+ settings: settingsReducer,
+ support: supportReducer,
+ version: versionReducer,
+ userInterface: userInterfaceReducer,
+ router: connectRouter(routerHistory),
+ };
+
+ const composeEnhancers: typeof compose = (() => {
+ const reduxCompose = window && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
+ if (process.env.NODE_ENV === 'development' && reduxCompose) {
+ return reduxCompose({ actionCreators });
+ }
+ return compose;
+ })();
+
+ const enhancer = composeEnhancers(applyMiddleware(routerMiddleware(routerHistory)));
+
+ const rootReducer = combineReducers(reducers);
+
+ if (initialState) {
+ return createStore(rootReducer, initialState, enhancer);
+ } else {
+ return createStore(rootReducer, enhancer);
+ }
+}
diff --git a/gui/src/renderer/redux/support/actions.ts b/gui/src/renderer/redux/support/actions.ts
new file mode 100644
index 0000000000..23cb57daf3
--- /dev/null
+++ b/gui/src/renderer/redux/support/actions.ts
@@ -0,0 +1,30 @@
+export interface ISupportReportForm {
+ email: string;
+ message: string;
+}
+
+export interface IKeepReportFormAction {
+ type: 'SAVE_REPORT_FORM';
+ form: ISupportReportForm;
+}
+
+export interface IClearReportFormAction {
+ type: 'CLEAR_REPORT_FORM';
+}
+
+export type SupportAction = IKeepReportFormAction | IClearReportFormAction;
+
+function saveReportForm(form: ISupportReportForm): IKeepReportFormAction {
+ return {
+ type: 'SAVE_REPORT_FORM',
+ form,
+ };
+}
+
+function clearReportForm(): IClearReportFormAction {
+ return {
+ type: 'CLEAR_REPORT_FORM',
+ };
+}
+
+export default { saveReportForm, clearReportForm };
diff --git a/gui/src/renderer/redux/support/reducers.ts b/gui/src/renderer/redux/support/reducers.ts
new file mode 100644
index 0000000000..7a300cf2ca
--- /dev/null
+++ b/gui/src/renderer/redux/support/reducers.ts
@@ -0,0 +1,35 @@
+import { ReduxAction } from '../store';
+
+export interface ISupportReduxState {
+ email: string;
+ message: string;
+}
+
+const initialState: ISupportReduxState = {
+ email: '',
+ message: '',
+};
+
+export default function(
+ state: ISupportReduxState = initialState,
+ action: ReduxAction,
+): ISupportReduxState {
+ switch (action.type) {
+ case 'SAVE_REPORT_FORM':
+ return {
+ ...state,
+ email: action.form.email,
+ message: action.form.message,
+ };
+
+ case 'CLEAR_REPORT_FORM':
+ return {
+ ...state,
+ email: '',
+ message: '',
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
new file mode 100644
index 0000000000..724f53883d
--- /dev/null
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -0,0 +1,29 @@
+export interface IUpdateWindowArrowPositionAction {
+ type: 'UPDATE_WINDOW_ARROW_POSITION';
+ arrowPosition: number;
+}
+
+export interface IUpdateConnectionInfoOpenAction {
+ type: 'UPDATE_CONNECTION_INFO_OPEN';
+ isOpen: boolean;
+}
+
+export type UserInterfaceAction =
+ | IUpdateWindowArrowPositionAction
+ | IUpdateConnectionInfoOpenAction;
+
+function updateWindowArrowPosition(arrowPosition: number): IUpdateWindowArrowPositionAction {
+ return {
+ type: 'UPDATE_WINDOW_ARROW_POSITION',
+ arrowPosition,
+ };
+}
+
+function updateConnectionInfoOpen(isOpen: boolean): IUpdateConnectionInfoOpenAction {
+ return {
+ type: 'UPDATE_CONNECTION_INFO_OPEN',
+ isOpen,
+ };
+}
+
+export default { updateWindowArrowPosition, updateConnectionInfoOpen };
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
new file mode 100644
index 0000000000..75005fd423
--- /dev/null
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -0,0 +1,26 @@
+import { ReduxAction } from '../store';
+
+export interface IUserInterfaceReduxState {
+ arrowPosition?: number;
+ connectionInfoOpen: boolean;
+}
+
+const initialState: IUserInterfaceReduxState = {
+ connectionInfoOpen: false,
+};
+
+export default function(
+ state: IUserInterfaceReduxState = initialState,
+ action: ReduxAction,
+): IUserInterfaceReduxState {
+ switch (action.type) {
+ case 'UPDATE_WINDOW_ARROW_POSITION':
+ return { ...state, arrowPosition: action.arrowPosition };
+
+ case 'UPDATE_CONNECTION_INFO_OPEN':
+ return { ...state, connectionInfoOpen: action.isOpen };
+
+ default:
+ return state;
+ }
+}
diff --git a/gui/src/renderer/redux/version/actions.ts b/gui/src/renderer/redux/version/actions.ts
new file mode 100644
index 0000000000..1eefb7e62c
--- /dev/null
+++ b/gui/src/renderer/redux/version/actions.ts
@@ -0,0 +1,36 @@
+import { IAppVersionInfo } from '../../../shared/daemon-rpc-types';
+
+interface IUpdateLatestActionPayload extends IAppVersionInfo {
+ upToDate: boolean;
+ nextUpgrade?: string;
+}
+
+export interface IUpdateLatestAction {
+ type: 'UPDATE_LATEST';
+ latestInfo: IUpdateLatestActionPayload;
+}
+
+export interface IUpdateVersionAction {
+ type: 'UPDATE_VERSION';
+ version: string;
+ consistent: boolean;
+}
+
+export type VersionAction = IUpdateLatestAction | IUpdateVersionAction;
+
+function updateLatest(latestInfo: IUpdateLatestActionPayload): IUpdateLatestAction {
+ return {
+ type: 'UPDATE_LATEST',
+ latestInfo,
+ };
+}
+
+function updateVersion(version: string, consistent: boolean): IUpdateVersionAction {
+ return {
+ type: 'UPDATE_VERSION',
+ version,
+ consistent,
+ };
+}
+
+export default { updateLatest, updateVersion };
diff --git a/gui/src/renderer/redux/version/reducers.ts b/gui/src/renderer/redux/version/reducers.ts
new file mode 100644
index 0000000000..775b605ded
--- /dev/null
+++ b/gui/src/renderer/redux/version/reducers.ts
@@ -0,0 +1,47 @@
+import { ReduxAction } from '../store';
+
+export interface IVersionReduxState {
+ current: string;
+ currentIsSupported: boolean;
+ latest?: string;
+ latestStable?: string;
+ nextUpgrade?: string;
+ upToDate: boolean;
+ consistent: boolean;
+}
+
+const initialState: IVersionReduxState = {
+ current: '',
+ currentIsSupported: true,
+ latest: undefined,
+ latestStable: undefined,
+ nextUpgrade: undefined,
+ upToDate: true,
+ consistent: true,
+};
+
+export default function(
+ state: IVersionReduxState = initialState,
+ action: ReduxAction,
+): IVersionReduxState {
+ switch (action.type) {
+ case 'UPDATE_LATEST': {
+ const { latest, ...other } = action.latestInfo;
+ return {
+ ...state,
+ ...other,
+ ...latest,
+ };
+ }
+
+ case 'UPDATE_VERSION':
+ return {
+ ...state,
+ current: action.version,
+ consistent: action.consistent,
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx
new file mode 100644
index 0000000000..7aaf15d1e7
--- /dev/null
+++ b/gui/src/renderer/routes.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react';
+import { Route, RouteComponentProps, Switch } from 'react-router';
+import App from './app';
+import TransitionContainer from './components/TransitionContainer';
+import AccountPage from './containers/AccountPage';
+import AdvancedSettingsPage from './containers/AdvancedSettingsPage';
+import ConnectPage from './containers/ConnectPage';
+import LaunchPage from './containers/LaunchPage';
+import LoginPage from './containers/LoginPage';
+import PlatformWindowContainer from './containers/PlatformWindowContainer';
+import PreferencesPage from './containers/PreferencesPage';
+import SelectLocationPage from './containers/SelectLocationPage';
+import SettingsPage from './containers/SettingsPage';
+import SupportPage from './containers/SupportPage';
+import { getTransitionProps } from './transitions';
+
+export interface ISharedRouteProps {
+ app: App;
+}
+
+type CustomRouteProps = {
+ component: React.ComponentClass<ISharedRouteProps>;
+} & Route['props'];
+
+export default function makeRoutes(componentProps: ISharedRouteProps) {
+ // Renders a route extended with shared props
+ function CustomRoute({ component: ComponentClass, ...routeProps }: CustomRouteProps) {
+ const renderOverride = () => <ComponentClass {...componentProps} />;
+
+ return <Route {...routeProps} render={renderOverride} />;
+ }
+
+ // store previous route
+ let sourceRoute: string | null = null;
+
+ function renderRoute({ location }: RouteComponentProps) {
+ const destinationRoute = location.pathname;
+ const transitionProps = getTransitionProps(sourceRoute, destinationRoute);
+ sourceRoute = destinationRoute;
+
+ return (
+ <PlatformWindowContainer>
+ <TransitionContainer {...transitionProps}>
+ <Switch key={location.key} location={location}>
+ <CustomRoute exact={true} path="/" component={LaunchPage} />
+ <CustomRoute exact={true} path="/login" component={LoginPage} />
+ <CustomRoute exact={true} path="/connect" component={ConnectPage} />
+ <CustomRoute exact={true} path="/settings" component={SettingsPage} />
+ <CustomRoute exact={true} path="/settings/account" component={AccountPage} />
+ <CustomRoute exact={true} path="/settings/preferences" component={PreferencesPage} />
+ <CustomRoute exact={true} path="/settings/advanced" component={AdvancedSettingsPage} />
+ <CustomRoute exact={true} path="/settings/support" component={SupportPage} />
+ <CustomRoute exact={true} path="/select-location" component={SelectLocationPage} />
+ </Switch>
+ </TransitionContainer>
+ </PlatformWindowContainer>
+ );
+ }
+
+ return <Route render={renderRoute} />;
+}
diff --git a/gui/src/renderer/transitions.ts b/gui/src/renderer/transitions.ts
new file mode 100644
index 0000000000..7a8ac825c2
--- /dev/null
+++ b/gui/src/renderer/transitions.ts
@@ -0,0 +1,103 @@
+import TransitionRule, { ITransitionDescriptor, ITransitionFork } from './lib/transition-rule';
+
+export interface ITransitionGroupProps {
+ name: string;
+ duration: number;
+}
+
+interface ITransitionMap {
+ [name: string]: ITransitionFork;
+}
+
+/**
+ * Transition descriptors
+ */
+const transitions: ITransitionMap = {
+ slide: {
+ forward: {
+ name: 'slide-up',
+ duration: 450,
+ },
+ backward: {
+ name: 'slide-down',
+ duration: 450,
+ },
+ },
+ push: {
+ forward: {
+ name: 'push',
+ duration: 450,
+ },
+ backward: {
+ name: 'pop',
+ duration: 450,
+ },
+ },
+};
+
+/**
+ * Transition rules
+ * (null) is used to indicate any route.
+ */
+const transitionRules = [
+ r('/settings', '/settings/account', transitions.push),
+ r('/settings', '/settings/preferences', transitions.push),
+ r('/settings', '/settings/advanced', transitions.push),
+ r('/settings', '/settings/support', transitions.push),
+ r(null, '/settings', transitions.slide),
+ r(null, '/select-location', transitions.slide),
+];
+
+/**
+ * Calculate TransitionGroup props.
+ *
+ * @param {string} [fromRoute] - source route
+ * @param {string} toRoute - target route
+ */
+export function getTransitionProps(
+ fromRoute: string | null,
+ toRoute: string,
+): ITransitionGroupProps {
+ // ignore initial transition and transition between the same routes
+ if (!fromRoute || fromRoute === toRoute) {
+ return noTransitionProps();
+ }
+
+ for (const rule of transitionRules) {
+ const match = rule.match(fromRoute, toRoute);
+ if (match) {
+ return toTransitionGroupProps(match.descriptor);
+ }
+ }
+
+ return noTransitionProps();
+}
+
+/**
+ * Integrate ITransitionDescriptor into ITransitionGroupProps
+ * @param {ITransitionDescriptor} descriptor
+ */
+function toTransitionGroupProps(descriptor: ITransitionDescriptor): ITransitionGroupProps {
+ const { name, duration } = descriptor;
+ return {
+ name,
+ duration,
+ };
+}
+
+/**
+ * Returns default props with no animation
+ */
+function noTransitionProps(): ITransitionGroupProps {
+ return {
+ name: '',
+ duration: 0,
+ };
+}
+
+/**
+ * Shortcut to create TransitionRule
+ */
+function r(from: string | null, to: string, fork: ITransitionFork): TransitionRule {
+ return new TransitionRule(from, to, fork);
+}
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);
+ });
+ };
+}