summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-09-10 14:35:00 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-09-12 12:50:51 +0200
commit57f5d8f246fa688a36e3138cbbcd51dd95fdf7ea (patch)
treefc85b595edbdda91e5f7e894d7ed536d0ac69ba8 /gui/src
parentef20b724201e5c374e6080298974bc8e97818cf2 (diff)
downloadmullvadvpn-57f5d8f246fa688a36e3138cbbcd51dd95fdf7ea.tar.xz
mullvadvpn-57f5d8f246fa688a36e3138cbbcd51dd95fdf7ea.zip
Add bridge selector
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/index.ts80
-rw-r--r--gui/src/renderer/app.tsx47
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx7
-rw-r--r--gui/src/renderer/components/LocationList.tsx161
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx298
-rw-r--r--gui/src/renderer/components/SelectLocationStyles.tsx2
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx57
-rw-r--r--gui/src/renderer/redux/settings/actions.ts32
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts31
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts19
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts10
-rw-r--r--gui/src/shared/daemon-rpc-types.ts27
-rw-r--r--gui/src/shared/ipc-event-channel.ts16
13 files changed, 529 insertions, 258 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 4f0454d5e2..ac583f4300 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -6,6 +6,7 @@ import * as path from 'path';
import * as uuid from 'uuid';
import {
AccountToken,
+ BridgeSettings,
BridgeState,
DaemonEvent,
IAccountData,
@@ -16,6 +17,7 @@ import {
ISettings,
IWireguardPublicKey,
KeygenEvent,
+ RelayLocation,
RelaySettings,
RelaySettingsUpdate,
TunnelState,
@@ -427,7 +429,11 @@ class ApplicationMain {
// fetch relays
try {
- this.setRelays(await this.daemonRpc.getRelayLocations(), this.settings.relaySettings);
+ this.setRelays(
+ await this.daemonRpc.getRelayLocations(),
+ this.settings.relaySettings,
+ this.settings.bridgeState,
+ );
} catch (error) {
log.error(`Failed to fetch relay locations: ${error.message}`);
@@ -533,7 +539,11 @@ class ApplicationMain {
} else if ('settings' in daemonEvent) {
this.setSettings(daemonEvent.settings);
} else if ('relayList' in daemonEvent) {
- this.setRelays(daemonEvent.relayList, this.settings.relaySettings);
+ this.setRelays(
+ daemonEvent.relayList,
+ this.settings.relaySettings,
+ this.settings.bridgeState,
+ );
} else if ('wireguardKey' in daemonEvent) {
this.handleWireguardKeygenEvent(daemonEvent.wireguardKey);
}
@@ -620,7 +630,7 @@ class ApplicationMain {
// since settings can have the relay constraints changed, the relay
// list should also be updated
- this.setRelays(this.relays, newSettings.relaySettings);
+ this.setRelays(this.relays, newSettings.relaySettings, newSettings.bridgeState);
}
private setLocation(newLocation: ILocation) {
@@ -631,16 +641,24 @@ class ApplicationMain {
}
}
- private setRelays(newRelayList: IRelayList, relaySettings: RelaySettings) {
+ private setRelays(
+ newRelayList: IRelayList,
+ relaySettings: RelaySettings,
+ bridgeState: BridgeState,
+ ) {
this.relays = newRelayList;
+
const filteredRelays = this.processRelaysForPresentation(newRelayList, relaySettings);
+ const filteredBridges = this.processBridgesForPresentation(newRelayList, bridgeState);
if (this.windowController) {
- IpcMainEventChannel.relays.notify(this.windowController.webContents, filteredRelays);
+ IpcMainEventChannel.relays.notify(this.windowController.webContents, {
+ relays: filteredRelays,
+ bridges: filteredBridges,
+ });
}
}
- //
private processRelaysForPresentation(
relayList: IRelayList,
relaySettings: RelaySettings,
@@ -672,20 +690,39 @@ class ApplicationMain {
}
return {
- countries: relayList.countries.map((country) => {
- return {
+ countries: relayList.countries.map((country) => ({
+ ...country,
+ cities: country.cities
+ .map((city) => ({
+ ...city,
+ relays: city.relays.filter(fnHasWantedTunnels),
+ }))
+ .filter((city) => city.relays.length > 0),
+ })),
+ };
+ }
+
+ private processBridgesForPresentation(
+ relayList: IRelayList,
+ bridgeState: BridgeState,
+ ): IRelayList {
+ if (bridgeState === 'on') {
+ const filteredCountries = relayList.countries
+ .map((country) => ({
...country,
cities: country.cities
- .map((city) => {
- return {
- ...city,
- relays: city.relays.filter(fnHasWantedTunnels),
- };
- })
+ .map((city) => ({
+ ...city,
+ relays: city.relays.filter((relay) => relay.bridges),
+ }))
.filter((city) => city.relays.length > 0),
- };
- }),
- };
+ }))
+ .filter((country) => country.cities.length > 0);
+
+ return { countries: filteredCountries };
+ } else {
+ return { countries: [] };
+ }
}
private setDaemonVersion(daemonVersion: string) {
@@ -893,6 +930,7 @@ class ApplicationMain {
settings: this.settings,
location: this.location,
relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings),
+ bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState),
currentVersion: this.currentVersion,
upgradeVersion: this.upgradeVersion,
guiSettings: this.guiSettings.state,
@@ -917,7 +955,15 @@ class ApplicationMain {
IpcMainEventChannel.settings.handleUpdateRelaySettings((update: RelaySettingsUpdate) =>
this.daemonRpc.updateRelaySettings(update),
);
+ IpcMainEventChannel.settings.handleUpdateBridgeLocation((location: RelayLocation) => {
+ const bridgeSettings: BridgeSettings = {
+ normal: {
+ location: { only: location },
+ },
+ };
+ return this.daemonRpc.setBridgeSettings(bridgeSettings);
+ });
IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => {
return this.setAutoStart(autoStart);
});
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 14bb45ff2e..6f54281f3c 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -16,7 +16,7 @@ import AppRoutes from './routes';
import accountActions from './redux/account/actions';
import connectionActions from './redux/connection/actions';
import settingsActions from './redux/settings/actions';
-import { IWgKey } from './redux/settings/reducers';
+import { IRelayLocationRedux, IWgKey } from './redux/settings/reducers';
import configureStore from './redux/store';
import userInterfaceActions from './redux/userinterface/actions';
import versionActions from './redux/version/actions';
@@ -24,11 +24,12 @@ import versionActions from './redux/version/actions';
import { IAppUpgradeInfo, ICurrentAppVersionInfo } from '../main';
import { cities, countries, loadTranslations, messages, relayLocations } from '../shared/gettext';
import { IGuiSettingsState } from '../shared/gui-settings-state';
-import { IpcRendererEventChannel } from '../shared/ipc-event-channel';
+import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-channel';
import { getRendererLogFile, setupLogging } from '../shared/logging';
import {
AccountToken,
+ BridgeSettings,
BridgeState,
IAccountData,
ILocation,
@@ -37,6 +38,7 @@ import {
IWireguardPublicKey,
KeygenEvent,
liftConstraint,
+ RelayLocation,
RelaySettings,
RelaySettingsUpdate,
TunnelState,
@@ -111,8 +113,9 @@ export default class AppRenderer {
this.setLocation(newLocation);
});
- IpcRendererEventChannel.relays.listen((newRelays: IRelayList) => {
- this.setRelays(newRelays);
+ IpcRendererEventChannel.relays.listen((relayListPair: IRelayListPair) => {
+ this.setRelays(relayListPair.relays);
+ this.setBridges(relayListPair.bridges);
});
IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
@@ -158,6 +161,7 @@ export default class AppRenderer {
}
this.setRelays(initialState.relays);
+ this.setBridges(initialState.bridges);
this.setCurrentVersion(initialState.currentVersion);
this.setUpgradeVersion(initialState.upgradeVersion);
this.setGuiSettings(initialState.guiSettings);
@@ -249,6 +253,10 @@ export default class AppRenderer {
return IpcRendererEventChannel.settings.updateRelaySettings(relaySettings);
}
+ public updateBridgeLocation(bridgeLocation: RelayLocation) {
+ return IpcRendererEventChannel.settings.updateBridgeLocation(bridgeLocation);
+ }
+
public async removeAccountFromHistory(accountToken: AccountToken): Promise<void> {
return IpcRendererEventChannel.accountHistory.removeItem(accountToken);
}
@@ -371,6 +379,22 @@ export default class AppRenderer {
}
}
+ private setBridgeSettings(bridgeSettings: BridgeSettings) {
+ const actions = this.reduxActions;
+
+ if ('normal' in bridgeSettings) {
+ actions.settings.updateBridgeSettings({
+ normal: {
+ location: liftConstraint(bridgeSettings.normal.location),
+ },
+ });
+ } else if ('custom' in bridgeSettings) {
+ actions.settings.updateBridgeSettings({
+ custom: bridgeSettings.custom,
+ });
+ }
+ }
+
private async onDaemonConnected() {
// Filter out the calls coming from IPC events arriving right after the constructor finished
// execution.
@@ -474,6 +498,7 @@ export default class AppRenderer {
reduxSettings.updateBridgeState(newSettings.bridgeState);
this.setRelaySettings(newSettings.relaySettings);
+ this.setBridgeSettings(newSettings.bridgeSettings);
if (newSettings.accountToken) {
reduxAccount.updateAccountToken(newSettings.accountToken);
@@ -525,8 +550,8 @@ export default class AppRenderer {
this.reduxActions.connection.newLocation(location);
}
- private setRelays(relayList: IRelayList) {
- const locations = relayList.countries
+ private covertRelayListToLocationList(relayList: IRelayList): IRelayLocationRedux[] {
+ return relayList.countries
.map((country) => ({
name: country.name,
code: country.code,
@@ -543,10 +568,20 @@ export default class AppRenderer {
.sort((cityA, cityB) => cityA.name.localeCompare(cityB.name)),
}))
.sort((countryA, countryB) => countryA.name.localeCompare(countryB.name));
+ }
+
+ private setRelays(relayList: IRelayList) {
+ const locations = this.covertRelayListToLocationList(relayList);
this.reduxActions.settings.updateRelayLocations(locations);
}
+ private setBridges(relayList: IRelayList) {
+ const locations = this.covertRelayListToLocationList(relayList);
+
+ this.reduxActions.settings.updateBridgeLocations(locations);
+ }
+
private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
this.reduxActions.version.updateVersion(versionInfo.gui, versionInfo.isConsistent);
}
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
index d909dd79dd..8928e9891b 100644
--- a/gui/src/renderer/components/CustomScrollbars.tsx
+++ b/gui/src/renderer/components/CustomScrollbars.tsx
@@ -56,6 +56,13 @@ export default class CustomScrollbars extends React.Component<IProps, IState> {
private thumbRef = React.createRef<HTMLDivElement>();
private autoHideTimer?: NodeJS.Timeout;
+ public scrollToTop() {
+ const scrollable = this.scrollableRef.current;
+ if (scrollable) {
+ scrollable.scrollTop = 0;
+ }
+ }
+
public scrollTo(x: number, y: number) {
const scrollable = this.scrollableRef.current;
if (scrollable) {
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
new file mode 100644
index 0000000000..418da66e94
--- /dev/null
+++ b/gui/src/renderer/components/LocationList.tsx
@@ -0,0 +1,161 @@
+import * as React from 'react';
+import { Component, View } from 'reactxp';
+import {
+ compareRelayLocation,
+ compareRelayLocationLoose,
+ RelayLocation,
+ relayLocationComponents,
+} from '../../shared/daemon-rpc-types';
+import { countries, relayLocations } from '../../shared/gettext';
+import { IRelayLocationRedux } from '../redux/settings/reducers';
+import CityRow from './CityRow';
+import CountryRow from './CountryRow';
+import RelayRow from './RelayRow';
+
+interface IProps {
+ relayLocations: IRelayLocationRedux[];
+ selectedLocation?: RelayLocation;
+ onSelect: (location: RelayLocation) => void;
+}
+
+interface IState {
+ selectedLocation?: RelayLocation;
+ expandedItems: RelayLocation[];
+}
+
+interface ICommonCellProps<T> {
+ location: RelayLocation;
+ selected: boolean;
+ ref?: React.RefObject<T>;
+}
+
+export default class LocationList extends Component<IProps, IState> {
+ public selectedCell = React.createRef<React.ReactNode>();
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ expandedItems: props.selectedLocation ? expandRelayLocation(props.selectedLocation) : [],
+ selectedLocation: props.selectedLocation,
+ };
+ }
+
+ public componentDidUpdate(prevProps: IProps, _prevState: IState) {
+ if (this.props.selectedLocation !== prevProps.selectedLocation) {
+ this.setState({ selectedLocation: this.props.selectedLocation });
+ }
+ }
+
+ public render() {
+ return (
+ <View>
+ {this.props.relayLocations.map((relayCountry) => {
+ const countryLocation: RelayLocation = { country: relayCountry.code };
+
+ return (
+ <CountryRow
+ key={getLocationKey(countryLocation)}
+ name={countries.gettext(relayCountry.name)}
+ hasActiveRelays={relayCountry.hasActiveRelays}
+ expanded={this.isExpanded(countryLocation)}
+ onSelect={this.handleSelection}
+ onExpand={this.handleExpand}
+ {...this.getCommonCellProps(countryLocation)}>
+ {relayCountry.cities.map((relayCity) => {
+ const cityLocation: RelayLocation = {
+ city: [relayCountry.code, relayCity.code],
+ };
+
+ return (
+ <CityRow
+ key={getLocationKey(cityLocation)}
+ name={relayLocations.gettext(relayCity.name)}
+ hasActiveRelays={relayCity.hasActiveRelays}
+ expanded={this.isExpanded(cityLocation)}
+ onSelect={this.handleSelection}
+ onExpand={this.handleExpand}
+ {...this.getCommonCellProps(cityLocation)}>
+ {relayCity.relays.map((relay) => {
+ const relayLocation: RelayLocation = {
+ hostname: [relayCountry.code, relayCity.code, relay.hostname],
+ };
+
+ return (
+ <RelayRow
+ key={getLocationKey(relayLocation)}
+ hostname={relay.hostname}
+ onSelect={this.handleSelection}
+ {...this.getCommonCellProps(relayLocation)}
+ />
+ );
+ })}
+ </CityRow>
+ );
+ })}
+ </CountryRow>
+ );
+ })}
+ </View>
+ );
+ }
+
+ private isExpanded(relayLocation: RelayLocation) {
+ return this.state.expandedItems.some((location) =>
+ compareRelayLocation(location, relayLocation),
+ );
+ }
+
+ private isSelected(relayLocation: RelayLocation) {
+ return compareRelayLocationLoose(this.state.selectedLocation, relayLocation);
+ }
+
+ private handleSelection = (location: RelayLocation) => {
+ if (!compareRelayLocationLoose(this.state.selectedLocation, location)) {
+ this.setState({ selectedLocation: location }, () => {
+ this.props.onSelect(location);
+ });
+ }
+ };
+
+ private handleExpand = (location: RelayLocation, expand: boolean) => {
+ this.setState((state) => {
+ const expandedItems = state.expandedItems.filter(
+ (item) => !compareRelayLocation(item, location),
+ );
+
+ if (expand) {
+ expandedItems.push(location);
+ }
+
+ return {
+ ...state,
+ expandedItems,
+ };
+ });
+ };
+
+ private getCommonCellProps<T>(location: RelayLocation): ICommonCellProps<T> {
+ const selected = this.isSelected(location);
+ const ref = selected ? (this.selectedCell as React.RefObject<T>) : undefined;
+
+ return { ref, selected, location };
+ }
+}
+
+function expandRelayLocation(location: RelayLocation): RelayLocation[] {
+ const expandedItems: RelayLocation[] = [];
+
+ if ('city' in location) {
+ expandedItems.push({ country: location.city[0] });
+ } else if ('hostname' in location) {
+ expandedItems.push({ country: location.hostname[0] });
+ expandedItems.push({ city: [location.hostname[0], location.hostname[1]] });
+ }
+
+ return expandedItems;
+}
+
+function getLocationKey(location: RelayLocation): string {
+ return relayLocationComponents(location).join('-');
+}
diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx
index 73f075764a..2f0a162b51 100644
--- a/gui/src/renderer/components/SelectLocation.tsx
+++ b/gui/src/renderer/components/SelectLocation.tsx
@@ -1,9 +1,13 @@
import * as React from 'react';
import ReactDOM from 'react-dom';
import { Component, View } from 'reactxp';
-import { countries, messages, relayLocations } from '../../shared/gettext';
+import { RelayLocation } from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { IRelayLocationRedux } from '../redux/settings/reducers';
+import { LocationScope } from '../redux/userinterface/reducers';
import CustomScrollbars from './CustomScrollbars';
import { Container, Layout } from './Layout';
+import LocationList from './LocationList';
import {
CloseBarItem,
NavigationBar,
@@ -18,102 +22,31 @@ import {
import styles from './SelectLocationStyles';
import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import CityRow from './CityRow';
-import CountryRow from './CountryRow';
-import RelayRow from './RelayRow';
-
-import {
- compareRelayLocation,
- compareRelayLocationLoose,
- RelayLocation,
-} from '../../shared/daemon-rpc-types';
-import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers';
-
interface IProps {
- relaySettings: RelaySettingsRedux;
+ locationScope: LocationScope;
+ selectedExitLocation?: RelayLocation;
+ selectedBridgeLocation?: RelayLocation;
relayLocations: IRelayLocationRedux[];
+ bridgeLocations: IRelayLocationRedux[];
+ allowBridgeSelection: boolean;
onClose: () => void;
- onSelect: (location: RelayLocation) => void;
-}
-
-interface IState {
- locationScope: LocationScope;
- selectedLocation?: RelayLocation;
- expandedItems: RelayLocation[];
-}
-
-enum LocationScope {
- relay = 0,
- bridge,
+ onChangeLocationScope: (location: LocationScope) => void;
+ onSelectExitLocation: (location: RelayLocation) => void;
+ onSelectBridgeLocation: (location: RelayLocation) => void;
}
-export default class SelectLocation extends Component<IProps, IState> {
- public state: IState = {
- locationScope: LocationScope.relay,
- expandedItems: [],
- };
- private selectedCellRef = React.createRef<React.ReactNode>();
- private scrollViewRef = React.createRef<CustomScrollbars>();
-
- constructor(props: IProps) {
- super(props);
-
- if ('normal' in this.props.relaySettings) {
- const location = this.props.relaySettings.normal.location;
-
- if (typeof location === 'object') {
- const expandedItems: RelayLocation[] = [];
-
- if ('city' in location) {
- expandedItems.push({ country: location.city[0] });
- } else if ('hostname' in location) {
- expandedItems.push({ country: location.hostname[0] });
- expandedItems.push({ city: [location.hostname[0], location.hostname[1]] });
- }
-
- this.state = {
- ...this.state,
- selectedLocation: location,
- expandedItems,
- };
- }
- }
- }
-
- public componentDidUpdate(oldProps: IProps) {
- const currentLocation = this.state.selectedLocation;
- let newLocation =
- 'normal' in this.props.relaySettings ? this.props.relaySettings.normal.location : undefined;
-
- let oldLocation =
- 'normal' in oldProps.relaySettings ? oldProps.relaySettings.normal.location : undefined;
-
- if (newLocation === 'any') {
- newLocation = undefined;
- }
-
- if (oldLocation === 'any') {
- oldLocation = undefined;
- }
+export default class SelectLocation extends Component<IProps> {
+ private scrollView = React.createRef<CustomScrollbars>();
+ private exitLocationList = React.createRef<LocationList>();
+ private bridgeLocationList = React.createRef<LocationList>();
- if (
- !compareRelayLocationLoose(oldLocation, newLocation) &&
- !compareRelayLocationLoose(currentLocation, newLocation)
- ) {
- this.setState({ selectedLocation: newLocation });
- }
+ public componentDidMount() {
+ this.scrollToSelectedCell();
}
- public componentDidMount() {
- // restore scroll to the selected cell
- const cell = this.selectedCellRef.current;
- const scrollView = this.scrollViewRef.current;
- if (scrollView && cell) {
- // TODO: Fix the browser specific code
- const cellDOMNode = ReactDOM.findDOMNode(cell as Element);
- if (cellDOMNode instanceof HTMLElement) {
- scrollView.scrollToElement(cellDOMNode, 'middle');
- }
+ public componentDidUpdate(prevProps: IProps) {
+ if (this.props.locationScope !== prevProps.locationScope) {
+ this.scrollToSelectedCell();
}
}
@@ -131,81 +64,60 @@ export default class SelectLocation extends Component<IProps, IState> {
</TitleBarItem>
</NavigationBar>
<StickyContentContainer style={styles.container}>
- <NavigationScrollbars ref={this.scrollViewRef}>
+ <NavigationScrollbars ref={this.scrollView}>
<View style={styles.content}>
- <SettingsHeader style={styles.header}>
+ <SettingsHeader
+ style={this.props.allowBridgeSelection ? styles.headerWithScope : undefined}>
<HeaderTitle>
{messages.pgettext('select-location-view', 'Select location')}
</HeaderTitle>
<HeaderSubTitle>
- {messages.pgettext(
- 'select-location-view',
- 'While connected, your real location is masked with a private and secure location in the selected region',
- )}
+ {this.props.locationScope === LocationScope.relay
+ ? messages.pgettext(
+ 'select-location-view',
+ 'While connected, your real location is masked with a private and secure location in the selected region',
+ )
+ : 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>
</SettingsHeader>
- <StickyContentHolder style={styles.stickyHolder}>
- <View style={styles.stickyContent}>
- <ScopeBar
- defaultSelectedIndex={this.state.locationScope}
- onChange={this.onChangeScope}>
- <ScopeBarItem>
- {messages.pgettext('select-location-nav', 'Exit')}
- </ScopeBarItem>
- <ScopeBarItem>
- {messages.pgettext('select-location-nav', 'Entry')}
- </ScopeBarItem>
- </ScopeBar>
- </View>
- </StickyContentHolder>
-
- {this.props.relayLocations.map((relayCountry) => {
- const countryLocation: RelayLocation = { country: relayCountry.code };
-
- return (
- <CountryRow
- key={getLocationKey(countryLocation)}
- name={countries.gettext(relayCountry.name)}
- hasActiveRelays={relayCountry.hasActiveRelays}
- expanded={this.isExpanded(countryLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- {...this.getCommonCellProps(countryLocation)}>
- {relayCountry.cities.map((relayCity) => {
- const cityLocation: RelayLocation = {
- city: [relayCountry.code, relayCity.code],
- };
+ {this.props.allowBridgeSelection && (
+ <StickyContentHolder style={styles.stickyHolder}>
+ <View style={styles.stickyContent}>
+ <ScopeBar
+ defaultSelectedIndex={this.props.locationScope}
+ onChange={this.props.onChangeLocationScope}>
+ <ScopeBarItem>
+ {messages.pgettext('select-location-nav', 'Entry')}
+ </ScopeBarItem>
+ <ScopeBarItem>
+ {messages.pgettext('select-location-nav', 'Exit')}
+ </ScopeBarItem>
+ </ScopeBar>
+ </View>
+ </StickyContentHolder>
+ )}
- return (
- <CityRow
- key={getLocationKey(cityLocation)}
- name={relayLocations.gettext(relayCity.name)}
- hasActiveRelays={relayCity.hasActiveRelays}
- expanded={this.isExpanded(cityLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- {...this.getCommonCellProps(cityLocation)}>
- {relayCity.relays.map((relay) => {
- const relayLocation: RelayLocation = {
- hostname: [relayCountry.code, relayCity.code, relay.hostname],
- };
-
- return (
- <RelayRow
- key={getLocationKey(relayLocation)}
- hostname={relay.hostname}
- onSelect={this.handleSelection}
- {...this.getCommonCellProps(relayLocation)}
- />
- );
- })}
- </CityRow>
- );
- })}
- </CountryRow>
- );
- })}
+ {this.props.locationScope === LocationScope.relay ? (
+ <LocationList
+ key={'exit-locations'}
+ ref={this.exitLocationList}
+ selectedLocation={this.props.selectedExitLocation}
+ relayLocations={this.props.relayLocations}
+ onSelect={this.props.onSelectExitLocation}
+ />
+ ) : (
+ <LocationList
+ key={'bridge-locations'}
+ ref={this.bridgeLocationList}
+ selectedLocation={this.props.selectedBridgeLocation}
+ relayLocations={this.props.bridgeLocations}
+ onSelect={this.props.onSelectBridgeLocation}
+ />
+ )}
</View>
</NavigationScrollbars>
</StickyContentContainer>
@@ -216,65 +128,27 @@ export default class SelectLocation extends Component<IProps, IState> {
);
}
- private onChangeScope = (selectedIndex: number) => {
- this.setState({ locationScope: selectedIndex });
- };
-
- private isExpanded(relayLocation: RelayLocation) {
- return this.state.expandedItems.some((location) =>
- compareRelayLocation(location, relayLocation),
- );
- }
-
- private isSelected(relayLocation: RelayLocation) {
- return compareRelayLocationLoose(this.state.selectedLocation, relayLocation);
- }
-
- private handleSelection = (location: RelayLocation) => {
- if (!compareRelayLocationLoose(this.state.selectedLocation, location)) {
- this.setState({ selectedLocation: location }, () => {
- this.props.onSelect(location);
- });
- }
- };
+ private scrollToSelectedCell() {
+ const ref =
+ this.props.locationScope === LocationScope.relay
+ ? this.exitLocationList
+ : this.bridgeLocationList;
+ const locationList = ref.current;
- private handleExpand = (location: RelayLocation, expand: boolean) => {
- this.setState((state) => {
- const expandedItems = state.expandedItems.filter(
- (item) => !compareRelayLocation(item, location),
- );
+ if (locationList) {
+ const cell = locationList.selectedCell.current;
+ const scrollView = this.scrollView.current;
- if (expand) {
- expandedItems.push(location);
+ if (scrollView) {
+ if (cell) {
+ const cellDOMNode = ReactDOM.findDOMNode(cell as Element);
+ if (cellDOMNode instanceof HTMLElement) {
+ scrollView.scrollToElement(cellDOMNode, 'middle');
+ }
+ } else {
+ scrollView.scrollToTop();
+ }
}
-
- return {
- ...state,
- expandedItems,
- };
- });
- };
-
- private getCommonCellProps<T>(
- location: RelayLocation,
- ): { location: RelayLocation; selected: boolean; ref?: React.RefObject<T> } {
- const selected = this.isSelected(location);
- const ref = selected ? (this.selectedCellRef as React.RefObject<T>) : undefined;
-
- return { ref, selected, location };
- }
-}
-
-function getLocationKey(location: RelayLocation): string {
- const components: string[] = [];
-
- if ('city' in location) {
- components.push(...location.city);
- } else if ('country' in location) {
- components.push(location.country);
- } else if ('hostname' in location) {
- components.push(...location.hostname);
+ }
}
-
- return ([] as string[]).concat(components).join('-');
}
diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/SelectLocationStyles.tsx
index ef4f01ee4e..bc326bb4a7 100644
--- a/gui/src/renderer/components/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/SelectLocationStyles.tsx
@@ -13,7 +13,7 @@ export default {
content: Styles.createViewStyle({
overflow: 'visible',
}),
- header: Styles.createViewStyle({
+ headerWithScope: Styles.createViewStyle({
paddingBottom: 4,
}),
stickyHolder: Styles.createViewStyle({
diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx
index a517c34b3f..24bae9f24d 100644
--- a/gui/src/renderer/containers/SelectLocationPage.tsx
+++ b/gui/src/renderer/containers/SelectLocationPage.tsx
@@ -5,19 +5,54 @@ import { bindActionCreators } from 'redux';
import { RelayLocation } from '../../shared/daemon-rpc-types';
import SelectLocation from '../components/SelectLocation';
import RelaySettingsBuilder from '../lib/relay-settings-builder';
+import userInterfaceActions from '../redux/userinterface/actions';
+import { LocationScope } from '../redux/userinterface/reducers';
import { IReduxState, ReduxDispatch } from '../redux/store';
import { ISharedRouteProps } from '../routes';
-const mapStateToProps = (state: IReduxState) => ({
- relaySettings: state.settings.relaySettings,
- relayLocations: state.settings.relayLocations,
-});
+const mapStateToProps = (state: IReduxState) => {
+ let selectedExitLocation: RelayLocation | undefined;
+ let selectedBridgeLocation: RelayLocation | undefined;
+
+ if ('normal' in state.settings.relaySettings) {
+ const exitLocation = state.settings.relaySettings.normal.location;
+ if (exitLocation !== 'any') {
+ selectedExitLocation = exitLocation;
+ }
+ }
+
+ if ('normal' in state.settings.bridgeSettings) {
+ const bridgeLocation = state.settings.bridgeSettings.normal.location;
+ if (bridgeLocation !== 'any') {
+ selectedBridgeLocation = bridgeLocation;
+ }
+ }
+
+ const allowBridgeSelection = state.settings.bridgeState === 'on';
+ const locationScope = allowBridgeSelection
+ ? state.userInterface.locationScope
+ : LocationScope.relay;
+
+ return {
+ selectedExitLocation,
+ selectedBridgeLocation,
+ relayLocations: state.settings.relayLocations,
+ bridgeLocations: state.settings.bridgeLocations,
+ locationScope,
+ allowBridgeSelection,
+ };
+};
const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) => {
const history = bindActionCreators({ goBack }, dispatch);
+ const userInterface = bindActionCreators(userInterfaceActions, dispatch);
+
return {
onClose: () => history.goBack(),
- onSelect: async (relayLocation: RelayLocation) => {
+ onChangeLocationScope: (scope: LocationScope) => {
+ userInterface.setLocationScope(scope);
+ },
+ onSelectExitLocation: async (relayLocation: RelayLocation) => {
// dismiss the view first
history.goBack();
@@ -29,7 +64,17 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: ISharedRouteProps) =
await props.app.updateRelaySettings(relayUpdate);
await props.app.connectTunnel();
} catch (e) {
- log.error(`Failed to select server: ${e.message}`);
+ log.error(`Failed to select the exit location: ${e.message}`);
+ }
+ },
+ onSelectBridgeLocation: async (bridgeLocation: RelayLocation) => {
+ // dismiss the view first
+ history.goBack();
+
+ try {
+ await props.app.updateBridgeLocation(bridgeLocation);
+ } catch (e) {
+ log.error(`Failed to select the bridge location: ${e.message}`);
}
},
};
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
index a7c766e7fd..6fee072cfd 100644
--- a/gui/src/renderer/redux/settings/actions.ts
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -1,6 +1,6 @@
import { BridgeState, IWireguardPublicKey, KeygenEvent } from '../../../shared/daemon-rpc-types';
import { IGuiSettingsState } from '../../../shared/gui-settings-state';
-import { IRelayLocationRedux, IWgKey, RelaySettingsRedux } from './reducers';
+import { BridgeSettingsRedux, IRelayLocationRedux, IWgKey, RelaySettingsRedux } from './reducers';
export interface IUpdateGuiSettingsAction {
type: 'UPDATE_GUI_SETTINGS';
@@ -17,6 +17,11 @@ export interface IUpdateRelayLocationsAction {
relayLocations: IRelayLocationRedux[];
}
+export interface IUpdateBridgeLocationsAction {
+ type: 'UPDATE_BRIDGE_LOCATIONS';
+ bridgeLocations: IRelayLocationRedux[];
+}
+
export interface IUpdateAllowLanAction {
type: 'UPDATE_ALLOW_LAN';
allowLan: boolean;
@@ -32,6 +37,11 @@ export interface IUpdateBlockWhenDisconnectedAction {
blockWhenDisconnected: boolean;
}
+export interface IUpdateBridgeSettingsAction {
+ type: 'UPDATE_BRIDGE_SETTINGS';
+ bridgeSettings: BridgeSettingsRedux;
+}
+
export interface IUpdateBridgeStateAction {
type: 'UPDATE_BRIDGE_STATE';
bridgeState: BridgeState;
@@ -81,9 +91,11 @@ export type SettingsAction =
| IUpdateGuiSettingsAction
| IUpdateRelayAction
| IUpdateRelayLocationsAction
+ | IUpdateBridgeLocationsAction
| IUpdateAllowLanAction
| IUpdateEnableIpv6Action
| IUpdateBlockWhenDisconnectedAction
+ | IUpdateBridgeSettingsAction
| IUpdateBridgeStateAction
| IUpdateOpenVpnMssfixAction
| IUpdateAutoStartAction
@@ -115,6 +127,15 @@ function updateRelayLocations(relayLocations: IRelayLocationRedux[]): IUpdateRel
};
}
+function updateBridgeLocations(
+ bridgeLocations: IRelayLocationRedux[],
+): IUpdateBridgeLocationsAction {
+ return {
+ type: 'UPDATE_BRIDGE_LOCATIONS',
+ bridgeLocations,
+ };
+}
+
function updateAllowLan(allowLan: boolean): IUpdateAllowLanAction {
return {
type: 'UPDATE_ALLOW_LAN',
@@ -138,6 +159,13 @@ function updateBlockWhenDisconnected(
};
}
+function updateBridgeSettings(bridgeSettings: BridgeSettingsRedux): IUpdateBridgeSettingsAction {
+ return {
+ type: 'UPDATE_BRIDGE_SETTINGS',
+ bridgeSettings,
+ };
+}
+
function updateBridgeState(bridgeState: BridgeState): IUpdateBridgeStateAction {
return {
type: 'UPDATE_BRIDGE_STATE',
@@ -211,9 +239,11 @@ export default {
updateGuiSettings,
updateRelay,
updateRelayLocations,
+ updateBridgeLocations,
updateAllowLan,
updateEnableIpv6,
updateBlockWhenDisconnected,
+ updateBridgeSettings,
updateBridgeState,
updateOpenVpnMssfix,
updateAutoStart,
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index ff7d531652..80cc2ee5c9 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -3,6 +3,7 @@ import {
BridgeState,
KeygenEvent,
LiftedConstraint,
+ ProxySettings,
RelayLocation,
RelayProtocol,
TunnelProtocol,
@@ -32,6 +33,16 @@ export type RelaySettingsRedux =
};
};
+export type BridgeSettingsRedux =
+ | {
+ normal: {
+ location: LiftedConstraint<RelayLocation>;
+ };
+ }
+ | {
+ custom: ProxySettings;
+ };
+
export interface IRelayLocationRelayRedux {
hostname: string;
ipv4AddrIn: string;
@@ -108,8 +119,10 @@ export interface ISettingsReduxState {
guiSettings: IGuiSettingsState;
relaySettings: RelaySettingsRedux;
relayLocations: IRelayLocationRedux[];
+ bridgeLocations: IRelayLocationRedux[];
allowLan: boolean;
enableIpv6: boolean;
+ bridgeSettings: BridgeSettingsRedux;
bridgeState: BridgeState;
blockWhenDisconnected: boolean;
openVpn: {
@@ -138,8 +151,14 @@ const initialState: ISettingsReduxState = {
},
},
relayLocations: [],
+ bridgeLocations: [],
allowLan: false,
enableIpv6: true,
+ bridgeSettings: {
+ normal: {
+ location: 'any',
+ },
+ },
bridgeState: 'auto',
blockWhenDisconnected: false,
openVpn: {},
@@ -171,6 +190,12 @@ export default function(
relayLocations: action.relayLocations,
};
+ case 'UPDATE_BRIDGE_LOCATIONS':
+ return {
+ ...state,
+ bridgeLocations: action.bridgeLocations,
+ };
+
case 'UPDATE_ALLOW_LAN':
return {
...state,
@@ -204,6 +229,12 @@ export default function(
autoStart: action.autoStart,
};
+ case 'UPDATE_BRIDGE_SETTINGS':
+ return {
+ ...state,
+ bridgeSettings: action.bridgeSettings,
+ };
+
case 'UPDATE_BRIDGE_STATE':
return {
...state,
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
index 0af66cd365..f9e2fff444 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -1,3 +1,5 @@
+import { LocationScope } from './reducers';
+
export interface IUpdateWindowArrowPositionAction {
type: 'UPDATE_WINDOW_ARROW_POSITION';
arrowPosition: number;
@@ -7,9 +9,15 @@ export interface IUpdateConnectionInfoOpenAction {
type: 'TOGGLE_CONNECTION_PANEL';
}
+export interface ISetLocationScopeAction {
+ type: 'SET_LOCATION_SCOPE';
+ scope: LocationScope;
+}
+
export type UserInterfaceAction =
| IUpdateWindowArrowPositionAction
- | IUpdateConnectionInfoOpenAction;
+ | IUpdateConnectionInfoOpenAction
+ | ISetLocationScopeAction;
function updateWindowArrowPosition(arrowPosition: number): IUpdateWindowArrowPositionAction {
return {
@@ -24,4 +32,11 @@ function toggleConnectionPanel(): IUpdateConnectionInfoOpenAction {
};
}
-export default { updateWindowArrowPosition, toggleConnectionPanel };
+function setLocationScope(scope: LocationScope): ISetLocationScopeAction {
+ return {
+ type: 'SET_LOCATION_SCOPE',
+ scope,
+ };
+}
+
+export default { updateWindowArrowPosition, toggleConnectionPanel, setLocationScope };
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index c6e7aafde8..729d5194b6 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -1,12 +1,19 @@
import { ReduxAction } from '../store';
+export enum LocationScope {
+ bridge = 0,
+ relay,
+}
+
export interface IUserInterfaceReduxState {
arrowPosition?: number;
connectionPanelVisible: boolean;
+ locationScope: LocationScope;
}
const initialState: IUserInterfaceReduxState = {
connectionPanelVisible: false,
+ locationScope: LocationScope.relay,
};
export default function(
@@ -20,6 +27,9 @@ export default function(
case 'TOGGLE_CONNECTION_PANEL':
return { ...state, connectionPanelVisible: !state.connectionPanelVisible };
+ case 'SET_LOCATION_SCOPE':
+ return { ...state, locationScope: action.scope };
+
default:
return state;
}
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index c2368364ac..86dd5ab9b2 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -327,17 +327,22 @@ export function parseSocketAddress(socketAddrStr: string): ISocketAddress {
return socketAddress;
}
-export function compareRelayLocation(lhs: RelayLocation, rhs: RelayLocation) {
- if ('country' in lhs && 'country' in rhs && lhs.country && rhs.country) {
- return lhs.country === rhs.country;
- } else if ('city' in lhs && 'city' in rhs && lhs.city && rhs.city) {
- return lhs.city[0] === rhs.city[0] && lhs.city[1] === rhs.city[1];
- } else if ('hostname' in lhs && 'hostname' in rhs && lhs.hostname && rhs.hostname) {
- return (
- lhs.hostname[0] === rhs.hostname[0] &&
- lhs.hostname[1] === rhs.hostname[1] &&
- lhs.hostname[2] === rhs.hostname[2]
- );
+export function relayLocationComponents(location: RelayLocation): string[] {
+ if ('country' in location) {
+ return [location.country];
+ } else if ('city' in location) {
+ return location.city;
+ } else {
+ return location.hostname;
+ }
+}
+
+export function compareRelayLocation(lhs: RelayLocation, rhs: RelayLocation): boolean {
+ const lhsComponents = relayLocationComponents(lhs);
+ const rhsComponents = relayLocationComponents(rhs);
+
+ if (lhsComponents.length === rhsComponents.length) {
+ return lhsComponents.every((value, index) => value === rhsComponents[index]);
} else {
return false;
}
diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts
index 7bdeb6e759..3df81c2eea 100644
--- a/gui/src/shared/ipc-event-channel.ts
+++ b/gui/src/shared/ipc-event-channel.ts
@@ -15,6 +15,7 @@ import {
ISettings,
IWireguardPublicKey,
KeygenEvent,
+ RelayLocation,
RelaySettingsUpdate,
TunnelState,
} from './daemon-rpc-types';
@@ -29,12 +30,18 @@ export interface IAppStateSnapshot {
settings: ISettings;
location?: ILocation;
relays: IRelayList;
+ bridges: IRelayList;
currentVersion: ICurrentAppVersionInfo;
upgradeVersion: IAppUpgradeInfo;
guiSettings: IGuiSettingsState;
wireguardPublicKey?: IWireguardPublicKey;
}
+export interface IRelayListPair {
+ relays: IRelayList;
+ bridges: IRelayList;
+}
+
interface ISender<T> {
notify(webContents: WebContents, value: T): void;
}
@@ -64,6 +71,7 @@ interface ISettingsMethods extends IReceiver<ISettings> {
setBridgeState(state: BridgeState): Promise<void>;
setOpenVpnMssfix(mssfix?: number): Promise<void>;
updateRelaySettings(update: RelaySettingsUpdate): Promise<void>;
+ updateBridgeLocation(location: RelayLocation): Promise<void>;
}
interface ISettingsHandlers extends ISender<ISettings> {
@@ -73,6 +81,7 @@ interface ISettingsHandlers extends ISender<ISettings> {
handleBridgeState(fn: (state: BridgeState) => Promise<void>): void;
handleOpenVpnMssfix(fn: (mssfix?: number) => Promise<void>): void;
handleUpdateRelaySettings(fn: (update: RelaySettingsUpdate) => Promise<void>): void;
+ handleUpdateBridgeLocation(fn: (location: RelayLocation) => Promise<void>): void;
}
interface IGuiSettingsMethods extends IReceiver<IGuiSettingsState> {
@@ -145,6 +154,7 @@ const SET_BLOCK_WHEN_DISCONNECTED = 'set-block-when-disconnected';
const SET_BRIDGE_STATE = 'set-bridge-state';
const SET_OPENVPN_MSSFIX = 'set-openvpn-mssfix';
const UPDATE_RELAY_SETTINGS = 'update-relay-settings';
+const UPDATE_BRIDGE_LOCATION = 'update-bridge-location';
const LOCATION_CHANGED = 'location-changed';
const RELAYS_CHANGED = 'relays-changed';
@@ -213,13 +223,14 @@ export class IpcRendererEventChannel {
setBridgeState: requestSender(SET_BRIDGE_STATE),
setOpenVpnMssfix: requestSender(SET_OPENVPN_MSSFIX),
updateRelaySettings: requestSender(UPDATE_RELAY_SETTINGS),
+ updateBridgeLocation: requestSender(UPDATE_BRIDGE_LOCATION),
};
public static location: IReceiver<ILocation> = {
listen: listen(LOCATION_CHANGED),
};
- public static relays: IReceiver<IRelayList> = {
+ public static relays: IReceiver<IRelayListPair> = {
listen: listen(RELAYS_CHANGED),
};
@@ -302,9 +313,10 @@ export class IpcMainEventChannel {
handleBridgeState: requestHandler(SET_BRIDGE_STATE),
handleOpenVpnMssfix: requestHandler(SET_OPENVPN_MSSFIX),
handleUpdateRelaySettings: requestHandler(UPDATE_RELAY_SETTINGS),
+ handleUpdateBridgeLocation: requestHandler(UPDATE_BRIDGE_LOCATION),
};
- public static relays: ISender<IRelayList> = {
+ public static relays: ISender<IRelayListPair> = {
notify: sender(RELAYS_CHANGED),
};