Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<details>
<summary>Windows Short Demo</summary>

![Windows Short Demo Video](https://github.com/FN-FAL113/server-picker-x/blob/chore/readme-assets/readme_assets/ServerPickerX-Windows.gif)
</details>
<details>
<summary>Linux (Arch) Short Demo</summary>
<summary>Linux Arch Short Demo</summary>

![Linux Arch Short Demo Video](https://github.com/FN-FAL113/server-picker-x/blob/chore/readme-assets/readme_assets/ServerPickerX-Linux-Arch.gif)
</details>
Expand Down
4 changes: 0 additions & 4 deletions ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
Assert.True(result);
foreach (var server in _vm.ServerModels)
{
Assert.Empty(server.Ping);

Check warning on line 217 in ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Empty(string value)'.
Assert.Equal("❌", server.Status);
}
}
Expand Down Expand Up @@ -272,11 +272,9 @@
{
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);
}
}
Expand Down Expand Up @@ -329,7 +327,6 @@
Assert.True(result);
foreach (var srv in _vm.ServerModels)
{
Assert.Contains("ms", srv.Ping);
Assert.Equal("✅", srv.Status);
}
}
Expand Down Expand Up @@ -431,7 +428,7 @@
Assert.True(result);
foreach (var srv in serverModels)
{
Assert.Empty(srv.Ping);

Check warning on line 431 in ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Empty(string value)'.
Assert.Equal("❌", srv.Status);
}
}
Expand All @@ -458,7 +455,6 @@
Assert.True(result);
foreach (var srv in serverModels)
{
Assert.Contains("ms", srv.Ping);
Assert.Equal("✅", srv.Status);
}
}
Expand Down Expand Up @@ -498,7 +494,7 @@
BindingFlags.Instance | BindingFlags.NonPublic
)!;

return prop?.GetValue(obj);

Check warning on line 497 in ServerPickerX.Tests/ViewModels/MainWindowViewModelTest.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.
}
}
}
Expand Down
1 change: 1 addition & 0 deletions ServerPickerX/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@
<StyleInclude Source="/Styles/MainButtonStyle.axaml" />
<StyleInclude Source="/Styles/TextBoxStyle.axaml" />
<StyleInclude Source="/Styles/ComboBoxStyle.axaml" />
<StyleInclude Source="/Styles/DataGridStyle.axaml" />
</Application.Styles>
</Application>
16 changes: 1 addition & 15 deletions ServerPickerX/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataAnnotationsValidationPlugin>().ToArray();

// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}
}
35 changes: 35 additions & 0 deletions ServerPickerX/Comparers/PacketLossComparer.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
27 changes: 27 additions & 0 deletions ServerPickerX/Converters/PacketLossColorConverter.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
29 changes: 29 additions & 0 deletions ServerPickerX/Converters/PingColorConverter.cs
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
2 changes: 1 addition & 1 deletion ServerPickerX/Helpers/ResourceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
59 changes: 45 additions & 14 deletions ServerPickerX/Models/ServerModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,19 @@ public partial class ServerModel : ObservableObject

[ObservableProperty]
public string? status;

[ObservableProperty]
public string? packetLoss;

public List<RelayModel> RelayModels { get; set; } = [];

private CancellationTokenSource? _cancelTokenSource;

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();
Expand All @@ -44,36 +44,67 @@ 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),
options: new PingOptions(),
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 = "❌";
}
}
Expand Down
15 changes: 8 additions & 7 deletions ServerPickerX/Program.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -18,14 +19,14 @@ public static void Main(string[] args) => BuildAvaloniaApp()
public static AppBuilder BuildAvaloniaApp()
{
IconProvider.Current
.Register<FontAwesomeIconProvider>();
.Register<FontAwesomeIconProvider>()
.Register<MaterialDesignIconProvider>();

return AppBuilder.Configure<App>()
.UsePlatformDetect()
.With(new Win32PlatformOptions
{
RenderingMode = [Win32RenderingMode.Software],
})
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
Expand Down
30 changes: 14 additions & 16 deletions ServerPickerX/ServerPickerX.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,32 @@
<PackageProjectUrl>https://github.com/FN-FAL113/server-picker-x</PackageProjectUrl>
<RepositoryUrl>https://github.com/FN-FAL113/server-picker-x</RepositoryUrl>
<ApplicationIcon>Assets\favicon.ico</ApplicationIcon>
<AssemblyVersion>1.0.6.0</AssemblyVersion>
<FileVersion>1.0.6.0</FileVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
</PropertyGroup>

<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Styles\**" />
<AvaloniaResource Include="Locales\**" />
</ItemGroup>

<ItemGroup>
<Reference Include="Interop.NetFwTypeLib">
<HintPath>libs\Interop.NetFwTypeLib.dll</HintPath>
<EmbedInteropTypes>true</EmbedInteropTypes>
</Reference>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.12">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="MessageBox.Avalonia" Version="3.3.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.6.2" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.21.3" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MessageBox.Avalonia" Version="12.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Optris.Icons.Avalonia.FontAwesome" Version="12.0.7" />
<PackageReference Include="Optris.Icons.Avalonia.MaterialDesign" Version="12.0.7" />
</ItemGroup>
</Project>
Loading
Loading