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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/host/Views/MainWindow.axaml.cs b/host/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..c5801bb
--- /dev/null
+++ b/host/Views/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace CommonwealthOnline.Host.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}