summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--gui/assets/images/icon-reload.svg11
-rw-r--r--gui/locales/messages.pot59
-rw-r--r--gui/src/main/daemon-rpc.ts52
-rw-r--r--gui/src/main/tunnel-state.ts2
-rw-r--r--gui/src/renderer/app.tsx4
-rw-r--r--gui/src/renderer/components/Accordion.tsx2
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/ConnectionPanel.tsx227
-rw-r--r--gui/src/renderer/components/ConnectionPanelDisclosure.tsx49
-rw-r--r--gui/src/renderer/components/MultiButton.tsx18
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx2
-rw-r--r--gui/src/renderer/components/SmallButton.tsx93
-rw-r--r--gui/src/renderer/components/TunnelControl.tsx305
-rw-r--r--gui/src/renderer/components/main-view/ConnectionActionButton.tsx63
-rw-r--r--gui/src/renderer/components/main-view/ConnectionDetails.tsx202
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanel.tsx113
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx40
-rw-r--r--gui/src/renderer/components/main-view/ConnectionStatus.tsx58
-rw-r--r--gui/src/renderer/components/main-view/FeatureIndicators.tsx251
-rw-r--r--gui/src/renderer/components/main-view/Hostname.tsx69
-rw-r--r--gui/src/renderer/components/main-view/Location.tsx41
-rw-r--r--gui/src/renderer/components/main-view/MainView.tsx67
-rw-r--r--gui/src/renderer/components/main-view/SelectLocationButton.tsx (renamed from gui/src/renderer/components/Connect.tsx)164
-rw-r--r--gui/src/renderer/components/main-view/styles.ts7
-rw-r--r--gui/src/renderer/containers/ConnectionPanelContainer.tsx124
-rw-r--r--gui/src/renderer/redux/connection/actions.ts21
-rw-r--r--gui/src/renderer/redux/connection/reducers.ts12
-rw-r--r--gui/src/shared/daemon-rpc-types.ts48
-rw-r--r--gui/src/shared/notifications/error.ts6
-rw-r--r--gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts33
-rw-r--r--gui/test/e2e/mocked/feature-indicators.spec.ts133
-rw-r--r--gui/test/e2e/mocked/tunnel-state.spec.ts4
-rw-r--r--gui/test/e2e/shared/tunnel-state.ts63
-rw-r--r--gui/test/unit/notification-evaluation.spec.ts2
34 files changed, 1402 insertions, 947 deletions
diff --git a/gui/assets/images/icon-reload.svg b/gui/assets/images/icon-reload.svg
index 873727a0a5..6d443ac8b4 100644
--- a/gui/assets/images/icon-reload.svg
+++ b/gui/assets/images/icon-reload.svg
@@ -1 +1,10 @@
-<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="redo-alt" class="svg-inline--fa fa-redo-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#000000" d="M256.455 8c66.269.119 126.437 26.233 170.859 68.685l35.715-35.715C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.75c-30.864-28.899-70.801-44.907-113.23-45.273-92.398-.798-170.283 73.977-169.484 169.442C88.764 348.009 162.184 424 256 424c41.127 0 79.997-14.678 110.629-41.556 4.743-4.161 11.906-3.908 16.368.553l39.662 39.662c4.872 4.872 4.631 12.815-.482 17.433C378.202 479.813 319.926 504 256 504 119.034 504 8.001 392.967 8 256.002 7.999 119.193 119.646 7.755 256.455 8z"></path></svg>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="icon">
+<mask id="mask0_774_19809" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
+<rect id="box" width="24" height="24" fill="#D9D9D9"/>
+</mask>
+<g mask="url(#mask0_774_19809)">
+<path id="vector" fill-rule="evenodd" clip-rule="evenodd" d="M6 12C6 8.68629 8.68629 6 12 6C13.7762 6 15.3729 6.77144 16.4724 8H15C14.4477 8 14 8.44772 14 9C14 9.55228 14.4477 10 15 10H19C19.5523 10 20 9.55228 20 9V5C20 4.44772 19.5523 4 19 4C18.4477 4 18 4.44772 18 5V6.70853C16.535 5.04867 14.3903 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C14.13 20 16.0674 19.1663 17.5001 17.8094C17.9011 17.4296 17.9183 16.7967 17.5386 16.3957C17.1588 15.9947 16.5259 15.9775 16.1249 16.3572C15.0487 17.3764 13.5983 18 12 18C8.68629 18 6 15.3137 6 12Z" fill="white"/>
+</g>
+</g>
+</svg>
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index 725863c57b..6f73b02d41 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -2,6 +2,9 @@ msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
+msgid "%(amount)d more..."
+msgstr ""
+
msgid "%(duration)s was added, account paid until %(expiry)s."
msgstr ""
@@ -86,9 +89,15 @@ msgstr ""
msgid "Connected"
msgstr ""
+msgid "CONNECTED"
+msgstr ""
+
msgid "Connecting"
msgstr ""
+msgid "CONNECTING..."
+msgstr ""
+
#. Creating a secure connection that isn't breakable by quantum computers.
msgid "CREATING QUANTUM SECURE CONNECTION"
msgstr ""
@@ -99,6 +108,11 @@ msgstr ""
msgid "Custom"
msgstr ""
+#. This refers to the Custom DNS setting in the VPN settings view. This is
+#. displayed when the feature is on.
+msgid "Custom DNS"
+msgstr ""
+
msgid "Default"
msgstr ""
@@ -120,10 +134,13 @@ msgstr ""
msgid "Disconnected"
msgstr ""
+msgid "DISCONNECTED"
+msgstr ""
+
msgid "Disconnecting"
msgstr ""
-msgid "Dismiss"
+msgid "DISCONNECTING..."
msgstr ""
msgid "Edit"
@@ -166,6 +183,11 @@ msgstr ""
msgid "less than a day left"
msgstr ""
+#. This refers to the multihop setting in the VPN settings view. This is
+#. displayed when the feature is on.
+msgid "Multihop"
+msgstr ""
+
msgid "Name"
msgstr ""
@@ -193,6 +215,11 @@ msgstr ""
msgid "Port"
msgstr ""
+#. This refers to the quantum resistance setting in the WireGuard settings view.
+#. This is displayed when the feature is on.
+msgid "Quantum resistance"
+msgstr ""
+
#. The connection is secure and isn't breakable by quantum computers.
msgid "QUANTUM SECURE CONNECTION"
msgstr ""
@@ -560,10 +587,18 @@ msgid "%(city)s (%(hostname)s)"
msgstr ""
msgctxt "connect-view"
+msgid "Active features"
+msgstr ""
+
+msgctxt "connect-view"
msgid "Congrats!"
msgstr ""
msgctxt "connect-view"
+msgid "Connection details"
+msgstr ""
+
+msgctxt "connect-view"
msgid "Disconnect"
msgstr ""
@@ -635,12 +670,6 @@ msgctxt "connect-view"
msgid "You’re all set!"
msgstr ""
-#. %(hostname)s - The current server the app is connected to, e.g. "se-got-wg-001 using DAITA"
-#. %(daita)s - Will be replaced with "DAITA"
-msgctxt "connection-info"
-msgid "%(hostname)s using %(daita)s"
-msgstr ""
-
#. The hostname line displayed below the country on the main screen
#. Available placeholders:
#. %(relay)s - the relay hostname
@@ -653,14 +682,6 @@ msgctxt "connection-info"
msgid "%(relay)s via Custom bridge"
msgstr ""
-#. The tunnel type line displayed below the hostname line on the main screen
-#. Available placeholders:
-#. %(tunnelType)s - the tunnel type, i.e OpenVPN
-#. %(bridgeType)s - the bridge type, i.e Shadowsocks
-msgctxt "connection-info"
-msgid "%(tunnelType)s via %(bridgeType)s"
-msgstr ""
-
msgctxt "connection-info"
msgid "In"
msgstr ""
@@ -1751,7 +1772,7 @@ msgid "Update your kernel."
msgstr ""
msgctxt "tunnel-control"
-msgid "Secure my connection"
+msgid "Connect"
msgstr ""
msgctxt "tunnel-control"
@@ -2255,6 +2276,9 @@ msgstr ""
msgid "Discard changes?"
msgstr ""
+msgid "Dismiss"
+msgstr ""
+
msgid "Edit custom lists"
msgstr ""
@@ -2423,6 +2447,9 @@ msgstr ""
msgid "Reset to default"
msgstr ""
+msgid "Secure my connection"
+msgstr ""
+
msgid "Secured"
msgstr ""
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index 65e3c6b1c5..f328329d81 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -29,8 +29,9 @@ import {
DeviceEvent,
DeviceState,
DirectMethod,
- ErrorState,
ErrorStateCause,
+ ErrorStateDetails,
+ FeatureIndicator,
FirewallPolicyError,
FirewallPolicyErrorType,
IAppVersionInfo,
@@ -980,6 +981,9 @@ function convertFromTunnelState(tunnelState: grpcTypes.TunnelState): TunnelState
details:
tunnelStateObject.connecting?.relayInfo &&
convertFromTunnelStateRelayInfo(tunnelStateObject.connecting.relayInfo),
+ featureIndicators: convertFromFeatureIndicators(
+ tunnelStateObject.connecting?.featureIndicators?.activeFeaturesList,
+ ),
};
case grpcTypes.TunnelState.StateCase.CONNECTED: {
const relayInfo =
@@ -989,13 +993,16 @@ function convertFromTunnelState(tunnelState: grpcTypes.TunnelState): TunnelState
relayInfo && {
state: 'connected',
details: relayInfo,
+ featureIndicators: convertFromFeatureIndicators(
+ tunnelStateObject.connected?.featureIndicators?.activeFeaturesList,
+ ),
}
);
}
}
}
-function convertFromTunnelStateError(state: grpcTypes.ErrorState.AsObject): ErrorState {
+function convertFromTunnelStateError(state: grpcTypes.ErrorState.AsObject): ErrorStateDetails {
const baseError = {
blockingError: state.blockingError && convertFromBlockingError(state.blockingError),
};
@@ -1127,6 +1134,47 @@ function convertFromTunnelStateRelayInfo(
return undefined;
}
+function convertFromFeatureIndicators(
+ featureIndicators?: Array<grpcTypes.FeatureIndicator>,
+): Array<FeatureIndicator> | undefined {
+ return featureIndicators?.map(convertFromFeatureIndicator);
+}
+
+function convertFromFeatureIndicator(
+ featureIndicator: grpcTypes.FeatureIndicator,
+): FeatureIndicator {
+ switch (featureIndicator) {
+ case grpcTypes.FeatureIndicator.QUANTUM_RESISTANCE:
+ return FeatureIndicator.quantumResistance;
+ case grpcTypes.FeatureIndicator.MULTIHOP:
+ return FeatureIndicator.multihop;
+ case grpcTypes.FeatureIndicator.BRIDGE_MODE:
+ return FeatureIndicator.bridgeMode;
+ case grpcTypes.FeatureIndicator.SPLIT_TUNNELING:
+ return FeatureIndicator.splitTunneling;
+ case grpcTypes.FeatureIndicator.LOCKDOWN_MODE:
+ return FeatureIndicator.lockdownMode;
+ case grpcTypes.FeatureIndicator.UDP_2_TCP:
+ return FeatureIndicator.udp2tcp;
+ case grpcTypes.FeatureIndicator.LAN_SHARING:
+ return FeatureIndicator.lanSharing;
+ case grpcTypes.FeatureIndicator.DNS_CONTENT_BLOCKERS:
+ return FeatureIndicator.dnsContentBlockers;
+ case grpcTypes.FeatureIndicator.CUSTOM_DNS:
+ return FeatureIndicator.customDns;
+ case grpcTypes.FeatureIndicator.SERVER_IP_OVERRIDE:
+ return FeatureIndicator.serverIpOverride;
+ case grpcTypes.FeatureIndicator.CUSTOM_MTU:
+ return FeatureIndicator.customMtu;
+ case grpcTypes.FeatureIndicator.CUSTOM_MSS_FIX:
+ return FeatureIndicator.customMssFix;
+ case grpcTypes.FeatureIndicator.DAITA:
+ return FeatureIndicator.daita;
+ case grpcTypes.FeatureIndicator.SHADOWSOCKS:
+ return FeatureIndicator.shadowsocks;
+ }
+}
+
function convertFromTunnelType(tunnelType: grpcTypes.TunnelType): TunnelType {
const tunnelTypeMap: Record<grpcTypes.TunnelType, TunnelType> = {
[grpcTypes.TunnelType.WIREGUARD]: 'wireguard',
diff --git a/gui/src/main/tunnel-state.ts b/gui/src/main/tunnel-state.ts
index 297a7e481e..43ebe97ad6 100644
--- a/gui/src/main/tunnel-state.ts
+++ b/gui/src/main/tunnel-state.ts
@@ -46,7 +46,7 @@ export default class TunnelStateHandler {
this.setTunnelState(
state === 'disconnecting'
? { state, details: 'nothing' as const, location: this.lastKnownDisconnectedLocation }
- : { state },
+ : { state, featureIndicators: undefined },
);
this.tunnelStateFallbackScheduler.schedule(() => {
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index e0655707c2..a4c76aa2d1 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -783,11 +783,11 @@ export default class AppRenderer {
batch(() => {
switch (tunnelState.state) {
case 'connecting':
- actions.connection.connecting(tunnelState.details);
+ actions.connection.connecting(tunnelState.details, tunnelState.featureIndicators);
break;
case 'connected':
- actions.connection.connected(tunnelState.details);
+ actions.connection.connected(tunnelState.details, tunnelState.featureIndicators);
break;
case 'disconnecting':
diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx
index f70258f103..70ae35c988 100644
--- a/gui/src/renderer/components/Accordion.tsx
+++ b/gui/src/renderer/components/Accordion.tsx
@@ -7,6 +7,7 @@ interface IProps {
children?: React.ReactNode;
onWillExpand?: (contentHeight: number) => void;
onTransitionEnd?: () => void;
+ className?: string;
}
interface IState {
@@ -55,6 +56,7 @@ export default class Accordion extends React.Component<IProps, IState> {
return (
<Container
ref={this.containerRef}
+ className={this.props.className}
$height={this.state.containerHeight}
$animationDuration={this.props.animationDuration}
onTransitionEnd={this.onTransitionEnd}>
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 0f85601a92..ca17d76f1f 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -8,7 +8,6 @@ import { ITransitionSpecification, transitions, useHistory } from '../lib/histor
import { RoutePath } from '../lib/routes';
import Account from './Account';
import ApiAccessMethods from './ApiAccessMethods';
-import Connect from './Connect';
import Debug from './Debug';
import { DeviceRevokedView } from './DeviceRevokedView';
import { EditApiAccessMethod } from './EditApiAccessMethod';
@@ -23,6 +22,7 @@ import ExpiredAccountErrorView from './ExpiredAccountErrorView';
import Filter from './Filter';
import Focus, { IFocusHandle } from './Focus';
import Launch from './Launch';
+import MainView from './main-view/MainView';
import OpenVpnSettings from './OpenVpnSettings';
import ProblemReport from './ProblemReport';
import SelectLanguage from './SelectLanguage';
@@ -71,7 +71,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.login} component={LoginPage} />
<Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
<Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
- <Route exact path={RoutePath.main} component={Connect} />
+ <Route exact path={RoutePath.main} component={MainView} />
<Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
<Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
<Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx
deleted file mode 100644
index 03e2753ede..0000000000
--- a/gui/src/renderer/components/ConnectionPanel.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import * as React from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors, strings } from '../../config.json';
-import {
- EndpointObfuscationType,
- ProxyType,
- RelayProtocol,
- TunnelType,
- tunnelTypeToString,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { default as ConnectionPanelDisclosure } from '../components/ConnectionPanelDisclosure';
-import { tinyText } from './common-styles';
-import Marquee from './Marquee';
-
-export interface IEndpoint {
- ip: string;
- port: number;
- protocol: RelayProtocol;
-}
-
-export interface IInAddress extends IEndpoint {
- tunnelType: TunnelType;
-}
-
-export interface IBridgeData extends IEndpoint {
- bridgeType: ProxyType;
-}
-
-export interface IObfuscationData extends IEndpoint {
- obfuscationType: EndpointObfuscationType;
-}
-
-export interface IOutAddress {
- ipv4?: string;
- ipv6?: string;
-}
-
-interface IProps {
- isOpen: boolean;
- hostname?: string;
- bridgeHostname?: string;
- entryHostname?: string;
- inAddress?: IInAddress;
- entryLocationInAddress?: IInAddress;
- bridgeInfo?: IBridgeData;
- outAddress?: IOutAddress;
- obfuscationEndpoint?: IObfuscationData;
- daita: boolean;
- onToggle: () => void;
- className?: string;
-}
-
-const Container = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-const Row = styled.div({
- display: 'flex',
- marginTop: '3px',
-});
-
-const Text = styled.span(tinyText, {
- lineHeight: '15px',
- color: colors.white,
-});
-
-const Caption = styled(Text)({
- flex: 0,
- marginRight: '8px',
-});
-
-const IpAddresses = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-const Header = styled.div({
- alignSelf: 'start',
- display: 'flex',
- alignItems: 'center',
- width: '100%',
-});
-
-export default class ConnectionPanel extends React.Component<IProps> {
- public render() {
- const { outAddress } = this.props;
- const entryPoint = this.getEntryPoint();
-
- return (
- <Container className={this.props.className}>
- {this.props.hostname && (
- <Header>
- <ConnectionPanelDisclosure pointsUp={this.props.isOpen} onToggle={this.props.onToggle}>
- <Marquee data-testid="hostname-line">{this.hostnameLine()}</Marquee>
- </ConnectionPanelDisclosure>
- </Header>
- )}
-
- {this.props.isOpen && this.props.hostname && (
- <React.Fragment>
- {this.props.inAddress && (
- <Row>
- <Text data-testid="tunnel-protocol">{this.transportLine()}</Text>
- </Row>
- )}
-
- {entryPoint && (
- <Row>
- <Caption>{messages.pgettext('connection-info', 'In')}</Caption>
- <Text data-testid="in-ip">
- {`${entryPoint.ip}:${entryPoint.port} ${entryPoint.protocol.toUpperCase()}`}
- </Text>
- </Row>
- )}
-
- {outAddress && (outAddress.ipv4 || outAddress.ipv6) && (
- <Row>
- <Caption>{messages.pgettext('connection-info', 'Out')}</Caption>
- <IpAddresses>
- {outAddress.ipv4 && <Text>{outAddress.ipv4}</Text>}
- {outAddress.ipv6 && <Text>{outAddress.ipv6}</Text>}
- </IpAddresses>
- </Row>
- )}
- </React.Fragment>
- )}
- </Container>
- );
- }
-
- private getEntryPoint(): IEndpoint | undefined {
- const { obfuscationEndpoint, inAddress, entryLocationInAddress, bridgeInfo } = this.props;
-
- if (obfuscationEndpoint) {
- return obfuscationEndpoint;
- } else if (entryLocationInAddress && inAddress) {
- return entryLocationInAddress;
- } else if (bridgeInfo && inAddress) {
- return bridgeInfo;
- } else {
- return inAddress;
- }
- }
-
- private hostnameLine() {
- let hostname = '';
-
- if (this.props.hostname && this.props.bridgeHostname) {
- hostname = 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) {
- hostname = sprintf(
- // TRANSLATORS: The hostname line displayed below the country on the main screen
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(relay)s - the relay hostname
- // TRANSLATORS: %(entry)s - the entry relay hostname
- messages.pgettext('connection-info', '%(relay)s via %(entry)s'),
- {
- relay: this.props.hostname,
- entry: this.props.entryHostname,
- },
- );
- } else if (this.props.bridgeInfo !== undefined) {
- hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), {
- relay: this.props.hostname,
- });
- } else if (this.props.hostname) {
- hostname = this.props.hostname;
- }
-
- if (hostname !== '' && this.props.daita) {
- hostname = sprintf(
- // TRANSLATORS: %(hostname)s - The current server the app is connected to, e.g. "se-got-wg-001 using DAITA"
- // TRANSLATORS: %(daita)s - Will be replaced with "DAITA"
- messages.pgettext('connection-info', '%(hostname)s using %(daita)s'),
- {
- hostname,
- daita: strings.daita,
- },
- );
- }
-
- return hostname;
- }
-
- private transportLine() {
- const { inAddress, bridgeInfo } = this.props;
-
- if (inAddress) {
- const tunnelType = tunnelTypeToString(inAddress.tunnelType);
-
- if (bridgeInfo) {
- const bridgeType = this.bridgeType();
-
- return sprintf(
- // TRANSLATORS: The tunnel type line displayed below the hostname line on the main screen
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(tunnelType)s - the tunnel type, i.e OpenVPN
- // TRANSLATORS: %(bridgeType)s - the bridge type, i.e Shadowsocks
- messages.pgettext('connection-info', '%(tunnelType)s via %(bridgeType)s'),
- {
- tunnelType,
- bridgeType,
- },
- );
- } else {
- return tunnelType;
- }
- } else {
- return '';
- }
- }
-
- private bridgeType() {
- if (this.props.bridgeHostname && this.props.bridgeInfo?.bridgeType === 'shadowsocks') {
- return 'Shadowsocks bridge';
- } else {
- return 'Custom bridge';
- }
- }
-}
diff --git a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx
deleted file mode 100644
index 3941c3d88f..0000000000
--- a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { normalText } from './common-styles';
-import ImageView from './ImageView';
-
-const Container = styled.div({
- display: 'flex',
- alignItems: 'center',
- width: '100%',
-});
-
-const Caption = styled.span<{ $open: boolean }>(normalText, (props) => ({
- fontWeight: 600,
- lineHeight: '20px',
- minWidth: '0px',
- color: props.$open ? colors.white : colors.white40,
- [Container + ':hover &&']: {
- color: colors.white,
- },
-}));
-
-const Chevron = styled(ImageView)({
- [Container + ':hover &&']: {
- backgroundColor: colors.white,
- },
-});
-
-interface IProps {
- pointsUp: boolean;
- onToggle?: () => void;
- children: React.ReactNode;
- className?: string;
-}
-
-export default function ConnectionPanelDisclosure(props: IProps) {
- return (
- <Container className={props.className} onClick={props.onToggle}>
- <Caption $open={props.pointsUp}>{props.children}</Caption>
- <Chevron
- source={props.pointsUp ? 'icon-chevron-up' : 'icon-chevron-down'}
- width={22}
- height={22}
- tintColor={colors.white40}
- />
- </Container>
- );
-}
diff --git a/gui/src/renderer/components/MultiButton.tsx b/gui/src/renderer/components/MultiButton.tsx
index e1fa3d379e..3129abcbb2 100644
--- a/gui/src/renderer/components/MultiButton.tsx
+++ b/gui/src/renderer/components/MultiButton.tsx
@@ -1,9 +1,7 @@
import React from 'react';
import styled from 'styled-components';
-import * as AppButton from './AppButton';
-
-const SIDE_BUTTON_WIDTH = 50;
+const SIDE_BUTTON_WIDTH = 44;
const ButtonRow = styled.div({
display: 'flex',
@@ -22,19 +20,23 @@ const SideButton = styled.button({
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
width: SIDE_BUTTON_WIDTH,
- alignItems: 'center',
- marginLeft: 1,
+ marginLeft: '1px !important',
});
+export interface MultiButtonCompatibleProps {
+ className?: string;
+ textOffset?: number;
+}
+
interface IMultiButtonProps {
- mainButton: React.ComponentType<AppButton.IProps>;
- sideButton: React.ComponentType<AppButton.IProps>;
+ mainButton: React.ComponentType<MultiButtonCompatibleProps>;
+ sideButton: React.ComponentType<MultiButtonCompatibleProps>;
}
export function MultiButton(props: IMultiButtonProps) {
return (
<ButtonRow>
- <MainButton as={props.mainButton} textOffset={SIDE_BUTTON_WIDTH} />
+ <MainButton as={props.mainButton} textOffset={SIDE_BUTTON_WIDTH + 1} />
<SideButton as={props.sideButton} />
</ButtonRow>
);
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
index 3b66abb204..ad84ad3697 100644
--- a/gui/src/renderer/components/NotificationBanner.tsx
+++ b/gui/src/renderer/components/NotificationBanner.tsx
@@ -133,7 +133,7 @@ const Collapsible = styled.div<ICollapsibleProps>((props) => {
display: 'flex',
flexDirection: 'column',
justifyContent: props.$alignBottom ? 'flex-end' : 'flex-start',
- backgroundColor: 'rgba(25, 38, 56, 0.95)',
+ backgroundColor: colors.darkerBlue,
overflow: 'hidden',
// Using auto as the initial value prevents transition if a notification is visible on mount.
height: props.$height === undefined ? 'auto' : `${props.$height}px`,
diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx
index f80552bee7..e88d719952 100644
--- a/gui/src/renderer/components/SmallButton.tsx
+++ b/gui/src/renderer/components/SmallButton.tsx
@@ -3,10 +3,12 @@ import styled from 'styled-components';
import { colors } from '../../config.json';
import { smallText } from './common-styles';
+import { MultiButtonCompatibleProps } from './MultiButton';
export enum SmallButtonColor {
blue,
red,
+ green,
}
function getButtonColors(color?: SmallButtonColor, disabled?: boolean) {
@@ -16,6 +18,11 @@ function getButtonColors(color?: SmallButtonColor, disabled?: boolean) {
background: disabled ? colors.red60 : colors.red,
backgroundHover: disabled ? colors.red60 : colors.red80,
};
+ case SmallButtonColor.green:
+ return {
+ background: disabled ? colors.green40 : colors.green,
+ backgroundHover: disabled ? colors.green40 : colors.green90,
+ };
default:
return {
background: disabled ? colors.blue50 : colors.blue,
@@ -26,48 +33,70 @@ function getButtonColors(color?: SmallButtonColor, disabled?: boolean) {
const BUTTON_GROUP_GAP = 12;
-const StyledSmallButton = styled.button<{ $color?: SmallButtonColor; disabled?: boolean }>(
- smallText,
- (props) => {
- const buttonColors = getButtonColors(props.$color, props.disabled);
- return {
- minHeight: '32px',
- padding: '5px 16px',
- border: 'none',
- background: buttonColors.background,
- color: props.disabled ? colors.white50 : colors.white,
- borderRadius: '4px',
- marginLeft: `${BUTTON_GROUP_GAP}px`,
+interface StyledSmallButtonProps {
+ $color?: SmallButtonColor;
+ disabled?: boolean;
+}
+
+const StyledSmallButton = styled.button<StyledSmallButtonProps>(smallText, (props) => {
+ const buttonColors = getButtonColors(props.$color, props.disabled);
+
+ return {
+ display: 'flex',
+ minHeight: '32px',
+ padding: '5px 16px',
+ border: 'none',
+ background: buttonColors.background,
+ color: props.disabled ? colors.white50 : colors.white,
+ borderRadius: '4px',
+ marginLeft: `${BUTTON_GROUP_GAP}px`,
+ alignItems: 'center',
+ justifyContent: 'center',
- [`${SmallButtonGroupStart} &&`]: {
- marginLeft: 0,
- marginRight: `${BUTTON_GROUP_GAP}px`,
- },
+ [`${SmallButtonGroupStart} &&`]: {
+ marginLeft: 0,
+ marginRight: `${BUTTON_GROUP_GAP}px`,
+ },
- [`${SmallButtonGrid} &&`]: {
- flex: '1 0 auto',
- marginLeft: 0,
- minWidth: `calc(50% - ${BUTTON_GROUP_GAP / 2}px)`,
- maxWidth: '100%',
- },
+ [`${SmallButtonGrid} &&`]: {
+ flex: '1 0 auto',
+ marginLeft: 0,
+ minWidth: `calc(50% - ${BUTTON_GROUP_GAP / 2}px)`,
+ maxWidth: '100%',
+ },
- '&&:hover': {
- background: buttonColors.backgroundHover,
- },
- };
- },
-);
+ '&&:hover': {
+ background: buttonColors.backgroundHover,
+ },
+ };
+});
+
+const StyledContent = styled.span({
+ flex: '1 0 fit-content',
+});
+
+const StyledTextOffset = styled.span<{ $width: number }>((props) => ({
+ display: 'flex',
+ flex: `0 1 ${props.$width}px`,
+}));
interface SmallButtonProps
- extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'>,
+ MultiButtonCompatibleProps {
onClick: () => void;
- children: string;
+ children: React.ReactNode;
color?: SmallButtonColor;
}
export function SmallButton(props: SmallButtonProps) {
- const { color, ...otherProps } = props;
- return <StyledSmallButton $color={props.color} {...otherProps} />;
+ const { color, textOffset, children, ...otherProps } = props;
+ return (
+ <StyledSmallButton $color={props.color} {...otherProps}>
+ {textOffset && textOffset > 0 ? <StyledTextOffset $width={Math.abs(textOffset)} /> : null}
+ <StyledContent>{children}</StyledContent>
+ {textOffset && textOffset < 0 ? <StyledTextOffset $width={Math.abs(textOffset)} /> : null}
+ </StyledSmallButton>
+ );
}
export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) => ({
diff --git a/gui/src/renderer/components/TunnelControl.tsx b/gui/src/renderer/components/TunnelControl.tsx
deleted file mode 100644
index 0d11762d38..0000000000
--- a/gui/src/renderer/components/TunnelControl.tsx
+++ /dev/null
@@ -1,305 +0,0 @@
-import * as React from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { TunnelState } from '../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../shared/gettext';
-import ConnectionPanelContainer from '../containers/ConnectionPanelContainer';
-import * as AppButton from './AppButton';
-import { hugeText, measurements, normalText } from './common-styles';
-import ImageView from './ImageView';
-import { Footer } from './Layout';
-import Marquee from './Marquee';
-import { MultiButton } from './MultiButton';
-import SecuredLabel, { SecuredDisplayStyle } from './SecuredLabel';
-
-interface ITunnelControlProps {
- tunnelState: TunnelState;
- blockWhenDisconnected: boolean;
- selectedRelayName: string;
- city?: string;
- country?: string;
- onConnect: () => void;
- onDisconnect: () => void;
- onReconnect: () => void;
- onSelectLocation: () => void;
-}
-
-const Secured = styled(SecuredLabel)(normalText, {
- fontWeight: 700,
- lineHeight: '22px',
-});
-
-const Body = styled.div({
- display: 'flex',
- flexDirection: 'column',
- padding: `0 ${measurements.viewMargin}`,
- minHeight: '185px',
-});
-
-const Wrapper = styled.div({
- display: 'flex',
- flexDirection: 'column',
- justifyContent: 'end',
- flex: 1,
-});
-
-const Location = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-const LocationRow = styled.div({
- height: '36px',
-});
-
-const StyledMarquee = styled(Marquee)(hugeText, {
- lineHeight: '36px',
- overflow: 'hidden',
-});
-
-const SelectedLocationChevron = styled(AppButton.Icon)({
- margin: '0 4px',
-});
-
-export default class TunnelControl extends React.Component<ITunnelControlProps> {
- public render() {
- let state = this.props.tunnelState.state;
- let pq = false;
-
- switch (this.props.tunnelState.state) {
- case 'disconnecting':
- switch (this.props.tunnelState.details) {
- case 'block':
- state = 'error';
- break;
- case 'reconnect':
- state = 'connecting';
- break;
- default:
- state = 'disconnecting';
- break;
- }
- break;
- case 'connecting':
- if (this.props.tunnelState.details) {
- pq = this.props.tunnelState.details.endpoint.quantumResistant;
- }
- break;
- case 'connected':
- pq = this.props.tunnelState.details.endpoint.quantumResistant;
- break;
- }
-
- switch (state) {
- case 'connecting': {
- const displayStyle = pq ? SecuredDisplayStyle.securingPq : SecuredDisplayStyle.securing;
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={displayStyle} />
- <Location>
- {this.renderCountry()}
- {this.renderCity()}
- </Location>
- <ConnectionPanelContainer />
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.switchLocationButton()}
- <MultiButton mainButton={this.cancelButton} sideButton={this.reconnectButton} />
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
- }
-
- case 'connected': {
- const displayStyle = pq ? SecuredDisplayStyle.securedPq : SecuredDisplayStyle.secured;
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={displayStyle} />
- <Location>
- {this.renderCountry()}
- {this.renderCity()}
- </Location>
- <ConnectionPanelContainer />
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.switchLocationButton()}
- <MultiButton mainButton={this.disconnectButton} sideButton={this.reconnectButton} />
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
- }
-
- case 'error':
- if (
- this.props.tunnelState.state === 'error' &&
- this.props.tunnelState.details.blockingError
- ) {
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={SecuredDisplayStyle.failedToSecure} />
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.switchLocationButton()}
- <MultiButton mainButton={this.dismissButton} sideButton={this.reconnectButton} />
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
- } else {
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={SecuredDisplayStyle.blocked} />
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.switchLocationButton()}
- <MultiButton mainButton={this.cancelButton} sideButton={this.reconnectButton} />
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
- }
-
- case 'disconnecting':
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={SecuredDisplayStyle.unsecuring} />
- <Location>
- {this.renderCountry()}
- <LocationRow />
- </Location>
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.selectLocationButton()}
- {this.connectButton()}
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
-
- case 'disconnected': {
- const displayStyle = this.props.blockWhenDisconnected
- ? SecuredDisplayStyle.blocked
- : SecuredDisplayStyle.unsecured;
- return (
- <Wrapper>
- <Body>
- <Secured displayStyle={displayStyle} />
- <Location>
- {this.renderCountry()}
- <LocationRow />
- </Location>
- </Body>
- <Footer>
- <AppButton.ButtonGroup>
- {this.selectLocationButton()}
- {this.connectButton()}
- </AppButton.ButtonGroup>
- </Footer>
- </Wrapper>
- );
- }
-
- default:
- throw new Error(`Unknown TunnelState: ${this.props.tunnelState}`);
- }
- }
-
- private renderCity() {
- const city = this.props.city === undefined ? '' : relayLocations.gettext(this.props.city);
- return (
- <LocationRow>
- <StyledMarquee data-testid="city">{city}</StyledMarquee>
- </LocationRow>
- );
- }
-
- private renderCountry() {
- const country =
- this.props.country === undefined ? '' : relayLocations.gettext(this.props.country);
- return (
- <LocationRow>
- <StyledMarquee data-testid="country">{country}</StyledMarquee>
- </LocationRow>
- );
- }
-
- private switchLocationButton() {
- return (
- <AppButton.TransparentButton onClick={this.props.onSelectLocation}>
- {messages.pgettext('tunnel-control', 'Switch location')}
- </AppButton.TransparentButton>
- );
- }
-
- private selectLocationButton() {
- return (
- <AppButton.TransparentButton
- onClick={this.props.onSelectLocation}
- aria-label={sprintf(
- messages.pgettext('accessibility', 'Select location. Current location is %(location)s'),
- { location: this.props.selectedRelayName },
- )}>
- <AppButton.Label>{this.props.selectedRelayName}</AppButton.Label>
- <SelectedLocationChevron height={12} width={7} source="icon-chevron" />
- </AppButton.TransparentButton>
- );
- }
-
- private connectButton() {
- return (
- <AppButton.GreenButton onClick={this.props.onConnect}>
- {messages.pgettext('tunnel-control', 'Secure my connection')}
- </AppButton.GreenButton>
- );
- }
-
- private disconnectButton = (props: AppButton.IProps) => {
- return (
- <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}>
- {messages.gettext('Disconnect')}
- </AppButton.RedTransparentButton>
- );
- };
-
- private cancelButton = (props: AppButton.IProps) => {
- return (
- <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}>
- {messages.gettext('Cancel')}
- </AppButton.RedTransparentButton>
- );
- };
-
- private dismissButton = (props: AppButton.IProps) => {
- return (
- <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}>
- {messages.gettext('Dismiss')}
- </AppButton.RedTransparentButton>
- );
- };
-
- private reconnectButton = (props: AppButton.IProps) => {
- return (
- <AppButton.RedTransparentButton
- onClick={this.props.onReconnect}
- aria-label={messages.gettext('Reconnect')}
- {...props}>
- <AppButton.Label>
- <ImageView height={22} width={22} source="icon-reload" tintColor="white" />
- </AppButton.Label>
- </AppButton.RedTransparentButton>
- );
- };
-}
diff --git a/gui/src/renderer/components/main-view/ConnectionActionButton.tsx b/gui/src/renderer/components/main-view/ConnectionActionButton.tsx
new file mode 100644
index 0000000000..ad579da671
--- /dev/null
+++ b/gui/src/renderer/components/main-view/ConnectionActionButton.tsx
@@ -0,0 +1,63 @@
+import { useCallback } from 'react';
+import styled from 'styled-components';
+
+import { messages } from '../../../shared/gettext';
+import log from '../../../shared/logging';
+import { useAppContext } from '../../context';
+import { useSelector } from '../../redux/store';
+import { SmallButton, SmallButtonColor } from '../SmallButton';
+
+const StyledConnectionButton = styled(SmallButton)({
+ margin: 0,
+});
+
+export default function ConnectionActionButton() {
+ const tunnelState = useSelector((state) => state.connection.status.state);
+
+ if (tunnelState === 'disconnected' || tunnelState === 'disconnecting') {
+ return <ConnectButton disabled={tunnelState === 'disconnecting'} />;
+ } else {
+ return <DisconnectButton />;
+ }
+}
+
+function ConnectButton(props: Partial<Parameters<typeof SmallButton>[0]>) {
+ const { connectTunnel } = useAppContext();
+
+ const onConnect = useCallback(async () => {
+ try {
+ await connectTunnel();
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to connect the tunnel: ${error.message}`);
+ }
+ }, []);
+
+ return (
+ <StyledConnectionButton color={SmallButtonColor.green} onClick={onConnect} {...props}>
+ {messages.pgettext('tunnel-control', 'Connect')}
+ </StyledConnectionButton>
+ );
+}
+
+function DisconnectButton() {
+ const { disconnectTunnel } = useAppContext();
+ const tunnelState = useSelector((state) => state.connection.status.state);
+
+ const onDisconnect = useCallback(async () => {
+ try {
+ await disconnectTunnel();
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to disconnect the tunnel: ${error.message}`);
+ }
+ }, []);
+
+ const displayAsCancel = tunnelState !== 'connected';
+
+ return (
+ <StyledConnectionButton color={SmallButtonColor.red} onClick={onDisconnect}>
+ {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')}
+ </StyledConnectionButton>
+ );
+}
diff --git a/gui/src/renderer/components/main-view/ConnectionDetails.tsx b/gui/src/renderer/components/main-view/ConnectionDetails.tsx
new file mode 100644
index 0000000000..c6bff32ef3
--- /dev/null
+++ b/gui/src/renderer/components/main-view/ConnectionDetails.tsx
@@ -0,0 +1,202 @@
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import {
+ EndpointObfuscationType,
+ ITunnelEndpoint,
+ parseSocketAddress,
+ ProxyType,
+ RelayProtocol,
+ TunnelState,
+ TunnelType,
+ tunnelTypeToString,
+} from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import { useSelector } from '../../redux/store';
+import { tinyText } from '../common-styles';
+
+interface Endpoint {
+ ip: string;
+ port: number;
+ protocol: RelayProtocol;
+}
+
+interface InAddress extends Endpoint {
+ tunnelType: TunnelType;
+}
+
+interface BridgeData extends Endpoint {
+ bridgeType: ProxyType;
+}
+
+interface ObfuscationData extends Endpoint {
+ obfuscationType: EndpointObfuscationType;
+}
+
+const StyledConnectionDetailsHeading = styled.h2(tinyText, {
+ margin: '0 0 4px',
+ fontSize: '10px',
+ lineHeight: '15px',
+ color: colors.white60,
+});
+
+const StyledConnectionDetailsContainer = styled.div({
+ marginTop: '16px',
+ marginBottom: '16px',
+});
+
+const StyledIpTable = styled.div({
+ display: 'grid',
+ gridTemplateColumns: 'minmax(48px, min-content) auto',
+});
+
+const StyledIpLabelContainer = styled.div({
+ display: 'flex',
+ flexDirection: 'column',
+});
+
+const StyledConnectionDetailsLabel = styled.span(tinyText, {
+ display: 'block',
+ color: colors.white,
+ fontWeight: '400',
+ minHeight: '1em',
+});
+
+const StyledConnectionDetailsTitle = styled(StyledConnectionDetailsLabel)({
+ color: colors.white60,
+ whiteSpace: 'nowrap',
+});
+
+export default function ConnectionDetails() {
+ const reduxConnection = useSelector((state) => state.connection);
+ const [connection, setConnection] = useState(reduxConnection);
+
+ const tunnelState = connection.status;
+
+ useEffect(() => {
+ if (
+ reduxConnection.status.state === 'connected' ||
+ reduxConnection.status.state === 'connecting'
+ ) {
+ setConnection(reduxConnection);
+ }
+ }, [reduxConnection, tunnelState.state]);
+
+ const entry = getEntryPoint(tunnelState);
+
+ const showDetails = tunnelState.state === 'connected' || tunnelState.state === 'connecting';
+ const hasEntry = showDetails && entry !== undefined;
+
+ return (
+ <StyledConnectionDetailsContainer>
+ <StyledConnectionDetailsHeading>
+ {messages.pgettext('connect-view', 'Connection details')}
+ </StyledConnectionDetailsHeading>
+ <StyledConnectionDetailsLabel data-testid="tunnel-protocol">
+ {showDetails &&
+ tunnelState.details !== undefined &&
+ tunnelTypeToString(tunnelState.details.endpoint.tunnelType)}
+ </StyledConnectionDetailsLabel>
+ <StyledIpTable>
+ <StyledConnectionDetailsTitle>
+ {messages.pgettext('connection-info', 'In')}
+ </StyledConnectionDetailsTitle>
+ <StyledConnectionDetailsLabel data-testid="in-ip">
+ {hasEntry ? `${entry.ip}:${entry.port} ${entry.protocol.toUpperCase()}` : ''}
+ </StyledConnectionDetailsLabel>
+ <StyledConnectionDetailsTitle>
+ {messages.pgettext('connection-info', 'Out')}
+ </StyledConnectionDetailsTitle>
+ <StyledIpLabelContainer>
+ {connection.ipv4 && (
+ <StyledConnectionDetailsLabel>{connection.ipv4}</StyledConnectionDetailsLabel>
+ )}
+ {connection.ipv6 && (
+ <StyledConnectionDetailsLabel>{connection.ipv6}</StyledConnectionDetailsLabel>
+ )}
+ </StyledIpLabelContainer>
+ </StyledIpTable>
+ </StyledConnectionDetailsContainer>
+ );
+}
+
+function getEntryPoint(tunnelState: TunnelState): Endpoint | undefined {
+ if (
+ (tunnelState.state !== 'connected' && tunnelState.state !== 'connecting') ||
+ tunnelState.details === undefined
+ ) {
+ return undefined;
+ }
+
+ const endpoint = tunnelState.details.endpoint;
+ const inAddress = tunnelEndpointToRelayInAddress(endpoint);
+ const entryLocationInAddress = tunnelEndpointToEntryLocationInAddress(endpoint);
+ const bridgeInfo = tunnelEndpointToBridgeData(endpoint);
+ const obfuscationEndpoint = tunnelEndpointToObfuscationEndpoint(endpoint);
+
+ if (obfuscationEndpoint) {
+ return obfuscationEndpoint;
+ } else if (entryLocationInAddress && inAddress) {
+ return entryLocationInAddress;
+ } else if (bridgeInfo && inAddress) {
+ return bridgeInfo;
+ } else {
+ return inAddress;
+ }
+}
+
+function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): InAddress {
+ const socketAddr = parseSocketAddress(tunnelEndpoint.address);
+ return {
+ ip: socketAddr.host,
+ port: socketAddr.port,
+ protocol: tunnelEndpoint.protocol,
+ tunnelType: tunnelEndpoint.tunnelType,
+ };
+}
+
+function tunnelEndpointToEntryLocationInAddress(
+ tunnelEndpoint: ITunnelEndpoint,
+): InAddress | 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): BridgeData | undefined {
+ if (!endpoint.proxy) {
+ return undefined;
+ }
+
+ const socketAddr = parseSocketAddress(endpoint.proxy.address);
+ return {
+ ip: socketAddr.host,
+ port: socketAddr.port,
+ protocol: endpoint.proxy.protocol,
+ bridgeType: endpoint.proxy.proxyType,
+ };
+}
+
+function tunnelEndpointToObfuscationEndpoint(
+ endpoint: ITunnelEndpoint,
+): ObfuscationData | undefined {
+ if (!endpoint.obfuscationEndpoint) {
+ return undefined;
+ }
+
+ return {
+ ip: endpoint.obfuscationEndpoint.address,
+ port: endpoint.obfuscationEndpoint.port,
+ protocol: endpoint.obfuscationEndpoint.protocol,
+ obfuscationType: endpoint.obfuscationEndpoint.obfuscationType,
+ };
+}
diff --git a/gui/src/renderer/components/main-view/ConnectionPanel.tsx b/gui/src/renderer/components/main-view/ConnectionPanel.tsx
new file mode 100644
index 0000000000..29c4139703
--- /dev/null
+++ b/gui/src/renderer/components/main-view/ConnectionPanel.tsx
@@ -0,0 +1,113 @@
+import { useCallback, useEffect } from 'react';
+import styled from 'styled-components';
+
+import { useBoolean } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
+import CustomScrollbars from '../CustomScrollbars';
+import ConnectionActionButton from './ConnectionActionButton';
+import ConnectionDetails from './ConnectionDetails';
+import ConnectionPanelChevron from './ConnectionPanelChevron';
+import ConnectionStatus from './ConnectionStatus';
+import FeatureIndicators from './FeatureIndicators';
+import Hostname from './Hostname';
+import Location from './Location';
+import SelectLocationButton from './SelectLocationButton';
+import { ConnectionPanelAccordion } from './styles';
+
+const PANEL_MARGIN = '16px';
+
+const StyledAccordion = styled(ConnectionPanelAccordion)({
+ flexShrink: 0,
+});
+
+const StyledConnectionPanel = styled.div<{ $expanded: boolean }>((props) => ({
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ maxHeight: `calc(100% - 2 * ${PANEL_MARGIN})`,
+ margin: `auto ${PANEL_MARGIN} ${PANEL_MARGIN}`,
+ padding: '16px',
+ justifySelf: 'flex-end',
+ borderRadius: '12px',
+ backgroundColor: `rgba(16, 24, 35, ${props.$expanded ? 0.8 : 0.4})`,
+ backdropFilter: 'blur(6px)',
+
+ transition: 'background-color 300ms ease-out',
+}));
+
+const StyledConnectionButtonContainer = styled.div({
+ transition: 'margin-top 300ms ease-out',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+ marginTop: '16px',
+});
+
+const StyledCustomScrollbars = styled(CustomScrollbars)({
+ flexShrink: 1,
+});
+
+const StyledConnectionPanelChevron = styled(ConnectionPanelChevron)({
+ position: 'absolute',
+ top: '16px',
+ right: '16px',
+ width: 'fit-content',
+});
+
+const StyledConnectionStatusContainer = styled.div<{
+ $expanded: boolean;
+ $hasFeatureIndicators: boolean;
+}>((props) => ({
+ paddingBottom: props.$hasFeatureIndicators || props.$expanded ? '16px' : 0,
+ marginBottom: props.$expanded && props.$hasFeatureIndicators ? '16px' : 0,
+ borderBottom: props.$expanded ? '1px rgba(255, 255, 255, 0.2) solid' : 'none',
+ transitionProperty: 'margin-bottom, padding-bottom',
+ transitionDuration: '300ms',
+ transitionTimingFunction: 'ease-out',
+}));
+
+export default function ConnectionPanel() {
+ const [expanded, expandImpl, collapse, toggleExpanded] = useBoolean();
+ const tunnelState = useSelector((state) => state.connection.status);
+
+ const allowExpand = tunnelState.state === 'connected' || tunnelState.state === 'connecting';
+
+ const expand = useCallback(() => {
+ if (allowExpand) {
+ expandImpl();
+ }
+ }, [allowExpand, expandImpl]);
+
+ const hasFeatureIndicators =
+ allowExpand &&
+ tunnelState.featureIndicators !== undefined &&
+ tunnelState.featureIndicators.length > 0;
+
+ useEffect(collapse, [tunnelState, collapse]);
+
+ return (
+ <StyledConnectionPanel $expanded={expanded}>
+ {allowExpand && (
+ <StyledConnectionPanelChevron pointsUp={!expanded} onToggle={toggleExpanded} />
+ )}
+ <StyledConnectionStatusContainer
+ $expanded={expanded}
+ $hasFeatureIndicators={hasFeatureIndicators}
+ onClick={toggleExpanded}>
+ <ConnectionStatus />
+ <Location />
+ <Hostname />
+ </StyledConnectionStatusContainer>
+ <StyledCustomScrollbars>
+ <FeatureIndicators expanded={expanded} expandIsland={expand} />
+ <StyledAccordion expanded={expanded}>
+ <ConnectionDetails />
+ </StyledAccordion>
+ </StyledCustomScrollbars>
+ <StyledConnectionButtonContainer>
+ <SelectLocationButton />
+ <ConnectionActionButton />
+ </StyledConnectionButtonContainer>
+ </StyledConnectionPanel>
+ );
+}
diff --git a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx
new file mode 100644
index 0000000000..a50fada589
--- /dev/null
+++ b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx
@@ -0,0 +1,40 @@
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import ImageView from '../ImageView';
+
+const Container = styled.button({
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ background: 'none',
+ border: 'none',
+});
+
+const Chevron = styled(ImageView)({
+ [Container + ':hover &&']: {
+ backgroundColor: colors.white80,
+ },
+});
+
+interface IProps {
+ pointsUp: boolean;
+ onToggle?: () => void;
+ className?: string;
+}
+
+export default function ConnectionPanelChevron(props: IProps) {
+ return (
+ <Container
+ data-testid="connection-panel-chevron"
+ className={props.className}
+ onClick={props.onToggle}>
+ <Chevron
+ source={props.pointsUp ? 'icon-chevron-up' : 'icon-chevron-down'}
+ width={24}
+ height={24}
+ tintColor={colors.white}
+ />
+ </Container>
+ );
+}
diff --git a/gui/src/renderer/components/main-view/ConnectionStatus.tsx b/gui/src/renderer/components/main-view/ConnectionStatus.tsx
new file mode 100644
index 0000000000..1745fdaf11
--- /dev/null
+++ b/gui/src/renderer/components/main-view/ConnectionStatus.tsx
@@ -0,0 +1,58 @@
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { TunnelState } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import { useSelector } from '../../redux/store';
+import { largeText } from '../common-styles';
+
+const StyledConnectionStatus = styled.span<{ $color: string }>(largeText, (props) => ({
+ minHeight: '24px',
+ color: props.$color,
+ marginBottom: '4px',
+}));
+
+export default function ConnectionStatus() {
+ const tunnelState = useSelector((state) => state.connection.status);
+ const lockdownMode = useSelector((state) => state.settings.blockWhenDisconnected);
+
+ const color = getConnectionSTatusLabelColor(tunnelState, lockdownMode);
+ const text = getConnectionStatusLabelText(tunnelState);
+
+ return (
+ <StyledConnectionStatus role="status" $color={color}>
+ {text}
+ </StyledConnectionStatus>
+ );
+}
+
+function getConnectionSTatusLabelColor(tunnelState: TunnelState, lockdownMode: boolean) {
+ switch (tunnelState.state) {
+ case 'connected':
+ return colors.green;
+ case 'connecting':
+ case 'disconnecting':
+ return colors.white;
+ case 'disconnected':
+ return lockdownMode ? colors.white : colors.red;
+ case 'error':
+ return tunnelState.details.blockingError ? colors.red : colors.white;
+ }
+}
+
+function getConnectionStatusLabelText(tunnelState: TunnelState) {
+ switch (tunnelState.state) {
+ case 'connected':
+ return messages.gettext('CONNECTED');
+ case 'connecting':
+ return messages.gettext('CONNECTING...');
+ case 'disconnecting':
+ return messages.gettext('DISCONNECTING...');
+ case 'disconnected':
+ return messages.gettext('DISCONNECTED');
+ case 'error':
+ return tunnelState.details.blockingError
+ ? messages.gettext('FAILED TO SECURE CONNECTION')
+ : messages.gettext('BLOCKED CONNECTION');
+ }
+}
diff --git a/gui/src/renderer/components/main-view/FeatureIndicators.tsx b/gui/src/renderer/components/main-view/FeatureIndicators.tsx
new file mode 100644
index 0000000000..81495e46eb
--- /dev/null
+++ b/gui/src/renderer/components/main-view/FeatureIndicators.tsx
@@ -0,0 +1,251 @@
+import { useLayoutEffect, useRef } from 'react';
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+
+import { colors, strings } from '../../../config.json';
+import { FeatureIndicator } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import { useStyledRef } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
+import { tinyText } from '../common-styles';
+import { ConnectionPanelAccordion } from './styles';
+
+const LINE_HEIGHT = 22;
+const GAP = 8;
+
+const StyledAccordion = styled(ConnectionPanelAccordion)({
+ flexShrink: 0,
+});
+
+const StyledFeatureIndicatorsContainer = styled.div<{ $expanded: boolean }>((props) => ({
+ marginTop: '0px',
+ marginBottom: props.$expanded ? '8px' : 0,
+ transition: 'margin-bottom 300ms ease-out',
+}));
+
+const StyledTitle = styled.h2(tinyText, {
+ margin: '0 0 2px',
+ fontSize: '10px',
+ lineHeight: '15px',
+ color: colors.white60,
+});
+
+const StyledFeatureIndicators = styled.div({
+ position: 'relative',
+});
+
+const StyledFeatureIndicatorsWrapper = styled.div<{ $expanded: boolean }>((props) => ({
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: `${GAP}px`,
+ maxHeight: props.$expanded ? 'fit-content' : '52px',
+ overflow: 'hidden',
+}));
+
+const StyledFeatureIndicatorLabel = styled.span<{ $expanded: boolean }>(tinyText, (props) => ({
+ display: 'inline',
+ padding: '2px 8px',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: '4px',
+ background: colors.darkerBlue,
+ color: colors.white,
+ fontWeight: 400,
+ whiteSpace: 'nowrap',
+ visibility: props.$expanded ? 'visible' : 'hidden',
+}));
+
+const StyledBaseEllipsis = styled.span(tinyText, {
+ position: 'absolute',
+ bottom: 0,
+ color: colors.white,
+ padding: '2px 8px 2px 16px',
+});
+
+const StyledEllipsisSpacer = styled(StyledBaseEllipsis)({
+ right: 0,
+ opacity: 0,
+});
+
+const StyledEllipsis = styled(StyledBaseEllipsis)({
+ visibility: 'hidden',
+});
+
+interface FeatureIndicatorsProps {
+ expanded: boolean;
+ expandIsland: () => void;
+}
+
+// This component needs to render a maximum of two lines of feature indicators and then ellipsis
+// with the text "N more...". This poses two challenges:
+// 1. We can't know the size of the content beforehand or how many indicators should be hidden
+// 2. The ellipsis string doesn't have a fixed width, the amount can change.
+//
+// To solve this the indicators are first rendered hidden along with a invisible "placeholder"
+// ellipsis at the end of the second row. Then after render, all indicators that either is placed
+// after the second row or overlaps with the invisible ellipsis text will be set to invisible. Then
+// we can count those and add another ellipsis element which is visible and place it after the last
+// visible indicator.
+export default function FeatureIndicators(props: FeatureIndicatorsProps) {
+ const tunnelState = useSelector((state) => state.connection.status);
+ const ellipsisRef = useStyledRef<HTMLSpanElement>();
+ const ellipsisSpacerRef = useStyledRef<HTMLSpanElement>();
+ const featureIndicatorsContainerRef = useStyledRef<HTMLDivElement>();
+
+ const featureIndicatorsVisible =
+ tunnelState.state === 'connected' || tunnelState.state === 'connecting';
+ const featureIndicators = useRef(
+ featureIndicatorsVisible ? tunnelState.featureIndicators ?? [] : [],
+ );
+
+ if (featureIndicatorsVisible && tunnelState.featureIndicators) {
+ featureIndicators.current = tunnelState.featureIndicators;
+ }
+
+ const ellipsis = messages.gettext('%(amount)d more...');
+
+ useLayoutEffect(() => {
+ if (
+ !props.expanded &&
+ featureIndicatorsContainerRef.current &&
+ ellipsisSpacerRef.current &&
+ ellipsisRef.current
+ ) {
+ // Get all feature indicator elements.
+ const indicatorElements = Array.from(
+ featureIndicatorsContainerRef.current.getElementsByTagName('span'),
+ );
+ let lastVisibleIndex = 0;
+ let hasHidden = false;
+ indicatorElements.forEach((indicatorElement, i) => {
+ if (
+ !indicatorShouldBeHidden(
+ featureIndicatorsContainerRef.current!,
+ indicatorElement,
+ ellipsisSpacerRef.current!,
+ )
+ ) {
+ // If an indicator should be visible we set its visibility and increment the variable
+ // containing the last visible index.
+ indicatorElement.style.visibility = 'visible';
+ lastVisibleIndex = i;
+ } else {
+ // If it should be visible we store that there exists hidden indicators.
+ hasHidden = true;
+ }
+ });
+
+ if (hasHidden) {
+ const lastVisibleIndicatorRect =
+ indicatorElements[lastVisibleIndex].getBoundingClientRect();
+ const containerRect = featureIndicatorsContainerRef.current.getBoundingClientRect();
+
+ // Place the ellipsis at the end of the last visible indicator.
+ const left = lastVisibleIndicatorRect.right - containerRect.left;
+ ellipsisRef.current.style.left = `${left}px`;
+ ellipsisRef.current.style.visibility = 'visible';
+
+ // Add the ellipsis text to the ellipsis.
+ ellipsisRef.current.textContent = sprintf(ellipsis, {
+ amount: indicatorElements.length - (lastVisibleIndex + 1),
+ });
+ }
+ }
+ });
+
+ return (
+ <StyledAccordion expanded={featureIndicatorsVisible && featureIndicators.current.length > 0}>
+ <StyledFeatureIndicatorsContainer onClick={props.expandIsland} $expanded={props.expanded}>
+ <StyledAccordion expanded={props.expanded}>
+ <StyledTitle>{messages.pgettext('connect-view', 'Active features')}</StyledTitle>
+ </StyledAccordion>
+ <StyledFeatureIndicators>
+ <StyledFeatureIndicatorsWrapper
+ ref={featureIndicatorsContainerRef}
+ $expanded={props.expanded}>
+ {featureIndicators.current.sort().map((indicator) => (
+ <StyledFeatureIndicatorLabel
+ key={indicator.toString()}
+ data-testid="feature-indicator"
+ $expanded={props.expanded}>
+ {getFeatureIndicatorLabel(indicator)}
+ </StyledFeatureIndicatorLabel>
+ ))}
+ </StyledFeatureIndicatorsWrapper>
+ {!props.expanded && (
+ <>
+ <StyledEllipsis ref={ellipsisRef} />
+ <StyledEllipsisSpacer ref={ellipsisSpacerRef}>
+ {
+ // Mock amount for the spacer ellipsis. This needs to be wider than the real
+ // ellipsis will ever be.
+ sprintf(ellipsis, { amount: 222 })
+ }
+ </StyledEllipsisSpacer>
+ </>
+ )}
+ </StyledFeatureIndicators>
+ </StyledFeatureIndicatorsContainer>
+ </StyledAccordion>
+ );
+}
+
+function indicatorShouldBeHidden(
+ container: HTMLElement,
+ indicator: HTMLElement,
+ ellipsisSpacer: HTMLElement,
+): boolean {
+ const indicatorRect = indicator.getBoundingClientRect();
+ const ellipsisSpacerRect = ellipsisSpacer.getBoundingClientRect();
+
+ // If 2 or less lines are required to display the indicators all should be visible. This is
+ // calculated based on the scroll height.
+ if (container.scrollHeight <= 2 * LINE_HEIGHT + GAP) {
+ return false;
+ }
+
+ // An indicator should be hidden if it's placed farther down than the spacer ellipsis, or if it
+ // overlaps it.
+ return (
+ indicatorRect.top >= ellipsisSpacerRect.bottom ||
+ (indicatorRect.top === ellipsisSpacerRect.top && indicatorRect.right >= ellipsisSpacerRect.left)
+ );
+}
+
+function getFeatureIndicatorLabel(indicator: FeatureIndicator) {
+ switch (indicator) {
+ case FeatureIndicator.daita:
+ return strings.daita;
+ case FeatureIndicator.udp2tcp:
+ case FeatureIndicator.shadowsocks:
+ return messages.pgettext('wireguard-settings-view', 'Obfuscation');
+ case FeatureIndicator.multihop:
+ // TRANSLATORS: This refers to the multihop setting in the VPN settings view. This is
+ // TRANSLATORS: displayed when the feature is on.
+ return messages.gettext('Multihop');
+ case FeatureIndicator.customDns:
+ // TRANSLATORS: This refers to the Custom DNS setting in the VPN settings view. This is
+ // TRANSLATORS: displayed when the feature is on.
+ return messages.gettext('Custom DNS');
+ case FeatureIndicator.customMtu:
+ return messages.pgettext('wireguard-settings-view', 'MTU');
+ case FeatureIndicator.bridgeMode:
+ return messages.pgettext('openvpn-settings-view', 'Bridge mode');
+ case FeatureIndicator.lanSharing:
+ return messages.pgettext('vpn-settings-view', 'Local network sharing');
+ case FeatureIndicator.customMssFix:
+ return messages.pgettext('openvpn-settings-view', 'Mssfix');
+ case FeatureIndicator.lockdownMode:
+ return messages.pgettext('vpn-settings-view', 'Lockdown mode');
+ case FeatureIndicator.splitTunneling:
+ return strings.splitTunneling;
+ case FeatureIndicator.serverIpOverride:
+ return messages.pgettext('settings-import', 'Server IP override');
+ case FeatureIndicator.quantumResistance:
+ // TRANSLATORS: This refers to the quantum resistance setting in the WireGuard settings view.
+ // TRANSLATORS: This is displayed when the feature is on.
+ return messages.gettext('Quantum resistance');
+ case FeatureIndicator.dnsContentBlockers:
+ return messages.pgettext('vpn-settings-view', 'DNS content blockers');
+ }
+}
diff --git a/gui/src/renderer/components/main-view/Hostname.tsx b/gui/src/renderer/components/main-view/Hostname.tsx
new file mode 100644
index 0000000000..097e3b291b
--- /dev/null
+++ b/gui/src/renderer/components/main-view/Hostname.tsx
@@ -0,0 +1,69 @@
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { messages } from '../../../shared/gettext';
+import { IConnectionReduxState } from '../../redux/connection/reducers';
+import { useSelector } from '../../redux/store';
+import { smallText } from '../common-styles';
+import Marquee from '../Marquee';
+import { ConnectionPanelAccordion } from './styles';
+
+const StyledAccordion = styled(ConnectionPanelAccordion)({
+ flexShrink: 0,
+});
+
+const StyledHostname = styled.span(smallText, {
+ color: colors.white60,
+ fontWeight: '400',
+ flexShrink: 0,
+ minHeight: '1em',
+});
+
+export default function Hostname() {
+ const tunnelState = useSelector((state) => state.connection.status.state);
+ const connection = useSelector((state) => state.connection);
+ const text = getHostnameText(connection);
+
+ return (
+ <StyledAccordion expanded={tunnelState === 'connecting' || tunnelState === 'connected'}>
+ <StyledHostname data-testid="hostname-line">
+ <Marquee>{text}</Marquee>
+ </StyledHostname>
+ </StyledAccordion>
+ );
+}
+
+function getHostnameText(connection: IConnectionReduxState) {
+ let hostname = '';
+
+ if (connection.hostname && connection.bridgeHostname) {
+ hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), {
+ relay: connection.hostname,
+ entry: connection.bridgeHostname,
+ });
+ } else if (connection.hostname && connection.entryHostname) {
+ hostname = sprintf(
+ // TRANSLATORS: The hostname line displayed below the country on the main screen
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(relay)s - the relay hostname
+ // TRANSLATORS: %(entry)s - the entry relay hostname
+ messages.pgettext('connection-info', '%(relay)s via %(entry)s'),
+ {
+ relay: connection.hostname,
+ entry: connection.entryHostname,
+ },
+ );
+ } else if (
+ (connection.status.state === 'connecting' || connection.status.state === 'connected') &&
+ connection.status.details?.endpoint.proxy !== undefined
+ ) {
+ hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), {
+ relay: connection.hostname,
+ });
+ } else if (connection.hostname) {
+ hostname = connection.hostname;
+ }
+
+ return hostname;
+}
diff --git a/gui/src/renderer/components/main-view/Location.tsx b/gui/src/renderer/components/main-view/Location.tsx
new file mode 100644
index 0000000000..2685394158
--- /dev/null
+++ b/gui/src/renderer/components/main-view/Location.tsx
@@ -0,0 +1,41 @@
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { TunnelState } from '../../../shared/daemon-rpc-types';
+import { useSelector } from '../../redux/store';
+import { largeText } from '../common-styles';
+import Marquee from '../Marquee';
+import { ConnectionPanelAccordion } from './styles';
+
+const StyledLocation = styled.span(largeText, {
+ color: colors.white,
+ flexShrink: 0,
+});
+
+export default function Location() {
+ const connection = useSelector((state) => state.connection);
+ const text = getLocationText(connection.status, connection.country, connection.city);
+
+ return (
+ <ConnectionPanelAccordion expanded={connection.status.state !== 'error'}>
+ <StyledLocation>
+ <Marquee>{text}</Marquee>
+ </StyledLocation>
+ </ConnectionPanelAccordion>
+ );
+}
+
+function getLocationText(tunnelState: TunnelState, country?: string, city?: string): string {
+ country = country ?? '';
+
+ switch (tunnelState.state) {
+ case 'connected':
+ case 'connecting':
+ return city ? `${country}, ${city}` : country;
+ case 'disconnecting':
+ case 'disconnected':
+ return country;
+ case 'error':
+ return '';
+ }
+}
diff --git a/gui/src/renderer/components/main-view/MainView.tsx b/gui/src/renderer/components/main-view/MainView.tsx
new file mode 100644
index 0000000000..9327094a51
--- /dev/null
+++ b/gui/src/renderer/components/main-view/MainView.tsx
@@ -0,0 +1,67 @@
+import styled from 'styled-components';
+
+import { useSelector } from '../../redux/store';
+import { calculateHeaderBarStyle, DefaultHeaderBar } from '../HeaderBar';
+import ImageView from '../ImageView';
+import { Container, Layout } from '../Layout';
+import Map from '../Map';
+import NotificationArea from '../NotificationArea';
+import ConnectionPanel from './ConnectionPanel';
+
+const StyledContainer = styled(Container)({
+ position: 'relative',
+});
+
+const Content = styled.div({
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ position: 'relative', // need this for z-index to work to cover the map
+ zIndex: 1,
+ maxHeight: '100%',
+});
+
+const StatusIcon = styled(ImageView)({
+ position: 'absolute',
+ alignSelf: 'center',
+ marginTop: 94,
+});
+
+const StyledNotificationArea = styled(NotificationArea)({
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ right: 0,
+});
+
+const StyledMain = styled.main({
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ maxHeight: '100%',
+});
+
+export default function MainView() {
+ const connection = useSelector((state) => state.connection);
+
+ const showSpinner =
+ connection.status.state === 'connecting' || connection.status.state === 'disconnecting';
+
+ return (
+ <Layout>
+ <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} />
+ <StyledContainer>
+ <Map />
+ <Content>
+ <StyledNotificationArea />
+
+ <StyledMain>
+ {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null}
+
+ <ConnectionPanel />
+ </StyledMain>
+ </Content>
+ </StyledContainer>
+ </Layout>
+ );
+}
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx
index 1a557ffa3d..30b132a84e 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/main-view/SelectLocationButton.tsx
@@ -2,127 +2,66 @@ import { useCallback, useMemo } from 'react';
import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
-import { ICustomList } from '../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { useAppContext } from '../context';
-import { transitions, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../redux/settings/reducers';
-import { useSelector } from '../redux/store';
-import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar';
-import ImageView from './ImageView';
-import { Container, Layout } from './Layout';
-import Map from './Map';
-import NotificationArea from './NotificationArea';
-import TunnelControl from './TunnelControl';
+import { ICustomList } from '../../../shared/daemon-rpc-types';
+import { messages, relayLocations } from '../../../shared/gettext';
+import log from '../../../shared/logging';
+import { useAppContext } from '../../context';
+import { transitions, useHistory } from '../../lib/history';
+import { RoutePath } from '../../lib/routes';
+import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers';
+import { useSelector } from '../../redux/store';
+import ImageView from '../ImageView';
+import { MultiButton, MultiButtonCompatibleProps } from '../MultiButton';
+import { SmallButton, SmallButtonColor } from '../SmallButton';
-const StyledContainer = styled(Container)({
- position: 'relative',
+const StyledSmallButton = styled(SmallButton)({
+ margin: 0,
});
-const Content = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- position: 'relative', // need this for z-index to work to cover the map
- zIndex: 1,
+const StyledReconnectButton = styled(StyledSmallButton)({
+ padding: '4px 8px 4px 8px',
});
-const StatusIcon = styled(ImageView)({
- position: 'absolute',
- alignSelf: 'center',
- marginTop: 94,
-});
-
-const StyledNotificationArea = styled(NotificationArea)({
- position: 'absolute',
- left: 0,
- top: 0,
- right: 0,
-});
+export default function SelectLocationButtons() {
+ const tunnelState = useSelector((state) => state.connection.status.state);
-const StyledMain = styled.main({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
+ if (tunnelState === 'connecting' || tunnelState === 'connected') {
+ return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />;
+ } else {
+ return <SelectLocationButton />;
+ }
+}
-export default function Connect() {
+function SelectLocationButton(props: MultiButtonCompatibleProps) {
const history = useHistory();
- const { connectTunnel, disconnectTunnel, reconnectTunnel } = useAppContext();
- const connection = useSelector((state) => state.connection);
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
+ const tunnelState = useSelector((state) => state.connection.status.state);
const relaySettings = useSelector((state) => state.settings.relaySettings);
const relayLocations = useSelector((state) => state.settings.relayLocations);
const customLists = useSelector((state) => state.settings.customLists);
- const showSpinner =
- connection.status.state === 'connecting' || connection.status.state === 'disconnecting';
-
- const onSelectLocation = useCallback(() => {
- history.push(RoutePath.selectLocation, { transition: transitions.show });
- }, [history.push]);
-
const selectedRelayName = useMemo(
() => getRelayName(relaySettings, customLists, relayLocations),
[relaySettings, customLists, relayLocations],
);
- const onConnect = useCallback(async () => {
- try {
- await connectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to connect the tunnel: ${error.message}`);
- }
- }, []);
-
- const onDisconnect = useCallback(async () => {
- try {
- await disconnectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to disconnect the tunnel: ${error.message}`);
- }
- }, []);
-
- const onReconnect = useCallback(async () => {
- try {
- await reconnectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to reconnect the tunnel: ${error.message}`);
- }
- }, []);
+ const onSelectLocation = useCallback(() => {
+ history.push(RoutePath.selectLocation, { transition: transitions.show });
+ }, [history.push]);
return (
- <Layout>
- <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} />
- <StyledContainer>
- <Map />
- <Content>
- <StyledNotificationArea />
-
- <StyledMain>
- {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null}
-
- <TunnelControl
- tunnelState={connection.status}
- blockWhenDisconnected={blockWhenDisconnected}
- selectedRelayName={selectedRelayName}
- city={connection.city}
- country={connection.country}
- onConnect={onConnect}
- onDisconnect={onDisconnect}
- onReconnect={onReconnect}
- onSelectLocation={onSelectLocation}
- />
- </StyledMain>
- </Content>
- </StyledContainer>
- </Layout>
+ <StyledSmallButton
+ color={SmallButtonColor.blue}
+ onClick={onSelectLocation}
+ aria-label={sprintf(
+ messages.pgettext('accessibility', 'Select location. Current location is %(location)s'),
+ { location: selectedRelayName },
+ )}
+ {...props}>
+ {tunnelState === 'disconnected'
+ ? selectedRelayName
+ : messages.pgettext('tunnel-control', 'Switch location')}
+ </StyledSmallButton>
);
}
@@ -179,3 +118,26 @@ function getRelayName(
throw new Error('Unsupported relay settings.');
}
}
+
+function ReconnectButton(props: MultiButtonCompatibleProps) {
+ const { reconnectTunnel } = useAppContext();
+
+ const onReconnect = useCallback(async () => {
+ try {
+ await reconnectTunnel();
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to reconnect the tunnel: ${error.message}`);
+ }
+ }, []);
+
+ return (
+ <StyledReconnectButton
+ color={SmallButtonColor.blue}
+ onClick={onReconnect}
+ aria-label={messages.gettext('Reconnect')}
+ {...props}>
+ <ImageView height={24} width={24} source="icon-reload" tintColor="white" />
+ </StyledReconnectButton>
+ );
+}
diff --git a/gui/src/renderer/components/main-view/styles.ts b/gui/src/renderer/components/main-view/styles.ts
new file mode 100644
index 0000000000..58c6e85eb2
--- /dev/null
+++ b/gui/src/renderer/components/main-view/styles.ts
@@ -0,0 +1,7 @@
+import styled from 'styled-components';
+
+import Accordion from '../Accordion';
+
+export const ConnectionPanelAccordion = styled(Accordion)({
+ transition: 'height 300ms ease-out',
+});
diff --git a/gui/src/renderer/containers/ConnectionPanelContainer.tsx b/gui/src/renderer/containers/ConnectionPanelContainer.tsx
deleted file mode 100644
index b12902bc5d..0000000000
--- a/gui/src/renderer/containers/ConnectionPanelContainer.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { connect } from 'react-redux';
-import { bindActionCreators } from 'redux';
-
-import { ITunnelEndpoint, parseSocketAddress } from '../../shared/daemon-rpc-types';
-import ConnectionPanel, {
- IBridgeData,
- IInAddress,
- IObfuscationData,
- IOutAddress,
-} from '../components/ConnectionPanel';
-import { IReduxState, ReduxDispatch } from '../redux/store';
-import userInterfaceActions from '../redux/userinterface/actions';
-
-function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): IInAddress {
- const socketAddr = parseSocketAddress(tunnelEndpoint.address);
- return {
- ip: socketAddr.host,
- port: socketAddr.port,
- protocol: tunnelEndpoint.protocol,
- tunnelType: tunnelEndpoint.tunnelType,
- };
-}
-
-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;
- }
-
- const socketAddr = parseSocketAddress(endpoint.proxy.address);
- return {
- ip: socketAddr.host,
- port: socketAddr.port,
- protocol: endpoint.proxy.protocol,
- bridgeType: endpoint.proxy.proxyType,
- };
-}
-
-function tunnelEndpointToObfuscationEndpoint(
- endpoint: ITunnelEndpoint,
-): IObfuscationData | undefined {
- if (!endpoint.obfuscationEndpoint) {
- return undefined;
- }
-
- return {
- ip: endpoint.obfuscationEndpoint.address,
- port: endpoint.obfuscationEndpoint.port,
- protocol: endpoint.obfuscationEndpoint.protocol,
- obfuscationType: endpoint.obfuscationEndpoint.obfuscationType,
- };
-}
-
-const mapStateToProps = (state: IReduxState) => {
- const status = state.connection.status;
-
- const outAddress: IOutAddress = {
- ipv4: state.connection.ipv4,
- ipv6: state.connection.ipv6,
- };
-
- const inAddress: IInAddress | undefined =
- (status.state === 'connecting' || status.state === 'connected') && status.details
- ? 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)
- : undefined;
-
- const obfuscationEndpoint: IObfuscationData | undefined =
- (status.state === 'connecting' || status.state === 'connected') && status.details
- ? tunnelEndpointToObfuscationEndpoint(status.details.endpoint)
- : undefined;
-
- const daita =
- ((status.state === 'connected' || status.state === 'connecting') &&
- status.details?.endpoint.daita) ??
- false;
-
- return {
- isOpen: state.userInterface.connectionPanelVisible,
- hostname: state.connection.hostname,
- bridgeHostname: state.connection.bridgeHostname,
- entryHostname: state.connection.entryHostname,
- inAddress,
- entryLocationInAddress,
- bridgeInfo,
- outAddress,
- obfuscationEndpoint,
- daita,
- };
-};
-
-const mapDispatchToProps = (dispatch: ReduxDispatch) => {
- const userInterface = bindActionCreators(userInterfaceActions, dispatch);
-
- return {
- onToggle: userInterface.toggleConnectionPanel,
- };
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(ConnectionPanel);
diff --git a/gui/src/renderer/redux/connection/actions.ts b/gui/src/renderer/redux/connection/actions.ts
index 35af568fb2..8a3f98efe5 100644
--- a/gui/src/renderer/redux/connection/actions.ts
+++ b/gui/src/renderer/redux/connection/actions.ts
@@ -1,6 +1,7 @@
import {
AfterDisconnect,
- ErrorState,
+ ErrorStateDetails,
+ FeatureIndicator,
ILocation,
ITunnelStateRelayInfo,
} from '../../../shared/daemon-rpc-types';
@@ -8,11 +9,13 @@ import {
interface IConnectingAction {
type: 'CONNECTING';
details?: ITunnelStateRelayInfo;
+ featureIndicators?: Array<FeatureIndicator>;
}
interface IConnectedAction {
type: 'CONNECTED';
details: ITunnelStateRelayInfo;
+ featureIndicators?: Array<FeatureIndicator>;
}
interface IDisconnectedAction {
@@ -26,7 +29,7 @@ interface IDisconnectingAction {
interface IBlockedAction {
type: 'TUNNEL_ERROR';
- errorState: ErrorState;
+ errorState: ErrorStateDetails;
}
interface INewLocationAction {
@@ -48,17 +51,25 @@ export type ConnectionAction =
| IBlockedAction
| IUpdateBlockStateAction;
-function connecting(details?: ITunnelStateRelayInfo): IConnectingAction {
+function connecting(
+ details?: ITunnelStateRelayInfo,
+ featureIndicators?: Array<FeatureIndicator>,
+): IConnectingAction {
return {
type: 'CONNECTING',
details,
+ featureIndicators,
};
}
-function connected(details: ITunnelStateRelayInfo): IConnectedAction {
+function connected(
+ details: ITunnelStateRelayInfo,
+ featureIndicators?: Array<FeatureIndicator>,
+): IConnectedAction {
return {
type: 'CONNECTED',
details,
+ featureIndicators,
};
}
@@ -75,7 +86,7 @@ function disconnecting(afterDisconnect: AfterDisconnect): IDisconnectingAction {
};
}
-function blocked(errorState: ErrorState): IBlockedAction {
+function blocked(errorState: ErrorStateDetails): IBlockedAction {
return {
type: 'TUNNEL_ERROR',
errorState,
diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts
index ffb0fd1d84..b597d29a64 100644
--- a/gui/src/renderer/redux/connection/reducers.ts
+++ b/gui/src/renderer/redux/connection/reducers.ts
@@ -54,13 +54,21 @@ export default function (
case 'CONNECTING':
return {
...state,
- status: { state: 'connecting', details: action.details },
+ status: {
+ state: 'connecting',
+ details: action.details,
+ featureIndicators: action.featureIndicators,
+ },
};
case 'CONNECTED':
return {
...state,
- status: { state: 'connected', details: action.details },
+ status: {
+ state: 'connected',
+ details: action.details,
+ featureIndicators: action.featureIndicators,
+ },
};
case 'DISCONNECTED':
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index 295db8f323..ed588ff811 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -65,7 +65,7 @@ export enum TunnelParameterError {
customTunnelHostResolutionError,
}
-export type ErrorState =
+export type ErrorStateDetails =
| {
cause:
| ErrorStateCause.ipv6Unavailable
@@ -180,12 +180,48 @@ export interface ITunnelStateRelayInfo {
location?: ILocation;
}
+// The order of the variants match the priority order and can be sorted on.
+export enum FeatureIndicator {
+ daita,
+ quantumResistance,
+ multihop,
+ bridgeMode,
+ splitTunneling,
+ lockdownMode,
+ udp2tcp,
+ shadowsocks,
+ lanSharing,
+ dnsContentBlockers,
+ customDns,
+ serverIpOverride,
+ customMtu,
+ customMssFix,
+}
+
+export type DisconnectedState = { state: 'disconnected'; location?: Partial<ILocation> };
+export type ConnectingState = {
+ state: 'connecting';
+ details?: ITunnelStateRelayInfo;
+ featureIndicators?: Array<FeatureIndicator>;
+};
+export type ConnectedState = {
+ state: 'connected';
+ details: ITunnelStateRelayInfo;
+ featureIndicators?: Array<FeatureIndicator>;
+};
+export type DisconnectingState = {
+ state: 'disconnecting';
+ details: AfterDisconnect;
+ location?: Partial<ILocation>;
+};
+export type ErrorState = { state: 'error'; details: ErrorStateDetails };
+
export type TunnelState =
- | { state: 'disconnected'; location?: Partial<ILocation> }
- | { state: 'connecting'; details?: ITunnelStateRelayInfo }
- | { state: 'connected'; details: ITunnelStateRelayInfo }
- | { state: 'disconnecting'; details: AfterDisconnect; location?: Partial<ILocation> }
- | { state: 'error'; details: ErrorState };
+ | DisconnectedState
+ | ConnectingState
+ | ConnectedState
+ | DisconnectingState
+ | ErrorState;
export interface RelayLocationCountry extends Partial<RelayLocationCustomList> {
country: string;
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
index dae080bb29..af82748d7b 100644
--- a/gui/src/shared/notifications/error.ts
+++ b/gui/src/shared/notifications/error.ts
@@ -3,8 +3,8 @@ import { sprintf } from 'sprintf-js';
import { strings } from '../../config.json';
import {
AuthFailedError,
- ErrorState,
ErrorStateCause,
+ ErrorStateDetails,
TunnelParameterError,
TunnelState,
} from '../daemon-rpc-types';
@@ -87,7 +87,7 @@ export class ErrorNotificationProvider
}
}
- private getMessage(errorState: ErrorState): string {
+ private getMessage(errorState: ErrorStateDetails): string {
if (errorState.blockingError) {
if (errorState.cause === ErrorStateCause.setFirewallPolicyError) {
switch (process.platform ?? window.env.platform) {
@@ -229,7 +229,7 @@ export class ErrorNotificationProvider
}
}
- private getActions(errorState: ErrorState): InAppNotificationAction | void {
+ private getActions(errorState: ErrorStateDetails): InAppNotificationAction | void {
const platform = process.platform ?? window.env.platform;
if (errorState.cause === ErrorStateCause.setFirewallPolicyError && platform === 'linux') {
diff --git a/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts b/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts
index 6c079629fb..af32668efb 100644
--- a/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts
+++ b/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts
@@ -4,7 +4,6 @@ import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
import {
expectConnected,
- expectConnectedPq,
expectDisconnected,
expectError,
} from '../../shared/tunnel-state';
@@ -35,7 +34,7 @@ test('App should show disconnected tunnel state', async () => {
});
test('App should connect', async () => {
- await page.getByText('Secure my connection').click();
+ await page.getByText('Connect', { exact: true }).click();
await expectConnected(page);
const relay = page.getByTestId('hostname-line');
@@ -65,10 +64,12 @@ test('App should show correct WireGuard port', async () => {
await exec('mullvad obfuscation set mode off');
await exec('mullvad relay set tunnel wireguard --port=53');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':53'));
await exec('mullvad relay set tunnel wireguard --port=51820');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':51820'));
await exec('mullvad relay set tunnel wireguard --port=any');
@@ -80,10 +81,12 @@ test('App should show correct WireGuard transport protocol', async () => {
await exec('mullvad obfuscation set mode udp2tcp');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp('TCP'));
await exec('mullvad obfuscation set mode off');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp('UDP$'));
});
@@ -94,6 +97,7 @@ test('App should show correct tunnel protocol', async () => {
await exec('mullvad relay set tunnel-protocol openvpn');
await exec('mullvad relay set location se');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(tunnelProtocol).toHaveText('OpenVPN');
});
@@ -104,23 +108,28 @@ test('App should show correct OpenVPN transport protocol and port', async () =>
await expect(inData).toContainText(new RegExp('(TCP|UDP)$'));
await exec('mullvad relay set tunnel openvpn --transport-protocol udp --port 1195');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':1195'));
await exec('mullvad relay set tunnel openvpn --transport-protocol udp --port 1300');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':1300'));
await exec('mullvad relay set tunnel openvpn --transport-protocol tcp --port any');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':[0-9]+'));
await expect(inData).toContainText(new RegExp('TCP$'));
await exec('mullvad relay set tunnel openvpn --transport-protocol tcp --port 80');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':80'));
await exec('mullvad relay set tunnel openvpn --transport-protocol tcp --port 443');
await expectConnected(page);
+ await page.getByTestId('connection-panel-chevron').click();
await expect(inData).toContainText(new RegExp(':443'));
await exec('mullvad relay set tunnel openvpn --transport-protocol any');
@@ -144,29 +153,19 @@ test('App should enter blocked state', async () => {
await expectConnected(page);
});
-test('App should disconnect', async () => {
- await page.getByText('Disconnect').click();
- await expectDisconnected(page);
-});
-
-test('App should create quantum secure connection', async () => {
- await exec('mullvad tunnel set wireguard --quantum-resistant on');
- await page.getByText('Secure my connection').click();
-
- await expectConnectedPq(page);
-});
-
test('App should show multihop', async () => {
await exec('mullvad relay set tunnel wireguard --use-multihop=on');
- await expectConnectedPq(page);
+ await expectConnected(page);
const relay = page.getByTestId('hostname-line');
await expect(relay).toHaveText(new RegExp('^' + escapeRegExp(`${process.env.HOSTNAME} via`), 'i'));
await exec('mullvad relay set tunnel wireguard --use-multihop=off');
-
- await exec('mullvad tunnel set wireguard --quantum-resistant off');
await page.getByText('Disconnect').click();
});
+test('App should disconnect', async () => {
+ await page.getByText('Disconnect').click();
+ await expectDisconnected(page);
+});
test('App should become connected when other frontend connects', async () => {
await expectDisconnected(page);
diff --git a/gui/test/e2e/mocked/feature-indicators.spec.ts b/gui/test/e2e/mocked/feature-indicators.spec.ts
new file mode 100644
index 0000000000..0cb0b1b1ec
--- /dev/null
+++ b/gui/test/e2e/mocked/feature-indicators.spec.ts
@@ -0,0 +1,133 @@
+import { expect, test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { MockedTestUtils, startMockedApp } from './mocked-utils';
+import { FeatureIndicator, ILocation, ITunnelEndpoint, TunnelState } from '../../../src/shared/daemon-rpc-types';
+import { expectConnected } from '../shared/tunnel-state';
+
+const endpoint: ITunnelEndpoint = {
+ address: 'wg10:80',
+ protocol: 'tcp',
+ quantumResistant: false,
+ tunnelType: 'wireguard',
+ daita: false,
+};
+
+const mockDisconnectedLocation: ILocation = {
+ country: 'Sweden',
+ city: 'Gothenburg',
+ latitude: 58,
+ longitude: 12,
+ mullvadExitIp: false,
+};
+
+const mockConnectedLocation: ILocation = { ...mockDisconnectedLocation, mullvadExitIp: true };
+
+let page: Page;
+let util: MockedTestUtils;
+
+test.beforeAll(async () => {
+ ({ page, util } = await startMockedApp());
+});
+
+test.afterAll(async () => {
+ await page.close();
+});
+
+test('App should show no feature indicators', async () => {
+ await util.mockIpcHandle<ILocation>({
+ channel: 'location-get',
+ response: mockDisconnectedLocation,
+ });
+ await util.sendMockIpcResponse<TunnelState>({
+ channel: 'tunnel-',
+ response: {
+ state: 'connected',
+ details: { endpoint, location: mockConnectedLocation },
+ featureIndicators: undefined,
+ },
+ });
+
+ await expectConnected(page);
+ await expectFeatureIndicators(page, []);
+
+ const ellipsis = page.getByText(/^\d more.../);
+ await expect(ellipsis).not.toBeVisible();
+
+ await page.getByTestId('connection-panel-chevron').click();
+ await expect(ellipsis).not.toBeVisible();
+
+ await expectFeatureIndicators(page, []);
+});
+
+test('App should show feature indicators', async () => {
+ await util.mockIpcHandle<ILocation>({
+ channel: 'location-get',
+ response: mockDisconnectedLocation,
+ });
+ await util.sendMockIpcResponse<TunnelState>({
+ channel: 'tunnel-',
+ response: {
+ state: 'connected',
+ details: { endpoint, location: mockConnectedLocation },
+ featureIndicators: [
+ FeatureIndicator.daita,
+ FeatureIndicator.udp2tcp,
+ FeatureIndicator.customMssFix,
+ FeatureIndicator.customMtu,
+ FeatureIndicator.lanSharing,
+ FeatureIndicator.serverIpOverride,
+ FeatureIndicator.customDns,
+ FeatureIndicator.lockdownMode,
+ FeatureIndicator.quantumResistance,
+ FeatureIndicator.multihop,
+ ],
+ },
+ });
+
+ await expectConnected(page);
+ await expectFeatureIndicators(page, ["DAITA", "Quantum resistance"], false);
+ await expectHiddenFeatureIndicator(page, "Mssfix");
+
+ const ellipsis = page.getByText(/^\d more.../);
+ await expect(ellipsis).toBeVisible();
+
+ await page.getByTestId('connection-panel-chevron').click();
+ await expect(ellipsis).not.toBeVisible();
+
+ await expectFeatureIndicators(page, [
+ "DAITA",
+ "Quantum resistance",
+ "Mssfix",
+ "MTU",
+ "Obfuscation",
+ "Local network sharing",
+ "Lockdown mode",
+ "Multihop",
+ "Custom DNS",
+ "Server IP override",
+ ]);
+});
+
+async function expectHiddenFeatureIndicator(page: Page, hiddenIndicator: string) {
+ const indicators = page.getByTestId('feature-indicator');
+ const indicator = indicators.getByText(hiddenIndicator, { exact: true });
+
+ await expect(indicator).toHaveCount(1);
+ await expect(indicator).not.toBeVisible();
+}
+
+async function expectFeatureIndicators(
+ page: Page,
+ expectedIndicators: Array<string>,
+ only = true,
+) {
+ const indicators = page.getByTestId('feature-indicator');
+ if (only) {
+ await expect(indicators).toHaveCount(expectedIndicators.length);
+ }
+
+ for (const indicator of expectedIndicators) {
+ await expect(indicators.getByText(indicator, { exact: true })).toBeVisible();
+ }
+}
diff --git a/gui/test/e2e/mocked/tunnel-state.spec.ts b/gui/test/e2e/mocked/tunnel-state.spec.ts
index b4de405841..3e20dab6ec 100644
--- a/gui/test/e2e/mocked/tunnel-state.spec.ts
+++ b/gui/test/e2e/mocked/tunnel-state.spec.ts
@@ -49,7 +49,7 @@ test('App should show connecting tunnel state', async () => {
});
await util.sendMockIpcResponse<TunnelState>({
channel: 'tunnel-',
- response: { state: 'connecting' },
+ response: { state: 'connecting', featureIndicators: undefined },
});
await expectConnecting(page);
});
@@ -73,7 +73,7 @@ test('App should show connected tunnel state', async () => {
};
await util.sendMockIpcResponse<TunnelState>({
channel: 'tunnel-',
- response: { state: 'connected', details: { endpoint, location } },
+ response: { state: 'connected', details: { endpoint, location }, featureIndicators: undefined },
});
await expectConnected(page);
diff --git a/gui/test/e2e/shared/tunnel-state.ts b/gui/test/e2e/shared/tunnel-state.ts
index db7b7e3658..4d75f79d01 100644
--- a/gui/test/e2e/shared/tunnel-state.ts
+++ b/gui/test/e2e/shared/tunnel-state.ts
@@ -3,51 +3,54 @@ import { Page } from 'playwright';
import { colors } from '../../../src/config.json';
import { anyOf } from '../utils';
-const UNSECURED_COLOR = colors.red;
-const SECURE_COLOR = colors.green;
+const DISCONNECTED_COLOR = colors.red;
+const CONNECTED_COLOR = colors.green;
const WHITE_COLOR = colors.white;
-const UNSECURE_BUTTON_COLOR = anyOf(colors.red60, colors.red80);
-const SECURE_BUTTON_COLOR = anyOf(colors.green, colors.green90);
+const DISCONNECTED_BUTTON_COLOR = anyOf(colors.red, colors.red80);
+const DISCONNECTING_BUTTON_COLOR = anyOf(colors.green40);
+const CONNECTED_BUTTON_COLOR = anyOf(colors.green, colors.green90);
const getLabel = (page: Page) => page.locator('span[role="status"]');
const getHeader = (page: Page) => page.locator('header');
export async function expectDisconnected(page: Page) {
await expectTunnelState(page, {
- labelText: 'unsecured connection',
- labelColor: UNSECURED_COLOR,
- headerColor: UNSECURED_COLOR,
- buttonText: 'secure my connection',
- buttonColor: SECURE_BUTTON_COLOR,
+ labelText: 'disconnected',
+ labelColor: DISCONNECTED_COLOR,
+ headerColor: DISCONNECTED_COLOR,
+ buttonText: 'connect',
+ buttonColor: CONNECTED_BUTTON_COLOR,
});
}
export async function expectConnecting(page: Page) {
await expectTunnelState(page, {
- labelText: 'creating secure connection',
+ labelText: 'connecting',
labelColor: WHITE_COLOR,
- headerColor: SECURE_COLOR,
+ headerColor: CONNECTED_COLOR,
buttonText: 'cancel',
- buttonColor: UNSECURE_BUTTON_COLOR,
+ buttonColor: DISCONNECTED_BUTTON_COLOR,
});
}
export async function expectConnected(page: Page) {
await expectTunnelState(page, {
- labelText: 'secure connection',
- labelColor: SECURE_COLOR,
- headerColor: SECURE_COLOR,
+ labelText: 'connected',
+ labelColor: CONNECTED_COLOR,
+ headerColor: CONNECTED_COLOR,
buttonText: 'disconnect',
- buttonColor: UNSECURE_BUTTON_COLOR,
+ buttonColor: DISCONNECTED_BUTTON_COLOR,
});
}
export async function expectDisconnecting(page: Page) {
await expectTunnelState(page, {
- headerColor: UNSECURED_COLOR,
- buttonText: 'secure my connection',
- buttonColor: SECURE_BUTTON_COLOR,
+ labelText: 'disconnecting',
+ labelColor: WHITE_COLOR,
+ headerColor: DISCONNECTED_COLOR,
+ buttonText: 'connect',
+ buttonColor: DISCONNECTING_BUTTON_COLOR,
});
}
@@ -55,27 +58,7 @@ export async function expectError(page: Page) {
await expectTunnelState(page, {
labelText: 'blocked connection',
labelColor: WHITE_COLOR,
- headerColor: SECURE_COLOR,
- });
-}
-
-export async function expectConnectingPq(page: Page) {
- await expectTunnelState(page, {
- labelText: 'creating quantum secure connection',
- labelColor: WHITE_COLOR,
- headerColor: SECURE_COLOR,
- buttonText: 'cancel',
- buttonColor: UNSECURE_BUTTON_COLOR,
- });
-}
-
-export async function expectConnectedPq(page: Page) {
- await expectTunnelState(page, {
- labelText: 'quantum secure connection',
- labelColor: SECURE_COLOR,
- headerColor: SECURE_COLOR,
- buttonText: 'disconnect',
- buttonColor: UNSECURE_BUTTON_COLOR,
+ headerColor: CONNECTED_COLOR,
});
}
diff --git a/gui/test/unit/notification-evaluation.spec.ts b/gui/test/unit/notification-evaluation.spec.ts
index d7d5e1fea6..27de83cf29 100644
--- a/gui/test/unit/notification-evaluation.spec.ts
+++ b/gui/test/unit/notification-evaluation.spec.ts
@@ -107,7 +107,7 @@ describe('System notifications', () => {
const controller = createController();
const disconnectedState: TunnelState = { state: 'disconnected' };
- const connectingState: TunnelState = { state: 'connecting' };
+ const connectingState: TunnelState = { state: 'connecting', featureIndicators: undefined };
const result1 = controller.notifyTunnelState(disconnectedState, false, false, false, true);
const result2 = controller.notifyTunnelState(disconnectedState, false, false, false, false);
const result3 = controller.notifyTunnelState(connectingState, false, false, false, true);