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
-
+
Windows Short Demo

- Linux (Arch) Short Demo
+ Linux Arch Short Demo

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">