diff --git a/.gitignore b/.gitignore index 974c893..b7e8c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ server/bans.json server/.admin-token .DS_Store Thumbs.db + +host/bin/ +host/obj/ diff --git a/host/App.axaml b/host/App.axaml new file mode 100644 index 0000000..3ca1064 --- /dev/null +++ b/host/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + #0E120B + #171C10 + #E6D28C + #D8D2BE + #C24B4B + + + + + + + + + diff --git a/host/App.axaml.cs b/host/App.axaml.cs new file mode 100644 index 0000000..96b355c --- /dev/null +++ b/host/App.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CommonwealthOnline.Host.ViewModels; +using CommonwealthOnline.Host.Views; + +namespace CommonwealthOnline.Host; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/host/Assets/logo.png b/host/Assets/logo.png new file mode 100644 index 0000000..d879492 Binary files /dev/null and b/host/Assets/logo.png differ diff --git a/host/CommonwealthOnline.Host.csproj b/host/CommonwealthOnline.Host.csproj new file mode 100644 index 0000000..b15accb --- /dev/null +++ b/host/CommonwealthOnline.Host.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net8.0 + enable + latest + true + false + CommonwealthOnline.Host + CommonwealthOnline.Host + + + + + + + + + + + + + + + diff --git a/host/Program.cs b/host/Program.cs new file mode 100644 index 0000000..30f4b76 --- /dev/null +++ b/host/Program.cs @@ -0,0 +1,16 @@ +using System; +using Avalonia; + +namespace CommonwealthOnline.Host; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/host/Services/ServerConfig.cs b/host/Services/ServerConfig.cs new file mode 100644 index 0000000..95bb379 --- /dev/null +++ b/host/Services/ServerConfig.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommonwealthOnline.Host.Services; + +// Mirrors CommonwealthOnline.Server Configuration. Keep the JSON shape aligned +// with the server's own serializer so a config saved here loads there. +public sealed class ServerConfig +{ + public string Host { get; set; } = "0.0.0.0"; + public int Port { get; set; } = 7777; + public string ServerName { get; set; } = "Commonwealth Online Server"; + public string ServerDescription { get; set; } = string.Empty; + public int MaxPlayers { get; set; } = 16; + public string LogVerbosity { get; set; } = "info"; + public int AdminPort { get; set; } = 7779; + public bool EnableGnsTransport { get; set; } + public string? GnsBridgePath { get; set; } + + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static ServerConfig Load(string path) + { + try + { + if (File.Exists(path)) + { + return JsonSerializer.Deserialize(File.ReadAllText(path), Options) + ?? new ServerConfig(); + } + } + catch (Exception) + { + // Fall back to defaults on unreadable/invalid config. + } + + return new ServerConfig(); + } + + public void Save(string path) => + File.WriteAllText(path, JsonSerializer.Serialize(this, Options)); +} diff --git a/host/Services/ServerController.cs b/host/Services/ServerController.cs new file mode 100644 index 0000000..f1ee490 --- /dev/null +++ b/host/Services/ServerController.cs @@ -0,0 +1,106 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace CommonwealthOnline.Host.Services; + +// Launches the CommonwealthOnline.Server process, preferring a published +// apphost, then a framework-dependent DLL, then a source-tree dotnet run. +public sealed class ServerController +{ + private Process? _process; + + public bool IsRunning => _process is { HasExited: false }; + + public event Action? LogReceived; + public event Action? RunningChanged; + + public void Start(string serverDir, string configPath) + { + if (IsRunning) + { + return; + } + + var startInfo = ResolveLaunch(serverDir, configPath); + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => Emit(e.Data); + process.ErrorDataReceived += (_, e) => Emit(e.Data); + process.Exited += (_, _) => RunningChanged?.Invoke(false); + + _process = process; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + RunningChanged?.Invoke(true); + } + + public void Stop() + { + if (_process is { HasExited: false } process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception) + { + // Process already gone or not killable; RunningChanged fires on Exited. + } + } + } + + private void Emit(string? line) + { + if (!string.IsNullOrEmpty(line)) + { + LogReceived?.Invoke(line); + } + } + + private static ProcessStartInfo ResolveLaunch(string serverDir, string configPath) + { + var exeName = OperatingSystem.IsWindows() + ? "CommonwealthOnline.Server.exe" + : "CommonwealthOnline.Server"; + var apphost = Path.Combine(serverDir, exeName); + var dll = Path.Combine(serverDir, "CommonwealthOnline.Server.dll"); + var project = Path.Combine(serverDir, "CommonwealthOnline.Server.csproj"); + + var info = new ProcessStartInfo + { + WorkingDirectory = serverDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (File.Exists(apphost)) + { + info.FileName = apphost; + info.ArgumentList.Add("serve"); + } + else if (File.Exists(dll)) + { + info.FileName = "dotnet"; + info.ArgumentList.Add(dll); + info.ArgumentList.Add("serve"); + } + else + { + info.FileName = "dotnet"; + info.ArgumentList.Add("run"); + info.ArgumentList.Add("--project"); + info.ArgumentList.Add(project); + info.ArgumentList.Add("-c"); + info.ArgumentList.Add("Release"); + info.ArgumentList.Add("--"); + info.ArgumentList.Add("serve"); + } + + info.ArgumentList.Add("--config"); + info.ArgumentList.Add(configPath); + return info; + } +} diff --git a/host/ViewModels/MainWindowViewModel.cs b/host/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..ca90b46 --- /dev/null +++ b/host/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,97 @@ +using System.Collections.ObjectModel; +using System.IO; +using Avalonia.Threading; +using CommonwealthOnline.Host.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace CommonwealthOnline.Host.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + private const int MaxLogLines = 2000; + + private readonly ServerController _controller = new(); + private readonly string _serverDir = System.IO.Directory.GetCurrentDirectory(); + private readonly string _configPath = + Path.Combine(System.IO.Directory.GetCurrentDirectory(), "commonwealth-server.json"); + + [ObservableProperty] private string _serverName; + [ObservableProperty] private string _host; + [ObservableProperty] private int _port; + [ObservableProperty] private int _maxPlayers; + [ObservableProperty] private int _adminPort; + [ObservableProperty] private string _logVerbosity; + [ObservableProperty] private bool _enableGnsTransport; + [ObservableProperty] private bool _isRunning; + [ObservableProperty] private string _statusText = "Stopped"; + + public ObservableCollection Log { get; } = new(); + + public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" }; + + public MainWindowViewModel() + { + var config = ServerConfig.Load(_configPath); + _serverName = config.ServerName; + _host = config.Host; + _port = config.Port; + _maxPlayers = config.MaxPlayers; + _adminPort = config.AdminPort; + _logVerbosity = config.LogVerbosity; + _enableGnsTransport = config.EnableGnsTransport; + + _controller.LogReceived += line => + Dispatcher.UIThread.Post(() => Append(line)); + _controller.RunningChanged += running => + Dispatcher.UIThread.Post(() => + { + IsRunning = running; + StatusText = running ? "Running" : "Stopped"; + StartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); + }); + } + + private void Append(string line) + { + Log.Add(line); + while (Log.Count > MaxLogLines) + { + Log.RemoveAt(0); + } + } + + private ServerConfig CurrentConfig() => new() + { + ServerName = ServerName, + Host = Host, + Port = Port, + MaxPlayers = MaxPlayers, + AdminPort = AdminPort, + LogVerbosity = LogVerbosity, + EnableGnsTransport = EnableGnsTransport, + }; + + [RelayCommand] + private void Save() => CurrentConfig().Save(_configPath); + + [RelayCommand(CanExecute = nameof(CanStart))] + private void Start() + { + Save(); + Append($"[host] starting server on {Host}:{Port}..."); + _controller.Start(_serverDir, _configPath); + } + + private bool CanStart() => !IsRunning; + + [RelayCommand(CanExecute = nameof(CanStop))] + private void Stop() + { + Append("[host] stopping server..."); + _controller.Stop(); + } + + private bool CanStop() => IsRunning; +} diff --git a/host/Views/MainWindow.axaml b/host/Views/MainWindow.axaml new file mode 100644 index 0000000..90b9df7 --- /dev/null +++ b/host/Views/MainWindow.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + +