Add Avalonia server-host launcher (MVP scaffold)

Cross-platform C# GUI to replace the Qt/C++ Host GUI, on the same dotnet
toolchain as the server. MVP: load/save commonwealth-server.json, Start/Stop
the CommonwealthOnline.Server process (published exe, then dll, then source
run), and stream its output to a live log. Themed to the Commonwealth Online
brand (near-black + amber). Builds clean on net8.0.

Next: player list + kick/ban via the admin port, then retire the Qt host.
This commit is contained in:
NomadsReach
2026-08-16 18:15:40 -04:00
parent 6861fa4276
commit 1f01e68794
11 changed files with 431 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="CommonwealthOnline.Host.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<Color x:Key="CoBackground">#0E120B</Color>
<Color x:Key="CoSurface">#171C10</Color>
<Color x:Key="CoAccent">#E6D28C</Color>
<Color x:Key="CoText">#D8D2BE</Color>
<Color x:Key="CoDanger">#C24B4B</Color>
<SolidColorBrush x:Key="CoBackgroundBrush" Color="{StaticResource CoBackground}" />
<SolidColorBrush x:Key="CoSurfaceBrush" Color="{StaticResource CoSurface}" />
<SolidColorBrush x:Key="CoAccentBrush" Color="{StaticResource CoAccent}" />
<SolidColorBrush x:Key="CoTextBrush" Color="{StaticResource CoText}" />
<SolidColorBrush x:Key="CoDangerBrush" Color="{StaticResource CoDanger}" />
</ResourceDictionary>
</Application.Resources>
</Application>
+25
View File
@@ -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();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<AssemblyName>CommonwealthOnline.Host</AssemblyName>
<RootNamespace>CommonwealthOnline.Host</RootNamespace>
<ApplicationIcon></ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets/**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
</ItemGroup>
</Project>
+16
View File
@@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
}
+49
View File
@@ -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<ServerConfig>(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));
}
+106
View File
@@ -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<string>? LogReceived;
public event Action<bool>? 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;
}
}
+97
View File
@@ -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<string> 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;
}
+73
View File
@@ -0,0 +1,73 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CommonwealthOnline.Host.ViewModels"
x:Class="CommonwealthOnline.Host.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="920" Height="640"
MinWidth="760" MinHeight="520"
Title="Commonwealth Online — Server Host"
Background="{StaticResource CoBackgroundBrush}">
<Grid RowDefinitions="Auto,*,Auto" Margin="18">
<Image Grid.Row="0" Source="/Assets/logo.png" Height="88"
HorizontalAlignment="Left" Margin="0,0,0,14" />
<Grid Grid.Row="1" ColumnDefinitions="330,*">
<StackPanel Grid.Column="0" Spacing="9" Margin="0,0,18,0">
<TextBlock Text="SERVER" Foreground="{StaticResource CoAccentBrush}"
FontWeight="Bold" FontSize="13" />
<TextBox Watermark="Server name" Text="{Binding ServerName}" />
<Grid ColumnDefinitions="*,*">
<TextBox Grid.Column="0" Watermark="Host" Text="{Binding Host}" Margin="0,0,4,0" />
<TextBox Grid.Column="1" Watermark="Port" Text="{Binding Port}" Margin="4,0,0,0" />
</Grid>
<Grid ColumnDefinitions="*,*">
<TextBox Grid.Column="0" Watermark="Max players" Text="{Binding MaxPlayers}" Margin="0,0,4,0" />
<TextBox Grid.Column="1" Watermark="Admin port" Text="{Binding AdminPort}" Margin="4,0,0,0" />
</Grid>
<TextBlock Text="LOG VERBOSITY" Foreground="{StaticResource CoAccentBrush}"
FontWeight="Bold" FontSize="12" Margin="0,4,0,0" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding VerbosityOptions}"
SelectedItem="{Binding LogVerbosity}" />
<CheckBox Content="Enable GNS transport" IsChecked="{Binding EnableGnsTransport}"
Foreground="{StaticResource CoTextBrush}" />
<Button Content="Save config" Command="{Binding SaveCommand}"
HorizontalAlignment="Stretch" />
</StackPanel>
<Border Grid.Column="1" Background="{StaticResource CoSurfaceBrush}"
CornerRadius="4" Padding="10">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="{StaticResource CoTextBrush}"
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="12"
TextWrapping="Wrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" Margin="0,14,0,0">
<TextBlock Grid.Column="0" Text="Status:" Foreground="{StaticResource CoTextBrush}"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Text="{Binding StatusText}"
Foreground="{StaticResource CoAccentBrush}" FontWeight="Bold"
VerticalAlignment="Center" />
<Button Grid.Column="2" Content="Start" Command="{Binding StartCommand}"
Margin="0,0,8,0" Padding="26,7"
Background="{StaticResource CoAccentBrush}" Foreground="#0E120B" FontWeight="Bold" />
<Button Grid.Column="3" Content="Stop" Command="{Binding StopCommand}"
Padding="26,7"
Background="{StaticResource CoDangerBrush}" Foreground="White" FontWeight="Bold" />
</Grid>
</Grid>
</Window>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace CommonwealthOnline.Host.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}