From e2167ef3c42006db2b0b5ba041f1199d0d3f306d Mon Sep 17 00:00:00 2001 From: Oskar Date: Tue, 13 Aug 2024 17:21:05 +0200 Subject: [PATCH 01/12] Add feature indicator plumbing --- gui/src/main/daemon-rpc.ts | 52 ++++++++++++++++++- gui/src/main/tunnel-state.ts | 2 +- gui/src/renderer/app.tsx | 4 +- gui/src/renderer/redux/connection/actions.ts | 21 ++++++-- gui/src/renderer/redux/connection/reducers.ts | 12 ++++- gui/src/shared/daemon-rpc-types.ts | 48 ++++++++++++++--- gui/src/shared/notifications/error.ts | 6 +-- gui/test/e2e/mocked/tunnel-state.spec.ts | 4 +- gui/test/unit/notification-evaluation.spec.ts | 2 +- 9 files changed, 127 insertions(+), 24 deletions(-) diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 65e3c6b1c591..f328329d8106 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, +): Array | 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.WIREGUARD]: 'wireguard', diff --git a/gui/src/main/tunnel-state.ts b/gui/src/main/tunnel-state.ts index 297a7e481e18..43ebe97ad6a8 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 e0655707c2e5..a4c76aa2d1a4 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/redux/connection/actions.ts b/gui/src/renderer/redux/connection/actions.ts index 35af568fb22b..8a3f98efe533 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; } interface IConnectedAction { type: 'CONNECTED'; details: ITunnelStateRelayInfo; + featureIndicators?: Array; } 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, +): IConnectingAction { return { type: 'CONNECTING', details, + featureIndicators, }; } -function connected(details: ITunnelStateRelayInfo): IConnectedAction { +function connected( + details: ITunnelStateRelayInfo, + featureIndicators?: Array, +): 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 ffb0fd1d8474..b597d29a645c 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 295db8f323fc..ed588ff811a5 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 }; +export type ConnectingState = { + state: 'connecting'; + details?: ITunnelStateRelayInfo; + featureIndicators?: Array; +}; +export type ConnectedState = { + state: 'connected'; + details: ITunnelStateRelayInfo; + featureIndicators?: Array; +}; +export type DisconnectingState = { + state: 'disconnecting'; + details: AfterDisconnect; + location?: Partial; +}; +export type ErrorState = { state: 'error'; details: ErrorStateDetails }; + export type TunnelState = - | { state: 'disconnected'; location?: Partial } - | { state: 'connecting'; details?: ITunnelStateRelayInfo } - | { state: 'connected'; details: ITunnelStateRelayInfo } - | { state: 'disconnecting'; details: AfterDisconnect; location?: Partial } - | { state: 'error'; details: ErrorState }; + | DisconnectedState + | ConnectingState + | ConnectedState + | DisconnectingState + | ErrorState; export interface RelayLocationCountry extends Partial { country: string; diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts index dae080bb291b..2871fea4d1a8 100644 --- a/gui/src/shared/notifications/error.ts +++ b/gui/src/shared/notifications/error.ts @@ -3,7 +3,7 @@ import { sprintf } from 'sprintf-js'; import { strings } from '../../config.json'; import { AuthFailedError, - ErrorState, + ErrorStateDetails, ErrorStateCause, TunnelParameterError, TunnelState, @@ -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/mocked/tunnel-state.spec.ts b/gui/test/e2e/mocked/tunnel-state.spec.ts index b4de4058410b..3e20dab6ec8e 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({ 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({ channel: 'tunnel-', - response: { state: 'connected', details: { endpoint, location } }, + response: { state: 'connected', details: { endpoint, location }, featureIndicators: undefined }, }); await expectConnected(page); diff --git a/gui/test/unit/notification-evaluation.spec.ts b/gui/test/unit/notification-evaluation.spec.ts index d7d5e1fea664..27de83cf2984 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); From 1daeff317e07fd2067e3f42d31b01935744fcfc8 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:39:29 +0200 Subject: [PATCH 02/12] Update reload icon --- gui/assets/images/icon-reload.svg | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/gui/assets/images/icon-reload.svg b/gui/assets/images/icon-reload.svg index 873727a0a506..6d443ac8b4c0 100644 --- a/gui/assets/images/icon-reload.svg +++ b/gui/assets/images/icon-reload.svg @@ -1 +1,10 @@ - + + + + + + + + + + From 3415f2e6e60faef6af419b54d4f4b5dd312720d5 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:43:26 +0200 Subject: [PATCH 03/12] Use color variable for NotificationBanner background --- gui/src/renderer/components/NotificationBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx index 3b66abb204b6..ad84ad369789 100644 --- a/gui/src/renderer/components/NotificationBanner.tsx +++ b/gui/src/renderer/components/NotificationBanner.tsx @@ -133,7 +133,7 @@ const Collapsible = styled.div((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`, From 6af3e1650bedca66955e10da09cb2f0def314ab3 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:40:15 +0200 Subject: [PATCH 04/12] Add className prop to Accordion --- gui/src/renderer/components/Accordion.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx index f70258f1032c..70ae35c988ba 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 { return ( From b90b3d13fc1518f45d9d8599000ec45fc8574050 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:41:24 +0200 Subject: [PATCH 05/12] Make MultiButton less specific to AppButton --- gui/src/renderer/components/MultiButton.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/gui/src/renderer/components/MultiButton.tsx b/gui/src/renderer/components/MultiButton.tsx index e1fa3d379e22..3129abcbb220 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; - sideButton: React.ComponentType; + mainButton: React.ComponentType; + sideButton: React.ComponentType; } export function MultiButton(props: IMultiButtonProps) { return ( - + ); From f6d1f4897ed411c501a123dcc6fd6f5c8c1533e0 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:45:15 +0200 Subject: [PATCH 06/12] Make SmallButton compatible with MultiButton --- gui/src/renderer/components/SmallButton.tsx | 93 +++++++++++++-------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx index f80552bee7df..e462a002b320 100644 --- a/gui/src/renderer/components/SmallButton.tsx +++ b/gui/src/renderer/components/SmallButton.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; import { smallText } from './common-styles'; +import { MultiButtonCompatibleProps } from './MultiButton'; export enum SmallButtonColor { blue, @@ -26,48 +27,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`, - - [`${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%', - }, - - '&&:hover': { - background: buttonColors.backgroundHover, - }, - }; - }, -); +interface StyledSmallButtonProps { + $color?: SmallButtonColor; + disabled?: boolean; +} + +const StyledSmallButton = styled.button(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`, + }, + + [`${SmallButtonGrid} &&`]: { + flex: '1 0 auto', + marginLeft: 0, + minWidth: `calc(50% - ${BUTTON_GROUP_GAP / 2}px)`, + maxWidth: '100%', + }, + + '&&: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, 'onClick' | 'color'> { + extends Omit, 'onClick' | 'color'>, + MultiButtonCompatibleProps { onClick: () => void; - children: string; + children: React.ReactNode; color?: SmallButtonColor; } export function SmallButton(props: SmallButtonProps) { - const { color, ...otherProps } = props; - return ; + const { color, textOffset, children, ...otherProps } = props; + return ( + + {textOffset && textOffset > 0 ? : null} + {children} + {textOffset && textOffset < 0 ? : null} + + ); } export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) => ({ From 86da8428eae571b22dee94a99e5ae00812516f7e Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:44:58 +0200 Subject: [PATCH 07/12] Add green to the possible colors of SmallButton --- gui/src/renderer/components/SmallButton.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx index e462a002b320..e88d71995248 100644 --- a/gui/src/renderer/components/SmallButton.tsx +++ b/gui/src/renderer/components/SmallButton.tsx @@ -8,6 +8,7 @@ import { MultiButtonCompatibleProps } from './MultiButton'; export enum SmallButtonColor { blue, red, + green, } function getButtonColors(color?: SmallButtonColor, disabled?: boolean) { @@ -17,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, From c9050739874b153d14efb9dfd363fd140278390f Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:45:54 +0200 Subject: [PATCH 08/12] Implement main view connection island --- gui/src/renderer/components/AppRouter.tsx | 4 +- .../renderer/components/ConnectionPanel.tsx | 227 ------------- .../components/ConnectionPanelDisclosure.tsx | 49 --- gui/src/renderer/components/TunnelControl.tsx | 305 ------------------ .../main-view/ConnectionActionButton.tsx | 63 ++++ .../main-view/ConnectionDetails.tsx | 202 ++++++++++++ .../components/main-view/ConnectionPanel.tsx | 113 +++++++ .../main-view/ConnectionPanelChevron.tsx | 37 +++ .../components/main-view/ConnectionStatus.tsx | 58 ++++ .../main-view/FeatureIndicators.tsx | 248 ++++++++++++++ .../components/main-view/Hostname.tsx | 69 ++++ .../components/main-view/Location.tsx | 41 +++ .../components/main-view/MainView.tsx | 67 ++++ .../SelectLocationButton.tsx} | 166 ++++------ .../renderer/components/main-view/styles.ts | 7 + .../containers/ConnectionPanelContainer.tsx | 124 ------- 16 files changed, 971 insertions(+), 809 deletions(-) delete mode 100644 gui/src/renderer/components/ConnectionPanel.tsx delete mode 100644 gui/src/renderer/components/ConnectionPanelDisclosure.tsx delete mode 100644 gui/src/renderer/components/TunnelControl.tsx create mode 100644 gui/src/renderer/components/main-view/ConnectionActionButton.tsx create mode 100644 gui/src/renderer/components/main-view/ConnectionDetails.tsx create mode 100644 gui/src/renderer/components/main-view/ConnectionPanel.tsx create mode 100644 gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx create mode 100644 gui/src/renderer/components/main-view/ConnectionStatus.tsx create mode 100644 gui/src/renderer/components/main-view/FeatureIndicators.tsx create mode 100644 gui/src/renderer/components/main-view/Hostname.tsx create mode 100644 gui/src/renderer/components/main-view/Location.tsx create mode 100644 gui/src/renderer/components/main-view/MainView.tsx rename gui/src/renderer/components/{Connect.tsx => main-view/SelectLocationButton.tsx} (50%) create mode 100644 gui/src/renderer/components/main-view/styles.ts delete mode 100644 gui/src/renderer/containers/ConnectionPanelContainer.tsx diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index 0f85601a92d7..ca17d76f1f9c 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() { - + diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx deleted file mode 100644 index 03e2753edebf..000000000000 --- 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 { - public render() { - const { outAddress } = this.props; - const entryPoint = this.getEntryPoint(); - - return ( - - {this.props.hostname && ( -
- - {this.hostnameLine()} - -
- )} - - {this.props.isOpen && this.props.hostname && ( - - {this.props.inAddress && ( - - {this.transportLine()} - - )} - - {entryPoint && ( - - {messages.pgettext('connection-info', 'In')} - - {`${entryPoint.ip}:${entryPoint.port} ${entryPoint.protocol.toUpperCase()}`} - - - )} - - {outAddress && (outAddress.ipv4 || outAddress.ipv6) && ( - - {messages.pgettext('connection-info', 'Out')} - - {outAddress.ipv4 && {outAddress.ipv4}} - {outAddress.ipv6 && {outAddress.ipv6}} - - - )} - - )} -
- ); - } - - 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 3941c3d88fdb..000000000000 --- 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 ( - - {props.children} - - - ); -} diff --git a/gui/src/renderer/components/TunnelControl.tsx b/gui/src/renderer/components/TunnelControl.tsx deleted file mode 100644 index 0d11762d38db..000000000000 --- 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 { - 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 ( - - - - - {this.renderCountry()} - {this.renderCity()} - - - -
- - {this.switchLocationButton()} - - -
-
- ); - } - - case 'connected': { - const displayStyle = pq ? SecuredDisplayStyle.securedPq : SecuredDisplayStyle.secured; - return ( - - - - - {this.renderCountry()} - {this.renderCity()} - - - -
- - {this.switchLocationButton()} - - -
-
- ); - } - - case 'error': - if ( - this.props.tunnelState.state === 'error' && - this.props.tunnelState.details.blockingError - ) { - return ( - - - - -
- - {this.switchLocationButton()} - - -
-
- ); - } else { - return ( - - - - -
- - {this.switchLocationButton()} - - -
-
- ); - } - - case 'disconnecting': - return ( - - - - - {this.renderCountry()} - - - -
- - {this.selectLocationButton()} - {this.connectButton()} - -
-
- ); - - case 'disconnected': { - const displayStyle = this.props.blockWhenDisconnected - ? SecuredDisplayStyle.blocked - : SecuredDisplayStyle.unsecured; - return ( - - - - - {this.renderCountry()} - - - -
- - {this.selectLocationButton()} - {this.connectButton()} - -
-
- ); - } - - default: - throw new Error(`Unknown TunnelState: ${this.props.tunnelState}`); - } - } - - private renderCity() { - const city = this.props.city === undefined ? '' : relayLocations.gettext(this.props.city); - return ( - - {city} - - ); - } - - private renderCountry() { - const country = - this.props.country === undefined ? '' : relayLocations.gettext(this.props.country); - return ( - - {country} - - ); - } - - private switchLocationButton() { - return ( - - {messages.pgettext('tunnel-control', 'Switch location')} - - ); - } - - private selectLocationButton() { - return ( - - {this.props.selectedRelayName} - - - ); - } - - private connectButton() { - return ( - - {messages.pgettext('tunnel-control', 'Secure my connection')} - - ); - } - - private disconnectButton = (props: AppButton.IProps) => { - return ( - - {messages.gettext('Disconnect')} - - ); - }; - - private cancelButton = (props: AppButton.IProps) => { - return ( - - {messages.gettext('Cancel')} - - ); - }; - - private dismissButton = (props: AppButton.IProps) => { - return ( - - {messages.gettext('Dismiss')} - - ); - }; - - private reconnectButton = (props: AppButton.IProps) => { - return ( - - - - - - ); - }; -} 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 000000000000..ad579da671ec --- /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 ; + } else { + return ; + } +} + +function ConnectButton(props: Partial[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 ( + + {messages.pgettext('tunnel-control', 'Connect')} + + ); +} + +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 ( + + {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')} + + ); +} 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 000000000000..c6bff32ef34b --- /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 ( + + + {messages.pgettext('connect-view', 'Connection details')} + + + {showDetails && + tunnelState.details !== undefined && + tunnelTypeToString(tunnelState.details.endpoint.tunnelType)} + + + + {messages.pgettext('connection-info', 'In')} + + + {hasEntry ? `${entry.ip}:${entry.port} ${entry.protocol.toUpperCase()}` : ''} + + + {messages.pgettext('connection-info', 'Out')} + + + {connection.ipv4 && ( + {connection.ipv4} + )} + {connection.ipv6 && ( + {connection.ipv6} + )} + + + + ); +} + +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 000000000000..29c413970309 --- /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 ( + + {allowExpand && ( + + )} + + + + + + + + + + + + + + + + + ); +} 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 000000000000..d62ec6402d28 --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx @@ -0,0 +1,37 @@ +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 ( + + + + ); +} 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 000000000000..1745fdaf11f7 --- /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 ( + + {text} + + ); +} + +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 000000000000..b3152c2f458a --- /dev/null +++ b/gui/src/renderer/components/main-view/FeatureIndicators.tsx @@ -0,0 +1,248 @@ +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(); + const ellipsisSpacerRef = useStyledRef(); + const featureIndicatorsContainerRef = useStyledRef(); + + 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 ( + 0}> + + + {messages.pgettext('connect-view', 'Active features')} + + + + {featureIndicators.current.sort().map((indicator) => ( + + {getFeatureIndicatorLabel(indicator)} + + ))} + + {!props.expanded && ( + <> + + + { + // Mock amount for the spacer ellipsis. This needs to be wider than the real + // ellipsis will ever be. + sprintf(ellipsis, { amount: 222 }) + } + + + )} + + + + ); +} + +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 000000000000..097e3b291b1c --- /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 ( + + + {text} + + + ); +} + +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 000000000000..268539415849 --- /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 ( + + + {text} + + + ); +} + +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 000000000000..9327094a5186 --- /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 ( + + + + + + + + + {showSpinner ? : null} + + + + + + + ); +} diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx similarity index 50% rename from gui/src/renderer/components/Connect.tsx rename to gui/src/renderer/components/main-view/SelectLocationButton.tsx index 1a557ffa3df8..30b132a84eeb 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'; - -const StyledContainer = styled(Container)({ - position: 'relative', +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 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 ; + } else { + return ; + } +} -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 ( - - - - - - - - - {showSpinner ? : null} - - - - - - + + {tunnelState === 'disconnected' + ? selectedRelayName + : messages.pgettext('tunnel-control', 'Switch location')} + ); } @@ -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 ( + + + + ); +} 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 000000000000..58c6e85eb2ae --- /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 b12902bc5d80..000000000000 --- 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); From e3f12ed56ff05d5d7db7adbbb7243ea2c2fe6369 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:27:50 +0200 Subject: [PATCH 09/12] Update translations --- gui/locales/messages.pot | 59 +++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 725863c57bfd..6f73b02d4174 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 "" @@ -559,10 +586,18 @@ msgctxt "connect-container" 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 "" From 97366cb723efb2e53cbb8e8f24cdf832de1cdb9d Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 13:48:27 +0200 Subject: [PATCH 10/12] Fix error.ts import order --- gui/src/shared/notifications/error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts index 2871fea4d1a8..af82748d7b04 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, - ErrorStateDetails, ErrorStateCause, + ErrorStateDetails, TunnelParameterError, TunnelState, } from '../daemon-rpc-types'; From 897fa5eaa1cc5bcd140047ac454ab846fec5e62f Mon Sep 17 00:00:00 2001 From: Oskar Date: Mon, 19 Aug 2024 14:39:47 +0200 Subject: [PATCH 11/12] Fix tunnel-state tests --- .../state-dependent/tunnel-state.spec.ts | 33 +++++----- gui/test/e2e/shared/tunnel-state.ts | 63 +++++++------------ 2 files changed, 39 insertions(+), 57 deletions(-) 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 6c079629fb35..af32668efb77 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/shared/tunnel-state.ts b/gui/test/e2e/shared/tunnel-state.ts index db7b7e365878..4d75f79d01bb 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, }); } From 6ceed1dffca2fc0c4b867fe60638db5bf29f1065 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 16 Aug 2024 15:25:04 +0200 Subject: [PATCH 12/12] Add feature-indicator test --- .../main-view/ConnectionPanelChevron.tsx | 5 +- .../main-view/FeatureIndicators.tsx | 5 +- .../e2e/mocked/feature-indicators.spec.ts | 133 ++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 gui/test/e2e/mocked/feature-indicators.spec.ts diff --git a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx index d62ec6402d28..a50fada589e8 100644 --- a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx +++ b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx @@ -25,7 +25,10 @@ interface IProps { export default function ConnectionPanelChevron(props: IProps) { return ( - + {featureIndicators.current.sort().map((indicator) => ( - + {getFeatureIndicatorLabel(indicator)} ))} 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 000000000000..0cb0b1b1ec93 --- /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({ + channel: 'location-get', + response: mockDisconnectedLocation, + }); + await util.sendMockIpcResponse({ + 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({ + channel: 'location-get', + response: mockDisconnectedLocation, + }); + await util.sendMockIpcResponse({ + 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, + 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(); + } +}