import React, { useCallback, useEffect, 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 { 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 { CustomScrollbarsRef } from './CustomScrollbars'; import ImageView from './ImageView'; import { Layout } from './Layout'; import List from './List'; 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, StyledSpinnerRow, StyledBrowseButton, StyledSearchInput, StyledClearButton, StyledSearchIcon, StyledClearIcon, StyledNoResultText, StyledSearchContainer, StyledNoResult, StyledNoResultSearchTerm, StyledDisabledWarning, StyledActionIcon, StyledCellWarningIcon, StyledListContainer, } from './SplitTunnelingSettingsStyles'; export default function SplitTunneling() { const { pop } = useHistory(); const [browsing, setBrowsing] = useState(false); const scrollbarsRef = useRef() as React.RefObject; const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]); return ( <> { // TRANSLATORS: Back button in navigation bar messages.pgettext('navigation-bar', 'Advanced') } { // TRANSLATORS: Title label in navigation bar messages.pgettext('split-tunneling-nav', 'Split tunneling') } ); } interface IPlatformSplitTunnelingSettingsProps { setBrowsing: (value: boolean) => void; scrollToTop: () => void; } function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { switch (window.env.platform) { case 'linux': return ; case 'win32': return ; default: throw new Error(`Split tunneling not implemented on ${window.env.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(); const [browseError, setBrowseError] = useState(); useEffect(() => void 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 ( <> {messages.pgettext('split-tunneling-view', 'Split tunneling')} {messages.pgettext( 'split-tunneling-view', 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', )} {messages.pgettext('split-tunneling-view', 'Find another app')} {browseError && ( {messages.gettext('Close')} , ]} 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 ? [ {messages.gettext('Back')} , ] : [ {messages.pgettext('split-tunneling-view', 'Launch')} , {messages.gettext('Cancel')} , ]; return ( <> {props.application.icon ? ( ) : ( )} {props.application.name} {props.application.warning && ( )} {showWarning && ( )} ); } 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(); 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 = ( <> {noResultTextParts[0]} {searchTerm} {noResultTextParts[1]} ); return ( <> {messages.pgettext('split-tunneling-view', 'Split tunneling')} {messages.pgettext( 'split-tunneling-view', 'Choose the apps you want to exclude from the VPN tunnel.', )} {!splitTunnelingEnabled && filteredSplitApplications?.length > 0 && ( {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.', )} )} {(showSplitSection || showNonSplitSection) && ( <> {messages.pgettext('split-tunneling-view', 'Excluded apps')} {messages.pgettext('split-tunneling-view', 'All apps')} )} {searchTerm !== '' && !showSplitSection && !showNonSplitSection && ( {noResult} {messages.pgettext('split-tunneling-view', 'Try a different search.')} )} {messages.pgettext('split-tunneling-view', 'Find another app')} ); } interface IApplicationListProps { applications: T[] | undefined; onSelect?: (application: T) => void; onRemove?: (application: T) => void; rowComponent: React.ComponentType>; } function ApplicationList(props: IApplicationListProps) { if (props.applications === undefined) { return ( ); } else { return ( {(application) => ( )} ); } } function applicationGetKey(application: T): string { return application.absolutepath; } interface IApplicationRowProps { application: T; onSelect?: (application: T) => void; onRemove?: (application: T) => void; } function ApplicationRow(props: IApplicationRowProps) { const onSelect = useCallback(() => { props.onSelect?.(props.application); }, [props.onSelect, props.application]); const onRemove = useCallback(() => { props.onRemove?.(props.application); }, [props.onRemove, props.application]); return ( {props.application.icon ? ( ) : ( )} {props.application.name} {props.onSelect && ( )} {props.onRemove && ( )} ); } interface ISearchBarProps { searchTerm: string; onSearch: (searchTerm: string) => void; } function SearchBar(props: ISearchBarProps) { const inputRef = useRef() as React.RefObject; 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 ( {props.searchTerm.length > 0 && ( )} ); } function includesSearchTerm(application: IApplication, searchTerm: string) { return application.name.toLowerCase().includes(searchTerm.toLowerCase()); }