/*
* 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.Controls;
using ProtonVPN.Client.Common.Collections;
using ProtonVPN.Client.Contracts.Profiles;
using ProtonVPN.Client.Core.Bases;
using ProtonVPN.Client.Core.Bases.ViewModels;
using ProtonVPN.Client.Core.Enums;
using ProtonVPN.Client.Core.Models;
using ProtonVPN.Client.Core.Services.Activation;
using ProtonVPN.Client.Core.Services.Navigation;
using ProtonVPN.Client.Core.Services.Selection;
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.Features.Bases;
using ProtonVPN.Client.UI.Main.Settings.Bases;
using ProtonVPN.Client.UI.Main.Settings.Connection;
using ProtonVPN.Client.UI.Overlays.Selection.Contracts;
using ProtonVPN.Common.Core.Extensions;
using ProtonVPN.Common.Core.Networking;
using ProtonVPN.StatisticalEvents.Contracts;
namespace ProtonVPN.Client.UI.Main.Features.SplitTunneling;
public partial class SplitTunnelingWidgetViewModel : FeatureWidgetViewModelBase
{
private readonly Lazy> _disableSplitTunnelingSettings;
private readonly Lazy> _enableStandardSplitTunnelingSettings;
private readonly Lazy> _enableInverseSplitTunnelingSettings;
private readonly Lazy> _modifySplitTunnelingStandardAppsList;
private readonly Lazy> _modifySplitTunnelingStandardIpAddressesList;
private readonly Lazy> _modifySplitTunnelingInverseAppsList;
private readonly Lazy> _modifySplitTunnelingInverseIpAddressesList;
private readonly Lazy> _enableIpv6Settings;
private readonly IAppSelector _appSelector;
private readonly IIpSelector _ipSelector;
private bool _wasIpv6WarningDisplayed;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasIpv6AddressesWhileIpv6Disabled))]
private bool _isIpv6Enabled;
public override string Header => Localizer.Get("Settings_Connection_SplitTunneling");
public string InfoMessage => !ConnectionManager.IsConnected || !Settings.IsSplitTunnelingEnabled
? Localizer.Get("Flyouts_SplitTunneling_Info")
: Localizer.Get(Settings.SplitTunnelingMode switch
{
SplitTunnelingMode.Standard => "Flyouts_SplitTunneling_Standard_Info",
SplitTunnelingMode.Inverse => "Flyouts_SplitTunneling_Inverse_Info",
_ => "Flyouts_SplitTunneling_Info"
});
public bool IsInfoMessageVisible => true;
public bool IsSplitTunnelingComponentVisible => IsSplitTunnelingEnabled;
public bool IsSplitTunnelingComponentDimmed => !ConnectionManager.IsConnected;
public bool IsSplitTunnelingEnabled => Settings.IsSplitTunnelingEnabled;
public SplitTunnelingMode SplitTunnelingMode => Settings.SplitTunnelingMode;
public bool IsStandardSplitTunneling => SplitTunnelingMode == SplitTunnelingMode.Standard;
public bool IsInverseSplitTunneling => SplitTunnelingMode == SplitTunnelingMode.Inverse;
public bool IsStandardSplitTunnelingEnabled => IsSplitTunnelingEnabled && IsStandardSplitTunneling;
public bool IsInverseSplitTunnelingEnabled => IsSplitTunnelingEnabled && IsInverseSplitTunneling;
public SmartObservableCollection IncludedIpAddresses { get; } = [];
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
=> IsInverseSplitTunnelingEnabled
&& !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());
public SmartObservableCollection IncludedApps { get; } = [];
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());
protected override UpsellModalContext ModalContext { get; } = new(ModalSource.SplitTunneling, ModalTrigger.Settings);
public SplitTunnelingWidgetViewModel(
IViewModelHelper viewModelHelper,
IApplicationThemeSelector applicationThemeSelector,
ISettings settings,
IMainViewNavigator mainViewNavigator,
ISettingsViewNavigator settingsViewNavigator,
IConnectionManager connectionManager,
IUpsellCarouselWindowActivator upsellCarouselWindowActivator,
IMainWindowOverlayActivator mainWindowOverlayActivator,
IRequiredReconnectionSettings requiredReconnectionSettings,
ISettingsConflictResolver settingsConflictResolver,
IProfileEditor profileEditor,
IAppSelector appSelector,
IIpSelector ipSelector)
: base(viewModelHelper,
mainViewNavigator,
settingsViewNavigator,
mainWindowOverlayActivator,
settings,
connectionManager,
upsellCarouselWindowActivator,
requiredReconnectionSettings,
settingsConflictResolver,
profileEditor,
ConnectionFeature.SplitTunneling)
{
_appSelector = appSelector;
_ipSelector = ipSelector;
ExcludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
IncludedIpAddresses.CollectionChanged += OnIpAddressesCollectionChanged;
ExcludedApps.CollectionChanged += OnAppsCollectionChanged;
IncludedApps.CollectionChanged += OnAppsCollectionChanged;
_disableSplitTunnelingSettings = new(() =>
[
ChangedSettingArgs.Create(() => Settings.IsSplitTunnelingEnabled, () => false)
]);
_enableStandardSplitTunnelingSettings = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingMode, () => SplitTunnelingMode.Standard),
ChangedSettingArgs.Create(() => Settings.IsSplitTunnelingEnabled, () => true)
]);
_enableInverseSplitTunnelingSettings = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingMode, () => SplitTunnelingMode.Inverse),
ChangedSettingArgs.Create(() => Settings.IsSplitTunnelingEnabled, () => true)
]);
_modifySplitTunnelingStandardAppsList = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardAppsList, () => GetSettingsApps(ExcludedApps)),
]);
_modifySplitTunnelingStandardIpAddressesList = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingStandardIpAddressesList, () => GetSettingsIpAddresses(ExcludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.IsIpv6Enabled, () => IsIpv6Enabled),
]);
_modifySplitTunnelingInverseAppsList = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseAppsList, () => GetSettingsApps(IncludedApps)),
]);
_modifySplitTunnelingInverseIpAddressesList = new(() =>
[
ChangedSettingArgs.Create(() => Settings.SplitTunnelingInverseIpAddressesList, () => GetSettingsIpAddresses(IncludedIpAddresses)),
ChangedSettingArgs.Create(() => Settings.IsIpv6Enabled, () => IsIpv6Enabled),
]);
_enableIpv6Settings = new(() =>
[
ChangedSettingArgs.Create(() => Settings.IsIpv6Enabled, () => IsIpv6Enabled),
]);
}
protected override IEnumerable GetSettingsChangedForUpdate()
{
yield return nameof(ISettings.IsSplitTunnelingEnabled);
yield return nameof(ISettings.SplitTunnelingMode);
yield return nameof(ISettings.SplitTunnelingStandardAppsList);
yield return nameof(ISettings.SplitTunnelingStandardIpAddressesList);
yield return nameof(ISettings.SplitTunnelingInverseAppsList);
yield return nameof(ISettings.SplitTunnelingInverseIpAddressesList);
}
protected override string GetFeatureStatus()
{
return Localizer.Get(
IsSplitTunnelingEnabled
? SplitTunnelingMode switch
{
SplitTunnelingMode.Standard => "Settings_Connection_SplitTunneling_Standard_Short",
SplitTunnelingMode.Inverse => "Settings_Connection_SplitTunneling_Inverse_Short",
_ => throw new ArgumentOutOfRangeException(nameof(ISettings.SplitTunnelingMode))
}
: "Common_States_Off");
}
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
OnPropertyChanged(nameof(InfoMessage));
OnPropertyChanged(nameof(AppsHeader));
OnPropertyChanged(nameof(IpAddressesHeader));
}
protected override void OnSettingsChanged()
{
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(InfoMessage));
OnPropertyChanged(nameof(IsSplitTunnelingComponentVisible));
OnPropertyChanged(nameof(IsSplitTunnelingEnabled));
OnPropertyChanged(nameof(SplitTunnelingMode));
OnPropertyChanged(nameof(IsStandardSplitTunnelingEnabled));
OnPropertyChanged(nameof(IsInverseSplitTunnelingEnabled));
OnRetrieveSettingsAsync().FireAndForget();
}
protected override void OnConnectionStatusChanged()
{
OnPropertyChanged(nameof(InfoMessage));
OnPropertyChanged(nameof(IsSplitTunnelingComponentDimmed));
}
protected override void OnActivated()
{
base.OnActivated();
OnRetrieveSettingsAsync().FireAndForget();
}
protected override bool IsOnFeaturePage(PageViewModelBase? currentPageContext)
{
return currentPageContext is SplitTunnelingPageViewModel;
}
protected override void OnFeatureFlyoutOpened()
{
base.OnFeatureFlyoutOpened();
OnRetrieveSettingsAsync().FireAndForget();
}
private async Task OnRetrieveSettingsAsync()
{
if (!IsSplitTunnelingComponentVisible)
{
return;
}
try
{
IsLoading = true;
IsIpv6Enabled = Settings.IsIpv6Enabled;
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;
}
}
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 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));
}
[RelayCommand]
private Task DisableSplitTunnelingAsync()
{
return TryChangeFeatureSettingsAsync(_disableSplitTunnelingSettings.Value);
}
[RelayCommand]
private Task EnableStandardSplitTunnelingAsync()
{
return TryChangeFeatureSettingsAsync(_enableStandardSplitTunnelingSettings.Value);
}
[RelayCommand]
private Task EnableInverseSplitTunnelingAsync()
{
return TryChangeFeatureSettingsAsync(_enableInverseSplitTunnelingSettings.Value);
}
[RelayCommand]
private 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)
{
return;
}
Apps.Reset(result);
bool haveFeatureSettingsChanged = await TryChangeFeatureSettingsAsync(IsStandardSplitTunneling
? _modifySplitTunnelingStandardAppsList.Value
: _modifySplitTunnelingInverseAppsList.Value);
if (!haveFeatureSettingsChanged)
{
await OnRetrieveSettingsAsync();
}
}
[RelayCommand]
private 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)
{
return;
}
IpAddresses.Reset(result);
if (HasIpv6AddressesWhileIpv6Disabled && !_wasIpv6WarningDisplayed)
{
await ShowIpv6DisabledWarningAsync();
}
bool haveFeatureSettingsChanged = await TryChangeFeatureSettingsAsync(IsStandardSplitTunneling
? _modifySplitTunnelingStandardIpAddressesList.Value
: _modifySplitTunnelingInverseIpAddressesList.Value);
if (!haveFeatureSettingsChanged)
{
await OnRetrieveSettingsAsync();
}
}
[RelayCommand]
private async Task TriggerIpv6DisabledWarningAsync()
{
if (await ShowIpv6DisabledWarningAsync())
{
bool hasFeatureSettingsChanged = await TryChangeFeatureSettingsAsync(_enableIpv6Settings.Value);
if (!hasFeatureSettingsChanged)
{
await OnRetrieveSettingsAsync();
}
}
}
private async Task ShowIpv6DisabledWarningAsync()
{
ContentDialogResult result = await MainWindowOverlayActivator.ShowMessageAsync(new()
{
Title = Localizer.Get("Overlay_Ipv6Disabled_Title"),
Message = Localizer.Get("Overlay_Ipv6Disabled_Description"),
PrimaryButtonText = Localizer.Get("Overlay_Ipv6Disabled_PrimaryButton"),
SecondaryButtonText = Localizer.Get("Overlay_Ipv6Disabled_SecondaryButton"),
});
// Show this warning only once per app launch
_wasIpv6WarningDisplayed = true;
if (result == ContentDialogResult.Primary)
{
IsIpv6Enabled = true;
return true;
}
return false;
}
}