summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-04-11 18:11:33 +0200
committerOskar Nyberg <oskar@mullvad.net>2024-04-11 18:11:33 +0200
commit9aaebecc3c6d1838722a66875b5a330ca7967020 (patch)
treec877bcc16c9eb3e325aecdd5f2861b9e6f5824a5
parentd2e198cd8ce15225d4329cbb61f4561e293ea239 (diff)
parent36322705705857c549fa90616884bc64740cbbef (diff)
downloadmullvadvpn-9aaebecc3c6d1838722a66875b5a330ca7967020.tar.xz
mullvadvpn-9aaebecc3c6d1838722a66875b5a330ca7967020.zip
Merge branch 'add-custom-bridge-to-gui-des-431'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/assets/images/icon-nearest.svg6
-rw-r--r--gui/locales/messages.pot46
-rw-r--r--gui/src/config.json1
-rw-r--r--gui/src/main/daemon-rpc.ts187
-rw-r--r--gui/src/main/settings.ts13
-rw-r--r--gui/src/renderer/app.tsx2
-rw-r--r--gui/src/renderer/components/AppRouter.tsx2
-rw-r--r--gui/src/renderer/components/ConnectionPanel.tsx16
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx453
-rw-r--r--gui/src/renderer/components/EditCustomBridge.tsx116
-rw-r--r--gui/src/renderer/components/OpenVpnSettings.tsx86
-rw-r--r--gui/src/renderer/components/ProxyForm.tsx532
-rw-r--r--gui/src/renderer/components/RelayStatusIndicator.tsx12
-rw-r--r--gui/src/renderer/components/SmallButton.tsx11
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx3
-rw-r--r--gui/src/renderer/components/select-location/CustomLists.tsx2
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx135
-rw-r--r--gui/src/renderer/components/select-location/LocationRowStyles.tsx150
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx27
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx50
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationContainer.tsx14
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx130
-rw-r--r--gui/src/renderer/components/select-location/custom-list-helpers.ts12
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts44
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts11
-rw-r--r--gui/src/renderer/lib/constraint-updater.ts77
-rw-r--r--gui/src/renderer/lib/routes.ts1
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts10
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts17
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts9
-rw-r--r--gui/src/shared/bridge-settings-builder.ts28
-rw-r--r--gui/src/shared/daemon-rpc-types.ts79
-rw-r--r--gui/src/shared/localization-contexts.ts1
-rw-r--r--gui/src/shared/relay-location-builder.ts41
35 files changed, 1366 insertions, 959 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4217f6079f..41c1337065 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ Line wrap the file at 100 chars. Th
- Add automatic MTU detection for desktop platforms. This currently only uses information about
dropped packets and does not take fragmentation into account.
- Add ability to import server IP overrides in GUI.
+- Add custom bridge settings in GUI.
### Changed
- Change default obfuscation setting to `auto`.
diff --git a/gui/assets/images/icon-nearest.svg b/gui/assets/images/icon-nearest.svg
deleted file mode 100644
index badbf3674d..0000000000
--- a/gui/assets/images/icon-nearest.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
- <title>icon-nearest</title>
- <desc>Mullvad VPN app</desc>
- <defs></defs>
- <path d="M20.9450712,11 L23,11 C23.5522847,11 24,11.4477153 24,12 C24,12.5522847 23.5522847,13 23,13 L20.9450712,13 C20.4839224,17.1716166 17.1716166,20.4839224 13,20.9450712 L13,23 C13,23.5522847 12.5522847,24 12,24 C11.4477153,24 11,23.5522847 11,23 L11,20.9450712 C6.82838339,20.4839224 3.5160776,17.1716166 3.05492878,13 L1,13 C0.44771525,13 0,12.5522847 0,12 C0,11.4477153 0.44771525,11 1,11 L3.05492878,11 C3.5160776,6.82838339 6.82838339,3.5160776 11,3.05492878 L11,1 C11,0.44771525 11.4477153,0 12,0 C12.5522847,0 13,0.44771525 13,1 L13,3.05492878 C17.1716166,3.5160776 20.4839224,6.82838339 20.9450712,11 Z M18.9291111,11 C18.4905984,7.93430884 16.0656912,5.50940162 13,5.07088886 L13,7 C13,7.55228475 12.5522847,8 12,8 C11.4477153,8 11,7.55228475 11,7 L11,5.07088886 C7.93430884,5.50940162 5.50940162,7.93430884 5.07088886,11 L7,11 C7.55228475,11 8,11.4477153 8,12 C8,12.5522847 7.55228475,13 7,13 L5.07088886,13 C5.50940162,16.0656912 7.93430884,18.4905984 11,18.9291111 L11,17 C11,16.4477153 11.4477153,16 12,16 C12.5522847,16 13,16.4477153 13,17 L13,18.9291111 C16.0656912,18.4905984 18.4905984,16.0656912 18.9291111,13 L17,13 C16.4477153,13 16,12.5522847 16,12 C16,11.4477153 16.4477153,11 17,11 L18.9291111,11 Z" id="icon-nearest" fill="#FFFFFF" fill-rule="nonzero"></path>
-</svg>
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index 35df069d18..da18e9b021 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -129,6 +129,9 @@ msgstr ""
msgid "Dismiss"
msgstr ""
+msgid "Enable"
+msgstr ""
+
msgid "Enable anyway"
msgstr ""
@@ -221,6 +224,8 @@ msgstr ""
msgid "TCP"
msgstr ""
+#. Warning shown in dialog to users when they enable setting that increases
+#. network latency (decreases performance).
#. Warning text in a dialog that is displayed after a setting is toggled.
msgid "This setting increases latency. Use only if needed."
msgstr ""
@@ -623,6 +628,10 @@ msgctxt "connection-info"
msgid "%(relay)s via %(entry)s"
msgstr ""
+msgctxt "connection-info"
+msgid "%(relay)s via Custom bridge"
+msgstr ""
+
#. The tunnel type line displayed below the hostname line on the main screen
#. Available placeholders:
#. %(tunnelType)s - the tunnel type, i.e OpenVPN
@@ -639,6 +648,22 @@ msgctxt "connection-info"
msgid "Out"
msgstr ""
+msgctxt "custom-bridge"
+msgid "Add custom bridge"
+msgstr ""
+
+msgctxt "custom-bridge"
+msgid "Delete custom bridge?"
+msgstr ""
+
+msgctxt "custom-bridge"
+msgid "Deleting the custom bridge will take you back to the select location view and the Automatic option will be selected instead."
+msgstr ""
+
+msgctxt "custom-bridge"
+msgid "Edit custom bridge"
+msgstr ""
+
#. Text displayed above button which logs out another device.
#. The text enclosed in "<b></b>" will appear bold.
#. Available placeholders:
@@ -1147,6 +1172,10 @@ msgctxt "openvpn-settings-view"
msgid "Bridge mode"
msgstr ""
+msgctxt "openvpn-settings-view"
+msgid "Enable bridge mode?"
+msgstr ""
+
#. This is used as a description for the bridge mode
#. setting.
#. Available placeholders:
@@ -1193,6 +1222,12 @@ msgctxt "openvpn-settings-view"
msgid "To activate UDP, change <b>Bridge mode</b> to <b>Automatic</b> or <b>Off</b>."
msgstr ""
+#. This text is shown beneath the bridge mode setting to instruct users how to
+#. configure the feature further.
+msgctxt "openvpn-settings-view"
+msgid "To select a specific bridge server, go to the Select location view."
+msgstr ""
+
msgctxt "openvpn-settings-view"
msgid "Transport protocol"
msgstr ""
@@ -1256,6 +1291,10 @@ msgctxt "select-location-view"
msgid "%(location)s (%(info)s)"
msgstr ""
+msgctxt "select-location-view"
+msgid "A custom bridge server can be used to circumvent censorship when regular Mullvad bridge servers don’t work."
+msgstr ""
+
#. This is a label shown above a list of options.
#. Available placeholder:
#. %(locationType) - Could be either "Country", "City" and "Relay"
@@ -1276,6 +1315,10 @@ msgid "Country"
msgstr ""
msgctxt "select-location-view"
+msgid "Custom bridge"
+msgstr ""
+
+msgctxt "select-location-view"
msgid "Custom lists"
msgstr ""
@@ -2145,9 +2188,6 @@ msgstr ""
msgid "Edit name"
msgstr ""
-msgid "Enable"
-msgstr ""
-
msgid "Enter MTU"
msgstr ""
diff --git a/gui/src/config.json b/gui/src/config.json
index 8db99ed2fb..c1ddcd6cac 100644
--- a/gui/src/config.json
+++ b/gui/src/config.json
@@ -17,6 +17,7 @@
"yellow": "rgb(255, 213, 36)",
"black": "rgb(0, 0, 0)",
"white": "rgb(255, 255, 255)",
+ "white90": "rgba(255, 255, 255, 0.9)",
"white80": "rgba(255, 255, 255, 0.8)",
"white60": "rgba(255, 255, 255, 0.6)",
"white50": "rgba(255, 255, 255, 0.5)",
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index 703ad027cc..531ec80ced 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -17,6 +17,7 @@ import {
ApiAccessMethodSettings,
AuthFailedError,
BridgeSettings,
+ BridgesMethod,
BridgeState,
BridgeType,
ConnectionConfig,
@@ -27,6 +28,7 @@ import {
DaemonEvent,
DeviceEvent,
DeviceState,
+ DirectMethod,
EndpointObfuscationType,
ErrorState,
ErrorStateCause,
@@ -82,7 +84,6 @@ const NETWORK_CALL_TIMEOUT = 10000;
const CHANNEL_STATE_TIMEOUT = 1000 * 60 * 60;
const noConnectionError = new Error('No connection established to daemon');
-const configNotSupported = new Error('Setting custom settings is not supported');
const invalidErrorStateCause = new Error(
'VPN_PERMISSION_DENIED is not a valid error state cause on desktop',
);
@@ -347,51 +348,17 @@ export class DaemonRpc {
public async setBridgeSettings(bridgeSettings: BridgeSettings): Promise<void> {
const grpcBridgeSettings = new grpcTypes.BridgeSettings();
- if (bridgeSettings.type === 'custom') {
- throw configNotSupported;
- }
-
- grpcBridgeSettings.setBridgeType(grpcTypes.BridgeSettings.BridgeType.NORMAL);
+ grpcBridgeSettings.setBridgeType(
+ bridgeSettings.type === 'normal'
+ ? grpcTypes.BridgeSettings.BridgeType.NORMAL
+ : grpcTypes.BridgeSettings.BridgeType.CUSTOM,
+ );
const normalSettings = convertToNormalBridgeSettings(bridgeSettings.normal);
grpcBridgeSettings.setNormal(normalSettings);
if (bridgeSettings.custom) {
- const customProxy = new grpcTypes.CustomProxy();
-
- const customSettings = bridgeSettings.custom;
-
- if ('local' in customSettings) {
- const local = customSettings.local;
- const socks5Local = new grpcTypes.Socks5Local();
- socks5Local.setLocalPort(local.localPort);
- socks5Local.setRemoteIp(local.remoteIp);
- socks5Local.setRemotePort(local.remotePort);
- customProxy.setSocks5local(socks5Local);
- }
- if ('remote' in customSettings) {
- const remote = customSettings.remote;
- const socks5Remote = new grpcTypes.Socks5Remote();
- if (remote.auth) {
- const auth = new grpcTypes.SocksAuth();
- auth.setUsername(remote.auth.username);
- auth.setPassword(remote.auth.password);
- socks5Remote.setAuth(auth);
- }
- socks5Remote.setIp(remote.ip);
- socks5Remote.setPort(remote.port);
- customProxy.setSocks5remote(socks5Remote);
- }
- if ('shadowsocks' in customSettings) {
- const shadowsocks = customSettings.shadowsocks;
- const shadowOut = new grpcTypes.Shadowsocks();
- shadowOut.setCipher(shadowsocks.cipher);
- shadowOut.setIp(shadowsocks.ip);
- shadowOut.setPort(shadowsocks.port);
- shadowOut.setPassword(shadowsocks.password);
- customProxy.setShadowsocks(shadowOut);
- }
-
+ const customProxy = convertToCustomProxy(bridgeSettings.custom);
grpcBridgeSettings.setCustom(customProxy);
}
@@ -1276,41 +1243,8 @@ function convertFromBridgeSettings(bridgeSettings: grpcTypes.BridgeSettings): Br
ownership,
};
- let custom = undefined;
-
- if (bridgeSettingsObject.custom) {
- const localSettings = bridgeSettingsObject.custom.socks5local;
- if (localSettings) {
- custom = {
- local: {
- localPort: localSettings.localPort,
- remoteIp: localSettings.remoteIp,
- remotePort: localSettings.remotePort,
- },
- };
- }
- const remoteSettings = bridgeSettingsObject.custom.socks5remote;
- if (remoteSettings) {
- custom = {
- remote: {
- ip: remoteSettings.ip,
- port: remoteSettings.port,
- auth: remoteSettings.auth && { ...remoteSettings.auth },
- },
- };
- }
- const shadowsocksSettings = bridgeSettingsObject.custom.shadowsocks;
- if (shadowsocksSettings) {
- custom = {
- shadowsocks: {
- ip: shadowsocksSettings.ip,
- port: shadowsocksSettings.port,
- password: shadowsocksSettings.password,
- cipher: shadowsocksSettings.cipher,
- },
- };
- }
- }
+ const grpcCustom = bridgeSettings.getCustom();
+ const custom = grpcCustom ? convertFromCustomProxy(grpcCustom) : undefined;
return { type, normal, custom };
}
@@ -1910,15 +1844,16 @@ function convertFromApiAccessMethodSettings(
): ApiAccessMethodSettings {
const direct = convertFromApiAccessMethodSetting(
ensureExists(accessMethods.getDirect(), "no 'Direct' access method was found"),
- );
+ ) as AccessMethodSetting<DirectMethod>;
const bridges = convertFromApiAccessMethodSetting(
ensureExists(accessMethods.getMullvadBridges(), "no 'Mullvad Bridges' access method was found"),
- );
- const custom =
- accessMethods
- .getCustomList()
- .filter((setting) => setting.hasId() && setting.hasAccessMethod())
- .map(convertFromApiAccessMethodSetting) ?? [];
+ ) as AccessMethodSetting<BridgesMethod>;
+ const custom = accessMethods
+ .getCustomList()
+ .filter((setting) => setting.hasId() && setting.hasAccessMethod())
+ .map(convertFromApiAccessMethodSetting)
+ // The last filter helps TypeScript infer the custom proxy type.
+ .filter(isCustomProxy);
return {
direct,
@@ -1927,6 +1862,12 @@ function convertFromApiAccessMethodSettings(
};
}
+function isCustomProxy(
+ accessMethod: AccessMethodSetting,
+): accessMethod is AccessMethodSetting<CustomProxy> {
+ return accessMethod.type !== 'direct' && accessMethod.type !== 'bridges';
+}
+
function convertFromApiAccessMethodSetting(
setting: grpcTypes.AccessMethodSetting,
): AccessMethodSetting {
@@ -1948,52 +1889,52 @@ function convertFromAccessMethod(method: grpcTypes.AccessMethod): AccessMethod {
case grpcTypes.AccessMethod.AccessMethodCase.BRIDGES:
return { type: 'bridges' };
case grpcTypes.AccessMethod.AccessMethodCase.CUSTOM: {
- const proxy = method.getCustom()!;
- switch (proxy.getProxyMethodCase()) {
- case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5LOCAL: {
- const socks5Local = proxy.getSocks5local()!;
- return {
- type: 'socks5-local',
- remoteIp: socks5Local.getRemoteIp(),
- remotePort: socks5Local.getRemotePort(),
- remoteTransportProtocol: convertFromTransportProtocol(
- socks5Local.getRemoteTransportProtocol(),
- ),
- localPort: socks5Local.getLocalPort(),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5REMOTE: {
- const socks5Remote = proxy.getSocks5remote()!;
- const auth = socks5Remote.getAuth();
- return {
- type: 'socks5-remote',
- ip: socks5Remote.getIp(),
- port: socks5Remote.getPort(),
- authentication: auth === undefined ? undefined : convertFromSocksAuth(auth),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.SHADOWSOCKS: {
- const shadowsocks = proxy.getShadowsocks()!;
- return {
- type: 'shadowsocks',
- ip: shadowsocks.getIp(),
- port: shadowsocks.getPort(),
- password: shadowsocks.getPassword(),
- cipher: shadowsocks.getCipher(),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.PROXY_METHOD_NOT_SET:
- throw new Error('Custom method not set, which should always be set');
- }
- // This break is required to prevent eslint from complainting about fallthrough, even though
- // all cases are covered above.
- break;
+ return convertFromCustomProxy(method.getCustom()!);
}
case grpcTypes.AccessMethod.AccessMethodCase.ACCESS_METHOD_NOT_SET:
throw new Error('Access method not set, which should always be set');
}
}
+function convertFromCustomProxy(proxy: grpcTypes.CustomProxy): CustomProxy {
+ switch (proxy.getProxyMethodCase()) {
+ case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5LOCAL: {
+ const socks5Local = proxy.getSocks5local()!;
+ return {
+ type: 'socks5-local',
+ remoteIp: socks5Local.getRemoteIp(),
+ remotePort: socks5Local.getRemotePort(),
+ remoteTransportProtocol: convertFromTransportProtocol(
+ socks5Local.getRemoteTransportProtocol(),
+ ),
+ localPort: socks5Local.getLocalPort(),
+ };
+ }
+ case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5REMOTE: {
+ const socks5Remote = proxy.getSocks5remote()!;
+ const auth = socks5Remote.getAuth();
+ return {
+ type: 'socks5-remote',
+ ip: socks5Remote.getIp(),
+ port: socks5Remote.getPort(),
+ authentication: auth === undefined ? undefined : convertFromSocksAuth(auth),
+ };
+ }
+ case grpcTypes.CustomProxy.ProxyMethodCase.SHADOWSOCKS: {
+ const shadowsocks = proxy.getShadowsocks()!;
+ return {
+ type: 'shadowsocks',
+ ip: shadowsocks.getIp(),
+ port: shadowsocks.getPort(),
+ password: shadowsocks.getPassword(),
+ cipher: shadowsocks.getCipher(),
+ };
+ }
+ case grpcTypes.CustomProxy.ProxyMethodCase.PROXY_METHOD_NOT_SET:
+ throw new Error('Custom method not set, which should always be set');
+ }
+}
+
function convertFromSocksAuth(auth: grpcTypes.SocksAuth): SocksAuth {
return {
username: auth.getUsername(),
diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts
index 16996eebd9..8bf60d9108 100644
--- a/gui/src/main/settings.ts
+++ b/gui/src/main/settings.ts
@@ -1,6 +1,5 @@
import fs from 'fs/promises';
-import BridgeSettingsBuilder from '../shared/bridge-settings-builder';
import { ISettings } from '../shared/daemon-rpc-types';
import { ICurrentAppVersionInfo } from '../shared/ipc-types';
import log from '../shared/logging';
@@ -44,9 +43,15 @@ export default class Settings implements Readonly<ISettings> {
IpcMainEventChannel.settings.handleSetBridgeState(async (bridgeState) => {
await this.daemonRpc.setBridgeState(bridgeState);
- // Reset bridge constraints to `any` when the state is set to auto or off
- if (bridgeState === 'auto' || bridgeState === 'off') {
- await this.daemonRpc.setBridgeSettings(new BridgeSettingsBuilder().location.any().build());
+ // Reset bridge constraints to `any` when the state is set to auto or off if not custom
+ if (
+ (bridgeState === 'auto' || bridgeState === 'off') &&
+ this.bridgeSettings.type === 'normal'
+ ) {
+ await this.daemonRpc.setBridgeSettings({
+ ...this.bridgeSettings,
+ normal: { ...this.bridgeSettings.normal, location: 'any' },
+ });
}
});
IpcMainEventChannel.settings.handleSetOpenVpnMssfix((mssfix?: number) =>
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 5e2c574d79..a8c1901ae8 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -657,6 +657,8 @@ export default class AppRenderer {
type: bridgeSettings.type,
normal: {
location: liftConstraint(bridgeSettings.normal.location),
+ providers: bridgeSettings.normal.providers,
+ ownership: bridgeSettings.normal.ownership,
},
custom: bridgeSettings.custom,
});
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 9e4a13adc8..0f85601a92 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -12,6 +12,7 @@ import Connect from './Connect';
import Debug from './Debug';
import { DeviceRevokedView } from './DeviceRevokedView';
import { EditApiAccessMethod } from './EditApiAccessMethod';
+import { EditCustomBridge } from './EditCustomBridge';
import {
SetupFinished,
TimeAdded,
@@ -92,6 +93,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
<Route exact path={RoutePath.debug} component={Debug} />
<Route exact path={RoutePath.selectLocation} component={SelectLocation} />
+ <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
<Route exact path={RoutePath.filter} component={Filter} />
</Switch>
</TransitionView>
diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx
index 07547363a0..a99140bb43 100644
--- a/gui/src/renderer/components/ConnectionPanel.tsx
+++ b/gui/src/renderer/components/ConnectionPanel.tsx
@@ -6,7 +6,6 @@ import { colors } from '../../config.json';
import {
EndpointObfuscationType,
ProxyType,
- proxyTypeToString,
RelayProtocol,
TunnelType,
tunnelTypeToString,
@@ -164,10 +163,9 @@ export default class ConnectionPanel extends React.Component<IProps> {
entry: this.props.entryHostname,
},
);
- } else if (this.props.bridgeInfo?.ip) {
- return sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), {
+ } else if (this.props.bridgeInfo !== undefined) {
+ return sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), {
relay: this.props.hostname,
- entry: this.props.bridgeInfo.ip,
});
} else {
return this.props.hostname || '';
@@ -181,7 +179,7 @@ export default class ConnectionPanel extends React.Component<IProps> {
const tunnelType = tunnelTypeToString(inAddress.tunnelType);
if (bridgeInfo) {
- const bridgeType = proxyTypeToString(bridgeInfo.bridgeType);
+ const bridgeType = this.bridgeType();
return sprintf(
// TRANSLATORS: The tunnel type line displayed below the hostname line on the main screen
@@ -201,4 +199,12 @@ export default class ConnectionPanel extends React.Component<IProps> {
return '';
}
}
+
+ private bridgeType() {
+ if (this.props.bridgeHostname && this.props.bridgeInfo?.bridgeType === 'shadowsocks') {
+ return 'Shadowsocks bridge';
+ } else {
+ return 'Custom bridge';
+ }
+ }
}
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
index b5b3bd6d48..59a5f2ff37 100644
--- a/gui/src/renderer/components/EditApiAccessMethod.tsx
+++ b/gui/src/renderer/components/EditApiAccessMethod.tsx
@@ -1,38 +1,27 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router';
import { sprintf } from 'sprintf-js';
import {
- AccessMethod,
- AccessMethodSetting,
CustomProxy,
+ NamedCustomProxy,
NewAccessMethodSetting,
- RelayProtocol,
- ShadowsocksAccessMethod,
- Socks5LocalAccessMethod,
- Socks5RemoteAccessMethod,
} from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { useScheduler } from '../../shared/scheduler';
import { useAppContext } from '../context';
import { useApiAccessMethodTest } from '../lib/api-access-methods';
import { useHistory } from '../lib/history';
-import { IpAddress } from '../lib/ip';
import { useSelector } from '../redux/store';
-import * as Cell from './cell';
-import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
-import { SettingsGroup } from './cell/SettingsGroup';
-import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
-import { SettingsRow } from './cell/SettingsRow';
-import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
-import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
+import { SettingsForm } from './cell/SettingsForm';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
import { ModalAlert, ModalAlertType } from './Modal';
import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import { NamedProxyForm } from './ProxyForm';
import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton, SmallButtonGroup } from './SmallButton';
+import { SmallButton } from './SmallButton';
export function EditApiAccessMethod() {
return (
@@ -45,7 +34,7 @@ export function EditApiAccessMethod() {
function AccessMethodForm() {
const history = useHistory();
const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
- const methods = useSelector((state) => state.settings.apiAccessMethods);
+ const methods = useSelector((state) => state.settings.apiAccessMethods.custom);
const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest(
false,
@@ -55,19 +44,9 @@ function AccessMethodForm() {
// Use id in url to figure out which method is to be edited. undefined means this is a new method.
const { id } = useParams<{ id: string | undefined }>();
- // Ugly way of iterating over all access methods, but it works.
- const method = [methods.direct, methods.mullvadBridges, ...methods.custom].find(
- (method) => method.id === id,
- );
-
- const updatedMethod = useRef<NewAccessMethodSetting | undefined>(method);
- const updateMethod = useCallback(
- (method: NewAccessMethodSetting) => (updatedMethod.current = method),
- [],
- );
+ const method = methods.find((method) => method.id === id);
- // Contains form submittability to know whether or not to enable the Add/Save button.
- const formSubmittable = useSettingsFormSubmittable();
+ const updatedMethod = useRef<NewAccessMethodSetting<CustomProxy> | undefined>(method);
const save = useCallback(() => {
if (updatedMethod.current !== undefined) {
@@ -81,15 +60,20 @@ function AccessMethodForm() {
}
}, [updatedMethod.current, id]);
- const onSave = useCallback(async () => {
- if (
- updatedMethod.current !== undefined &&
- (await testApiAccessMethod(updatedMethod.current as CustomProxy))
- ) {
- // Hide the save dialog after 1.5 seconds.
- saveScheduler.schedule(save, 1500);
- }
- }, [updatedMethod, save, history.pop]);
+ const onSave = useCallback(
+ async (newMethod: NamedCustomProxy) => {
+ const enabled = id === undefined ? true : method?.enabled ?? true;
+ updatedMethod.current = { ...newMethod, enabled };
+ if (
+ updatedMethod.current !== undefined &&
+ (await testApiAccessMethod(updatedMethod.current as CustomProxy))
+ ) {
+ // Hide the save dialog after 1.5 seconds.
+ saveScheduler.schedule(save, 1500);
+ }
+ },
+ [updatedMethod, save, history.pop],
+ );
const title = getTitle(id === undefined);
const subtitle = getSubtitle(id === undefined);
@@ -116,15 +100,8 @@ function AccessMethodForm() {
{id !== undefined && method === undefined ? (
<span>Failed to open method</span>
) : (
- <AccessMethodFormImpl method={method} updateMethod={updateMethod} />
+ <NamedProxyForm proxy={method} onSave={onSave} onCancel={history.pop} />
)}
-
- <SmallButtonGroup>
- <SmallButton onClick={history.pop}>{messages.gettext('Cancel')}</SmallButton>
- <SmallButton onClick={onSave} disabled={!formSubmittable}>
- {id === undefined ? messages.gettext('Add') : messages.gettext('Save')}
- </SmallButton>
- </SmallButtonGroup>
</StyledSettingsContent>
<TestingDialog
@@ -257,387 +234,3 @@ function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel:
return [cancelButton];
}
}
-
-interface EditApiAccessMethodImplProps {
- method?: AccessMethodSetting;
- updateMethod: (method: NewAccessMethodSetting) => void;
-}
-
-function AccessMethodFormImpl(props: EditApiAccessMethodImplProps) {
- // Available method types.
- const types = useMemo<Array<SettingsSelectItem<AccessMethod['type']>>>(
- () => [
- { value: 'shadowsocks', label: 'Shadowsocks' },
- {
- value: 'socks5-remote',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
- },
- {
- value: 'socks5-local',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
- },
- ],
- [],
- );
- const [type, setType] = useState(props.method?.type ?? 'shadowsocks');
-
- // State for the name input.
- const name = useRef(props.method?.name ?? '');
- const method = useRef<AccessMethod | undefined>(props.method);
-
- // When the form makes up a valid method the parent is updated.
- const onUpdate = useCallback(() => {
- if (method.current !== undefined && name.current !== '') {
- props.updateMethod({ ...method.current, name: name.current, enabled: true });
- }
- }, []);
-
- const updateName = useCallback(
- (value: string) => {
- name.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- const updateMethod = useCallback(
- (value: AccessMethod) => {
- method.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- return (
- <>
- <SettingsRow label={messages.gettext('Name')}>
- <SettingsTextInput
- defaultValue={name.current}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
- onUpdate={updateName}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Type')}>
- <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
- </SettingsRow>
-
- {type === 'shadowsocks' && (
- <EditShadowsocks
- onUpdate={updateMethod}
- method={props.method?.type === 'shadowsocks' ? props.method : undefined}
- />
- )}
- {type === 'socks5-remote' && (
- <EditSocks5Remote
- onUpdate={updateMethod}
- method={props.method?.type === 'socks5-remote' ? props.method : undefined}
- />
- )}
- {type === 'socks5-local' && (
- <EditSocks5Local
- onUpdate={updateMethod}
- method={props.method?.type === 'socks5-local' ? props.method : undefined}
- />
- )}
- </>
- );
-}
-
-interface EditMethodProps<T> {
- method?: T;
- onUpdate: (method: AccessMethod) => void;
-}
-
-function EditShadowsocks(props: EditMethodProps<ShadowsocksAccessMethod>) {
- const [ip, setIp] = useState(props.method?.ip ?? '');
- const [port, setPort] = useState(props.method?.port);
- const [password, setPassword] = useState(props.method?.password ?? '');
- const [cipher, setCipher] = useState(props.method?.cipher);
-
- const ciphers = useMemo(
- () =>
- [
- { value: 'aes-128-cfb', label: 'aes-128-cfb' },
- { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
- { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
- { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
- { value: 'aes-256-cfb', label: 'aes-256-cfb' },
- { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
- { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
- { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
- { value: 'rc4', label: 'rc4' },
- { value: 'rc4-md5', label: 'rc4-md5' },
- { value: 'chacha20', label: 'chacha20' },
- { value: 'salsa20', label: 'salsa20' },
- { value: 'chacha20-ietf', label: 'chacha20-ietf' },
- { value: 'aes-128-gcm', label: 'aes-128-gcm' },
- { value: 'aes-256-gcm', label: 'aes-256-gcm' },
- { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
- { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
- { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
- { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
- ].sort((a, b) => a.label.localeCompare(b.label)),
- [],
- );
-
- // Report back to form component with the method values when all required values are set.
- useEffect(() => {
- if (ip !== '' && port !== undefined && cipher !== undefined) {
- props.onUpdate({
- type: 'shadowsocks',
- ip,
- port,
- password,
- cipher,
- });
- }
- }, [ip, port, password, cipher]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Optional')}
- onUpdate={setPassword}
- optionalInForm
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Cipher')}>
- <SettingsSelect
- data-testid="ciphers"
- direction="up"
- defaultValue={cipher}
- onUpdate={setCipher}
- items={ciphers}
- />
- </SettingsRow>
- </SettingsGroup>
- );
-}
-
-function EditSocks5Remote(props: EditMethodProps<Socks5RemoteAccessMethod>) {
- const [ip, setIp] = useState(props.method?.ip ?? '');
- const [port, setPort] = useState(props.method?.port);
- const [authentication, setAuthentication] = useState(props.method?.authentication !== undefined);
- const [username, setUsername] = useState(props.method?.authentication?.username ?? '');
- const [password, setPassword] = useState(props.method?.authentication?.password ?? '');
-
- // Report back to form component with the method values when all required values are set.
- useEffect(() => {
- if (
- ip !== '' &&
- port !== undefined &&
- (!authentication || (username !== '' && password !== ''))
- ) {
- props.onUpdate({
- type: 'socks5-remote',
- ip,
- port,
- authentication: authentication ? { username, password } : undefined,
- });
- }
- }, [ip, port, username, password]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}>
- <Cell.Switch isOn={authentication} onChange={setAuthentication} />
- </SettingsRow>
-
- {authentication && (
- <>
- <SettingsRow label={messages.gettext('Username')}>
- <SettingsTextInput
- value={username}
- placeholder={messages.gettext('Required')}
- onUpdate={setUsername}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Required')}
- onUpdate={setPassword}
- />
- </SettingsRow>
- </>
- )}
- </SettingsGroup>
- );
-}
-
-function EditSocks5Local(props: EditMethodProps<Socks5LocalAccessMethod>) {
- const [remoteIp, setRemoteIp] = useState(props.method?.remoteIp ?? '');
- const [remotePort, setRemotePort] = useState(props.method?.remotePort);
- const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
- props.method?.remoteTransportProtocol ?? 'tcp',
- );
- const [localPort, setLocalPort] = useState(props.method?.localPort);
-
- const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
- () => [
- { value: 'tcp', label: 'TCP' },
- { value: 'udp', label: 'UDP' },
- ],
- [],
- );
-
- useEffect(() => {
- if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
- props.onUpdate({
- type: 'socks5-local',
- remoteIp,
- remotePort,
- remoteTransportProtocol,
- localPort,
- });
- }
- }, [remoteIp, remotePort, localPort, remoteTransportProtocol]);
-
- return (
- <>
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')}
- infoMessage={messages.pgettext(
- 'api-access-methods-view',
- 'The TCP port where your local SOCKS5 server is listening.',
- )}>
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid localhost port.',
- )}>
- <SettingsNumberInput
- value={localPort}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setLocalPort}
- validate={validatePort}
- />
- </SettingsRow>
- </SettingsGroup>
-
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Remote Server')}
- infoMessage={[
- messages.pgettext(
- 'api-access-methods-view',
- 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This is needed so our app can allow that traffic in the firewall.',
- ),
- ]}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={remoteIp}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setRemoteIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={remotePort ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setRemotePort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}>
- <SettingsRadioGroup<'tcp' | 'udp'>
- defaultValue={remoteTransportProtocol}
- onUpdate={setRemoteTransportProtocol}
- items={remoteTransportProtocols}
- />
- </SettingsRow>
- </SettingsGroup>
- </>
- );
-}
-
-function validateIp(ip: string): boolean {
- try {
- void IpAddress.fromString(ip);
- return true;
- } catch {
- return false;
- }
-}
-
-function validatePort(port: number): boolean {
- return port > 0 && port <= 65535;
-}
diff --git a/gui/src/renderer/components/EditCustomBridge.tsx b/gui/src/renderer/components/EditCustomBridge.tsx
new file mode 100644
index 0000000000..2692217ea0
--- /dev/null
+++ b/gui/src/renderer/components/EditCustomBridge.tsx
@@ -0,0 +1,116 @@
+import { useCallback } from 'react';
+
+import { CustomProxy } from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { useBridgeSettingsUpdater } from '../lib/constraint-updater';
+import { useHistory } from '../lib/history';
+import { useBoolean } from '../lib/utilityHooks';
+import { useSelector } from '../redux/store';
+import { SettingsForm } from './cell/SettingsForm';
+import { BackAction } from './KeyboardNavigation';
+import { Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType } from './Modal';
+import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import { ProxyForm } from './ProxyForm';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
+import { SmallButton, SmallButtonColor } from './SmallButton';
+
+export function EditCustomBridge() {
+ return (
+ <SettingsForm>
+ <CustomBridgeForm />
+ </SettingsForm>
+ );
+}
+
+function CustomBridgeForm() {
+ const history = useHistory();
+ const bridgeSettingsUpdater = useBridgeSettingsUpdater();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+
+ const [deleteDialogVisible, showDeleteDialog, hideDeleteDialog] = useBoolean();
+
+ // If there are no custom bridge settings, we should prompt the user to add a custom bridge.
+ // Otherwise, we should prompt them to edit the existing custom bridge settings.
+ const title =
+ bridgeSettings.custom === undefined
+ ? messages.pgettext('custom-bridge', 'Add custom bridge')
+ : messages.pgettext('custom-bridge', 'Edit custom bridge');
+
+ const onSave = useCallback(
+ (newBridge: CustomProxy) => {
+ void bridgeSettingsUpdater((bridgeSettings) => {
+ bridgeSettings.type = 'custom';
+ bridgeSettings.custom = newBridge;
+ return bridgeSettings;
+ });
+ history.pop();
+ },
+ [bridgeSettingsUpdater, history.pop],
+ );
+
+ const onDelete = useCallback(() => {
+ if (bridgeSettings.custom !== undefined) {
+ hideDeleteDialog();
+ void bridgeSettingsUpdater((bridgeSettings) => {
+ bridgeSettings.type = 'normal';
+ delete bridgeSettings.custom;
+ return bridgeSettings;
+ });
+ history.pop();
+ }
+ }, [bridgeSettingsUpdater, history.pop]);
+
+ return (
+ <BackAction action={history.pop}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <TitleBarItem>{title}</TitleBarItem>
+ </NavigationItems>
+ </NavigationBar>
+
+ <StyledNavigationScrollbars fillContainer>
+ <StyledContent>
+ <SettingsHeader>
+ <HeaderTitle>{title}</HeaderTitle>
+ </SettingsHeader>
+
+ <StyledSettingsContent>
+ <ProxyForm
+ proxy={bridgeSettings.custom}
+ onSave={onSave}
+ onCancel={history.pop}
+ onDelete={bridgeSettings.custom === undefined ? undefined : showDeleteDialog}
+ />
+ </StyledSettingsContent>
+
+ <ModalAlert
+ isOpen={deleteDialogVisible}
+ type={ModalAlertType.warning}
+ gridButtons={[
+ <SmallButton key="cancel" onClick={hideDeleteDialog}>
+ {messages.gettext('Cancel')}
+ </SmallButton>,
+ <SmallButton key="delete" color={SmallButtonColor.red} onClick={onDelete}>
+ {messages.gettext('Delete')}
+ </SmallButton>,
+ ]}
+ close={hideDeleteDialog}
+ title={messages.pgettext('custom-bridge', 'Delete custom bridge?')}
+ message={messages.pgettext(
+ 'custom-bridge',
+ 'Deleting the custom bridge will take you back to the select location view and the Automatic option will be selected instead.',
+ )}
+ />
+ </StyledContent>
+ </StyledNavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
diff --git a/gui/src/renderer/components/OpenVpnSettings.tsx b/gui/src/renderer/components/OpenVpnSettings.tsx
index 679794d831..dee1e64089 100644
--- a/gui/src/renderer/components/OpenVpnSettings.tsx
+++ b/gui/src/renderer/components/OpenVpnSettings.tsx
@@ -18,13 +18,12 @@ import { useHistory } from '../lib/history';
import { formatHtml } from '../lib/html-formatter';
import { useBoolean } from '../lib/utilityHooks';
import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
import * as Cell from './cell';
import Selector, { SelectorItem } from './cell/Selector';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
+import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
import {
NavigationBar,
NavigationContainer,
@@ -33,6 +32,7 @@ import {
TitleBarItem,
} from './NavigationBar';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import { SmallButton } from './SmallButton';
const MIN_MSSFIX_VALUE = 1000;
const MAX_MSSFIX_VALUE = 1450;
@@ -308,6 +308,8 @@ function BridgeModeSelector() {
await setBridgeState('on');
}, [hideConfirmationDialog, setBridgeState]);
+ const footerText = bridgeModeFooterText(bridgeState === 'on', tunnelProtocol, transportProtocol);
+
return (
<>
<AriaInputGroup>
@@ -317,31 +319,57 @@ function BridgeModeSelector() {
// TRANSLATORS: The title for the shadowsocks bridge selector section.
messages.pgettext('openvpn-settings-view', 'Bridge mode')
}
+ infoTitle={messages.pgettext('openvpn-settings-view', 'Bridge mode')}
+ details={
+ <>
+ <ModalMessage>
+ {sprintf(
+ // TRANSLATORS: This is used as a description for the bridge mode
+ // TRANSLATORS: setting.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
+ messages.pgettext(
+ 'openvpn-settings-view',
+ 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an %(openvpn)s server. Obfuscation is added to make fingerprinting harder.',
+ ),
+ { openvpn: strings.openvpn },
+ )}
+ </ModalMessage>
+ <ModalMessage>
+ {messages.gettext('This setting increases latency. Use only if needed.')}
+ </ModalMessage>
+ </>
+ }
items={options}
value={bridgeState}
onSelect={onSelectBridgeState}
automaticValue={'auto' as const}
/>
</StyledSelectorContainer>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {bridgeModeFooterText(tunnelProtocol, transportProtocol)}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
+ {footerText !== undefined && (
+ <Cell.CellFooter>
+ <AriaDescription>
+ <Cell.CellFooterText>{footerText}</Cell.CellFooterText>
+ </AriaDescription>
+ </Cell.CellFooter>
+ )}
</AriaInputGroup>
<ModalAlert
isOpen={confirmationDialogVisible}
type={ModalAlertType.caution}
- message={messages.gettext('This setting increases latency. Use only if needed.')}
- buttons={[
- <AppButton.RedButton key="confirm" onClick={confirmBridgeState}>
- {messages.gettext('Enable anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={hideConfirmationDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
+ title={messages.pgettext('openvpn-settings-view', 'Enable bridge mode?')}
+ message={
+ // TRANSLATORS: Warning shown in dialog to users when they enable setting that increases
+ // TRANSLATORS: network latency (decreases performance).
+ messages.gettext('This setting increases latency. Use only if needed.')
+ }
+ gridButtons={[
+ <SmallButton key="cancel" onClick={hideConfirmationDialog}>
+ {messages.gettext('Cancel')}
+ </SmallButton>,
+ <SmallButton key="confirm" onClick={confirmBridgeState}>
+ {messages.gettext('Enable')}
+ </SmallButton>,
]}
close={hideConfirmationDialog}
/>
@@ -350,10 +378,18 @@ function BridgeModeSelector() {
}
function bridgeModeFooterText(
+ bridgeModeOn: boolean,
tunnelProtocol: TunnelProtocol | null,
transportProtocol: RelayProtocol | null,
-) {
- if (tunnelProtocol !== 'openvpn') {
+): React.ReactNode | void {
+ if (bridgeModeOn) {
+ // TRANSLATORS: This text is shown beneath the bridge mode setting to instruct users how to
+ // TRANSLATORS: configure the feature further.
+ return messages.pgettext(
+ 'openvpn-settings-view',
+ 'To select a specific bridge server, go to the Select location view.',
+ );
+ } else if (tunnelProtocol !== 'openvpn') {
return formatHtml(
sprintf(
// TRANSLATORS: This is used to instruct users how to make the bridge mode setting
@@ -391,18 +427,6 @@ function bridgeModeFooterText(
},
),
);
- } else {
- return sprintf(
- // TRANSLATORS: This is used as a description for the bridge mode
- // TRANSLATORS: setting.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
- messages.pgettext(
- 'openvpn-settings-view',
- 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an %(openvpn)s server. Obfuscation is added to make fingerprinting harder.',
- ),
- { openvpn: strings.openvpn },
- );
}
}
diff --git a/gui/src/renderer/components/ProxyForm.tsx b/gui/src/renderer/components/ProxyForm.tsx
new file mode 100644
index 0000000000..5fbcf34d0f
--- /dev/null
+++ b/gui/src/renderer/components/ProxyForm.tsx
@@ -0,0 +1,532 @@
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import React from 'react';
+
+import {
+ CustomProxy,
+ NamedCustomProxy,
+ RelayProtocol,
+ ShadowsocksCustomProxy,
+ Socks5LocalCustomProxy,
+ Socks5RemoteCustomProxy,
+} from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { IpAddress } from '../lib/ip';
+import * as Cell from './cell';
+import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
+import { SettingsGroup } from './cell/SettingsGroup';
+import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
+import { SettingsRow } from './cell/SettingsRow';
+import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
+import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
+import {
+ SmallButton,
+ SmallButtonColor,
+ SmallButtonGroup,
+ SmallButtonGroupStart,
+} from './SmallButton';
+
+interface ProxyFormContext {
+ proxy?: CustomProxy;
+ setProxy: (proxy: CustomProxy) => void;
+ onSave: () => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+const proxyFormContext = React.createContext<ProxyFormContext>({
+ get proxy(): CustomProxy {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ setProxy(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onSave(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onCancel(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onDelete(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+});
+
+interface ProxyFormContextProviderProps {
+ proxy?: CustomProxy;
+ onSave: (proxy: CustomProxy) => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+function ProxyFormContextProvider(props: React.PropsWithChildren<ProxyFormContextProviderProps>) {
+ const [proxy, setProxy] = useState<CustomProxy | undefined>(props.proxy);
+
+ const onSave = useCallback(() => {
+ if (proxy !== undefined) {
+ props.onSave(proxy);
+ }
+ }, [proxy, props.onSave]);
+
+ const value = useMemo(
+ () => ({ proxy, setProxy, onSave, onCancel: props.onCancel, onDelete: props.onDelete }),
+ [proxy, onSave, props.onCancel, props.onDelete],
+ );
+
+ return <proxyFormContext.Provider value={value}>{props.children}</proxyFormContext.Provider>;
+}
+
+export function ProxyForm(props: ProxyFormContextProviderProps) {
+ return (
+ <ProxyFormContextProvider {...props}>
+ <SettingsForm>
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ );
+}
+
+interface NamedProxyFormContext {
+ name?: string;
+ setName: (name: string) => void;
+}
+
+const namedProxyFormContext = React.createContext<NamedProxyFormContext>({
+ get name(): string {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+ setName(): void {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+});
+
+interface NamedProxyFormContainerProps
+ extends Omit<ProxyFormContextProviderProps, 'proxy' | 'onSave'> {
+ proxy?: NamedCustomProxy;
+ onSave: (proxy: NamedCustomProxy) => void;
+}
+
+export function NamedProxyForm(props: NamedProxyFormContainerProps) {
+ const { onSave, ...otherProps } = props;
+
+ const [name, setName] = useState<string>(props.proxy?.name ?? '');
+
+ const save = useCallback(
+ (proxy: CustomProxy) => {
+ if (name !== '') {
+ onSave({ ...proxy, name });
+ }
+ },
+ [name, onSave],
+ );
+
+ const nameContextValue = useMemo(() => ({ name, setName }), [name]);
+
+ return (
+ <namedProxyFormContext.Provider value={nameContextValue}>
+ <ProxyFormContextProvider {...otherProps} onSave={save}>
+ <SettingsForm>
+ <ProxyFormNameField />
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ </namedProxyFormContext.Provider>
+ );
+}
+
+function ProxyFormNameField() {
+ const { name, setName } = useContext(namedProxyFormContext);
+
+ return (
+ <SettingsRow label={messages.gettext('Name')}>
+ <SettingsTextInput
+ defaultValue={name}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
+ onUpdate={setName}
+ />
+ </SettingsRow>
+ );
+}
+
+export function ProxyFormButtons() {
+ const { proxy, onSave, onCancel, onDelete } = useContext(proxyFormContext);
+
+ // Contains form submittability to know whether or not to enable the Add/Save button.
+ const formSubmittable = useSettingsFormSubmittable();
+
+ return (
+ <SmallButtonGroup>
+ {onDelete !== undefined && (
+ <SmallButtonGroupStart>
+ <SmallButton color={SmallButtonColor.red} onClick={onDelete}>
+ {messages.gettext('Delete')}
+ </SmallButton>
+ </SmallButtonGroupStart>
+ )}
+ <SmallButton onClick={onCancel}>{messages.gettext('Cancel')}</SmallButton>
+ <SmallButton onClick={onSave} disabled={!formSubmittable}>
+ {proxy === undefined ? messages.gettext('Add') : messages.gettext('Save')}
+ </SmallButton>
+ </SmallButtonGroup>
+ );
+}
+
+function ProxyFormInner() {
+ const { proxy, setProxy } = useContext(proxyFormContext);
+
+ // Available custom proxies
+ const types = useMemo<Array<SettingsSelectItem<CustomProxy['type']>>>(
+ () => [
+ { value: 'shadowsocks', label: 'Shadowsocks' },
+ {
+ value: 'socks5-remote',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
+ },
+ {
+ value: 'socks5-local',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
+ },
+ ],
+ [],
+ );
+ const [type, setType] = useState(proxy?.type ?? 'shadowsocks');
+ const proxyRef = useRef<CustomProxy | undefined>(proxy);
+
+ const updateProxy = useCallback(
+ (value: CustomProxy) => {
+ proxyRef.current = value;
+
+ // When the form makes up a valid proxy the parent is updated.
+ if (proxyRef.current !== undefined) {
+ setProxy(proxyRef.current);
+ }
+ },
+ [setProxy],
+ );
+
+ return (
+ <>
+ <SettingsRow label={messages.gettext('Type')}>
+ <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
+ </SettingsRow>
+
+ {type === 'shadowsocks' && (
+ <EditShadowsocks
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'shadowsocks' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-remote' && (
+ <EditSocks5Remote
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-remote' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-local' && (
+ <EditSocks5Local
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-local' ? proxy : undefined}
+ />
+ )}
+ </>
+ );
+}
+
+interface EditProxyProps<T> {
+ proxy?: T;
+ onUpdate: (proxy: CustomProxy) => void;
+}
+
+function EditShadowsocks(props: EditProxyProps<ShadowsocksCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [password, setPassword] = useState(props.proxy?.password ?? '');
+ const [cipher, setCipher] = useState(props.proxy?.cipher);
+
+ const ciphers = useMemo(
+ () =>
+ [
+ { value: 'aes-128-cfb', label: 'aes-128-cfb' },
+ { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
+ { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
+ { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
+ { value: 'aes-256-cfb', label: 'aes-256-cfb' },
+ { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
+ { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
+ { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
+ { value: 'rc4', label: 'rc4' },
+ { value: 'rc4-md5', label: 'rc4-md5' },
+ { value: 'chacha20', label: 'chacha20' },
+ { value: 'salsa20', label: 'salsa20' },
+ { value: 'chacha20-ietf', label: 'chacha20-ietf' },
+ { value: 'aes-128-gcm', label: 'aes-128-gcm' },
+ { value: 'aes-256-gcm', label: 'aes-256-gcm' },
+ { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
+ { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
+ { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
+ { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
+ ].sort((a, b) => a.label.localeCompare(b.label)),
+ [],
+ );
+
+ // Report back to form component with the proxy values when all required values are set.
+ useEffect(() => {
+ if (ip !== '' && port !== undefined && cipher !== undefined) {
+ props.onUpdate({
+ type: 'shadowsocks',
+ ip,
+ port,
+ password,
+ cipher,
+ });
+ }
+ }, [ip, port, password, cipher]);
+
+ return (
+ <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={ip}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={port ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Password')}>
+ <SettingsTextInput
+ value={password}
+ placeholder={messages.gettext('Optional')}
+ onUpdate={setPassword}
+ optionalInForm
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Cipher')}>
+ <SettingsSelect
+ data-testid="ciphers"
+ direction="up"
+ defaultValue={cipher}
+ onUpdate={setCipher}
+ items={ciphers}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Remote(props: EditProxyProps<Socks5RemoteCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [authentication, setAuthentication] = useState(props.proxy?.authentication !== undefined);
+ const [username, setUsername] = useState(props.proxy?.authentication?.username ?? '');
+ const [password, setPassword] = useState(props.proxy?.authentication?.password ?? '');
+
+ // Report back to form component with the proxy values when all required values are set.
+ useEffect(() => {
+ if (
+ ip !== '' &&
+ port !== undefined &&
+ (!authentication || (username !== '' && password !== ''))
+ ) {
+ props.onUpdate({
+ type: 'socks5-remote',
+ ip,
+ port,
+ authentication: authentication ? { username, password } : undefined,
+ });
+ }
+ }, [ip, port, username, password]);
+
+ return (
+ <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={ip}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={port ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}>
+ <Cell.Switch isOn={authentication} onChange={setAuthentication} />
+ </SettingsRow>
+
+ {authentication && (
+ <>
+ <SettingsRow label={messages.gettext('Username')}>
+ <SettingsTextInput
+ value={username}
+ placeholder={messages.gettext('Required')}
+ onUpdate={setUsername}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Password')}>
+ <SettingsTextInput
+ value={password}
+ placeholder={messages.gettext('Required')}
+ onUpdate={setPassword}
+ />
+ </SettingsRow>
+ </>
+ )}
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Local(props: EditProxyProps<Socks5LocalCustomProxy>) {
+ const [remoteIp, setRemoteIp] = useState(props.proxy?.remoteIp ?? '');
+ const [remotePort, setRemotePort] = useState(props.proxy?.remotePort);
+ const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
+ props.proxy?.remoteTransportProtocol ?? 'tcp',
+ );
+ const [localPort, setLocalPort] = useState(props.proxy?.localPort);
+
+ const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
+ () => [
+ { value: 'tcp', label: 'TCP' },
+ { value: 'udp', label: 'UDP' },
+ ],
+ [],
+ );
+
+ useEffect(() => {
+ if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
+ props.onUpdate({
+ type: 'socks5-local',
+ remoteIp,
+ remotePort,
+ remoteTransportProtocol,
+ localPort,
+ });
+ }
+ }, [remoteIp, remotePort, localPort, remoteTransportProtocol]);
+
+ return (
+ <>
+ <SettingsGroup
+ title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')}
+ infoMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'The TCP port where your local SOCKS5 server is listening.',
+ )}>
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid localhost port.',
+ )}>
+ <SettingsNumberInput
+ value={localPort}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setLocalPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+
+ <SettingsGroup
+ title={messages.pgettext('api-access-methods-view', 'Remote Server')}
+ infoMessage={[
+ messages.pgettext(
+ 'api-access-methods-view',
+ 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.',
+ ),
+ messages.pgettext(
+ 'api-access-methods-view',
+ 'This is needed so our app can allow that traffic in the firewall.',
+ ),
+ ]}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={remoteIp}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setRemoteIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={remotePort ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setRemotePort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}>
+ <SettingsRadioGroup<'tcp' | 'udp'>
+ defaultValue={remoteTransportProtocol}
+ onUpdate={setRemoteTransportProtocol}
+ items={remoteTransportProtocols}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+ </>
+ );
+}
+
+function validateIp(ip: string): boolean {
+ try {
+ void IpAddress.fromString(ip);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function validatePort(port: number): boolean {
+ return port > 0 && port <= 65535;
+}
diff --git a/gui/src/renderer/components/RelayStatusIndicator.tsx b/gui/src/renderer/components/RelayStatusIndicator.tsx
index 549fec4e70..1060121b9e 100644
--- a/gui/src/renderer/components/RelayStatusIndicator.tsx
+++ b/gui/src/renderer/components/RelayStatusIndicator.tsx
@@ -1,13 +1,19 @@
import styled from 'styled-components';
+import { Styles } from 'styled-components/dist/types';
import { colors } from '../../config.json';
import * as Cell from './cell';
-const StyledRelayStatus = styled.div<{ $active: boolean }>((props) => ({
+const indicatorStyles: Styles<
+ React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
+> = {
width: '16px',
height: '16px',
borderRadius: '8px',
margin: '0 12px 0 4px',
+};
+
+const StyledRelayStatus = styled.div<{ $active: boolean }>(indicatorStyles, (props) => ({
backgroundColor: props.$active ? colors.green90 : colors.red95,
}));
@@ -28,3 +34,7 @@ export default function RelayStatusIndicator(props: IProps) {
<StyledRelayStatus $active={props.active} />
);
}
+
+export const SpecialLocationIndicator = styled.div(indicatorStyles, {
+ backgroundColor: colors.white90,
+});
diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx
index 181a557fd2..ba82f15cd4 100644
--- a/gui/src/renderer/components/SmallButton.tsx
+++ b/gui/src/renderer/components/SmallButton.tsx
@@ -37,6 +37,11 @@ const StyledSmallButton = styled.button<{ $color?: SmallButtonColor; disabled?:
borderRadius: '4px',
marginLeft: '12px',
+ [`${SmallButtonGroupStart} &&`]: {
+ marginLeft: 0,
+ marginRight: '12px',
+ },
+
[`${StyledSmallButtonGrid} &&`]: {
marginLeft: 0,
},
@@ -67,6 +72,12 @@ export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) =
marginTop: props.$noMarginTop ? 0 : '30px',
}));
+export const SmallButtonGroupStart = styled(SmallButtonGroup)({
+ flex: 1,
+ justifyContent: 'start',
+ margin: 0,
+});
+
const StyledSmallButtonGrid = styled.div<{ $columns: number }>((props) => ({
display: 'grid',
gridTemplateColumns: `repeat(${props.$columns}, 1fr)`,
diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx
index debcdbef38..e527017435 100644
--- a/gui/src/renderer/components/cell/Selector.tsx
+++ b/gui/src/renderer/components/cell/Selector.tsx
@@ -25,6 +25,7 @@ interface CommonSelectorProps<T, U> {
value: T | U;
selectedCellRef?: React.Ref<HTMLElement>;
className?: string;
+ infoTitle?: string;
details?: React.ReactElement;
expandable?: { expandable: boolean; id: string };
disabled?: boolean;
@@ -82,7 +83,7 @@ export default function Selector<T, U>(props: SelectorProps<T, U>) {
</AriaLabel>
{props.details && (
<AriaDetails>
- <InfoButton>{props.details}</InfoButton>
+ <InfoButton title={props.infoTitle}>{props.details}</InfoButton>
</AriaDetails>
)}
</>
diff --git a/gui/src/renderer/components/select-location/CustomLists.tsx b/gui/src/renderer/components/select-location/CustomLists.tsx
index 737d66bf89..ca4638360c 100644
--- a/gui/src/renderer/components/select-location/CustomLists.tsx
+++ b/gui/src/renderer/components/select-location/CustomLists.tsx
@@ -12,7 +12,7 @@ import * as Cell from '../cell';
import { measurements } from '../common-styles';
import { BackAction } from '../KeyboardNavigation';
import SimpleInput from '../SimpleInput';
-import { StyledLocationRowIcon } from './LocationRow';
+import { StyledLocationRowIcon } from './LocationRowStyles';
import { useRelayListContext } from './RelayListContext';
import RelayLocationList from './RelayLocationList';
import { useScrollPositionContext } from './ScrollPositionContext';
diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
index 79b44d372a..f76b5d3f61 100644
--- a/gui/src/renderer/components/select-location/LocationRow.tsx
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -1,8 +1,6 @@
import React, { useCallback, useRef } from 'react';
import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-import { colors } from '../../../config.json';
import {
compareRelayLocation,
compareRelayLocationGeographical,
@@ -16,11 +14,18 @@ import { useSelector } from '../../redux/store';
import Accordion from '../Accordion';
import * as Cell from '../cell';
import ChevronButton from '../ChevronButton';
-import { measurements, normalText } from '../common-styles';
-import ImageView from '../ImageView';
import RelayStatusIndicator from '../RelayStatusIndicator';
import { AddToListDialog, DeleteConfirmDialog, EditListDialog } from './CustomListDialogs';
import {
+ getButtonColor,
+ StyledHoverIcon,
+ StyledHoverIconButton,
+ StyledLocationRowButton,
+ StyledLocationRowContainer,
+ StyledLocationRowIcon,
+ StyledLocationRowLabel,
+} from './LocationRowStyles';
+import {
CitySpecification,
CountrySpecification,
getLocationChildren,
@@ -28,110 +33,6 @@ import {
RelaySpecification,
} from './select-location-types';
-interface IButtonColorProps {
- $backgroundColor: string;
- $backgroundColorHover: string;
-}
-
-const buttonColor = (props: IButtonColorProps) => {
- return {
- backgroundColor: props.$backgroundColor,
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColorHover,
- },
- };
-};
-
-export const StyledLocationRowContainer = styled(Cell.Container)({
- display: 'flex',
- padding: 0,
- background: 'none',
-});
-
-export const StyledLocationRowButton = styled(Cell.Row)<IButtonColorProps & { $level: number }>(
- buttonColor,
- (props) => {
- const paddingLeft = (props.$level + 1) * 16 + 2;
-
- return {
- display: 'flex',
- flex: 1,
- overflow: 'hidden',
- border: 'none',
- padding: `0 10px 0 ${paddingLeft}px`,
- margin: 0,
- };
- },
-);
-
-export const StyledLocationRowIcon = styled.button<IButtonColorProps>(buttonColor, {
- position: 'relative',
- alignSelf: 'stretch',
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
-
- '&&::before': {
- content: '""',
- position: 'absolute',
- margin: 'auto',
- top: 0,
- left: 0,
- bottom: 0,
- height: '50%',
- width: '1px',
- backgroundColor: colors.darkBlue,
- },
-});
-
-export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
- flex: 1,
- minWidth: 0,
- fontWeight: 400,
- lineHeight: '24px',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
-});
-
-const StyledHoverIconButton = styled.button<IButtonColorProps & { $isLast?: boolean }>(
- buttonColor,
- (props) => ({
- flex: 0,
- display: 'none',
- padding: '0 10px',
- paddingRight: props.$isLast ? '17px' : '10px',
- margin: 0,
- border: 0,
- height: measurements.rowMinHeight,
- appearance: 'none',
-
- '&&:last-child': {
- paddingRight: '25px',
- },
-
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColor,
- },
- [`${StyledLocationRowContainer}:hover &&`]: {
- display: 'block',
- },
- [`${StyledLocationRowButton}:hover ~ &&`]: {
- backgroundColor: props.$backgroundColorHover,
- },
- }),
-);
-
-const StyledHoverIcon = styled(ImageView).attrs({
- width: 18,
- height: 18,
- tintColor: colors.white60,
- tintHoverColor: colors.white,
-})({
- [`${StyledHoverIconButton}:hover &&`]: {
- backgroundColor: colors.white,
- },
-});
-
interface IProps<C extends LocationSpecification> {
source: C;
level: number;
@@ -336,24 +237,6 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
// a lot more work than necessary
export default React.memo(LocationRow, compareProps);
-export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
- let backgroundColor = colors.blue60;
- if (selected) {
- backgroundColor = colors.green;
- } else if (level === 1) {
- backgroundColor = colors.blue40;
- } else if (level === 2) {
- backgroundColor = colors.blue20;
- } else if (level === 3) {
- backgroundColor = colors.blue10;
- }
-
- return {
- $backgroundColor: backgroundColor,
- $backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
- };
-}
-
function compareProps<C extends LocationSpecification>(
oldProps: IProps<C>,
nextProps: IProps<C>,
diff --git a/gui/src/renderer/components/select-location/LocationRowStyles.tsx b/gui/src/renderer/components/select-location/LocationRowStyles.tsx
new file mode 100644
index 0000000000..6a159f4054
--- /dev/null
+++ b/gui/src/renderer/components/select-location/LocationRowStyles.tsx
@@ -0,0 +1,150 @@
+import styled from 'styled-components';
+import { Styles } from 'styled-components/dist/types';
+
+import { colors } from '../../../config.json';
+import * as Cell from '../cell';
+import { measurements, normalText } from '../common-styles';
+import ImageView from '../ImageView';
+import InfoButton from '../InfoButton';
+
+interface ButtonColorProps {
+ $backgroundColor: string;
+ $backgroundColorHover: string;
+}
+
+export const buttonColor = (props: ButtonColorProps) => {
+ return {
+ backgroundColor: props.$backgroundColor,
+ '&&:not(:disabled):hover': {
+ backgroundColor: props.$backgroundColorHover,
+ },
+ };
+};
+
+export const StyledLocationRowContainer = styled(Cell.Container)({
+ display: 'flex',
+ padding: 0,
+ background: 'none',
+});
+
+export const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
+ marginBottom: 1,
+});
+
+export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
+ flex: 1,
+ minWidth: 0,
+ fontWeight: 400,
+ lineHeight: '24px',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+});
+
+export const StyledLocationRowButton = styled(Cell.Row)<ButtonColorProps & { $level: number }>(
+ buttonColor,
+ (props) => {
+ const paddingLeft = (props.$level + 1) * 16 + 2;
+
+ return {
+ display: 'flex',
+ flex: 1,
+ overflow: 'hidden',
+ border: 'none',
+ padding: `0 10px 0 ${paddingLeft}px`,
+ margin: 0,
+ };
+ },
+);
+
+export const StyledLocationRowIcon = styled.button<ButtonColorProps>(buttonColor, {
+ position: 'relative',
+ alignSelf: 'stretch',
+ paddingLeft: measurements.viewMargin,
+ paddingRight: measurements.viewMargin,
+ border: 0,
+
+ '&&::before': {
+ content: '""',
+ position: 'absolute',
+ margin: 'auto',
+ top: 0,
+ left: 0,
+ bottom: 0,
+ height: '50%',
+ width: '1px',
+ backgroundColor: colors.darkBlue,
+ },
+});
+
+interface HoverButtonProps {
+ $isLast?: boolean;
+}
+
+const hoverButton = (
+ props: ButtonColorProps & HoverButtonProps,
+): Styles<
+ React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+> => ({
+ flex: 0,
+ display: 'none',
+ padding: '0 10px',
+ paddingRight: props.$isLast ? '17px' : '10px',
+ margin: 0,
+ border: 0,
+ height: measurements.rowMinHeight,
+ appearance: 'none',
+
+ '&&:last-child': {
+ paddingRight: '25px',
+ },
+
+ '&&:not(:disabled):hover': {
+ backgroundColor: props.$backgroundColor,
+ },
+ [`${StyledLocationRowContainer}:hover &&`]: {
+ display: 'block',
+ },
+ [`${StyledLocationRowButton}:hover ~ &&`]: {
+ backgroundColor: props.$backgroundColorHover,
+ },
+});
+
+export const StyledHoverIconButton = styled.button<ButtonColorProps & HoverButtonProps>(
+ buttonColor,
+ hoverButton,
+);
+
+export const StyledHoverIcon = styled(ImageView).attrs({
+ width: 18,
+ height: 18,
+ tintColor: colors.white60,
+ tintHoverColor: colors.white,
+})({
+ [`${StyledHoverIconButton}:hover &&`]: {
+ backgroundColor: colors.white,
+ },
+});
+
+export const StyledHoverInfoButton = styled(InfoButton)<ButtonColorProps & HoverButtonProps>(
+ buttonColor,
+ hoverButton,
+);
+
+export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
+ let backgroundColor = colors.blue60;
+ if (selected) {
+ backgroundColor = colors.green;
+ } else if (level === 1) {
+ backgroundColor = colors.blue40;
+ } else if (level === 2) {
+ backgroundColor = colors.blue20;
+ } else if (level === 3) {
+ backgroundColor = colors.blue10;
+ }
+
+ return {
+ $backgroundColor: backgroundColor,
+ $backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
+ };
+}
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
index d0ee5d6e85..86dd7e5467 100644
--- a/gui/src/renderer/components/select-location/RelayListContext.tsx
+++ b/gui/src/renderer/components/select-location/RelayListContext.tsx
@@ -135,6 +135,15 @@ function useRelayList(
const selectedLocation = useSelectedLocation();
const disabledLocation = useDisabledLocation();
+ const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
+
+ const isLocationSelected = useCallback(
+ (location: RelayLocation) => {
+ return preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation);
+ },
+ [preventDueToCustomBridgeSelected, selectedLocation],
+ );
+
return useMemo(() => {
return relayList
.map((country) => {
@@ -149,7 +158,7 @@ function useRelayList(
disabled: countryDisabledReason !== undefined,
disabledReason: countryDisabledReason,
expanded: isExpanded(countryLocation, expandedLocations),
- selected: isSelected(countryLocation, selectedLocation),
+ selected: isLocationSelected(countryLocation),
cities: country.cities
.map((city) => {
const cityLocation: RelayLocation = { country: country.code, city: city.code };
@@ -164,7 +173,7 @@ function useRelayList(
disabled: cityDisabledReason !== undefined,
disabledReason: cityDisabledReason,
expanded: isExpanded(cityLocation, expandedLocations),
- selected: isSelected(cityLocation, selectedLocation),
+ selected: isLocationSelected(cityLocation),
relays: city.relays
.map((relay) => {
const relayLocation: RelayLocation = {
@@ -183,7 +192,7 @@ function useRelayList(
location: relayLocation,
disabled: relayDisabledReason !== undefined,
disabledReason: relayDisabledReason,
- selected: isSelected(relayLocation, selectedLocation),
+ selected: isLocationSelected(relayLocation),
};
})
.sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })),
@@ -193,7 +202,17 @@ function useRelayList(
};
})
.sort((a, b) => a.label.localeCompare(b.label, locale));
- }, [locale, expandedLocations, relayList, selectedLocation, disabledLocation]);
+ }, [locale, expandedLocations, relayList, disabledLocation, isLocationSelected]);
+}
+
+export function usePreventDueToCustomBridgeSelected(): boolean {
+ const relaySettings = useNormalRelaySettings();
+ const { locationType } = useSelectLocationContext();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const isBridgeSelection =
+ relaySettings?.tunnelProtocol === 'openvpn' && locationType === LocationType.entry;
+
+ return isBridgeSelection && bridgeSettings.type === 'custom';
}
// Return all RelayLocations that should be expanded
diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index 6bf0fcff6f..5ba0203939 100644
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -9,7 +9,7 @@ import { filterSpecialLocations } from '../../lib/filter-locations';
import { useHistory } from '../../lib/history';
import { formatHtml } from '../../lib/html-formatter';
import { RoutePath } from '../../lib/routes';
-import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { useNormalRelaySettings } from '../../lib/utilityHooks';
import { useSelector } from '../../redux/store';
import * as Cell from '../cell';
import { useFilteredProviders } from '../Filter';
@@ -34,12 +34,7 @@ import {
useOnSelectEntryLocation,
useOnSelectExitLocation,
} from './select-location-hooks';
-import {
- LocationType,
- SpecialBridgeLocationType,
- SpecialLocation,
- SpecialLocationIcon,
-} from './select-location-types';
+import { LocationType, SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
import {
StyledClearFilterButton,
@@ -54,6 +49,11 @@ import {
StyledSearchBar,
} from './SelectLocationStyles';
import { SpacePreAllocationView } from './SpacePreAllocationView';
+import {
+ AutomaticLocationRow,
+ CustomBridgeLocationRow,
+ CustomExitLocationRow,
+} from './SpecialLocationList';
export default function SelectLocation() {
const history = useHistory();
@@ -260,22 +260,21 @@ function SelectLocationContent() {
const [onSelectBridgeRelay, onSelectBridgeSpecial] = useOnSelectBridgeLocation();
const relaySettings = useNormalRelaySettings();
- const bridgeSettings = useNormalBridgeSettings();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
const allowAddToCustomList = useSelector((state) => state.settings.customLists.length > 0);
if (locationType === LocationType.exit) {
// Add "Custom" item if a custom relay is selected
- const specialList: Array<SpecialLocation<undefined>> =
- relaySettings === undefined
- ? [
- {
- label: messages.gettext('Custom'),
- value: undefined,
- selected: true,
- },
- ]
- : [];
+ const specialList: Array<SpecialLocation<undefined>> = [];
+ if (relaySettings === undefined) {
+ specialList.push({
+ label: messages.gettext('Custom'),
+ value: undefined,
+ selected: true,
+ component: CustomExitLocationRow,
+ });
+ }
const specialLocations = filterSpecialLocations(searchTerm, specialList);
return (
@@ -320,14 +319,17 @@ function SelectLocationContent() {
// Add the "Automatic" item
const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [
{
+ label: messages.pgettext('select-location-view', 'Custom bridge'),
+ value: SpecialBridgeLocationType.custom,
+ selected: bridgeSettings?.type === 'custom',
+ disabled: bridgeSettings?.custom === undefined,
+ component: CustomBridgeLocationRow,
+ },
+ {
label: messages.gettext('Automatic'),
- icon: SpecialLocationIcon.geoLocation,
- info: messages.pgettext(
- 'select-location-view',
- 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
- ),
value: SpecialBridgeLocationType.closestToExit,
- selected: bridgeSettings?.location === 'any',
+ selected: bridgeSettings?.type === 'normal' && bridgeSettings.normal?.location === 'any',
+ component: AutomaticLocationRow,
},
];
diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
index 5843d5e6f4..1d0e5251f7 100644
--- a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
@@ -1,5 +1,8 @@
import React, { useContext, useMemo, useState } from 'react';
+import useActions from '../../lib/actionsHook';
+import { useSelector } from '../../redux/store';
+import userInterface from '../../redux/userinterface/actions';
import { RelayListContextProvider } from './RelayListContext';
import { ScrollPositionContextProvider } from './ScrollPositionContext';
import { LocationType } from './select-location-types';
@@ -20,13 +23,14 @@ export function useSelectLocationContext() {
}
export default function SelectLocationContainer() {
- const [locationType, setLocationType] = useState(LocationType.exit);
+ const locationType = useSelector((state) => state.userInterface.selectLocationView);
+ const { setSelectLocationView } = useActions(userInterface);
const [searchTerm, setSearchTerm] = useState('');
- const value = useMemo(() => ({ locationType, setLocationType, searchTerm, setSearchTerm }), [
- locationType,
- searchTerm,
- ]);
+ const value = useMemo(
+ () => ({ locationType, setLocationType: setSelectLocationView, searchTerm, setSearchTerm }),
+ [locationType, searchTerm],
+ );
return (
<selectLocationContext.Provider value={value}>
diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
index 030520fc73..9579a6b4b5 100644
--- a/gui/src/renderer/components/select-location/SpecialLocationList.tsx
+++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -3,16 +3,21 @@ import styled from 'styled-components';
import { colors } from '../../../config.json';
import { messages } from '../../../shared/gettext';
-import * as Cell from '../cell';
+import { useHistory } from '../../lib/history';
+import { RoutePath } from '../../lib/routes';
+import { useSelector } from '../../redux/store';
+import ImageView from '../ImageView';
import InfoButton from '../InfoButton';
+import { SpecialLocationIndicator } from '../RelayStatusIndicator';
import {
getButtonColor,
+ StyledHoverInfoButton,
StyledLocationRowButton,
- StyledLocationRowContainer,
+ StyledLocationRowContainerWithMargin,
StyledLocationRowIcon,
StyledLocationRowLabel,
-} from './LocationRow';
-import { SpecialLocation } from './select-location-types';
+} from './LocationRowStyles';
+import { SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
interface SpecialLocationsProps<T> {
source: Array<SpecialLocation<T>>;
@@ -30,21 +35,8 @@ export default function SpecialLocationList<T>({ source, ...props }: SpecialLoca
);
}
-const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
- marginBottom: 1,
-});
-
-const StyledSpecialLocationIcon = styled(Cell.Icon)({
- flex: 0,
- marginLeft: '2px',
- marginRight: '8px',
-});
-
-const StyledSpecialLocationInfoButton = styled(InfoButton)({
- margin: 0,
- padding: '0 25px',
- backgroundColor: colors.blue,
-});
+const StyledSpecialLocationInfoButton = styled(InfoButton)({ padding: '0 25px', margin: 0 });
+const StyledSpecialLocationSideButton = styled(ImageView)({ padding: '0 3px' });
interface SpecialLocationRowProps<T> {
source: SpecialLocation<T>;
@@ -59,30 +51,98 @@ function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) {
}
}, [props.source.selected, props.onSelect, props.source.value]);
- const icon = props.source.selected ? 'icon-tick' : props.source.icon ?? undefined;
+ const innerProps: SpecialLocationRowInnerProps<T> = {
+ ...props,
+ onSelect,
+ };
+ return <props.source.component {...innerProps} />;
+}
+
+export interface SpecialLocationRowInnerProps<T>
+ extends Omit<SpecialLocationRowProps<T>, 'onSelect'> {
+ onSelect: () => void;
+}
+
+export function AutomaticLocationRow(
+ props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
+) {
const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
const background = getButtonColor(props.source.selected, 0, props.source.disabled);
return (
<StyledLocationRowContainerWithMargin ref={selectedRef}>
- <StyledLocationRowButton onClick={onSelect} $level={0} {...background}>
- {icon && (
- <StyledSpecialLocationIcon
- source={icon}
- tintColor={colors.white}
- height={22}
- width={22}
- />
+ <StyledLocationRowButton onClick={props.onSelect} $level={0} {...background}>
+ <SpecialLocationIndicator />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ <StyledLocationRowIcon
+ as={StyledSpecialLocationInfoButton}
+ title={messages.gettext('Automatic')}
+ message={messages.pgettext(
+ 'select-location-view',
+ 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
)}
+ aria-label={messages.pgettext('accessibility', 'info')}
+ {...background}
+ />
+ </StyledLocationRowContainerWithMargin>
+ );
+}
+
+export function CustomExitLocationRow(props: SpecialLocationRowInnerProps<undefined>) {
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ const background = getButtonColor(props.source.selected, 0, props.source.disabled);
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef}>
+ <StyledLocationRowButton $level={0} {...background}>
<StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
</StyledLocationRowButton>
- {props.source.info && (
- <StyledLocationRowIcon
- as={StyledSpecialLocationInfoButton}
- message={props.source.info}
- aria-label={messages.pgettext('accessibility', 'info')}
- {...background}
+ </StyledLocationRowContainerWithMargin>
+ );
+}
+
+const StyledInfoButton = styled(StyledHoverInfoButton)({ display: 'block' });
+
+export function CustomBridgeLocationRow(
+ props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
+) {
+ const history = useHistory();
+
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const icon = bridgeSettings.custom !== undefined ? 'icon-edit' : 'icon-add';
+
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ const background = getButtonColor(props.source.selected, 0, props.source.disabled);
+
+ const navigate = useCallback(() => history.push(RoutePath.editCustomBridge), [history.push]);
+
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef} disabled={props.source.disabled}>
+ <StyledLocationRowButton
+ as="button"
+ onClick={props.onSelect}
+ $level={0}
+ disabled={props.source.disabled}
+ {...background}>
+ <SpecialLocationIndicator />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ <StyledInfoButton
+ {...background}
+ $isLast
+ title={messages.pgettext('select-location-view', 'Custom bridge')}
+ message={messages.pgettext(
+ 'select-location-view',
+ 'A custom bridge server can be used to circumvent censorship when regular Mullvad bridge servers don’t work.',
+ )}
+ />
+ <StyledLocationRowIcon {...background} onClick={navigate}>
+ <StyledSpecialLocationSideButton
+ source={icon}
+ width={18}
+ tintColor={colors.white}
+ tintHoverColor={colors.white80}
/>
- )}
+ </StyledLocationRowIcon>
</StyledLocationRowContainerWithMargin>
);
}
diff --git a/gui/src/renderer/components/select-location/custom-list-helpers.ts b/gui/src/renderer/components/select-location/custom-list-helpers.ts
index 799deb8ed3..5cb6d695b8 100644
--- a/gui/src/renderer/components/select-location/custom-list-helpers.ts
+++ b/gui/src/renderer/components/select-location/custom-list-helpers.ts
@@ -4,7 +4,11 @@ import { ICustomList, RelayLocation } from '../../../shared/daemon-rpc-types';
import { hasValue } from '../../../shared/utils';
import { searchMatch } from '../../lib/filter-locations';
import { useSelector } from '../../redux/store';
-import { useDisabledLocation, useSelectedLocation } from './RelayListContext';
+import {
+ useDisabledLocation,
+ usePreventDueToCustomBridgeSelected,
+ useSelectedLocation,
+} from './RelayListContext';
import { isCustomListDisabled, isExpanded, isSelected } from './select-location-helpers';
import {
CitySpecification,
@@ -26,6 +30,8 @@ export function useCustomListsRelayList(
const { searchTerm } = useSelectLocationContext();
const customLists = useSelector((state) => state.settings.customLists);
+ const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
+
// Populate all custom lists with the real location trees for the list locations.
return useMemo(
() =>
@@ -34,6 +40,7 @@ export function useCustomListsRelayList(
list,
relayList,
searchTerm,
+ preventDueToCustomBridgeSelected,
selectedLocation,
disabledLocation,
expandedLocations,
@@ -48,6 +55,7 @@ function prepareCustomList(
list: ICustomList,
fullRelayList: GeographicalRelayList,
searchTerm: string,
+ preventDueToCustomBridgeSelected: boolean,
selectedLocation?: RelayLocation,
disabledLocation?: { location: RelayLocation; reason: DisabledReason },
expandedLocations?: Array<RelayLocation>,
@@ -64,7 +72,7 @@ function prepareCustomList(
disabled: disabledReason !== undefined,
disabledReason,
expanded: isExpanded(location, expandedLocations),
- selected: isSelected(location, selectedLocation),
+ selected: preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation),
visible: searchMatch(searchTerm, list.name),
locations,
};
diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts
index b795f4a74f..08ed2dccf3 100644
--- a/gui/src/renderer/components/select-location/select-location-hooks.ts
+++ b/gui/src/renderer/components/select-location/select-location-hooks.ts
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
-import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder';
import {
BridgeSettings,
RelayLocation,
@@ -10,8 +9,8 @@ import {
import log from '../../../shared/logging';
import { useAppContext } from '../../context';
import { useRelaySettingsModifier } from '../../lib/constraint-updater';
+import { useBridgeSettingsModifier } from '../../lib/constraint-updater';
import { useHistory } from '../../lib/history';
-import { useSelector } from '../../redux/store';
import { LocationType, SpecialBridgeLocationType } from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
@@ -89,7 +88,7 @@ function useOnSelectLocation() {
export function useOnSelectBridgeLocation() {
const { updateBridgeSettings } = useAppContext();
const { setLocationType } = useSelectLocationContext();
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const bridgeSettingsModifier = useBridgeSettingsModifier();
const setLocation = useCallback(async (bridgeUpdate: BridgeSettings) => {
if (bridgeUpdate) {
@@ -105,21 +104,38 @@ export function useOnSelectBridgeLocation() {
const onSelectRelay = useCallback(
(location: RelayLocation) => {
- const bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location).build();
- bridgeUpdate.custom = bridgeSettings.custom;
- return setLocation(bridgeUpdate);
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.type = 'normal';
+ bridgeSettings.normal.location = wrapConstraint(location);
+ return bridgeSettings;
+ }),
+ );
},
- [bridgeSettings],
+ [bridgeSettingsModifier],
);
- const onSelectSpecial = useCallback((location: SpecialBridgeLocationType) => {
- switch (location) {
- case SpecialBridgeLocationType.closestToExit: {
- const bridgeUpdate = new BridgeSettingsBuilder().location.any().build();
- return setLocation(bridgeUpdate);
+ const onSelectSpecial = useCallback(
+ (location: SpecialBridgeLocationType) => {
+ switch (location) {
+ case SpecialBridgeLocationType.closestToExit:
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.normal.location = 'any';
+ return bridgeSettings;
+ }),
+ );
+ case SpecialBridgeLocationType.custom:
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.type = 'custom';
+ return bridgeSettings;
+ }),
+ );
}
- }
- }, []);
+ },
+ [bridgeSettingsModifier],
+ );
return [onSelectRelay, onSelectSpecial] as const;
}
diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts
index c42cc45f6b..ca6afecf74 100644
--- a/gui/src/renderer/components/select-location/select-location-types.ts
+++ b/gui/src/renderer/components/select-location/select-location-types.ts
@@ -11,6 +11,7 @@ import {
IRelayLocationCountryRedux,
IRelayLocationRelayRedux,
} from '../../redux/settings/reducers';
+import { SpecialLocationRowInnerProps } from './SpecialLocationList';
export enum LocationType {
entry = 0,
@@ -21,11 +22,8 @@ export type RelayList = GeographicalRelayList | Array<CustomListSpecification>;
export type GeographicalRelayList = Array<CountrySpecification>;
export enum SpecialBridgeLocationType {
- closestToExit = 0,
-}
-
-export enum SpecialLocationIcon {
- geoLocation = 'icon-nearest',
+ closestToExit,
+ custom,
}
export interface LocationVisibility {
@@ -40,9 +38,8 @@ interface CommonLocationSpecification {
}
export interface SpecialLocation<T> extends CommonLocationSpecification {
- icon?: SpecialLocationIcon;
- info?: string;
value: T;
+ component: React.ComponentType<SpecialLocationRowInnerProps<T>>;
}
type GeographicalLocationSpecification =
diff --git a/gui/src/renderer/lib/constraint-updater.ts b/gui/src/renderer/lib/constraint-updater.ts
index 054f063465..73fbdd1336 100644
--- a/gui/src/renderer/lib/constraint-updater.ts
+++ b/gui/src/renderer/lib/constraint-updater.ts
@@ -1,6 +1,8 @@
import { useCallback } from 'react';
import {
+ BridgeSettings,
+ IBridgeConstraints,
IOpenVpnConstraints,
IRelaySettingsNormal,
IWireguardConstraints,
@@ -8,7 +10,12 @@ import {
wrapConstraint,
} from '../../shared/daemon-rpc-types';
import { useAppContext } from '../context';
-import { NormalRelaySettingsRedux } from '../redux/settings/reducers';
+import {
+ BridgeSettingsRedux,
+ NormalBridgeSettingsRedux,
+ NormalRelaySettingsRedux,
+} from '../redux/settings/reducers';
+import { useSelector } from '../redux/store';
import { useNormalRelaySettings } from './utilityHooks';
export function wrapRelaySettingsOrDefault(
@@ -59,7 +66,7 @@ export function wrapRelaySettingsOrDefault(
};
}
-type UpdateFunction = (
+type RelaySettingsUpdateFunction = (
settings: IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>,
) => IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>;
@@ -67,7 +74,7 @@ export function useRelaySettingsModifier() {
const relaySettings = useNormalRelaySettings();
return useCallback(
- (fn: UpdateFunction) => {
+ (fn: RelaySettingsUpdateFunction) => {
const settings = wrapRelaySettingsOrDefault(relaySettings);
return fn(settings);
},
@@ -80,10 +87,72 @@ export function useRelaySettingsUpdater() {
const modifyRelaySettings = useRelaySettingsModifier();
return useCallback(
- async (fn: UpdateFunction) => {
+ async (fn: RelaySettingsUpdateFunction) => {
const modifiedSettings = modifyRelaySettings(fn);
await setRelaySettings({ normal: modifiedSettings });
},
[setRelaySettings, modifyRelaySettings],
);
}
+
+export function wrapBridgeSettingsOrDefault(bridgeSettings?: BridgeSettingsRedux): BridgeSettings {
+ if (bridgeSettings) {
+ return {
+ type: bridgeSettings.type,
+ normal: wrapNormalBridgeSettingsOrDefault(bridgeSettings.normal),
+ custom: bridgeSettings.custom,
+ };
+ }
+
+ return {
+ type: 'normal',
+ normal: wrapNormalBridgeSettingsOrDefault(),
+ };
+}
+
+function wrapNormalBridgeSettingsOrDefault(
+ bridgeSettings?: NormalBridgeSettingsRedux,
+): IBridgeConstraints {
+ if (bridgeSettings) {
+ const location = wrapConstraint(bridgeSettings.location);
+
+ return {
+ location,
+ providers: [...bridgeSettings.providers],
+ ownership: bridgeSettings.ownership,
+ };
+ }
+
+ return {
+ location: 'any',
+ providers: [],
+ ownership: Ownership.any,
+ };
+}
+
+type BridgeSettingsUpdateFunction = (settings: BridgeSettings) => BridgeSettings;
+
+export function useBridgeSettingsModifier() {
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+
+ return useCallback(
+ (fn: BridgeSettingsUpdateFunction) => {
+ const settings = wrapBridgeSettingsOrDefault(bridgeSettings);
+ return fn(settings);
+ },
+ [bridgeSettings],
+ );
+}
+
+export function useBridgeSettingsUpdater() {
+ const { updateBridgeSettings } = useAppContext();
+ const modifyBridgeSettings = useBridgeSettingsModifier();
+
+ return useCallback(
+ async (fn: BridgeSettingsUpdateFunction) => {
+ const modifiedSettings = modifyBridgeSettings(fn);
+ await updateBridgeSettings(modifiedSettings);
+ },
+ [updateBridgeSettings, modifyBridgeSettings],
+ );
+}
diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts
index 499157976a..1f2a3c28a7 100644
--- a/gui/src/renderer/lib/routes.ts
+++ b/gui/src/renderer/lib/routes.ts
@@ -25,5 +25,6 @@ export enum RoutePath {
problemReport = '/settings/support/problem-report',
debug = '/settings/debug',
selectLocation = '/select-location',
+ editCustomBridge = '/select-location/edit-custom-bridge',
filter = '/select-location/filter',
}
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index 07502ab280..3c502ec103 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -6,6 +6,7 @@ import {
BridgeState,
BridgeType,
CustomLists,
+ CustomProxy,
IDnsOptions,
IpVersion,
IWireguardEndpointData,
@@ -13,7 +14,6 @@ import {
ObfuscationSettings,
ObfuscationType,
Ownership,
- ProxySettings,
RelayEndpointType,
RelayLocation,
RelayOverride,
@@ -42,6 +42,10 @@ export type NormalRelaySettingsRedux = {
export type NormalBridgeSettingsRedux = {
location: LiftedConstraint<RelayLocation>;
+ /** Providers are used to filter bridges and as bridge constraints for the daemon. */
+ providers: string[];
+ /** Ownership is used to filter bridges and as bridge constraints for the daemon. */
+ ownership: Ownership;
};
export type RelaySettingsRedux =
@@ -59,7 +63,7 @@ export type RelaySettingsRedux =
export type BridgeSettingsRedux = {
type: BridgeType;
normal: NormalBridgeSettingsRedux;
- custom?: ProxySettings;
+ custom?: CustomProxy;
};
export interface IRelayLocationRelayRedux {
@@ -150,6 +154,8 @@ const initialState: ISettingsReduxState = {
type: 'normal',
normal: {
location: 'any',
+ providers: [],
+ ownership: Ownership.any,
},
custom: undefined,
},
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
index b4b5885370..cc43990980 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -1,5 +1,6 @@
import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
import { IChangelog } from '../../../shared/ipc-types';
+import { LocationType } from '../../components/select-location/select-location-types';
export interface IUpdateLocaleAction {
type: 'UPDATE_LOCALE';
@@ -50,6 +51,11 @@ export interface ISetIsPerformingPostUpgrade {
isPerformingPostUpgrade: boolean;
}
+export interface ISetSelectLocationView {
+ type: 'SET_SELECT_LOCATION_VIEW';
+ selectLocationView: LocationType;
+}
+
export type UserInterfaceAction =
| IUpdateLocaleAction
| IUpdateWindowArrowPositionAction
@@ -60,7 +66,8 @@ export type UserInterfaceAction =
| ISetDaemonAllowed
| ISetChangelog
| ISetForceShowChanges
- | ISetIsPerformingPostUpgrade;
+ | ISetIsPerformingPostUpgrade
+ | ISetSelectLocationView;
function updateLocale(locale: string): IUpdateLocaleAction {
return {
@@ -133,6 +140,13 @@ function setIsPerformingPostUpgrade(isPerformingPostUpgrade: boolean): ISetIsPer
};
}
+function setSelectLocationView(selectLocationView: LocationType): ISetSelectLocationView {
+ return {
+ type: 'SET_SELECT_LOCATION_VIEW',
+ selectLocationView,
+ };
+}
+
export default {
updateLocale,
updateWindowArrowPosition,
@@ -144,4 +158,5 @@ export default {
setChangelog,
setForceShowChanges,
setIsPerformingPostUpgrade,
+ setSelectLocationView,
};
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index f9ecc6fdad..622b7814aa 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -1,5 +1,6 @@
import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
import { IChangelog } from '../../../shared/ipc-types';
+import { LocationType } from '../../components/select-location/select-location-types';
import { ReduxAction } from '../store';
export interface IUserInterfaceReduxState {
@@ -13,6 +14,7 @@ export interface IUserInterfaceReduxState {
changelog: IChangelog;
forceShowChanges: boolean;
isPerformingPostUpgrade: boolean;
+ selectLocationView: LocationType;
}
const initialState: IUserInterfaceReduxState = {
@@ -25,6 +27,7 @@ const initialState: IUserInterfaceReduxState = {
changelog: [],
forceShowChanges: false,
isPerformingPostUpgrade: false,
+ selectLocationView: LocationType.exit,
};
export default function (
@@ -71,6 +74,12 @@ export default function (
isPerformingPostUpgrade: action.isPerformingPostUpgrade,
};
+ case 'SET_SELECT_LOCATION_VIEW':
+ return {
+ ...state,
+ selectLocationView: action.selectLocationView,
+ };
+
default:
return state;
}
diff --git a/gui/src/shared/bridge-settings-builder.ts b/gui/src/shared/bridge-settings-builder.ts
deleted file mode 100644
index 2ee5469707..0000000000
--- a/gui/src/shared/bridge-settings-builder.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { BridgeSettings, IBridgeConstraints, Ownership } from './daemon-rpc-types';
-import makeLocationBuilder, { ILocationBuilder } from './relay-location-builder';
-
-export default class BridgeSettingsBuilder {
- private payload: Partial<IBridgeConstraints> = {};
-
- public build(): BridgeSettings {
- if (this.payload.location) {
- return {
- type: 'normal',
- normal: {
- location: this.payload.location,
- providers: this.payload.providers ?? [],
- ownership: this.payload.ownership ?? Ownership.any,
- },
- custom: undefined,
- };
- } else {
- throw new Error('Unsupported configuration');
- }
- }
-
- get location(): ILocationBuilder<BridgeSettingsBuilder> {
- return makeLocationBuilder(this, (location) => {
- this.payload.location = location;
- });
- }
-}
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index e24c124f4c..738eef5e95 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -128,16 +128,6 @@ export function wrapConstraint<T>(
}
export type ProxyType = 'shadowsocks' | 'custom';
-export function proxyTypeToString(proxy: ProxyType): string {
- switch (proxy) {
- case 'shadowsocks':
- return 'Shadowsocks bridge';
- case 'custom':
- return 'custom bridge';
- default:
- return '';
- }
-}
export enum Ownership {
any,
@@ -352,35 +342,6 @@ export interface IDnsOptions {
};
}
-export type ProxySettings =
- | { local: ILocalProxySettings }
- | { remote: IRemoteProxySettings }
- | { shadowsocks: IShadowsocksProxySettings };
-
-export interface ILocalProxySettings {
- localPort: number;
- remoteIp: string;
- remotePort: number;
-}
-
-export interface IRemoteProxySettings {
- ip: string;
- port: number;
- auth?: IRemoteProxyAuth;
-}
-
-export interface IRemoteProxyAuth {
- username: string;
- password: string;
-}
-
-export interface IShadowsocksProxySettings {
- ip: string;
- port: number;
- password: string;
- cipher: string;
-}
-
export interface IAppVersionInfo {
supported: boolean;
suggestedUpgrade?: string;
@@ -471,7 +432,7 @@ export type BridgeType = 'normal' | 'custom';
export interface BridgeSettings {
type: BridgeType;
normal: IBridgeConstraints;
- custom?: ProxySettings;
+ custom?: CustomProxy;
}
export interface ISocketAddress {
@@ -488,7 +449,7 @@ export interface SocksAuth {
password: string;
}
-export type Socks5LocalAccessMethod = {
+export type Socks5LocalCustomProxy = {
type: 'socks5-local';
remoteIp: string;
remotePort: number;
@@ -496,14 +457,14 @@ export type Socks5LocalAccessMethod = {
localPort: number;
};
-export type Socks5RemoteAccessMethod = {
+export type Socks5RemoteCustomProxy = {
type: 'socks5-remote';
ip: string;
port: number;
authentication?: SocksAuth;
};
-export type ShadowsocksAccessMethod = {
+export type ShadowsocksCustomProxy = {
type: 'shadowsocks';
ip: string;
port: number;
@@ -511,33 +472,29 @@ export type ShadowsocksAccessMethod = {
cipher: string;
};
-export type CustomProxy =
- | Socks5LocalAccessMethod
- | Socks5RemoteAccessMethod
- | ShadowsocksAccessMethod;
+export type CustomProxy = Socks5LocalCustomProxy | Socks5RemoteCustomProxy | ShadowsocksCustomProxy;
+export type NamedCustomProxy = CustomProxy & { name: string };
-export type AccessMethod =
- | {
- type: 'direct';
- }
- | {
- type: 'bridges';
- }
- | CustomProxy;
+export type DirectMethod = { type: 'direct' };
+export type BridgesMethod = { type: 'bridges' };
+export type AccessMethod = DirectMethod | BridgesMethod | CustomProxy;
-export type NewAccessMethodSetting = AccessMethod & {
- name: string;
+export type NamedAccessMethod<T extends AccessMethod> = T & { name: string };
+
+export type NewAccessMethodSetting<T extends AccessMethod = AccessMethod> = NamedAccessMethod<T> & {
enabled: boolean;
};
-export type AccessMethodSetting = NewAccessMethodSetting & {
+export type AccessMethodSetting<
+ T extends AccessMethod = AccessMethod
+> = NewAccessMethodSetting<T> & {
id: string;
};
export type ApiAccessMethodSettings = {
- direct: AccessMethodSetting;
- mullvadBridges: AccessMethodSetting;
- custom: Array<AccessMethodSetting>;
+ direct: AccessMethodSetting<DirectMethod>;
+ mullvadBridges: AccessMethodSetting<BridgesMethod>;
+ custom: Array<AccessMethodSetting<CustomProxy>>;
};
export interface RelayOverride {
diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts
index 71a663def0..f30212025c 100644
--- a/gui/src/shared/localization-contexts.ts
+++ b/gui/src/shared/localization-contexts.ts
@@ -15,6 +15,7 @@ export type LocalizationContexts =
| 'account-expiry'
| 'select-location-view'
| 'select-location-nav'
+ | 'custom-bridge'
| 'filter-view'
| 'filter-nav'
| 'settings-view'
diff --git a/gui/src/shared/relay-location-builder.ts b/gui/src/shared/relay-location-builder.ts
deleted file mode 100644
index 7e585f9eaf..0000000000
--- a/gui/src/shared/relay-location-builder.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Constraint, LiftedConstraint, RelayLocation } from './daemon-rpc-types';
-
-export 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: LiftedConstraint<RelayLocation>): Self;
-}
-
-export default function makeLocationBuilder<T>(
- context: T,
- receiver: (constraint: Constraint<RelayLocation>) => void,
-): ILocationBuilder<T> {
- return {
- country: (country: string) => {
- receiver({ only: { country } });
- return context;
- },
- city: (country: string, city: string) => {
- receiver({ only: { country, city } });
- return context;
- },
- hostname: (country: string, city: string, hostname: string) => {
- receiver({ only: { country, city, hostname } });
- return context;
- },
- any: () => {
- receiver('any');
- return context;
- },
- fromRaw(location: LiftedConstraint<RelayLocation>) {
- if (location === 'any') {
- return this.any();
- } else {
- receiver({ only: location });
- return context;
- }
- },
- };
-}