Merge pull request #17 from G-A-R-D-E-N/feature/avalonia-host
Avalonia host GUI (replaces the Qt host)
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
name: Host GUI (Avalonia)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "host/**"
|
||||
- ".github/workflows/host.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "host/**"
|
||||
- ".github/workflows/host.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Avalonia host
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
# The runner user cannot write to system /usr/share/dotnet; install the
|
||||
# pinned SDK into a runner-writable, cached path instead.
|
||||
env:
|
||||
DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Build host
|
||||
run: dotnet build host/CommonwealthOnline.Host.csproj -c Release --nologo
|
||||
@@ -58,3 +58,6 @@ server/bans.json
|
||||
server/.admin-token
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
host/bin/
|
||||
host/obj/
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(CommonwealthOnlineHost)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 COMPONENTS Core Gui Widgets Network Concurrent REQUIRED)
|
||||
find_program(DOTNET_EXECUTABLE dotnet REQUIRED)
|
||||
|
||||
set(PROJECT_SOURCES
|
||||
src/main.cpp
|
||||
src/MainWindow.h
|
||||
src/MainWindow.cpp
|
||||
src/ConfigDialog.h
|
||||
src/ConfigDialog.cpp
|
||||
src/ServerProcess.h
|
||||
src/ServerProcess.cpp
|
||||
src/resources/resources.qrc
|
||||
)
|
||||
|
||||
add_executable(CommonwealthOnlineHost ${PROJECT_SOURCES})
|
||||
target_link_libraries(CommonwealthOnlineHost Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network Qt6::Concurrent)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES WIN32_EXECUTABLE ON VS_DPI_AWARE "ON")
|
||||
set(CO_SERVER_RID "win-x64")
|
||||
set(CO_SERVER_EXECUTABLE_SUFFIX ".exe")
|
||||
else()
|
||||
set(CO_SERVER_RID "linux-x64")
|
||||
set(CO_SERVER_EXECUTABLE_SUFFIX "")
|
||||
endif()
|
||||
|
||||
set_target_properties(CommonwealthOnlineHost PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
|
||||
set(CO_SERVER_SOURCE_DIR "${CMAKE_SOURCE_DIR}/server")
|
||||
set(CO_SERVER_PROJECT "${CO_SERVER_SOURCE_DIR}/CommonwealthOnline.Server.csproj")
|
||||
set(CO_SERVER_PUBLISH_DIR "${CMAKE_BINARY_DIR}/server-publish/${CO_SERVER_RID}")
|
||||
set(CO_SERVER_STAGE_DIR "$<TARGET_FILE_DIR:CommonwealthOnlineHost>/server")
|
||||
set(CO_SERVER_STAGE_CONFIG "${CO_SERVER_STAGE_DIR}/commonwealth-server.json")
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_PROJECT}")
|
||||
message(FATAL_ERROR "Bundled C# server project not found at ${CO_SERVER_PROJECT}")
|
||||
endif()
|
||||
|
||||
add_custom_command(TARGET CommonwealthOnlineHost POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf "${CO_SERVER_PUBLISH_DIR}"
|
||||
COMMAND ${DOTNET_EXECUTABLE} publish "${CO_SERVER_PROJECT}"
|
||||
-c Release
|
||||
-r ${CO_SERVER_RID}
|
||||
--self-contained true
|
||||
-p:PublishSingleFile=true
|
||||
-p:DebugType=None
|
||||
-p:DebugSymbols=false
|
||||
-o "${CO_SERVER_PUBLISH_DIR}"
|
||||
--nologo
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DCO_SERVER_SOURCE_DIR=${CO_SERVER_SOURCE_DIR}
|
||||
-DCO_SERVER_PUBLISH_DIR=${CO_SERVER_PUBLISH_DIR}
|
||||
-DCO_SERVER_STAGE_DIR=${CO_SERVER_STAGE_DIR}
|
||||
-DCO_SERVER_STAGE_CONFIG=${CO_SERVER_STAGE_CONFIG}
|
||||
-DCO_SERVER_EXECUTABLE_SUFFIX=${CO_SERVER_EXECUTABLE_SUFFIX}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/stage_server.cmake"
|
||||
COMMENT "Publishing and staging C# Commonwealth Online server"
|
||||
VERBATIM
|
||||
)
|
||||
@@ -1,55 +1,42 @@
|
||||
# Commonwealth Online Server and Qt Host
|
||||
# Commonwealth Online — Server & Host
|
||||
|
||||
This repository contains the Commonwealth Online authoritative dedicated server and the Qt Host GUI.
|
||||
The dedicated server and its host GUI for Commonwealth Online (Fallout 4
|
||||
multiplayer). Everything now builds on one .NET 8 toolchain.
|
||||
|
||||
The dedicated server is C#/.NET. Valve GameNetworkingSockets remains behind the small native C++ bridge in `server/native_transport/`.
|
||||
## Components
|
||||
|
||||
## Server
|
||||
- **[`server/`](server/README.md)** — the dedicated relay server
|
||||
(`CommonwealthOnline.Server`, a .NET 8 console app). Run it with
|
||||
`server/start.sh` / `server/start.bat`, or `CommonwealthOnline.Server serve`.
|
||||
- **[`host/`](host/README.md)** — the **Avalonia** cross-platform host GUI:
|
||||
start/stop the server, edit config, watch the log, and manage players
|
||||
(kick/ban). Replaces the former Qt/C++ host.
|
||||
|
||||
## Quick start — server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet build CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
|
||||
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
|
||||
./start.sh # Linux / macOS
|
||||
start.bat # Windows
|
||||
```
|
||||
|
||||
The server owns Protocol V2 admission, server-owned player IDs, packet validation, movement validation, interest filtering, durable player state, scoped NPC authority and epochs, combat routing, world state, bans, rate limits, LAN discovery and localhost administration.
|
||||
Needs the .NET 8 runtime (or the SDK for a source checkout). Allow TCP `7777`
|
||||
(and optionally UDP `7778` for LAN discovery) through the firewall. `0.0.0.0`
|
||||
is a bind address, not the address players join.
|
||||
|
||||
Transport policy remains:
|
||||
## Quick start — host GUI
|
||||
|
||||
- `transform`, `npcState`: unreliable/sequenced under GNS
|
||||
- session/control, player state, combat, world state and authority: reliable/ordered
|
||||
|
||||
TCP compatibility keeps newline framing inside the TCP transport only. GNS is message-oriented and uses the `COG2` snapshot sequence envelope for latest-wins snapshots.
|
||||
|
||||
## Native GNS bridge
|
||||
|
||||
`server/native_transport` stays C++ and owns only GNS listen/connection/message mechanics and endpoint lookup. C# loads the existing C ABI directly.
|
||||
|
||||
## Qt Host GUI
|
||||
|
||||
The Qt Host remains native C++. It launches the published `CommonwealthOnline.Server` process and uses the authenticated localhost admin channel for stats, clients, kicks and bans.
|
||||
|
||||
Build requirements on Windows:
|
||||
|
||||
- Visual Studio 2022 C++ tools
|
||||
- CMake 3.20+
|
||||
- Qt 6.4+
|
||||
- .NET 8 SDK
|
||||
|
||||
```bat
|
||||
check-setup.bat
|
||||
build.bat
|
||||
deploy.bat
|
||||
```bash
|
||||
dotnet run --project host/CommonwealthOnline.Host.csproj
|
||||
```
|
||||
|
||||
CMake publishes the C# server self-contained and stages it under the Host GUI `server` directory. Packaged users do not need a separate .NET runtime.
|
||||
## Build
|
||||
|
||||
## Default ports
|
||||
```bash
|
||||
dotnet build server/CommonwealthOnline.Server.csproj -c Release
|
||||
dotnet build host/CommonwealthOnline.Host.csproj -c Release
|
||||
```
|
||||
|
||||
- TCP 7777: gameplay compatibility
|
||||
- UDP 7777: GNS gameplay when enabled
|
||||
- UDP 7778: LAN discovery
|
||||
- TCP 127.0.0.1:7779: authenticated admin control
|
||||
|
||||
See [server/README.md](server/README.md), [SETUP.md](SETUP.md), [DEVELOPMENT.md](DEVELOPMENT.md), and [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
> The Qt/C++ host and its CMake build were retired in favour of the Avalonia
|
||||
> host. `SETUP.md` / `DEVELOPMENT.md` / `DEPLOYMENT.md` are from the Qt era and
|
||||
> are pending a refresh.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt Host and C# Server Build
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
cmake --version >nul 2>&1 || (
|
||||
echo ERROR: CMake is not installed or not in PATH.
|
||||
exit /b 1
|
||||
)
|
||||
dotnet --version >nul 2>&1 || (
|
||||
echo ERROR: .NET 8 SDK is not installed or dotnet is not in PATH.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set QT6_PATH=
|
||||
set PATHS_TO_CHECK[0]=C:\Qt\6.11.1\msvc2022_64
|
||||
set PATHS_TO_CHECK[1]=C:\Qt\6.10.2\msvc2022_64
|
||||
set PATHS_TO_CHECK[2]=C:\Qt\6.8.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[3]=C:\Qt\6.7.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[4]=C:\Qt\6.6.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[5]=C:\Qt\6.5.0\msvc2022_64
|
||||
set PATHS_TO_CHECK[6]=C:\Qt\6.4.0\msvc2022_64
|
||||
|
||||
for /l %%i in (0,1,6) do (
|
||||
if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" if "!QT6_PATH!"=="" set QT6_PATH=!PATHS_TO_CHECK[%%i]!
|
||||
)
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo ERROR: Qt6 not found at standard C:\Qt paths.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Qt6: !QT6_PATH!
|
||||
echo .NET:
|
||||
dotnet --version
|
||||
|
||||
dotnet build server\CommonwealthOnline.Server.csproj -c Release --nologo || exit /b 1
|
||||
dotnet run --project server\tests\CommonwealthOnline.Server.Tests.csproj -c Release || exit /b 1
|
||||
|
||||
if not exist build mkdir build
|
||||
pushd build
|
||||
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!" || (popd & exit /b 1)
|
||||
cmake --build . --config Release || (popd & exit /b 1)
|
||||
popd
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Build successful
|
||||
echo ================================================================================
|
||||
echo Host GUI: build\bin\Release\CommonwealthOnlineHost.exe
|
||||
echo CMake also published and staged the self-contained C# server beside the host.
|
||||
exit /b 0
|
||||
@@ -1,56 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt Host and C# Server Setup Check
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
cmake --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] CMake not found.
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] CMake found
|
||||
|
||||
dotnet --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] .NET 8 SDK not found or dotnet is not in PATH.
|
||||
exit /b 1
|
||||
)
|
||||
for /f "tokens=*" %%V in ('dotnet --version') do set DOTNET_VERSION=%%V
|
||||
echo [OK] .NET SDK found: !DOTNET_VERSION!
|
||||
|
||||
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Community" if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional" if not exist "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" (
|
||||
echo [ERROR] Visual Studio 2022 not found.
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Visual Studio 2022 found
|
||||
|
||||
set QT6_FOUND=0
|
||||
set QT_PATH=
|
||||
for %%Q in (
|
||||
"C:\Qt\6.11.1\msvc2022_64"
|
||||
"C:\Qt\6.10.2\msvc2022_64"
|
||||
"C:\Qt\6.8.0\msvc2022_64"
|
||||
"C:\Qt\6.7.0\msvc2022_64"
|
||||
"C:\Qt\6.6.0\msvc2022_64"
|
||||
"C:\Qt\6.5.0\msvc2022_64"
|
||||
"C:\Qt\6.4.0\msvc2022_64"
|
||||
) do (
|
||||
if exist "%%~Q\lib\cmake\Qt6" if !QT6_FOUND!==0 (
|
||||
set QT6_FOUND=1
|
||||
set QT_PATH=%%~Q
|
||||
)
|
||||
)
|
||||
|
||||
if %QT6_FOUND%==0 (
|
||||
echo [ERROR] Qt6 MSVC 2022 package not found at the standard C:\Qt locations.
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Qt6 found at: %QT_PATH%
|
||||
|
||||
echo.
|
||||
echo All prerequisites found. build.bat will build the Qt host and publish the bundled C# server.
|
||||
exit /b 0
|
||||
@@ -1,69 +0,0 @@
|
||||
if(NOT EXISTS "${CO_SERVER_PUBLISH_DIR}")
|
||||
message(FATAL_ERROR "Published C# server directory not found: ${CO_SERVER_PUBLISH_DIR}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_PUBLISH_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
|
||||
message(FATAL_ERROR "Published C# server entrypoint not found: ${CO_SERVER_PUBLISH_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
|
||||
endif()
|
||||
|
||||
set(_preserve_config "")
|
||||
if(EXISTS "${CO_SERVER_STAGE_CONFIG}")
|
||||
file(READ "${CO_SERVER_STAGE_CONFIG}" _preserve_config)
|
||||
endif()
|
||||
|
||||
set(_stage_bans "${CO_SERVER_STAGE_DIR}/bans.json")
|
||||
set(_preserve_bans "")
|
||||
if(EXISTS "${_stage_bans}")
|
||||
file(READ "${_stage_bans}" _preserve_bans)
|
||||
endif()
|
||||
|
||||
set(_stage_admin_token "${CO_SERVER_STAGE_DIR}/.admin-token")
|
||||
set(_preserve_admin_token "")
|
||||
if(EXISTS "${_stage_admin_token}")
|
||||
file(READ "${_stage_admin_token}" _preserve_admin_token)
|
||||
endif()
|
||||
|
||||
file(REMOVE_RECURSE "${CO_SERVER_STAGE_DIR}")
|
||||
file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
|
||||
file(COPY "${CO_SERVER_PUBLISH_DIR}/" DESTINATION "${CO_SERVER_STAGE_DIR}")
|
||||
|
||||
if(NOT "${_preserve_config}" STREQUAL "")
|
||||
file(WRITE "${CO_SERVER_STAGE_CONFIG}" "${_preserve_config}")
|
||||
elseif(EXISTS "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json")
|
||||
file(COPY "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json" DESTINATION "${CO_SERVER_STAGE_DIR}")
|
||||
endif()
|
||||
|
||||
if(NOT "${_preserve_bans}" STREQUAL "")
|
||||
file(WRITE "${_stage_bans}" "${_preserve_bans}")
|
||||
endif()
|
||||
|
||||
if(NOT "${_preserve_admin_token}" STREQUAL "")
|
||||
file(WRITE "${_stage_admin_token}" "${_preserve_admin_token}")
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE _prohibited_legacy_files
|
||||
LIST_DIRECTORIES false
|
||||
"${CO_SERVER_STAGE_DIR}/*.py"
|
||||
"${CO_SERVER_STAGE_DIR}/*.pyw"
|
||||
"${CO_SERVER_STAGE_DIR}/*.pyi"
|
||||
"${CO_SERVER_STAGE_DIR}/*.pyc"
|
||||
"${CO_SERVER_STAGE_DIR}/*.pyo"
|
||||
"${CO_SERVER_STAGE_DIR}/*.whl"
|
||||
"${CO_SERVER_STAGE_DIR}/*.egg"
|
||||
"${CO_SERVER_STAGE_DIR}/requirements*.txt"
|
||||
"${CO_SERVER_STAGE_DIR}/Pipfile"
|
||||
"${CO_SERVER_STAGE_DIR}/Pipfile.lock"
|
||||
"${CO_SERVER_STAGE_DIR}/pyproject.toml"
|
||||
"${CO_SERVER_STAGE_DIR}/poetry.lock"
|
||||
"${CO_SERVER_STAGE_DIR}/setup.py"
|
||||
"${CO_SERVER_STAGE_DIR}/setup.cfg"
|
||||
"${CO_SERVER_STAGE_DIR}/tox.ini"
|
||||
)
|
||||
if(_prohibited_legacy_files)
|
||||
string(JOIN "\n " _prohibited_list ${_prohibited_legacy_files})
|
||||
message(FATAL_ERROR "Prohibited legacy runtime artifacts were staged:\n ${_prohibited_list}")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${CO_SERVER_STAGE_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
|
||||
message(FATAL_ERROR "Failed to stage C# server entrypoint")
|
||||
endif()
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online - Qt Host Deployment
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
set QT6_PATH=
|
||||
if exist "C:\Qt\6.11.1\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.11.1\msvc2022_64
|
||||
if "!QT6_PATH!"=="" if exist "C:\Qt\6.10.2\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.10.2\msvc2022_64
|
||||
if "!QT6_PATH!"=="" if exist "C:\Qt\6.8.0\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.8.0\msvc2022_64
|
||||
if "!QT6_PATH!"=="" (
|
||||
echo ERROR: Could not find Qt6 installation.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set DEPLOY_DIR=%cd%\build\bin\Release
|
||||
if not exist "!DEPLOY_DIR!\CommonwealthOnlineHost.exe" (
|
||||
echo ERROR: Host executable not found. Run build.bat first.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set DLLS=Qt6Core.dll Qt6Gui.dll Qt6Widgets.dll Qt6Network.dll Qt6Concurrent.dll Qt6DBus.dll Qt6Xml.dll
|
||||
for %%D in (%DLLS%) do (
|
||||
if exist "!QT6_PATH!\bin\%%D" copy /Y "!QT6_PATH!\bin\%%D" "!DEPLOY_DIR!\%%D" >nul
|
||||
)
|
||||
|
||||
if not exist "!DEPLOY_DIR!\plugins" mkdir "!DEPLOY_DIR!\plugins"
|
||||
xcopy /Y /Q /I "!QT6_PATH!\plugins\platforms" "!DEPLOY_DIR!\plugins\platforms\" >nul
|
||||
xcopy /Y /Q /I "!QT6_PATH!\plugins\styles" "!DEPLOY_DIR!\plugins\styles\" >nul 2>&1
|
||||
xcopy /Y /Q /I "!QT6_PATH!\plugins\imageformats" "!DEPLOY_DIR!\plugins\imageformats\" >nul 2>&1
|
||||
|
||||
set SERVER_DIR=!DEPLOY_DIR!\server
|
||||
if not exist "!SERVER_DIR!\CommonwealthOnline.Server.exe" (
|
||||
echo ERROR: Self-contained C# server is not staged beside the Host GUI.
|
||||
echo Run build.bat. CMake publishes the server during the Host GUI build.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set PROHIBITED_FOUND=0
|
||||
for /r "!SERVER_DIR!" %%F in (*.py *.pyw *.pyi *.pyc *.pyo *.whl *.egg requirements*.txt Pipfile Pipfile.lock pyproject.toml poetry.lock setup.py setup.cfg tox.ini) do (
|
||||
if exist "%%F" (
|
||||
echo ERROR: Prohibited legacy runtime artifact found in staged package: %%F
|
||||
set PROHIBITED_FOUND=1
|
||||
)
|
||||
)
|
||||
if "!PROHIBITED_FOUND!"=="1" exit /b 1
|
||||
|
||||
echo.
|
||||
echo Deployment complete:
|
||||
echo !DEPLOY_DIR!\CommonwealthOnlineHost.exe
|
||||
echo !SERVER_DIR!\CommonwealthOnline.Server.exe
|
||||
echo.
|
||||
echo Copy the entire Release directory when distributing. The bundled server is self-contained.
|
||||
exit /b 0
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Qt Installation Finder
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check common locations
|
||||
echo Searching for Qt6 installation...
|
||||
echo.
|
||||
|
||||
set FOUND=0
|
||||
|
||||
echo Checking C:\Qt...
|
||||
if exist "C:\Qt" (
|
||||
echo Found C:\Qt directory
|
||||
dir /b "C:\Qt" | findstr /R "^[0-9]"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files...
|
||||
if exist "C:\Program Files\Qt" (
|
||||
echo Found C:\Program Files\Qt
|
||||
dir /b "C:\Program Files\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking C:\Program Files ^(x86^)...
|
||||
if exist "C:\Program Files (x86)\Qt" (
|
||||
echo Found C:\Program Files (x86)\Qt
|
||||
dir /b "C:\Program Files (x86)\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking AppData...
|
||||
if exist "%APPDATA%\Qt" (
|
||||
echo Found %APPDATA%\Qt
|
||||
dir /b "%APPDATA%\Qt"
|
||||
set FOUND=1
|
||||
)
|
||||
|
||||
if %FOUND%==0 (
|
||||
echo.
|
||||
echo [WARNING] Qt6 not found in common locations!
|
||||
echo.
|
||||
echo Please try one of the following:
|
||||
echo.
|
||||
echo 1. Install Qt6 from https://www.qt.io/download-open-source
|
||||
echo Use default installation path: C:\Qt\6.8.0\
|
||||
echo.
|
||||
echo 2. If Qt is installed elsewhere, manually edit build.bat
|
||||
echo and add your Qt path to the PATHS_TO_CHECK list
|
||||
echo.
|
||||
echo 3. Or run CMake manually with explicit path:
|
||||
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="path\to\your\Qt"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation search complete!
|
||||
echo.
|
||||
pause
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Commonwealth Online — Server Host (Avalonia)
|
||||
|
||||
Cross-platform C# GUI (.NET 8 + Avalonia) for running a Commonwealth Online
|
||||
dedicated server. Replaces the former Qt/C++ host, so the whole project now
|
||||
builds on one `dotnet` toolchain.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
dotnet run --project host/CommonwealthOnline.Host.csproj
|
||||
```
|
||||
|
||||
Launch it from (or point its working directory at) a folder that holds the
|
||||
server — a published `CommonwealthOnline.Server` executable, the framework
|
||||
`CommonwealthOnline.Server.dll`, or a source checkout — alongside
|
||||
`commonwealth-server.json`.
|
||||
|
||||
## Features
|
||||
|
||||
- Edit and save `commonwealth-server.json` (host, port, name, max players,
|
||||
admin port, log verbosity, GNS toggle).
|
||||
- Start / Stop the server and stream its output to a live log.
|
||||
- Live player list with **Kick** / **Ban**, driven by the server's
|
||||
token-authenticated admin port.
|
||||
|
||||
## Publish
|
||||
|
||||
```
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r win-x64 --self-contained false
|
||||
dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r linux-x64 --self-contained false
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CommonwealthOnline.Host.Services;
|
||||
|
||||
// Speaks the server's token-authenticated admin protocol on 127.0.0.1:AdminPort:
|
||||
// read .admin-token, send {..,"adminToken"}\n, read one newline-terminated JSON reply.
|
||||
public sealed class AdminClient
|
||||
{
|
||||
private const int MaxResponseBytes = 1_000_000;
|
||||
|
||||
private readonly int _port;
|
||||
private readonly string _tokenPath;
|
||||
|
||||
public AdminClient(int adminPort, string tokenPath)
|
||||
{
|
||||
_port = adminPort;
|
||||
_tokenPath = tokenPath;
|
||||
}
|
||||
|
||||
public async Task<JsonObject?> SendAsync(JsonObject request, CancellationToken ct = default)
|
||||
{
|
||||
var token = (await File.ReadAllTextAsync(_tokenPath, ct).ConfigureAwait(false)).Trim();
|
||||
var authenticated = (JsonObject)request.DeepClone();
|
||||
authenticated["adminToken"] = token;
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(authenticated);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port, ct).ConfigureAwait(false);
|
||||
var stream = client.GetStream();
|
||||
await stream.WriteAsync(payload, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var one = new byte[1];
|
||||
while (buffer.Length < MaxResponseBytes)
|
||||
{
|
||||
var read = await stream.ReadAsync(one, ct).ConfigureAwait(false);
|
||||
if (read == 0 || one[0] == (byte)'\n')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
buffer.WriteByte(one[0]);
|
||||
}
|
||||
|
||||
return JsonNode.Parse(buffer.ToArray()) as JsonObject;
|
||||
}
|
||||
|
||||
public Task<JsonObject?> StatusAsync(CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "status" }, ct);
|
||||
|
||||
public Task<JsonObject?> ClientsAsync(CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "clients" }, ct);
|
||||
|
||||
public Task<JsonObject?> KickAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||
|
||||
public Task<JsonObject?> BanAsync(uint playerId, string reason, CancellationToken ct = default) =>
|
||||
SendAsync(new JsonObject { ["cmd"] = "ban", ["playerId"] = playerId, ["reason"] = reason }, ct);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
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 = Directory.GetCurrentDirectory();
|
||||
private readonly string _configPath =
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json");
|
||||
private readonly DispatcherTimer _pollTimer;
|
||||
private bool _polling;
|
||||
|
||||
[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";
|
||||
[ObservableProperty] private string _statsText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(KickCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(BanCommand))]
|
||||
private PlayerRow? _selectedPlayer;
|
||||
|
||||
public ObservableCollection<string> Log { get; } = new();
|
||||
public ObservableCollection<PlayerRow> Players { 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();
|
||||
if (!running)
|
||||
{
|
||||
Players.Clear();
|
||||
StatsText = string.Empty;
|
||||
}
|
||||
});
|
||||
|
||||
_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
|
||||
_pollTimer.Tick += async (_, _) => await PollAsync();
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
private AdminClient CreateAdminClient()
|
||||
{
|
||||
var tokenPath = Path.Combine(Path.GetDirectoryName(_configPath) ?? _serverDir, ".admin-token");
|
||||
return new AdminClient(AdminPort, tokenPath);
|
||||
}
|
||||
|
||||
private async Task PollAsync()
|
||||
{
|
||||
if (!IsRunning || _polling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_polling = true;
|
||||
try
|
||||
{
|
||||
var admin = CreateAdminClient();
|
||||
var clients = await admin.ClientsAsync().ConfigureAwait(true);
|
||||
ApplyClients(clients);
|
||||
|
||||
var status = await admin.StatusAsync().ConfigureAwait(true);
|
||||
ApplyStatus(status);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Server still starting, admin port not up yet, or token not written — ignore this tick.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyClients(JsonObject? response)
|
||||
{
|
||||
if (response?["clients"] is not JsonArray array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previouslySelected = SelectedPlayer?.PlayerId;
|
||||
Players.Clear();
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject client)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Players.Add(new PlayerRow
|
||||
{
|
||||
PlayerId = (uint)(client["player_id"]?.GetValue<long>() ?? 0),
|
||||
Label = client["label"]?.GetValue<string>() ?? string.Empty,
|
||||
Address = client["address"]?.GetValue<string>() ?? string.Empty,
|
||||
PacketsReceived = client["packets_received"]?.GetValue<long>() ?? 0,
|
||||
PacketsSent = client["packets_sent"]?.GetValue<long>() ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (previouslySelected is { } id)
|
||||
{
|
||||
foreach (var row in Players)
|
||||
{
|
||||
if (row.PlayerId == id)
|
||||
{
|
||||
SelectedPlayer = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStatus(JsonObject? response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var connected = response["connected_clients"]?.GetValue<long>() ?? Players.Count;
|
||||
var uptime = response["uptime_seconds"]?.GetValue<long>() ?? 0;
|
||||
StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||
private async Task Kick()
|
||||
{
|
||||
if (SelectedPlayer is not { } player)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAdminAction(admin => admin.KickAsync(player.PlayerId, "Kicked by host"),
|
||||
$"[host] kick #{player.PlayerId}").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
|
||||
private async Task Ban()
|
||||
{
|
||||
if (SelectedPlayer is not { } player)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAdminAction(admin => admin.BanAsync(player.PlayerId, "Banned by host"),
|
||||
$"[host] ban #{player.PlayerId}").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private bool CanActOnPlayer() => IsRunning && SelectedPlayer is not null;
|
||||
|
||||
private async Task RunAdminAction(Func<AdminClient, Task<JsonObject?>> action, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await action(CreateAdminClient()).ConfigureAwait(true);
|
||||
var message = response?["message"]?.GetValue<string>();
|
||||
Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}");
|
||||
await PollAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Append($"{label} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace CommonwealthOnline.Host.ViewModels;
|
||||
|
||||
public sealed class PlayerRow
|
||||
{
|
||||
public uint PlayerId { get; init; }
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public string Address { get; init; } = string.Empty;
|
||||
public long PacketsReceived { get; init; }
|
||||
public long PacketsSent { get; init; }
|
||||
|
||||
public string Display =>
|
||||
$"#{PlayerId} {(string.IsNullOrEmpty(Label) ? "player" : Label)} {Address} ↓{PacketsReceived} ↑{PacketsSent}";
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<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="960" Height="700"
|
||||
MinWidth="820" MinHeight="600"
|
||||
Title="Commonwealth Online — Server Host"
|
||||
Background="{StaticResource CoBackgroundBrush}">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="18">
|
||||
|
||||
<Image Grid.Row="0" Source="/Assets/logo.png" Height="84"
|
||||
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>
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto">
|
||||
|
||||
<Border Grid.Row="0" 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>
|
||||
|
||||
<Border Grid.Row="1" Background="{StaticResource CoSurfaceBrush}"
|
||||
CornerRadius="4" Padding="10" Margin="0,10,0,0" Height="188">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,6">
|
||||
<TextBlock DockPanel.Dock="Left" Text="PLAYERS"
|
||||
Foreground="{StaticResource CoAccentBrush}" FontWeight="Bold" FontSize="12" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding StatsText}"
|
||||
Foreground="{StaticResource CoTextBrush}" FontSize="11"
|
||||
HorizontalAlignment="Right" />
|
||||
</DockPanel>
|
||||
<ListBox Grid.Row="1" Background="Transparent"
|
||||
ItemsSource="{Binding Players}" SelectedItem="{Binding SelectedPlayer}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Display}" Foreground="{StaticResource CoTextBrush}"
|
||||
FontFamily="Cascadia Mono,Consolas,monospace" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
|
||||
<Button Content="Kick" Command="{Binding KickCommand}" Padding="18,4" />
|
||||
<Button Content="Ban" Command="{Binding BanCommand}" Padding="18,4"
|
||||
Background="{StaticResource CoDangerBrush}" Foreground="White" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace CommonwealthOnline.Host.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
namespace {
|
||||
constexpr int kServerNameMaxLength = 64;
|
||||
constexpr int kServerDescriptionMaxLength = 256;
|
||||
}
|
||||
|
||||
ConfigDialog::ConfigDialog(const QString &configPath, QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, configPath(configPath)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Server Settings"));
|
||||
setModal(true);
|
||||
setMinimumWidth(480);
|
||||
setupUI();
|
||||
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configPath)) {
|
||||
if (!loadFromFile(configPath, &data, &error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Config"),
|
||||
QStringLiteral("Could not load config; using defaults.\n%1").arg(error));
|
||||
data = ServerConfigData{};
|
||||
}
|
||||
}
|
||||
loadIntoForm(data);
|
||||
}
|
||||
|
||||
ServerConfigData ConfigDialog::config() const {
|
||||
ServerConfigData data;
|
||||
data.serverName = serverNameEdit->text().trimmed();
|
||||
data.serverDescription = descriptionEdit->toPlainText().trimmed();
|
||||
data.host = hostEdit->text().trimmed();
|
||||
data.port = portSpin->value();
|
||||
data.adminPort = adminPortSpin->value();
|
||||
data.maxPlayers = maxPlayersSpin->value();
|
||||
data.logVerbosity = logVerbosityCombo->currentData().toString();
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ConfigDialog::loadFromFile(const QString &configPath, ServerConfigData *out, QString *error) {
|
||||
if (!out) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Output config pointer is null");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not open %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Invalid JSON: %1").arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject obj = doc.object();
|
||||
out->host = obj.value(QStringLiteral("host")).toString(QStringLiteral("0.0.0.0"));
|
||||
out->port = obj.value(QStringLiteral("port")).toInt(7777);
|
||||
out->adminPort = obj.value(QStringLiteral("admin_port")).toInt(7779);
|
||||
out->serverName = obj.value(QStringLiteral("server_name")).toString(QStringLiteral("Commonwealth Online Server"));
|
||||
out->serverDescription = obj.value(QStringLiteral("server_description")).toString();
|
||||
out->maxPlayers = obj.value(QStringLiteral("max_players")).toInt(16);
|
||||
out->logVerbosity = obj.value(QStringLiteral("log_verbosity")).toString(QStringLiteral("info"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigDialog::saveToFile(const QString &configPath, const ServerConfigData &config, QString *error) {
|
||||
QFileInfo info(configPath);
|
||||
if (!info.dir().exists() && !QDir().mkpath(info.absolutePath())) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not create directory for %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("host"), config.host);
|
||||
obj.insert(QStringLiteral("port"), config.port);
|
||||
obj.insert(QStringLiteral("admin_port"), config.adminPort);
|
||||
obj.insert(QStringLiteral("server_name"), config.serverName);
|
||||
obj.insert(QStringLiteral("server_description"), config.serverDescription);
|
||||
obj.insert(QStringLiteral("max_players"), config.maxPlayers);
|
||||
obj.insert(QStringLiteral("log_verbosity"), config.logVerbosity);
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not write %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::setupUI() {
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
auto *form = new QFormLayout();
|
||||
form->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
|
||||
serverNameEdit = new QLineEdit();
|
||||
serverNameEdit->setMaxLength(kServerNameMaxLength);
|
||||
serverNameEdit->setPlaceholderText(QStringLiteral("Commonwealth Online Server"));
|
||||
form->addRow(QStringLiteral("Server name"), serverNameEdit);
|
||||
|
||||
descriptionEdit = new QPlainTextEdit();
|
||||
descriptionEdit->setPlaceholderText(QStringLiteral("Short description shown in the server browser"));
|
||||
descriptionEdit->setMaximumHeight(80);
|
||||
form->addRow(QStringLiteral("Description"), descriptionEdit);
|
||||
|
||||
hostEdit = new QLineEdit();
|
||||
hostEdit->setPlaceholderText(QStringLiteral("0.0.0.0"));
|
||||
form->addRow(QStringLiteral("Bind address"), hostEdit);
|
||||
|
||||
portSpin = new QSpinBox();
|
||||
portSpin->setRange(1, 65535);
|
||||
portSpin->setValue(7777);
|
||||
form->addRow(QStringLiteral("Port"), portSpin);
|
||||
|
||||
adminPortSpin = new QSpinBox();
|
||||
adminPortSpin->setRange(1, 65535);
|
||||
adminPortSpin->setValue(7779);
|
||||
form->addRow(QStringLiteral("Admin port (localhost)"), adminPortSpin);
|
||||
|
||||
maxPlayersSpin = new QSpinBox();
|
||||
maxPlayersSpin->setRange(1, 128);
|
||||
maxPlayersSpin->setValue(16);
|
||||
form->addRow(QStringLiteral("Max players"), maxPlayersSpin);
|
||||
|
||||
logVerbosityCombo = new QComboBox();
|
||||
logVerbosityCombo->addItem(QStringLiteral("Debug"), QStringLiteral("debug"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Info"), QStringLiteral("info"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Warning"), QStringLiteral("warning"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Error"), QStringLiteral("error"));
|
||||
form->addRow(QStringLiteral("Log level"), logVerbosityCombo);
|
||||
|
||||
mainLayout->addLayout(form);
|
||||
|
||||
pathLabel = new QLabel();
|
||||
pathLabel->setWordWrap(true);
|
||||
pathLabel->setStyleSheet(QStringLiteral("QLabel { color: #666666; font-size: 11px; }"));
|
||||
pathLabel->setText(QStringLiteral("Config file: %1").arg(configPath));
|
||||
mainLayout->addWidget(pathLabel);
|
||||
|
||||
auto *hint = new QLabel(QStringLiteral("Changes apply the next time the server is started."));
|
||||
hint->setStyleSheet(QStringLiteral("QLabel { color: #555555; font-size: 11px; }"));
|
||||
hint->setWordWrap(true);
|
||||
mainLayout->addWidget(hint);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
|
||||
buttons->button(QDialogButtonBox::Save)->setText(QStringLiteral("Save"));
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &ConfigDialog::onAccepted);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &ConfigDialog::reject);
|
||||
mainLayout->addWidget(buttons);
|
||||
}
|
||||
|
||||
void ConfigDialog::loadIntoForm(const ServerConfigData &config) {
|
||||
serverNameEdit->setText(config.serverName);
|
||||
descriptionEdit->setPlainText(config.serverDescription);
|
||||
hostEdit->setText(config.host);
|
||||
portSpin->setValue(config.port > 0 ? config.port : 7777);
|
||||
adminPortSpin->setValue(config.adminPort > 0 ? config.adminPort : 7779);
|
||||
maxPlayersSpin->setValue(config.maxPlayers > 0 ? config.maxPlayers : 16);
|
||||
|
||||
const int idx = logVerbosityCombo->findData(config.logVerbosity.toLower());
|
||||
logVerbosityCombo->setCurrentIndex(idx >= 0 ? idx : logVerbosityCombo->findData(QStringLiteral("info")));
|
||||
}
|
||||
|
||||
bool ConfigDialog::validateForm(QString *error) const {
|
||||
const ServerConfigData data = config();
|
||||
|
||||
if (data.serverName.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverName.size() > kServerNameMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name must be at most %1 characters.").arg(kServerNameMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverDescription.size() > kServerDescriptionMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Description must be at most %1 characters.").arg(kServerDescriptionMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.host.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Bind address cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.port < 1 || data.port > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort < 1 || data.adminPort > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort == data.port) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must differ from the game port.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.maxPlayers < 1) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Max players must be at least 1.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::onAccepted() {
|
||||
QString error;
|
||||
if (!validateForm(&error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid settings"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!saveToFile(configPath, config(), &error)) {
|
||||
QMessageBox::critical(this, QStringLiteral("Save failed"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
accept();
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#ifndef CONFIGDIALOG_H
|
||||
#define CONFIGDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
|
||||
struct ServerConfigData {
|
||||
QString host = QStringLiteral("0.0.0.0");
|
||||
int port = 7777;
|
||||
int adminPort = 7779;
|
||||
QString serverName = QStringLiteral("Commonwealth Online Server");
|
||||
QString serverDescription;
|
||||
int maxPlayers = 16;
|
||||
QString logVerbosity = QStringLiteral("info");
|
||||
};
|
||||
|
||||
class ConfigDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConfigDialog(const QString &configPath, QWidget *parent = nullptr);
|
||||
|
||||
ServerConfigData config() const;
|
||||
|
||||
static bool loadFromFile(const QString &configPath, ServerConfigData *out, QString *error = nullptr);
|
||||
static bool saveToFile(const QString &configPath, const ServerConfigData &config, QString *error = nullptr);
|
||||
|
||||
private slots:
|
||||
void onAccepted();
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void loadIntoForm(const ServerConfigData &config);
|
||||
bool validateForm(QString *error) const;
|
||||
|
||||
QString configPath;
|
||||
|
||||
QLineEdit *serverNameEdit;
|
||||
QPlainTextEdit *descriptionEdit;
|
||||
QLineEdit *hostEdit;
|
||||
QSpinBox *portSpin;
|
||||
QSpinBox *adminPortSpin;
|
||||
QSpinBox *maxPlayersSpin;
|
||||
QComboBox *logVerbosityCombo;
|
||||
QLabel *pathLabel;
|
||||
};
|
||||
|
||||
#endif // CONFIGDIALOG_H
|
||||
@@ -1,846 +0,0 @@
|
||||
#include "MainWindow.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QStatusBar>
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QHeaderView>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QStyle>
|
||||
#include <QInputDialog>
|
||||
#include <QTimer>
|
||||
#include <QPalette>
|
||||
#include <QColor>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, isServerRunning(false)
|
||||
, serverName("Commonwealth Online Server")
|
||||
, configFilePath("commonwealth-server.json")
|
||||
{
|
||||
setWindowTitle("Commonwealth Online — Server Host");
|
||||
setWindowIcon(QIcon(":/icons/app.ico"));
|
||||
setGeometry(100, 100, 960, 640);
|
||||
setMinimumSize(780, 520);
|
||||
|
||||
setupUI();
|
||||
setupStyles();
|
||||
setupConnections();
|
||||
setupTimer();
|
||||
|
||||
serverProcess = new ServerProcess(this);
|
||||
connect(serverProcess, &ServerProcess::started, this, &MainWindow::onServerStarted);
|
||||
connect(serverProcess, &ServerProcess::stopped, this, &MainWindow::onServerStopped);
|
||||
connect(serverProcess, &ServerProcess::logMessage, this, &MainWindow::onServerLog);
|
||||
connect(serverProcess, &ServerProcess::clientsUpdated, this, &MainWindow::onUpdateClients);
|
||||
connect(serverProcess, &ServerProcess::statsUpdated, this, &MainWindow::onStatsUpdated);
|
||||
connect(serverProcess, &ServerProcess::error, this, &MainWindow::onServerError);
|
||||
connect(serverProcess, &ServerProcess::adminCommandFinished, this, &MainWindow::onAdminCommandFinished);
|
||||
|
||||
resolveConfigPath();
|
||||
loadConfigIntoUi();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupUI() {
|
||||
centralWidget = new QWidget(this);
|
||||
setCentralWidget(centralWidget);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||
mainLayout->setContentsMargins(0, 0, 0, 0);
|
||||
mainLayout->setSpacing(0);
|
||||
|
||||
// ===== TOOLBAR =====
|
||||
auto *toolbar = new QFrame();
|
||||
toolbar->setObjectName("toolBar");
|
||||
auto *toolbarLayout = new QHBoxLayout(toolbar);
|
||||
toolbarLayout->setContentsMargins(8, 6, 8, 6);
|
||||
toolbarLayout->setSpacing(6);
|
||||
|
||||
serverNameLabel = new QLabel(serverName);
|
||||
serverNameLabel->setObjectName("serverNameLabel");
|
||||
|
||||
startButton = new QPushButton("Start");
|
||||
startButton->setObjectName("startButton");
|
||||
startButton->setFixedWidth(72);
|
||||
startButton->setToolTip("Start the relay server");
|
||||
|
||||
stopButton = new QPushButton("Stop");
|
||||
stopButton->setObjectName("stopButton");
|
||||
stopButton->setFixedWidth(72);
|
||||
stopButton->setEnabled(false);
|
||||
stopButton->setToolTip("Stop the relay server");
|
||||
|
||||
configButton = new QPushButton("Settings");
|
||||
configButton->setFixedWidth(80);
|
||||
configButton->setToolTip("Edit server settings");
|
||||
|
||||
statusDot = new QLabel();
|
||||
statusDot->setObjectName("statusDot");
|
||||
statusDot->setFixedSize(10, 10);
|
||||
statusDot->setProperty("running", false);
|
||||
|
||||
statusIndicator = new QLabel("Stopped");
|
||||
statusIndicator->setObjectName("statusText");
|
||||
|
||||
toolbarLayout->addWidget(startButton);
|
||||
toolbarLayout->addWidget(stopButton);
|
||||
toolbarLayout->addWidget(configButton);
|
||||
toolbarLayout->addSpacing(12);
|
||||
toolbarLayout->addWidget(serverNameLabel, 1);
|
||||
toolbarLayout->addWidget(statusDot);
|
||||
toolbarLayout->addWidget(statusIndicator);
|
||||
mainLayout->addWidget(toolbar);
|
||||
|
||||
// ===== STATUS STRIP =====
|
||||
auto *statusStrip = new QFrame();
|
||||
statusStrip->setObjectName("statusStrip");
|
||||
auto *statusLayout = new QHBoxLayout(statusStrip);
|
||||
statusLayout->setContentsMargins(10, 5, 10, 5);
|
||||
statusLayout->setSpacing(16);
|
||||
|
||||
bindAddressLabel = new QLabel("Bind: 0.0.0.0:7777");
|
||||
lanAddressLabel = new QLabel("LAN: —");
|
||||
clientsLabel = new QLabel("Clients: 0");
|
||||
uptimeLabel = new QLabel("Uptime: 0s");
|
||||
transformPacketsLabel = new QLabel("Transform: 0 / 0");
|
||||
worldStatePacketsLabel = new QLabel("WorldState: 0 / 0");
|
||||
|
||||
for (QLabel *label : {bindAddressLabel, lanAddressLabel, clientsLabel, uptimeLabel,
|
||||
transformPacketsLabel, worldStatePacketsLabel}) {
|
||||
label->setObjectName("statLabel");
|
||||
statusLayout->addWidget(label);
|
||||
}
|
||||
statusLayout->addStretch();
|
||||
mainLayout->addWidget(statusStrip);
|
||||
|
||||
// ===== MAIN SPLITTER (clients + log) =====
|
||||
mainSplitter = new QSplitter(Qt::Vertical);
|
||||
mainSplitter->setObjectName("mainSplitter");
|
||||
mainSplitter->setChildrenCollapsible(false);
|
||||
|
||||
auto *clientsPane = new QFrame();
|
||||
clientsPane->setObjectName("pane");
|
||||
auto *clientsLayout = new QVBoxLayout(clientsPane);
|
||||
clientsLayout->setContentsMargins(8, 6, 8, 4);
|
||||
clientsLayout->setSpacing(4);
|
||||
|
||||
auto *clientsHeaderRow = new QHBoxLayout();
|
||||
clientsHeaderRow->setContentsMargins(0, 0, 0, 0);
|
||||
clientsHeaderRow->setSpacing(8);
|
||||
|
||||
auto *clientsHeader = new QLabel("Connected Clients");
|
||||
clientsHeader->setObjectName("paneHeader");
|
||||
clientsHeaderRow->addWidget(clientsHeader, 1);
|
||||
|
||||
kickButton = new QPushButton(QStringLiteral("Kick"));
|
||||
kickButton->setEnabled(false);
|
||||
kickButton->setToolTip(QStringLiteral("Disconnect the selected player without banning"));
|
||||
banButton = new QPushButton(QStringLiteral("Ban IP"));
|
||||
banButton->setEnabled(false);
|
||||
banButton->setToolTip(QStringLiteral("Disconnect and permanently ban the selected player's IP"));
|
||||
bansButton = new QPushButton(QStringLiteral("Bans…"));
|
||||
bansButton->setEnabled(false);
|
||||
bansButton->setToolTip(QStringLiteral("View and unban banned IP addresses"));
|
||||
clientsHeaderRow->addWidget(kickButton);
|
||||
clientsHeaderRow->addWidget(banButton);
|
||||
clientsHeaderRow->addWidget(bansButton);
|
||||
clientsLayout->addLayout(clientsHeaderRow);
|
||||
|
||||
clientsTable = new QTableWidget();
|
||||
clientsTable->setColumnCount(5);
|
||||
clientsTable->setHorizontalHeaderLabels({"Player ID", "Address", "Label", "Connected", "Packets"});
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
|
||||
clientsTable->verticalHeader()->setVisible(false);
|
||||
clientsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
clientsTable->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
clientsTable->setAlternatingRowColors(true);
|
||||
clientsTable->setShowGrid(false);
|
||||
clientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
clientsTable->setFocusPolicy(Qt::StrongFocus);
|
||||
clientsLayout->addWidget(clientsTable);
|
||||
|
||||
mainSplitter->addWidget(clientsPane);
|
||||
|
||||
auto *logPane = new QFrame();
|
||||
logPane->setObjectName("pane");
|
||||
auto *logLayout = new QVBoxLayout(logPane);
|
||||
logLayout->setContentsMargins(8, 4, 8, 8);
|
||||
logLayout->setSpacing(4);
|
||||
|
||||
auto *logHeader = new QLabel("Server Log");
|
||||
logHeader->setObjectName("paneHeader");
|
||||
logLayout->addWidget(logHeader);
|
||||
|
||||
logViewer = new QTextEdit();
|
||||
logViewer->setReadOnly(true);
|
||||
logViewer->setLineWrapMode(QTextEdit::NoWrap);
|
||||
logViewer->setFont(QFont("Consolas", 9));
|
||||
logViewer->setPlaceholderText("Server output appears here…");
|
||||
logLayout->addWidget(logViewer);
|
||||
mainSplitter->addWidget(logPane);
|
||||
|
||||
mainSplitter->setStretchFactor(0, 2);
|
||||
mainSplitter->setStretchFactor(1, 3);
|
||||
mainSplitter->setSizes({220, 320});
|
||||
mainLayout->addWidget(mainSplitter, 1);
|
||||
|
||||
statusBar()->showMessage("Ready");
|
||||
}
|
||||
|
||||
void MainWindow::setupStyles() {
|
||||
// Force a light application palette so Windows dark mode cannot leave
|
||||
// white text on the Host GUI's light backgrounds (status strip, dialogs).
|
||||
QPalette lightPalette;
|
||||
const QColor windowBg(243, 243, 243);
|
||||
const QColor baseBg(255, 255, 255);
|
||||
const QColor text(31, 31, 31);
|
||||
const QColor muted(68, 68, 68);
|
||||
const QColor disabled(153, 153, 153);
|
||||
const QColor highlight(204, 228, 247);
|
||||
lightPalette.setColor(QPalette::Window, windowBg);
|
||||
lightPalette.setColor(QPalette::WindowText, text);
|
||||
lightPalette.setColor(QPalette::Base, baseBg);
|
||||
lightPalette.setColor(QPalette::AlternateBase, QColor(247, 247, 247));
|
||||
lightPalette.setColor(QPalette::Text, text);
|
||||
lightPalette.setColor(QPalette::Button, baseBg);
|
||||
lightPalette.setColor(QPalette::ButtonText, text);
|
||||
lightPalette.setColor(QPalette::BrightText, text);
|
||||
lightPalette.setColor(QPalette::ToolTipBase, baseBg);
|
||||
lightPalette.setColor(QPalette::ToolTipText, text);
|
||||
lightPalette.setColor(QPalette::PlaceholderText, muted);
|
||||
lightPalette.setColor(QPalette::Highlight, highlight);
|
||||
lightPalette.setColor(QPalette::HighlightedText, text);
|
||||
lightPalette.setColor(QPalette::Link, QColor(59, 121, 183));
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::WindowText, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::Text, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::ButtonText, disabled);
|
||||
qApp->setPalette(lightPalette);
|
||||
|
||||
const QString stylesheet = R"(
|
||||
* {
|
||||
font-family: "Segoe UI", "Segoe UI Variable", sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QInputDialog, QMessageBox {
|
||||
background-color: #f3f3f3;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QStatusBar {
|
||||
background-color: #e8e8e8;
|
||||
color: #444444;
|
||||
border-top: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
QStatusBar QLabel {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
QFrame#toolBar {
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip {
|
||||
background-color: #ececec;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip QLabel {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QFrame#pane {
|
||||
background-color: #f3f3f3;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QLabel#serverNameLabel {
|
||||
color: #1f1f1f;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QLabel#statusText {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
QLabel#statusDot {
|
||||
background-color: #c44;
|
||||
border: 1px solid #a33;
|
||||
border-radius: 5px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusDot[running="true"] {
|
||||
background-color: #2e8b4e;
|
||||
border: 1px solid #246b3c;
|
||||
}
|
||||
|
||||
QLabel#paneHeader {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QLabel#statLabel {
|
||||
color: #1f1f1f;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QInputDialog QLabel, QMessageBox QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #eef5fc;
|
||||
border-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #dceaf8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #f0f0f0;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
QPushButton#startButton {
|
||||
background-color: #2e8b4e;
|
||||
color: #ffffff;
|
||||
border-color: #246b3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#startButton:hover {
|
||||
background-color: #359957;
|
||||
}
|
||||
|
||||
QPushButton#startButton:pressed {
|
||||
background-color: #246b3c;
|
||||
}
|
||||
|
||||
QPushButton#startButton:disabled {
|
||||
background-color: #9cbcab;
|
||||
border-color: #8aa996;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton#stopButton {
|
||||
background-color: #c44;
|
||||
color: #ffffff;
|
||||
border-color: #a33;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:hover {
|
||||
background-color: #d25555;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:pressed {
|
||||
background-color: #a33;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:disabled {
|
||||
background-color: #d0a0a0;
|
||||
border-color: #b88888;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #ffffff;
|
||||
alternate-background-color: #f7f7f7;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #c8c8c8;
|
||||
gridline-color: #e6e6e6;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #ececec;
|
||||
color: #333333;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d4d4d4;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QTextEdit {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #c8c8c8;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
background-color: #d0d0d0;
|
||||
height: 3px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical:hover {
|
||||
background-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||
border-color: #3b79b7;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #ececec;
|
||||
border: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 72px;
|
||||
}
|
||||
)";
|
||||
|
||||
qApp->setStyle("Fusion");
|
||||
qApp->setStyleSheet(stylesheet);
|
||||
}
|
||||
|
||||
void MainWindow::setupConnections() {
|
||||
connect(startButton, &QPushButton::clicked, this, &MainWindow::onStartServer);
|
||||
connect(stopButton, &QPushButton::clicked, this, &MainWindow::onStopServer);
|
||||
connect(configButton, &QPushButton::clicked, this, &MainWindow::onOpenConfig);
|
||||
connect(kickButton, &QPushButton::clicked, this, &MainWindow::onKickSelectedClient);
|
||||
connect(banButton, &QPushButton::clicked, this, &MainWindow::onBanSelectedClient);
|
||||
connect(bansButton, &QPushButton::clicked, this, &MainWindow::onManageBans);
|
||||
connect(clientsTable, &QTableWidget::itemSelectionChanged, this, &MainWindow::onClientSelectionChanged);
|
||||
}
|
||||
|
||||
void MainWindow::resolveConfigPath() {
|
||||
const QString serverDir = serverProcess ? serverProcess->serverDirectory() : QString();
|
||||
if (!serverDir.isEmpty()) {
|
||||
configFilePath = QDir(serverDir).absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
} else {
|
||||
configFilePath = QDir(QCoreApplication::applicationDirPath())
|
||||
.absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadConfigIntoUi() {
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configFilePath) && ConfigDialog::loadFromFile(configFilePath, &data, &error)) {
|
||||
applyConfigToUi(data);
|
||||
} else {
|
||||
applyConfigToUi(ServerConfigData{});
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::applyConfigToUi(const ServerConfigData &config) {
|
||||
serverName = config.serverName;
|
||||
serverNameLabel->setText(serverName);
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(config.host).arg(config.port));
|
||||
if (serverProcess) {
|
||||
serverProcess->setAdminPort(config.adminPort);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupTimer() {
|
||||
statsTimer = new QTimer(this);
|
||||
connect(statsTimer, &QTimer::timeout, this, &MainWindow::onUpdateStats);
|
||||
}
|
||||
|
||||
void MainWindow::onStartServer() {
|
||||
startButton->setEnabled(false);
|
||||
statusBar()->showMessage("Starting server...");
|
||||
loadConfigIntoUi();
|
||||
serverProcess->start(configFilePath);
|
||||
}
|
||||
|
||||
void MainWindow::onStopServer() {
|
||||
stopButton->setEnabled(false);
|
||||
statusBar()->showMessage("Stopping server...");
|
||||
serverProcess->stop();
|
||||
}
|
||||
|
||||
void MainWindow::onOpenConfig() {
|
||||
resolveConfigPath();
|
||||
|
||||
if (serverProcess && serverProcess->serverDirectory().isEmpty()) {
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("Server Settings"),
|
||||
QStringLiteral("Server directory not found. Settings cannot be saved until the server folder is available."));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigDialog dialog(configFilePath, this);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ServerConfigData data = dialog.config();
|
||||
applyConfigToUi(data);
|
||||
addLogMessage(QStringLiteral("[GUI] Saved server settings to %1").arg(configFilePath));
|
||||
|
||||
if (isServerRunning) {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved — restart the server to apply"));
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Settings saved"),
|
||||
QStringLiteral("Settings were saved.\n\nStop and start the server for them to take effect."));
|
||||
} else {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerStarted() {
|
||||
isServerRunning = true;
|
||||
updateServerStatus(true);
|
||||
bansButton->setEnabled(true);
|
||||
statsTimer->start(1000); // Update every second
|
||||
statusBar()->showMessage("Server running");
|
||||
addLogMessage("[GUI] Server started successfully");
|
||||
// Give the server admin port a moment to bind before the first poll.
|
||||
QTimer *startupPoll = new QTimer(this);
|
||||
startupPoll->setSingleShot(true);
|
||||
connect(startupPoll, &QTimer::timeout, this, [this, startupPoll]() {
|
||||
onUpdateStats();
|
||||
startupPoll->deleteLater();
|
||||
});
|
||||
startupPoll->start(750);
|
||||
}
|
||||
|
||||
void MainWindow::onServerStopped() {
|
||||
isServerRunning = false;
|
||||
updateServerStatus(false);
|
||||
statsTimer->stop();
|
||||
clientsTable->setRowCount(0);
|
||||
bansButton->setEnabled(false);
|
||||
onClientSelectionChanged();
|
||||
clientsLabel->setText(QStringLiteral("Clients: 0"));
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: 0s"));
|
||||
transformPacketsLabel->setText(QStringLiteral("Transform: 0 / 0"));
|
||||
worldStatePacketsLabel->setText(QStringLiteral("WorldState: 0 / 0"));
|
||||
lanAddressLabel->setText(QStringLiteral("LAN: —"));
|
||||
statusBar()->showMessage("Server stopped");
|
||||
addLogMessage("[GUI] Server stopped");
|
||||
}
|
||||
|
||||
void MainWindow::updateServerStatus(bool running) {
|
||||
statusDot->setProperty("running", running);
|
||||
statusDot->style()->unpolish(statusDot);
|
||||
statusDot->style()->polish(statusDot);
|
||||
|
||||
if (running) {
|
||||
statusIndicator->setText("Running");
|
||||
startButton->setEnabled(false);
|
||||
stopButton->setEnabled(true);
|
||||
} else {
|
||||
statusIndicator->setText("Stopped");
|
||||
startButton->setEnabled(true);
|
||||
stopButton->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerLog(const QString &message) {
|
||||
addLogMessage(message);
|
||||
}
|
||||
|
||||
void MainWindow::addLogMessage(const QString &message) {
|
||||
QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss");
|
||||
logViewer->append(QString("[%1] %2").arg(timestamp, message));
|
||||
|
||||
// Auto-scroll to bottom
|
||||
QTextCursor cursor = logViewer->textCursor();
|
||||
cursor.movePosition(QTextCursor::End);
|
||||
logViewer->setTextCursor(cursor);
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateStats() {
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onStatsUpdated(const QJsonObject &stats) {
|
||||
applyStatsToUi(stats);
|
||||
}
|
||||
|
||||
void MainWindow::applyStatsToUi(const QJsonObject &stats) {
|
||||
const int connected = stats.value(QStringLiteral("connected_clients")).toInt();
|
||||
clientsLabel->setText(QStringLiteral("Clients: %1").arg(connected));
|
||||
|
||||
const double uptime = stats.value(QStringLiteral("uptime_seconds")).toDouble();
|
||||
if (uptime < 60.0) {
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1s").arg(static_cast<int>(uptime)));
|
||||
} else if (uptime < 3600.0) {
|
||||
const int minutes = static_cast<int>(uptime / 60.0);
|
||||
const int seconds = static_cast<int>(uptime) % 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1m %2s").arg(minutes).arg(seconds));
|
||||
} else {
|
||||
const int hours = static_cast<int>(uptime / 3600.0);
|
||||
const int minutes = (static_cast<int>(uptime) % 3600) / 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1h %2m").arg(hours).arg(minutes));
|
||||
}
|
||||
|
||||
transformPacketsLabel->setText(
|
||||
QStringLiteral("Transform: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("transform_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("transform_packets_broadcast")).toInt()));
|
||||
worldStatePacketsLabel->setText(
|
||||
QStringLiteral("WorldState: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_broadcast")).toInt()));
|
||||
|
||||
const QString host = stats.value(QStringLiteral("host")).toString();
|
||||
const QString port = stats.value(QStringLiteral("port")).toString();
|
||||
if (!host.isEmpty() && !port.isEmpty()) {
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(host, port));
|
||||
}
|
||||
|
||||
// LAN addresses are not currently in the stats snapshot; leave placeholder.
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateClients(const QJsonArray &clients) {
|
||||
const int selectedRow = clientsTable->currentRow();
|
||||
int selectedPlayerId = -1;
|
||||
if (selectedRow >= 0 && clientsTable->item(selectedRow, 0)) {
|
||||
selectedPlayerId = clientsTable->item(selectedRow, 0)->text().toInt();
|
||||
}
|
||||
|
||||
clientsTable->setRowCount(0);
|
||||
|
||||
int restoreRow = -1;
|
||||
for (int i = 0; i < clients.size(); ++i) {
|
||||
QJsonObject client = clients[i].toObject();
|
||||
|
||||
int row = clientsTable->rowCount();
|
||||
clientsTable->insertRow(row);
|
||||
|
||||
const int playerId = client.value(QStringLiteral("player_id")).toInt();
|
||||
QString connectedText = QStringLiteral("?");
|
||||
const QJsonValue connectedValue = client.value(QStringLiteral("connected_at"));
|
||||
if (connectedValue.isDouble()) {
|
||||
connectedText = QDateTime::fromSecsSinceEpoch(
|
||||
static_cast<qint64>(connectedValue.toDouble())).toString(QStringLiteral("HH:mm:ss"));
|
||||
} else if (connectedValue.isString()) {
|
||||
connectedText = connectedValue.toString();
|
||||
}
|
||||
|
||||
clientsTable->setItem(row, 0, new QTableWidgetItem(QString::number(playerId)));
|
||||
clientsTable->setItem(row, 1, new QTableWidgetItem(client.value(QStringLiteral("address")).toString()));
|
||||
clientsTable->setItem(row, 2, new QTableWidgetItem(client.value(QStringLiteral("label")).toString()));
|
||||
clientsTable->setItem(row, 3, new QTableWidgetItem(connectedText));
|
||||
clientsTable->setItem(row, 4, new QTableWidgetItem(QString::number(client.value(QStringLiteral("packets_sent")).toInt())));
|
||||
|
||||
if (playerId == selectedPlayerId) {
|
||||
restoreRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreRow >= 0) {
|
||||
clientsTable->selectRow(restoreRow);
|
||||
}
|
||||
onClientSelectionChanged();
|
||||
}
|
||||
|
||||
void MainWindow::onClientSelectionChanged() {
|
||||
const bool hasSelection = isServerRunning && clientsTable->currentRow() >= 0;
|
||||
kickButton->setEnabled(hasSelection);
|
||||
banButton->setEnabled(hasSelection);
|
||||
}
|
||||
|
||||
void MainWindow::onKickSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Kick player"),
|
||||
QStringLiteral("Kick player %1 (%2)? They can reconnect.").arg(playerId).arg(address));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->kickPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onBanSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const QString ip = address.section(QLatin1Char(':'), 0, 0);
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Ban IP"),
|
||||
QStringLiteral(
|
||||
"Ban IP %1 (player %2)?\n\n"
|
||||
"They will be disconnected and cannot reconnect until unbanned.")
|
||||
.arg(ip)
|
||||
.arg(playerId));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->banPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onManageBans() {
|
||||
if (!serverProcess || !isServerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray bans = serverProcess->listBans();
|
||||
if (bans.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
QStringList ips;
|
||||
for (const QJsonValue &value : bans) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString ip = entry.value(QStringLiteral("ip")).toString();
|
||||
if (ip.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
ips.append(ip);
|
||||
const QString reason = entry.value(QStringLiteral("reason")).toString();
|
||||
lines.append(reason.isEmpty() ? ip : QStringLiteral("%1 — %2").arg(ip, reason));
|
||||
}
|
||||
|
||||
if (ips.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString chosen = QInputDialog::getItem(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("Select an IP to unban:"),
|
||||
lines,
|
||||
0,
|
||||
false,
|
||||
&ok);
|
||||
if (!ok || chosen.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int index = lines.indexOf(chosen);
|
||||
if (index < 0 || index >= ips.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString ip = ips.at(index);
|
||||
const auto confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Unban IP"),
|
||||
QStringLiteral("Remove %1 from the ban list?").arg(ip));
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->unbanIp(ip);
|
||||
}
|
||||
|
||||
void MainWindow::onAdminCommandFinished(bool ok, const QString &message) {
|
||||
if (ok) {
|
||||
addLogMessage(QStringLiteral("[ADMIN] %1").arg(message));
|
||||
statusBar()->showMessage(message);
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
} else {
|
||||
addLogMessage(QStringLiteral("[ADMIN ERROR] %1").arg(message));
|
||||
statusBar()->showMessage(QStringLiteral("Admin error: %1").arg(message));
|
||||
QMessageBox::warning(this, QStringLiteral("Admin command failed"), message);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerError(const QString &error) {
|
||||
addLogMessage(QString("[ERROR] %1").arg(error));
|
||||
statusBar()->showMessage(QString("Error: %1").arg(error));
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
#ifndef MAINWINDOW_H
|
||||
#define MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableWidget>
|
||||
#include <QTextEdit>
|
||||
#include <QSplitter>
|
||||
#include <QTimer>
|
||||
#include "ServerProcess.h"
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void onStartServer();
|
||||
void onStopServer();
|
||||
void onOpenConfig();
|
||||
void onServerStarted();
|
||||
void onServerStopped();
|
||||
void onServerLog(const QString &message);
|
||||
void onUpdateStats();
|
||||
void onUpdateClients(const QJsonArray &clients);
|
||||
void onServerError(const QString &error);
|
||||
void onKickSelectedClient();
|
||||
void onBanSelectedClient();
|
||||
void onManageBans();
|
||||
void onClientSelectionChanged();
|
||||
void onAdminCommandFinished(bool ok, const QString &message);
|
||||
void onStatsUpdated(const QJsonObject &stats);
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void setupStyles();
|
||||
void setupConnections();
|
||||
void setupTimer();
|
||||
|
||||
void updateServerStatus(bool running);
|
||||
void addLogMessage(const QString &message);
|
||||
void applyStatsToUi(const QJsonObject &stats);
|
||||
void resolveConfigPath();
|
||||
void loadConfigIntoUi();
|
||||
void applyConfigToUi(const ServerConfigData &config);
|
||||
|
||||
// UI Components
|
||||
QWidget *centralWidget;
|
||||
|
||||
// Toolbar
|
||||
QLabel *serverNameLabel;
|
||||
QLabel *statusDot;
|
||||
QLabel *statusIndicator;
|
||||
QPushButton *startButton;
|
||||
QPushButton *stopButton;
|
||||
QPushButton *configButton;
|
||||
|
||||
// Status strip
|
||||
QLabel *uptimeLabel;
|
||||
QLabel *clientsLabel;
|
||||
QLabel *transformPacketsLabel;
|
||||
QLabel *worldStatePacketsLabel;
|
||||
QLabel *bindAddressLabel;
|
||||
QLabel *lanAddressLabel;
|
||||
|
||||
// Main panes
|
||||
QSplitter *mainSplitter;
|
||||
QTableWidget *clientsTable;
|
||||
QPushButton *kickButton;
|
||||
QPushButton *banButton;
|
||||
QPushButton *bansButton;
|
||||
QTextEdit *logViewer;
|
||||
|
||||
// Server Process
|
||||
ServerProcess *serverProcess;
|
||||
|
||||
// Timer for stats updates
|
||||
QTimer *statsTimer;
|
||||
|
||||
// State
|
||||
bool isServerRunning;
|
||||
QString serverName;
|
||||
QString configFilePath;
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW_H
|
||||
@@ -1,313 +0,0 @@
|
||||
#include "ServerProcess.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHostAddress>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace {
|
||||
constexpr int kDefaultAdminPort = 7779;
|
||||
constexpr int kAdminTimeoutMs = 3000;
|
||||
constexpr auto kAdminTokenFileName = ".admin-token";
|
||||
|
||||
QString appHostName() {
|
||||
#ifdef Q_OS_WIN
|
||||
return QStringLiteral("CommonwealthOnline.Server.exe");
|
||||
#else
|
||||
return QStringLiteral("CommonwealthOnline.Server");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
ServerProcess::ServerProcess(QObject *parent)
|
||||
: QObject(parent)
|
||||
, process(nullptr)
|
||||
, running(false)
|
||||
, m_adminPort(kDefaultAdminPort)
|
||||
, adminFailCount(0)
|
||||
{
|
||||
serverDir = findServerDirectory();
|
||||
resolveServerLaunch();
|
||||
}
|
||||
|
||||
ServerProcess::~ServerProcess() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void ServerProcess::setAdminPort(int port) {
|
||||
m_adminPort = (port > 0 && port <= 65535) ? port : kDefaultAdminPort;
|
||||
}
|
||||
|
||||
int ServerProcess::adminPort() const {
|
||||
return m_adminPort;
|
||||
}
|
||||
|
||||
void ServerProcess::start(const QString &configPath) {
|
||||
if (running) {
|
||||
emit error(QStringLiteral("Server is already running"));
|
||||
return;
|
||||
}
|
||||
if (serverDir.isEmpty()) {
|
||||
emit error(QStringLiteral("C# server directory not found"));
|
||||
return;
|
||||
}
|
||||
if (serverProgram.isEmpty() && !resolveServerLaunch()) {
|
||||
emit error(QStringLiteral("CommonwealthOnline.Server executable was not found. Build or publish the .NET server first."));
|
||||
return;
|
||||
}
|
||||
|
||||
process = new QProcess(this);
|
||||
connect(process, SIGNAL(finished(int, QProcess::ExitStatus)), this, SLOT(onProcessFinished(int, QProcess::ExitStatus)));
|
||||
connect(process, SIGNAL(error(QProcess::ProcessError)), this, SLOT(onProcessError(QProcess::ProcessError)));
|
||||
connect(process, SIGNAL(readyReadStandardOutput()), this, SLOT(onReadyReadStandardOutput()));
|
||||
connect(process, SIGNAL(readyReadStandardError()), this, SLOT(onReadyReadStandardError()));
|
||||
connect(process, SIGNAL(started()), this, SLOT(onProcessStarted()));
|
||||
|
||||
QStringList arguments = serverPrefixArguments;
|
||||
arguments << QStringLiteral("serve") << QStringLiteral("--config") << configPath;
|
||||
process->setWorkingDirectory(serverDir);
|
||||
process->start(serverProgram, arguments);
|
||||
}
|
||||
|
||||
void ServerProcess::stop() {
|
||||
if (!process) return;
|
||||
if (process->state() == QProcess::Running) {
|
||||
process->terminate();
|
||||
if (!process->waitForFinished(3000)) {
|
||||
process->kill();
|
||||
process->waitForFinished();
|
||||
}
|
||||
}
|
||||
process->deleteLater();
|
||||
process = nullptr;
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
}
|
||||
|
||||
QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) {
|
||||
if (serverDir.isEmpty()) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Server directory is unavailable for admin authentication")}};
|
||||
}
|
||||
|
||||
QFile tokenFile(QDir(serverDir).absoluteFilePath(QString::fromLatin1(kAdminTokenFileName)));
|
||||
if (!tokenFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is not available yet")}};
|
||||
}
|
||||
const QByteArray token = tokenFile.readAll().trimmed();
|
||||
if (token.isEmpty()) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is empty")}};
|
||||
}
|
||||
|
||||
QJsonObject authenticatedRequest = request;
|
||||
authenticatedRequest.insert(QStringLiteral("adminToken"), QString::fromUtf8(token));
|
||||
|
||||
QTcpSocket socket;
|
||||
socket.connectToHost(QStringLiteral("127.0.0.1"), static_cast<quint16>(m_adminPort));
|
||||
if (!socket.waitForConnected(kAdminTimeoutMs)) {
|
||||
return {{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Could not connect to admin port 127.0.0.1:%1 (%2)").arg(m_adminPort).arg(socket.errorString())}};
|
||||
}
|
||||
|
||||
const QByteArray payload = QJsonDocument(authenticatedRequest).toJson(QJsonDocument::Compact) + '\n';
|
||||
if (socket.write(payload) < 0 || !socket.waitForBytesWritten(kAdminTimeoutMs)) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Failed to send admin command")}};
|
||||
}
|
||||
|
||||
QByteArray buffer;
|
||||
while (!buffer.contains('\n')) {
|
||||
if (!socket.waitForReadyRead(kAdminTimeoutMs)) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Timed out waiting for admin response")}};
|
||||
}
|
||||
buffer += socket.readAll();
|
||||
if (buffer.size() > 64 * 1024) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin response exceeded maximum size")}};
|
||||
}
|
||||
}
|
||||
|
||||
const QByteArray line = buffer.left(buffer.indexOf('\n'));
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(line, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Invalid admin response JSON")}};
|
||||
}
|
||||
return doc.object();
|
||||
}
|
||||
|
||||
void ServerProcess::fetchStats() {
|
||||
if (!running) return;
|
||||
const QJsonObject statsResponse = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("stats")}});
|
||||
if (statsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
adminFailCount = 0;
|
||||
emit statsUpdated(statsResponse.value(QStringLiteral("data")).toObject());
|
||||
} else {
|
||||
++adminFailCount;
|
||||
if (adminFailCount == 3 || adminFailCount == 10 || (adminFailCount % 30) == 0) {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1").arg(statsResponse.value(QStringLiteral("error")).toString(QStringLiteral("Admin stats request failed."))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject clientsResponse = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("clients")}});
|
||||
if (clientsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
emit clientsUpdated(clientsResponse.value(QStringLiteral("data")).toObject().value(QStringLiteral("clients")).toArray());
|
||||
} else {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1").arg(clientsResponse.value(QStringLiteral("error")).toString(QStringLiteral("Admin clients request failed."))));
|
||||
}
|
||||
}
|
||||
|
||||
bool ServerProcess::kickPlayer(int playerId, const QString &reason) {
|
||||
const QJsonObject response = sendAdminCommand({
|
||||
{QStringLiteral("cmd"), QStringLiteral("kick")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
});
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("Player kicked."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Kick failed.")));
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::banPlayer(int playerId, const QString &reason) {
|
||||
const QJsonObject response = sendAdminCommand({
|
||||
{QStringLiteral("cmd"), QStringLiteral("ban")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
});
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("Player banned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Ban failed.")));
|
||||
if (ok) fetchStats();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::unbanIp(const QString &ip) {
|
||||
const QJsonObject response = sendAdminCommand({
|
||||
{QStringLiteral("cmd"), QStringLiteral("unban")},
|
||||
{QStringLiteral("ip"), ip}
|
||||
});
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("IP unbanned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Unban failed.")));
|
||||
return ok;
|
||||
}
|
||||
|
||||
QJsonArray ServerProcess::listBans() {
|
||||
const QJsonObject response = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("bans")}});
|
||||
if (!response.value(QStringLiteral("ok")).toBool()) {
|
||||
emit adminCommandFinished(false, response.value(QStringLiteral("error")).toString(QStringLiteral("Could not list bans.")));
|
||||
return {};
|
||||
}
|
||||
return response.value(QStringLiteral("data")).toObject().value(QStringLiteral("bans")).toArray();
|
||||
}
|
||||
|
||||
bool ServerProcess::isRunning() const {
|
||||
return running && process && process->state() == QProcess::Running;
|
||||
}
|
||||
|
||||
QString ServerProcess::serverDirectory() const {
|
||||
return serverDir;
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessStarted() {
|
||||
running = true;
|
||||
adminFailCount = 0;
|
||||
emit started();
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
emit stopped();
|
||||
if (exitStatus == QProcess::NormalExit) emit logMessage(QStringLiteral("Server exited with code %1").arg(exitCode));
|
||||
else emit error(QStringLiteral("Server process crashed"));
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessError(QProcess::ProcessError processError) {
|
||||
QString message;
|
||||
switch (processError) {
|
||||
case QProcess::FailedToStart: message = QStringLiteral("Failed to start CommonwealthOnline.Server"); break;
|
||||
case QProcess::Crashed: message = QStringLiteral("Server process crashed"); break;
|
||||
case QProcess::Timedout: message = QStringLiteral("Server process timed out"); break;
|
||||
default: message = QStringLiteral("Unknown server process error"); break;
|
||||
}
|
||||
emit error(message);
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardOutput() {
|
||||
if (!process) return;
|
||||
outputBuffer += process->readAllStandardOutput();
|
||||
while (outputBuffer.contains('\n')) {
|
||||
const int newlinePos = outputBuffer.indexOf('\n');
|
||||
QString line = outputBuffer.left(newlinePos).trimmed();
|
||||
outputBuffer = outputBuffer.mid(newlinePos + 1);
|
||||
if (!line.isEmpty()) {
|
||||
parseLogLine(line);
|
||||
emit logMessage(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardError() {
|
||||
if (!process) return;
|
||||
const QString value = QString::fromUtf8(process->readAllStandardError()).trimmed();
|
||||
if (!value.isEmpty()) emit logMessage(QStringLiteral("[STDERR] ") + value);
|
||||
}
|
||||
|
||||
QString ServerProcess::findServerDirectory() {
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
|
||||
if (QFileInfo::exists(candidate + QStringLiteral("/CommonwealthOnline.Server.csproj")) ||
|
||||
QFileInfo::exists(candidate + QStringLiteral("/CommonwealthOnline.Server.dll")) ||
|
||||
QFileInfo::exists(candidate + QLatin1Char('/') + appHostName())) {
|
||||
return QFileInfo(candidate).absoluteFilePath();
|
||||
}
|
||||
if (!dir.cdUp()) break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool ServerProcess::resolveServerLaunch() {
|
||||
serverProgram.clear();
|
||||
serverPrefixArguments.clear();
|
||||
if (serverDir.isEmpty()) return false;
|
||||
|
||||
const QDir dir(serverDir);
|
||||
const QStringList appHostCandidates = {
|
||||
dir.absoluteFilePath(appHostName()),
|
||||
dir.absoluteFilePath(QStringLiteral("publish/") + appHostName()),
|
||||
dir.absoluteFilePath(QStringLiteral("bin/Release/net8.0/") + appHostName())
|
||||
};
|
||||
for (const QString &candidate : appHostCandidates) {
|
||||
if (QFileInfo::exists(candidate)) {
|
||||
serverProgram = QFileInfo(candidate).absoluteFilePath();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const QStringList dllCandidates = {
|
||||
dir.absoluteFilePath(QStringLiteral("CommonwealthOnline.Server.dll")),
|
||||
dir.absoluteFilePath(QStringLiteral("publish/CommonwealthOnline.Server.dll")),
|
||||
dir.absoluteFilePath(QStringLiteral("bin/Release/net8.0/CommonwealthOnline.Server.dll"))
|
||||
};
|
||||
QString dllPath;
|
||||
for (const QString &candidate : dllCandidates) {
|
||||
if (QFileInfo::exists(candidate)) { dllPath = QFileInfo(candidate).absoluteFilePath(); break; }
|
||||
}
|
||||
if (dllPath.isEmpty()) return false;
|
||||
|
||||
QProcess probe;
|
||||
probe.start(QStringLiteral("dotnet"), {QStringLiteral("--version")});
|
||||
if (!probe.waitForFinished(3000) || probe.exitStatus() != QProcess::NormalExit || probe.exitCode() != 0) return false;
|
||||
serverProgram = QStringLiteral("dotnet");
|
||||
serverPrefixArguments << dllPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ServerProcess::parseLogLine(const QString &line) {
|
||||
Q_UNUSED(line);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#ifndef SERVERPROCESS_H
|
||||
#define SERVERPROCESS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QProcess>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QTcpSocket>
|
||||
|
||||
class ServerProcess : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ServerProcess(QObject *parent = nullptr);
|
||||
~ServerProcess();
|
||||
|
||||
void start(const QString &configPath);
|
||||
void stop();
|
||||
void fetchStats();
|
||||
bool kickPlayer(int playerId, const QString &reason = QString());
|
||||
bool banPlayer(int playerId, const QString &reason = QString());
|
||||
bool unbanIp(const QString &ip);
|
||||
QJsonArray listBans();
|
||||
bool isRunning() const;
|
||||
QString serverDirectory() const;
|
||||
void setAdminPort(int port);
|
||||
int adminPort() const;
|
||||
|
||||
signals:
|
||||
void started();
|
||||
void stopped();
|
||||
void logMessage(const QString &message);
|
||||
void clientsUpdated(const QJsonArray &clients);
|
||||
void statsUpdated(const QJsonObject &stats);
|
||||
void error(const QString &message);
|
||||
void adminCommandFinished(bool ok, const QString &message);
|
||||
|
||||
private slots:
|
||||
void onProcessStarted();
|
||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||
void onProcessError(QProcess::ProcessError error);
|
||||
void onReadyReadStandardOutput();
|
||||
void onReadyReadStandardError();
|
||||
|
||||
private:
|
||||
QString findServerDirectory();
|
||||
bool resolveServerLaunch();
|
||||
void parseLogLine(const QString &line);
|
||||
QJsonObject sendAdminCommand(const QJsonObject &request);
|
||||
|
||||
QProcess *process;
|
||||
QString serverDir;
|
||||
QString serverProgram;
|
||||
QStringList serverPrefixArguments;
|
||||
bool running;
|
||||
QString outputBuffer;
|
||||
int m_adminPort;
|
||||
int adminFailCount;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,16 +0,0 @@
|
||||
#include <QApplication>
|
||||
#include "MainWindow.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
app.setApplicationName("Commonwealth Online Host");
|
||||
app.setApplicationVersion("1.0.0");
|
||||
app.setApplicationDisplayName("Commonwealth Online — Server Host");
|
||||
|
||||
MainWindow window;
|
||||
window.show();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
IDI_ICON1 ICON "icons/app.ico"
|
||||
@@ -1 +0,0 @@
|
||||
<!-- Placeholder for app.ico - Qt will handle resource loading gracefully if missing -->
|
||||
@@ -1,5 +0,0 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>icons/app.ico</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
Reference in New Issue
Block a user