summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-09-05 15:16:33 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-09-11 14:55:41 +0200
commit4ff6060f02996e23c1f86c4cf1c17e6e80652cfa (patch)
tree5c9faa79188de017963d8ed06387886b35f6c639
parente4a4795f26a6479fe511ff4a1b5e7e2347fe8c9c (diff)
downloadmullvadvpn-4ff6060f02996e23c1f86c4cf1c17e6e80652cfa.tar.xz
mullvadvpn-4ff6060f02996e23c1f86c4cf1c17e6e80652cfa.zip
Refactor Split tunneling settings
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettings.tsx714
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/SplitTunnelingSettingsStyles.tsx87
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingContext.tsx40
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/SplitTunnelingView.tsx58
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/ApplicationIcon.tsx28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-icon/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/ApplicationLabel.tsx24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-label/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/ApplicationList.tsx48
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-list/utils.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRow.tsx56
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowContext.tsx39
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/ApplicationRowTypes.ts8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/AddButton.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/hooks/use-add-application.ts13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/add-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/DeleteButton.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/hooks/use-delete-application.ts13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/delete-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/RemoveButton.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/hooks/use-remove-application.ts13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/components/remove-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-application.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-add-button.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-delete-button.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/hooks/use-show-remove-button.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/ApplicationSearchBar.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-bar/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/ApplicationSearchNoResult.tsx36
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/application-search-no-result/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/index.ts6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettings.tsx68
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/LinuxSettingsContext.tsx46
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/LaunchErrorDialog.tsx36
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-has-browse-error.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/hooks/use-hide-browse-failure-dialog.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/launch-error-dialog/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/LinuxApplicationList.tsx21
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRow.tsx36
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/LinuxApplicationRowContext.tsx44
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/index.ts3
-rw-r--r--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.tsx27
-rw-r--r--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.ts1
-rw-r--r--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.tsx28
-rw-r--r--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.tsx15
-rw-r--r--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.ts1
-rw-r--r--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.ts2
-rw-r--r--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.tsx18
-rw-r--r--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.ts1
-rw-r--r--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.ts2
-rw-r--r--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.ts13
-rw-r--r--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.ts33
-rw-r--r--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.ts1
-rw-r--r--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.tsx16
-rw-r--r--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.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/index.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-application.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/hooks/use-disabled.ts9
-rw-r--r--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.ts9
-rw-r--r--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.ts20
-rw-r--r--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.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/OpenFilePickerButton.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/hooks/use-launch-with-file-picker.ts17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/open-file-picker-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-filtered-applications.ts15
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-launch-application.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-linux-application-list.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/hooks/use-show-no-search-result.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettings.tsx82
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/SplitTunnelingSettingsContext.tsx68
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/AddApplicationFilePickerButton.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/add-application-file-picker-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/ApplicationLists.tsx20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/NonSplitApplicationSection.tsx39
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/hooks/index.ts1
-rw-r--r--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.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/non-split-application-section/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/SplitApplicationSection.tsx35
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/hooks/index.ts1
-rw-r--r--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.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/components/split-application-section/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/application-lists/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/SplitTunnelingSettingsHeader.tsx30
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/components/index.ts2
-rw-r--r--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.tsx41
-rw-r--r--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.ts1
-rw-r--r--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.ts11
-rw-r--r--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.ts1
-rw-r--r--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.tsx14
-rw-r--r--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.ts1
-rw-r--r--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.ts11
-rw-r--r--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.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/hooks/use-show-header-subtitle.ts9
-rw-r--r--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.ts15
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/components/split-tunneling-settings-header/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/index.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-application.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-browsed-for-application.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-add-with-file-picker.ts28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-can-edit-split-tunneling.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-fetch-need-full-disk-permissions.ts19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-non-split-applications.ts29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-filtered-split-applications.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-non-split-applications.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-has-split-applications.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-scroll-to-top.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-application-lists.tsx14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/hooks/use-show-no-search-result.ts16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/split-tunneling-settings/utils.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/hooks/use-file-picker.ts28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/utils.ts13
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,
+});