summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-01-15 17:33:28 +0100
committerOskar Nyberg <oskar@mullvad.net>2021-07-02 16:15:01 +0200
commitde95e3465268f0489b00bb1ee2c1a04fc54bb3e5 (patch)
treef3b75fcdf5c9acaf84f0a8ab8ecea9acaffae132 /gui
parent694ad87692c378a621c7c4a669c0ff88ca78d633 (diff)
downloadmullvadvpn-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.json1
-rw-r--r--gui/src/main/index.ts4
-rw-r--r--gui/src/renderer/app.tsx6
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx8
-rw-r--r--gui/src/renderer/components/AdvancedSettingsStyles.tsx2
-rw-r--r--gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx318
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx593
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx164
-rw-r--r--gui/src/renderer/containers/AdvancedSettingsPage.tsx2
-rw-r--r--gui/src/renderer/redux/settings/actions.ts24
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts4
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,