diff options
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); |
