/*
* Copyright (c) 2025 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.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml.Media;
using ProtonVPN.Client.Common.Attributes;
using ProtonVPN.Client.Common.Collections;
using ProtonVPN.Client.Contracts.Services.Browsing;
using ProtonVPN.Client.Core.Bases;
using ProtonVPN.Client.Core.Helpers;
using ProtonVPN.Client.Core.Models;
using ProtonVPN.Client.Core.Services.Activation;
using ProtonVPN.Client.Core.Services.Navigation;
using ProtonVPN.Client.Logic.Connection.Contracts;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Client.Settings.Contracts.Enums;
using ProtonVPN.Client.Settings.Contracts.Models;
using ProtonVPN.Client.Settings.Contracts.RequiredReconnections;
using ProtonVPN.Client.UI.Main.Settings.Bases;
using ProtonVPN.Client.UI.Overlays.Selection.Contracts;
using ProtonVPN.Common.Core.Networking;
namespace ProtonVPN.Client.UI.Main.Settings.Connection;
public partial class SplitTunnelingPageViewModel : SettingsPageViewModelBase
{
private readonly IUrlsBrowser _urlsBrowser;
private readonly IIpSelector _ipSelector;
private readonly IAppSelector _appSelector;
private bool _wasIpv6WarningDisplayed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasIpv6AddressesWhileIpv6Disabled))]
private bool _isIpv6Enabled;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SplitTunnelingFeatureIconSource))]
[NotifyPropertyChangedFor(nameof(HasIpv6AddressesWhileIpv6Disabled))]
private bool _isSplitTunnelingEnabled;
[ObservableProperty]
[property: SettingName(nameof(ISettings.SplitTunnelingMode))]
[NotifyPropertyChangedFor(nameof(IsStandardSplitTunneling))]
[NotifyPropertyChangedFor(nameof(IsInverseSplitTunneling))]
[NotifyPropertyChangedFor(nameof(SplitTunnelingFeatureIconSource))]
[NotifyPropertyChangedFor(nameof(IpAddresses))]
[NotifyPropertyChangedFor(nameof(IpAddressesHeader))]
[NotifyPropertyChangedFor(nameof(SelectedIpAddresses))]
[NotifyPropertyChangedFor(nameof(HasSelectedIpAddresses))]
[NotifyPropertyChangedFor(nameof(Apps))]
[NotifyPropertyChangedFor(nameof(AppsHeader))]
[NotifyPropertyChangedFor(nameof(SelectedApps))]
[NotifyPropertyChangedFor(nameof(HasSelectedApps))]
[NotifyPropertyChangedFor(nameof(HasIpv6AddressesWhileIpv6Disabled))]
private SplitTunnelingMode _currentSplitTunnelingMode;
[ObservableProperty]
private bool _isLoading;
public override string Title => Localizer.Get("Settings_Connection_SplitTunneling");
public ImageSource SplitTunnelingFeatureIconSource => GetFeatureIconSource(IsSplitTunnelingEnabled, CurrentSplitTunnelingMode);
public string LearnMoreUrl => _urlsBrowser.SplitTunnelingLearnMore;
public bool IsStandardSplitTunneling
{
get => IsSplitTunnelingMode(SplitTunnelingMode.Standard);
set => SetSplitTunnelingMode(value, SplitTunnelingMode.Standard);
}
public bool IsInverseSplitTunneling
{
get => IsSplitTunnelingMode(SplitTunnelingMode.Inverse);
set => SetSplitTunnelingMode(value, SplitTunnelingMode.Inverse);
}
[property: SettingName(nameof(ISettings.SplitTunnelingInverseIpAddressesList))]
public SmartObservableCollection IncludedIpAddresses { get; } = [];
[property: SettingName(nameof(ISettings.SplitTunnelingStandardIpAddressesList))]
public SmartObservableCollection ExcludedIpAddresses { get; } = [];
public SmartObservableCollection IpAddresses
=> IsStandardSplitTunneling ? ExcludedIpAddresses : IncludedIpAddresses;
public IEnumerable SelectedIpAddresses
=> IpAddresses.Where(ip => ip.IsSelected).Select(ip => ip.Value);
public bool HasSelectedIpAddresses => SelectedIpAddresses.Any();
public bool HasIpv6AddressesWhileIpv6Disabled
=> IsSplitTunnelingEnabled
&& IsInverseSplitTunneling
&& !IsIpv6Enabled
&& SelectedIpAddresses.Any(ip => ip.IsIpV6);
public string IpAddressesHeader => Localizer.GetFormat(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_IpAddresses_Excluded_FormattedHeader"
: "Settings_Connection_SplitTunneling_IpAddresses_Included_FormattedHeader", SelectedIpAddresses.Count());
[property: SettingName(nameof(ISettings.SplitTunnelingInverseAppsList))]
public SmartObservableCollection IncludedApps { get; } = [];
[property: SettingName(nameof(ISettings.SplitTunnelingStandardAppsList))]
public SmartObservableCollection ExcludedApps { get; } = [];
public SmartObservableCollection Apps
=> IsStandardSplitTunneling ? ExcludedApps : IncludedApps;
public IEnumerable SelectedApps
=> Apps.Where(app => app.IsSelected && app.Value.IsValid).Select(app => app.Value);
public bool HasSelectedApps => SelectedApps.Any();
public string AppsHeader => Localizer.GetFormat(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_Apps_Excluded_FormattedHeader"
: "Settings_Connection_SplitTunneling_Apps_Included_FormattedHeader", SelectedApps.Count());
public SplitTunnelingPageViewModel(
IUrlsBrowser urlsBrowser,
IRequiredReconnectionSettings requiredReconnectionSettings,
IMainViewNavigator mainViewNavigator,
ISettingsViewNavigator settingsViewNavigator,
IMainWindowOverlayActivator mainWindowOverlayActivator,
ISettings settings,
ISettingsConflictResolver settingsConflictResolver,
IConnectionManager connectionManager,
IIpSelector ipSelector,
IAppSelector appSelector,
IViewModelHelper viewModelHelper)
: base(requiredReconnectionSettings,
mainViewNavigator,
settingsViewNavigator,
mainWindowOverlayActivator,
settings,
settingsConflictResolver,
connectionManager,
viewModelHelper)
{
_urlsBrowser = urlsBrowser;
_ipSelector = ipSelector;
_appSelector = appSelector;
ExcludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
IncludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
ExcludedApps.CollectionChanged += OnAppsCollectionChanged;
IncludedApps.CollectionChanged += OnAppsCollectionChanged;
PageSettings =
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardAppsList, () => GetSettingsApps(ExcludedApps)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardIpAddressesList, () => GetSettingsIpAddresses(ExcludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseAppsList, () => GetSettingsApps(IncludedApps)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseIpAddressesList, () => GetSettingsIpAddresses(IncludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.SplitTunnelingMode, () => CurrentSplitTunnelingMode),
ChangedSettingArgs.Create(() => Settings.IsSplitTunnelingEnabled, () => IsSplitTunnelingEnabled),
ChangedSettingArgs.Create(() => Settings.IsIpv6Enabled, () => IsIpv6Enabled)
];
}
public static ImageSource GetFeatureIconSource(bool isEnabled, SplitTunnelingMode mode)
{
if (!isEnabled)
{
return ResourceHelper.GetIllustration("SplitTunnelingOffIllustrationSource");
}
return mode switch
{
SplitTunnelingMode.Standard => ResourceHelper.GetIllustration("SplitTunnelingStandardIllustrationSource"),
SplitTunnelingMode.Inverse => ResourceHelper.GetIllustration("SplitTunnelingInverseIllustrationSource"),
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
}
[RelayCommand]
public async Task TriggerIpv6DisabledWarningAsync()
{
if (await ShowIpv6DisabledWarningAsync())
{
IsIpv6Enabled = true;
}
// Show this warning only once per app launch
_wasIpv6WarningDisplayed = true;
}
[RelayCommand]
public async Task SelectIpsAsync()
{
_ipSelector.Title = Localizer.Get(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_IpAddresses_Excluded_Header"
: "Settings_Connection_SplitTunneling_IpAddresses_Included_Header");
_ipSelector.Description = Localizer.Get(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_IpAddresses_Excluded_Description"
: "Settings_Connection_SplitTunneling_IpAddresses_Included_Description");
_ipSelector.Caption = Localizer.Get("Settings_Connection_SplitTunneling_IpAddresses_AddNew");
_ipSelector.CanReorder = false;
_ipSelector.IsAddressRangeAuthorized = true;
List? result = await _ipSelector.SelectAsync(IpAddresses.Select(ip => ip.Clone()).ToList());
if (result != null)
{
IpAddresses.Reset(result);
if (HasIpv6AddressesWhileIpv6Disabled && !_wasIpv6WarningDisplayed)
{
await TriggerIpv6DisabledWarningAsync();
}
}
}
[RelayCommand]
public async Task SelectAppsAsync()
{
_appSelector.Title = Localizer.Get(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_Apps_Excluded_Header"
: "Settings_Connection_SplitTunneling_Apps_Included_Header");
_appSelector.Description = Localizer.Get(IsStandardSplitTunneling
? "Settings_Connection_SplitTunneling_Apps_Excluded_Description"
: "Settings_Connection_SplitTunneling_Apps_Included_Description");
List? result = await _appSelector.SelectAsync(Apps.Select(app => app.Clone()).ToList());
if (result != null)
{
Apps.Reset(result);
}
}
protected override async Task OnRetrieveSettingsAsync()
{
try
{
IsLoading = true;
IsIpv6Enabled = Settings.IsIpv6Enabled;
IsSplitTunnelingEnabled = Settings.IsSplitTunnelingEnabled;
CurrentSplitTunnelingMode = Settings.SplitTunnelingMode;
ExcludedIpAddresses.Reset(GetObservableIpAddresses(Settings.SplitTunnelingStandardIpAddressesList));
IncludedIpAddresses.Reset(GetObservableIpAddresses(Settings.SplitTunnelingInverseIpAddressesList));
ExcludedApps.Reset(await GetObservableAppsAsync(Settings.SplitTunnelingStandardAppsList));
IncludedApps.Reset(await GetObservableAppsAsync(Settings.SplitTunnelingInverseAppsList));
}
finally
{
IsLoading = false;
}
}
protected override bool IsReconnectionRequiredDueToChanges(IEnumerable changedSettings)
{
bool isReconnectionRequired = base.IsReconnectionRequiredDueToChanges(changedSettings);
if (isReconnectionRequired)
{
// Check if there was any active apps or IP adresses from the settings
// then check if there is any active apps or IP adresses now.
// If there is none in both case, no need to reconnect.
bool isSameSplitTunnelingMode = CurrentSplitTunnelingMode == Settings.SplitTunnelingMode;
bool hadAnyActiveAppsOrIps =
Settings.IsSplitTunnelingEnabled &&
Settings.SplitTunnelingMode switch
{
SplitTunnelingMode.Standard => 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),
_ => 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),
_ => false
};
if (isSameSplitTunnelingMode && !hadAnyActiveAppsOrIps && !hasAnyActiveAppsOrIps)
{
return false;
}
}
return isReconnectionRequired;
}
private List GetSettingsIpAddresses(IEnumerable ipAddresses)
{
return ipAddresses.Select(ip => new SplitTunnelingIpAddress(ip.Value.ToString(), ip.IsSelected)).ToList();
}
private List GetObservableIpAddresses(List settingsIpAddresses)
{
List addresses = [];
foreach (SplitTunnelingIpAddress ip in settingsIpAddresses)
{
if (NetworkAddress.TryParse(ip.IpAddress, out NetworkAddress address))
{
addresses.Add(new SelectableNetworkAddress(address, ip.IsActive));
}
}
return addresses;
}
private List GetSettingsApps(IEnumerable apps)
{
return apps.Select(ip => new SplitTunnelingApp(ip.Value.AppPath, ip.Value.AlternateAppPaths, ip.IsSelected)).ToList();
}
private async Task> 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;
}
private bool IsSplitTunnelingMode(SplitTunnelingMode splitTunnelingMode)
{
return CurrentSplitTunnelingMode == splitTunnelingMode;
}
private void SetSplitTunnelingMode(bool value, SplitTunnelingMode splitTunnelingMode)
{
if (value)
{
CurrentSplitTunnelingMode = splitTunnelingMode;
}
}
private void OnAppsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(Apps));
OnPropertyChanged(nameof(AppsHeader));
OnPropertyChanged(nameof(SelectedApps));
OnPropertyChanged(nameof(HasSelectedApps));
}
private void OnIpAddressesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(IpAddresses));
OnPropertyChanged(nameof(IpAddressesHeader));
OnPropertyChanged(nameof(SelectedIpAddresses));
OnPropertyChanged(nameof(HasSelectedIpAddresses));
OnPropertyChanged(nameof(HasIpv6AddressesWhileIpv6Disabled));
}
}