diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-05 15:16:33 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-11 14:55:41 +0200 |
| commit | 4ff6060f02996e23c1f86c4cf1c17e6e80652cfa (patch) | |
| tree | 5c9faa79188de017963d8ed06387886b35f6c639 | |
| parent | e4a4795f26a6479fe511ff4a1b5e7e2347fe8c9c (diff) | |
| download | mullvadvpn-4ff6060f02996e23c1f86c4cf1c17e6e80652cfa.tar.xz mullvadvpn-4ff6060f02996e23c1f86c4cf1c17e6e80652cfa.zip | |
Refactor Split tunneling settings
134 files changed, 1828 insertions, 803 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx index eb40349089..7c72b833f9 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx @@ -28,7 +28,6 @@ import SelectLanguage from './SelectLanguage'; import SettingsImport from './SettingsImport'; import SettingsTextImport from './SettingsTextImport'; import Shadowsocks from './Shadowsocks'; -import SplitTunnelingSettings from './SplitTunnelingSettings'; import Support from './Support'; import TooManyDevices from './TooManyDevices'; import UdpOverTcp from './UdpOverTcp'; @@ -40,6 +39,7 @@ import { LaunchView, LoginView, SettingsView, + SplitTunnelingView, } from './views'; import VpnSettings from './VpnSettings'; import WireguardSettings from './WireguardSettings'; @@ -76,7 +76,7 @@ export default function AppRouter() { <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} /> <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} /> <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} /> - <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> + <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingView} /> <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} /> <Route exact path={RoutePath.settingsImport} component={SettingsImport} /> <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} /> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettings.tsx deleted file mode 100644 index d18f671b5f..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettings.tsx +++ /dev/null @@ -1,714 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { - IApplication, - ILinuxSplitTunnelingApplication, - ISplitTunnelingApplication, -} from '../../shared/application-types'; -import { strings } from '../../shared/constants'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { Button, Container, Flex, FootnoteMini, IconButton, Spinner } from '../lib/components'; -import { FlexColumn } from '../lib/components/flex-column'; -import { Colors, colors } from '../lib/foundations'; -import { useHistory } from '../lib/history'; -import { formatHtml } from '../lib/html-formatter'; -import { useAfterTransition } from '../lib/transition-hooks'; -import { useEffectEvent, useStyledRef } from '../lib/utility-hooks'; -import { IReduxState } from '../redux/store'; -import { AppNavigationHeader } from './'; -import Accordion from './Accordion'; -import * as Cell from './cell'; -import { CustomScrollbarsRef } from './CustomScrollbars'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import List from './List'; -import { ModalAlert, ModalAlertType } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; -import { - StyledCellButton, - StyledCellLabel, - StyledCellWarningIcon, - StyledIcon, - StyledIconPlaceholder, - StyledNavigationScrollbars, - StyledNoResult, - StyledNoResultText, - StyledPageCover, - StyledSearchBar, - StyledSpinnerRow, -} from './SplitTunnelingSettingsStyles'; -import Switch from './Switch'; - -export default function SplitTunneling() { - const { pop } = useHistory(); - const [browsing, setBrowsing] = useState(false); - const scrollbarsRef = useStyledRef<CustomScrollbarsRef>(); - - const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]); - - return ( - <> - <StyledPageCover $show={browsing} /> - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader title={strings.splitTunneling} /> - - <StyledNavigationScrollbars ref={scrollbarsRef}> - <PlatformSpecificSplitTunnelingSettings - setBrowsing={setBrowsing} - scrollToTop={scrollToTop} - /> - </StyledNavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - </> - ); -} - -interface IPlatformSplitTunnelingSettingsProps { - setBrowsing: (value: boolean) => void; - scrollToTop: () => void; -} - -function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { - switch (window.env.platform) { - case 'linux': - return <LinuxSplitTunnelingSettings {...props} />; - default: - return <SplitTunnelingSettings {...props} />; - } -} - -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]); - } - }, [setOpen, showOpenDialog, buttonLabel, filter, select]); -} - -function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { - const { getLinuxSplitTunnelingApplications, launchExcludedApplication } = useAppContext(); - const runAfterTransition = useAfterTransition(); - - const [searchTerm, setSearchTerm] = useState(''); - const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); - const [browseError, setBrowseError] = useState<string>(); - - const updateApplications = useEffectEvent(() => { - runAfterTransition(async () => { - const applications = await getLinuxSplitTunnelingApplications(); - setApplications(applications); - }); - }); - - // These lint rules are disabled for now because the react plugin for eslint does - // not understand that useEffectEvent should not be added to the dependency array. - // Enable these rules again when eslint can lint useEffectEvent properly. - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => void updateApplications(), []); - - 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), []); - - const rowRenderer = useCallback( - (application: ILinuxSplitTunnelingApplication) => ( - <LinuxApplicationRow application={application} onSelect={launchApplication} /> - ), - [launchApplication], - ); - - return ( - <> - <SettingsHeader> - <HeaderTitle>{strings.splitTunneling}</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> - - <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> - - {searchTerm !== '' && - (filteredApplications === undefined || filteredApplications.length === 0) && ( - <StyledNoResult> - <StyledNoResultText> - {formatHtml( - sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }), - )} - </StyledNoResultText> - <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText> - </StyledNoResult> - )} - - <FlexColumn $gap="medium"> - {filteredApplications !== undefined && filteredApplications.length > 0 && ( - <ApplicationList applications={filteredApplications} rowRenderer={rowRenderer} /> - )} - - <Flex $margin={{ horizontal: 'medium', bottom: 'large' }}> - <Button onClick={launchWithFilePicker}> - <Button.Text> - { - // TRANSLATORS: Button label for browsing applications with split tunneling. - messages.pgettext('split-tunneling-view', 'Find another app') - } - </Button.Text> - </Button> - </Flex> - </FlexColumn> - - <ModalAlert - isOpen={browseError !== undefined} - type={ModalAlertType.warning} - iconColor={colors.red} - message={sprintf( - // TRANSLATORS: Error message showed in a dialog when an application fails to launch. - messages.pgettext( - 'split-tunneling-view', - 'Unable to launch selection. %(detailedErrorMessage)s', - ), - { detailedErrorMessage: browseError }, - )} - buttons={[ - <Button key="close" onClick={hideBrowseFailureDialog}> - <Button.Text>{messages.gettext('Close')}</Button.Text> - </Button>, - ]} - close={hideBrowseFailureDialog} - /> - </> - ); -} - -interface ILinuxApplicationRowProps { - application: ILinuxSplitTunnelingApplication; - onSelect?: (application: ILinuxSplitTunnelingApplication) => void; -} - -function LinuxApplicationRow(props: ILinuxApplicationRowProps) { - const { onSelect } = props; - - const [showWarning, setShowWarning] = useState(false); - - const launch = useCallback(() => { - setShowWarning(false); - onSelect?.(props.application); - }, [onSelect, props.application]); - - const showWarningDialog = useCallback(() => setShowWarning(true), []); - const hideWarningDialog = useCallback(() => setShowWarning(false), []); - - const disabled = props.application.warning === 'launches-elsewhere'; - const warningColor: Colors = disabled ? 'red' : '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 - ? [ - <Button key="cancel" onClick={hideWarningDialog}> - <Button.Text>{messages.gettext('Back')}</Button.Text> - </Button>, - ] - : [ - <Button key="launch" onClick={launch}> - <Button.Text> - { - // TRANSLATORS: Button label for launching an application with split tunneling. - messages.pgettext('split-tunneling-view', 'Launch') - } - </Button.Text> - </Button>, - <Button key="cancel" onClick={hideWarningDialog}> - <Button.Text>{messages.gettext('Cancel')}</Button.Text> - </Button>, - ]; - - 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 && ( - <StyledCellWarningIcon icon="alert-circle" color={warningColor} /> - )} - </StyledCellButton> - <ModalAlert - isOpen={showWarning} - type={ModalAlertType.warning} - iconColor={warningColor} - message={warningMessage} - buttons={warningDialogButtons} - close={hideWarningDialog} - /> - </> - ); -} - -export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { - const { scrollToTop } = props; - - const { - addSplitTunnelingApplication, - removeSplitTunnelingApplication, - forgetManuallyAddedSplitTunnelingApplication, - getSplitTunnelingApplications, - needFullDiskPermissions, - setSplitTunnelingState, - } = useAppContext(); - const runAfterTransition = useAfterTransition(); - const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling); - const splitTunnelingApplications = useSelector( - (state: IReduxState) => state.settings.splitTunnelingApplications, - ); - - const [searchTerm, setSearchTerm] = useState(''); - const [applications, setApplications] = useState<ISplitTunnelingApplication[]>(); - - const [loadingDiskPermissions, setLoadingDiskPermissions] = useState(false); - const [splitTunnelingAvailable, setSplitTunnelingAvailable] = useState( - window.env.platform === 'darwin' ? undefined : true, - ); - - const canEditSplitTunneling = splitTunnelingEnabled && (splitTunnelingAvailable ?? false); - - const fetchNeedFullDiskPermissions = useCallback(async () => { - setLoadingDiskPermissions(true); - const needPermissions = await needFullDiskPermissions(); - setSplitTunnelingAvailable(!needPermissions); - setLoadingDiskPermissions(false); - }, [needFullDiskPermissions]); - - useEffect((): void | (() => void) => { - if (window.env.platform === 'darwin') { - void fetchNeedFullDiskPermissions(); - } - }, [fetchNeedFullDiskPermissions]); - - const onMount = useEffectEvent(() => { - runAfterTransition(async () => { - const { fromCache, applications } = await getSplitTunnelingApplications(); - setApplications(applications); - - if (fromCache) { - const { applications } = await getSplitTunnelingApplications(true); - setApplications(applications); - } - }); - }); - - // These lint rules are disabled for now because the react plugin for eslint does - // not understand that useEffectEvent should not be added to the dependency array. - // Enable these rules again when eslint can lint useEffectEvent properly. - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => void onMount(), []); - - 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: ISplitTunnelingApplication | string) => { - if (!canEditSplitTunneling) { - await setSplitTunnelingState(true); - } - await addSplitTunnelingApplication(application); - }, - [addSplitTunnelingApplication, canEditSplitTunneling, setSplitTunnelingState], - ); - - const addBrowsedForApplication = useCallback( - async (application: string) => { - await addApplication(application); - const { applications } = await getSplitTunnelingApplications(); - setApplications(applications); - }, - [addApplication, getSplitTunnelingApplications], - ); - - const forgetManuallyAddedApplicationAndUpdate = useCallback( - async (application: ISplitTunnelingApplication) => { - await forgetManuallyAddedSplitTunnelingApplication(application); - const { applications } = await getSplitTunnelingApplications(); - setApplications(applications); - }, - [forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications], - ); - - const removeApplication = useCallback( - async (application: ISplitTunnelingApplication) => { - if (!canEditSplitTunneling) { - await setSplitTunnelingState(true); - } - removeSplitTunnelingApplication(application); - }, - [removeSplitTunnelingApplication, setSplitTunnelingState, canEditSplitTunneling], - ); - - const filePickerCallback = useFilePicker( - messages.pgettext('split-tunneling-view', 'Add'), - props.setBrowsing, - addBrowsedForApplication, - getFilePickerOptionsForPlatform(), - ); - - const addWithFilePicker = useCallback(async () => { - scrollToTop(); - await filePickerCallback(); - }, [filePickerCallback, scrollToTop]); - - const excludedRowRenderer = useCallback( - (application: ISplitTunnelingApplication) => ( - <ApplicationRow application={application} onRemove={removeApplication} /> - ), - [removeApplication], - ); - - const includedRowRenderer = useCallback( - (application: ISplitTunnelingApplication) => { - const onForget = application.deletable ? forgetManuallyAddedApplicationAndUpdate : undefined; - return ( - <ApplicationRow application={application} onAdd={addApplication} onDelete={onForget} /> - ); - }, - [addApplication, forgetManuallyAddedApplicationAndUpdate], - ); - - const showSplitSection = canEditSplitTunneling && filteredSplitApplications.length > 0; - const showNonSplitSection = - canEditSplitTunneling && - (!filteredNonSplitApplications || filteredNonSplitApplications.length > 0); - - const excludedTitle = ( - <Cell.SectionTitle> - {messages.pgettext('split-tunneling-view', 'Excluded apps')} - </Cell.SectionTitle> - ); - - const allTitle = ( - <Cell.SectionTitle>{messages.pgettext('split-tunneling-view', 'All apps')}</Cell.SectionTitle> - ); - - return ( - <> - <SettingsHeader> - <Flex $justifyContent="space-between" $alignItems="center"> - <HeaderTitle>{strings.splitTunneling}</HeaderTitle> - <Switch - isOn={splitTunnelingEnabled} - disabled={ - !splitTunnelingEnabled && (!splitTunnelingAvailable || loadingDiskPermissions) - } - onChange={setSplitTunnelingState} - /> - </Flex> - {!loadingDiskPermissions && ( - <> - <MacOsSplitTunnelingAvailability - needFullDiskPermissions={ - window.env.platform === 'darwin' && splitTunnelingAvailable === false - } - /> - {splitTunnelingAvailable && ( - <HeaderSubTitle> - {messages.pgettext( - 'split-tunneling-view', - 'Choose the apps you want to exclude from the VPN tunnel.', - )} - </HeaderSubTitle> - )} - </> - )} - </SettingsHeader> - {loadingDiskPermissions && ( - <Flex $justifyContent="center" $margin={{ top: 'large' }}> - <Spinner size="big" /> - </Flex> - )} - - {canEditSplitTunneling && ( - <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> - )} - - {canEditSplitTunneling && searchTerm !== '' && !showSplitSection && !showNonSplitSection && ( - <StyledNoResult> - <StyledNoResultText> - {formatHtml( - sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }), - )} - </StyledNoResultText> - <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText> - </StyledNoResult> - )} - - <Flex $flexDirection="column" $gap="medium" $margin={{ bottom: 'large' }}> - {(showSplitSection || showNonSplitSection) && ( - <Flex $flexDirection="column" $gap="medium"> - <Accordion expanded={showSplitSection}> - <Cell.Section sectionTitle={excludedTitle}> - <ApplicationList - data-testid="split-applications" - applications={filteredSplitApplications} - rowRenderer={excludedRowRenderer} - /> - </Cell.Section> - </Accordion> - - <Accordion expanded={showNonSplitSection}> - <Cell.Section sectionTitle={allTitle}> - <ApplicationList - data-testid="non-split-applications" - applications={filteredNonSplitApplications} - rowRenderer={includedRowRenderer} - /> - </Cell.Section> - </Accordion> - </Flex> - )} - - {canEditSplitTunneling && ( - <Container size="3"> - <Button onClick={addWithFilePicker}> - <Button.Text> - {messages.pgettext('split-tunneling-view', 'Find another app')} - </Button.Text> - </Button> - </Container> - )} - </Flex> - </> - ); -} - -interface MacOsSplitTunnelingAvailabilityProps { - needFullDiskPermissions: boolean; -} - -function MacOsSplitTunnelingAvailability({ - needFullDiskPermissions, -}: MacOsSplitTunnelingAvailabilityProps) { - const { showFullDiskAccessSettings, daemonPrepareRestart } = useAppContext(); - const restartDaemon = useCallback(() => daemonPrepareRestart(true), [daemonPrepareRestart]); - - if (!needFullDiskPermissions) return null; - - return ( - <Flex $flexDirection="column" $gap="large"> - <HeaderSubTitle> - {messages.pgettext( - 'split-tunneling-view', - 'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.', - )} - </HeaderSubTitle> - <Flex $flexDirection="column" $gap="small"> - <Flex $flexDirection="column" $gap="big"> - <Button onClick={showFullDiskAccessSettings}> - <Button.Text> - {messages.pgettext('split-tunneling-view', 'Open System Settings')} - </Button.Text> - </Button> - <FootnoteMini color="whiteAlpha60"> - {messages.pgettext( - 'split-tunneling-view', - 'Enabled "Full disk access" and still having issues?', - )} - </FootnoteMini> - </Flex> - <Button onClick={restartDaemon}> - <Button.Text> - {messages.pgettext('split-tunneling-view', 'Restart Mullvad Service')} - </Button.Text> - </Button> - </Flex> - </Flex> - ); -} - -interface IApplicationListProps<T extends IApplication> { - applications: T[] | undefined; - rowRenderer: (application: T) => React.ReactElement; - 'data-testid'?: string; -} - -function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>) { - if (props.applications == undefined) { - return ( - <StyledSpinnerRow> - <Spinner size="big" /> - </StyledSpinnerRow> - ); - } else { - return ( - <Flex $flexDirection="column" data-testid={props['data-testid']}> - <List - data-testid={props['data-testid']} - items={props.applications.sort((a, b) => a.name.localeCompare(b.name))} - getKey={applicationGetKey}> - {props.rowRenderer} - </List> - </Flex> - ); - } -} - -function applicationGetKey<T extends IApplication>(application: T): string { - return application.absolutepath; -} - -const StyledContainer = styled(Cell.Container)({ - backgroundColor: colors.blue40, -}); - -interface IApplicationRowProps { - application: ISplitTunnelingApplication; - onAdd?: (application: ISplitTunnelingApplication) => void; - onRemove?: (application: ISplitTunnelingApplication) => void; - onDelete?: (application: ISplitTunnelingApplication) => void; -} - -function ApplicationRow(props: IApplicationRowProps) { - const { onAdd: propsOnAdd, onRemove: propsOnRemove, onDelete: propsOnDelete } = props; - - const onAdd = useCallback(() => { - propsOnAdd?.(props.application); - }, [propsOnAdd, props.application]); - - const onRemove = useCallback(() => { - propsOnRemove?.(props.application); - }, [propsOnRemove, props.application]); - - const onDelete = useCallback(() => { - propsOnDelete?.(props.application); - }, [propsOnDelete, props.application]); - - return ( - <StyledContainer> - {props.application.icon ? ( - <StyledIcon source={props.application.icon} width={35} height={35} /> - ) : ( - <StyledIconPlaceholder /> - )} - <StyledCellLabel>{props.application.name}</StyledCellLabel> - <Flex $gap="small"> - {props.onDelete && ( - <IconButton variant="secondary" onClick={onDelete}> - <IconButton.Icon icon="cross-circle" /> - </IconButton> - )} - {props.onAdd && ( - <IconButton variant="secondary" onClick={onAdd}> - <IconButton.Icon icon="add-circle" /> - </IconButton> - )} - {props.onRemove && ( - <IconButton variant="secondary" onClick={onRemove}> - <IconButton.Icon icon="remove-circle" /> - </IconButton> - )} - </Flex> - </StyledContainer> - ); -} - -function includesSearchTerm(application: IApplication, searchTerm: string) { - return application.name.toLowerCase().includes(searchTerm.toLowerCase()); -} - -function getFilePickerOptionsForPlatform(): - | { name: string; extensions: Array<string> } - | undefined { - return window.env.platform === 'win32' - ? { name: 'Executables', extensions: ['exe', 'lnk'] } - : undefined; -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettingsStyles.tsx deleted file mode 100644 index 5ec3c597e1..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettingsStyles.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import styled from 'styled-components'; - -import { colors, spacings } from '../lib/foundations'; -import * as Cell from './cell'; -import { measurements, normalText } from './common-styles'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SearchBar from './SearchBar'; - -export const StyledPageCover = styled.div<{ $show: boolean }>((props) => ({ - position: 'absolute', - zIndex: 2, - top: 0, - left: 0, - right: 0, - bottom: 0, - opacity: 0.5, - display: props.$show ? 'block' : 'none', -})); - -export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -export const StyledCellButton = styled(Cell.CellButton)<{ $lookDisabled?: boolean }>((props) => ({ - '&&:not(:disabled):hover': { - backgroundColor: props.$lookDisabled ? colors.blue : undefined, - }, -})); - -interface DisabledApplicationProps { - $lookDisabled?: boolean; -} - -const disabledApplication = (props: DisabledApplicationProps) => ({ - opacity: props.$lookDisabled ? 0.6 : undefined, -}); - -export const StyledIcon = styled(Cell.CellImage)<DisabledApplicationProps>(disabledApplication, { - marginRight: spacings.small, -}); - -export const StyledCellWarningIcon = styled(Cell.CellTintedIcon)({ - marginLeft: spacings.small, - marginRight: spacings.tiny, -}); - -export const StyledCellLabel = styled(Cell.Label)<DisabledApplicationProps>( - disabledApplication, - normalText, - { - fontWeight: 400, - wordWrap: 'break-word', - overflow: 'hidden', - }, -); - -export const StyledIconPlaceholder = styled.div({ - width: '35px', - marginRight: spacings.small, -}); - -export const StyledSpinnerRow = styled(Cell.CellButton)({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: `${spacings.small} 0`, - marginBottom: measurements.rowVerticalMargin, - background: colors.blue40, -}); - -export const StyledNoResult = styled(Cell.CellFooter)({ - display: 'flex', - flexDirection: 'column', - paddingTop: 0, - marginTop: 0, - marginBottom: spacings.large, -}); - -export const StyledNoResultText = styled(Cell.CellFooterText)({ - textAlign: 'center', -}); - -export const StyledSearchBar = styled(SearchBar)({ - marginLeft: measurements.horizontalViewMargin, - marginRight: measurements.horizontalViewMargin, - marginBottom: measurements.buttonVerticalMargin, -}); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts index c307b7eb71..e35670b52a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts @@ -4,3 +4,4 @@ export * from './launch'; export * from './login'; export * from './changelog'; export * from './settings'; +export * from './split-tunneling'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingContext.tsx new file mode 100644 index 0000000000..120ea83697 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingContext.tsx @@ -0,0 +1,40 @@ +import React, { useMemo, useState } from 'react'; + +import { useStyledRef } from '../../../lib/utility-hooks'; +import { type CustomScrollbarsRef } from '../../CustomScrollbars'; + +type SplitTunnelingContextProviderProps = { + children: React.ReactNode; +}; + +type SplitTunnelingContext = { + browsing: boolean; + scrollbarsRef: React.RefObject<CustomScrollbarsRef | null>; + setBrowsing: (value: boolean) => void; +}; + +const SplitTunnelingContext = React.createContext<SplitTunnelingContext | undefined>(undefined); + +export const useSplitTunnelingContext = (): SplitTunnelingContext => { + const context = React.useContext(SplitTunnelingContext); + if (!context) { + throw new Error('useSplitTunnelingContext must be used within a SplitTunnelingContext'); + } + return context; +}; + +export function SplitTunnelingContextProvider({ children }: SplitTunnelingContextProviderProps) { + const [browsing, setBrowsing] = useState(false); + const scrollbarsRef = useStyledRef<CustomScrollbarsRef>(); + + const value = useMemo( + () => ({ + browsing, + scrollbarsRef, + setBrowsing, + }), + [browsing, scrollbarsRef], + ); + + return <SplitTunnelingContext value={value}>{children}</SplitTunnelingContext>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingView.tsx new file mode 100644 index 0000000000..f584baf797 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingView.tsx @@ -0,0 +1,58 @@ +import styled from 'styled-components'; + +import { strings } from '../../../../shared/constants'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import { LinuxSettings, Settings } from './components'; +import { SplitTunnelingContextProvider, useSplitTunnelingContext } from './SplitTunnelingContext'; + +const StyledPageCover = styled.div<{ $show: boolean }>((props) => ({ + position: 'absolute', + zIndex: 2, + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: 0.5, + display: props.$show ? 'block' : 'none', +})); + +const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + flex: 1, +}); + +function SplitTunnelingInner() { + const { pop } = useHistory(); + const { browsing, scrollbarsRef } = useSplitTunnelingContext(); + const showLinuxSettings = window.env.platform === 'linux'; + + return ( + <> + <StyledPageCover $show={browsing} /> + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader title={strings.splitTunneling} /> + <StyledNavigationScrollbars ref={scrollbarsRef}> + {showLinuxSettings ? <LinuxSettings /> : <Settings />} + </StyledNavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + </> + ); +} + +export function SplitTunnelingView() { + return ( + <SplitTunnelingContextProvider> + <SplitTunnelingInner /> + </SplitTunnelingContextProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/ApplicationIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/ApplicationIcon.tsx new file mode 100644 index 0000000000..bb0d5db510 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/ApplicationIcon.tsx @@ -0,0 +1,28 @@ +import styled from 'styled-components'; + +import { type IApplication } from '../../../../../../shared/application-types'; +import { spacings } from '../../../../../lib/foundations'; +import { CellImage } from '../../../../cell'; +import { disabledApplication, type DisabledApplicationProps } from '../../utils'; + +export const StyledIcon = styled(CellImage)<DisabledApplicationProps>(disabledApplication, { + marginRight: spacings.small, +}); + +export const StyledIconPlaceholder = styled.div({ + width: '35px', + marginRight: spacings.small, +}); + +export type ApplicationIconProps = { + disabled?: boolean; + icon?: IApplication['icon']; +}; + +export function ApplicationIcon({ disabled, icon }: ApplicationIconProps) { + if (icon) { + return <StyledIcon source={icon} width={35} height={35} $lookDisabled={disabled} />; + } + + return <StyledIconPlaceholder />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/index.ts new file mode 100644 index 0000000000..715b7f0539 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/index.ts @@ -0,0 +1 @@ +export * from './ApplicationIcon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/ApplicationLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/ApplicationLabel.tsx new file mode 100644 index 0000000000..ea5cbcb767 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/ApplicationLabel.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +import { Label } from '../../../../cell'; +import { normalText } from '../../../../common-styles'; +import { disabledApplication, type DisabledApplicationProps } from '../../utils'; + +export const StyledCellLabel = styled(Label)<DisabledApplicationProps>( + disabledApplication, + normalText, + { + fontWeight: 400, + wordWrap: 'break-word', + overflow: 'hidden', + }, +); + +export type ApplicationLabelProps = { + children: React.ReactNode; + disabled?: boolean; +}; + +export function ApplicationLabel({ children, disabled }: ApplicationLabelProps) { + return <StyledCellLabel $lookDisabled={disabled}>{children}</StyledCellLabel>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/index.ts new file mode 100644 index 0000000000..d5abc249f1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/index.ts @@ -0,0 +1 @@ +export * from './ApplicationLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/ApplicationList.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/ApplicationList.tsx new file mode 100644 index 0000000000..4f6e16a686 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/ApplicationList.tsx @@ -0,0 +1,48 @@ +import styled from 'styled-components'; + +import { IApplication } from '../../../../../../shared/application-types'; +import { Flex, Spinner } from '../../../../../lib/components'; +import { colors, spacings } from '../../../../../lib/foundations'; +import { CellButton } from '../../../../cell'; +import { measurements } from '../../../../common-styles'; +import List from '../../../../List'; +import { applicationGetKey } from './utils'; + +export const StyledSpinnerRow = styled(CellButton)({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: `${spacings.small} 0`, + marginBottom: measurements.rowVerticalMargin, + background: colors.blue40, +}); + +export type ApplicationListProps<T extends IApplication> = { + applications: T[] | undefined; + rowRenderer: (application: T) => React.ReactElement; + 'data-testid'?: string; +}; + +export function ApplicationList<T extends IApplication>({ + applications, + rowRenderer, + ...props +}: ApplicationListProps<T>) { + if (applications == undefined) { + return ( + <StyledSpinnerRow> + <Spinner size="big" /> + </StyledSpinnerRow> + ); + } else { + const items = applications.slice().sort((a, b) => a.name.localeCompare(b.name)); + + return ( + <Flex $flexDirection="column" data-testid={props['data-testid']}> + <List data-testid={props['data-testid']} items={items} getKey={applicationGetKey}> + {rowRenderer} + </List> + </Flex> + ); + } +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/index.ts new file mode 100644 index 0000000000..9f526ae802 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/index.ts @@ -0,0 +1 @@ +export * from './ApplicationList'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/utils.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/utils.ts new file mode 100644 index 0000000000..b51bfaefb3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/utils.ts @@ -0,0 +1,5 @@ +import { type IApplication } from '../../../../../../shared/application-types'; + +export function applicationGetKey<T extends IApplication>(application: T): string { + return application.absolutepath; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRow.tsx new file mode 100644 index 0000000000..b59e052276 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRow.tsx @@ -0,0 +1,56 @@ +import styled from 'styled-components'; + +import { ISplitTunnelingApplication } from '../../../../../../shared/application-types'; +import { Flex } from '../../../../../lib/components'; +import { colors } from '../../../../../lib/foundations'; +import { Container } from '../../../../cell'; +import { ApplicationIcon } from '../application-icon'; +import { ApplicationLabel } from '../application-label'; +import { ApplicationRowContextProvider } from './ApplicationRowContext'; +import { AddButton, DeleteButton, RemoveButton } from './components'; +import { + useApplication, + useShowAddButton, + useShowDeleteButton, + useShowRemoveButton, +} from './hooks'; + +export type ApplicationRowProps = { + application: ISplitTunnelingApplication; + onAdd?: (application: ISplitTunnelingApplication) => void; + onDelete?: (application: ISplitTunnelingApplication) => void; + onRemove?: (application: ISplitTunnelingApplication) => void; +}; + +export const StyledContainer = styled(Container)({ + backgroundColor: colors.blue40, +}); + +export function ApplicationRowInner() { + const application = useApplication(); + const showAddButton = useShowAddButton(); + const showDeleteButton = useShowDeleteButton(); + const showRemoveButton = useShowRemoveButton(); + + return ( + <> + <StyledContainer> + <ApplicationIcon icon={application.icon} /> + <ApplicationLabel>{application.name}</ApplicationLabel> + <Flex $gap="small"> + {showAddButton && <AddButton />} + {showDeleteButton && <DeleteButton />} + {showRemoveButton && <RemoveButton />} + </Flex> + </StyledContainer> + </> + ); +} + +export function ApplicationRow(props: ApplicationRowProps) { + return ( + <ApplicationRowContextProvider {...props}> + <ApplicationRowInner /> + </ApplicationRowContextProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowContext.tsx new file mode 100644 index 0000000000..f5c259ccdc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowContext.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; + +import { type ApplicationRowProps } from './ApplicationRow'; + +type ApplicationRowContextProviderProps = ApplicationRowProps & { + children: React.ReactNode; +}; + +type ApplicationRowContext = ApplicationRowProps; + +const ApplicationRowContext = React.createContext<ApplicationRowContext | undefined>(undefined); + +export const useApplicationRowContext = (): ApplicationRowContext => { + const context = React.useContext(ApplicationRowContext); + if (!context) { + throw new Error('useApplicationRow must be used within a ApplicationRowContext'); + } + return context; +}; + +export function ApplicationRowContextProvider({ + application, + children, + onAdd, + onDelete, + onRemove, +}: ApplicationRowContextProviderProps) { + const value = useMemo( + () => ({ + application, + onAdd, + onDelete, + onRemove, + }), + [application, onAdd, onDelete, onRemove], + ); + + return <ApplicationRowContext value={value}>{children}</ApplicationRowContext>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowTypes.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowTypes.ts new file mode 100644 index 0000000000..77bf3bc9da --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowTypes.ts @@ -0,0 +1,8 @@ +import { ISplitTunnelingApplication } from '../../../../../../shared/application-types'; + +export type ApplicationRowProps = { + application: ISplitTunnelingApplication; + onAdd?: (application: ISplitTunnelingApplication) => void; + onDelete?: (application: ISplitTunnelingApplication) => void; + onRemove?: (application: ISplitTunnelingApplication) => void; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/AddButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/AddButton.tsx new file mode 100644 index 0000000000..17af46048e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/AddButton.tsx @@ -0,0 +1,12 @@ +import { IconButton } from '../../../../../../../lib/components'; +import { useAddApplication } from './hooks'; + +export function AddButton() { + const addApplication = useAddApplication(); + + return ( + <IconButton variant="secondary" onClick={addApplication}> + <IconButton.Icon icon="add-circle" /> + </IconButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/index.ts new file mode 100644 index 0000000000..c0997ad341 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-add-application'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/use-add-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/use-add-application.ts new file mode 100644 index 0000000000..9ad79f3170 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/use-add-application.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import { useApplicationRowContext } from '../../../ApplicationRowContext'; + +export function useAddApplication() { + const { application, onAdd } = useApplicationRowContext(); + + const addApplication = useCallback(() => { + onAdd?.(application); + }, [application, onAdd]); + + return addApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/index.ts new file mode 100644 index 0000000000..f10ccfe0f8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/index.ts @@ -0,0 +1 @@ +export * from './AddButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/DeleteButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/DeleteButton.tsx new file mode 100644 index 0000000000..34c984e16b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/DeleteButton.tsx @@ -0,0 +1,12 @@ +import { IconButton } from '../../../../../../../lib/components'; +import { useDeleteApplication } from './hooks'; + +export function DeleteButton() { + const deleteApplication = useDeleteApplication(); + + return ( + <IconButton variant="secondary" onClick={deleteApplication}> + <IconButton.Icon icon="cross-circle" /> + </IconButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/index.ts new file mode 100644 index 0000000000..d2e08f46df --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-delete-application'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/use-delete-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/use-delete-application.ts new file mode 100644 index 0000000000..a2ed1cb3f9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/use-delete-application.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import { useApplicationRowContext } from '../../../ApplicationRowContext'; + +export function useDeleteApplication() { + const { application, onDelete } = useApplicationRowContext(); + + const deleteApplication = useCallback(() => { + onDelete?.(application); + }, [application, onDelete]); + + return deleteApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/index.ts new file mode 100644 index 0000000000..29e1bc4831 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/index.ts @@ -0,0 +1 @@ +export * from './DeleteButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/index.ts new file mode 100644 index 0000000000..344d7067ae --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/index.ts @@ -0,0 +1,3 @@ +export * from './add-button'; +export * from './delete-button'; +export * from './remove-button'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/RemoveButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/RemoveButton.tsx new file mode 100644 index 0000000000..3438f78b19 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/RemoveButton.tsx @@ -0,0 +1,12 @@ +import { IconButton } from '../../../../../../../lib/components'; +import { useRemoveApplication } from './hooks'; + +export function RemoveButton() { + const removeApplication = useRemoveApplication(); + + return ( + <IconButton variant="secondary" onClick={removeApplication}> + <IconButton.Icon icon="remove-circle" /> + </IconButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/index.ts new file mode 100644 index 0000000000..fad5c5e6e7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-remove-application'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/use-remove-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/use-remove-application.ts new file mode 100644 index 0000000000..a54bded94b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/use-remove-application.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import { useApplicationRowContext } from '../../../ApplicationRowContext'; + +export function useRemoveApplication() { + const { application, onRemove } = useApplicationRowContext(); + + const removeApplication = useCallback(() => { + onRemove?.(application); + }, [application, onRemove]); + + return removeApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/index.ts new file mode 100644 index 0000000000..38a9e24aac --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/index.ts @@ -0,0 +1 @@ +export * from './RemoveButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/index.ts new file mode 100644 index 0000000000..8fe4af86d1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './use-application'; +export * from './use-show-add-button'; +export * from './use-show-delete-button'; +export * from './use-show-remove-button'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-application.ts new file mode 100644 index 0000000000..06623a4fcb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-application.ts @@ -0,0 +1,7 @@ +import { useApplicationRowContext } from '../ApplicationRowContext'; + +export const useApplication = () => { + const { application } = useApplicationRowContext(); + + return application; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-add-button.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-add-button.ts new file mode 100644 index 0000000000..2e22c57d34 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-add-button.ts @@ -0,0 +1,9 @@ +import { useApplicationRowContext } from '../ApplicationRowContext'; + +export function useShowAddButton() { + const { onAdd } = useApplicationRowContext(); + + const showAddButton = onAdd !== undefined; + + return showAddButton; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-delete-button.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-delete-button.ts new file mode 100644 index 0000000000..3e3ef5cc87 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-delete-button.ts @@ -0,0 +1,9 @@ +import { useApplicationRowContext } from '../ApplicationRowContext'; + +export function useShowDeleteButton() { + const { onDelete } = useApplicationRowContext(); + + const showDeleteButton = onDelete !== undefined; + + return showDeleteButton; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-remove-button.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-remove-button.ts new file mode 100644 index 0000000000..104657ea71 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-remove-button.ts @@ -0,0 +1,9 @@ +import { useApplicationRowContext } from '../ApplicationRowContext'; + +export function useShowRemoveButton() { + const { onRemove } = useApplicationRowContext(); + + const showRemoveButton = onRemove !== undefined; + + return showRemoveButton; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/index.ts new file mode 100644 index 0000000000..d6042df8f3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/index.ts @@ -0,0 +1 @@ +export * from './ApplicationRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/ApplicationSearchBar.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/ApplicationSearchBar.tsx new file mode 100644 index 0000000000..27f529420e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/ApplicationSearchBar.tsx @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +import { measurements } from '../../../../common-styles'; +import SearchBar, { type ISearchBarProps } from '../../../../SearchBar'; + +export type SearchBarProps = ISearchBarProps; + +export const StyledSearchBar = styled(SearchBar)({ + marginLeft: measurements.horizontalViewMargin, + marginRight: measurements.horizontalViewMargin, + marginBottom: measurements.buttonVerticalMargin, +}); + +export function ApplicationSearchBar(props: SearchBarProps) { + return <StyledSearchBar {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/index.ts new file mode 100644 index 0000000000..1eefc3b0ca --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/index.ts @@ -0,0 +1 @@ +export * from './ApplicationSearchBar'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/ApplicationSearchNoResult.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/ApplicationSearchNoResult.tsx new file mode 100644 index 0000000000..9f720e20d3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/ApplicationSearchNoResult.tsx @@ -0,0 +1,36 @@ +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { messages } from '../../../../../../shared/gettext'; +import { spacings } from '../../../../../lib/foundations'; +import { formatHtml } from '../../../../../lib/html-formatter'; +import { CellFooter, CellFooterText } from '../../../../cell'; + +export const StyledNoResult = styled(CellFooter)({ + display: 'flex', + flexDirection: 'column', + paddingTop: 0, + marginTop: 0, + marginBottom: spacings.large, +}); + +export const StyledNoResultText = styled(CellFooterText)({ + textAlign: 'center', +}); + +export type NoSearchResultProps = { + searchTerm: string; +}; + +export function ApplicationSearchNoResult({ searchTerm }: NoSearchResultProps) { + return ( + <StyledNoResult> + <StyledNoResultText> + {formatHtml( + sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }), + )} + </StyledNoResultText> + <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText> + </StyledNoResult> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/index.ts new file mode 100644 index 0000000000..fd02094bd8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/index.ts @@ -0,0 +1 @@ +export * from './ApplicationSearchNoResult'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/index.ts new file mode 100644 index 0000000000..a5759daf46 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/index.ts @@ -0,0 +1,6 @@ +export * from './application-list'; +export * from './application-row'; +export * from './application-search-bar'; +export * from './application-search-no-result'; +export * from './linux-settings'; +export * from './split-tunneling-settings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettings.tsx new file mode 100644 index 0000000000..c0970d13dc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettings.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { Flex } from '../../../../../lib/components'; +import { FlexColumn } from '../../../../../lib/components/flex-column'; +import { useAfterTransition } from '../../../../../lib/transition-hooks'; +import { useEffectEvent } from '../../../../../lib/utility-hooks'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from '../../../../SettingsHeader'; +import { ApplicationSearchBar } from '../application-search-bar'; +import { ApplicationSearchNoResult } from '../application-search-no-result'; +import { LaunchErrorDialog, LinuxApplicationList, OpenFilePickerButton } from './components'; +import { useShowLinuxApplicationList, useShowNoSearchResult } from './hooks'; +import { LinuxSettingsContextProvider, useLinuxSettingsContext } from './LinuxSettingsContext'; + +function LinuxSettingsInner() { + const { getLinuxSplitTunnelingApplications } = useAppContext(); + const { searchTerm, setApplications, setSearchTerm } = useLinuxSettingsContext(); + const runAfterTransition = useAfterTransition(); + const showLinuxApplicationList = useShowLinuxApplicationList(); + const showNoSearchResult = useShowNoSearchResult(); + + const updateApplications = useEffectEvent(() => { + runAfterTransition(async () => { + const applications = await getLinuxSplitTunnelingApplications(); + setApplications(applications); + }); + }); + + // These lint rules are disabled for now because the react plugin for eslint does + // not understand that useEffectEvent should not be added to the dependency array. + // Enable these rules again when eslint can lint useEffectEvent properly. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void updateApplications(), []); + + return ( + <> + <SettingsHeader> + <HeaderTitle>{strings.splitTunneling}</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> + <ApplicationSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + {showNoSearchResult && <ApplicationSearchNoResult searchTerm={searchTerm} />} + <FlexColumn $gap="medium"> + {showLinuxApplicationList && <LinuxApplicationList />} + <Flex $margin={{ horizontal: 'medium', bottom: 'large' }}> + <OpenFilePickerButton /> + </Flex> + </FlexColumn> + <LaunchErrorDialog /> + </> + ); +} + +export function LinuxSettings() { + return ( + <LinuxSettingsContextProvider> + <LinuxSettingsInner /> + </LinuxSettingsContextProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettingsContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettingsContext.tsx new file mode 100644 index 0000000000..5af8fa2277 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettingsContext.tsx @@ -0,0 +1,46 @@ +import React, { useMemo, useState } from 'react'; + +import { type ILinuxSplitTunnelingApplication } from '../../../../../../shared/application-types'; + +type LinuxSettingsContextProviderProps = { + children: React.ReactNode; +}; + +type LinuxSettingsContext = { + applications?: ILinuxSplitTunnelingApplication[]; + browseError?: string; + searchTerm: string; + setApplications: (value: ILinuxSplitTunnelingApplication[]) => void; + setBrowseError: (value?: string) => void; + setSearchTerm: (value: string) => void; +}; + +const LinuxSettingsContext = React.createContext<LinuxSettingsContext | undefined>(undefined); + +export const useLinuxSettingsContext = (): LinuxSettingsContext => { + const context = React.useContext(LinuxSettingsContext); + if (!context) { + throw new Error('useLinuxSettingsContext must be used within a LinuxSettingsContext'); + } + return context; +}; + +export function LinuxSettingsContextProvider({ children }: LinuxSettingsContextProviderProps) { + const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); + const [browseError, setBrowseError] = useState<string>(); + const [searchTerm, setSearchTerm] = useState(''); + + const value = useMemo( + () => ({ + applications, + browseError, + searchTerm, + setApplications, + setBrowseError, + setSearchTerm, + }), + [applications, browseError, searchTerm, setApplications, setBrowseError, setSearchTerm], + ); + + return <LinuxSettingsContext value={value}>{children}</LinuxSettingsContext>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/index.ts new file mode 100644 index 0000000000..6dd9b7e1cb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/index.ts @@ -0,0 +1,3 @@ +export * from './launch-error-dialog'; +export * from './linux-application-list'; +export * from './open-file-picker-button'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/LaunchErrorDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/LaunchErrorDialog.tsx new file mode 100644 index 0000000000..3530e587b8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/LaunchErrorDialog.tsx @@ -0,0 +1,36 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../../../../shared/gettext'; +import { Button } from '../../../../../../../lib/components'; +import { colors } from '../../../../../../../lib/foundations'; +import { ModalAlert, ModalAlertType } from '../../../../../../Modal'; +import { useLinuxSettingsContext } from '../../LinuxSettingsContext'; +import { useHasBrowseError, useHideBrowseFailureDialog } from './hooks'; + +export function LaunchErrorDialog() { + const { browseError } = useLinuxSettingsContext(); + const hasBrowseError = useHasBrowseError(); + const hideBrowseFailureDialog = useHideBrowseFailureDialog(); + + return ( + <ModalAlert + isOpen={hasBrowseError} + type={ModalAlertType.warning} + iconColor={colors.red} + message={sprintf( + // TRANSLATORS: Error message showed in a dialog when an application fails to launch. + messages.pgettext( + 'split-tunneling-view', + 'Unable to launch selection. %(detailedErrorMessage)s', + ), + { detailedErrorMessage: browseError }, + )} + buttons={[ + <Button key="close" onClick={hideBrowseFailureDialog}> + <Button.Text>{messages.gettext('Close')}</Button.Text> + </Button>, + ]} + close={hideBrowseFailureDialog} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/index.ts new file mode 100644 index 0000000000..5323c5d93e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-has-browse-error'; +export * from './use-hide-browse-failure-dialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-has-browse-error.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-has-browse-error.ts new file mode 100644 index 0000000000..ce715987e2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-has-browse-error.ts @@ -0,0 +1,9 @@ +import { useLinuxSettingsContext } from '../../../LinuxSettingsContext'; + +export function useHasBrowseError() { + const { browseError } = useLinuxSettingsContext(); + + const hasBrowseError = browseError !== undefined; + + return hasBrowseError; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-hide-browse-failure-dialog.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-hide-browse-failure-dialog.ts new file mode 100644 index 0000000000..87252dd0da --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-hide-browse-failure-dialog.ts @@ -0,0 +1,11 @@ +import { useCallback } from 'react'; + +import { useLinuxSettingsContext } from '../../../LinuxSettingsContext'; + +export function useHideBrowseFailureDialog() { + const { setBrowseError } = useLinuxSettingsContext(); + + const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), [setBrowseError]); + + return hideBrowseFailureDialog; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/index.ts new file mode 100644 index 0000000000..52df473ff0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/index.ts @@ -0,0 +1 @@ +export * from './LaunchErrorDialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/LinuxApplicationList.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/LinuxApplicationList.tsx new file mode 100644 index 0000000000..817c6e10c2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/LinuxApplicationList.tsx @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; + +import { ILinuxSplitTunnelingApplication } from '../../../../../../../../shared/application-types'; +import { ApplicationList } from '../../../application-list'; +import { useFilteredApplications, useLaunchApplication } from '../../hooks'; +import { LinuxApplicationRow } from './components'; + +export function LinuxApplicationList() { + const launchApplication = useLaunchApplication(); + + const rowRenderer = useCallback( + (application: ILinuxSplitTunnelingApplication) => ( + <LinuxApplicationRow application={application} onSelect={launchApplication} /> + ), + [launchApplication], + ); + + const filteredApplications = useFilteredApplications(); + + return <ApplicationList applications={filteredApplications} rowRenderer={rowRenderer} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/index.ts new file mode 100644 index 0000000000..c60cafa990 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/index.ts @@ -0,0 +1 @@ +export * from './linux-application-row'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRow.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRow.tsx new file mode 100644 index 0000000000..8bd050d203 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRow.tsx @@ -0,0 +1,36 @@ +import { type ILinuxSplitTunnelingApplication } from '../../../../../../../../../../shared/application-types'; +import { ApplicationIcon } from '../../../../../application-icon'; +import { ApplicationLabel } from '../../../../../application-label'; +import { LaunchButton, WarningDialog, WarningIcon } from './components'; +import { useApplication, useDisabled, useHasApplicationWarning } from './hooks'; +import { LinuxApplicationRowContextProvider } from './LinuxApplicationRowContext'; + +export type LinuxApplicationRowProps = { + application: ILinuxSplitTunnelingApplication; + onSelect?: (application: ILinuxSplitTunnelingApplication) => void; +}; + +function LinuxApplicationRowInner() { + const application = useApplication(); + const disabled = useDisabled(); + const hasApplicationWarning = useHasApplicationWarning(); + + return ( + <> + <LaunchButton> + <ApplicationIcon icon={application.icon} disabled={disabled} /> + <ApplicationLabel disabled={disabled}>{application.name}</ApplicationLabel> + {hasApplicationWarning && <WarningIcon />} + </LaunchButton> + <WarningDialog /> + </> + ); +} + +export function LinuxApplicationRow(props: LinuxApplicationRowProps) { + return ( + <LinuxApplicationRowContextProvider {...props}> + <LinuxApplicationRowInner /> + </LinuxApplicationRowContextProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRowContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRowContext.tsx new file mode 100644 index 0000000000..0c0fe1e3dc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRowContext.tsx @@ -0,0 +1,44 @@ +import React, { useMemo, useState } from 'react'; + +import { type LinuxApplicationRowProps } from './LinuxApplicationRow'; + +type LinuxApplicationRowContextProviderProps = LinuxApplicationRowProps & { + children: React.ReactNode; +}; + +type LinuxApplicationRowContext = LinuxApplicationRowProps & { + showWarningDialog: boolean; + setShowWarningDialog: (value: boolean) => void; +}; + +const LinuxApplicationRowContext = React.createContext<LinuxApplicationRowContext | undefined>( + undefined, +); + +export const useLinuxApplicationRowContext = (): LinuxApplicationRowContext => { + const context = React.useContext(LinuxApplicationRowContext); + if (!context) { + throw new Error('useLinuxApplicationRow must be used within a LinuxApplicationRowProvider'); + } + return context; +}; + +export function LinuxApplicationRowContextProvider({ + application, + children, + onSelect, +}: LinuxApplicationRowContextProviderProps) { + const [showWarningDialog, setShowWarningDialog] = useState(false); + + const value = useMemo( + () => ({ + application, + showWarningDialog, + setShowWarningDialog, + onSelect, + }), + [application, onSelect, showWarningDialog, setShowWarningDialog], + ); + + return <LinuxApplicationRowContext value={value}>{children}</LinuxApplicationRowContext>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/index.ts new file mode 100644 index 0000000000..8b180e18a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/index.ts @@ -0,0 +1,3 @@ +export * from './launch-button'; +export * from './warning-dialog'; +export * from './warning-icon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/LaunchButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/LaunchButton.tsx new file mode 100644 index 0000000000..190efceda6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/LaunchButton.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../../../../../../../../../lib/foundations'; +import { CellButton } from '../../../../../../../../../../cell'; +import { useDisabled, useLaunchApplication } from '../../hooks'; + +export const StyledCellButton = styled(CellButton)<{ $lookDisabled?: boolean }>((props) => ({ + '&&:not(:disabled):hover': { + backgroundColor: props.$lookDisabled ? colors.blue : undefined, + }, +})); + +export type LaunchButtonProps = { + children: React.ReactNode; +}; + +export function LaunchButton({ children }: LaunchButtonProps) { + const disabled = useDisabled(); + const launchApplication = useLaunchApplication(); + + return ( + <StyledCellButton onClick={launchApplication} $lookDisabled={disabled}> + {children} + </StyledCellButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/index.ts new file mode 100644 index 0000000000..1ffe2e9468 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/launch-button/index.ts @@ -0,0 +1 @@ +export * from './LaunchButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/WarningDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/WarningDialog.tsx new file mode 100644 index 0000000000..707cc4367f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/WarningDialog.tsx @@ -0,0 +1,28 @@ +import { ModalAlert, ModalAlertType } from '../../../../../../../../../../Modal'; +import { useDisabled, useWarningColor } from '../../hooks'; +import { useLinuxApplicationRowContext } from '../../LinuxApplicationRowContext'; +import { CancelButton, LaunchButton } from './components'; +import { useHideWarningDialog, useWarningMessage } from './hooks'; + +export function WarningDialog() { + const { showWarningDialog } = useLinuxApplicationRowContext(); + const disabled = useDisabled(); + const hideWarningDialog = useHideWarningDialog(); + const warningColor = useWarningColor(); + const warningMessage = useWarningMessage(); + + const warningDialogButtons = disabled + ? [<CancelButton key="cancel" />] + : [<LaunchButton key="launch" />, <CancelButton key="cancel" />]; + + return ( + <ModalAlert + isOpen={showWarningDialog} + type={ModalAlertType.warning} + iconColor={warningColor} + message={warningMessage} + buttons={warningDialogButtons} + close={hideWarningDialog} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/CancelButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/CancelButton.tsx new file mode 100644 index 0000000000..a389870a5d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/CancelButton.tsx @@ -0,0 +1,15 @@ +import { messages } from '../../../../../../../../../../../../../../shared/gettext'; +import { Button } from '../../../../../../../../../../../../../lib/components'; +import { useDisabled } from '../../../../hooks'; +import { useHideWarningDialog } from '../../hooks'; + +export function CancelButton() { + const disabled = useDisabled(); + const hideWarningDialog = useHideWarningDialog(); + + return ( + <Button key="cancel" onClick={hideWarningDialog}> + <Button.Text>{disabled ? messages.gettext('Back') : messages.gettext('Cancel')}</Button.Text> + </Button> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/index.ts new file mode 100644 index 0000000000..886c5b0107 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/cancel-button/index.ts @@ -0,0 +1 @@ +export * from './CancelButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/index.ts new file mode 100644 index 0000000000..a76b48c590 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/index.ts @@ -0,0 +1,2 @@ +export * from './cancel-button'; +export * from './launch-button'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx new file mode 100644 index 0000000000..58c82d4c42 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx @@ -0,0 +1,18 @@ +import { messages } from '../../../../../../../../../../../../../../shared/gettext'; +import { Button } from '../../../../../../../../../../../../../lib/components'; +import { useLaunchApplication } from '../../../../hooks'; + +export function LaunchButton() { + const launchApplication = useLaunchApplication(); + + return ( + <Button onClick={launchApplication}> + <Button.Text> + { + // TRANSLATORS: Button label for launching an application with split tunneling. + messages.pgettext('split-tunneling-view', 'Launch') + } + </Button.Text> + </Button> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/index.ts new file mode 100644 index 0000000000..1ffe2e9468 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/index.ts @@ -0,0 +1 @@ +export * from './LaunchButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/index.ts new file mode 100644 index 0000000000..abcf7f8e96 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-hide-warning-dialog'; +export * from './use-warning-message'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-hide-warning-dialog.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-hide-warning-dialog.ts new file mode 100644 index 0000000000..8cf0680915 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-hide-warning-dialog.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import { useLinuxApplicationRowContext } from '../../../LinuxApplicationRowContext'; + +export function useHideWarningDialog() { + const { setShowWarningDialog } = useLinuxApplicationRowContext(); + + const hideWarningDialog = useCallback(() => { + setShowWarningDialog(false); + }, [setShowWarningDialog]); + + return hideWarningDialog; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-warning-message.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-warning-message.ts new file mode 100644 index 0000000000..21ebd71f5b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/hooks/use-warning-message.ts @@ -0,0 +1,33 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../../../../../../../../../shared/gettext'; +import { useApplication, useDisabled } from '../../../hooks'; + +export function useWarningMessage() { + const application = useApplication(); + const disabled = useDisabled(); + + const applicationName = application.name; + + if (disabled) { + return sprintf( + messages.pgettext( + 'split-tunneling-view', + '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.', + ), + { + applicationName, + }, + ); + } + + return 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, + }, + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/index.ts new file mode 100644 index 0000000000..253370c116 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/index.ts @@ -0,0 +1 @@ +export * from './WarningDialog'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/WarningIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/WarningIcon.tsx new file mode 100644 index 0000000000..3bfa2ee867 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/WarningIcon.tsx @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +import { spacings } from '../../../../../../../../../../../lib/foundations'; +import { CellTintedIcon } from '../../../../../../../../../../cell'; +import { useWarningColor } from '../../hooks'; + +export const StyledCellWarningIcon = styled(CellTintedIcon)({ + marginLeft: spacings.small, + marginRight: spacings.tiny, +}); + +export function WarningIcon() { + const warningColor = useWarningColor(); + + return <StyledCellWarningIcon icon="alert-circle" color={warningColor} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/index.ts new file mode 100644 index 0000000000..f8efa62fe2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-icon/index.ts @@ -0,0 +1 @@ +export * from './WarningIcon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/index.ts new file mode 100644 index 0000000000..b5d2af7e12 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './use-application'; +export * from './use-disabled'; +export * from './use-has-application-warning'; +export * from './use-launch-application'; +export * from './use-warning-color'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-application.ts new file mode 100644 index 0000000000..19b4880425 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-application.ts @@ -0,0 +1,7 @@ +import { useLinuxApplicationRowContext } from '../LinuxApplicationRowContext'; + +export const useApplication = () => { + const { application } = useLinuxApplicationRowContext(); + + return application; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-disabled.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-disabled.ts new file mode 100644 index 0000000000..052c610b3a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-disabled.ts @@ -0,0 +1,9 @@ +import { useApplication } from './use-application'; + +export function useDisabled() { + const application = useApplication(); + + const disabled = application.warning === 'launches-elsewhere'; + + return disabled; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-has-application-warning.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-has-application-warning.ts new file mode 100644 index 0000000000..898f8ffe34 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-has-application-warning.ts @@ -0,0 +1,9 @@ +import { useApplication } from './use-application'; + +export function useHasApplicationWarning() { + const application = useApplication(); + + const hasApplicationWarning = application.warning !== undefined; + + return hasApplicationWarning; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-launch-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-launch-application.ts new file mode 100644 index 0000000000..1b91848e6b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-launch-application.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +import { useLinuxApplicationRowContext } from '../LinuxApplicationRowContext'; +import { useHasApplicationWarning } from './use-has-application-warning'; + +export function useLaunchApplication() { + const { application, onSelect, setShowWarningDialog } = useLinuxApplicationRowContext(); + const hasApplicationWarning = useHasApplicationWarning(); + + const launchApplication = useCallback(() => { + if (hasApplicationWarning) { + setShowWarningDialog(true); + } else { + setShowWarningDialog(false); + onSelect?.(application); + } + }, [application, hasApplicationWarning, onSelect, setShowWarningDialog]); + + return launchApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-warning-color.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-warning-color.ts new file mode 100644 index 0000000000..fddc6294d3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-warning-color.ts @@ -0,0 +1,10 @@ +import { Colors } from '../../../../../../../../../../lib/foundations'; +import { useDisabled } from './use-disabled'; + +export function useWarningColor(): Colors { + const disabled = useDisabled(); + + const warningColor = disabled ? 'red' : 'yellow'; + + return warningColor; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/index.ts new file mode 100644 index 0000000000..dcb4e87c28 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/index.ts @@ -0,0 +1 @@ +export * from './LinuxApplicationRow'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/index.ts new file mode 100644 index 0000000000..38468d2ab6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/index.ts @@ -0,0 +1 @@ +export * from './LinuxApplicationList'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/OpenFilePickerButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/OpenFilePickerButton.tsx new file mode 100644 index 0000000000..0b7fe7bec1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/OpenFilePickerButton.tsx @@ -0,0 +1,18 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { Button } from '../../../../../../../lib/components'; +import { useLaunchWithFilePicker } from './hooks'; + +export function OpenFilePickerButton() { + const launchWithFilePicker = useLaunchWithFilePicker(); + + return ( + <Button onClick={launchWithFilePicker}> + <Button.Text> + { + // TRANSLATORS: Button label for browsing applications with split tunneling. + messages.pgettext('split-tunneling-view', 'Find another app') + } + </Button.Text> + </Button> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/index.ts new file mode 100644 index 0000000000..502bc77f87 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-launch-with-file-picker'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/use-launch-with-file-picker.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/use-launch-with-file-picker.ts new file mode 100644 index 0000000000..70703b8ae8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/use-launch-with-file-picker.ts @@ -0,0 +1,17 @@ +import { messages } from '../../../../../../../../../shared/gettext'; +import { useFilePicker } from '../../../../../hooks'; +import { useSplitTunnelingContext } from '../../../../../SplitTunnelingContext'; +import { useLaunchApplication } from '../../../hooks'; + +export function useLaunchWithFilePicker() { + const { setBrowsing } = useSplitTunnelingContext(); + const launchApplication = useLaunchApplication(); + + const launchWithFilePicker = useFilePicker( + messages.pgettext('split-tunneling-view', 'Launch'), + setBrowsing, + launchApplication, + ); + + return launchWithFilePicker; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/index.ts new file mode 100644 index 0000000000..3492fd46b6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/index.ts @@ -0,0 +1 @@ +export * from './OpenFilePickerButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/index.ts new file mode 100644 index 0000000000..103337c62a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './use-filtered-applications'; +export * from './use-launch-application'; +export * from './use-show-linux-application-list'; +export * from './use-show-no-search-result'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-filtered-applications.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-filtered-applications.ts new file mode 100644 index 0000000000..888d779f62 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-filtered-applications.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; + +import { includesSearchTerm } from '../../../utils'; +import { useLinuxSettingsContext } from '../LinuxSettingsContext'; + +export function useFilteredApplications() { + const { applications, searchTerm } = useLinuxSettingsContext(); + + const filteredApplications = useMemo( + () => applications?.filter((application) => includesSearchTerm(application, searchTerm)), + [applications, searchTerm], + ); + + return filteredApplications; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-launch-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-launch-application.ts new file mode 100644 index 0000000000..302f9385eb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-launch-application.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { type ILinuxSplitTunnelingApplication } from '../../../../../../../shared/application-types'; +import { useAppContext } from '../../../../../../context'; +import { useLinuxSettingsContext } from '../LinuxSettingsContext'; + +export function useLaunchApplication() { + const { launchExcludedApplication } = useAppContext(); + const { setBrowseError } = useLinuxSettingsContext(); + + const launchApplication = useCallback( + async (application: ILinuxSplitTunnelingApplication | string) => { + const result = await launchExcludedApplication(application); + if ('error' in result) { + setBrowseError(result.error); + } + }, + [launchExcludedApplication, setBrowseError], + ); + + return launchApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-linux-application-list.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-linux-application-list.ts new file mode 100644 index 0000000000..678c9b6798 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-linux-application-list.ts @@ -0,0 +1,10 @@ +import { useFilteredApplications } from './use-filtered-applications'; + +export function useShowLinuxApplicationList() { + const filteredApplications = useFilteredApplications(); + + const showLinuxApplicationList = + filteredApplications !== undefined && filteredApplications.length > 0; + + return showLinuxApplicationList; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-no-search-result.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-no-search-result.ts new file mode 100644 index 0000000000..735e24242f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-no-search-result.ts @@ -0,0 +1,12 @@ +import { useLinuxSettingsContext } from '../LinuxSettingsContext'; +import { useFilteredApplications } from './use-filtered-applications'; + +export function useShowNoSearchResult() { + const { searchTerm } = useLinuxSettingsContext(); + const filteredApplications = useFilteredApplications(); + + const showNoSearchResult = + searchTerm !== '' && (filteredApplications === undefined || filteredApplications.length === 0); + + return showNoSearchResult; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/index.ts new file mode 100644 index 0000000000..1168c662b9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/index.ts @@ -0,0 +1 @@ +export * from './LinuxSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettings.tsx new file mode 100644 index 0000000000..76ac803fff --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettings.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; + +import { useAppContext } from '../../../../../context'; +import { Flex, Spinner } from '../../../../../lib/components'; +import { useAfterTransition } from '../../../../../lib/transition-hooks'; +import { useEffectEvent } from '../../../../../lib/utility-hooks'; +import { ApplicationSearchBar } from '../application-search-bar'; +import { ApplicationSearchNoResult } from '../application-search-no-result'; +import { + AddApplicationFilePickerButton, + ApplicationLists, + SplitTunnelingSettingsHeader, +} from './components'; +import { useCanEditSplitTunneling, useShowNoSearchResult } from './hooks'; +import { useFetchNeedFullDiskPermissions, useShowApplicationLists } from './hooks'; +import { + SplitTunnelingSettingsContextProvider, + useSplitTunnelingSettingsContext, +} from './SplitTunnelingSettingsContext'; + +function SettingsInner() { + const { getSplitTunnelingApplications } = useAppContext(); + const { loadingDiskPermissions, searchTerm, setApplications, setSearchTerm } = + useSplitTunnelingSettingsContext(); + const fetchNeedFullDiskPermissions = useFetchNeedFullDiskPermissions(); + const runAfterTransition = useAfterTransition(); + const canEditSplitTunneling = useCanEditSplitTunneling(); + const showApplicationLists = useShowApplicationLists(); + const showNoSearchResult = useShowNoSearchResult(); + + useEffect((): void | (() => void) => { + if (window.env.platform === 'darwin') { + void fetchNeedFullDiskPermissions(); + } + }, [fetchNeedFullDiskPermissions]); + + const onMount = useEffectEvent(() => { + runAfterTransition(async () => { + const { fromCache, applications } = await getSplitTunnelingApplications(); + setApplications(applications); + + if (fromCache) { + const { applications } = await getSplitTunnelingApplications(true); + setApplications(applications); + } + }); + }); + + // These lint rules are disabled for now because the react plugin for eslint does + // not understand that useEffectEvent should not be added to the dependency array. + // Enable these rules again when eslint can lint useEffectEvent properly. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void onMount(), []); + + return ( + <> + <SplitTunnelingSettingsHeader /> + {loadingDiskPermissions && ( + <Flex $justifyContent="center" $margin={{ top: 'large' }}> + <Spinner size="big" /> + </Flex> + )} + {canEditSplitTunneling && ( + <ApplicationSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + )} + {showNoSearchResult && <ApplicationSearchNoResult searchTerm={searchTerm} />} + <Flex $flexDirection="column" $gap="medium" $margin={{ bottom: 'large' }}> + {showApplicationLists && <ApplicationLists />} + {canEditSplitTunneling && <AddApplicationFilePickerButton />} + </Flex> + </> + ); +} + +export function Settings() { + return ( + <SplitTunnelingSettingsContextProvider> + <SettingsInner /> + </SplitTunnelingSettingsContextProvider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettingsContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettingsContext.tsx new file mode 100644 index 0000000000..fef10a5eca --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettingsContext.tsx @@ -0,0 +1,68 @@ +import React, { useMemo, useState } from 'react'; + +import { type ISplitTunnelingApplication } from '../../../../../../shared/application-types'; + +type SplitTunnelingSettingsContextProviderProps = { + children: React.ReactNode; +}; + +type SplitTunnelingSettingsContext = { + applications?: ISplitTunnelingApplication[]; + loadingDiskPermissions: boolean; + searchTerm: string; + setApplications: (value: ISplitTunnelingApplication[]) => void; + setLoadingDiskPermissions: (value: boolean) => void; + setSearchTerm: (value: string) => void; + setSplitTunnelingAvailable: (value: boolean) => void; + splitTunnelingAvailable?: boolean; +}; + +const SplitTunnelingSettingsContext = React.createContext< + SplitTunnelingSettingsContext | undefined +>(undefined); + +export const useSplitTunnelingSettingsContext = (): SplitTunnelingSettingsContext => { + const context = React.useContext(SplitTunnelingSettingsContext); + if (!context) { + throw new Error( + 'useSplitTunnelingSettingsContext must be used within a SplitTunnelingSettingsContext', + ); + } + return context; +}; + +export function SplitTunnelingSettingsContextProvider({ + children, +}: SplitTunnelingSettingsContextProviderProps) { + const [applications, setApplications] = useState<ISplitTunnelingApplication[]>(); + const [loadingDiskPermissions, setLoadingDiskPermissions] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [splitTunnelingAvailable, setSplitTunnelingAvailable] = useState( + window.env.platform === 'darwin' ? undefined : true, + ); + + const value = useMemo( + () => ({ + applications, + loadingDiskPermissions, + searchTerm, + setApplications, + setLoadingDiskPermissions, + setSearchTerm, + setSplitTunnelingAvailable, + splitTunnelingAvailable, + }), + [ + applications, + loadingDiskPermissions, + searchTerm, + setApplications, + setLoadingDiskPermissions, + setSearchTerm, + setSplitTunnelingAvailable, + splitTunnelingAvailable, + ], + ); + + return <SplitTunnelingSettingsContext value={value}>{children}</SplitTunnelingSettingsContext>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/AddApplicationFilePickerButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/AddApplicationFilePickerButton.tsx new file mode 100644 index 0000000000..2a7d551b9a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/AddApplicationFilePickerButton.tsx @@ -0,0 +1,16 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { Button } from '../../../../../../../lib/components'; +import { Container } from '../../../../../../../lib/components'; +import { useAddWithFilePicker } from '../../hooks'; + +export function AddApplicationFilePickerButton() { + const addWithFilePicker = useAddWithFilePicker(); + + return ( + <Container size="3"> + <Button onClick={addWithFilePicker}> + <Button.Text>{messages.pgettext('split-tunneling-view', 'Find another app')}</Button.Text> + </Button> + </Container> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/index.ts new file mode 100644 index 0000000000..8baf654562 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/index.ts @@ -0,0 +1 @@ +export * from './AddApplicationFilePickerButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/ApplicationLists.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/ApplicationLists.tsx new file mode 100644 index 0000000000..7be69a0c56 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/ApplicationLists.tsx @@ -0,0 +1,20 @@ +import { Flex } from '../../../../../../../lib/components'; +import Accordion from '../../../../../../Accordion'; +import { useHasNonSplitApplications, useHasSplitApplications } from '../../hooks'; +import { NonSplitApplicationSection, SplitApplicationSection } from './components'; + +export function ApplicationLists() { + const hasNonSplitApplications = useHasNonSplitApplications(); + const hasSplitApplications = useHasSplitApplications(); + + return ( + <Flex $flexDirection="column" $gap="medium"> + <Accordion expanded={hasSplitApplications}> + <SplitApplicationSection /> + </Accordion> + <Accordion expanded={hasNonSplitApplications}> + <NonSplitApplicationSection /> + </Accordion> + </Flex> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/index.ts new file mode 100644 index 0000000000..96849eca54 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/index.ts @@ -0,0 +1,2 @@ +export * from './non-split-application-section'; +export * from './split-application-section'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/NonSplitApplicationSection.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/NonSplitApplicationSection.tsx new file mode 100644 index 0000000000..9da9519c09 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/NonSplitApplicationSection.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; + +import { ISplitTunnelingApplication } from '../../../../../../../../../../shared/application-types'; +import { messages } from '../../../../../../../../../../shared/gettext'; +import { Section, SectionTitle } from '../../../../../../../../cell'; +import { ApplicationList } from '../../../../../application-list'; +import { ApplicationRow } from '../../../../../application-row'; +import { useAddApplication, useFilteredNonSplitApplications } from '../../../../hooks'; +import { useForgetManuallyAddedApplicationAndUpdate } from './hooks'; + +export function NonSplitApplicationSection() { + const addApplication = useAddApplication(); + const filteredNonSplitApplications = useFilteredNonSplitApplications(); + const forgetManuallyAddedApplicationAndUpdate = useForgetManuallyAddedApplicationAndUpdate(); + + const includedRowRenderer = useCallback( + (application: ISplitTunnelingApplication) => { + const onForget = application.deletable ? forgetManuallyAddedApplicationAndUpdate : undefined; + return ( + <ApplicationRow application={application} onAdd={addApplication} onDelete={onForget} /> + ); + }, + [addApplication, forgetManuallyAddedApplicationAndUpdate], + ); + + const sectionTitle = ( + <SectionTitle>{messages.pgettext('split-tunneling-view', 'All apps')}</SectionTitle> + ); + + return ( + <Section sectionTitle={sectionTitle}> + <ApplicationList + data-testid="non-split-applications" + applications={filteredNonSplitApplications} + rowRenderer={includedRowRenderer} + /> + </Section> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/index.ts new file mode 100644 index 0000000000..cf4b670b19 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-forget-manually-added-application-and-update'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/use-forget-manually-added-application-and-update.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/use-forget-manually-added-application-and-update.ts new file mode 100644 index 0000000000..cd531e3653 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/use-forget-manually-added-application-and-update.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { type ISplitTunnelingApplication } from '../../../../../../../../../../../shared/application-types'; +import { useAppContext } from '../../../../../../../../../../context'; +import { useSplitTunnelingSettingsContext } from '../../../../../SplitTunnelingSettingsContext'; + +export function useForgetManuallyAddedApplicationAndUpdate() { + const { forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications } = + useAppContext(); + const { setApplications } = useSplitTunnelingSettingsContext(); + + const forgetManuallyAddedApplicationAndUpdate = useCallback( + async (application: ISplitTunnelingApplication) => { + await forgetManuallyAddedSplitTunnelingApplication(application); + const { applications } = await getSplitTunnelingApplications(); + setApplications(applications); + }, + [forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications, setApplications], + ); + + return forgetManuallyAddedApplicationAndUpdate; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/index.ts new file mode 100644 index 0000000000..35389c0bce --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/index.ts @@ -0,0 +1 @@ +export * from './NonSplitApplicationSection'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/SplitApplicationSection.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/SplitApplicationSection.tsx new file mode 100644 index 0000000000..8b81da2e62 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/SplitApplicationSection.tsx @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { type ISplitTunnelingApplication } from '../../../../../../../../../../shared/application-types'; +import { messages } from '../../../../../../../../../../shared/gettext'; +import { Section, SectionTitle } from '../../../../../../../../cell'; +import { ApplicationList } from '../../../../../application-list'; +import { ApplicationRow } from '../../../../../application-row'; +import { useFilteredSplitApplications } from '../../../../hooks'; +import { useRemoveApplication } from './hooks'; + +export function SplitApplicationSection() { + const filteredSplitApplications = useFilteredSplitApplications(); + const removeApplication = useRemoveApplication(); + + const excludedRowRenderer = useCallback( + (application: ISplitTunnelingApplication) => ( + <ApplicationRow application={application} onRemove={removeApplication} /> + ), + [removeApplication], + ); + + const sectionTitle = ( + <SectionTitle>{messages.pgettext('split-tunneling-view', 'Excluded apps')}</SectionTitle> + ); + + return ( + <Section sectionTitle={sectionTitle}> + <ApplicationList + data-testid="split-applications" + applications={filteredSplitApplications} + rowRenderer={excludedRowRenderer} + /> + </Section> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/index.ts new file mode 100644 index 0000000000..fad5c5e6e7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-remove-application'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/use-remove-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/use-remove-application.ts new file mode 100644 index 0000000000..67fd9e0e71 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/use-remove-application.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { type ISplitTunnelingApplication } from '../../../../../../../../../../../shared/application-types'; +import { useAppContext } from '../../../../../../../../../../context'; +import { useCanEditSplitTunneling } from '../../../../../hooks'; + +export function useRemoveApplication() { + const { removeSplitTunnelingApplication, setSplitTunnelingState } = useAppContext(); + const canEditSplitTunneling = useCanEditSplitTunneling(); + + const removeApplication = useCallback( + async (application: ISplitTunnelingApplication) => { + if (!canEditSplitTunneling) { + await setSplitTunnelingState(true); + } + removeSplitTunnelingApplication(application); + }, + [removeSplitTunnelingApplication, setSplitTunnelingState, canEditSplitTunneling], + ); + + return removeApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/index.ts new file mode 100644 index 0000000000..70743ab9a9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/index.ts @@ -0,0 +1 @@ +export * from './SplitApplicationSection'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/index.ts new file mode 100644 index 0000000000..b778383a1b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/index.ts @@ -0,0 +1 @@ +export * from './ApplicationLists'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/index.ts new file mode 100644 index 0000000000..3df485b970 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/index.ts @@ -0,0 +1,3 @@ +export * from './add-application-file-picker-button'; +export * from './application-lists'; +export * from './split-tunneling-settings-header'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/SplitTunnelingSettingsHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/SplitTunnelingSettingsHeader.tsx new file mode 100644 index 0000000000..8399cec893 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/SplitTunnelingSettingsHeader.tsx @@ -0,0 +1,30 @@ +import { strings } from '../../../../../../../../shared/constants'; +import { messages } from '../../../../../../../../shared/gettext'; +import { Flex } from '../../../../../../../lib/components'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from '../../../../../../SettingsHeader'; +import { MacOsSplitTunnelingAvailability, SplitTunnelingStateSwitch } from './components'; +import { useShowMacOsSplitTunnelingAvailability } from './hooks'; +import { useShowHeaderSubtitle } from './hooks'; + +export function SplitTunnelingSettingsHeader() { + const showHeaderSubtitle = useShowHeaderSubtitle(); + const showMacOsSplitTunnelingAvailability = useShowMacOsSplitTunnelingAvailability(); + + return ( + <SettingsHeader> + <Flex $justifyContent="space-between" $alignItems="center"> + <HeaderTitle>{strings.splitTunneling}</HeaderTitle> + <SplitTunnelingStateSwitch /> + </Flex> + {showMacOsSplitTunnelingAvailability && <MacOsSplitTunnelingAvailability />} + {showHeaderSubtitle && ( + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Choose the apps you want to exclude from the VPN tunnel.', + )} + </HeaderSubTitle> + )} + </SettingsHeader> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/index.ts new file mode 100644 index 0000000000..67d382bf33 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/index.ts @@ -0,0 +1,2 @@ +export * from './macos-split-tunneling-availability'; +export * from './split-tunneling-state-switch'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/MacOsSplitTunnelingAvailability.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/MacOsSplitTunnelingAvailability.tsx new file mode 100644 index 0000000000..632105231a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/MacOsSplitTunnelingAvailability.tsx @@ -0,0 +1,41 @@ +import { messages } from '../../../../../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../../../../../context'; +import { Button, Flex, FootnoteMini } from '../../../../../../../../../lib/components'; +import { HeaderSubTitle } from '../../../../../../../../SettingsHeader'; +import { useRestartDaemon } from './hooks'; + +export function MacOsSplitTunnelingAvailability() { + const { showFullDiskAccessSettings } = useAppContext(); + const restartDaemon = useRestartDaemon(); + + return ( + <Flex $flexDirection="column" $gap="large"> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.', + )} + </HeaderSubTitle> + <Flex $flexDirection="column" $gap="small"> + <Flex $flexDirection="column" $gap="big"> + <Button onClick={showFullDiskAccessSettings}> + <Button.Text> + {messages.pgettext('split-tunneling-view', 'Open System Settings')} + </Button.Text> + </Button> + <FootnoteMini color="whiteAlpha60"> + {messages.pgettext( + 'split-tunneling-view', + 'Enabled "Full disk access" and still having issues?', + )} + </FootnoteMini> + </Flex> + <Button onClick={restartDaemon}> + <Button.Text> + {messages.pgettext('split-tunneling-view', 'Restart Mullvad Service')} + </Button.Text> + </Button> + </Flex> + </Flex> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/index.ts new file mode 100644 index 0000000000..e481a633bb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-restart-daemon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/use-restart-daemon.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/use-restart-daemon.ts new file mode 100644 index 0000000000..76efa81016 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/hooks/use-restart-daemon.ts @@ -0,0 +1,11 @@ +import { useCallback } from 'react'; + +import { useAppContext } from '../../../../../../../../../../context'; + +export function useRestartDaemon() { + const { daemonPrepareRestart } = useAppContext(); + + const restartDaemon = useCallback(() => daemonPrepareRestart(true), [daemonPrepareRestart]); + + return restartDaemon; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/index.ts new file mode 100644 index 0000000000..b6ff748ef9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/macos-split-tunneling-availability/index.ts @@ -0,0 +1 @@ +export * from './MacOsSplitTunnelingAvailability'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/SplitTunnelingStateSwitch.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/SplitTunnelingStateSwitch.tsx new file mode 100644 index 0000000000..bf641a6f5e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/SplitTunnelingStateSwitch.tsx @@ -0,0 +1,14 @@ +import { useAppContext } from '../../../../../../../../../context'; +import { useSelector } from '../../../../../../../../../redux/store'; +import { Switch } from '../../../../../../../../cell'; +import { useDisabled } from './hooks'; + +export function SplitTunnelingStateSwitch() { + const { setSplitTunnelingState } = useAppContext(); + const disabled = useDisabled(); + const splitTunnelingEnabled = useSelector((state) => state.settings.splitTunneling); + + return ( + <Switch isOn={splitTunnelingEnabled} disabled={disabled} onChange={setSplitTunnelingState} /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/index.ts new file mode 100644 index 0000000000..f789928df2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-disabled'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/use-disabled.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/use-disabled.ts new file mode 100644 index 0000000000..67bba31937 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/hooks/use-disabled.ts @@ -0,0 +1,11 @@ +import { useSelector } from '../../../../../../../../../../redux/store'; +import { useSplitTunnelingSettingsContext } from '../../../../../SplitTunnelingSettingsContext'; + +export function useDisabled() { + const { loadingDiskPermissions, splitTunnelingAvailable } = useSplitTunnelingSettingsContext(); + const splitTunnelingEnabled = useSelector((state) => state.settings.splitTunneling); + + const disabled = !splitTunnelingEnabled && (!splitTunnelingAvailable || loadingDiskPermissions); + + return disabled; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/index.ts new file mode 100644 index 0000000000..687a44a4e8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/split-tunneling-state-switch/index.ts @@ -0,0 +1 @@ +export * from './SplitTunnelingStateSwitch'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/index.ts new file mode 100644 index 0000000000..44bb0615c2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-show-header-subtitle'; +export * from './use-show-macos-split-tunneling-availability'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-header-subtitle.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-header-subtitle.ts new file mode 100644 index 0000000000..1522a6b6a7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-header-subtitle.ts @@ -0,0 +1,9 @@ +import { useSplitTunnelingSettingsContext } from '../../../SplitTunnelingSettingsContext'; + +export function useShowHeaderSubtitle() { + const { loadingDiskPermissions, splitTunnelingAvailable } = useSplitTunnelingSettingsContext(); + + const showHeaderSubtitle = !loadingDiskPermissions && splitTunnelingAvailable; + + return showHeaderSubtitle; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-macos-split-tunneling-availability.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-macos-split-tunneling-availability.ts new file mode 100644 index 0000000000..9b5badf1f3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-macos-split-tunneling-availability.ts @@ -0,0 +1,15 @@ +import { useSplitTunnelingSettingsContext } from '../../../SplitTunnelingSettingsContext'; + +export function useShowMacOsSplitTunnelingAvailability() { + const { loadingDiskPermissions, splitTunnelingAvailable } = useSplitTunnelingSettingsContext(); + + if (window.env.platform === 'darwin') { + const needFullDiskPermissions = splitTunnelingAvailable === false; + + const showMacOsSplitTunnelingAvailability = !loadingDiskPermissions && needFullDiskPermissions; + + return showMacOsSplitTunnelingAvailability; + } + + return false; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/index.ts new file mode 100644 index 0000000000..60318e7911 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/index.ts @@ -0,0 +1 @@ +export * from './SplitTunnelingSettingsHeader'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/index.ts new file mode 100644 index 0000000000..37e9513baf --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/index.ts @@ -0,0 +1,12 @@ +export * from './use-add-application'; +export * from './use-add-browsed-for-application'; +export * from './use-add-with-file-picker'; +export * from './use-can-edit-split-tunneling'; +export * from './use-fetch-need-full-disk-permissions'; +export * from './use-filtered-non-split-applications'; +export * from './use-filtered-split-applications'; +export * from './use-has-non-split-applications'; +export * from './use-has-split-applications'; +export * from './use-scroll-to-top'; +export * from './use-show-application-lists'; +export * from './use-show-no-search-result'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-application.ts new file mode 100644 index 0000000000..e3beaf494a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-application.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { type ISplitTunnelingApplication } from '../../../../../../../shared/application-types'; +import { useAppContext } from '../../../../../../context'; +import { useCanEditSplitTunneling } from './use-can-edit-split-tunneling'; + +export function useAddApplication() { + const { addSplitTunnelingApplication, setSplitTunnelingState } = useAppContext(); + const canEditSplitTunneling = useCanEditSplitTunneling(); + + const addApplication = useCallback( + async (application: ISplitTunnelingApplication | string) => { + if (!canEditSplitTunneling) { + await setSplitTunnelingState(true); + } + await addSplitTunnelingApplication(application); + }, + [addSplitTunnelingApplication, canEditSplitTunneling, setSplitTunnelingState], + ); + + return addApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-browsed-for-application.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-browsed-for-application.ts new file mode 100644 index 0000000000..6023b693c8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-browsed-for-application.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; + +import { useAppContext } from '../../../../../../context'; +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; +import { useAddApplication } from './use-add-application'; + +export function useAddBrowsedForApplication() { + const { getSplitTunnelingApplications } = useAppContext(); + const addApplication = useAddApplication(); + const { setApplications } = useSplitTunnelingSettingsContext(); + + const addBrowsedForApplication = useCallback( + async (application: string) => { + await addApplication(application); + const { applications } = await getSplitTunnelingApplications(); + setApplications(applications); + }, + [addApplication, getSplitTunnelingApplications, setApplications], + ); + + return addBrowsedForApplication; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-with-file-picker.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-with-file-picker.ts new file mode 100644 index 0000000000..3d09077adc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-with-file-picker.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../../shared/gettext'; +import { useFilePicker } from '../../../hooks'; +import { useSplitTunnelingContext } from '../../../SplitTunnelingContext'; +import { getFilePickerOptionsForPlatform } from '../utils'; +import { useAddBrowsedForApplication } from './use-add-browsed-for-application'; +import { useScrollToTop } from './use-scroll-to-top'; + +export function useAddWithFilePicker() { + const { setBrowsing } = useSplitTunnelingContext(); + const addBrowsedForApplication = useAddBrowsedForApplication(); + const scrollToTop = useScrollToTop(); + + const filePickerCallback = useFilePicker( + messages.pgettext('split-tunneling-view', 'Add'), + setBrowsing, + addBrowsedForApplication, + getFilePickerOptionsForPlatform(), + ); + + const addWithFilePicker = useCallback(async () => { + scrollToTop(); + await filePickerCallback(); + }, [filePickerCallback, scrollToTop]); + + return addWithFilePicker; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-can-edit-split-tunneling.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-can-edit-split-tunneling.ts new file mode 100644 index 0000000000..60fc022857 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-can-edit-split-tunneling.ts @@ -0,0 +1,11 @@ +import { useSelector } from '../../../../../../redux/store'; +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; + +export function useCanEditSplitTunneling() { + const { splitTunnelingAvailable } = useSplitTunnelingSettingsContext(); + const splitTunnelingEnabled = useSelector((state) => state.settings.splitTunneling); + + const canEditSplitTunneling = splitTunnelingEnabled && (splitTunnelingAvailable ?? false); + + return canEditSplitTunneling; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-fetch-need-full-disk-permissions.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-fetch-need-full-disk-permissions.ts new file mode 100644 index 0000000000..b83b585217 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-fetch-need-full-disk-permissions.ts @@ -0,0 +1,19 @@ +import { useCallback } from 'react'; + +import { useAppContext } from '../../../../../../context'; +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; + +export function useFetchNeedFullDiskPermissions() { + const { needFullDiskPermissions } = useAppContext(); + const { setLoadingDiskPermissions, setSplitTunnelingAvailable } = + useSplitTunnelingSettingsContext(); + + const fetchNeedFullDiskPermissions = useCallback(async () => { + setLoadingDiskPermissions(true); + const needPermissions = await needFullDiskPermissions(); + setSplitTunnelingAvailable(!needPermissions); + setLoadingDiskPermissions(false); + }, [needFullDiskPermissions, setLoadingDiskPermissions, setSplitTunnelingAvailable]); + + return fetchNeedFullDiskPermissions; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-non-split-applications.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-non-split-applications.ts new file mode 100644 index 0000000000..33e4ea906d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-non-split-applications.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import { useSelector } from '../../../../../../redux/store'; +import { includesSearchTerm } from '../../../utils'; +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; + +export function useFilteredNonSplitApplications() { + const { applications, searchTerm } = useSplitTunnelingSettingsContext(); + const splitTunnelingApplications = useSelector( + (state) => state.settings.splitTunnelingApplications, + ); + + const filteredNonSplitApplications = useMemo(() => { + if (!applications) { + return []; + } + + return applications.filter( + (application) => + includesSearchTerm(application, searchTerm) && + !splitTunnelingApplications.some( + (splitTunnelingApplication) => + application.absolutepath === splitTunnelingApplication.absolutepath, + ), + ); + }, [applications, splitTunnelingApplications, searchTerm]); + + return filteredNonSplitApplications; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-split-applications.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-split-applications.ts new file mode 100644 index 0000000000..7ae7f5e526 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-split-applications.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { useSelector } from '../../../../../../redux/store'; +import { includesSearchTerm } from '../../../utils'; +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; + +export function useFilteredSplitApplications() { + const { searchTerm } = useSplitTunnelingSettingsContext(); + const splitTunnelingApplications = useSelector( + (state) => state.settings.splitTunnelingApplications, + ); + + const filteredSplitApplications = useMemo( + () => + splitTunnelingApplications.filter((application) => + includesSearchTerm(application, searchTerm), + ), + [splitTunnelingApplications, searchTerm], + ); + + return filteredSplitApplications; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-non-split-applications.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-non-split-applications.ts new file mode 100644 index 0000000000..c82bd39cea --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-non-split-applications.ts @@ -0,0 +1,9 @@ +import { useFilteredNonSplitApplications } from './use-filtered-non-split-applications'; + +export function useHasNonSplitApplications() { + const filteredNonSplitApplications = useFilteredNonSplitApplications(); + + const hasNonSplitApplications = filteredNonSplitApplications.length > 0; + + return hasNonSplitApplications; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-split-applications.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-split-applications.ts new file mode 100644 index 0000000000..f978a09c28 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-split-applications.ts @@ -0,0 +1,9 @@ +import { useFilteredSplitApplications } from './use-filtered-split-applications'; + +export function useHasSplitApplications() { + const filteredSplitApplications = useFilteredSplitApplications(); + + const hasSplitApplications = filteredSplitApplications.length > 0; + + return hasSplitApplications; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-scroll-to-top.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-scroll-to-top.ts new file mode 100644 index 0000000000..05f6062fac --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-scroll-to-top.ts @@ -0,0 +1,11 @@ +import { useCallback } from 'react'; + +import { useSplitTunnelingContext } from '../../../SplitTunnelingContext'; + +export function useScrollToTop() { + const { scrollbarsRef } = useSplitTunnelingContext(); + + const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]); + + return scrollToTop; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-application-lists.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-application-lists.tsx new file mode 100644 index 0000000000..cb3f5a39c8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-application-lists.tsx @@ -0,0 +1,14 @@ +import { useCanEditSplitTunneling } from './use-can-edit-split-tunneling'; +import { useHasNonSplitApplications } from './use-has-non-split-applications'; +import { useHasSplitApplications } from './use-has-split-applications'; + +export function useShowApplicationLists() { + const canEditSplitTunneling = useCanEditSplitTunneling(); + const hasNonSplitApplications = useHasNonSplitApplications(); + const hasSplitApplications = useHasSplitApplications(); + + const showApplicationLists = + canEditSplitTunneling && (hasSplitApplications || hasNonSplitApplications); + + return showApplicationLists; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-no-search-result.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-no-search-result.ts new file mode 100644 index 0000000000..510dab34c3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-no-search-result.ts @@ -0,0 +1,16 @@ +import { useSplitTunnelingSettingsContext } from '../SplitTunnelingSettingsContext'; +import { useCanEditSplitTunneling } from './use-can-edit-split-tunneling'; +import { useHasNonSplitApplications } from './use-has-non-split-applications'; +import { useHasSplitApplications } from './use-has-split-applications'; + +export function useShowNoSearchResult() { + const { searchTerm } = useSplitTunnelingSettingsContext(); + const canEditSplitTunneling = useCanEditSplitTunneling(); + const hasNonSplitApplications = useHasNonSplitApplications(); + const hasSplitApplications = useHasSplitApplications(); + + const showNoSearchResult = + canEditSplitTunneling && searchTerm !== '' && !hasSplitApplications && !hasNonSplitApplications; + + return showNoSearchResult; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/index.ts new file mode 100644 index 0000000000..7e25bcd0d0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/index.ts @@ -0,0 +1 @@ +export * from './SplitTunnelingSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/utils.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/utils.ts new file mode 100644 index 0000000000..d1fac4c9ea --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/utils.ts @@ -0,0 +1,7 @@ +export function getFilePickerOptionsForPlatform(): + | { name: string; extensions: Array<string> } + | undefined { + return window.env.platform === 'win32' + ? { name: 'Executables', extensions: ['exe', 'lnk'] } + : undefined; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/index.ts new file mode 100644 index 0000000000..195be47c25 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-file-picker'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/use-file-picker.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/use-file-picker.ts new file mode 100644 index 0000000000..52fcab7a6e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/use-file-picker.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; + +import { useAppContext } from '../../../../context'; + +export function useFilePicker( + buttonLabel: string, + setOpen: (value: boolean) => void, + select: (path: string) => void, + filter?: { name: string; extensions: string[] }, +) { + const { showOpenDialog } = useAppContext(); + + const filePicker = 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]); + } + }, [setOpen, showOpenDialog, buttonLabel, filter, select]); + + return filePicker; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/index.ts new file mode 100644 index 0000000000..8e2cae17cb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/index.ts @@ -0,0 +1 @@ +export * from './SplitTunnelingView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/utils.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/utils.ts new file mode 100644 index 0000000000..dc78a31f90 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/utils.ts @@ -0,0 +1,13 @@ +import { type IApplication } from '../../../../shared/application-types'; + +export function includesSearchTerm(application: IApplication, searchTerm: string) { + return application.name.toLowerCase().includes(searchTerm.toLowerCase()); +} + +export interface DisabledApplicationProps { + $lookDisabled?: boolean; +} + +export const disabledApplication = (props: DisabledApplicationProps) => ({ + opacity: props.$lookDisabled ? 0.6 : undefined, +}); |
