diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-01-15 17:33:28 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-07-02 16:15:01 +0200 |
| commit | de95e3465268f0489b00bb1ee2c1a04fc54bb3e5 (patch) | |
| tree | f3b75fcdf5c9acaf84f0a8ab8ecea9acaffae132 /gui | |
| parent | 694ad87692c378a621c7c4a669c0ff88ca78d633 (diff) | |
| download | mullvadvpn-de95e3465268f0489b00bb1ee2c1a04fc54bb3e5.tar.xz mullvadvpn-de95e3465268f0489b00bb1ee2c1a04fc54bb3e5.zip | |
Rework linux split tunneling view to include Windows view
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/src/config.json | 1 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 4 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettingsStyles.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx | 318 | ||||
| -rw-r--r-- | gui/src/renderer/components/SplitTunnelingSettings.tsx | 593 | ||||
| -rw-r--r-- | gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx | 164 | ||||
| -rw-r--r-- | gui/src/renderer/containers/AdvancedSettingsPage.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/actions.ts | 24 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/reducers.ts | 4 |
11 files changed, 785 insertions, 341 deletions
diff --git a/gui/src/config.json b/gui/src/config.json index 6407c017f5..c573e0214b 100644 --- a/gui/src/config.json +++ b/gui/src/config.json @@ -16,6 +16,7 @@ "red": "rgb(227, 64, 57)", "darkYellow": "rgb(142, 78, 19)", "yellow": "rgb(255, 213, 36)", + "black": "rgb(0, 0, 0)", "white": "rgb(255, 255, 255)", "white80": "rgba(255, 255, 255, 0.8)", "white60": "rgba(255, 255, 255, 0.6)", diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 6700db0a5c..37b7550cd5 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -1191,10 +1191,10 @@ class ApplicationMain { throw Error('linuxSplitTunneling.getApplications function called without being imported'); } }); - IpcMainEventChannel.windowsSplitTunneling.handleGetApplications((updateCache: boolean) => { + IpcMainEventChannel.windowsSplitTunneling.handleGetApplications((updateCaches: boolean) => { if (windowsSplitTunneling) { return windowsSplitTunneling.getApplications({ - updateCache, + updateCaches, }); } else { throw Error('windowsSplitTunneling.getApplications function called without being imported'); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 93529b87c2..a32ca8e95c 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -472,8 +472,8 @@ export default class AppRenderer { return IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application); } - public setSplitTunnelingState(enabled: boolean) { - consumePromise(IpcRendererEventChannel.windowsSplitTunneling.setState(enabled)); + public setSplitTunnelingState(enabled: boolean): Promise<void> { + return IpcRendererEventChannel.windowsSplitTunneling.setState(enabled); } public addSplitTunnelingApplication(application: IApplication | string): Promise<void> { @@ -742,7 +742,7 @@ export default class AppRenderer { reduxSettings.updateWireguardMtu(newSettings.tunnelOptions.wireguard.mtu); reduxSettings.updateBridgeState(newSettings.bridgeState); reduxSettings.updateDnsOptions(newSettings.tunnelOptions.dns); - reduxSettings.updateSplitTunneling(newSettings.splitTunnel); + reduxSettings.updateSplitTunnelingState(newSettings.splitTunnel); this.setRelaySettings(newSettings.relaySettings); this.setBridgeSettings(newSettings.bridgeSettings); diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index ef59798a46..eb906d3747 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -85,7 +85,7 @@ interface IProps { setWireguardRelayPort: (port?: number) => void; setDnsOptions: (dns: IDnsOptions) => Promise<void>; onViewWireguardKeys: () => void; - onViewLinuxSplitTunneling: () => void; + onViewSplitTunneling: () => void; onClose: () => void; } @@ -438,9 +438,11 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { </Cell.Label> <Cell.Icon height={12} width={7} source="icon-chevron" /> </Cell.CellButton> + </StyledButtonCellGroup> - {window.platform === 'linux' && ( - <Cell.CellButton onClick={this.props.onViewLinuxSplitTunneling}> + <StyledButtonCellGroup> + {(window.platform === 'linux' || window.platform === 'win32') && ( + <Cell.CellButton onClick={this.props.onViewSplitTunneling}> <Cell.Label> {messages.pgettext('advanced-settings-view', 'Split tunneling')} </Cell.Label> diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index b618db0844..78b5049f4b 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -33,7 +33,7 @@ export const StyledButtonCellGroup = styled.div({ display: 'flex', flexDirection: 'column', flex: 1, - marginBottom: '22px', + marginBottom: '20px', }); export const StyledNoWireguardKeyErrorContainer = styled(Cell.Footer)({ diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx deleted file mode 100644 index fea2fe23db..0000000000 --- a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { messages } from '../../shared/gettext'; -import { ILinuxSplitTunnelingApplication } from '../../shared/application-types'; -import consumePromise from '../../shared/promise'; -import { useAppContext } from '../context'; -import * as AppButton from './AppButton'; -import * as Cell from './cell'; -import ImageView from './ImageView'; -import { Container, Layout } from './Layout'; -import { ModalContainer, ModalAlert, ModalAlertType } from './Modal'; -import { - BackBarItem, - NavigationBar, - NavigationContainer, - NavigationItems, - NavigationScrollbars, - TitleBarItem, -} from './NavigationBar'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; -import { useHistory } from '../lib/history'; - -const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ - position: 'absolute', - zIndex: 2, - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: '#000000', - opacity: 0.6, - display: props.show ? 'block' : 'none', -})); - -const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -const StyledCellButton = styled(Cell.CellButton)((props: { lookDisabled: boolean }) => ({ - ':not(:disabled):hover': { - backgroundColor: props.lookDisabled ? colors.blue : undefined, - }, -})); - -const disabledApplication = (props: { lookDisabled: boolean }) => ({ - opacity: props.lookDisabled ? 0.6 : undefined, -}); - -const StyledIcon = styled(Cell.UntintedIcon)(disabledApplication, { - marginRight: '12px', -}); - -const StyledCellLabel = styled(Cell.Label)(disabledApplication, { - fontFamily: 'Open Sans', - fontWeight: 'normal', - fontSize: '16px', -}); - -const StyledIconPlaceholder = styled.div({ - width: '35px', - marginRight: '12px', -}); - -const StyledApplicationListContent = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const StyledApplicationListAnimation = styled.div({}, (props: { height?: number }) => ({ - overflow: 'hidden', - height: props.height ? `${props.height}px` : 'auto', - transition: 'height 500ms ease-in-out', - marginBottom: '20px', -})); - -const StyledSpinnerRow = styled.div({ - display: 'flex', - justifyContent: 'center', - padding: '8px 0', - background: colors.blue40, -}); - -const StyledBrowseButton = styled(AppButton.BlueButton)({ - margin: '0 22px 22px', -}); - -export default function LinuxSplitTunnelingSettings() { - const { - getSplitTunnelingApplications, - launchExcludedApplication, - showOpenDialog, - } = useAppContext(); - const history = useHistory(); - - const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); - const [applicationListHeight, setApplicationListHeight] = useState<number>(); - const [browsing, setBrowsing] = useState(false); - const [browseError, setBrowseError] = useState<string>(); - - const applicationListRef = useRef() as React.RefObject<HTMLDivElement>; - - const launchApplication = useCallback( - async (application: ILinuxSplitTunnelingApplication | string) => { - const result = await launchExcludedApplication(application); - if ('error' in result) { - setBrowseError(result.error); - } - }, - [], - ); - - const launchWithFilePicker = useCallback(async () => { - setBrowsing(true); - const file = await showOpenDialog({ - properties: ['openFile'], - buttonLabel: messages.pgettext('split-tunneling-view', 'Launch application'), - }); - setBrowsing(false); - - if (file.filePaths[0]) { - await launchApplication(file.filePaths[0]); - } - }, []); - - const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []); - - useEffect(() => { - consumePromise(getSplitTunnelingApplications().then(setApplications)); - }, []); - - useLayoutEffect(() => { - const height = applicationListRef.current?.getBoundingClientRect().height; - setApplicationListHeight(height); - }, [applications]); - - return ( - <> - <StyledPageCover show={browsing} /> - <ModalContainer> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <BackBarItem action={history.pop}> - { - // TRANSLATORS: Back button in navigation bar - messages.pgettext('navigation-bar', 'Advanced') - } - </BackBarItem> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('split-tunneling-nav', 'Split tunneling') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <StyledNavigationScrollbars> - <StyledContent> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('split-tunneling-view', 'Split tunneling')} - </HeaderTitle> - <HeaderSubTitle> - {messages.pgettext( - 'split-tunneling-view', - 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', - )} - </HeaderSubTitle> - </SettingsHeader> - - <StyledApplicationListAnimation height={applicationListHeight}> - <StyledApplicationListContent ref={applicationListRef}> - {applications === undefined ? ( - <StyledSpinnerRow> - <ImageView source="icon-spinner" height={60} width={60} /> - </StyledSpinnerRow> - ) : ( - applications.map((application) => ( - <ApplicationRow - key={application.absolutepath} - application={application} - launchApplication={launchApplication} - /> - )) - )} - </StyledApplicationListContent> - </StyledApplicationListAnimation> - - <StyledBrowseButton onClick={launchWithFilePicker}> - {messages.pgettext('split-tunneling-view', 'Browse')} - </StyledBrowseButton> - </StyledContent> - </StyledNavigationScrollbars> - </NavigationContainer> - </StyledContainer> - </Layout> - {browseError && ( - <ModalAlert - type={ModalAlertType.warning} - iconColor={colors.red} - message={sprintf( - // TRANSLATORS: Error message showed in a dialog when an application failes to launch. - messages.pgettext( - 'split-tunneling-view', - 'Unable to launch selection. %(detailedErrorMessage)s', - ), - { detailedErrorMessage: browseError }, - )} - buttons={[ - <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}> - {messages.gettext('Close')} - </AppButton.BlueButton>, - ]} - close={hideBrowseFailureDialog} - /> - )} - </ModalContainer> - </> - ); -} - -interface IApplicationRowProps { - application: ILinuxSplitTunnelingApplication; - launchApplication: (application: ILinuxSplitTunnelingApplication) => void; -} - -function ApplicationRow(props: IApplicationRowProps) { - const [showWarning, setShowWarning] = useState(false); - - const launch = useCallback(() => { - setShowWarning(false); - props.launchApplication(props.application); - }, [props.launchApplication, props.application]); - - const showWarningDialog = useCallback(() => setShowWarning(true), []); - const hideWarningDialog = useCallback(() => setShowWarning(false), []); - - const disabled = props.application.warning === 'launches-elsewhere'; - const warningColor = disabled ? colors.red : colors.yellow; - const warningMessage = disabled - ? sprintf( - messages.pgettext( - 'split-tunneling-view', - '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.', - ), - { - applicationName: props.application.name, - }, - ) - : sprintf( - messages.pgettext( - 'split-tunneling-view', - 'If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel.', - ), - { - applicationName: props.application.name, - }, - ); - const warningDialogButtons = disabled - ? [ - <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> - {messages.gettext('Back')} - </AppButton.BlueButton>, - ] - : [ - <AppButton.BlueButton key="launch" onClick={launch}> - {messages.pgettext('split-tunneling-view', 'Launch')} - </AppButton.BlueButton>, - <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> - {messages.gettext('Cancel')} - </AppButton.BlueButton>, - ]; - - return ( - <> - <StyledCellButton - onClick={props.application.warning ? showWarningDialog : launch} - lookDisabled={disabled}> - {props.application.icon ? ( - <StyledIcon - source={props.application.icon} - width={35} - height={35} - lookDisabled={disabled} - /> - ) : ( - <StyledIconPlaceholder /> - )} - <StyledCellLabel lookDisabled={disabled}>{props.application.name}</StyledCellLabel> - {props.application.warning && <Cell.Icon source="icon-alert" tintColor={warningColor} />} - </StyledCellButton> - {showWarning && ( - <ModalAlert - type={ModalAlertType.warning} - iconColor={warningColor} - message={warningMessage} - buttons={warningDialogButtons} - close={hideWarningDialog} - /> - )} - </> - ); -} diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx new file mode 100644 index 0000000000..ca2c7abdae --- /dev/null +++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx @@ -0,0 +1,593 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { sprintf } from 'sprintf-js'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { IApplication, ILinuxSplitTunnelingApplication } from '../../shared/application-types'; +import consumePromise from '../../shared/promise'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { useAsyncEffect } from '../lib/utilityHooks'; +import { IReduxState } from '../redux/store'; +import Accordion from './Accordion'; +import * as AppButton from './AppButton'; +import * as Cell from './cell'; +import CustomScrollbars from './CustomScrollbars'; +import ImageView from './ImageView'; +import { Layout } from './Layout'; +import { ModalContainer, ModalAlert, ModalAlertType } from './Modal'; +import { + BackBarItem, + NavigationBar, + NavigationContainer, + NavigationItems, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; +import { + StyledPageCover, + StyledContainer, + StyledNavigationScrollbars, + StyledContent, + StyledCellButton, + StyledIcon, + StyledCellLabel, + StyledIconPlaceholder, + StyledApplicationListContent, + StyledApplicationListAnimation, + StyledSpinnerRow, + StyledBrowseButton, + StyledSearchInput, + StyledClearButton, + StyledSearchIcon, + StyledClearIcon, + StyledNoResultText, + StyledSearchContainer, + StyledNoResult, + StyledNoResultSearchTerm, + StyledDisabledWarning, +} from './SplitTunnelingSettingsStyles'; + +export default function SplitTunneling() { + const { pop } = useHistory(); + const [browsing, setBrowsing] = useState(false); + const scrollbarsRef = useRef() as React.RefObject<CustomScrollbars>; + + const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]); + + return ( + <> + <StyledPageCover show={browsing} /> + <ModalContainer> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <BackBarItem action={pop}> + { + // TRANSLATORS: Back button in navigation bar + messages.pgettext('navigation-bar', 'Advanced') + } + </BackBarItem> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('split-tunneling-nav', 'Split tunneling') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <StyledNavigationScrollbars ref={scrollbarsRef}> + <StyledContent> + <PlatformSpecificSplitTunnelingSettings + setBrowsing={setBrowsing} + scrollToTop={scrollToTop} + /> + </StyledContent> + </StyledNavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </ModalContainer> + </> + ); +} + +interface IPlatformSplitTunnelingSettingsProps { + setBrowsing: (value: boolean) => void; + scrollToTop: () => void; +} + +function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + switch (window.platform) { + case 'linux': + return <LinuxSplitTunnelingSettings {...props} />; + case 'win32': + return <WindowsSplitTunnelingSettings {...props} />; + default: + throw new Error(`Split tunneling not implemented on ${window.platform}`); + } +} + +function useFilePicker( + buttonLabel: string, + setOpen: (value: boolean) => void, + select: (path: string) => void, + filter?: { name: string; extensions: string[] }, +) { + const { showOpenDialog } = useAppContext(); + + return useCallback(async () => { + setOpen(true); + const file = await showOpenDialog({ + properties: ['openFile'], + buttonLabel, + filters: filter ? [filter] : undefined, + }); + setOpen(false); + + if (file.filePaths[0]) { + select(file.filePaths[0]); + } + }, [buttonLabel, setOpen, select]); +} + +function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + const { getLinuxSplitTunnelingApplications, launchExcludedApplication } = useAppContext(); + + const [searchTerm, setSearchTerm] = useState(''); + const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); + const [browseError, setBrowseError] = useState<string>(); + + useEffect(() => consumePromise(getLinuxSplitTunnelingApplications().then(setApplications)), []); + + const launchApplication = useCallback( + async (application: ILinuxSplitTunnelingApplication | string) => { + const result = await launchExcludedApplication(application); + if ('error' in result) { + setBrowseError(result.error); + } + }, + [launchExcludedApplication], + ); + + const launchWithFilePicker = useFilePicker( + messages.pgettext('split-tunneling-view', 'Launch'), + props.setBrowsing, + launchApplication, + ); + + const filteredApplications = useMemo( + () => applications?.filter((application) => includesSearchTerm(application, searchTerm)), + [applications, searchTerm], + ); + + const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []); + + return ( + <> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('split-tunneling-view', 'Split tunneling')}</HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', + )} + </HeaderSubTitle> + </SettingsHeader> + + <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + <ApplicationList + applications={filteredApplications} + onSelect={launchApplication} + rowComponent={LinuxApplicationRow} + /> + + <StyledBrowseButton onClick={launchWithFilePicker}> + {messages.pgettext('split-tunneling-view', 'Find another app')} + </StyledBrowseButton> + + {browseError && ( + <ModalAlert + type={ModalAlertType.warning} + iconColor={colors.red} + message={sprintf( + // TRANSLATORS: Error message showed in a dialog when an application failes to launch. + messages.pgettext( + 'split-tunneling-view', + 'Unable to launch selection. %(detailedErrorMessage)s', + ), + { detailedErrorMessage: browseError }, + )} + buttons={[ + <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}> + {messages.gettext('Close')} + </AppButton.BlueButton>, + ]} + close={hideBrowseFailureDialog} + /> + )} + </> + ); +} + +interface ILinuxApplicationRowProps { + application: ILinuxSplitTunnelingApplication; + onSelect?: (application: ILinuxSplitTunnelingApplication) => void; +} + +function LinuxApplicationRow(props: ILinuxApplicationRowProps) { + const [showWarning, setShowWarning] = useState(false); + + const launch = useCallback(() => { + setShowWarning(false); + props.onSelect?.(props.application); + }, [props.onSelect, props.application]); + + const showWarningDialog = useCallback(() => setShowWarning(true), []); + const hideWarningDialog = useCallback(() => setShowWarning(false), []); + + const disabled = props.application.warning === 'launches-elsewhere'; + const warningColor = disabled ? colors.red : colors.yellow; + const warningMessage = disabled + ? sprintf( + messages.pgettext( + 'split-tunneling-view', + '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.', + ), + { + applicationName: props.application.name, + }, + ) + : sprintf( + messages.pgettext( + 'split-tunneling-view', + 'If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel.', + ), + { + applicationName: props.application.name, + }, + ); + const warningDialogButtons = disabled + ? [ + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ] + : [ + <AppButton.BlueButton key="launch" onClick={launch}> + {messages.pgettext('split-tunneling-view', 'Launch')} + </AppButton.BlueButton>, + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Cancel')} + </AppButton.BlueButton>, + ]; + + return ( + <> + <StyledCellButton + onClick={props.application.warning ? showWarningDialog : launch} + lookDisabled={disabled}> + {props.application.icon ? ( + <StyledIcon + source={props.application.icon} + width={35} + height={35} + lookDisabled={disabled} + /> + ) : ( + <StyledIconPlaceholder /> + )} + <StyledCellLabel lookDisabled={disabled}>{props.application.name}</StyledCellLabel> + {props.application.warning && <Cell.Icon source="icon-alert" tintColor={warningColor} />} + </StyledCellButton> + {showWarning && ( + <ModalAlert + type={ModalAlertType.warning} + iconColor={warningColor} + message={warningMessage} + buttons={warningDialogButtons} + close={hideWarningDialog} + /> + )} + </> + ); +} + +export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + const { + addSplitTunnelingApplication, + removeSplitTunnelingApplication, + getWindowsSplitTunnelingApplications, + setSplitTunnelingState, + } = useAppContext(); + const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling); + const splitTunnelingApplications = useSelector( + (state: IReduxState) => state.settings.splitTunnelingApplications, + ); + + const [searchTerm, setSearchTerm] = useState(''); + const [applications, setApplications] = useState<IApplication[]>(); + useAsyncEffect(async () => { + const { fromCache, applications } = await getWindowsSplitTunnelingApplications(); + setApplications(applications); + + if (fromCache) { + const { applications } = await getWindowsSplitTunnelingApplications(true); + setApplications(applications); + } + }, []); + + const filteredSplitApplications = useMemo( + () => + splitTunnelingApplications.filter((application) => + includesSearchTerm(application, searchTerm), + ), + [splitTunnelingApplications, searchTerm], + ); + + const filteredNonSplitApplications = useMemo(() => { + return applications?.filter( + (application) => + includesSearchTerm(application, searchTerm) && + !splitTunnelingApplications.some( + (splitTunnelingApplication) => + application.absolutepath === splitTunnelingApplication.absolutepath, + ), + ); + }, [applications, splitTunnelingApplications, searchTerm]); + + const addApplication = useCallback( + async (application: IApplication | string) => { + if (!splitTunnelingEnabled) { + await setSplitTunnelingState(true); + } + await addSplitTunnelingApplication(application); + }, + [addSplitTunnelingApplication, splitTunnelingEnabled, setSplitTunnelingState], + ); + + const addApplicationAndUpdate = useCallback( + async (application: IApplication | string) => { + await addApplication(application); + const { applications } = await getWindowsSplitTunnelingApplications(); + setApplications(applications); + }, + [addApplication, getWindowsSplitTunnelingApplications], + ); + + const removeApplication = useCallback( + async (application: IApplication) => { + if (!splitTunnelingEnabled) { + await setSplitTunnelingState(true); + } + removeSplitTunnelingApplication(application); + }, + [removeSplitTunnelingApplication, splitTunnelingEnabled], + ); + + const filePickerCallback = useFilePicker( + messages.pgettext('split-tunneling-view', 'Add'), + props.setBrowsing, + addApplicationAndUpdate, + { name: 'Executables', extensions: ['exe', 'lnk'] }, + ); + + const addWithFilePicker = useCallback(async () => { + props.scrollToTop(); + await filePickerCallback(); + }, [filePickerCallback, props.scrollToTop]); + + const showSplitSection = filteredSplitApplications.length > 0; + const showNonSplitSection = + !filteredNonSplitApplications || filteredNonSplitApplications.length > 0; + + const noResultTextParts = messages + .pgettext('split-tunneling-view', 'No result for %(searchTerm)s.') + .split('%(searchTerm)s', 2); + const noResult = ( + <> + <span>{noResultTextParts[0]}</span> + <StyledNoResultSearchTerm>{searchTerm}</StyledNoResultSearchTerm> + <span>{noResultTextParts[1]}</span> + </> + ); + + return ( + <> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('split-tunneling-view', 'Split tunneling')}</HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Choose the apps you want to exclude from the VPN tunnel.', + )} + </HeaderSubTitle> + </SettingsHeader> + + {!splitTunnelingEnabled && filteredSplitApplications?.length > 0 && ( + <StyledDisabledWarning> + {messages.pgettext( + 'split-tunneling-view', + 'Split tunneling has been disabled from the CLI and will automatically be enabled when adding or removing applications from the lists below.', + )} + </StyledDisabledWarning> + )} + + <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + + {(showSplitSection || showNonSplitSection) && ( + <> + <Accordion expanded={showSplitSection}> + <Cell.Section> + <Cell.SectionTitle> + {messages.pgettext('split-tunneling-view', 'Excluded apps')} + </Cell.SectionTitle> + <ApplicationList + applications={filteredSplitApplications} + onRemove={removeApplication} + rowComponent={ApplicationRow} + /> + </Cell.Section> + </Accordion> + + <Accordion expanded={showNonSplitSection}> + <Cell.Section> + <Cell.SectionTitle> + {messages.pgettext('split-tunneling-view', 'All apps')} + </Cell.SectionTitle> + <ApplicationList + applications={filteredNonSplitApplications} + onSelect={addApplication} + rowComponent={ApplicationRow} + /> + </Cell.Section> + </Accordion> + </> + )} + + {searchTerm !== '' && !showSplitSection && !showNonSplitSection && ( + <StyledNoResult> + <StyledNoResultText>{noResult}</StyledNoResultText> + <StyledNoResultText> + {messages.pgettext('split-tunneling-view', 'Try a different search.')} + </StyledNoResultText> + </StyledNoResult> + )} + + <StyledBrowseButton onClick={addWithFilePicker}> + {messages.pgettext('split-tunneling-view', 'Find another app')} + </StyledBrowseButton> + </> + ); +} + +interface IApplicationListProps<T extends IApplication> { + applications: T[] | undefined; + onSelect?: (application: T) => void; + onRemove?: (application: T) => void; + rowComponent: React.ComponentType<IApplicationRowProps<T>>; +} + +function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>) { + const [applicationListHeight, setApplicationListHeight] = useState<number>(); + const applicationListRef = useRef() as React.RefObject<HTMLDivElement>; + + useLayoutEffect(() => { + const height = applicationListRef.current?.getBoundingClientRect().height; + setApplicationListHeight(height); + }, [applicationListRef, props.applications]); + + return ( + <StyledApplicationListAnimation height={applicationListHeight}> + <StyledApplicationListContent ref={applicationListRef}> + {props.applications === undefined ? ( + <StyledSpinnerRow> + <ImageView source="icon-spinner" height={60} width={60} /> + </StyledSpinnerRow> + ) : ( + props.applications.map((application) => ( + <props.rowComponent + key={application.absolutepath} + application={application} + onSelect={props.onSelect} + onRemove={props.onRemove} + /> + )) + )} + </StyledApplicationListContent> + </StyledApplicationListAnimation> + ); +} + +interface IApplicationRowProps<T extends IApplication> { + application: T; + onSelect?: (application: T) => void; + onRemove?: (application: T) => void; +} + +function ApplicationRow<T extends IApplication>(props: IApplicationRowProps<T>) { + const onSelect = useCallback(() => { + props.onSelect?.(props.application); + }, [props.onSelect, props.application]); + + const onRemove = useCallback(() => { + props.onRemove?.(props.application); + }, [props.onRemove, props.application]); + + return ( + <Cell.CellButton> + {props.application.icon ? ( + <StyledIcon source={props.application.icon} width={35} height={35} /> + ) : ( + <StyledIconPlaceholder /> + )} + <StyledCellLabel>{props.application.name}</StyledCellLabel> + {props.onSelect && ( + <ImageView + source="icon-add" + width={24} + height={24} + onClick={onSelect} + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + )} + {props.onRemove && ( + <ImageView + source="icon-remove" + width={24} + height={24} + onClick={onRemove} + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + )} + </Cell.CellButton> + ); +} + +interface ISearchBarProps { + searchTerm: string; + onSearch: (searchTerm: string) => void; +} + +function SearchBar(props: ISearchBarProps) { + const inputRef = useRef() as React.RefObject<HTMLInputElement>; + + const onInput = useCallback( + (event: React.FormEvent) => { + const element = event.target as HTMLInputElement; + props.onSearch(element.value); + }, + [props.onSearch], + ); + + const onClear = useCallback(() => { + props.onSearch(''); + inputRef.current?.blur(); + }, [props.onSearch]); + + return ( + <StyledSearchContainer> + <StyledSearchInput + ref={inputRef} + value={props.searchTerm} + onInput={onInput} + placeholder={messages.pgettext('split-tunneling-view', 'Filter...')} + /> + <StyledSearchIcon source="icon-filter" width={24} tintColor={colors.white60} /> + {props.searchTerm.length > 0 && ( + <StyledClearButton onClick={onClear}> + <StyledClearIcon source="icon-close-sml" width={16} tintColor={colors.white40} /> + </StyledClearButton> + )} + </StyledSearchContainer> + ); +} + +function includesSearchTerm(application: IApplication, searchTerm: string) { + return application.name.toLowerCase().includes(searchTerm.toLowerCase()); +} diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx new file mode 100644 index 0000000000..ca7b7954ec --- /dev/null +++ b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx @@ -0,0 +1,164 @@ +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import * as AppButton from './AppButton'; +import * as Cell from './cell'; +import { mediumText, smallText } from './common-styles'; +import ImageView from './ImageView'; +import { Container } from './Layout'; +import { NavigationScrollbars } from './NavigationBar'; + +export const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ + position: 'absolute', + zIndex: 2, + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.black, + opacity: 0.5, + display: props.show ? 'block' : 'none', +})); + +export const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + flex: 1, +}); + +export const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, +}); + +export const StyledCellButton = styled(Cell.CellButton)((props: { lookDisabled?: boolean }) => ({ + ':not(:disabled):hover': { + backgroundColor: props.lookDisabled ? colors.blue : undefined, + }, +})); + +const disabledApplication = (props: { lookDisabled?: boolean }) => ({ + opacity: props.lookDisabled ? 0.6 : undefined, +}); + +export const StyledIcon = styled(Cell.UntintedIcon)(disabledApplication, { + marginRight: '12px', +}); + +export const StyledCellLabel = styled(Cell.Label)(disabledApplication, { + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', +}); + +export const StyledIconPlaceholder = styled.div({ + width: '35px', + marginRight: '12px', +}); + +export const StyledApplicationListContent = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const StyledApplicationListAnimation = styled.div({}, (props: { height?: number }) => ({ + overflow: 'hidden', + height: props.height ? `${props.height}px` : 'auto', + transition: 'height 500ms ease-in-out', + marginBottom: '20px', +})); + +export const StyledSpinnerRow = styled.div({ + display: 'flex', + justifyContent: 'center', + padding: '8px 0', + background: colors.blue40, +}); + +export const StyledBrowseButton = styled(AppButton.BlueButton)({ + margin: '0 22px 22px', +}); + +export const StyledCellContainer = styled(Cell.Container)({ + marginBottom: '20px', +}); + +export const StyledSearchContainer = styled.div({ + position: 'relative', + marginBottom: '18px', +}); + +export const StyledSearchInput = styled.input.attrs({ type: 'text' })({ + ...mediumText, + width: 'calc(100% - 22px * 2)', + border: 'none', + borderRadius: '4px', + padding: '9px 38px', + margin: '0 22px', + color: colors.white60, + backgroundColor: colors.white10, + '::placeholder': { + color: colors.white60, + }, + ':focus': { + color: colors.blue, + backgroundColor: colors.white, + '::placeholder': { + color: colors.blue40, + }, + }, +}); + +export const StyledClearButton = styled.button({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + right: '28px', + border: 'none', + background: 'none', + padding: 0, +}); + +export const StyledSearchIcon = styled(ImageView)({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: '28px', + [`${StyledSearchInput}:focus ~ &`]: { + backgroundColor: colors.blue, + }, +}); + +export const StyledClearIcon = styled(ImageView)({ + ':hover': { + backgroundColor: colors.white60, + }, + [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: { + backgroundColor: colors.blue40, + ':hover': { + backgroundColor: colors.blue, + }, + }, +}); + +export const StyledNoResult = styled(Cell.Footer)({ + display: 'flex', + flexDirection: 'column', + paddingTop: 0, + marginTop: 0, +}); + +export const StyledNoResultText = styled(Cell.FooterText)({ + textAlign: 'center', +}); + +export const StyledNoResultSearchTerm = styled.span({ + fontWeight: 'bold', +}); + +export const StyledDisabledWarning = styled.span(smallText, { + margin: '0 22px 18px', + color: colors.red, +}); diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx index a9e603718b..36a1a77963 100644 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx @@ -164,7 +164,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp }, onViewWireguardKeys: () => props.history.push('/settings/advanced/wireguard-keys'), - onViewLinuxSplitTunneling: () => props.history.push('/settings/advanced/linux-split-tunneling'), + onViewSplitTunneling: () => props.history.push('/settings/advanced/split-tunneling'), }; }; diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index c5f9d33e08..1b1e48265c 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -108,13 +108,13 @@ export interface IUpdateDnsOptionsAction { dns: IDnsOptions; } -export interface ISplitTunnelingEnableExclusions { - type: 'SPLIT_TUNNELING_ENABLE_EXCLUSIONS'; +export interface IUpdateSplitTunnelingStateAction { + type: 'UPDATE_SPLIT_TUNNELING_STATE'; enabled: boolean; } -export interface ISplitTunnelingApplications { - type: 'SPLIT_TUNNELING_APPLICATIONS'; +export interface ISetSplitTunnelingApplicationsAction { + type: 'SET_SPLIT_TUNNELING_APPLICATIONS'; applications: IApplication[]; } @@ -139,8 +139,8 @@ export type SettingsAction = | IWireguardKeygenEvent | IWireguardKeyVerifiedAction | IUpdateDnsOptionsAction - | ISplitTunnelingEnableExclusions - | ISplitTunnelingApplications; + | IUpdateSplitTunnelingStateAction + | ISetSplitTunnelingApplicationsAction; function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction { return { @@ -292,16 +292,18 @@ function updateDnsOptions(dns: IDnsOptions): IUpdateDnsOptionsAction { }; } -function updateSplitTunneling(enabled: boolean): ISplitTunnelingEnableExclusions { +function updateSplitTunnelingState(enabled: boolean): IUpdateSplitTunnelingStateAction { return { - type: 'SPLIT_TUNNELING_ENABLE_EXCLUSIONS', + type: 'UPDATE_SPLIT_TUNNELING_STATE', enabled, }; } -function setSplitTunnelingApplications(applications: IApplication[]): ISplitTunnelingApplications { +function setSplitTunnelingApplications( + applications: IApplication[], +): ISetSplitTunnelingApplicationsAction { return { - type: 'SPLIT_TUNNELING_APPLICATIONS', + type: 'SET_SPLIT_TUNNELING_APPLICATIONS', applications, }; } @@ -327,6 +329,6 @@ export default { verifyWireguardKey, completeWireguardKeyVerification, updateDnsOptions, - updateSplitTunneling, + updateSplitTunnelingState, setSplitTunnelingApplications, }; diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index d7efdcc2b3..1b07e805fa 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -325,13 +325,13 @@ export default function ( dns: action.dns, }; - case 'SPLIT_TUNNELING_ENABLE_EXCLUSIONS': + case 'UPDATE_SPLIT_TUNNELING_STATE': return { ...state, splitTunneling: action.enabled, }; - case 'SPLIT_TUNNELING_APPLICATIONS': + case 'SET_SPLIT_TUNNELING_APPLICATIONS': return { ...state, splitTunnelingApplications: action.applications, |
