diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ar-SA/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ar-SA/Resources.resw
index 0c917a619..06db9c3cd 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ar-SA/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ar-SA/Resources.resw
@@ -3999,6 +3999,15 @@ VPN
استبعاد
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ المجلدات المختارة ({0})
+
+
+ إزالة المجلد
+
+
+ لم يتم العثور على المجلد
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/be-BY/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/be-BY/Resources.resw
index 9419eba50..8021899c8 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/be-BY/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/be-BY/Resources.resw
@@ -3783,6 +3783,15 @@ Secure Core
Выключае
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Выбраныя папкі ({0})
+
+
+ Выдаліць папку
+
+
+ Папка не знойдзеna
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ca-ES/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ca-ES/Resources.resw
index 152b5231b..32e48ebfe 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ca-ES/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/ca-ES/Resources.resw
@@ -3568,6 +3568,15 @@ Una càrrega elevada del servidor pot alentir la vostra connexió.
Exclou
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Carpetes seleccionades ({0})
+
+
+ Eliminar carpeta
+
+
+ Carpeta no trobada
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/cs-CZ/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/cs-CZ/Resources.resw
index bce758a69..b6d065c5c 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/cs-CZ/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/cs-CZ/Resources.resw
@@ -3783,6 +3783,15 @@ Vysoké vytížení serveru může zpomalit vaše připojení.
Vyloučit
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Vybrané složky ({0})
+
+
+ Odebrat složku
+
+
+ Složka nenalezena
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/da-DK/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/da-DK/Resources.resw
index 65b619505..9661592aa 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/da-DK/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/da-DK/Resources.resw
@@ -3567,6 +3567,15 @@ En høj serverbelastning kan gøre din forbindelse langsommere.
Ekskluder
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Valgte mapper ({0})
+
+
+ Fjern mappe
+
+
+ Mappen blev ikke fundet
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/de-DE/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/de-DE/Resources.resw
index 9bf2acd5e..1a8116dad 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/de-DE/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/de-DE/Resources.resw
@@ -3567,6 +3567,15 @@ Eine hohe Serverauslastung kann deine Verbindung verlangsamen.
Ausschließen
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Ausgewählte Ordner ({0})
+
+
+ Ordner entfernen
+
+
+ Ordner nicht gefunden
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/el-GR/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/el-GR/Resources.resw
index 53d1a9e97..fbf45e376 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/el-GR/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/el-GR/Resources.resw
@@ -3567,6 +3567,15 @@
Εξαίρεση
Shorter version (if possible) of "Exclude mode". To be used when available space is limited.
+
+ Επιλεγμένοι φάκελοι ({0})
+
+
+ Κατάργηση φακέλου
+
+
+ Δεν βρέθηκε ο φάκελος
+
VPN Accelerator
diff --git a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/en-US/Resources.resw b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/en-US/Resources.resw
index bbabbe238..04ba3d783 100644
--- a/src/Client/Localization/ProtonVPN.Client.Localization/Strings/en-US/Resources.resw
+++ b/src/Client/Localization/ProtonVPN.Client.Localization/Strings/en-US/Resources.resw
@@ -1,4 +1,4 @@
-
+
+along with ProtonVPN. If not, see .
+-->
.-->
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:pathicons="using:ProtonVPN.Client.Common.UI.Assets.Icons.PathIcons"
xmlns:custom="using:ProtonVPN.Client.Common.UI.Controls.Custom"
+ xmlns:connection="using:ProtonVPN.Client.UI.Main.Settings.Connection"
xmlns:models="using:ProtonVPN.Client.Core.Models"
xmlns:featureicons="using:ProtonVPN.Client.UI.Main.FeatureIcons"
xmlns:networking="using:ProtonVPN.Common.Core.Networking"
@@ -138,9 +140,10 @@ along with ProtonVPN. If not, see .-->
IsActive="{x:Bind ViewModel.IsLoading}"
Visibility="{x:Bind ViewModel.IsLoading, Converter={StaticResource BooleanToVisibilityConverter}}" />
-
+
@@ -151,7 +154,6 @@ along with ProtonVPN. If not, see .-->
Style="{StaticResource DefaultSettingsCardStyle}">
-
.-->
-
@@ -200,9 +201,152 @@ along with ProtonVPN. If not, see .-->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -235,7 +379,6 @@ along with ProtonVPN. If not, see .-->
Style="{StaticResource DefaultSettingsCardStyle}">
-
.-->
-
@@ -273,4 +415,4 @@ along with ProtonVPN. If not, see .-->
-
\ No newline at end of file
+
diff --git a/src/Client/ProtonVPN.Client/UI/Main/Settings/Pages/Connection/SplitTunnelingPageViewModel.cs b/src/Client/ProtonVPN.Client/UI/Main/Settings/Pages/Connection/SplitTunnelingPageViewModel.cs
index 9def84f11..270b3165c 100644
--- a/src/Client/ProtonVPN.Client/UI/Main/Settings/Pages/Connection/SplitTunnelingPageViewModel.cs
+++ b/src/Client/ProtonVPN.Client/UI/Main/Settings/Pages/Connection/SplitTunnelingPageViewModel.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -17,6 +17,7 @@
* along with ProtonVPN. If not, see .
*/
+using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -42,9 +43,12 @@ namespace ProtonVPN.Client.UI.Main.Settings.Connection;
public partial class SplitTunnelingPageViewModel : SettingsPageViewModelBase
{
+ private const int MAX_FOLDERS = 50;
+
private readonly IUrlsBrowser _urlsBrowser;
private readonly IIpSelector _ipSelector;
private readonly IAppSelector _appSelector;
+ private readonly IMainWindowActivator _mainWindowActivator;
private bool _wasIpv6WarningDisplayed;
@@ -61,6 +65,9 @@ public partial class SplitTunnelingPageViewModel : SettingsPageViewModelBase
[property: SettingName(nameof(ISettings.SplitTunnelingMode))]
[NotifyPropertyChangedFor(nameof(IsStandardSplitTunneling))]
[NotifyPropertyChangedFor(nameof(IsInverseSplitTunneling))]
+ [NotifyPropertyChangedFor(nameof(HasStandardFolders))]
+ [NotifyPropertyChangedFor(nameof(HasInverseFolders))]
+ [NotifyPropertyChangedFor(nameof(ActiveFoldersCount))]
[NotifyPropertyChangedFor(nameof(SplitTunnelingFeatureIconSource))]
[NotifyPropertyChangedFor(nameof(IpAddresses))]
[NotifyPropertyChangedFor(nameof(IpAddressesHeader))]
@@ -94,12 +101,29 @@ public bool IsInverseSplitTunneling
set => SetSplitTunnelingMode(value, SplitTunnelingMode.Inverse);
}
- [property: SettingName(nameof(ISettings.SplitTunnelingInverseIpAddressesList))]
- public SmartObservableCollection IncludedIpAddresses { get; } = [];
+ // Folders
+ [property: SettingName(nameof(ISettings.SplitTunnelingStandardFoldersList))]
+ public ObservableCollection StandardFolders { get; }
+
+ [property: SettingName(nameof(ISettings.SplitTunnelingInverseFoldersList))]
+ public ObservableCollection InverseFolders { get; }
+ public bool HasStandardFolders => CurrentSplitTunnelingMode == SplitTunnelingMode.Standard && StandardFolders.Any();
+ public bool HasInverseFolders => CurrentSplitTunnelingMode == SplitTunnelingMode.Inverse && InverseFolders.Any();
+
+ public int ActiveFoldersCount => CurrentSplitTunnelingMode == SplitTunnelingMode.Standard
+ ? StandardFolders.Count(a => a.IsActive)
+ : InverseFolders.Count(a => a.IsActive);
+
+ public bool CanAddFolder => StandardFolders.Count + InverseFolders.Count < MAX_FOLDERS;
+
+ // IP Addresses
[property: SettingName(nameof(ISettings.SplitTunnelingStandardIpAddressesList))]
public SmartObservableCollection ExcludedIpAddresses { get; } = [];
+ [property: SettingName(nameof(ISettings.SplitTunnelingInverseIpAddressesList))]
+ public SmartObservableCollection IncludedIpAddresses { get; } = [];
+
public SmartObservableCollection IpAddresses
=> IsStandardSplitTunneling ? ExcludedIpAddresses : IncludedIpAddresses;
@@ -118,12 +142,13 @@ public bool HasIpv6AddressesWhileIpv6Disabled
? "Settings_Connection_SplitTunneling_IpAddresses_Excluded_FormattedHeader"
: "Settings_Connection_SplitTunneling_IpAddresses_Included_FormattedHeader", SelectedIpAddresses.Count());
- [property: SettingName(nameof(ISettings.SplitTunnelingInverseAppsList))]
- public SmartObservableCollection IncludedApps { get; } = [];
-
+ // Apps
[property: SettingName(nameof(ISettings.SplitTunnelingStandardAppsList))]
public SmartObservableCollection ExcludedApps { get; } = [];
+ [property: SettingName(nameof(ISettings.SplitTunnelingInverseAppsList))]
+ public SmartObservableCollection IncludedApps { get; } = [];
+
public SmartObservableCollection Apps
=> IsStandardSplitTunneling ? ExcludedApps : IncludedApps;
@@ -138,6 +163,7 @@ public IEnumerable SelectedApps
public SplitTunnelingPageViewModel(
IUrlsBrowser urlsBrowser,
+ IMainWindowActivator mainWindowActivator,
IRequiredReconnectionSettings requiredReconnectionSettings,
IMainViewNavigator mainViewNavigator,
ISettingsViewNavigator settingsViewNavigator,
@@ -158,9 +184,16 @@ public SplitTunnelingPageViewModel(
viewModelHelper)
{
_urlsBrowser = urlsBrowser;
+ _mainWindowActivator = mainWindowActivator;
_ipSelector = ipSelector;
_appSelector = appSelector;
+ StandardFolders = new();
+ StandardFolders.CollectionChanged += OnFoldersCollectionChanged;
+
+ InverseFolders = new();
+ InverseFolders.CollectionChanged += OnFoldersCollectionChanged;
+
ExcludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
IncludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
ExcludedApps.CollectionChanged += OnAppsCollectionChanged;
@@ -169,8 +202,10 @@ public SplitTunnelingPageViewModel(
PageSettings =
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardAppsList, () => GetSettingsApps(ExcludedApps)),
+ ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardFoldersList, () => GetSplitTunnelingFoldersList(StandardFolders)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardIpAddressesList, () => GetSettingsIpAddresses(ExcludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseAppsList, () => GetSettingsApps(IncludedApps)),
+ ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseFoldersList, () => GetSplitTunnelingFoldersList(InverseFolders)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseIpAddressesList, () => GetSettingsIpAddresses(IncludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingMode, () => CurrentSplitTunnelingMode),
ChangedSettingArgs.Create(() => Settings.IsSplitTunnelingEnabled, () => IsSplitTunnelingEnabled),
@@ -205,6 +240,40 @@ public async Task TriggerIpv6DisabledWarningAsync()
_wasIpv6WarningDisplayed = true;
}
+ [RelayCommand]
+ public async Task AddFolderAsync()
+ {
+ if (_mainWindowActivator.Window == null)
+ {
+ return;
+ }
+
+ if (!CanAddFolder)
+ {
+ return;
+ }
+
+ ObservableCollection folders = GetFolders();
+ string? folderPath = await _mainWindowActivator.Window.PickSingleFolderAsync();
+
+ if (string.IsNullOrEmpty(folderPath) || !Directory.Exists(folderPath))
+ {
+ return;
+ }
+
+ SplitTunnelingFolderViewModel? existing = folders.FirstOrDefault(f =>
+ string.Equals(f.FolderPath, folderPath, StringComparison.OrdinalIgnoreCase));
+
+ if (existing != null)
+ {
+ existing.IsActive = true;
+ }
+ else
+ {
+ folders.Add(new SplitTunnelingFolderViewModel(ViewModelHelper, this, folderPath, true));
+ }
+ }
+
[RelayCommand]
public async Task SelectIpsAsync()
{
@@ -247,6 +316,16 @@ public async Task SelectAppsAsync()
}
}
+ public void RemoveFolder(SplitTunnelingFolderViewModel folder)
+ {
+ GetFolders().Remove(folder);
+ }
+
+ public void InvalidateFoldersCount()
+ {
+ OnPropertyChanged(nameof(ActiveFoldersCount));
+ }
+
protected override async Task OnRetrieveSettingsAsync()
{
try
@@ -257,6 +336,9 @@ protected override async Task OnRetrieveSettingsAsync()
IsSplitTunnelingEnabled = Settings.IsSplitTunnelingEnabled;
CurrentSplitTunnelingMode = Settings.SplitTunnelingMode;
+ SetFolders(StandardFolders, Settings.SplitTunnelingStandardFoldersList);
+ SetFolders(InverseFolders, Settings.SplitTunnelingInverseFoldersList);
+
ExcludedIpAddresses.Reset(GetObservableIpAddresses(Settings.SplitTunnelingStandardIpAddressesList));
IncludedIpAddresses.Reset(GetObservableIpAddresses(Settings.SplitTunnelingInverseIpAddressesList));
@@ -274,24 +356,29 @@ protected override bool IsReconnectionRequiredDueToChanges(IEnumerable Settings.SplitTunnelingStandardAppsList.Any(app => app.IsActive) || Settings.SplitTunnelingStandardIpAddressesList.Any(ip => ip.IsActive),
- SplitTunnelingMode.Inverse => Settings.SplitTunnelingInverseAppsList.Any(app => app.IsActive) || Settings.SplitTunnelingInverseIpAddressesList.Any(ip => ip.IsActive),
+ SplitTunnelingMode.Standard => Settings.SplitTunnelingStandardAppsList.Any(app => app.IsActive)
+ || Settings.SplitTunnelingStandardFoldersList.Any(f => f.IsActive)
+ || Settings.SplitTunnelingStandardIpAddressesList.Any(ip => ip.IsActive),
+ SplitTunnelingMode.Inverse => Settings.SplitTunnelingInverseAppsList.Any(app => app.IsActive)
+ || Settings.SplitTunnelingInverseFoldersList.Any(f => f.IsActive)
+ || Settings.SplitTunnelingInverseIpAddressesList.Any(ip => ip.IsActive),
_ => false
};
bool hasAnyActiveAppsOrIps =
IsSplitTunnelingEnabled &&
CurrentSplitTunnelingMode switch
{
- SplitTunnelingMode.Standard => ExcludedApps.Any(app => app.IsSelected) || ExcludedIpAddresses.Any(ip => ip.IsSelected),
- SplitTunnelingMode.Inverse => IncludedApps.Any(app => app.IsSelected) || IncludedIpAddresses.Any(ip => ip.IsSelected),
+ SplitTunnelingMode.Standard => ExcludedApps.Any(app => app.IsSelected)
+ || StandardFolders.Any(f => f.IsActive)
+ || ExcludedIpAddresses.Any(ip => ip.IsSelected),
+ SplitTunnelingMode.Inverse => IncludedApps.Any(app => app.IsSelected)
+ || InverseFolders.Any(f => f.IsActive)
+ || IncludedIpAddresses.Any(ip => ip.IsSelected),
_ => false
};
if (isSameSplitTunnelingMode && !hadAnyActiveAppsOrIps && !hasAnyActiveAppsOrIps)
@@ -303,6 +390,25 @@ protected override bool IsReconnectionRequiredDueToChanges(IEnumerable GetFolders()
+ {
+ return CurrentSplitTunnelingMode == SplitTunnelingMode.Standard ? StandardFolders : InverseFolders;
+ }
+
+ private void SetFolders(ObservableCollection folders, List settingsFolders)
+ {
+ folders.Clear();
+ foreach (SplitTunnelingFolder folder in settingsFolders)
+ {
+ folders.Add(new SplitTunnelingFolderViewModel(ViewModelHelper, this, folder.FolderPath, folder.IsActive));
+ }
+ }
+
+ private List GetSplitTunnelingFoldersList(ObservableCollection folders)
+ {
+ return folders.Select(f => new SplitTunnelingFolder(f.FolderPath, f.IsActive)).ToList();
+ }
+
private List GetSettingsIpAddresses(IEnumerable ipAddresses)
{
return ipAddresses.Select(ip => new SplitTunnelingIpAddress(ip.Value.ToString(), ip.IsSelected)).ToList();
@@ -311,7 +417,6 @@ private List GetSettingsIpAddresses(IEnumerable GetObservableIpAddresses(List settingsIpAddresses)
{
List addresses = [];
-
foreach (SplitTunnelingIpAddress ip in settingsIpAddresses)
{
if (NetworkAddress.TryParse(ip.IpAddress, out NetworkAddress address))
@@ -319,7 +424,6 @@ private List GetObservableIpAddresses(List GetSettingsApps(IEnumerable> GetObservableAppsAsync(List settingsApps)
{
List apps = [];
-
foreach (SplitTunnelingApp app in settingsApps)
{
TunnelingApp tunnelingApp = await TunnelingApp.TryCreateAsync(app.AppFilePath, app.AlternateAppFilePaths)
?? TunnelingApp.NotFound(app.AppFilePath, Localizer.Get("Common_Message_AppNotFound"), app.AlternateAppFilePaths);
-
apps.Add(new SelectableTunnelingApp(tunnelingApp, app.IsActive));
}
-
return apps;
}
@@ -364,6 +465,14 @@ private void OnAppsCollectionChanged(object? sender, NotifyCollectionChangedEven
OnPropertyChanged(nameof(HasSelectedApps));
}
+ private void OnFoldersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ OnPropertyChanged(nameof(HasStandardFolders));
+ OnPropertyChanged(nameof(HasInverseFolders));
+ OnPropertyChanged(nameof(CanAddFolder));
+ InvalidateFoldersCount();
+ }
+
private void OnIpAddressesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(IpAddresses));
@@ -372,4 +481,4 @@ private void OnIpAddressesCollectionChanged(object? sender, NotifyCollectionChan
OnPropertyChanged(nameof(HasSelectedIpAddresses));
OnPropertyChanged(nameof(HasIpv6AddressesWhileIpv6Disabled));
}
-}
\ No newline at end of file
+}
diff --git a/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/DefaultSettings.cs b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/DefaultSettings.cs
index bd2865d94..ad20f3f94 100644
--- a/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/DefaultSettings.cs
+++ b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/DefaultSettings.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -66,6 +66,7 @@ public static class DefaultSettings
public static bool IsSmartReconnectEnabled = true;
public static SplitTunnelingMode SplitTunnelingMode = SplitTunnelingMode.Standard;
public static List SplitTunnelingIpAddressesList = [];
+ public static List SplitTunnelingFoldersList = [];
public static List Ipv6Fragments = [];
public static bool IsIpv6LeakProtectionEnabled = true;
public static bool IsIpv6Enabled = false;
diff --git a/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/IUserSettings.cs b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/IUserSettings.cs
index 606632165..57097e166 100644
--- a/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/IUserSettings.cs
+++ b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/IUserSettings.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -71,6 +71,8 @@ public interface IUserSettings
List SplitTunnelingInverseAppsList { get; set; }
List SplitTunnelingStandardIpAddressesList { get; set; }
List SplitTunnelingInverseIpAddressesList { get; set; }
+ List SplitTunnelingStandardFoldersList { get; set; }
+ List SplitTunnelingInverseFoldersList { get; set; }
List Ipv6Fragments { get; set; }
string? LastLogicalsStatusId { get; set; }
ChangeServerAttempts ChangeServerAttempts { get; set; }
diff --git a/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/Models/SplitTunnelingFolder.cs b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/Models/SplitTunnelingFolder.cs
new file mode 100644
index 000000000..e84c84c3a
--- /dev/null
+++ b/src/Client/Settings/ProtonVPN.Client.Settings.Contracts/Models/SplitTunnelingFolder.cs
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2026 Proton AG
+ *
+ * This file is part of ProtonVPN.
+ *
+ * ProtonVPN is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonVPN is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonVPN. If not, see .
+ */
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace ProtonVPN.Client.Settings.Contracts.Models;
+
+public struct SplitTunnelingFolder : IEquatable
+{
+ public string FolderPath { get; set; }
+
+ public bool IsActive { get; set; }
+
+ public SplitTunnelingFolder(string folderPath, bool isActive)
+ {
+ FolderPath = folderPath;
+ IsActive = isActive;
+ }
+
+ public bool Equals(SplitTunnelingFolder other)
+ {
+ return string.Equals(FolderPath, other.FolderPath, StringComparison.OrdinalIgnoreCase)
+ && IsActive == other.IsActive;
+ }
+
+ public override bool Equals([NotNullWhen(true)] object? obj)
+ {
+ if (obj?.GetType() != GetType())
+ {
+ return false;
+ }
+ return Equals((SplitTunnelingFolder)obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(FolderPath?.ToUpperInvariant(), IsActive);
+ }
+}
diff --git a/src/Client/Settings/ProtonVPN.Client.Settings/UserSettings.cs b/src/Client/Settings/ProtonVPN.Client.Settings/UserSettings.cs
index 03389dd39..1228feaf5 100644
--- a/src/Client/Settings/ProtonVPN.Client.Settings/UserSettings.cs
+++ b/src/Client/Settings/ProtonVPN.Client.Settings/UserSettings.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -359,6 +359,18 @@ public List SplitTunnelingInverseIpAddressesList
set => _userCache.SetListValueType(value, SettingEncryption.Unencrypted);
}
+ public List SplitTunnelingStandardFoldersList
+ {
+ get => _userCache.GetListValueType(SettingEncryption.Unencrypted) ?? DefaultSettings.SplitTunnelingFoldersList;
+ set => _userCache.SetListValueType(value, SettingEncryption.Unencrypted);
+ }
+
+ public List SplitTunnelingInverseFoldersList
+ {
+ get => _userCache.GetListValueType(SettingEncryption.Unencrypted) ?? DefaultSettings.SplitTunnelingFoldersList;
+ set => _userCache.SetListValueType(value, SettingEncryption.Unencrypted);
+ }
+
public List Ipv6Fragments
{
get => _userCache.GetListReferenceType(SettingEncryption.Unencrypted) ?? DefaultSettings.Ipv6Fragments;
diff --git a/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Controllers/IVpnController.cs b/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Controllers/IVpnController.cs
index 0ce390a2f..0d327ce7a 100644
--- a/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Controllers/IVpnController.cs
+++ b/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Controllers/IVpnController.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2026 Proton AG
*
* This file is part of ProtonVPN.
@@ -38,4 +38,7 @@ public interface IVpnController : IServiceController
Task RequestNetShieldStats(CancellationToken cancelToken);
Task RequestConnectionDetails(CancellationToken cancelToken);
+
+ Task AddAppPathsDynamically(DynamicAppPathsIpcEntity appPathsEntity, CancellationToken cancelToken);
+ Task RemoveAppPathsDynamically(DynamicAppPathsIpcEntity appPathsEntity, CancellationToken cancelToken);
}
\ No newline at end of file
diff --git a/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Entities/Settings/DynamicAppPathsIpcEntity.cs b/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Entities/Settings/DynamicAppPathsIpcEntity.cs
new file mode 100644
index 000000000..b1fb1ec67
--- /dev/null
+++ b/src/ProcessCommunication/ProtonVPN.ProcessCommunication.Contracts/Entities/Settings/DynamicAppPathsIpcEntity.cs
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Proton AG
+ *
+ * This file is part of ProtonVPN.
+ *
+ * ProtonVPN is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ProtonVPN is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with ProtonVPN. If not, see .
+ */
+
+using System.Runtime.Serialization;
+
+namespace ProtonVPN.ProcessCommunication.Contracts.Entities.Settings;
+
+[DataContract]
+public class DynamicAppPathsIpcEntity
+{
+ [DataMember]
+ public string[] AppPaths { get; set; } = [];
+}
diff --git a/src/ProtonVPN.Service/SplitTunneling/ISplitTunnelClient.cs b/src/ProtonVPN.Service/SplitTunneling/ISplitTunnelClient.cs
index 4ca69baa8..e6512abf5 100644
--- a/src/ProtonVPN.Service/SplitTunneling/ISplitTunnelClient.cs
+++ b/src/ProtonVPN.Service/SplitTunneling/ISplitTunnelClient.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -28,4 +28,8 @@ public interface ISplitTunnelClient
void EnableIncludeMode(string[] appPaths, IPAddress serverIpv4Address, IPAddress serverIpv6Address);
void Disable();
+
+ void AddAppPathsDynamically(string[] appPaths);
+
+ void RemoveAppPathsDynamically(string[] appPaths);
}
\ No newline at end of file
diff --git a/src/ProtonVPN.Service/SplitTunneling/SplitTunnelClient.cs b/src/ProtonVPN.Service/SplitTunneling/SplitTunnelClient.cs
index b2e12aabd..2d68d408c 100644
--- a/src/ProtonVPN.Service/SplitTunneling/SplitTunnelClient.cs
+++ b/src/ProtonVPN.Service/SplitTunneling/SplitTunnelClient.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright (c) 2025 Proton AG
*
* This file is part of ProtonVPN.
@@ -68,6 +68,30 @@ public void Disable()
EnsureSucceeded(_filters.Disable, "SplitTunnel: Disabling");
}
+ public void AddAppPathsDynamically(string[] appPaths)
+ {
+ if (appPaths == null || appPaths.Length == 0)
+ {
+ return;
+ }
+
+ EnsureSucceeded(
+ () => _filters.AddAppPathsDynamically(appPaths),
+ "SplitTunnel: Adding app paths dynamically");
+ }
+
+ public void RemoveAppPathsDynamically(string[] appPaths)
+ {
+ if (appPaths == null || appPaths.Length == 0)
+ {
+ return;
+ }
+
+ EnsureSucceeded(
+ () => _filters.RemoveAppPathsDynamically(appPaths),
+ "SplitTunnel: Removing app paths dynamically");
+ }
+
private void EnsureSucceeded(System.Action action, string actionMessage)
{
try
diff --git a/src/ProtonVPN.Service/SplitTunneling/SplitTunnelNetworkFilters.cs b/src/ProtonVPN.Service/SplitTunneling/SplitTunnelNetworkFilters.cs
index 496ff99e6..b5cdfedae 100644
--- a/src/ProtonVPN.Service/SplitTunneling/SplitTunnelNetworkFilters.cs
+++ b/src/ProtonVPN.Service/SplitTunneling/SplitTunnelNetworkFilters.cs
@@ -35,6 +35,15 @@ public class SplitTunnelNetworkFilters
private IpFilter _ipFilter;
private Sublayer _subLayer;
+ private bool _isActive;
+ private Callout _connectRedirectCalloutV4;
+ private Callout _bindRedirectCalloutV4;
+ private ProviderContext _providerContextV4;
+ private Callout _connectRedirectCalloutV6;
+ private Callout _bindRedirectCalloutV6;
+ private ProviderContext _providerContextV6;
+ private readonly System.Collections.Generic.Dictionary> _appFilterIds = new(StringComparer.OrdinalIgnoreCase);
+
public void EnableExcludeMode(string[] apps, IPAddress localIpv4Address, IPAddress localIpv6Address)
{
Create();
@@ -71,23 +80,75 @@ public void EnableIncludeMode(string[] apps, IPAddress serverIpv4Address, IPAddr
}
}
+ public void AddAppPathsDynamically(string[] apps)
+ {
+ if (!_isActive || _ipFilter == null || _subLayer == null) return;
+
+ _ipFilter.Session.StartTransaction();
+ try
+ {
+ CreateAppCalloutFilters(apps, _bindRedirectCalloutV4, Layer.BindRedirectV4, _providerContextV4);
+ CreateAppCalloutFilters(apps, _connectRedirectCalloutV4, Layer.AppConnectRedirectV4, _providerContextV4);
+
+ if (_bindRedirectCalloutV6 != null && _connectRedirectCalloutV6 != null && _providerContextV6 != null)
+ {
+ CreateAppCalloutFilters(apps, _connectRedirectCalloutV6, Layer.AppConnectRedirectV6, _providerContextV6);
+ CreateAppCalloutFilters(apps, _bindRedirectCalloutV6, Layer.BindRedirectV6, _providerContextV6);
+ }
+
+ _ipFilter.Session.CommitTransaction();
+ }
+ catch
+ {
+ _ipFilter.Session.AbortTransaction();
+ throw;
+ }
+ }
+
+ public void RemoveAppPathsDynamically(string[] apps)
+ {
+ if (!_isActive || _ipFilter == null || _subLayer == null) return;
+
+ _ipFilter.Session.StartTransaction();
+ try
+ {
+ foreach (string app in apps)
+ {
+ if (_appFilterIds.TryGetValue(app, out System.Collections.Generic.List filterIds))
+ {
+ foreach (Guid filterId in filterIds)
+ {
+ _subLayer.DestroyFilter(filterId);
+ }
+ _appFilterIds.Remove(app);
+ }
+ }
+ _ipFilter.Session.CommitTransaction();
+ }
+ catch
+ {
+ _ipFilter.Session.AbortTransaction();
+ throw;
+ }
+ }
+
private void Redirect(string[] apps, IPAddress ipv4Address, IPAddress ipv6Address)
{
- Callout connectRedirectCalloutV4 = CreateConnectRedirectCallout(Layer.AppConnectRedirectV4, _connectRedirectV4CalloutKey);
- Callout bindRedirectCalloutV4 = CreateUDPRedirectCallout(Layer.BindRedirectV4, _bindRedirectV4CalloutKey);
+ _connectRedirectCalloutV4 = CreateConnectRedirectCallout(Layer.AppConnectRedirectV4, _connectRedirectV4CalloutKey);
+ _bindRedirectCalloutV4 = CreateUDPRedirectCallout(Layer.BindRedirectV4, _bindRedirectV4CalloutKey);
- ProviderContext providerContextV4 = GetProviderContext(ipv4Address);
- CreateAppCalloutFilters(apps, bindRedirectCalloutV4, Layer.BindRedirectV4, providerContextV4);
- CreateAppCalloutFilters(apps, connectRedirectCalloutV4, Layer.AppConnectRedirectV4, providerContextV4);
+ _providerContextV4 = GetProviderContext(ipv4Address);
+ CreateAppCalloutFilters(apps, _bindRedirectCalloutV4, Layer.BindRedirectV4, _providerContextV4);
+ CreateAppCalloutFilters(apps, _connectRedirectCalloutV4, Layer.AppConnectRedirectV4, _providerContextV4);
if (ipv6Address is not null)
{
- ProviderContext providerContextV6 = GetProviderContext(ipv6Address);
- Callout connectRedirectCalloutV6 = CreateConnectRedirectCallout(Layer.AppConnectRedirectV6, _connectRedirectV6CalloutKey);
- Callout redirectUDPCalloutV6 = CreateUDPRedirectCallout(Layer.BindRedirectV6, _bindRedirectV6CalloutKey);
+ _providerContextV6 = GetProviderContext(ipv6Address);
+ _connectRedirectCalloutV6 = CreateConnectRedirectCallout(Layer.AppConnectRedirectV6, _connectRedirectV6CalloutKey);
+ _bindRedirectCalloutV6 = CreateUDPRedirectCallout(Layer.BindRedirectV6, _bindRedirectV6CalloutKey);
- CreateAppCalloutFilters(apps, connectRedirectCalloutV6, Layer.AppConnectRedirectV6, providerContextV6);
- CreateAppCalloutFilters(apps, redirectUDPCalloutV6, Layer.BindRedirectV6, providerContextV6);
+ CreateAppCalloutFilters(apps, _connectRedirectCalloutV6, Layer.AppConnectRedirectV6, _providerContextV6);
+ CreateAppCalloutFilters(apps, _bindRedirectCalloutV6, Layer.BindRedirectV6, _providerContextV6);
}
}
@@ -109,6 +170,9 @@ public void Disable()
private void Create()
{
+ _appFilterIds.Clear();
+ _isActive = true;
+
_ipFilter = IpFilter.Create(
Session.Dynamic(),
new DisplayData { Name = "Proton AG", Description = "ProtonVPN Split Tunnel provider" });
@@ -123,6 +187,15 @@ private void Remove()
_ipFilter?.Session.Close();
_ipFilter = null;
_subLayer = null;
+
+ _isActive = false;
+ _appFilterIds.Clear();
+ _connectRedirectCalloutV4 = null;
+ _bindRedirectCalloutV4 = null;
+ _providerContextV4 = null;
+ _connectRedirectCalloutV6 = null;
+ _bindRedirectCalloutV6 = null;
+ _providerContextV6 = null;
}
private void CreateAppCalloutFilters(string[] apps, Callout callout, Layer layer, ProviderContext providerContext)
@@ -137,16 +210,24 @@ private void SafeCreateAppFilter(string app, Callout callout, Layer layer, Provi
{
try
{
- CreateAppFilter(app, callout, layer, providerContext);
+ Guid filterId = CreateAppFilter(app, callout, layer, providerContext);
+
+ if (!_appFilterIds.TryGetValue(app, out System.Collections.Generic.List ids))
+ {
+ ids = new System.Collections.Generic.List();
+ _appFilterIds[app] = ids;
+ }
+
+ ids.Add(filterId);
}
catch (NetworkFilterException)
{
}
}
- private void CreateAppFilter(string app, Callout callout, Layer layer, ProviderContext providerContext)
+ private Guid CreateAppFilter(string app, Callout callout, Layer layer, ProviderContext providerContext)
{
- _subLayer.CreateAppCalloutFilter(
+ return _subLayer.CreateAppCalloutFilter(
new DisplayData
{
Name = "ProtonVPN Split Tunnel redirect app",
diff --git a/src/ProtonVPN.Service/VpnController.cs b/src/ProtonVPN.Service/VpnController.cs
index 2ba7badd8..0137cc897 100644
--- a/src/ProtonVPN.Service/VpnController.cs
+++ b/src/ProtonVPN.Service/VpnController.cs
@@ -35,6 +35,7 @@
using ProtonVPN.Service.ControllerRetries;
using ProtonVPN.Service.ProcessCommunication;
using ProtonVPN.Service.Settings;
+using ProtonVPN.Service.SplitTunneling;
using ProtonVPN.Vpn.Common;
using ProtonVPN.Vpn.LocalAgent;
using ProtonVPN.Vpn.PortMapping;
@@ -52,6 +53,7 @@ public class VpnController : IVpnController
private readonly IEntityMapper _entityMapper;
private readonly ILocalAgentTlsCredentialsCache _localAgentTlsCredentialsCache;
private readonly IControllerRetryManager _controllerRetryManager;
+ private readonly ISplitTunnelClient _splitTunnelClient;
public VpnController(
IVpnConnection vpnConnection,
@@ -62,7 +64,8 @@ public VpnController(
IClientControllerSender appControllerCaller,
IEntityMapper entityMapper,
ILocalAgentTlsCredentialsCache localAgentTlsCredentialsCache,
- IControllerRetryManager controllerRetryManager)
+ IControllerRetryManager controllerRetryManager,
+ ISplitTunnelClient splitTunnelClient)
{
_vpnConnection = vpnConnection;
_logger = logger;
@@ -73,6 +76,7 @@ public VpnController(
_entityMapper = entityMapper;
_localAgentTlsCredentialsCache = localAgentTlsCredentialsCache;
_controllerRetryManager = controllerRetryManager;
+ _splitTunnelClient = splitTunnelClient;
}
public async Task Connect(ConnectionRequestIpcEntity connectionRequest, CancellationToken cancelToken)
@@ -143,4 +147,14 @@ public async Task RequestConnectionDetails(CancellationToken cancelToken)
{
_vpnConnection.RequestConnectionDetails();
}
+
+ public async Task AddAppPathsDynamically(DynamicAppPathsIpcEntity appPathsEntity, CancellationToken cancelToken)
+ {
+ _splitTunnelClient.AddAppPathsDynamically(appPathsEntity.AppPaths ?? System.Array.Empty());
+ }
+
+ public async Task RemoveAppPathsDynamically(DynamicAppPathsIpcEntity appPathsEntity, CancellationToken cancelToken)
+ {
+ _splitTunnelClient.RemoveAppPathsDynamically(appPathsEntity.AppPaths ?? System.Array.Empty());
+ }
}
\ No newline at end of file