diff --git a/SS14.Launcher.sln.DotSettings b/SS14.Launcher.sln.DotSettings index 6c2ee26..238b512 100644 --- a/SS14.Launcher.sln.DotSettings +++ b/SS14.Launcher.sln.DotSettings @@ -3,6 +3,7 @@ OS VM True + True True True True \ No newline at end of file diff --git a/SS14.Launcher/Assets/Locale/en-US/text.ftl b/SS14.Launcher/Assets/Locale/en-US/text.ftl index e8fce43..869b075 100644 --- a/SS14.Launcher/Assets/Locale/en-US/text.ftl +++ b/SS14.Launcher/Assets/Locale/en-US/text.ftl @@ -234,6 +234,10 @@ region-short-south-america-west = SA West ## Strings for the "servers" tab tab-servers-title = Servers +tab-servers-byond-title = BYOND Servers +tab-servers-byond-error-msg = BYOND not installed or found +tab-servers-byond-error-desc = To connect to BYOND servers, please install BYOND from https://www.byond.com/download/ and ensure it is set as the default program for handling byond:// links. +tab-servers-byond-error-link-text = Download BYOND tab-servers-refresh = Refresh filters = Filters ({ $filteredServers } / { $totalServers }) tab-servers-search-watermark = Search For Servers… diff --git a/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs b/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs new file mode 100644 index 0000000..47a3dc4 --- /dev/null +++ b/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using Splat; +using SS14.Launcher.Utility; + +namespace SS14.Launcher.Models.ServerStatus; + +public sealed class ClassicServerListCache +{ + private readonly HttpClient _http; + private readonly ObservableCollection _allServers = new(); + + public ReadOnlyObservableCollection AllServers { get; } + + public ClassicServerListCache() + { + _http = Locator.Current.GetRequiredService(); + AllServers = new ReadOnlyObservableCollection(_allServers); + } + + public async Task Refresh() + { + try + { + var response = await _http.GetStringAsync("http://www.byond.com/games/exadv1/spacestation13?format=text"); + var servers = ParseByondResponse(response); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _allServers.Clear(); + foreach (var server in servers) + { + _allServers.Add(server); + } + }); + } + catch (Exception e) + { + Log.Error(e, "Failed to fetch Classic SS13 server list."); + } + } + + private List ParseByondResponse(string response) + { + var list = new List(); + using var reader = new StringReader(response); + + string? line; + string? currentName = null; + string? currentUrl = null; + string? currentStatus = null; + int currentPlayers = 0; + + // Simple state machine to parse the text format + // The format uses 'world/ID' blocks for servers. + + bool inServerBlock = false; + + while ((line = reader.ReadLine()) != null) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + + if (trimmed.StartsWith("world/")) + { + // If we were parsing a server, save it + if (inServerBlock && currentUrl != null) + { + // Name might be missing, try to extract from status or use URL + var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server"; + var roundTime = ExtractRoundTimeFromStatus(currentStatus); + list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby")); + } + + // Reset for new server + inServerBlock = true; + currentName = null; + currentUrl = null; + currentStatus = null; + currentPlayers = 0; + } + else if (inServerBlock) + { + if (trimmed.StartsWith("name =")) + { + currentName = ParseStringValue(trimmed); + } + else if (trimmed.StartsWith("url =")) + { + currentUrl = ParseStringValue(trimmed); + } + else if (trimmed.StartsWith("status =")) + { + currentStatus = ParseStringValue(trimmed); + } + else if (trimmed.StartsWith("players = list(")) + { + // "players = list("Bob","Alice")" + // Just count the commas + 1, correcting for empty list "list()" + var content = trimmed.Substring("players = list(".Length); + if (content.EndsWith(")")) + { + content = content.Substring(0, content.Length - 1); + if (string.IsNullOrWhiteSpace(content)) + { + currentPlayers = 0; + } + else + { + // A simple Count(',') + 1 is risky if names contain commas, but usually they are quoted. + // However, parsing full CSV is safer but 'Splitting by ",' might be enough? + // Let's iterate and count quoted segments. + // Or simpler: Splitting by ',' is mostly fine for SS13 ckeys. + currentPlayers = content.Split(',').Length; + } + } + } + else if (trimmed.StartsWith("players =")) + { + // Fallback for simple number if ever used + var parts = trimmed.Split('='); + if (parts.Length > 1 && int.TryParse(parts[1].Trim(), out var p)) + { + currentPlayers = p; + } + } + } + } + + // Add the last one if exists + if (inServerBlock && currentUrl != null) + { + var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server"; + var roundTime = ExtractRoundTimeFromStatus(currentStatus); + list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby")); + } + + return list; + } + + private string? ExtractRoundTimeFromStatus(string? status) + { + if (string.IsNullOrEmpty(status)) return null; + + // Try to match "Round time: 00:07" or similar + var match = System.Text.RegularExpressions.Regex.Match(status, @"Round\s+time:\s+(?:)?(\d{1,2}:\d{2})(?:)?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success) + { + return match.Groups[1].Value; + } + return null; + } + + private string? ExtractNameFromStatus(string? status) + { + if (string.IsNullOrEmpty(status)) return null; + // Usually starts with Name + var match = System.Text.RegularExpressions.Regex.Match(status, @"(.*?)"); + if (match.Success) + { + var raw = match.Groups[1].Value; + // Remove nested tags if any + var clean = System.Text.RegularExpressions.Regex.Replace(raw, "<.*?>", String.Empty); + return System.Net.WebUtility.HtmlDecode(clean); + } + return null; + } + + private string? CleanStatus(string? status, string? nameToRemove) + { + if (string.IsNullOrEmpty(status)) return null; + + var s = status.Replace("
", "\n").Replace("
", "\n").Replace("
", "\n"); + // Remove tags + s = System.Text.RegularExpressions.Regex.Replace(s, "<.*?>", String.Empty); + + // Decode HTML + s = System.Net.WebUtility.HtmlDecode(s); + + if (nameToRemove != null && s.StartsWith(nameToRemove)) + { + s = s.Substring(nameToRemove.Length); + } + + // Clean artifacts + char[] trims = { ' ', '\t', '\n', '\r', ']', ')', '-', '—', ':' }; + s = s.TrimStart(trims).Trim(); + + // Reduce multiple newlines + s = System.Text.RegularExpressions.Regex.Replace(s, @"\n\s+", "\n"); + s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n"); + + return s; + } + + private string ParseStringValue(string line) + { + // format: key = "value" + var idx = line.IndexOf('"'); + if (idx == -1) return string.Empty; + var lastIdx = line.LastIndexOf('"'); + if (lastIdx <= idx) return string.Empty; + + // Extract content inside quotes + var inner = line.Substring(idx + 1, lastIdx - idx - 1); + + // Unescape BYOND/C string escapes + // \" -> " + // \n -> newline + // \\ -> \ + // The most critical one is \n showing up as literal \n in UI. + + // Simple manual unescape for common sequences + return inner.Replace("\\\"", "\"") + .Replace("\\n", "\n") + .Replace("\\\\", "\\") + .Replace("\\t", "\t"); + } +} diff --git a/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs b/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs new file mode 100644 index 0000000..51ac0b7 --- /dev/null +++ b/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs @@ -0,0 +1,10 @@ +namespace SS14.Launcher.Models.ServerStatus; + +public class ClassicServerStatusData(string name, string address, int playerCount, string status, string roundTime) +{ + public string Name { get; } = name; + public string Address { get; } = address; + public int PlayerCount { get; } = playerCount; + public string Status { get; } = status; + public string RoundTime { get; } = roundTime; +} diff --git a/SS14.Launcher/Program.cs b/SS14.Launcher/Program.cs index 12d6c08..81787f0 100644 --- a/SS14.Launcher/Program.cs +++ b/SS14.Launcher/Program.cs @@ -234,6 +234,7 @@ private static AppBuilder BuildAvaloniaApp(DataManager cfg) locator.RegisterConstant(authApi); locator.RegisterConstant(hubApi); locator.RegisterConstant(new ServerListCache()); + locator.RegisterConstant(new ClassicServerListCache()); locator.RegisterConstant(loginManager); locator.RegisterConstant(overrideAssets); locator.RegisterConstant(launcherInfo); diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs new file mode 100644 index 0000000..ce49746 --- /dev/null +++ b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Diagnostics; +using ReactiveUI; +using Serilog; +using Splat; +using SS14.Launcher.Localization; +using SS14.Launcher.Models; +using SS14.Launcher.Models.ServerStatus; +using SS14.Launcher.Utility; + +namespace SS14.Launcher.ViewModels.MainWindowTabs; + +public class ClassicServerEntryViewModel : ViewModelBase +{ + private readonly MainWindowViewModel _mainWindow; + private readonly ClassicServerStatusData _server; + + public string Name => _server.Name; + public string Address => _server.Address; + public string PlayerCount => _server.PlayerCount.ToString(); + public string Status => _server.Status; + public string RoundTime => _server.RoundTime; + + private bool _isExpanded; + + public bool IsExpanded + { + get => _isExpanded; + set => this.RaiseAndSetIfChanged(ref _isExpanded, value); + } + + public ReactiveCommand ConnectCommand { get; } + + public ClassicServerEntryViewModel(MainWindowViewModel mainWindow, ClassicServerStatusData server) + { + _mainWindow = mainWindow; + _server = server; + + ConnectCommand = ReactiveCommand.Create(Connect); + } + + private void Connect() + { + if (IsByondInstalled()) + Helpers.OpenUri(new Uri(Address)); + else + { + Log.Information("User attempted to connect to BYOND server but BYOND is not installed."); + // Set the MainWindowViewModel's CustomInfo to show the BYOND not installed message + // I didn't wanna make another dialog, reuse the generic thing :) + _mainWindow.CustomInfo = new LauncherInfoManager.CustomInfo() + { + Message = LocalizationManager.Instance.GetString("tab-servers-byond-error-msg"), + Description = LocalizationManager.Instance.GetString("tab-servers-byond-error-desc"), + LinkText = LocalizationManager.Instance.GetString("tab-servers-byond-error-link-text"), + Link = "https://www.byond.com/download/", + }; + } + } + + private bool IsByondInstalled() + { + #if WINDOWS + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Dantom\BYOND"); + return key != null; + #elif LINUX + // Ask xdg-mime if BYOND is registered + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "xdg-mime", + Arguments = "query default x-scheme-handler/byond", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + return !string.IsNullOrWhiteSpace(output); + #elif MACOS + return true; // No idea, they might have it, might not + #endif + } +} diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs new file mode 100644 index 0000000..0895870 --- /dev/null +++ b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using Splat; +using ReactiveUI; +using SS14.Launcher.Models.ServerStatus; +using SS14.Launcher.Localization; +using SS14.Launcher.Utility; + +namespace SS14.Launcher.ViewModels.MainWindowTabs; + +public class ClassicServerListTabViewModel : MainWindowTabViewModel +{ + private readonly MainWindowViewModel _mainWindow; + private readonly ClassicServerListCache _cache; + + private readonly LocalizationManager _loc = LocalizationManager.Instance; + + public override string Name => _loc.GetString("tab-servers-byond-title"); + + private string? _searchString; + + public string? SearchString + { + get => _searchString; + set + { + this.RaiseAndSetIfChanged(ref _searchString, value); + UpdateList(); + } + } + + public ObservableCollection AllServers { get; } = new(); + public ReactiveCommand RefreshPressed { get; } + + public ClassicServerListTabViewModel(MainWindowViewModel mainWindow) + { + _mainWindow = mainWindow; + _cache = Locator.Current.GetRequiredService(); + RefreshPressed = ReactiveCommand.CreateFromTask(_cache.Refresh); + + // Initial populate if any + UpdateList(); + + ((INotifyCollectionChanged)_cache.AllServers).CollectionChanged += OnServersChanged; + } + + private void OnServersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateList(); + } + + private void UpdateList() + { + AllServers.Clear(); + // Filter then Sort by Players descending + var filtered = _cache.AllServers.Where(DoesSearchMatch); + var sorted = filtered.OrderByDescending(s => s.PlayerCount).ToList(); + + foreach (var s in sorted) + { + AllServers.Add(new ClassicServerEntryViewModel(_mainWindow, s)); + } + } + + private bool DoesSearchMatch(ClassicServerStatusData data) + { + if (string.IsNullOrWhiteSpace(_searchString)) + return true; + + return data.Name.Contains(_searchString, System.StringComparison.CurrentCultureIgnoreCase); + } + + public override async void Selected() + { + await _cache.Refresh(); + } +} diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs index c4c44c1..ab5a312 100644 --- a/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs +++ b/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs index 904e944..450b547 100644 --- a/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs +++ b/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs @@ -3,7 +3,6 @@ using System.Linq; using Avalonia.Controls; using Avalonia.VisualTree; -using DynamicData; using Microsoft.Toolkit.Mvvm.ComponentModel; using Microsoft.Toolkit.Mvvm.Messaging; using SS14.Launcher.Api; diff --git a/SS14.Launcher/ViewModels/MainWindowViewModel.cs b/SS14.Launcher/ViewModels/MainWindowViewModel.cs index 9380753..044d277 100644 --- a/SS14.Launcher/ViewModels/MainWindowViewModel.cs +++ b/SS14.Launcher/ViewModels/MainWindowViewModel.cs @@ -39,6 +39,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IErrorOverlayOwner public HomePageViewModel HomeTab { get; } public ServerListTabViewModel ServersTab { get; } + public ClassicServerListTabViewModel ClassicServersTab { get; } public NewsTabViewModel NewsTab { get; } public OptionsTabViewModel OptionsTab { get; } @@ -51,6 +52,7 @@ public MainWindowViewModel() _loc = LocalizationManager.Instance; ServersTab = new ServerListTabViewModel(this); + ClassicServersTab = new ClassicServerListTabViewModel(this); NewsTab = new NewsTabViewModel(); HomeTab = new HomePageViewModel(this); OptionsTab = new OptionsTabViewModel(); @@ -58,6 +60,7 @@ public MainWindowViewModel() var tabs = new List(); tabs.Add(HomeTab); tabs.Add(ServersTab); + tabs.Add(ClassicServersTab); // tabs.Add(NewsTab); //TODO: Make our own news site tabs.Add(OptionsTab); #if DEVELOPMENT diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml new file mode 100644 index 0000000..6a72768 --- /dev/null +++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs new file mode 100644 index 0000000..c1377b3 --- /dev/null +++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace SS14.Launcher.Views.MainWindowTabs; + +public partial class ClassicServerListTabView : UserControl +{ + public ClassicServerListTabView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +}