summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-01-03 14:23:02 +0100
committerOskar Nyberg <oskar@mullvad.net>2022-01-03 14:23:02 +0100
commit5e72bac67addd36682443568de651fc05cd6e207 (patch)
tree750ac3f0737f4580258c69540e491303ac619461 /gui/src
parentf4cd4fc51955874f7e7ac13636b63ecfa8f9eaf3 (diff)
parentc7027fac2b5db7a2a19f1f79137b1a2d371335cb (diff)
downloadmullvadvpn-5e72bac67addd36682443568de651fc05cd6e207.tar.xz
mullvadvpn-5e72bac67addd36682443568de651fc05cd6e207.zip
Merge branch 'add-wg-multihop'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/daemon-rpc.ts32
-rw-r--r--gui/src/main/index.ts27
-rw-r--r--gui/src/renderer/app.tsx33
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx2
-rw-r--r--gui/src/renderer/components/BridgeLocations.tsx2
-rw-r--r--gui/src/renderer/components/ConnectionPanel.tsx32
-rw-r--r--gui/src/renderer/components/ConnectionPanelDisclosure.tsx6
-rw-r--r--gui/src/renderer/components/LocationList.tsx240
-rw-r--r--gui/src/renderer/components/LocationRow.tsx6
-rw-r--r--gui/src/renderer/components/Locations.tsx (renamed from gui/src/renderer/components/ExitLocations.tsx)17
-rw-r--r--gui/src/renderer/components/OpenVPNSettings.tsx133
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx216
-rw-r--r--gui/src/renderer/components/WireguardSettings.tsx353
-rw-r--r--gui/src/renderer/containers/ConnectionPanelContainer.tsx23
-rw-r--r--gui/src/renderer/containers/OpenVPNSettingsPage.tsx14
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx57
-rw-r--r--gui/src/renderer/containers/WireguardSettingsPage.tsx82
-rw-r--r--gui/src/renderer/lib/constraint-updater.ts36
-rw-r--r--gui/src/renderer/redux/connection/reducers.ts3
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts6
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts15
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts10
-rw-r--r--gui/src/shared/daemon-rpc-types.ts9
-rw-r--r--gui/src/shared/relay-settings-builder.ts19
24 files changed, 1001 insertions, 372 deletions
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index a2416e9943..edfc3ec4cd 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -862,6 +862,9 @@ function convertFromTunnelStateRelayInfo(
tunnelType: convertFromTunnelType(state.tunnelEndpoint.tunnelType),
protocol: convertFromTransportProtocol(state.tunnelEndpoint.protocol),
proxy: state.tunnelEndpoint.proxy && convertFromProxyEndpoint(state.tunnelEndpoint.proxy),
+ entryEndpoint:
+ state.tunnelEndpoint.entryEndpoint &&
+ convertFromEntryEndpoint(state.tunnelEndpoint.entryEndpoint),
},
};
}
@@ -890,6 +893,13 @@ function convertFromProxyEndpoint(proxyEndpoint: grpcTypes.ProxyEndpoint.AsObjec
};
}
+function convertFromEntryEndpoint(entryEndpoint: grpcTypes.Endpoint.AsObject) {
+ return {
+ address: entryEndpoint.address,
+ transportProtocol: convertFromTransportProtocol(entryEndpoint.protocol),
+ };
+}
+
function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefined {
const settingsObject = settings.toObject();
const bridgeState = convertFromBridgeState(settingsObject.bridgeState!.state!);
@@ -1172,7 +1182,12 @@ function convertFromOpenVpnConstraints(
function convertFromWireguardConstraints(
constraints: grpcTypes.WireguardConstraints,
): IWireguardConstraints {
- const result: IWireguardConstraints = { port: 'any', ipVersion: 'any' };
+ const result: IWireguardConstraints = {
+ port: 'any',
+ ipVersion: 'any',
+ useMultihop: constraints.getUseMultihop(),
+ entryLocation: 'any',
+ };
const port = constraints.getPort()?.getPort();
if (port) {
@@ -1189,6 +1204,11 @@ function convertFromWireguardConstraints(
break;
}
+ const entryLocation = constraints.getEntryLocation();
+ if (entryLocation) {
+ result.entryLocation = { only: convertFromLocation(entryLocation.toObject()) };
+ }
+
return result;
}
@@ -1310,6 +1330,16 @@ function convertToWireguardConstraints(
wireguardConstraints.setIpVersion(ipVersionConstraints);
}
+ if (constraint.useMultihop) {
+ wireguardConstraints.setUseMultihop(constraint.useMultihop);
+ }
+
+ const entryLocation = liftConstraint(constraint.entryLocation);
+ if (entryLocation) {
+ const entryLocationConstraint = convertToLocation(entryLocation);
+ wireguardConstraints.setEntryLocation(entryLocationConstraint);
+ }
+
return wireguardConstraints;
}
return undefined;
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 5bce63cc2d..a2c49bcacc 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -158,6 +158,8 @@ class ApplicationMain {
wireguardConstraints: {
port: 'any',
ipVersion: 'any',
+ useMultihop: false,
+ entryLocation: 'any',
},
},
},
@@ -905,11 +907,7 @@ class ApplicationMain {
) {
this.relays = newRelayList;
- const filteredRelays = this.processRelaysForPresentation(
- newRelayList,
- relaySettings,
- bridgeState,
- );
+ const filteredRelays = this.processRelaysForPresentation(newRelayList, relaySettings);
const filteredBridges = this.processBridgesForPresentation(newRelayList, bridgeState);
if (this.windowController) {
@@ -923,7 +921,6 @@ class ApplicationMain {
private processRelaysForPresentation(
relayList: IRelayList,
relaySettings: RelaySettings,
- bridgeState: BridgeState,
): IRelayList {
const tunnelProtocol =
'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined;
@@ -943,14 +940,16 @@ class ApplicationMain {
case 'wireguard':
return relay.tunnels.wireguard.length > 0;
- case 'any':
- // TODO: Drop win32 check when Wireguard becomes default on Windows
- if (process.platform === 'win32' || bridgeState === 'on') {
- return relay.tunnels.openvpn.length > 0;
+ case 'any': {
+ const useMultihop =
+ 'normal' in relaySettings &&
+ relaySettings.normal.wireguardConstraints.useMultihop;
+ if (useMultihop) {
+ return relay.tunnels.wireguard.length > 0;
} else {
return relay.tunnels.openvpn.length > 0 || relay.tunnels.wireguard.length > 0;
}
-
+ }
default:
return false;
}
@@ -1151,11 +1150,7 @@ class ApplicationMain {
tunnelState: this.tunnelState,
settings: this.settings,
relayListPair: {
- relays: this.processRelaysForPresentation(
- this.relays,
- this.settings.relaySettings,
- this.settings.bridgeState,
- ),
+ relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings),
bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState),
},
currentVersion: this.currentVersion,
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 8b4116d647..0419bb0919 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -10,7 +10,7 @@ import { AppContext } from './context';
import accountActions from './redux/account/actions';
import connectionActions from './redux/connection/actions';
import settingsActions from './redux/settings/actions';
-import { IRelayLocationRedux, IWgKey } from './redux/settings/reducers';
+import { IWgKey } from './redux/settings/reducers';
import configureStore from './redux/store';
import userInterfaceActions from './redux/userinterface/actions';
import versionActions from './redux/version/actions';
@@ -33,7 +33,6 @@ import {
IAppVersionInfo,
IDnsOptions,
ILocation,
- IRelayList,
ISettings,
IWireguardPublicKey,
KeygenEvent,
@@ -88,7 +87,6 @@ export default class AppRenderer {
userInterface: bindActionCreators(userInterfaceActions, this.reduxStore.dispatch),
};
- private locale = 'en';
private location?: Partial<ILocation>;
private lastDisconnectedLocation?: Partial<ILocation>;
private relayListPair!: IRelayListPair;
@@ -597,7 +595,6 @@ export default class AppRenderer {
}
private setLocale(locale: string) {
- this.locale = locale;
this.reduxActions.userInterface.updateLocale(locale);
}
@@ -624,6 +621,8 @@ export default class AppRenderer {
wireguard: {
port: liftConstraint(wireguardConstraints.port),
ipVersion: liftConstraint(wireguardConstraints.ipVersion),
+ useMultihop: wireguardConstraints.useMultihop,
+ entryLocation: liftConstraint(wireguardConstraints.entryLocation),
},
tunnelProtocol: liftConstraint(tunnelProtocol),
},
@@ -835,36 +834,14 @@ export default class AppRenderer {
}
}
- private convertRelayListToLocationList(relayList: IRelayList): IRelayLocationRedux[] {
- return relayList.countries
- .map((country) => ({
- name: country.name,
- code: country.code,
- hasActiveRelays: country.cities.some((city) => city.relays.some((relay) => relay.active)),
- cities: country.cities
- .map((city) => ({
- name: city.name,
- code: city.code,
- latitude: city.latitude,
- longitude: city.longitude,
- hasActiveRelays: city.relays.some((relay) => relay.active),
- relays: city.relays.sort((relayA, relayB) =>
- relayA.hostname.localeCompare(relayB.hostname, this.locale, { numeric: true }),
- ),
- }))
- .sort((cityA, cityB) => cityA.name.localeCompare(cityB.name, this.locale)),
- }))
- .sort((countryA, countryB) => countryA.name.localeCompare(countryB.name, this.locale));
- }
-
private setRelayListPair(relayListPair: IRelayListPair) {
this.relayListPair = relayListPair;
this.propagateRelayListPairToRedux();
}
private propagateRelayListPairToRedux() {
- const relays = this.convertRelayListToLocationList(this.relayListPair.relays);
- const bridges = this.convertRelayListToLocationList(this.relayListPair.bridges);
+ const relays = this.relayListPair.relays.countries;
+ const bridges = this.relayListPair.bridges.countries;
this.reduxActions.settings.updateRelayLocations(relays);
this.reduxActions.settings.updateBridgeLocations(bridges);
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx
index f511265464..369f5169a2 100644
--- a/gui/src/renderer/components/AdvancedSettings.tsx
+++ b/gui/src/renderer/components/AdvancedSettings.tsx
@@ -236,7 +236,7 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
type={ModalAlertType.caution}
buttons={[
<AppButton.RedButton key="confirm" onClick={this.confirmEnableBlockWhenDisconnected}>
- {messages.pgettext('advanced-settings-view', 'Enable anyway')}
+ {messages.gettext('Enable anyway')}
</AppButton.RedButton>,
<AppButton.BlueButton key="back" onClick={this.hideConfirmBlockWhenDisconnectedAlert}>
{messages.gettext('Back')}
diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx
index 802797eb77..a62368e166 100644
--- a/gui/src/renderer/components/BridgeLocations.tsx
+++ b/gui/src/renderer/components/BridgeLocations.tsx
@@ -17,6 +17,7 @@ export enum SpecialBridgeLocationType {
interface IBridgeLocationsProps {
source: IRelayLocationRedux[];
+ locale: string;
defaultExpandedLocations?: RelayLocation[];
selectedValue?: LiftedConstraint<RelayLocation>;
selectedElementRef?: React.Ref<React.ReactInstance>;
@@ -53,6 +54,7 @@ const BridgeLocations = React.forwardRef(function BridgeLocationsT(
</SpecialLocations>
<RelayLocations
source={props.source}
+ locale={props.locale}
onWillExpand={props.onWillExpand}
onTransitionEnd={props.onTransitionEnd}
/>
diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx
index d0b0098d50..65a47ea1b3 100644
--- a/gui/src/renderer/components/ConnectionPanel.tsx
+++ b/gui/src/renderer/components/ConnectionPanel.tsx
@@ -11,6 +11,7 @@ import {
} from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { default as ConnectionPanelDisclosure } from '../components/ConnectionPanelDisclosure';
+import Marquee from './Marquee';
export interface IEndpoint {
ip: string;
@@ -35,7 +36,9 @@ interface IProps {
isOpen: boolean;
hostname?: string;
bridgeHostname?: string;
+ entryHostname?: string;
inAddress?: IInAddress;
+ entryLocationInAddress?: IInAddress;
bridgeInfo?: IBridgeData;
outAddress?: IOutAddress;
onToggle: () => void;
@@ -72,15 +75,15 @@ const Header = styled.div({
export default class ConnectionPanel extends React.Component<IProps> {
public render() {
- const { inAddress, outAddress, bridgeInfo } = this.props;
- const entryPoint = bridgeInfo && inAddress ? bridgeInfo : inAddress;
+ const { outAddress } = this.props;
+ const entryPoint = this.getEntryPoint();
return (
<div className={this.props.className}>
{this.props.hostname && (
<Header>
<ConnectionPanelDisclosure pointsUp={this.props.isOpen} onToggle={this.props.onToggle}>
- {this.hostnameLine()}
+ <Marquee>{this.hostnameLine()}</Marquee>
</ConnectionPanelDisclosure>
</Header>
)}
@@ -117,17 +120,34 @@ export default class ConnectionPanel extends React.Component<IProps> {
);
}
+ private getEntryPoint() {
+ const { inAddress, entryLocationInAddress, bridgeInfo } = this.props;
+
+ if (entryLocationInAddress && inAddress) {
+ return entryLocationInAddress;
+ } else if (bridgeInfo && inAddress) {
+ return bridgeInfo;
+ } else {
+ return inAddress;
+ }
+ }
+
private hostnameLine() {
if (this.props.hostname && this.props.bridgeHostname) {
+ return sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), {
+ relay: this.props.hostname,
+ entry: this.props.bridgeHostname,
+ });
+ } else if (this.props.hostname && this.props.entryHostname) {
return sprintf(
// TRANSLATORS: The hostname line displayed below the country on the main screen
// TRANSLATORS: Available placeholders:
// TRANSLATORS: %(relay)s - the relay hostname
- // TRANSLATORS: %(bridge)s - the bridge hostname
- messages.pgettext('connection-info', '%(relay)s via %(bridge)s'),
+ // TRANSLATORS: %(entry)s - the entry relay hostname
+ messages.pgettext('connection-info', '%(relay)s via %(entry)s'),
{
relay: this.props.hostname,
- bridge: this.props.bridgeHostname,
+ entry: this.props.entryHostname,
},
);
} else {
diff --git a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx
index ad7c092f9c..d0727ddb6f 100644
--- a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx
+++ b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx
@@ -6,13 +6,15 @@ import ImageView from './ImageView';
const Container = styled.div({
display: 'flex',
alignItems: 'center',
+ width: '100%',
});
-const Caption = styled.span((props: { open: boolean }) => ({
+const Caption = styled.span({}, (props: { open: boolean }) => ({
fontFamily: 'Open Sans',
fontSize: '15px',
fontWeight: 600,
lineHeight: '20px',
+ minWidth: '0px',
color: props.open ? colors.white : colors.white40,
[Container + ':hover &']: {
color: colors.white,
@@ -28,7 +30,7 @@ const Chevron = styled(ImageView)({
interface IProps {
pointsUp: boolean;
onToggle?: () => void;
- children: React.ReactText;
+ children: React.ReactNode;
className?: string;
}
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
index 6389784ecd..855efdc066 100644
--- a/gui/src/renderer/components/LocationList.tsx
+++ b/gui/src/renderer/components/LocationList.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { colors } from '../../config.json';
import {
@@ -7,8 +8,12 @@ import {
RelayLocation,
relayLocationComponents,
} from '../../shared/daemon-rpc-types';
-import { relayLocations } from '../../shared/gettext';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
+import { messages, relayLocations } from '../../shared/gettext';
+import {
+ IRelayLocationRedux,
+ IRelayLocationCityRedux,
+ IRelayLocationRelayRedux,
+} from '../redux/settings/reducers';
import * as Cell from './cell';
import LocationRow from './LocationRow';
@@ -257,35 +262,82 @@ export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>
};
}
+export enum DisabledReason {
+ entry,
+ exit,
+ inactive,
+}
+
interface IRelayLocationsProps {
source: IRelayLocationRedux[];
+ locale: string;
selectedLocation?: RelayLocation;
selectedElementRef?: React.Ref<React.ReactInstance>;
expandedItems?: RelayLocation[];
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason };
onSelect?: (location: RelayLocation) => void;
onExpand?: (location: RelayLocation, expand: boolean) => void;
onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
onTransitionEnd?: () => void;
}
+interface Relay extends IRelayLocationRelayRedux {
+ label: string;
+ disabled: boolean;
+}
+
+interface City extends Omit<IRelayLocationCityRedux, 'relays'> {
+ label: string;
+ active: boolean;
+ disabled: boolean;
+ relays: Array<Relay>;
+}
+
+interface Country extends Omit<IRelayLocationRedux, 'cities'> {
+ label: string;
+ active: boolean;
+ disabled: boolean;
+ cities: Array<City>;
+}
+
+type CountryList = Array<Country>;
+
+interface IRelayLocationsState {
+ countries: CountryList;
+}
+
interface ICommonCellProps {
location: RelayLocation;
selected: boolean;
ref?: React.Ref<HTMLDivElement>;
}
-export class RelayLocations extends React.PureComponent<IRelayLocationsProps> {
+export class RelayLocations extends React.PureComponent<
+ IRelayLocationsProps,
+ IRelayLocationsState
+> {
+ public state = {
+ countries: this.prepareRelaysForPresentation(this.props.source),
+ };
+
+ public componentDidUpdate(prevProps: IRelayLocationsProps) {
+ if (this.props.source !== prevProps.source) {
+ this.setState({ countries: this.prepareRelaysForPresentation(this.props.source) });
+ }
+ }
+
public render() {
return (
<>
- {this.props.source.map((relayCountry) => {
+ {this.state.countries.map((relayCountry) => {
const countryLocation: RelayLocation = { country: relayCountry.code };
return (
<LocationRow
key={getLocationKey(countryLocation)}
- name={relayLocations.gettext(relayCountry.name)}
- active={relayCountry.hasActiveRelays}
+ name={relayCountry.label}
+ active={relayCountry.active}
+ disabled={relayCountry.disabled}
expanded={this.isExpanded(countryLocation)}
onSelect={this.handleSelection}
onExpand={this.handleExpand}
@@ -300,8 +352,9 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> {
return (
<LocationRow
key={getLocationKey(cityLocation)}
- name={relayLocations.gettext(relayCity.name)}
- active={relayCity.hasActiveRelays}
+ name={relayCity.label}
+ active={relayCity.active}
+ disabled={relayCity.disabled}
expanded={this.isExpanded(cityLocation)}
onSelect={this.handleSelection}
onExpand={this.handleExpand}
@@ -316,8 +369,9 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> {
return (
<LocationRow
key={getLocationKey(relayLocation)}
- name={relay.hostname}
+ name={relay.label}
active={relay.active}
+ disabled={relay.disabled}
onSelect={this.handleSelection}
{...this.getCommonCellProps(relayLocation)}
/>
@@ -333,6 +387,174 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> {
);
}
+ private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): CountryList {
+ return relayList
+ .map((country) => {
+ const countryDisabled = this.isCountryDisabled(country, country.code);
+ const countryLocation = { country: country.code };
+
+ return {
+ ...country,
+ label: this.formatRowName(country.name, countryLocation, countryDisabled),
+ active: countryDisabled !== DisabledReason.inactive,
+ disabled: countryDisabled !== undefined,
+ cities: country.cities
+ .map((city) => {
+ const cityDisabled =
+ countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]);
+ const cityLocation: RelayLocation = { city: [country.code, city.code] };
+
+ return {
+ ...city,
+ label: this.formatRowName(city.name, cityLocation, cityDisabled),
+ active: cityDisabled !== DisabledReason.inactive,
+ disabled: cityDisabled !== undefined,
+ relays: city.relays
+ .map((relay) => {
+ const relayDisabled =
+ countryDisabled ??
+ cityDisabled ??
+ this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]);
+ const relayLocation: RelayLocation = {
+ hostname: [country.code, city.code, relay.hostname],
+ };
+
+ return {
+ ...relay,
+ label: this.formatRowName(relay.hostname, relayLocation, relayDisabled),
+ disabled: relayDisabled !== undefined,
+ };
+ })
+ .sort((a, b) =>
+ a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }),
+ ),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name, this.props.locale)),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name, this.props.locale));
+ }
+
+ private formatRowName(
+ name: string,
+ location: RelayLocation,
+ disabledReason?: DisabledReason,
+ ): string {
+ const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
+ const disabledLocation = this.props.disabledLocation;
+ const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location);
+
+ let info: string | undefined;
+ if (
+ disabledReason === DisabledReason.entry ||
+ (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry)
+ ) {
+ info = messages.pgettext('select-location-view', 'Entry');
+ } else if (
+ disabledReason === DisabledReason.exit ||
+ (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit)
+ ) {
+ info = messages.pgettext('select-location-view', 'Exit');
+ }
+
+ return info !== undefined
+ ? sprintf(
+ // TRANSLATORS: This is used for appending information about a location.
+ // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(location)s - Translated location name
+ // TRANSLATORS: %(info)s - Information about the location
+ messages.pgettext('select-location-view', '%(location)s (%(info)s)'),
+ {
+ location: translatedName,
+ info,
+ },
+ )
+ : translatedName;
+ }
+
+ private isRelayDisabled(
+ relay: IRelayLocationRelayRedux,
+ location: [string, string, string],
+ ): DisabledReason | undefined {
+ if (!relay.active) {
+ return DisabledReason.inactive;
+ } else if (
+ this.props.disabledLocation &&
+ compareRelayLocation({ hostname: location }, this.props.disabledLocation.location)
+ ) {
+ return this.props.disabledLocation.reason;
+ } else {
+ return undefined;
+ }
+ }
+
+ private isCityDisabled(
+ city: IRelayLocationCityRedux,
+ location: [string, string],
+ ): DisabledReason | undefined {
+ const relaysDisabled = city.relays.map((relay) =>
+ this.isRelayDisabled(relay, [...location, relay.hostname]),
+ );
+ if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
+ return DisabledReason.inactive;
+ }
+
+ const disabledDueToSelection = relaysDisabled.find(
+ (status) => status === DisabledReason.entry || status === DisabledReason.exit,
+ );
+
+ if (
+ relaysDisabled.every((status) => status !== undefined) &&
+ disabledDueToSelection !== undefined
+ ) {
+ return disabledDueToSelection;
+ }
+
+ if (
+ this.props.disabledLocation &&
+ compareRelayLocation({ city: location }, this.props.disabledLocation.location) &&
+ city.relays.filter((relay) => relay.active).length <= 1
+ ) {
+ return this.props.disabledLocation.reason;
+ }
+
+ return undefined;
+ }
+
+ private isCountryDisabled(
+ country: IRelayLocationRedux,
+ location: string,
+ ): DisabledReason | undefined {
+ const citiesDisabled = country.cities.map((city) =>
+ this.isCityDisabled(city, [location, city.code]),
+ );
+ if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
+ return DisabledReason.inactive;
+ }
+
+ const disabledDueToSelection = citiesDisabled.find(
+ (status) => status === DisabledReason.entry || status === DisabledReason.exit,
+ );
+ if (
+ citiesDisabled.every((status) => status !== undefined) &&
+ disabledDueToSelection !== undefined
+ ) {
+ return disabledDueToSelection;
+ }
+
+ if (
+ this.props.disabledLocation &&
+ compareRelayLocation({ country: location }, this.props.disabledLocation.location) &&
+ country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
+ ) {
+ return this.props.disabledLocation.reason;
+ }
+
+ return undefined;
+ }
+
private isExpanded(relayLocation: RelayLocation) {
return (this.props.expandedItems || []).some((location) =>
compareRelayLocation(location, relayLocation),
diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx
index cf7bc5942c..980ffaae3e 100644
--- a/gui/src/renderer/components/LocationRow.tsx
+++ b/gui/src/renderer/components/LocationRow.tsx
@@ -67,6 +67,7 @@ const Label = styled(Cell.Label)({
interface IProps {
name: string;
active: boolean;
+ disabled: boolean;
location: RelayLocation;
selected: boolean;
expanded?: boolean;
@@ -105,13 +106,13 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
<Container
ref={ref}
selected={props.selected}
- disabled={!props.active}
+ disabled={props.disabled}
location={props.location}>
<Button
ref={buttonRef}
onClick={handleClick}
location={props.location}
- disabled={!props.active}>
+ disabled={props.disabled}>
<RelayStatusIndicator active={props.active} selected={props.selected} />
<Label>{props.name}</Label>
</Button>
@@ -149,6 +150,7 @@ function compareProps(oldProps: IProps, nextProps: IProps): boolean {
React.Children.count(oldProps.children) === React.Children.count(nextProps.children) &&
oldProps.name === nextProps.name &&
oldProps.active === nextProps.active &&
+ oldProps.disabled === nextProps.disabled &&
oldProps.selected === nextProps.selected &&
oldProps.expanded === nextProps.expanded &&
oldProps.onSelect === nextProps.onSelect &&
diff --git a/gui/src/renderer/components/ExitLocations.tsx b/gui/src/renderer/components/Locations.tsx
index e4174a0e7c..901d27c9ec 100644
--- a/gui/src/renderer/components/ExitLocations.tsx
+++ b/gui/src/renderer/components/Locations.tsx
@@ -2,25 +2,25 @@ import * as React from 'react';
import { RelayLocation } from '../../shared/daemon-rpc-types';
import { IRelayLocationRedux } from '../redux/settings/reducers';
import LocationList, {
+ DisabledReason,
LocationSelection,
LocationSelectionType,
RelayLocations,
} from './LocationList';
-interface IExitLocationsProps {
+interface ILocationsProps {
source: IRelayLocationRedux[];
+ locale: string;
defaultExpandedLocations?: RelayLocation[];
selectedValue?: RelayLocation;
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason };
selectedElementRef?: React.Ref<React.ReactInstance>;
onSelect?: (value: LocationSelection<never>) => void;
onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
onTransitionEnd?: () => void;
}
-const ExitLocations = React.forwardRef(function ExitLocationsT(
- props: IExitLocationsProps,
- ref: React.Ref<LocationList<never>>,
-) {
+function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) {
const selectedValue: LocationSelection<never> | undefined = props.selectedValue
? { type: LocationSelectionType.relay, value: props.selectedValue }
: undefined;
@@ -34,11 +34,14 @@ const ExitLocations = React.forwardRef(function ExitLocationsT(
onSelect={props.onSelect}>
<RelayLocations
source={props.source}
+ locale={props.locale}
+ disabledLocation={props.disabledLocation}
onWillExpand={props.onWillExpand}
onTransitionEnd={props.onTransitionEnd}
/>
</LocationList>
);
-});
+}
-export default ExitLocations;
+export const ExitLocations = React.forwardRef(Locations);
+export const EntryLocations = React.forwardRef(Locations);
diff --git a/gui/src/renderer/components/OpenVPNSettings.tsx b/gui/src/renderer/components/OpenVPNSettings.tsx
index 72e672b681..1696f45052 100644
--- a/gui/src/renderer/components/OpenVPNSettings.tsx
+++ b/gui/src/renderer/components/OpenVPNSettings.tsx
@@ -3,10 +3,11 @@ import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { BridgeState, RelayProtocol } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
+import * as AppButton from './AppButton';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
import * as Cell from './cell';
import { Layout, SettingsContainer } from './Layout';
-import { ModalContainer } from './Modal';
+import { ModalAlert, ModalAlertType, ModalContainer } from './Modal';
import {
BackBarItem,
NavigationBar,
@@ -17,6 +18,7 @@ import {
} from './NavigationBar';
import Selector, { ISelectorItem } from './cell/Selector';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import { formatMarkdown } from '../markdown-formatter';
const MIN_MSSFIX_VALUE = 1000;
const MAX_MSSFIX_VALUE = 1450;
@@ -43,7 +45,12 @@ export const StyledInputFrame = styled(Cell.InputFrame)({
flex: 0,
});
+export const StyledSelectorForFooter = (styled(Selector)({
+ marginBottom: 0,
+}) as unknown) as new <T>() => Selector<T>;
+
interface IProps {
+ tunnelProtocolIsOpenVpn: boolean;
openvpn: {
protocol?: RelayProtocol;
port?: number;
@@ -56,10 +63,15 @@ interface IProps {
onClose: () => void;
}
-export default class OpenVpnSettings extends React.Component<IProps> {
+interface IState {
+ showBridgeStateConfirmationDialog: boolean;
+}
+
+export default class OpenVpnSettings extends React.Component<IProps, IState> {
+ public state = { showBridgeStateConfirmationDialog: false };
+
private portItems: { [key in RelayProtocol]: Array<ISelectorItem<OptionalPort>> };
private protocolItems: Array<ISelectorItem<OptionalRelayProtocol>>;
- private bridgeStateItems: Array<ISelectorItem<BridgeState>>;
constructor(props: IProps) {
super(props);
@@ -88,21 +100,6 @@ export default class OpenVpnSettings extends React.Component<IProps> {
value: 'udp',
},
];
-
- this.bridgeStateItems = [
- {
- label: messages.gettext('Automatic'),
- value: 'auto',
- },
- {
- label: messages.gettext('On'),
- value: 'on',
- },
- {
- label: messages.gettext('Off'),
- value: 'off',
- },
- ];
}
public render() {
@@ -164,15 +161,39 @@ export default class OpenVpnSettings extends React.Component<IProps> {
</AriaInputGroup>
<AriaInputGroup>
- <Selector
- title={
- // TRANSLATORS: The title for the shadowsocks bridge selector section.
- messages.pgettext('openvpn-settings-view', 'Bridge mode')
- }
- values={this.bridgeStateItems}
- value={this.props.bridgeState}
- onSelect={this.onSelectBridgeState}
- />
+ <StyledSelectorContainer>
+ <StyledSelectorForFooter
+ title={
+ // TRANSLATORS: The title for the shadowsocks bridge selector section.
+ messages.pgettext('openvpn-settings-view', 'Bridge mode')
+ }
+ values={this.bridgeStateItems(this.props.tunnelProtocolIsOpenVpn)}
+ value={this.props.bridgeState}
+ onSelect={this.onSelectBridgeState}
+ />
+ </StyledSelectorContainer>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {this.props.tunnelProtocolIsOpenVpn
+ ? // This line is here to prevent prettier from moving up the next line.
+ // TRANSLATORS: This is used as a description for the bridge mode
+ // TRANSLATORS: setting.
+ messages.pgettext(
+ 'openvpn-settings-view',
+ 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an OpenVPN server. Obfuscation is added to make fingerprinting harder.',
+ )
+ : formatMarkdown(
+ // TRANSLATORS: This is used to instruct users how to make the bridge
+ // TRANSLATORS: mode setting available.
+ messages.pgettext(
+ 'openvpn-settings-view',
+ 'To activate Bridge mode, go back and change **Tunnel protocol** to **OpenVPN**.',
+ ),
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
</AriaInputGroup>
<AriaInputGroup>
@@ -222,10 +243,30 @@ export default class OpenVpnSettings extends React.Component<IProps> {
</NavigationContainer>
</SettingsContainer>
</Layout>
+
+ {this.state.showBridgeStateConfirmationDialog && this.renderBridgeStateConfirmation()}
</ModalContainer>
);
}
+ private bridgeStateItems(onAvailable: boolean): Array<ISelectorItem<BridgeState>> {
+ return [
+ {
+ label: messages.gettext('Automatic'),
+ value: 'auto',
+ },
+ {
+ label: messages.gettext('On'),
+ value: 'on',
+ disabled: !onAvailable,
+ },
+ {
+ label: messages.gettext('Off'),
+ value: 'off',
+ },
+ ];
+ }
+
private onSelectOpenvpnProtocol = (protocol?: RelayProtocol) => {
this.props.setOpenVpnRelayProtocolAndPort(protocol);
};
@@ -234,10 +275,6 @@ export default class OpenVpnSettings extends React.Component<IProps> {
this.props.setOpenVpnRelayProtocolAndPort(this.props.openvpn.protocol, port);
};
- private onSelectBridgeState = (bridgeState: BridgeState) => {
- this.props.setBridgeState(bridgeState);
- };
-
private onMssfixSubmit = (value: string) => {
const parsedValue = value === '' ? undefined : parseInt(value, 10);
if (OpenVpnSettings.mssfixIsValid(value)) {
@@ -256,4 +293,38 @@ export default class OpenVpnSettings extends React.Component<IProps> {
(parsedMssFix >= MIN_MSSFIX_VALUE && parsedMssFix <= MAX_MSSFIX_VALUE)
);
}
+
+ private renderBridgeStateConfirmation = () => {
+ return (
+ <ModalAlert
+ type={ModalAlertType.info}
+ message={messages.gettext('This setting increases latency. Use only if needed.')}
+ buttons={[
+ <AppButton.RedButton key="confirm" onClick={this.confirmBridgeState}>
+ {messages.gettext('Enable anyway')}
+ </AppButton.RedButton>,
+ <AppButton.BlueButton key="back" onClick={this.hideBridgeStateConfirmationDialog}>
+ {messages.gettext('Back')}
+ </AppButton.BlueButton>,
+ ]}
+ close={this.hideBridgeStateConfirmationDialog}></ModalAlert>
+ );
+ };
+
+ private onSelectBridgeState = (newValue: BridgeState) => {
+ if (newValue === 'on') {
+ this.setState({ showBridgeStateConfirmationDialog: true });
+ } else {
+ this.props.setBridgeState(newValue);
+ }
+ };
+
+ private hideBridgeStateConfirmationDialog = () => {
+ this.setState({ showBridgeStateConfirmationDialog: false });
+ };
+
+ private confirmBridgeState = () => {
+ this.setState({ showBridgeStateConfirmationDialog: false });
+ this.props.setBridgeState('on');
+ };
}
diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx
index 528e8129ef..b6698fcbc7 100644
--- a/gui/src/renderer/components/SelectLocation.tsx
+++ b/gui/src/renderer/components/SelectLocation.tsx
@@ -1,16 +1,19 @@
import React from 'react';
import { sprintf } from 'sprintf-js';
import { colors } from '../../config.json';
-import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types';
+import { LiftedConstraint, RelayLocation, TunnelProtocol } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { IRelayLocationRedux } from '../redux/settings/reducers';
-import { LocationScope } from '../redux/userinterface/reducers';
import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations';
import { CustomScrollbarsRef } from './CustomScrollbars';
-import ExitLocations from './ExitLocations';
+import { EntryLocations, ExitLocations } from './Locations';
import ImageView from './ImageView';
import { Layout } from './Layout';
-import LocationList, { LocationSelection, LocationSelectionType } from './LocationList';
+import LocationList, {
+ DisabledReason,
+ LocationSelection,
+ LocationSelectionType,
+} from './LocationList';
import {
CloseBarItem,
NavigationBar,
@@ -37,25 +40,33 @@ import {
import { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
interface IProps {
- locationScope: LocationScope;
+ locale: string;
selectedExitLocation?: RelayLocation;
+ selectedEntryLocation?: RelayLocation;
selectedBridgeLocation?: LiftedConstraint<RelayLocation>;
relayLocations: IRelayLocationRedux[];
bridgeLocations: IRelayLocationRedux[];
- allowBridgeSelection: boolean;
+ allowEntrySelection: boolean;
+ tunnelProtocol: LiftedConstraint<TunnelProtocol>;
providers: string[];
onClose: () => void;
onViewFilterByProvider: () => void;
- onChangeLocationScope: (location: LocationScope) => void;
onSelectExitLocation: (location: RelayLocation) => void;
+ onSelectEntryLocation: (location: RelayLocation) => void;
onSelectBridgeLocation: (location: RelayLocation) => void;
onSelectClosestToExit: () => void;
onClearProviders: () => void;
}
+enum LocationScope {
+ entry = 0,
+ exit,
+}
+
interface IState {
showFilterMenu: boolean;
headingHeight: number;
+ locationScope: LocationScope;
}
interface ISelectLocationSnapshot {
@@ -64,17 +75,19 @@ interface ISelectLocationSnapshot {
}
export default class SelectLocation extends React.Component<IProps, IState> {
- public state = { showFilterMenu: false, headingHeight: 0 };
+ public state = { showFilterMenu: false, headingHeight: 0, locationScope: LocationScope.exit };
private scrollView = React.createRef<CustomScrollbarsRef>();
private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>();
private selectedExitLocationRef = React.createRef<React.ReactInstance>();
+ private selectedEntryLocationRef = React.createRef<React.ReactInstance>();
private selectedBridgeLocationRef = React.createRef<React.ReactInstance>();
private exitLocationList = React.createRef<LocationList<never>>();
+ private entryLocationList = React.createRef<LocationList<never>>();
private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>();
- private snapshotByScope: { [index: number]: ISelectLocationSnapshot } = {};
+ private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {};
private filterButtonRef = React.createRef<HTMLDivElement>();
private headerRef = React.createRef<HTMLHeadingElement>();
@@ -87,25 +100,25 @@ export default class SelectLocation extends React.Component<IProps, IState> {
}
public componentDidUpdate(
- prevProps: IProps,
- _prevState: unknown,
+ _prevProps: IProps,
+ prevState: IState,
snapshot?: ISelectLocationSnapshot,
) {
- if (this.props.locationScope !== prevProps.locationScope) {
- this.restoreScrollPosition(this.props.locationScope);
+ if (this.state.locationScope !== prevState.locationScope) {
+ this.restoreScrollPosition(this.state.locationScope);
if (snapshot) {
- this.snapshotByScope[prevProps.locationScope] = snapshot;
+ this.snapshotByScope[prevState.locationScope] = snapshot;
}
}
}
- public getSnapshotBeforeUpdate(prevProps: IProps): ISelectLocationSnapshot | undefined {
+ public getSnapshotBeforeUpdate(
+ prevProps: IProps,
+ prevState: IState,
+ ): ISelectLocationSnapshot | undefined {
const scrollView = this.scrollView.current;
- const locationList =
- prevProps.locationScope === LocationScope.relay
- ? this.exitLocationList.current
- : this.bridgeLocationList.current;
+ const locationList = this.getLocationListRef(prevProps, prevState);
if (scrollView && locationList) {
return {
@@ -164,13 +177,7 @@ export default class SelectLocation extends React.Component<IProps, IState> {
messages.pgettext('select-location-view', 'Select location')
}
</HeaderTitle>
- <HeaderSubTitle>
- {this.props.allowBridgeSelection &&
- messages.pgettext(
- 'select-location-view',
- 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).',
- )}
- </HeaderSubTitle>
+ {this.renderHeaderSubtitle()}
</StyledSettingsHeader>
{this.props.providers.length > 0 && (
@@ -200,45 +207,21 @@ export default class SelectLocation extends React.Component<IProps, IState> {
</StyledProvidersCount>
</StyledProviderCountRow>
)}
- {this.props.allowBridgeSelection && (
+ {this.props.allowEntrySelection && (
<StyledScopeBar
- defaultSelectedIndex={this.props.locationScope}
- onChange={this.props.onChangeLocationScope}>
+ defaultSelectedIndex={this.state.locationScope}
+ onChange={this.onChangeLocationScope}>
<ScopeBarItem>
- {messages.pgettext('select-location-nav', 'Entry')}
+ {messages.pgettext('select-location-view', 'Entry')}
</ScopeBarItem>
<ScopeBarItem>
- {messages.pgettext('select-location-nav', 'Exit')}
+ {messages.pgettext('select-location-view', 'Exit')}
</ScopeBarItem>
</StyledScopeBar>
)}
</StyledNavigationBarAttachment>
- <StyledContent>
- {this.props.locationScope === LocationScope.relay ? (
- <ExitLocations
- ref={this.exitLocationList}
- source={this.props.relayLocations}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedExitLocation}
- selectedElementRef={this.selectedExitLocationRef}
- onSelect={this.onSelectExitLocation}
- onWillExpand={this.onWillExpand}
- onTransitionEnd={this.resetHeight}
- />
- ) : (
- <BridgeLocations
- ref={this.bridgeLocationList}
- source={this.props.bridgeLocations}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedBridgeLocation}
- selectedElementRef={this.selectedBridgeLocationRef}
- onSelect={this.onSelectBridgeLocation}
- onWillExpand={this.onWillExpand}
- onTransitionEnd={this.resetHeight}
- />
- )}
- </StyledContent>
+ <StyledContent>{this.renderLocationList()}</StyledContent>
</SpacePreAllocationView>
</NavigationScrollbars>
</NavigationContainer>
@@ -257,12 +240,118 @@ export default class SelectLocation extends React.Component<IProps, IState> {
}
}
+ private getLocationListRef(prevProps: IProps, prevState: IState) {
+ if (prevState.locationScope === LocationScope.exit) {
+ return this.exitLocationList.current;
+ } else if (prevProps.tunnelProtocol === 'wireguard') {
+ return this.entryLocationList.current;
+ } else {
+ return this.bridgeLocationList.current;
+ }
+ }
+
+ private getSelectedLocationRef() {
+ if (this.state.locationScope === LocationScope.exit) {
+ return this.selectedExitLocationRef.current;
+ } else if (this.props.tunnelProtocol === 'wireguard') {
+ return this.selectedEntryLocationRef.current;
+ } else {
+ return this.selectedBridgeLocationRef.current;
+ }
+ }
+
+ private renderHeaderSubtitle() {
+ if (this.props.allowEntrySelection) {
+ if (this.props.tunnelProtocol === 'openvpn') {
+ return (
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'select-location-view',
+ 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).',
+ )}
+ </HeaderSubTitle>
+ );
+ } else {
+ return (
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'select-location-view',
+ 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).',
+ )}
+ </HeaderSubTitle>
+ );
+ }
+ } else {
+ return null;
+ }
+ }
+
+ private renderLocationList() {
+ if (this.state.locationScope === LocationScope.exit) {
+ const disabledLocation = this.props.selectedEntryLocation
+ ? {
+ location: this.props.selectedEntryLocation,
+ reason: DisabledReason.entry,
+ }
+ : undefined;
+ return (
+ <ExitLocations
+ ref={this.exitLocationList}
+ source={this.props.relayLocations}
+ locale={this.props.locale}
+ defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
+ selectedValue={this.props.selectedExitLocation}
+ selectedElementRef={this.selectedExitLocationRef}
+ disabledLocation={disabledLocation}
+ onSelect={this.onSelectExitLocation}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.resetHeight}
+ />
+ );
+ } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') {
+ const disabledLocation = this.props.selectedExitLocation
+ ? {
+ location: this.props.selectedExitLocation,
+ reason: DisabledReason.exit,
+ }
+ : undefined;
+ return (
+ <EntryLocations
+ ref={this.entryLocationList}
+ source={this.props.relayLocations}
+ locale={this.props.locale}
+ defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
+ selectedValue={this.props.selectedEntryLocation}
+ selectedElementRef={this.selectedEntryLocationRef}
+ disabledLocation={disabledLocation}
+ onSelect={this.onSelectEntryLocation}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.resetHeight}
+ />
+ );
+ } else {
+ return (
+ <BridgeLocations
+ ref={this.bridgeLocationList}
+ source={this.props.bridgeLocations}
+ locale={this.props.locale}
+ defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
+ selectedValue={this.props.selectedBridgeLocation}
+ selectedElementRef={this.selectedBridgeLocationRef}
+ onSelect={this.onSelectBridgeLocation}
+ onWillExpand={this.onWillExpand}
+ onTransitionEnd={this.resetHeight}
+ />
+ );
+ }
+ }
+
private resetHeight = () => {
this.spacePreAllocationViewRef.current?.reset();
};
private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined {
- const snapshot = this.snapshotByScope[this.props.locationScope];
+ const snapshot = this.snapshotByScope[this.state.locationScope];
if (snapshot) {
return snapshot.expandedLocations;
} else {
@@ -278,10 +367,7 @@ export default class SelectLocation extends React.Component<IProps, IState> {
}
private scrollToSelectedCell() {
- const ref =
- this.props.locationScope === LocationScope.relay
- ? this.selectedExitLocationRef.current
- : this.selectedBridgeLocationRef.current;
+ const ref = this.getSelectedLocationRef();
const scrollView = this.scrollView.current;
if (scrollView) {
@@ -295,12 +381,20 @@ export default class SelectLocation extends React.Component<IProps, IState> {
}
}
+ private onChangeLocationScope = (locationScope: LocationScope) => {
+ this.setState({ locationScope });
+ };
+
private onSelectExitLocation = (location: LocationSelection<never>) => {
if (location.type === LocationSelectionType.relay) {
this.props.onSelectExitLocation(location.value);
}
};
+ private onSelectEntryLocation = (location: LocationSelection<never>) => {
+ this.props.onSelectEntryLocation(location.value);
+ };
+
private onSelectBridgeLocation = (location: LocationSelection<SpecialBridgeLocationType>) => {
if (location.type === LocationSelectionType.relay) {
this.props.onSelectBridgeLocation(location.value);
diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx
index 8bd379d7d4..d41d647266 100644
--- a/gui/src/renderer/components/WireguardSettings.tsx
+++ b/gui/src/renderer/components/WireguardSettings.tsx
@@ -3,9 +3,11 @@ import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { IpVersion } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
+import * as AppButton from './AppButton';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
import * as Cell from './cell';
import { Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType, ModalContainer } from './Modal';
import {
BackBarItem,
NavigationBar,
@@ -16,6 +18,7 @@ import {
} from './NavigationBar';
import Selector, { ISelectorItem } from './cell/Selector';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import Switch from './Switch';
const MIN_WIREGUARD_MTU_VALUE = 1280;
const MAX_WIREGUARD_MTU_VALUE = 1420;
@@ -47,13 +50,24 @@ export const StyledInputFrame = styled(Cell.InputFrame)({
interface IProps {
wireguard: { port?: number; ipVersion?: IpVersion };
wireguardMtu?: number;
+ wireguardMultihop: boolean;
setWireguardMtu: (value: number | undefined) => void;
- setWireguardRelayPortAndIpVersion: (port?: number, ipVersion?: IpVersion) => void;
+ setWireguardMultihop: (value: boolean) => void;
+ setWireguardPort: (port?: number) => void;
+ setWireguardIpVersion: (ipVersion?: IpVersion) => void;
onViewWireguardKeys: () => void;
onClose: () => void;
}
-export default class WireguardSettings extends React.Component<IProps> {
+interface IState {
+ showMultihopConfirmationDialog: boolean;
+}
+
+export default class WireguardSettings extends React.Component<IProps, IState> {
+ public state = { showMultihopConfirmationDialog: false };
+
+ private multihopRef = React.createRef<Switch>();
+
private wireguardPortItems: Array<ISelectorItem<OptionalPort>>;
private wireguardIpVersionItems: Array<ISelectorItem<OptionalIpVersion>>;
@@ -87,149 +101,178 @@ export default class WireguardSettings extends React.Component<IProps> {
public render() {
return (
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <BackBarItem action={this.props.onClose}>
- {
- // TRANSLATORS: Back button in navigation bar
- messages.pgettext('navigation-bar', 'Advanced')
- }
- </BackBarItem>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('wireguard-settings-nav', 'WireGuard settings')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
+ <ModalContainer>
+ <Layout>
+ <SettingsContainer>
+ <NavigationContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <BackBarItem action={this.props.onClose}>
+ {
+ // TRANSLATORS: Back button in navigation bar
+ messages.pgettext('navigation-bar', 'Advanced')
+ }
+ </BackBarItem>
+ <TitleBarItem>
+ {
+ // TRANSLATORS: Title label in navigation bar
+ messages.pgettext('wireguard-settings-nav', 'WireGuard settings')
+ }
+ </TitleBarItem>
+ </NavigationItems>
+ </NavigationBar>
- <StyledNavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('wireguard-settings-view', 'WireGuard settings')}
- </HeaderTitle>
- </SettingsHeader>
+ <StyledNavigationScrollbars>
+ <SettingsHeader>
+ <HeaderTitle>
+ {messages.pgettext('wireguard-settings-view', 'WireGuard settings')}
+ </HeaderTitle>
+ </SettingsHeader>
- <AriaInputGroup>
- <StyledSelectorContainer>
- <StyledSelectorForFooter
- // TRANSLATORS: The title for the WireGuard port selector.
- title={messages.pgettext('wireguard-settings-view', 'Port')}
- values={this.wireguardPortItems}
- value={this.props.wireguard.port}
- onSelect={this.onSelectWireguardPort}
- />
- </StyledSelectorContainer>
- <Cell.Footer>
- <AriaDescription>
- <Cell.FooterText>
- {
- // TRANSLATORS: The hint displayed below the WireGuard port selector.
- messages.pgettext(
- 'wireguard-settings-view',
- 'The automatic setting will randomly choose from a wide range of ports.',
- )
- }
- </Cell.FooterText>
- </AriaDescription>
- </Cell.Footer>
- </AriaInputGroup>
-
- <AriaInputGroup>
- <StyledSelectorContainer>
- <StyledSelectorForFooter
- // TRANSLATORS: The title for the WireGuard IP version selector.
- title={messages.pgettext('wireguard-settings-view', 'IP version')}
- values={this.wireguardIpVersionItems}
- value={this.props.wireguard.ipVersion}
- onSelect={this.onSelectWireguardIpVersion}
- />
- </StyledSelectorContainer>
- <Cell.Footer>
- <AriaDescription>
- <Cell.FooterText>
- {
- // TRANSLATORS: The hint displayed below the WireGuard IP version selector.
- messages.pgettext(
- 'wireguard-settings-view',
- 'This allows access to WireGuard for devices that only support IPv6.',
- )
- }
- </Cell.FooterText>
- </AriaDescription>
- </Cell.Footer>
- </AriaInputGroup>
-
- <Cell.CellButtonGroup>
- <Cell.CellButton onClick={this.props.onViewWireguardKeys}>
- <Cell.Label>
- {messages.pgettext('wireguard-settings-view', 'WireGuard key')}
- </Cell.Label>
- <Cell.Icon height={12} width={7} source="icon-chevron" />
- </Cell.CellButton>
- </Cell.CellButtonGroup>
+ <AriaInputGroup>
+ <StyledSelectorContainer>
+ <StyledSelectorForFooter
+ // TRANSLATORS: The title for the WireGuard port selector.
+ title={messages.pgettext('wireguard-settings-view', 'Port')}
+ values={this.wireguardPortItems}
+ value={this.props.wireguard.port}
+ onSelect={this.props.setWireguardPort}
+ />
+ </StyledSelectorContainer>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {
+ // TRANSLATORS: The hint displayed below the WireGuard port selector.
+ messages.pgettext(
+ 'wireguard-settings-view',
+ 'The automatic setting will randomly choose from a wide range of ports.',
+ )
+ }
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('wireguard-settings-view', 'MTU')}
- </Cell.InputLabel>
- </AriaLabel>
- <StyledInputFrame>
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {
+ // TRANSLATORS: The label next to the multihop settings toggle.
+ messages.pgettext('advanced-settings-view', 'Enable multihop')
+ }
+ </Cell.InputLabel>
+ </AriaLabel>
<AriaInput>
- <Cell.AutoSizingTextInput
- value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''}
- inputMode={'numeric'}
- maxLength={4}
- placeholder={messages.gettext('Default')}
- onSubmitValue={this.onWireguardMtuSubmit}
- validateValue={WireguardSettings.wireguarMtuIsValid}
- submitOnBlur={true}
- modifyValue={WireguardSettings.removeNonNumericCharacters}
+ <Cell.Switch
+ ref={this.multihopRef}
+ isOn={this.props.wireguardMultihop}
+ onChange={this.setWireguardMultihop}
/>
</AriaInput>
- </StyledInputFrame>
- </Cell.Container>
- <Cell.Footer>
- <AriaDescription>
- <Cell.FooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the WireGuard MTU input field.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value
- // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value
- messages.pgettext(
- 'wireguard-settings-view',
- 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.',
- ),
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
{
- min: MIN_WIREGUARD_MTU_VALUE,
- max: MAX_WIREGUARD_MTU_VALUE,
- },
- )}
- </Cell.FooterText>
- </AriaDescription>
- </Cell.Footer>
- </AriaInputGroup>
- </StyledNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- );
- }
+ // TRANSLATORS: Description for multihop settings toggle.
+ messages.pgettext(
+ 'advanced-settings-view',
+ 'Increases anonymity by routing your traffic into one WireGuard server and out another, making it harder to trace.',
+ )
+ }
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- private onSelectWireguardPort = (port?: number) => {
- this.props.setWireguardRelayPortAndIpVersion(port, this.props.wireguard.ipVersion);
- };
+ <AriaInputGroup>
+ <StyledSelectorContainer>
+ <StyledSelectorForFooter
+ // TRANSLATORS: The title for the WireGuard IP version selector.
+ title={messages.pgettext('wireguard-settings-view', 'IP version')}
+ values={this.wireguardIpVersionItems}
+ value={this.props.wireguard.ipVersion}
+ onSelect={this.props.setWireguardIpVersion}
+ />
+ </StyledSelectorContainer>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {
+ // TRANSLATORS: The hint displayed below the WireGuard IP version selector.
+ messages.pgettext(
+ 'wireguard-settings-view',
+ 'This allows access to WireGuard for devices that only support IPv6.',
+ )
+ }
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
- private onSelectWireguardIpVersion = (ipVersion?: IpVersion) => {
- this.props.setWireguardRelayPortAndIpVersion(this.props.wireguard.port, ipVersion);
- };
+ <Cell.CellButtonGroup>
+ <Cell.CellButton onClick={this.props.onViewWireguardKeys}>
+ <Cell.Label>
+ {messages.pgettext('wireguard-settings-view', 'WireGuard key')}
+ </Cell.Label>
+ <Cell.Icon height={12} width={7} source="icon-chevron" />
+ </Cell.CellButton>
+ </Cell.CellButtonGroup>
+
+ <AriaInputGroup>
+ <Cell.Container>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('wireguard-settings-view', 'MTU')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <StyledInputFrame>
+ <AriaInput>
+ <Cell.AutoSizingTextInput
+ value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''}
+ inputMode={'numeric'}
+ maxLength={4}
+ placeholder={messages.gettext('Default')}
+ onSubmitValue={this.onWireguardMtuSubmit}
+ validateValue={WireguardSettings.wireguarMtuIsValid}
+ submitOnBlur={true}
+ modifyValue={WireguardSettings.removeNonNumericCharacters}
+ />
+ </AriaInput>
+ </StyledInputFrame>
+ </Cell.Container>
+ <Cell.Footer>
+ <AriaDescription>
+ <Cell.FooterText>
+ {sprintf(
+ // TRANSLATORS: The hint displayed below the WireGuard MTU input field.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value
+ // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value
+ messages.pgettext(
+ 'wireguard-settings-view',
+ 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.',
+ ),
+ {
+ min: MIN_WIREGUARD_MTU_VALUE,
+ max: MAX_WIREGUARD_MTU_VALUE,
+ },
+ )}
+ </Cell.FooterText>
+ </AriaDescription>
+ </Cell.Footer>
+ </AriaInputGroup>
+ </StyledNavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+
+ {this.state.showMultihopConfirmationDialog && this.renderMultihopConfirmation()}
+ </ModalContainer>
+ );
+ }
private static removeNonNumericCharacters(value: string) {
return value.replace(/[^0-9]/g, '');
@@ -249,4 +292,42 @@ export default class WireguardSettings extends React.Component<IProps> {
(parsedMtu >= MIN_WIREGUARD_MTU_VALUE && parsedMtu <= MAX_WIREGUARD_MTU_VALUE)
);
}
+
+ private renderMultihopConfirmation = () => {
+ return (
+ <ModalAlert
+ type={ModalAlertType.info}
+ message={
+ // TRANSLATORS: Warning text in a dialog that is displayed after a setting is toggled.
+ messages.gettext('This setting increases latency. Use only if needed.')
+ }
+ buttons={[
+ <AppButton.RedButton key="confirm" onClick={this.confirmWireguardMultihop}>
+ {messages.gettext('Enable anyway')}
+ </AppButton.RedButton>,
+ <AppButton.BlueButton key="back" onClick={this.hideWireguardMultihopConfirmationDialog}>
+ {messages.gettext('Back')}
+ </AppButton.BlueButton>,
+ ]}
+ close={this.hideWireguardMultihopConfirmationDialog}></ModalAlert>
+ );
+ };
+
+ private setWireguardMultihop = (newValue: boolean) => {
+ if (newValue) {
+ this.setState({ showMultihopConfirmationDialog: true });
+ } else {
+ this.props.setWireguardMultihop(false);
+ }
+ };
+
+ private hideWireguardMultihopConfirmationDialog = () => {
+ this.setState({ showMultihopConfirmationDialog: false });
+ this.multihopRef.current?.setOn(this.props.wireguardMultihop);
+ };
+
+ private confirmWireguardMultihop = () => {
+ this.setState({ showMultihopConfirmationDialog: false });
+ this.props.setWireguardMultihop(true);
+ };
}
diff --git a/gui/src/renderer/containers/ConnectionPanelContainer.tsx b/gui/src/renderer/containers/ConnectionPanelContainer.tsx
index b3b67e9438..bf8ac77660 100644
--- a/gui/src/renderer/containers/ConnectionPanelContainer.tsx
+++ b/gui/src/renderer/containers/ConnectionPanelContainer.tsx
@@ -19,6 +19,22 @@ function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): IInAdd
};
}
+function tunnelEndpointToEntryLocationInAddress(
+ tunnelEndpoint: ITunnelEndpoint,
+): IInAddress | undefined {
+ if (!tunnelEndpoint.entryEndpoint) {
+ return undefined;
+ }
+
+ const socketAddr = parseSocketAddress(tunnelEndpoint.entryEndpoint.address);
+ return {
+ ip: socketAddr.host,
+ port: socketAddr.port,
+ protocol: tunnelEndpoint.entryEndpoint.transportProtocol,
+ tunnelType: tunnelEndpoint.tunnelType,
+ };
+}
+
function tunnelEndpointToBridgeData(endpoint: ITunnelEndpoint): IBridgeData | undefined {
if (!endpoint.proxy) {
return undefined;
@@ -46,6 +62,11 @@ const mapStateToProps = (state: IReduxState) => {
? tunnelEndpointToRelayInAddress(status.details.endpoint)
: undefined;
+ const entryLocationInAddress: IInAddress | undefined =
+ (status.state === 'connecting' || status.state === 'connected') && status.details
+ ? tunnelEndpointToEntryLocationInAddress(status.details.endpoint)
+ : undefined;
+
const bridgeInfo: IBridgeData | undefined =
(status.state === 'connecting' || status.state === 'connected') && status.details
? tunnelEndpointToBridgeData(status.details.endpoint)
@@ -55,7 +76,9 @@ const mapStateToProps = (state: IReduxState) => {
isOpen: state.userInterface.connectionPanelVisible,
hostname: state.connection.hostname,
bridgeHostname: state.connection.bridgeHostname,
+ entryHostname: state.connection.entryHostname,
inAddress,
+ entryLocationInAddress,
bridgeInfo,
outAddress,
};
diff --git a/gui/src/renderer/containers/OpenVPNSettingsPage.tsx b/gui/src/renderer/containers/OpenVPNSettingsPage.tsx
index 9212ca68ac..9885afe971 100644
--- a/gui/src/renderer/containers/OpenVPNSettingsPage.tsx
+++ b/gui/src/renderer/containers/OpenVPNSettingsPage.tsx
@@ -13,12 +13,26 @@ const mapStateToProps = (state: IReduxState) => {
const protocolAndPort = mapRelaySettingsToProtocolAndPort(state.settings.relaySettings);
return {
+ tunnelProtocolIsOpenVpn: mapRelaySettingsToProtocol(state.settings.relaySettings) === 'openvpn',
mssfix: state.settings.openVpn.mssfix,
bridgeState: state.settings.bridgeState,
...protocolAndPort,
};
};
+const mapRelaySettingsToProtocol = (relaySettings: RelaySettingsRedux) => {
+ if ('normal' in relaySettings) {
+ const { tunnelProtocol } = relaySettings.normal;
+ return tunnelProtocol === 'any' ? undefined : tunnelProtocol;
+ // since the GUI doesn't display custom settings, just display the default ones.
+ // If the user sets any settings, then those will be applied.
+ } else if ('customTunnelEndpoint' in relaySettings) {
+ return undefined;
+ } else {
+ throw new Error('Unknown type of relay settings.');
+ }
+};
+
const mapRelaySettingsToProtocolAndPort = (relaySettings: RelaySettingsRedux) => {
if ('normal' in relaySettings) {
const { openvpn } = relaySettings.normal;
diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx
index cd455a01f6..1effeac473 100644
--- a/gui/src/renderer/containers/SelectLocationPage.tsx
+++ b/gui/src/renderer/containers/SelectLocationPage.tsx
@@ -1,21 +1,21 @@
import { connect } from 'react-redux';
-import { bindActionCreators } from 'redux';
import BridgeSettingsBuilder from '../../shared/bridge-settings-builder';
import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types';
import log from '../../shared/logging';
import RelaySettingsBuilder from '../../shared/relay-settings-builder';
import SelectLocation from '../components/SelectLocation';
import withAppContext, { IAppContext } from '../context';
+import { createWireguardRelayUpdater } from '../lib/constraint-updater';
import { IHistoryProps, withHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
import { IRelayLocationRedux } from '../redux/settings/reducers';
import { IReduxState, ReduxDispatch } from '../redux/store';
-import userInterfaceActions from '../redux/userinterface/actions';
-import { LocationScope } from '../redux/userinterface/reducers';
-const mapStateToProps = (state: IReduxState) => {
+const mapStateToProps = (state: IReduxState, props: IHistoryProps & IAppContext) => {
let selectedExitLocation: RelayLocation | undefined;
+ let selectedEntryLocation: RelayLocation | undefined;
let selectedBridgeLocation: LiftedConstraint<RelayLocation> | undefined;
+ let multihopEnabled = false;
if ('normal' in state.settings.relaySettings) {
const exitLocation = state.settings.relaySettings.normal.location;
@@ -24,37 +24,58 @@ const mapStateToProps = (state: IReduxState) => {
}
}
- if ('normal' in state.settings.bridgeSettings) {
+ const relaySettings = state.settings.relaySettings;
+ const tunnelProtocol = 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any';
+
+ if (tunnelProtocol === 'openvpn' && 'normal' in state.settings.bridgeSettings) {
selectedBridgeLocation = state.settings.bridgeSettings.normal.location;
+ } else if ('normal' in relaySettings) {
+ const entryLocation = relaySettings.normal.wireguard.entryLocation;
+ if (entryLocation !== 'any') {
+ selectedEntryLocation = entryLocation;
+ }
+
+ multihopEnabled = relaySettings.normal.wireguard.useMultihop;
}
- const allowBridgeSelection = state.settings.bridgeState === 'on';
- const locationScope = allowBridgeSelection
- ? state.userInterface.locationScope
- : LocationScope.relay;
+ const allowEntrySelection =
+ (tunnelProtocol === 'openvpn' && state.settings.bridgeState === 'on') ||
+ ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled);
- const relaySettings = state.settings.relaySettings;
const providers = 'normal' in relaySettings ? relaySettings.normal.providers : [];
return {
+ locale: state.userInterface.locale,
selectedExitLocation,
+ selectedEntryLocation,
selectedBridgeLocation,
relayLocations: filterLocationsByProvider(state.settings.relayLocations, providers),
bridgeLocations: filterLocationsByProvider(state.settings.bridgeLocations, providers),
- locationScope,
- allowBridgeSelection,
+ allowEntrySelection,
+ tunnelProtocol,
providers,
+
+ onSelectEntryLocation: async (entryLocation: RelayLocation) => {
+ // dismiss the view first
+ props.history.dismiss();
+
+ const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings)
+ .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation))
+ .build();
+
+ try {
+ await props.app.updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error('Failed to select the entry location', error.message);
+ }
+ },
};
};
-const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => {
- const userInterface = bindActionCreators(userInterfaceActions, dispatch);
-
+const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => {
return {
onClose: () => props.history.dismiss(),
onViewFilterByProvider: () => props.history.push(RoutePath.filterByProvider),
- onChangeLocationScope: (scope: LocationScope) => {
- userInterface.setLocationScope(scope);
- },
onSelectExitLocation: async (relayLocation: RelayLocation) => {
// dismiss the view first
props.history.dismiss();
diff --git a/gui/src/renderer/containers/WireguardSettingsPage.tsx b/gui/src/renderer/containers/WireguardSettingsPage.tsx
index 5e6c27105e..4b0af37510 100644
--- a/gui/src/renderer/containers/WireguardSettingsPage.tsx
+++ b/gui/src/renderer/containers/WireguardSettingsPage.tsx
@@ -1,21 +1,75 @@
import { connect } from 'react-redux';
import { IpVersion } from '../../shared/daemon-rpc-types';
import log from '../../shared/logging';
-import RelaySettingsBuilder from '../../shared/relay-settings-builder';
import WireguardSettings from '../components/WireguardSettings';
import withAppContext, { IAppContext } from '../context';
+import { createWireguardRelayUpdater } from '../lib/constraint-updater';
import { IHistoryProps, withHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
import { RelaySettingsRedux } from '../redux/settings/reducers';
import { IReduxState, ReduxDispatch } from '../redux/store';
-const mapStateToProps = (state: IReduxState) => {
+const mapStateToProps = (state: IReduxState, props: IAppContext) => {
const protocolAndPort = mapRelaySettingsToProtocolAndPort(state.settings.relaySettings);
+ let wireguardMultihop = false;
+ if ('normal' in state.settings.relaySettings) {
+ wireguardMultihop = state.settings.relaySettings.normal.wireguard.useMultihop;
+ }
+
return {
wireguardMtu: state.settings.wireguard.mtu,
+ wireguardMultihop,
...protocolAndPort,
+
+ setWireguardPort: async (port?: number) => {
+ const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings)
+ .tunnel.wireguard((wireguard) => {
+ if (port) {
+ wireguard.port.exact(port);
+ } else {
+ wireguard.port.any();
+ }
+ })
+ .build();
+ try {
+ await props.app.updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error('Failed to update relay settings', error.message);
+ }
+ },
+
+ setWireguardIpVersion: async (ipVersion?: IpVersion) => {
+ const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings)
+ .tunnel.wireguard((wireguard) => {
+ if (ipVersion) {
+ wireguard.ipVersion.exact(ipVersion);
+ } else {
+ wireguard.ipVersion.any();
+ }
+ })
+ .build();
+ try {
+ await props.app.updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error('Failed to update relay settings', error.message);
+ }
+ },
+
+ setWireguardMultihop: async (enabled: boolean) => {
+ const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings)
+ .tunnel.wireguard((wireguard) => wireguard.useMultihop(enabled))
+ .build();
+ try {
+ await props.app.updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error('Failed to update WireGuard multihop settings', error.message);
+ }
+ },
};
};
@@ -46,30 +100,6 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp
props.history.pop();
},
- setWireguardRelayPortAndIpVersion: async (port?: number, ipVersion?: IpVersion) => {
- const relayUpdate = RelaySettingsBuilder.normal()
- .tunnel.wireguard((wireguard) => {
- if (port) {
- wireguard.port.exact(port);
- } else {
- wireguard.port.any();
- }
-
- if (ipVersion) {
- wireguard.ipVersion.exact(ipVersion);
- } else {
- wireguard.ipVersion.any();
- }
- })
- .build();
- try {
- await props.app.updateRelaySettings(relayUpdate);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update relay settings', error.message);
- }
- },
-
setWireguardMtu: async (mtu?: number) => {
try {
await props.app.setWireguardMtu(mtu);
diff --git a/gui/src/renderer/lib/constraint-updater.ts b/gui/src/renderer/lib/constraint-updater.ts
new file mode 100644
index 0000000000..ce9f49f376
--- /dev/null
+++ b/gui/src/renderer/lib/constraint-updater.ts
@@ -0,0 +1,36 @@
+import { RelaySettingsRedux } from '../redux/settings/reducers';
+import RelaySettingsBuilder from '../../shared/relay-settings-builder';
+
+export function createWireguardRelayUpdater(
+ relaySettings: RelaySettingsRedux,
+): ReturnType<typeof RelaySettingsBuilder['normal']> {
+ if ('normal' in relaySettings) {
+ const constraints = relaySettings.normal.wireguard;
+
+ const relayUpdate = RelaySettingsBuilder.normal().tunnel.wireguard((wireguard) => {
+ if (constraints.port === 'any') {
+ wireguard.port.any();
+ } else {
+ wireguard.port.exact(constraints.port);
+ }
+
+ if (constraints.ipVersion === 'any') {
+ wireguard.ipVersion.any();
+ } else {
+ wireguard.ipVersion.exact(constraints.ipVersion);
+ }
+
+ wireguard.useMultihop(constraints.useMultihop);
+
+ if (constraints.entryLocation === 'any') {
+ wireguard.entryLocation.any();
+ } else if (constraints.entryLocation !== undefined) {
+ wireguard.entryLocation.exact(constraints.entryLocation);
+ }
+ });
+
+ return relayUpdate;
+ } else {
+ return RelaySettingsBuilder.normal();
+ }
+}
diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts
index 31551d81ba..ffb0fd1d84 100644
--- a/gui/src/renderer/redux/connection/reducers.ts
+++ b/gui/src/renderer/redux/connection/reducers.ts
@@ -8,6 +8,7 @@ export interface IConnectionReduxState {
ipv6?: Ip;
hostname?: string;
bridgeHostname?: string;
+ entryHostname?: string;
latitude?: number;
longitude?: number;
country?: string;
@@ -21,6 +22,7 @@ const initialState: IConnectionReduxState = {
ipv6: undefined,
hostname: undefined,
bridgeHostname: undefined,
+ entryHostname: undefined,
latitude: undefined,
longitude: undefined,
country: undefined,
@@ -43,6 +45,7 @@ export default function (
longitude: action.newLocation.longitude,
hostname: action.newLocation.hostname,
bridgeHostname: action.newLocation.bridgeHostname,
+ entryHostname: action.newLocation.entryHostname,
};
case 'UPDATE_BLOCK_STATE':
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index 3e1e5cb19d..5ac19cadf6 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -27,6 +27,8 @@ export type RelaySettingsRedux =
wireguard: {
port: LiftedConstraint<number>;
ipVersion: LiftedConstraint<IpVersion>;
+ useMultihop: boolean;
+ entryLocation: LiftedConstraint<RelayLocation>;
};
};
}
@@ -62,14 +64,12 @@ export interface IRelayLocationCityRedux {
code: string;
latitude: number;
longitude: number;
- hasActiveRelays: boolean;
relays: IRelayLocationRelayRedux[];
}
export interface IRelayLocationRedux {
name: string;
code: string;
- hasActiveRelays: boolean;
cities: IRelayLocationCityRedux[];
}
@@ -161,7 +161,7 @@ const initialState: ISettingsReduxState = {
location: 'any',
tunnelProtocol: 'any',
providers: [],
- wireguard: { port: 'any', ipVersion: 'any' },
+ wireguard: { port: 'any', ipVersion: 'any', useMultihop: false, entryLocation: 'any' },
openvpn: {
port: 'any',
protocol: 'any',
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
index 88ffbf216f..e6010d42f2 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -1,5 +1,4 @@
import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
-import { LocationScope } from './reducers';
export interface IUpdateLocaleAction {
type: 'UPDATE_LOCALE';
@@ -15,11 +14,6 @@ export interface IUpdateConnectionInfoOpenAction {
type: 'TOGGLE_CONNECTION_PANEL';
}
-export interface ISetLocationScopeAction {
- type: 'SET_LOCATION_SCOPE';
- scope: LocationScope;
-}
-
export interface ISetWindowFocusedAction {
type: 'SET_WINDOW_FOCUSED';
focused: boolean;
@@ -50,7 +44,6 @@ export type UserInterfaceAction =
| IUpdateLocaleAction
| IUpdateWindowArrowPositionAction
| IUpdateConnectionInfoOpenAction
- | ISetLocationScopeAction
| ISetWindowFocusedAction
| IAddScrollPosition
| IRemoveScrollPosition
@@ -77,13 +70,6 @@ function toggleConnectionPanel(): IUpdateConnectionInfoOpenAction {
};
}
-function setLocationScope(scope: LocationScope): ISetLocationScopeAction {
- return {
- type: 'SET_LOCATION_SCOPE',
- scope,
- };
-}
-
function setWindowFocused(focused: boolean): ISetWindowFocusedAction {
return {
type: 'SET_WINDOW_FOCUSED',
@@ -126,7 +112,6 @@ export default {
updateLocale,
updateWindowArrowPosition,
toggleConnectionPanel,
- setLocationScope,
setWindowFocused,
addScrollPosition,
removeScrollPosition,
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index 5adac07937..9247dc8a5f 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -1,16 +1,10 @@
import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
import { ReduxAction } from '../store';
-export enum LocationScope {
- bridge = 0,
- relay,
-}
-
export interface IUserInterfaceReduxState {
locale: string;
arrowPosition?: number;
connectionPanelVisible: boolean;
- locationScope: LocationScope;
windowFocused: boolean;
scrollPosition: Record<string, [number, number]>;
macOsScrollbarVisibility?: MacOsScrollbarVisibility;
@@ -20,7 +14,6 @@ export interface IUserInterfaceReduxState {
const initialState: IUserInterfaceReduxState = {
locale: 'en',
connectionPanelVisible: false,
- locationScope: LocationScope.relay,
windowFocused: false,
scrollPosition: {},
macOsScrollbarVisibility: undefined,
@@ -41,9 +34,6 @@ export default function (
case 'TOGGLE_CONNECTION_PANEL':
return { ...state, connectionPanelVisible: !state.connectionPanelVisible };
- case 'SET_LOCATION_SCOPE':
- return { ...state, locationScope: action.scope };
-
case 'SET_WINDOW_FOCUSED':
return { ...state, windowFocused: action.focused };
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index 300af97660..3f026b032c 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -14,6 +14,7 @@ export interface ILocation {
mullvadExitIp: boolean;
hostname?: string;
bridgeHostname?: string;
+ entryHostname?: string;
provider?: string;
}
@@ -88,6 +89,12 @@ export interface ITunnelEndpoint {
protocol: RelayProtocol;
tunnelType: TunnelType;
proxy?: IProxyEndpoint;
+ entryEndpoint?: IEndpoint;
+}
+
+export interface IEndpoint {
+ address: string;
+ transportProtocol: RelayProtocol;
}
export interface IProxyEndpoint {
@@ -133,6 +140,8 @@ export interface IOpenVpnConstraints {
export interface IWireguardConstraints {
port: Constraint<number>;
ipVersion: Constraint<IpVersion>;
+ useMultihop: boolean;
+ entryLocation: Constraint<RelayLocation>;
}
export type TunnelProtocol = 'wireguard' | 'openvpn';
diff --git a/gui/src/shared/relay-settings-builder.ts b/gui/src/shared/relay-settings-builder.ts
index 418de10888..e4c11e5132 100644
--- a/gui/src/shared/relay-settings-builder.ts
+++ b/gui/src/shared/relay-settings-builder.ts
@@ -3,6 +3,7 @@ import {
IOpenVpnConstraints,
IpVersion,
IWireguardConstraints,
+ RelayLocation,
RelayProtocol,
RelaySettingsNormalUpdate,
RelaySettingsUpdate,
@@ -23,6 +24,8 @@ interface IOpenVPNConfigurator {
interface IWireguardConfigurator {
port: IExactOrAny<number, IWireguardConfigurator>;
ipVersion: IExactOrAny<IpVersion, IWireguardConfigurator>;
+ useMultihop: (value: boolean) => IWireguardConfigurator;
+ entryLocation: IExactOrAny<RelayLocation, IWireguardConfigurator>;
}
interface ITunnelProtocolConfigurator {
@@ -137,6 +140,22 @@ class NormalRelaySettingsBuilder {
any: () => apply('any'),
};
},
+ get useMultihop() {
+ return (useMultihop: boolean) => {
+ updateWireguard({ useMultihop });
+ return this;
+ };
+ },
+ get entryLocation() {
+ const apply = (entryLocation: Constraint<RelayLocation> | undefined) => {
+ updateWireguard({ entryLocation });
+ return this;
+ };
+ return {
+ exact: (entryLocation: RelayLocation) => apply({ only: entryLocation }),
+ any: () => apply('any'),
+ };
+ },
};
configurator(wireguardBuilder);
return this;