diff --git a/README.md b/README.md index e6ca298..9173669 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ Lightweight server picker for CS2 and Deadlock with cross-platform support for * ### [Releases](https://github.com/FN-FAL113/server-picker-x/releases) ## 📷 Screenshot -![ServerPickerX](https://github.com/user-attachments/assets/97f8316f-fd09-4242-b996-56c971d97416) +![ServerPickerX](https://github.com/user-attachments/assets/21a3a513-9dce-461b-a174-becddcb57f4a)
Windows Short Demo ![Windows Short Demo Video](https://github.com/FN-FAL113/server-picker-x/blob/chore/readme-assets/readme_assets/ServerPickerX-Windows.gif)
- Linux (Arch) Short Demo + Linux Arch Short Demo ![Linux Arch Short Demo Video](https://github.com/FN-FAL113/server-picker-x/blob/chore/readme-assets/readme_assets/ServerPickerX-Linux-Arch.gif)
diff --git a/ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs b/ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs index 905ac04..89ae0fc 100644 --- a/ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs +++ b/ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs @@ -272,11 +272,9 @@ public async Task Test_BlockSelectedAsync_WithSelection() { if (item.index is 0 or 2) { - Assert.Empty(item.value.Ping); Assert.Equal("❌", item.value.Status); } else { - Assert.True(item.value.Ping.Contains("ms")); Assert.Equal("✅", item.value.Status); } } @@ -329,7 +327,6 @@ public async Task Test_UnblockAllAsync_WithServers() Assert.True(result); foreach (var srv in _vm.ServerModels) { - Assert.Contains("ms", srv.Ping); Assert.Equal("✅", srv.Status); } } @@ -458,7 +455,6 @@ public async Task Test_PerformOperationAsync_Unblocking_Success() Assert.True(result); foreach (var srv in serverModels) { - Assert.Contains("ms", srv.Ping); Assert.Equal("✅", srv.Status); } } diff --git a/ServerPickerX/App.axaml b/ServerPickerX/App.axaml index c9f0643..1b2d8d5 100644 --- a/ServerPickerX/App.axaml +++ b/ServerPickerX/App.axaml @@ -31,5 +31,6 @@ + diff --git a/ServerPickerX/App.axaml.cs b/ServerPickerX/App.axaml.cs index c427192..8ac84a3 100644 --- a/ServerPickerX/App.axaml.cs +++ b/ServerPickerX/App.axaml.cs @@ -111,26 +111,12 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugin desktop.MainWindow = new MainWindow(); } base.OnFrameworkInitializationCompleted(); } - // Reflection is partially used here and might not be trim-compatible unless JsonSerializerIsReflectionEnabledByDefault is set to true in .csproj - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } } } diff --git a/ServerPickerX/Comparers/PacketLossComparer.cs b/ServerPickerX/Comparers/PacketLossComparer.cs new file mode 100644 index 0000000..23b37fc --- /dev/null +++ b/ServerPickerX/Comparers/PacketLossComparer.cs @@ -0,0 +1,35 @@ +using ServerPickerX.Models; +using System; +using System.Collections; +using System.ComponentModel; +using System.Text.RegularExpressions; + +namespace ServerPickerX.Comparers +{ + public class PacketLossComparer : IComparer + { + public ListSortDirection _direction; + + public PacketLossComparer(ListSortDirection direction) + { + _direction = direction; + } + + public int Compare(object? x, object? y) + { + ServerModel? model1 = x as ServerModel; + ServerModel? model2 = y as ServerModel; + + // Remove "%" suffix + string? loss1Str = Regex.Replace(model1?.PacketLoss ?? "", @"[^\d]", ""); + string? loss2Str = Regex.Replace(model2?.PacketLoss ?? "", @"[^\d]", ""); + + int loss1 = int.Parse(!String.IsNullOrEmpty(loss1Str) ? loss1Str : "100"); + int loss2 = int.Parse(!String.IsNullOrEmpty(loss2Str) ? loss2Str : "100"); + + var result = loss1.CompareTo(loss2); + + return _direction == ListSortDirection.Descending ? result : -result; + } + } +} \ No newline at end of file diff --git a/ServerPickerX/Converters/PacketLossColorConverter.cs b/ServerPickerX/Converters/PacketLossColorConverter.cs new file mode 100644 index 0000000..08a9fe6 --- /dev/null +++ b/ServerPickerX/Converters/PacketLossColorConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ServerPickerX.Converters +{ + public class PacketLossColorConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + string str = value as string ?? "0%"; + if (double.TryParse(str.Replace("%", ""), NumberStyles.Any, culture, out double val)) + { + if (val < 5) return Brushes.LimeGreen; + if (val <= 20) return Brushes.Orange; + return Brushes.Red; + } + return Brushes.White; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/ServerPickerX/Converters/PingColorConverter.cs b/ServerPickerX/Converters/PingColorConverter.cs new file mode 100644 index 0000000..252095f --- /dev/null +++ b/ServerPickerX/Converters/PingColorConverter.cs @@ -0,0 +1,29 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; +using System; +using System.Globalization; + +namespace ServerPickerX.Converters +{ + public class PingColorConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, + object? parameter, System.Globalization.CultureInfo culture) + { + string str = value as string ?? "0ms"; + if (double.TryParse(str.Replace("ms", ""), NumberStyles.Any, culture, out double val)) + { + if (val <= 75) return Brushes.LimeGreen; + if (val <= 150) return Brushes.Orange; + return Brushes.Red; + } + return Brushes.White; + } + + public object? ConvertBack(object? value, Type targetType, + object? parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException("Not implemented."); + } + } +} \ No newline at end of file diff --git a/ServerPickerX/Helpers/ResourceHelper.cs b/ServerPickerX/Helpers/ResourceHelper.cs index cdd8cff..840e498 100644 --- a/ServerPickerX/Helpers/ResourceHelper.cs +++ b/ServerPickerX/Helpers/ResourceHelper.cs @@ -18,7 +18,7 @@ public static Uri CreateResourceUriFromPath(string path) var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name; // Use actual assembly name for designer previewer since it loads in a different context - if (assemblyName == "Avalonia.Designer.HostApp") + if (assemblyName is "Avalonia.Designer.HostApp" or "AvaloniaUI.Previewer.HostApp") { assemblyName = "ServerPickerX"; } diff --git a/ServerPickerX/Models/ServerModel.cs b/ServerPickerX/Models/ServerModel.cs index 49f07c1..846c6a1 100644 --- a/ServerPickerX/Models/ServerModel.cs +++ b/ServerPickerX/Models/ServerModel.cs @@ -22,6 +22,9 @@ public partial class ServerModel : ObservableObject [ObservableProperty] public string? status; + + [ObservableProperty] + public string? packetLoss; public List RelayModels { get; set; } = []; @@ -29,12 +32,9 @@ public partial class ServerModel : ObservableObject public async void PingServer() { - // If there's an ongoing ping operation then cancel it through token signals - // Linux ICMP behaves differently. Executing too many ping operations may result in a timeout if (this._cancelTokenSource != null) { this._cancelTokenSource.Cancel(); - } this._cancelTokenSource = new CancellationTokenSource(); @@ -44,11 +44,14 @@ public async void PingServer() Ping = "Pinging server"; + RelayModel? bestRelay = null; + long bestRtt = long.MaxValue; + + // Phase 1, Find the best relay (lowest RTT) foreach (RelayModel relay in RelayModels) { try { - // Cancellable async operation var res = await ping.SendPingAsync( address: IPAddress.Parse(relay.IPv4), timeout: TimeSpan.FromMilliseconds(800), @@ -56,24 +59,52 @@ public async void PingServer() cancellationToken: cancelToken ); - if (res.Status == IPStatus.Success && res.RoundtripTime >= 0) + if (res.Status == IPStatus.Success && res.RoundtripTime >= 0 && res.RoundtripTime < bestRtt) { - Ping = res.RoundtripTime + "ms"; - Status = "✅"; - - break; + bestRtt = res.RoundtripTime; + bestRelay = relay; } } - catch (Exception ex) when (ex is PingException or OperationCanceledException) + catch (Exception ex) when(ex is OperationCanceledException) { } + } + + if (bestRelay != null) + { + PacketLoss = "Probing"; + + // Phase 2, Probe the best relay 4 times + int successCount = 0; + long finalBestRtt = long.MaxValue; + const int probeCount = 4; + + for (int i = 0; i < probeCount; i++) { - break; + try + { + var res = await ping.SendPingAsync( + address: IPAddress.Parse(bestRelay.IPv4), + timeout: TimeSpan.FromMilliseconds(2000), + options: new PingOptions(), + cancellationToken: cancelToken + ); + + if (res.Status == IPStatus.Success && res.RoundtripTime >= 0) + { + successCount++; + finalBestRtt = Math.Min(finalBestRtt, res.RoundtripTime); + } + } + catch (Exception ex) when (ex is OperationCanceledException) { } } - } - // if pinging status remains depite ping all relays then its blocked or unreachable - if (Ping == "Pinging server") + double lossPercent = (1 - (successCount / probeCount)) * 100; + Ping = successCount > 0 ? finalBestRtt + "ms" : ""; + Status = successCount > 0 ? "✅" : "❌"; + PacketLoss = $"{lossPercent:F0}%"; + } else if (Ping == "Pinging server") { Ping = ""; + PacketLoss = ""; Status = "❌"; } } diff --git a/ServerPickerX/Program.cs b/ServerPickerX/Program.cs index 89ad75a..71ad8ce 100644 --- a/ServerPickerX/Program.cs +++ b/ServerPickerX/Program.cs @@ -1,7 +1,8 @@ using Avalonia; -using Projektanker.Icons.Avalonia; -using Projektanker.Icons.Avalonia.FontAwesome; using System; +using Optris.Icons.Avalonia; +using Optris.Icons.Avalonia.FontAwesome; +using Optris.Icons.Avalonia.MaterialDesign; namespace ServerPickerX { @@ -18,14 +19,14 @@ public static void Main(string[] args) => BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp() { IconProvider.Current - .Register(); + .Register() + .Register(); return AppBuilder.Configure() .UsePlatformDetect() - .With(new Win32PlatformOptions - { - RenderingMode = [Win32RenderingMode.Software], - }) +#if DEBUG + .WithDeveloperTools() +#endif .WithInterFont() .LogToTrace(); } diff --git a/ServerPickerX/ServerPickerX.csproj b/ServerPickerX/ServerPickerX.csproj index cd1f1d1..2d62e50 100644 --- a/ServerPickerX/ServerPickerX.csproj +++ b/ServerPickerX/ServerPickerX.csproj @@ -22,8 +22,8 @@ https://github.com/FN-FAL113/server-picker-x https://github.com/FN-FAL113/server-picker-x Assets\favicon.ico - 1.0.6.0 - 1.0.6.0 + 1.1.0.0 + 1.1.0.0 @@ -31,25 +31,23 @@ + libs\Interop.NetFwTypeLib.dll true - - - - - + + + + + - - None - All - - - - - - + + + + + + diff --git a/ServerPickerX/Styles/DataGridStyle.axaml b/ServerPickerX/Styles/DataGridStyle.axaml new file mode 100644 index 0000000..8c8d510 --- /dev/null +++ b/ServerPickerX/Styles/DataGridStyle.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/ServerPickerX/Styles/TitleBarButtonStyle.axaml b/ServerPickerX/Styles/TitleBarButtonStyle.axaml index 0d36d37..f430286 100644 --- a/ServerPickerX/Styles/TitleBarButtonStyle.axaml +++ b/ServerPickerX/Styles/TitleBarButtonStyle.axaml @@ -1,10 +1,14 @@  - + - - + + + + + + @@ -20,12 +24,5 @@ - - + diff --git a/ServerPickerX/ViewModels/MainWindowViewModel.cs b/ServerPickerX/ViewModels/MainWindowViewModel.cs index 26fe4fa..17768e4 100644 --- a/ServerPickerX/ViewModels/MainWindowViewModel.cs +++ b/ServerPickerX/ViewModels/MainWindowViewModel.cs @@ -151,7 +151,7 @@ public async Task SetClusterStateAsync(bool isClustered, bool shouldUnblockCurre await _jsonSetting.SaveSettingsAsync(); - await MarkPresetSelectionDirtyAsync(); + await ClearLastSelectedPresetByGameModeAsync(); } ServerData serverData = _serverDataService.GetServerData(); @@ -275,7 +275,7 @@ public void PingServers(ICollection serverModels) } catch (InvalidOperationException) { - // when user suddenly tries to cluster or uncluster the servers while ServerModels is being iterated + // when user suddenly tries to cluster or uncluster the servers while server models are being iterated } } @@ -319,24 +319,14 @@ await _messageBoxService.ShowMessageBoxAsync( } [RelayCommand] - public async Task UnblockAllAsync() + public async Task UnblockAllAsync(bool? shouldClearLastSelectedPreset = true) { if (ServerModels == null || ServerModels.Count == 0) { return false; } - return await PerformOperationAsync(false, FilteredServerModels); - } - - public async Task UnblockCurrentGameServersAsync() - { - if (ServerModels.Count == 0) - { - return true; - } - - return await PerformOperationAsync(false, new ObservableCollection(ServerModels), false); + return await PerformOperationAsync(false, ServerModels, shouldClearLastSelectedPreset ?? true); } [RelayCommand] @@ -360,7 +350,7 @@ await _messageBoxService.ShowMessageBoxAsync( public async Task PerformOperationAsync( bool shouldBlock, ObservableCollection serverModels, - bool shouldUpdatePresetSelection = true + bool shouldClearLastSelectedPreset = true ) { if (PendingOperation) @@ -394,12 +384,12 @@ await _messageBoxService.ShowMessageBoxAsync( await _loggerService.LogInfoAsync("Servers unblocked successfully"); } - if (shouldUpdatePresetSelection) + if (shouldClearLastSelectedPreset) { - await MarkPresetSelectionDirtyAsync(); + await ClearLastSelectedPresetByGameModeAsync(); } - // Ping servers (parallel operation) + // Ping servers (parallel/fire-forget operation) PingServers(serverModels); return true; @@ -583,7 +573,7 @@ private ObservableCollection GetMatchingServerModels(PresetModel se ); } - private async Task MarkPresetSelectionDirtyAsync() + private async Task ClearLastSelectedPresetByGameModeAsync() { await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); diff --git a/ServerPickerX/Views/MainWindow.axaml b/ServerPickerX/Views/MainWindow.axaml index 3e747da..de5f654 100644 --- a/ServerPickerX/Views/MainWindow.axaml +++ b/ServerPickerX/Views/MainWindow.axaml @@ -9,36 +9,26 @@ mc:Ignorable="d" x:Class="ServerPickerX.Views.MainWindow" x:DataType="vm:MainWindowViewModel" + Title="Server Picker X" + FontWeight="SemiBold" Width="920" - MinWidth="920" - MaxWidth="1280" Height="640" - MinHeight="500" - MaxHeight="960" Icon="/Assets/favicon.ico" - Title="ServerPickerX" - SystemDecorations="BorderOnly" + WindowDecorations="BorderOnly" ExtendClientAreaToDecorationsHint="True" - ExtendClientAreaChromeHints="NoChrome" ExtendClientAreaTitleBarHeightHint="-1" WindowStartupLocation="CenterScreen" Loaded="Window_Loaded"> - - - - - - - + + + BorderThickness="1.0"> @@ -50,8 +40,9 @@ @@ -214,9 +205,31 @@ - + + + + + + + + + + + + + + - + + - - - - - - + BorderThickness="1"> diff --git a/ServerPickerX/Views/UserWindows/SettingsWindow.axaml b/ServerPickerX/Views/UserWindows/SettingsWindow.axaml index b929321..2fd8815 100644 --- a/ServerPickerX/Views/UserWindows/SettingsWindow.axaml +++ b/ServerPickerX/Views/UserWindows/SettingsWindow.axaml @@ -6,13 +6,15 @@ xmlns:constants="clr-namespace:ServerPickerX.Constants" xmlns:viewmodels="using:ServerPickerX.ViewModels" mc:Ignorable="d" - Width="400" - Height="250" x:Class="ServerPickerX.SettingsWindow" x:DataType="viewmodels:SettingsWindowViewModel" - SystemDecorations="BorderOnly" + x:Name="Settings" + Width="400" + Height="250" + Title="Settings" + FontWeight="SemiBold" + WindowDecorations="BorderOnly" ExtendClientAreaToDecorationsHint="True" - ExtendClientAreaChromeHints="NoChrome" ExtendClientAreaTitleBarHeightHint="-1" WindowStartupLocation="CenterOwner" CanResize="False" @@ -38,7 +40,7 @@ Classes="wBorder" Background="#373B78" BorderBrush="#575cd4" - BorderThickness="1.35"> + BorderThickness="1">